@getforgeai/cli 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +80 -0
  3. package/dist/bin/forgeai.d.ts +3 -0
  4. package/dist/bin/forgeai.js +8 -0
  5. package/dist/cli.d.ts +4 -0
  6. package/dist/cli.js +180 -0
  7. package/dist/commands/auth-login.d.ts +4 -0
  8. package/dist/commands/auth-login.js +202 -0
  9. package/dist/commands/auth-logout.d.ts +2 -0
  10. package/dist/commands/auth-logout.js +41 -0
  11. package/dist/commands/auth-status.d.ts +4 -0
  12. package/dist/commands/auth-status.js +81 -0
  13. package/dist/commands/cli-list.d.ts +4 -0
  14. package/dist/commands/cli-list.js +40 -0
  15. package/dist/commands/cli-unlink.d.ts +4 -0
  16. package/dist/commands/cli-unlink.js +45 -0
  17. package/dist/commands/config-set.d.ts +5 -0
  18. package/dist/commands/config-set.js +63 -0
  19. package/dist/commands/connect.d.ts +9 -0
  20. package/dist/commands/connect.js +165 -0
  21. package/dist/commands/disconnect.d.ts +5 -0
  22. package/dist/commands/disconnect.js +126 -0
  23. package/dist/commands/journal-clear.d.ts +2 -0
  24. package/dist/commands/journal-clear.js +16 -0
  25. package/dist/commands/journal-list.d.ts +2 -0
  26. package/dist/commands/journal-list.js +20 -0
  27. package/dist/commands/journal-status.d.ts +2 -0
  28. package/dist/commands/journal-status.js +18 -0
  29. package/dist/commands/mcp-serve.d.ts +13 -0
  30. package/dist/commands/mcp-serve.js +74 -0
  31. package/dist/commands/org-list.d.ts +4 -0
  32. package/dist/commands/org-list.js +56 -0
  33. package/dist/commands/org-unlink.d.ts +4 -0
  34. package/dist/commands/org-unlink.js +63 -0
  35. package/dist/commands/reconnect.d.ts +4 -0
  36. package/dist/commands/reconnect.js +46 -0
  37. package/dist/commands/release-open.d.ts +2 -0
  38. package/dist/commands/release-open.js +43 -0
  39. package/dist/commands/session-respond.d.ts +2 -0
  40. package/dist/commands/session-respond.js +29 -0
  41. package/dist/commands/task-open.d.ts +2 -0
  42. package/dist/commands/task-open.js +42 -0
  43. package/dist/commands/vm.d.ts +21 -0
  44. package/dist/commands/vm.js +122 -0
  45. package/dist/commands/workflow-open.d.ts +2 -0
  46. package/dist/commands/workflow-open.js +43 -0
  47. package/dist/config/config-manager.d.ts +31 -0
  48. package/dist/config/config-manager.js +70 -0
  49. package/dist/config/token-manager.d.ts +11 -0
  50. package/dist/config/token-manager.js +87 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.js +22 -0
  53. package/dist/lib/auth-client.d.ts +1670 -0
  54. package/dist/lib/auth-client.js +10 -0
  55. package/dist/lib/branch-manager.d.ts +79 -0
  56. package/dist/lib/branch-manager.js +620 -0
  57. package/dist/lib/clone-and-checkout.d.ts +6 -0
  58. package/dist/lib/clone-and-checkout.js +41 -0
  59. package/dist/lib/connection-manager.d.ts +58 -0
  60. package/dist/lib/connection-manager.js +436 -0
  61. package/dist/lib/daemon-entry.d.ts +2 -0
  62. package/dist/lib/daemon-entry.js +210 -0
  63. package/dist/lib/daemon.d.ts +38 -0
  64. package/dist/lib/daemon.js +148 -0
  65. package/dist/lib/event-journal.d.ts +24 -0
  66. package/dist/lib/event-journal.js +76 -0
  67. package/dist/lib/ipc-client.d.ts +10 -0
  68. package/dist/lib/ipc-client.js +70 -0
  69. package/dist/lib/ipc-server.d.ts +36 -0
  70. package/dist/lib/ipc-server.js +70 -0
  71. package/dist/lib/node-version-guard.d.ts +8 -0
  72. package/dist/lib/node-version-guard.js +60 -0
  73. package/dist/lib/open-directory.d.ts +7 -0
  74. package/dist/lib/open-directory.js +41 -0
  75. package/dist/lib/repo-manager.d.ts +29 -0
  76. package/dist/lib/repo-manager.js +103 -0
  77. package/dist/lib/resilient-emitter.d.ts +53 -0
  78. package/dist/lib/resilient-emitter.js +160 -0
  79. package/dist/lib/rest-client.d.ts +164 -0
  80. package/dist/lib/rest-client.js +139 -0
  81. package/dist/lib/skill-manager.d.ts +23 -0
  82. package/dist/lib/skill-manager.js +167 -0
  83. package/dist/lib/socket-client.d.ts +68 -0
  84. package/dist/lib/socket-client.js +160 -0
  85. package/dist/mcp/mcp-probe.d.ts +11 -0
  86. package/dist/mcp/mcp-probe.js +106 -0
  87. package/dist/mcp/mcp-response.d.ts +29 -0
  88. package/dist/mcp/mcp-response.js +67 -0
  89. package/dist/mcp/mcp-server.d.ts +19 -0
  90. package/dist/mcp/mcp-server.js +76 -0
  91. package/dist/mcp/resources/project-conventions.d.ts +4 -0
  92. package/dist/mcp/resources/project-conventions.js +50 -0
  93. package/dist/mcp/resources/task-current.d.ts +4 -0
  94. package/dist/mcp/resources/task-current.js +51 -0
  95. package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
  96. package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
  97. package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
  98. package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
  99. package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
  100. package/dist/mcp/tools/forge-ask-human.js +80 -0
  101. package/dist/mcp/tools/forge-column-done.d.ts +4 -0
  102. package/dist/mcp/tools/forge-column-done.js +46 -0
  103. package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
  104. package/dist/mcp/tools/forge-complete-step.js +139 -0
  105. package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
  106. package/dist/mcp/tools/forge-create-task-group.js +22 -0
  107. package/dist/mcp/tools/forge-create-task.d.ts +4 -0
  108. package/dist/mcp/tools/forge-create-task.js +28 -0
  109. package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
  110. package/dist/mcp/tools/forge-delete-task-group.js +17 -0
  111. package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
  112. package/dist/mcp/tools/forge-delete-task.js +17 -0
  113. package/dist/mcp/tools/forge-get-task.d.ts +4 -0
  114. package/dist/mcp/tools/forge-get-task.js +17 -0
  115. package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
  116. package/dist/mcp/tools/forge-merge-back.js +179 -0
  117. package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
  118. package/dist/mcp/tools/forge-release-merge.js +159 -0
  119. package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
  120. package/dist/mcp/tools/forge-release-notes.js +51 -0
  121. package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
  122. package/dist/mcp/tools/forge-release-tag.js +125 -0
  123. package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
  124. package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
  125. package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
  126. package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
  127. package/dist/mcp/tools/forge-requeue.d.ts +4 -0
  128. package/dist/mcp/tools/forge-requeue.js +39 -0
  129. package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
  130. package/dist/mcp/tools/forge-submit-pr.js +68 -0
  131. package/dist/mcp/tools/forge-update-status.d.ts +4 -0
  132. package/dist/mcp/tools/forge-update-status.js +77 -0
  133. package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
  134. package/dist/mcp/tools/forge-update-task-group.js +21 -0
  135. package/dist/mcp/tools/forge-update-task.d.ts +4 -0
  136. package/dist/mcp/tools/forge-update-task.js +25 -0
  137. package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
  138. package/dist/mcp/tools/forge-validate-merge.js +194 -0
  139. package/dist/mcp/types.d.ts +39 -0
  140. package/dist/mcp/types.js +8 -0
  141. package/dist/methodology/artifact-manager.d.ts +14 -0
  142. package/dist/methodology/artifact-manager.js +55 -0
  143. package/dist/orchestrator/agent-registry.d.ts +53 -0
  144. package/dist/orchestrator/agent-registry.js +96 -0
  145. package/dist/orchestrator/agent-spawner.d.ts +112 -0
  146. package/dist/orchestrator/agent-spawner.js +1518 -0
  147. package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
  148. package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
  149. package/dist/orchestrator/connectors/connector.d.ts +95 -0
  150. package/dist/orchestrator/connectors/connector.js +25 -0
  151. package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
  152. package/dist/orchestrator/connectors/docker-connector.js +337 -0
  153. package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
  154. package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
  155. package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
  156. package/dist/orchestrator/connectors/opencode-connector.js +39 -0
  157. package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
  158. package/dist/orchestrator/connectors/vm-connector.js +139 -0
  159. package/dist/orchestrator/dispatch-queue.d.ts +43 -0
  160. package/dist/orchestrator/dispatch-queue.js +93 -0
  161. package/dist/orchestrator/resume-handler.d.ts +34 -0
  162. package/dist/orchestrator/resume-handler.js +159 -0
  163. package/dist/orchestrator/stream-batcher.d.ts +16 -0
  164. package/dist/orchestrator/stream-batcher.js +50 -0
  165. package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
  166. package/dist/orchestrator/stream-json-extractor.js +579 -0
  167. package/dist/vm/claude-token-manager.d.ts +36 -0
  168. package/dist/vm/claude-token-manager.js +223 -0
  169. package/dist/vm/docker-executor.d.ts +131 -0
  170. package/dist/vm/docker-executor.js +360 -0
  171. package/dist/vm/docker-process-proxy.d.ts +31 -0
  172. package/dist/vm/docker-process-proxy.js +105 -0
  173. package/dist/vm/forge-vm-agent.d.ts +11 -0
  174. package/dist/vm/forge-vm-agent.js +159 -0
  175. package/dist/vm/git-credentials.d.ts +43 -0
  176. package/dist/vm/git-credentials.js +53 -0
  177. package/dist/vm/image-manager.d.ts +7 -0
  178. package/dist/vm/image-manager.js +61 -0
  179. package/dist/vm/log.d.ts +9 -0
  180. package/dist/vm/log.js +18 -0
  181. package/dist/vm/mcp-bridge.d.ts +63 -0
  182. package/dist/vm/mcp-bridge.js +243 -0
  183. package/dist/vm/mcp-stub.d.ts +15 -0
  184. package/dist/vm/mcp-stub.js +67 -0
  185. package/dist/vm/native-addon.d.ts +69 -0
  186. package/dist/vm/native-addon.js +68 -0
  187. package/dist/vm/provider-resolver.d.ts +7 -0
  188. package/dist/vm/provider-resolver.js +27 -0
  189. package/dist/vm/providers/lima.d.ts +17 -0
  190. package/dist/vm/providers/lima.js +191 -0
  191. package/dist/vm/providers/qemu.d.ts +28 -0
  192. package/dist/vm/providers/qemu.js +260 -0
  193. package/dist/vm/providers/vfkit.d.ts +40 -0
  194. package/dist/vm/providers/vfkit.js +371 -0
  195. package/dist/vm/providers/wsl2.d.ts +27 -0
  196. package/dist/vm/providers/wsl2.js +226 -0
  197. package/dist/vm/session-manager.d.ts +130 -0
  198. package/dist/vm/session-manager.js +494 -0
  199. package/dist/vm/session-snapshot.d.ts +31 -0
  200. package/dist/vm/session-snapshot.js +180 -0
  201. package/dist/vm/types.d.ts +73 -0
  202. package/dist/vm/types.js +2 -0
  203. package/dist/vm/vm-helper-client.d.ts +81 -0
  204. package/dist/vm/vm-helper-client.js +335 -0
  205. package/dist/vm/vm-helper.d.ts +11 -0
  206. package/dist/vm/vm-helper.js +156 -0
  207. package/dist/vm/vm-ipc-protocol.d.ts +84 -0
  208. package/dist/vm/vm-ipc-protocol.js +9 -0
  209. package/dist/vm/vm-manager-facade.d.ts +23 -0
  210. package/dist/vm/vm-manager-facade.js +43 -0
  211. package/dist/vm/vm-manager.d.ts +74 -0
  212. package/dist/vm/vm-manager.js +515 -0
  213. package/dist/vm/vm-process-proxy.d.ts +34 -0
  214. package/dist/vm/vm-process-proxy.js +64 -0
  215. package/dist/vm/vsock-bridge.d.ts +44 -0
  216. package/dist/vm/vsock-bridge.js +140 -0
  217. package/dist/vm/vsock-protocol.d.ts +98 -0
  218. package/dist/vm/vsock-protocol.js +57 -0
  219. package/package.json +65 -0
  220. package/rootfs/Dockerfile +50 -0
  221. package/rootfs/forge-mcp-relay.mjs +158 -0
@@ -0,0 +1,1518 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { TaskResumePayloadSchema, WorkflowStepDispatchPayloadSchema, } from "@getforgeai/protocol";
4
+ import { dockerConnector } from "./connectors/docker-connector.js";
5
+ import { StreamBatcher } from "./stream-batcher.js";
6
+ import { StreamJsonExtractor } from "./stream-json-extractor.js";
7
+ import { AgentRegistry } from "./agent-registry.js";
8
+ import { handleResume } from "./resume-handler.js";
9
+ import { pullSkill } from "../lib/skill-manager.js";
10
+ import { getConfig } from "../config/config-manager.js";
11
+ import { vmLog } from "../vm/log.js";
12
+ /** Pending fallback REST calls (advanceTask/requeueTask) from agent exit handlers. */
13
+ const pendingFallbacks = new Set();
14
+ /** Wait for pending fallback REST calls to complete (with timeout). */
15
+ export async function awaitPendingFallbacks(timeoutMs) {
16
+ if (pendingFallbacks.size === 0)
17
+ return;
18
+ const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
19
+ await Promise.race([Promise.all(pendingFallbacks), timeout]);
20
+ }
21
+ export const DEFAULT_SKILL_NAME = "execute-task";
22
+ const IDLE_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
23
+ /** Tracks agents in their 5-minute idle window after process exit */
24
+ const idleWindows = new Map();
25
+ /** Agents dismissed by user while still RUNNING — skip idle window on exit */
26
+ const dismissedWhileRunning = new Set();
27
+ /** Board tasks that called forge_ask_human — save snapshot on exit for --continue resume */
28
+ const pendingAskHuman = new Set();
29
+ /** Mark a task as having called forge_ask_human (called from MCP tool) */
30
+ export function markAskHuman(taskId) {
31
+ pendingAskHuman.add(taskId);
32
+ }
33
+ /**
34
+ * Clear all idle windows and their timers.
35
+ * Called on socket disconnect to prevent stale timers emitting on dead sockets.
36
+ * Also unregisters IDLE agents from registry to free slots on reconnect.
37
+ */
38
+ export function clearAllIdleWindows(socket, agentSlots) {
39
+ const reg = getRegistry(agentSlots);
40
+ for (const [agentId, idleState] of idleWindows) {
41
+ clearTimeout(idleState.timer);
42
+ socket.off("workflow:message", idleState.messageHandler);
43
+ // Free the slot so reconnected CLI has full capacity
44
+ reg.unregister(agentId);
45
+ // Destroy container (no snapshot on disconnect — best effort cleanup)
46
+ void (async () => {
47
+ try {
48
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
49
+ await getVmManager().destroySession(agentId);
50
+ }
51
+ catch {
52
+ /* best effort */
53
+ }
54
+ })();
55
+ vmLog.dim("spawner", `Cleared idle window for step ${agentId} on disconnect (container destroyed, slot freed)`);
56
+ }
57
+ idleWindows.clear();
58
+ }
59
+ export const TaskDispatchSchema = z.object({
60
+ taskId: z.string(),
61
+ projectId: z.string(),
62
+ assigneeId: z.string(),
63
+ taskCommand: z.string().optional(),
64
+ skillSlug: z.string().optional(),
65
+ workflowBaseBranch: z.string().optional(),
66
+ taskBranch: z.string().optional(),
67
+ taskSlug: z.string().optional(),
68
+ projectSlug: z.string().optional(),
69
+ repoUrl: z.string().optional(),
70
+ metadata: z.record(z.string(), z.unknown()).optional(),
71
+ });
72
+ let registry = null;
73
+ function getRegistry(agentSlots) {
74
+ registry ??= new AgentRegistry(agentSlots);
75
+ return registry;
76
+ }
77
+ /** Exported for testing */
78
+ export function resetRegistry() {
79
+ registry = null;
80
+ }
81
+ export function getAgentRegistry(agentSlots) {
82
+ return getRegistry(agentSlots);
83
+ }
84
+ export function resolveConnector(agentTypeOrName) {
85
+ return dockerConnector;
86
+ }
87
+ /**
88
+ * Attach exit and error handlers to an agent process.
89
+ * Shared between spawnAgent and handleResume to deduplicate handler code.
90
+ */
91
+ export function attachProcessHandlers(process, args) {
92
+ const { taskId, projectId, registry: reg, socketClient, restClient, queue, spawnAgentFn, label, } = args;
93
+ // Capture stdout/stderr via StreamBatcher (best-effort fire-and-forget)
94
+ const childProcess = process;
95
+ const batchers = [];
96
+ let jsonExtractorTask;
97
+ if (childProcess.stdout) {
98
+ const stdoutBatcher = new StreamBatcher((chunk) => {
99
+ socketClient.emitAgentStream({
100
+ agentId: taskId,
101
+ chunk,
102
+ projectId,
103
+ turnIndex: jsonExtractorTask?.turnIndex ?? 0,
104
+ });
105
+ });
106
+ // Parse stream-json output: extract text deltas + tool activity
107
+ jsonExtractorTask = new StreamJsonExtractor((textDelta) => {
108
+ stdoutBatcher.write(textDelta);
109
+ }, undefined, // onQuestion — not needed for task agents
110
+ (activity) => {
111
+ socketClient.emitAgentToolActivity({
112
+ agentId: taskId,
113
+ toolName: activity.toolName,
114
+ toolDetail: activity.toolDetail,
115
+ toolUseId: activity.toolUseId,
116
+ parentToolUseId: activity.parentToolUseId,
117
+ projectId,
118
+ });
119
+ }, undefined, // onTokenUsage
120
+ args.sessionId);
121
+ childProcess.stdout.on("data", (data) => jsonExtractorTask.write(data.toString()));
122
+ batchers.push(stdoutBatcher);
123
+ }
124
+ // Accumulate last 4 KB of stderr for billing error detection on exit
125
+ let stderrTail = "";
126
+ const STDERR_MAX = 4096;
127
+ if (childProcess.stderr) {
128
+ const stderrBatcher = new StreamBatcher((chunk) => {
129
+ socketClient.emitAgentStream({
130
+ agentId: taskId,
131
+ chunk,
132
+ projectId,
133
+ });
134
+ });
135
+ childProcess.stderr.on("data", (data) => {
136
+ const text = data.toString();
137
+ stderrTail += text;
138
+ if (stderrTail.length > STDERR_MAX) {
139
+ stderrTail = stderrTail.slice(-STDERR_MAX);
140
+ }
141
+ stderrBatcher.write(text);
142
+ });
143
+ batchers.push(stderrBatcher);
144
+ }
145
+ process.on("exit", (code) => {
146
+ const entry = reg.getByTaskId(taskId);
147
+ if (!entry)
148
+ return;
149
+ // Flush remaining stream data before emitting final status
150
+ if (jsonExtractorTask)
151
+ jsonExtractorTask.flush();
152
+ for (const batcher of batchers) {
153
+ batcher.destroy();
154
+ }
155
+ // Ask-human: save snapshot + destroy container immediately (no idle window).
156
+ // On resume, snapshot is restored with --continue for full context.
157
+ if (code === 0 && pendingAskHuman.has(taskId)) {
158
+ pendingAskHuman.delete(taskId);
159
+ vmLog.cyan(label, `Agent for task ${taskId} asked human — saving snapshot before destroy`);
160
+ // Save snapshot + destroy in background (non-blocking)
161
+ const savedOrgId = entry.orgId;
162
+ void (async () => {
163
+ try {
164
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
165
+ const sm = getVmManager();
166
+ const session = sm.getSessionByTaskId(taskId);
167
+ if (session) {
168
+ const { autoSaveCode, createSessionSnapshot } = await import("../vm/session-snapshot.js");
169
+ await autoSaveCode(session.containerId, session.id, session.gitCredentials);
170
+ const snapshot = await createSessionSnapshot(session);
171
+ if (snapshot) {
172
+ socketClient.socket.emit("workflow:step:snapshot", {
173
+ orgId: savedOrgId,
174
+ workflowId: taskId,
175
+ stepId: taskId,
176
+ snapshot,
177
+ });
178
+ vmLog.cyan(label, `Board task snapshot saved for ${taskId}`);
179
+ }
180
+ await sm.destroySession(taskId);
181
+ }
182
+ }
183
+ catch {
184
+ try {
185
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
186
+ await getVmManager().destroySession(taskId);
187
+ }
188
+ catch {
189
+ /* best effort */
190
+ }
191
+ }
192
+ })();
193
+ // Free slot immediately (don't block on snapshot)
194
+ // Don't unregister/emit status here — let the normal exit flow handle it below
195
+ }
196
+ const status = code === 0 ? "COMPLETED" : "ERROR";
197
+ // Detect billing/token errors from agent output + stderr
198
+ const isBilling = status === "ERROR" &&
199
+ (jsonExtractorTask?.isBillingError(stderrTail) ?? false);
200
+ const errorReason = isBilling ? "TOKEN_EXHAUSTED" : undefined;
201
+ const resetAt = isBilling
202
+ ? (jsonExtractorTask?.extractResetTimestamp(stderrTail) ?? undefined)
203
+ : undefined;
204
+ entry.status = status;
205
+ reg.unregister(taskId);
206
+ // Emit updated heartbeat IMMEDIATELY so Cloud sees the freed slot.
207
+ // Without this, Redis heartbeat stays stale and Cloud's dispatch engine
208
+ // finds 0 available slots, delaying re-dispatch until the periodic heartbeat fires.
209
+ const cliCfg = getConfig();
210
+ socketClient.emitCliHeartbeat({
211
+ cliId: cliCfg.cliId ?? "unknown",
212
+ status: "CONNECTED",
213
+ name: cliCfg.cliName ?? "CLI",
214
+ agentSlots: args.agentSlots,
215
+ activeAgents: reg.getProvisionedCount(),
216
+ runningAgents: reg.getActiveCount(),
217
+ });
218
+ socketClient.emitAgentStatus({
219
+ agentId: taskId,
220
+ status,
221
+ projectId,
222
+ errorReason,
223
+ });
224
+ if (isBilling) {
225
+ const resetInfo = resetAt ? ` (limit resets at ${resetAt})` : "";
226
+ vmLog.error(label, `Agent for task ${taskId} exited with TOKEN_EXHAUSTED — CLI will enter cooldown${resetInfo}`);
227
+ }
228
+ vmLog.dim(label, `Agent for task ${taskId} exited (code: ${code}, status: ${status}, activeAgents: ${reg.getProvisionedCount()}, runningAgents: ${reg.getActiveCount()})`);
229
+ // Safety net: ensure task never stays stuck after agent exit.
230
+ //
231
+ // COMPLETED → fallback release via REST: if forge_column_done already
232
+ // ran, the task's cliDeviceId is null and this is a no-op. Otherwise,
233
+ // releases CLI device without advancing column so task gets re-dispatched.
234
+ //
235
+ // ERROR → requeue via REST so the task releases its cliDeviceId and
236
+ // becomes eligible for re-dispatch. Without this, the card keeps its
237
+ // agent dot and stays assigned to a dead agent indefinitely.
238
+ if (status === "COMPLETED") {
239
+ const fallbackPromise = restClient
240
+ .advanceTask(taskId, { fallback: true })
241
+ .then((result) => {
242
+ if (result.released) {
243
+ vmLog.warn(label, `Released task ${taskId} for re-dispatch (fallback): staying on column ${result.column ?? "unknown"}`);
244
+ }
245
+ else if (result.advanced) {
246
+ vmLog.warn(label, `Auto-advanced task ${taskId} (fallback): ${result.previousColumn} → ${result.newColumn}`);
247
+ }
248
+ else {
249
+ vmLog.dim(label, `Fallback no-op for task ${taskId} (forge_column_done already ran)`);
250
+ }
251
+ })
252
+ .catch((err) => {
253
+ vmLog.warn(label, `Fallback for task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
254
+ })
255
+ .finally(() => {
256
+ pendingFallbacks.delete(fallbackPromise);
257
+ });
258
+ pendingFallbacks.add(fallbackPromise);
259
+ }
260
+ else if (status === "ERROR") {
261
+ const reason = isBilling
262
+ ? "Agent exited with billing/token error (TOKEN_EXHAUSTED)"
263
+ : "Agent exited with error — releasing for re-dispatch";
264
+ const requeuePromise = restClient
265
+ .requeueTask(taskId, reason, errorReason, resetAt)
266
+ .then((result) => {
267
+ if (result.allowed) {
268
+ vmLog.warn(label, `Requeued task ${taskId} after agent error (${result.count}/${result.max})`);
269
+ }
270
+ })
271
+ .catch((err) => {
272
+ vmLog.warn(label, `Requeue fallback for task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
273
+ })
274
+ .finally(() => {
275
+ pendingFallbacks.delete(requeuePromise);
276
+ });
277
+ pendingFallbacks.add(requeuePromise);
278
+ }
279
+ // Auth error: clear queue + try token refresh (all tasks will fail with bad token)
280
+ const isAuthError = "isAuthError" in process &&
281
+ process.isAuthError === true;
282
+ // Auto-cycle: drain next queued dispatch.
283
+ // Skip for billing or auth errors — all queued tasks will fail the same way.
284
+ if (isBilling) {
285
+ queue.clear();
286
+ queue.pause(resetAt);
287
+ }
288
+ else if (isAuthError) {
289
+ queue.clear();
290
+ vmLog.error(label, "Auth error — queue cleared. Attempting token refresh from Keychain...");
291
+ void (async () => {
292
+ try {
293
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
294
+ const refreshed = await getVmManager().refreshToken();
295
+ if (refreshed) {
296
+ vmLog.cyan(label, "Token refreshed — next dispatch will use the new token");
297
+ }
298
+ else {
299
+ vmLog.error(label, "No new token in Keychain. Run: forge auth setup-claude");
300
+ }
301
+ }
302
+ catch {
303
+ vmLog.error(label, "Token refresh failed. Run: forge auth setup-claude");
304
+ }
305
+ })();
306
+ }
307
+ else {
308
+ // Delay so the "slot freed" heartbeat propagates to Cloud → browser
309
+ // before a new spawn emits an eager heartbeat re-filling the slot.
310
+ setTimeout(() => {
311
+ queue.drainNext({ spawnAgent: spawnAgentFn }).catch((err) => {
312
+ vmLog.warn(label, `Auto-cycle drain failed: ${err instanceof Error ? err.message : String(err)}`);
313
+ });
314
+ }, 500);
315
+ }
316
+ });
317
+ process.on("error", (err) => {
318
+ const entry = reg.getByTaskId(taskId);
319
+ if (!entry)
320
+ return;
321
+ reg.unregister(taskId);
322
+ // Emit updated heartbeat so Cloud sees the freed slot immediately
323
+ const cliCfg = getConfig();
324
+ socketClient.emitCliHeartbeat({
325
+ cliId: cliCfg.cliId ?? "unknown",
326
+ status: "CONNECTED",
327
+ name: cliCfg.cliName ?? "CLI",
328
+ agentSlots: args.agentSlots,
329
+ activeAgents: reg.getProvisionedCount(),
330
+ runningAgents: reg.getActiveCount(),
331
+ });
332
+ socketClient.emitAgentStatus({
333
+ agentId: taskId,
334
+ status: "ERROR",
335
+ });
336
+ vmLog.error(label, `Agent error for task ${taskId}: ${err.message}`);
337
+ // Auto-cycle on error too
338
+ queue.drainNext({ spawnAgent: spawnAgentFn }).catch((error_) => {
339
+ vmLog.warn(label, `Auto-cycle drain after error failed: ${error_ instanceof Error ? error_.message : String(error_)}`);
340
+ });
341
+ });
342
+ }
343
+ /**
344
+ * Attach exit and error handlers to a workflow step agent process.
345
+ * Dual-emits: workflow:stream (wizard chat) + agent:stream (activity monitoring).
346
+ * On exit, emits workflow:stream:end with accumulated content for persistence.
347
+ */
348
+ export function attachWorkflowProcessHandlers(process, args) {
349
+ const { taskId, projectId, registry: reg, socketClient, queue, spawnAgentFn, label, workflowId, stepId, conversationId, } = args;
350
+ const childProcess = process;
351
+ const batchers = [];
352
+ const MAX_ACCUMULATED_BYTES = 256 * 1024; // 256KB limit for workflow:stream:end payload
353
+ let currentMessageId = randomUUID();
354
+ let chunkIndex = 0;
355
+ let accumulatedContent = "";
356
+ let contentTruncated = false;
357
+ // Parse stream-json output from Claude Code: extract only text deltas
358
+ let jsonExtractor;
359
+ if (childProcess.stdout) {
360
+ const stdoutBatcher = new StreamBatcher((chunk) => {
361
+ // Dual emit: wizard chat + activity monitoring
362
+ socketClient.emitWorkflowStream({
363
+ workflowId,
364
+ stepId,
365
+ conversationId,
366
+ messageId: currentMessageId,
367
+ chunk,
368
+ index: chunkIndex++,
369
+ turnIndex: jsonExtractor?.turnIndex ?? 0,
370
+ });
371
+ socketClient.emitAgentStream({
372
+ agentId: taskId,
373
+ chunk,
374
+ projectId,
375
+ turnIndex: jsonExtractor?.turnIndex ?? 0,
376
+ });
377
+ if (!contentTruncated) {
378
+ if (accumulatedContent.length + chunk.length > MAX_ACCUMULATED_BYTES) {
379
+ accumulatedContent += chunk.slice(0, MAX_ACCUMULATED_BYTES - accumulatedContent.length);
380
+ contentTruncated = true;
381
+ }
382
+ else {
383
+ accumulatedContent += chunk;
384
+ }
385
+ }
386
+ });
387
+ // StreamJsonExtractor parses newline-delimited JSON from --output-format stream-json
388
+ // and emits only text_delta content to the batcher (no thinking, tool_use, system events)
389
+ // When AskUserQuestion is detected, emit structured questions via workflow:question
390
+ jsonExtractor = new StreamJsonExtractor((textDelta) => {
391
+ stdoutBatcher.write(textDelta);
392
+ }, (questions) => {
393
+ socketClient.emitWorkflowQuestion({
394
+ workflowId,
395
+ stepId,
396
+ conversationId,
397
+ questions,
398
+ });
399
+ }, (activity) => {
400
+ socketClient.emitWorkflowToolActivity({
401
+ workflowId,
402
+ stepId,
403
+ ...activity,
404
+ });
405
+ // Dual emit: task activity panel
406
+ socketClient.emitAgentToolActivity({
407
+ agentId: taskId,
408
+ toolName: activity.toolName,
409
+ toolDetail: activity.toolDetail,
410
+ toolUseId: activity.toolUseId,
411
+ parentToolUseId: activity.parentToolUseId,
412
+ projectId,
413
+ });
414
+ }, (usage) => {
415
+ socketClient.emitWorkflowTokenUsage({
416
+ workflowId,
417
+ stepId,
418
+ ...usage,
419
+ });
420
+ }, args.sessionId);
421
+ childProcess.stdout.on("data", (data) => jsonExtractor.write(data.toString()));
422
+ batchers.push(stdoutBatcher);
423
+ }
424
+ // Accumulate last 4 KB of stderr for billing error detection on exit
425
+ let workflowStderrTail = "";
426
+ const WORKFLOW_STDERR_MAX = 4096;
427
+ if (childProcess.stderr) {
428
+ const stderrBatcher = new StreamBatcher((chunk) => {
429
+ socketClient.emitAgentStream({
430
+ agentId: taskId,
431
+ chunk,
432
+ projectId,
433
+ });
434
+ });
435
+ childProcess.stderr.on("data", (data) => {
436
+ const text = data.toString();
437
+ workflowStderrTail += text;
438
+ if (workflowStderrTail.length > WORKFLOW_STDERR_MAX) {
439
+ workflowStderrTail = workflowStderrTail.slice(-WORKFLOW_STDERR_MAX);
440
+ }
441
+ stderrBatcher.write(text);
442
+ });
443
+ batchers.push(stderrBatcher);
444
+ }
445
+ const { onExit } = args;
446
+ process.on("exit", (code) => {
447
+ const entry = reg.getByTaskId(taskId);
448
+ if (!entry)
449
+ return;
450
+ // Flush remaining stream data (extractor first, then batchers)
451
+ if (jsonExtractor)
452
+ jsonExtractor.flush();
453
+ for (const batcher of batchers) {
454
+ batcher.destroy();
455
+ }
456
+ // Emit workflow:stream:end with accumulated content for persistence
457
+ if (accumulatedContent) {
458
+ socketClient.emitWorkflowStreamEnd({
459
+ workflowId,
460
+ stepId,
461
+ conversationId,
462
+ messageId: currentMessageId,
463
+ finalContent: accumulatedContent,
464
+ });
465
+ }
466
+ // If onExit callback provided (idle window), delegate cleanup
467
+ if (onExit) {
468
+ onExit(code, accumulatedContent);
469
+ return;
470
+ }
471
+ // Default behavior: unregister + drain
472
+ const status = code === 0 ? "COMPLETED" : "ERROR";
473
+ // Detect billing/token errors from agent output + stderr
474
+ const isBilling = status === "ERROR" &&
475
+ (jsonExtractor?.isBillingError(workflowStderrTail) ?? false);
476
+ const errorReason = isBilling ? "TOKEN_EXHAUSTED" : undefined;
477
+ const resetAt = isBilling
478
+ ? (jsonExtractor?.extractResetTimestamp(workflowStderrTail) ?? undefined)
479
+ : undefined;
480
+ entry.status = status;
481
+ reg.unregister(taskId);
482
+ // Emit updated heartbeat so Cloud sees the freed slot immediately
483
+ const cliCfg = getConfig();
484
+ socketClient.emitCliHeartbeat({
485
+ cliId: cliCfg.cliId ?? "unknown",
486
+ status: "CONNECTED",
487
+ name: cliCfg.cliName ?? "CLI",
488
+ agentSlots: args.agentSlots,
489
+ activeAgents: reg.getProvisionedCount(),
490
+ runningAgents: reg.getActiveCount(),
491
+ });
492
+ socketClient.emitAgentStatus({
493
+ agentId: taskId,
494
+ status,
495
+ projectId,
496
+ errorReason,
497
+ });
498
+ if (isBilling) {
499
+ const resetInfo = resetAt ? ` (limit resets at ${resetAt})` : "";
500
+ vmLog.error(label, `Workflow agent for step ${taskId} exited with TOKEN_EXHAUSTED${resetInfo}`);
501
+ }
502
+ vmLog.dim(label, `Agent for step ${taskId} exited (code: ${code}, status: ${status}, activeAgents: ${reg.getProvisionedCount()}, runningAgents: ${reg.getActiveCount()})`);
503
+ // Auto-cycle: drain next queued dispatch.
504
+ // Skip for billing errors — all queued tasks will fail the same way.
505
+ if (isBilling) {
506
+ queue.clear();
507
+ queue.pause(resetAt);
508
+ }
509
+ else {
510
+ // Delay so the "slot freed" heartbeat propagates to Cloud → browser
511
+ // before a new spawn emits an eager heartbeat re-filling the slot.
512
+ setTimeout(() => {
513
+ queue.drainNext({ spawnAgent: spawnAgentFn }).catch((err) => {
514
+ vmLog.warn(label, `Auto-cycle drain failed: ${err instanceof Error ? err.message : String(err)}`);
515
+ });
516
+ }, 500);
517
+ }
518
+ });
519
+ process.on("error", (err) => {
520
+ const entry = reg.getByTaskId(taskId);
521
+ if (!entry)
522
+ return;
523
+ reg.unregister(taskId);
524
+ // Emit updated heartbeat so Cloud sees the freed slot immediately
525
+ const cliCfg2 = getConfig();
526
+ socketClient.emitCliHeartbeat({
527
+ cliId: cliCfg2.cliId ?? "unknown",
528
+ status: "CONNECTED",
529
+ name: cliCfg2.cliName ?? "CLI",
530
+ agentSlots: args.agentSlots,
531
+ activeAgents: reg.getProvisionedCount(),
532
+ runningAgents: reg.getActiveCount(),
533
+ });
534
+ socketClient.emitAgentStatus({
535
+ agentId: taskId,
536
+ status: "ERROR",
537
+ });
538
+ vmLog.error(label, `Agent error for step ${taskId}: ${err.message}`);
539
+ // Auto-cycle on error too
540
+ queue.drainNext({ spawnAgent: spawnAgentFn }).catch((error_) => {
541
+ vmLog.warn(label, `Auto-cycle drain after error failed: ${error_ instanceof Error ? error_.message : String(error_)}`);
542
+ });
543
+ });
544
+ }
545
+ /**
546
+ * Helper: Check if spawn is already in flight for taskId (race condition guard).
547
+ */
548
+ function checkDuplicateSpawn(reg, taskId) {
549
+ if (reg.hasPendingReservation(taskId)) {
550
+ vmLog.dim("spawner", `Spawn already in flight for task ${taskId}, skipping duplicate`);
551
+ return true;
552
+ }
553
+ return false;
554
+ }
555
+ /**
556
+ * Helper: Stop running agent if one exists for taskId (stop-and-replace pattern).
557
+ */
558
+ async function stopExistingAgent(reg, taskId) {
559
+ const existingEntry = reg.getByTaskId(taskId);
560
+ if (existingEntry?.status === "RUNNING") {
561
+ vmLog.cyan("spawner", `Stopping existing agent for task ${taskId} (PID: ${existingEntry.pid}) before re-dispatch`);
562
+ // Unregister first to prevent exit handler from interfering with new spawn
563
+ reg.unregister(taskId);
564
+ const connector = resolveConnector(existingEntry.connectorName);
565
+ try {
566
+ await connector.stop(existingEntry);
567
+ }
568
+ catch (err) {
569
+ vmLog.warn("spawner", `Error stopping old agent for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
570
+ }
571
+ }
572
+ }
573
+ /**
574
+ * Helper: Attempt to reserve slot; if no capacity, enqueue and return early.
575
+ */
576
+ function reserveSlotOrEnqueue(reg, params, ctx) {
577
+ if (!reg.reserveSlot(params.taskId)) {
578
+ vmLog.warn("spawner", `No capacity for task ${params.taskId}, enqueuing`);
579
+ ctx.queue.enqueue(params);
580
+ return false;
581
+ }
582
+ return true;
583
+ }
584
+ /**
585
+ * Main spawn pipeline — orchestrates the full agent lifecycle.
586
+ * Uses slot reservation to prevent over-allocation during async pipeline.
587
+ */
588
+ export async function spawnAgent(params, ctx) {
589
+ const reg = getRegistry(ctx.agentSlots);
590
+ // Guard: if dispatch queue is paused (billing cooldown), skip spawn entirely
591
+ if (ctx.queue.isPaused()) {
592
+ vmLog.dim("spawner", `Spawn skipped — queue paused (billing cooldown) for task ${params.taskId}`);
593
+ return;
594
+ }
595
+ // Guard: if a spawn is already in flight for this taskId, skip to prevent
596
+ // dual-path race conditions (e.g. advance + dispatch arriving concurrently)
597
+ if (checkDuplicateSpawn(reg, params.taskId)) {
598
+ return;
599
+ }
600
+ // Stop-and-replace: if an agent is already running for this taskId
601
+ // (e.g. column advance re-dispatch while old agent still alive),
602
+ // stop the old agent first to prevent two agents in the same worktree.
603
+ await stopExistingAgent(reg, params.taskId);
604
+ // 1. Reserve slot (atomic capacity check)
605
+ if (!reserveSlotOrEnqueue(reg, params, ctx)) {
606
+ return;
607
+ }
608
+ // Emit eager heartbeat so the browser shows the slot as reserved immediately
609
+ // (not waiting for the full spawn pipeline to complete)
610
+ const cliCfg = getConfig();
611
+ ctx.socketClient.emitCliHeartbeat({
612
+ cliId: cliCfg.cliId ?? "unknown",
613
+ status: "CONNECTED",
614
+ name: cliCfg.cliName ?? "CLI",
615
+ agentSlots: ctx.agentSlots,
616
+ activeAgents: reg.getProvisionedCount(),
617
+ runningAgents: reg.getActiveCount(),
618
+ });
619
+ try {
620
+ const projectSlug = params.projectSlug ?? "default";
621
+ const taskSlug = params.taskSlug ?? params.taskId;
622
+ const taskBranch = params.taskBranch ??
623
+ (params.workflowBaseBranch
624
+ ? `${params.workflowBaseBranch}/${taskSlug}`
625
+ : taskSlug);
626
+ // Pull and prepare skill
627
+ const skillName = params.skillSlug ?? DEFAULT_SKILL_NAME;
628
+ const skill = await pullSkill(skillName, ctx.restClient);
629
+ // Resolve connector (always VM now)
630
+ const connector = resolveConnector(ctx.agentType);
631
+ // VM connector handles everything: boot VM, clone, checkout, workspace, spawn
632
+ const vmSpawnParams = {
633
+ taskId: params.taskId,
634
+ projectId: params.projectId,
635
+ orgId: params.orgId,
636
+ workingDir: "/workspace",
637
+ mcpConfigPath: "/workspace/.claude/mcp_config.json",
638
+ skillName: skill?.name ?? skillName,
639
+ prompt: params.taskCommand,
640
+ repoUrl: params.repoUrl,
641
+ taskBranch,
642
+ workflowBaseBranch: params.workflowBaseBranch,
643
+ skill: skill ? { name: skill.name, files: skill.files } : undefined,
644
+ taskContext: {
645
+ type: "task",
646
+ taskId: params.taskId,
647
+ taskBranch,
648
+ parentBranch: params.workflowBaseBranch ?? null,
649
+ projectSlug,
650
+ taskSlug,
651
+ repoUrl: params.repoUrl ?? null,
652
+ ...(params.metadata ?? {}),
653
+ },
654
+ };
655
+ const result = await connector.start(vmSpawnParams);
656
+ // Confirm slot, register agent (use workingDir, add vmId)
657
+ reg.confirmSlot(params.taskId, {
658
+ taskId: params.taskId,
659
+ pid: result.pid,
660
+ process: result.process,
661
+ connectorName: connector.name,
662
+ workingDir: "/workspace",
663
+ orgId: params.orgId,
664
+ startedAt: new Date().toISOString(),
665
+ status: "RUNNING",
666
+ vmId: result.vmId,
667
+ });
668
+ // Emit agent:status + heartbeat
669
+ ctx.socketClient.emitAgentStatus({
670
+ agentId: params.taskId,
671
+ status: "RUNNING",
672
+ projectId: params.projectId,
673
+ });
674
+ ctx.socketClient.emitCliHeartbeat({
675
+ cliId: cliCfg.cliId ?? "unknown",
676
+ status: "CONNECTED",
677
+ name: cliCfg.cliName ?? "CLI",
678
+ agentSlots: ctx.agentSlots,
679
+ activeAgents: reg.getProvisionedCount(),
680
+ runningAgents: reg.getActiveCount(),
681
+ });
682
+ vmLog.info("spawner", `Agent spawned for task ${params.taskId} (PID: ${result.pid}, connector: ${connector.name}, vmId: ${result.vmId ?? "none"})`);
683
+ // Attach process handlers (exit + error) with auto-cycle
684
+ const boundSpawnAgent = (p) => spawnAgent(p, ctx);
685
+ attachProcessHandlers(result.process, {
686
+ taskId: params.taskId,
687
+ projectId: params.projectId,
688
+ registry: reg,
689
+ socketClient: ctx.socketClient,
690
+ restClient: ctx.restClient,
691
+ queue: ctx.queue,
692
+ spawnAgentFn: boundSpawnAgent,
693
+ label: "spawner",
694
+ agentSlots: ctx.agentSlots,
695
+ sessionId: result.vmId,
696
+ });
697
+ }
698
+ catch (err) {
699
+ // Release reservation on failure
700
+ reg.releaseSlot(params.taskId);
701
+ throw err;
702
+ }
703
+ }
704
+ /**
705
+ * Stop a specific agent by taskId.
706
+ */
707
+ export async function stopAgent(taskId, ctx) {
708
+ const reg = getRegistry(ctx.agentSlots);
709
+ const entry = reg.getByTaskId(taskId);
710
+ if (!entry)
711
+ return;
712
+ // Unregister first to prevent the exit handler from double-emitting
713
+ reg.unregister(taskId);
714
+ const connector = resolveConnector(entry.connectorName);
715
+ await connector.stop(entry);
716
+ }
717
+ /**
718
+ * Graceful shutdown of all running agents.
719
+ * Clears queue, unregisters first (prevents exit handler race), then sends SIGTERM,
720
+ * waits up to 10s, then SIGKILL survivors.
721
+ */
722
+ export async function stopAllAgents(ctx) {
723
+ const reg = getRegistry(ctx.agentSlots);
724
+ // Clear all idle windows and their timers
725
+ for (const [agentId, idleState] of idleWindows) {
726
+ clearTimeout(idleState.timer);
727
+ ctx.socketClient.socket.off("workflow:message", idleState.messageHandler);
728
+ vmLog.dim("spawner", `Cleared idle window for step ${agentId} at shutdown`);
729
+ }
730
+ idleWindows.clear();
731
+ // Clear queue first to prevent new spawns during shutdown
732
+ const queueSize = ctx.queue.size();
733
+ ctx.queue.clear();
734
+ if (queueSize > 0) {
735
+ vmLog.cyan("spawner", `Cleared ${queueSize} queued dispatches at shutdown`);
736
+ }
737
+ const allAgents = reg.getAll();
738
+ if (allAgents.length === 0)
739
+ return;
740
+ vmLog.cyan("spawner", `Stopping ${allAgents.length} running agent(s)...`);
741
+ // Unregister all first to prevent exit handlers from double-emitting
742
+ for (const entry of allAgents) {
743
+ reg.unregister(entry.taskId);
744
+ }
745
+ const stopPromises = allAgents.map(async (entry) => {
746
+ const connector = resolveConnector(entry.connectorName);
747
+ try {
748
+ await connector.stop(entry);
749
+ }
750
+ catch (err) {
751
+ vmLog.warn("spawner", `Error stopping agent ${entry.taskId}: ${err instanceof Error ? err.message : String(err)}`);
752
+ }
753
+ ctx.socketClient.emitAgentStatus({
754
+ agentId: entry.taskId,
755
+ status: "COMPLETED",
756
+ });
757
+ });
758
+ await Promise.all(stopPromises);
759
+ vmLog.info("spawner", "All agents stopped");
760
+ }
761
+ /**
762
+ * Register the task:dispatch Socket.io handler on the CLI socket.
763
+ */
764
+ export function registerDispatchHandler(socket, orgId, ctx) {
765
+ socket.on("task:dispatch", (payload) => {
766
+ const result = TaskDispatchSchema.safeParse(payload);
767
+ if (!result.success) {
768
+ vmLog.warn("spawner", `Invalid task:dispatch payload: ${result.error.issues.map((i) => i.message).join(", ")}`);
769
+ return;
770
+ }
771
+ const dispatchParams = {
772
+ ...result.data,
773
+ orgId,
774
+ };
775
+ // Diagnostic: log received dispatch params to trace command resolution issues
776
+ const taskCommandPreview = result.data.taskCommand
777
+ ? `"${result.data.taskCommand.slice(0, 80)}..."`
778
+ : "MISSING";
779
+ vmLog.cyan("spawner", `task:dispatch received for ${result.data.taskId} — taskCommand: ${taskCommandPreview}, skillSlug: ${result.data.skillSlug ?? "MISSING"}`);
780
+ // Fire-and-forget — never await full agent lifecycle
781
+ spawnAgent(dispatchParams, ctx).catch((err) => {
782
+ try {
783
+ vmLog.error("spawner", `Failed to spawn agent for task ${result.data.taskId}: ${err instanceof Error ? err.message : String(err)}`);
784
+ ctx.socketClient.emitAgentStatus({
785
+ agentId: result.data.taskId,
786
+ status: "ERROR",
787
+ });
788
+ }
789
+ catch (error_) {
790
+ vmLog.error("spawner", `Failed to emit error status: ${error_ instanceof Error ? error_.message : String(error_)}`);
791
+ }
792
+ });
793
+ });
794
+ return { spawnAgent: (params) => spawnAgent(params, ctx) };
795
+ }
796
+ /**
797
+ * Register the task:resume Socket.io handler on the CLI socket (Story 6.4).
798
+ */
799
+ export function registerResumeHandler(socket, orgId, ctx) {
800
+ socket.on("task:resume", (payload) => {
801
+ const result = TaskResumePayloadSchema.safeParse(payload);
802
+ if (!result.success) {
803
+ vmLog.warn("resume", `Invalid task:resume payload: ${result.error.issues.map((i) => i.message).join(", ")}`);
804
+ return;
805
+ }
806
+ const data = result.data;
807
+ const reg = getRegistry(ctx.agentSlots);
808
+ // Fire-and-forget — never await full resume lifecycle
809
+ handleResume({
810
+ taskId: data.taskId,
811
+ projectId: data.projectId,
812
+ orgId,
813
+ response: data.response,
814
+ skillSlug: data.skillSlug,
815
+ taskSlug: data.taskSlug,
816
+ projectSlug: data.projectSlug,
817
+ taskBranch: data.taskBranch,
818
+ repoUrl: data.repoUrl,
819
+ }, {
820
+ restClient: ctx.restClient,
821
+ socketClient: ctx.socketClient,
822
+ agentType: ctx.agentType,
823
+ agentSlots: ctx.agentSlots,
824
+ registry: reg,
825
+ queue: ctx.queue,
826
+ }).catch((err) => {
827
+ vmLog.error("resume", `Failed to resume agent for task ${data.taskId}: ${err instanceof Error ? err.message : String(err)}`);
828
+ ctx.socketClient.emitAgentStatus({
829
+ agentId: data.taskId,
830
+ status: "ERROR",
831
+ });
832
+ });
833
+ });
834
+ }
835
+ /**
836
+ * Helper: Check slot state for workflow step agent and reserve if needed.
837
+ * Returns true if slot was reserved; false if duplicate/no capacity.
838
+ */
839
+ function checkAndReserveWorkflowSlot(reg, agentId) {
840
+ const existingEntry = reg.getByTaskId(agentId);
841
+ // If RUNNING, skip duplicate dispatch
842
+ if (existingEntry?.status === "RUNNING") {
843
+ vmLog.dim("workflow-dispatch", `Agent already running for step ${agentId}, skipping duplicate dispatch`);
844
+ return false;
845
+ }
846
+ // If pending reservation, skip to prevent race conditions
847
+ if (reg.hasPendingReservation(agentId)) {
848
+ vmLog.dim("workflow-dispatch", `Spawn already in flight for step ${agentId}, skipping duplicate`);
849
+ return false;
850
+ }
851
+ // If IDLE, unregister to free the slot for a fresh reserve
852
+ if (existingEntry?.status === "IDLE") {
853
+ reg.unregister(agentId);
854
+ }
855
+ // Try to reserve (only if not already reserved)
856
+ if (!existingEntry || existingEntry.status === "IDLE") {
857
+ if (!reg.reserveSlot(agentId)) {
858
+ vmLog.warn("workflow-dispatch", `No capacity for step ${agentId}, skipping`);
859
+ return false;
860
+ }
861
+ }
862
+ return true;
863
+ }
864
+ /**
865
+ * Helper: Handle error exit for workflow step agent (cleanup, emit status).
866
+ */
867
+ function handleWorkflowStepErrorExit(code, agentId, params, reg, ctx, messageHandler, boundSpawnAgent) {
868
+ ctx.socketClient.socket.off("workflow:message", messageHandler);
869
+ const entry = reg.getByTaskId(agentId);
870
+ if (entry) {
871
+ entry.status = "ERROR";
872
+ reg.unregister(agentId);
873
+ }
874
+ // Destroy container on error (no idle window, no snapshot save)
875
+ void (async () => {
876
+ try {
877
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
878
+ await getVmManager().destroySession(agentId);
879
+ }
880
+ catch {
881
+ /* best effort */
882
+ }
883
+ })();
884
+ const cliCfgErr = getConfig();
885
+ ctx.socketClient.emitCliHeartbeat({
886
+ cliId: cliCfgErr.cliId ?? "unknown",
887
+ status: "CONNECTED",
888
+ name: cliCfgErr.cliName ?? "CLI",
889
+ agentSlots: ctx.agentSlots,
890
+ activeAgents: reg.getProvisionedCount(),
891
+ runningAgents: reg.getActiveCount(),
892
+ });
893
+ ctx.socketClient.emitAgentStatus({
894
+ agentId,
895
+ status: "ERROR",
896
+ projectId: params.projectId,
897
+ });
898
+ vmLog.dim("workflow-dispatch", `Agent for step ${agentId} exited with error (code: ${code}), container destroyed`);
899
+ ctx.queue.drainNext({ spawnAgent: boundSpawnAgent }).catch(() => { });
900
+ }
901
+ /**
902
+ * Helper: Setup idle window timeout with message handler and auto-drain on expiry.
903
+ * On timeout: auto-save code + save session snapshot → destroy container → emit timeout.
904
+ */
905
+ function setupIdleWindowTimeout(agentId, messageHandler, idleState, params, reg, ctx, boundSpawnAgent) {
906
+ return setTimeout(() => {
907
+ // Guard: if agent was already resumed, skip cleanup
908
+ if (idleState.resumed) {
909
+ vmLog.dim("workflow-dispatch", `Idle timeout fired for step ${agentId} but agent already resumed, skipping`);
910
+ return;
911
+ }
912
+ // Free slot FIRST (synchronously), then do async snapshot/cleanup.
913
+ // This ensures board tasks can dispatch immediately, even if
914
+ // snapshot save or container destroy is slow.
915
+ idleWindows.delete(agentId);
916
+ ctx.socketClient.socket.off("workflow:message", messageHandler);
917
+ reg.unregister(agentId);
918
+ ctx.socketClient.emitWorkflowAgentTimeout({
919
+ workflowId: params.workflowId,
920
+ stepId: params.stepId,
921
+ });
922
+ const cliCfg = getConfig();
923
+ ctx.socketClient.emitCliHeartbeat({
924
+ cliId: cliCfg.cliId ?? "unknown",
925
+ status: "CONNECTED",
926
+ name: cliCfg.cliName ?? "CLI",
927
+ agentSlots: ctx.agentSlots,
928
+ activeAgents: reg.getProvisionedCount(),
929
+ runningAgents: reg.getActiveCount(),
930
+ });
931
+ vmLog.dim("workflow-dispatch", `Idle window expired for step ${agentId} (slot freed)`);
932
+ // Drain queue immediately — slot is free now
933
+ ctx.queue.drainNext({ spawnAgent: boundSpawnAgent }).catch(() => { });
934
+ // Async: save snapshot + destroy container (best-effort, non-blocking)
935
+ void (async () => {
936
+ try {
937
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
938
+ const sm = getVmManager();
939
+ const session = sm.getSessionByTaskId(agentId);
940
+ if (session) {
941
+ const { autoSaveCode, createSessionSnapshot } = await import("../vm/session-snapshot.js");
942
+ await autoSaveCode(session.containerId, session.id, session.gitCredentials);
943
+ const snapshot = await createSessionSnapshot(session);
944
+ if (snapshot) {
945
+ ctx.socketClient.socket.emit("workflow:step:snapshot", {
946
+ orgId: params.orgId,
947
+ workflowId: params.workflowId,
948
+ stepId: params.stepId,
949
+ snapshot,
950
+ });
951
+ vmLog.cyan("workflow-dispatch", `Session snapshot uploaded for step ${agentId}`);
952
+ }
953
+ await sm.destroySession(agentId);
954
+ }
955
+ }
956
+ catch (saveErr) {
957
+ vmLog.warn("workflow-dispatch", `Failed to save snapshot on idle timeout: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`);
958
+ try {
959
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
960
+ await getVmManager().destroySession(agentId);
961
+ }
962
+ catch {
963
+ /* best effort */
964
+ }
965
+ }
966
+ })();
967
+ }, IDLE_WINDOW_MS);
968
+ }
969
+ /**
970
+ * Spawn an agent for a workflow step dispatch (Story 6b-7).
971
+ * Similar to spawnAgent but uses workflow context for MCP config and step command as prompt.
972
+ * After agent exits (code 0), enters a 5-minute idle window for follow-up messages.
973
+ */
974
+ async function spawnWorkflowStepAgent(params, ctx) {
975
+ const reg = getRegistry(ctx.agentSlots);
976
+ // Use stepId as the agent identifier (like taskId for task agents)
977
+ const agentId = params.stepId;
978
+ // 1. Reserve slot (if not already reserved from idle window resume)
979
+ if (!checkAndReserveWorkflowSlot(reg, agentId)) {
980
+ return;
981
+ }
982
+ let messageHandlerRef = null;
983
+ let slotWasReserved = false;
984
+ try {
985
+ slotWasReserved = true;
986
+ // Pull and prepare skill
987
+ const skillName = params.skillSlug ?? DEFAULT_SKILL_NAME;
988
+ const skill = await pullSkill(skillName, ctx.restClient);
989
+ // Resolve connector (always VM now)
990
+ const connector = resolveConnector(ctx.agentType);
991
+ // Build prompt: base command + optional conversation history
992
+ let prompt = params.stepCommand ?? params.stepTitle;
993
+ if (params.conversationHistory) {
994
+ prompt = `${prompt}\n\n## Conversation History\n\n${params.conversationHistory}`;
995
+ }
996
+ // VM connector handles everything: boot VM, clone, checkout, workspace, spawn
997
+ const vmSpawnParams = {
998
+ taskId: agentId,
999
+ projectId: params.projectId,
1000
+ orgId: params.orgId,
1001
+ workingDir: "/workspace",
1002
+ mcpConfigPath: "/workspace/.claude/mcp_config.json",
1003
+ skillName: skill?.name ?? skillName,
1004
+ prompt,
1005
+ repoUrl: params.repoUrl,
1006
+ taskBranch: params.baseBranch,
1007
+ workflowBaseBranch: params.sourceBranch,
1008
+ skill: skill ? { name: skill.name, files: skill.files } : undefined,
1009
+ workflowContext: {
1010
+ workflowId: params.workflowId,
1011
+ stepId: params.stepId,
1012
+ },
1013
+ };
1014
+ // Join workflow room for message streaming
1015
+ ctx.socketClient.socket.emit("join:workflow", params.workflowId);
1016
+ // If snapshot available, fetch it and use resumeWithSnapshot for --continue.
1017
+ // With --continue, Claude Code already has the full session context.
1018
+ // The -p prompt should be ONLY the user's latest message, not stepCommand + history.
1019
+ let result;
1020
+ if (params.hasSnapshot && connector.resumeWithSnapshot) {
1021
+ // For --continue, extract the user's latest message for -p.
1022
+ // The session snapshot already has the full conversation context.
1023
+ let snapshotPrompt;
1024
+ if (params.conversationHistory) {
1025
+ // History format: "USER: content\n\nASSISTANT: content\n\n..."
1026
+ const lines = params.conversationHistory.split("\n\n");
1027
+ const lastUserLine = lines
1028
+ .reverse()
1029
+ .find((l) => l.startsWith("USER: "));
1030
+ if (lastUserLine) {
1031
+ snapshotPrompt = lastUserLine.replace(/^USER: /, "");
1032
+ }
1033
+ }
1034
+ // If no conversation history (e.g. auto-retry dispatch), use generic continue.
1035
+ // Do NOT pass stepCommand — the snapshot already has the task context.
1036
+ vmSpawnParams.prompt =
1037
+ snapshotPrompt ?? "Continue your work from where you left off.";
1038
+ vmLog.cyan("workflow-dispatch", `Fetching session snapshot for step ${agentId}`);
1039
+ const snapshotData = await new Promise((resolve) => {
1040
+ const timeout = setTimeout(() => resolve(null), 10_000);
1041
+ ctx.socketClient.socket.emit("workflow:step:snapshot:fetch", { workflowId: params.workflowId, stepId: params.stepId }, (data) => {
1042
+ clearTimeout(timeout);
1043
+ resolve(data);
1044
+ });
1045
+ });
1046
+ if (snapshotData?.snapshot) {
1047
+ vmLog.cyan("workflow-dispatch", `Snapshot received (${(snapshotData.snapshot.length / 1024).toFixed(0)}KB), restoring with --continue`);
1048
+ vmSpawnParams.sessionSnapshotBase64 = snapshotData.snapshot;
1049
+ result = await connector.resumeWithSnapshot(vmSpawnParams);
1050
+ }
1051
+ else {
1052
+ vmLog.warn("workflow-dispatch", `Snapshot fetch failed for step ${agentId}, falling back to fresh start`);
1053
+ result = await connector.start(vmSpawnParams);
1054
+ }
1055
+ }
1056
+ else {
1057
+ result = await connector.start(vmSpawnParams);
1058
+ }
1059
+ // Register in agent registry (overwrite if resuming from idle)
1060
+ reg.confirmSlot(agentId, {
1061
+ taskId: agentId,
1062
+ pid: result.pid,
1063
+ process: result.process,
1064
+ connectorName: connector.name,
1065
+ workingDir: "/workspace",
1066
+ orgId: params.orgId,
1067
+ startedAt: new Date().toISOString(),
1068
+ status: "RUNNING",
1069
+ vmId: result.vmId,
1070
+ });
1071
+ // 7. Emit agent:status(RUNNING) + immediate heartbeat so browser
1072
+ // shows the agent as provisioned right away (not waiting for next 30s tick)
1073
+ ctx.socketClient.emitAgentStatus({
1074
+ agentId,
1075
+ status: "RUNNING",
1076
+ projectId: params.projectId,
1077
+ });
1078
+ const cliCfg = getConfig();
1079
+ ctx.socketClient.emitCliHeartbeat({
1080
+ cliId: cliCfg.cliId ?? "unknown",
1081
+ status: "CONNECTED",
1082
+ name: cliCfg.cliName ?? "CLI",
1083
+ agentSlots: ctx.agentSlots,
1084
+ activeAgents: reg.getProvisionedCount(),
1085
+ runningAgents: reg.getActiveCount(),
1086
+ });
1087
+ vmLog.info("workflow-dispatch", `Agent spawned for step ${agentId} (PID: ${result.pid}, connector: ${connector.name})`);
1088
+ // 8. Register workflow:message listener for user messages
1089
+ let messageHandlerConsumed = false;
1090
+ const messageHandler = (payload) => {
1091
+ if (payload.workflowId !== params.workflowId)
1092
+ return;
1093
+ if (payload.role !== "USER")
1094
+ return;
1095
+ if (messageHandlerConsumed)
1096
+ return;
1097
+ // Check if agent is in idle window — resume with user message
1098
+ const idleState = idleWindows.get(agentId);
1099
+ if (idleState) {
1100
+ // Set resumed flag BEFORE clearTimeout — prevents race where timeout
1101
+ // callback fires concurrently during async resume
1102
+ idleState.resumed = true;
1103
+ messageHandlerConsumed = true;
1104
+ vmLog.cyan("workflow-dispatch", `Resuming idle agent for step ${agentId} with user message`);
1105
+ clearTimeout(idleState.timer);
1106
+ // Remove THIS handler before resume spawns a new one (prevents duplicates)
1107
+ ctx.socketClient.socket.off("workflow:message", messageHandler);
1108
+ idleWindows.delete(agentId);
1109
+ // Resume: spawn fresh agent with conversation context
1110
+ resumeWorkflowStepAgent(agentId, payload.content, idleState.lastAssistantContent, params, ctx).catch((err) => {
1111
+ try {
1112
+ vmLog.error("workflow-dispatch", `Failed to resume agent for step ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
1113
+ }
1114
+ catch {
1115
+ // Best-effort logging
1116
+ }
1117
+ });
1118
+ return;
1119
+ }
1120
+ // Agent is running in one-shot mode — can't write to stdin
1121
+ // Notify user via system message so wizard-chat shows feedback
1122
+ ctx.socketClient.socket.emit("workflow:message", {
1123
+ orgId: params.orgId,
1124
+ workflowId: params.workflowId,
1125
+ stepId: params.stepId,
1126
+ conversationId: params.conversationId ?? "",
1127
+ messageId: randomUUID(),
1128
+ content: "Agent is processing — your message will be handled when the current step completes.",
1129
+ role: "SYSTEM",
1130
+ timestamp: new Date().toISOString(),
1131
+ });
1132
+ vmLog.warn("workflow-dispatch", `Agent running in one-shot mode for step ${agentId}, message will be handled on next idle cycle`);
1133
+ };
1134
+ ctx.socketClient.socket.on("workflow:message", messageHandler);
1135
+ messageHandlerRef = messageHandler;
1136
+ // 9. Attach workflow process handlers with idle window on exit
1137
+ const boundSpawnAgent = (p) => spawnAgent(p, ctx);
1138
+ attachWorkflowProcessHandlers(result.process, {
1139
+ taskId: agentId,
1140
+ projectId: params.projectId,
1141
+ registry: reg,
1142
+ socketClient: ctx.socketClient,
1143
+ restClient: ctx.restClient,
1144
+ queue: ctx.queue,
1145
+ spawnAgentFn: boundSpawnAgent,
1146
+ label: "workflow-dispatch",
1147
+ agentSlots: ctx.agentSlots,
1148
+ sessionId: result.vmId,
1149
+ workflowId: params.workflowId,
1150
+ stepId: params.stepId,
1151
+ conversationId: params.conversationId ?? agentId,
1152
+ onExit: (code, accumulatedContent) => {
1153
+ // On error exit, clean up immediately — no idle window
1154
+ if (code !== 0) {
1155
+ handleWorkflowStepErrorExit(code, agentId, params, reg, ctx, messageHandler, boundSpawnAgent);
1156
+ return;
1157
+ }
1158
+ // If user already approved/advanced while agent was running,
1159
+ // skip idle window — destroy container and free slot immediately.
1160
+ if (dismissedWhileRunning.has(agentId)) {
1161
+ dismissedWhileRunning.delete(agentId);
1162
+ vmLog.cyan("workflow-dispatch", `Agent for step ${agentId} exited — dismissed while running, saving code before destroy`);
1163
+ ctx.socketClient.socket.off("workflow:message", messageHandler);
1164
+ reg.unregister(agentId);
1165
+ void (async () => {
1166
+ try {
1167
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
1168
+ const sm = getVmManager();
1169
+ const session = sm.getSessionByTaskId(agentId);
1170
+ if (session) {
1171
+ const { autoSaveCode } = await import("../vm/session-snapshot.js");
1172
+ await autoSaveCode(session.containerId, session.id, session.gitCredentials);
1173
+ }
1174
+ await sm.destroySession(agentId);
1175
+ }
1176
+ catch {
1177
+ /* best effort */
1178
+ }
1179
+ })();
1180
+ const cliDismiss = getConfig();
1181
+ ctx.socketClient.emitCliHeartbeat({
1182
+ cliId: cliDismiss.cliId ?? "unknown",
1183
+ status: "CONNECTED",
1184
+ name: cliDismiss.cliName ?? "CLI",
1185
+ agentSlots: ctx.agentSlots,
1186
+ activeAgents: reg.getProvisionedCount(),
1187
+ runningAgents: reg.getActiveCount(),
1188
+ });
1189
+ ctx.queue.drainNext({ spawnAgent: boundSpawnAgent }).catch(() => { });
1190
+ return;
1191
+ }
1192
+ // Success exit: enter 5-minute idle window
1193
+ const entry = reg.getByTaskId(agentId);
1194
+ if (entry) {
1195
+ entry.status = "IDLE";
1196
+ }
1197
+ const idleUntil = new Date(Date.now() + IDLE_WINDOW_MS).toISOString();
1198
+ ctx.socketClient.emitWorkflowAgentIdle({
1199
+ workflowId: params.workflowId,
1200
+ stepId: params.stepId,
1201
+ idleUntil,
1202
+ });
1203
+ // Emit fresh heartbeat so browser sees agent as provisioned during idle
1204
+ const cliConfig = getConfig();
1205
+ ctx.socketClient.emitCliHeartbeat({
1206
+ cliId: cliConfig.cliId ?? "unknown",
1207
+ status: "CONNECTED",
1208
+ name: cliConfig.cliName ?? "CLI",
1209
+ agentSlots: ctx.agentSlots,
1210
+ activeAgents: reg.getProvisionedCount(),
1211
+ runningAgents: reg.getActiveCount(),
1212
+ });
1213
+ vmLog.cyan("workflow-dispatch", `Agent for step ${agentId} entering idle window until ${idleUntil} (slot provisioned)`);
1214
+ // Create idle state object first so timeout and messageHandler share
1215
+ // the same `resumed` flag via object reference
1216
+ const idleState = {
1217
+ timer: null,
1218
+ spawnParams: vmSpawnParams,
1219
+ connector,
1220
+ messageHandler,
1221
+ workflowId: params.workflowId,
1222
+ stepId: params.stepId,
1223
+ conversationId: params.conversationId ?? agentId,
1224
+ stepCommand: params.stepCommand,
1225
+ stepTitle: params.stepTitle,
1226
+ projectId: params.projectId,
1227
+ repoUrl: params.repoUrl,
1228
+ projectSlug: params.projectSlug,
1229
+ lastAssistantContent: accumulatedContent,
1230
+ resumed: false,
1231
+ };
1232
+ idleState.timer = setupIdleWindowTimeout(agentId, messageHandler, idleState, params, reg, ctx, boundSpawnAgent);
1233
+ idleWindows.set(agentId, idleState);
1234
+ },
1235
+ });
1236
+ }
1237
+ catch (err) {
1238
+ // Clean up message listener if it was registered
1239
+ if (messageHandlerRef) {
1240
+ ctx.socketClient.socket.off("workflow:message", messageHandlerRef);
1241
+ }
1242
+ // Only release if we reserved (not resuming from idle)
1243
+ if (slotWasReserved) {
1244
+ reg.releaseSlot(agentId);
1245
+ }
1246
+ throw err;
1247
+ }
1248
+ }
1249
+ /**
1250
+ * Resume a workflow step agent from idle window with a new user message.
1251
+ * Uses `claude --continue` in the same container for full context preservation.
1252
+ * Falls back to fresh spawn with conversation history if container is dead.
1253
+ */
1254
+ async function resumeWorkflowStepAgent(agentId, userMessage, lastAssistantContent, originalParams, ctx) {
1255
+ // Try --continue in the same container (container stays alive during idle window)
1256
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
1257
+ const sm = getVmManager();
1258
+ const session = sm.getSessionByTaskId(agentId);
1259
+ if (session && session.status === "RUNNING") {
1260
+ vmLog.cyan("workflow-dispatch", `Resuming step ${agentId} with --continue in existing container ${session.id}`);
1261
+ // Use connector.resume() which handles MCP restart + --continue
1262
+ const connector = resolveConnector(ctx.agentType);
1263
+ const vmSpawnParams = {
1264
+ taskId: agentId,
1265
+ projectId: originalParams.projectId,
1266
+ orgId: originalParams.orgId,
1267
+ workingDir: "/workspace",
1268
+ mcpConfigPath: "/workspace/.claude/mcp_config.json",
1269
+ skillName: DEFAULT_SKILL_NAME,
1270
+ prompt: userMessage,
1271
+ repoUrl: originalParams.repoUrl,
1272
+ taskBranch: originalParams.baseBranch,
1273
+ workflowBaseBranch: originalParams.sourceBranch,
1274
+ workflowContext: {
1275
+ workflowId: originalParams.workflowId,
1276
+ stepId: originalParams.stepId,
1277
+ },
1278
+ };
1279
+ const result = await connector.resume(vmSpawnParams);
1280
+ // Re-register in agent registry
1281
+ const reg = getRegistry(ctx.agentSlots);
1282
+ reg.confirmSlot(agentId, {
1283
+ taskId: agentId,
1284
+ pid: result.pid,
1285
+ process: result.process,
1286
+ connectorName: connector.name,
1287
+ workingDir: "/workspace",
1288
+ orgId: originalParams.orgId,
1289
+ startedAt: new Date().toISOString(),
1290
+ status: "RUNNING",
1291
+ vmId: result.vmId,
1292
+ });
1293
+ ctx.socketClient.emitAgentStatus({
1294
+ agentId,
1295
+ status: "RUNNING",
1296
+ projectId: originalParams.projectId,
1297
+ });
1298
+ const cliCfg = getConfig();
1299
+ ctx.socketClient.emitCliHeartbeat({
1300
+ cliId: cliCfg.cliId ?? "unknown",
1301
+ status: "CONNECTED",
1302
+ name: cliCfg.cliName ?? "CLI",
1303
+ agentSlots: ctx.agentSlots,
1304
+ activeAgents: reg.getProvisionedCount(),
1305
+ runningAgents: reg.getActiveCount(),
1306
+ });
1307
+ // Re-attach workflow process handlers with new idle window
1308
+ const boundSpawnAgent = (p) => spawnAgent(p, ctx);
1309
+ let newMessageHandlerConsumed = false;
1310
+ const newMessageHandler = (payload) => {
1311
+ if (payload.workflowId !== originalParams.workflowId)
1312
+ return;
1313
+ if (payload.role !== "USER")
1314
+ return;
1315
+ if (newMessageHandlerConsumed)
1316
+ return;
1317
+ const newIdleState = idleWindows.get(agentId);
1318
+ if (newIdleState) {
1319
+ newIdleState.resumed = true;
1320
+ newMessageHandlerConsumed = true;
1321
+ clearTimeout(newIdleState.timer);
1322
+ ctx.socketClient.socket.off("workflow:message", newMessageHandler);
1323
+ idleWindows.delete(agentId);
1324
+ resumeWorkflowStepAgent(agentId, payload.content, newIdleState.lastAssistantContent, originalParams, ctx).catch((err) => {
1325
+ vmLog.error("workflow-dispatch", `Failed to resume: ${err instanceof Error ? err.message : String(err)}`);
1326
+ });
1327
+ }
1328
+ };
1329
+ ctx.socketClient.socket.on("workflow:message", newMessageHandler);
1330
+ attachWorkflowProcessHandlers(result.process, {
1331
+ taskId: agentId,
1332
+ projectId: originalParams.projectId,
1333
+ registry: reg,
1334
+ socketClient: ctx.socketClient,
1335
+ restClient: ctx.restClient,
1336
+ queue: ctx.queue,
1337
+ spawnAgentFn: boundSpawnAgent,
1338
+ label: "workflow-dispatch",
1339
+ agentSlots: ctx.agentSlots,
1340
+ sessionId: result.vmId,
1341
+ workflowId: originalParams.workflowId,
1342
+ stepId: originalParams.stepId,
1343
+ conversationId: originalParams.conversationId ?? agentId,
1344
+ onExit: (code, accumulatedContent) => {
1345
+ if (code !== 0) {
1346
+ handleWorkflowStepErrorExit(code, agentId, originalParams, reg, ctx, newMessageHandler, boundSpawnAgent);
1347
+ return;
1348
+ }
1349
+ // Re-enter idle window
1350
+ const entry = reg.getByTaskId(agentId);
1351
+ if (entry)
1352
+ entry.status = "IDLE";
1353
+ const idleUntil = new Date(Date.now() + IDLE_WINDOW_MS).toISOString();
1354
+ ctx.socketClient.emitWorkflowAgentIdle({
1355
+ workflowId: originalParams.workflowId,
1356
+ stepId: originalParams.stepId,
1357
+ idleUntil,
1358
+ });
1359
+ const cliResume = getConfig();
1360
+ ctx.socketClient.emitCliHeartbeat({
1361
+ cliId: cliResume.cliId ?? "unknown",
1362
+ status: "CONNECTED",
1363
+ name: cliResume.cliName ?? "CLI",
1364
+ agentSlots: ctx.agentSlots,
1365
+ activeAgents: reg.getProvisionedCount(),
1366
+ runningAgents: reg.getActiveCount(),
1367
+ });
1368
+ vmLog.cyan("workflow-dispatch", `Agent for step ${agentId} re-entering idle window until ${idleUntil} (slot provisioned)`);
1369
+ const resumeIdleState = {
1370
+ timer: null,
1371
+ spawnParams: vmSpawnParams,
1372
+ connector,
1373
+ messageHandler: newMessageHandler,
1374
+ workflowId: originalParams.workflowId,
1375
+ stepId: originalParams.stepId,
1376
+ conversationId: originalParams.conversationId ?? agentId,
1377
+ stepCommand: originalParams.stepCommand,
1378
+ stepTitle: originalParams.stepTitle,
1379
+ projectId: originalParams.projectId,
1380
+ repoUrl: originalParams.repoUrl,
1381
+ projectSlug: originalParams.projectSlug,
1382
+ lastAssistantContent: accumulatedContent,
1383
+ resumed: false,
1384
+ };
1385
+ resumeIdleState.timer = setupIdleWindowTimeout(agentId, newMessageHandler, resumeIdleState, originalParams, reg, ctx, boundSpawnAgent);
1386
+ idleWindows.set(agentId, resumeIdleState);
1387
+ },
1388
+ });
1389
+ return;
1390
+ }
1391
+ // Fallback: container is dead — fresh start with conversation history
1392
+ vmLog.warn("workflow-dispatch", `Container dead for step ${agentId}, falling back to fresh spawn with history`);
1393
+ const historyParts = [];
1394
+ if (lastAssistantContent) {
1395
+ historyParts.push(`ASSISTANT: ${lastAssistantContent}`);
1396
+ }
1397
+ historyParts.push(`USER: ${userMessage}`);
1398
+ const conversationHistory = historyParts.join("\n\n");
1399
+ await spawnWorkflowStepAgent({
1400
+ ...originalParams,
1401
+ conversationHistory,
1402
+ }, ctx);
1403
+ }
1404
+ /**
1405
+ * Register the workflow:step:dispatch Socket.io handler on the CLI socket (Story 6b-7).
1406
+ */
1407
+ export function registerWorkflowStepDispatchHandler(socket, orgId, ctx) {
1408
+ // Remove any existing listeners to prevent handler stacking on reconnect
1409
+ socket.removeAllListeners("workflow:step:dispatch");
1410
+ socket.on("workflow:step:dispatch", (payload) => {
1411
+ const result = WorkflowStepDispatchPayloadSchema.safeParse(payload);
1412
+ if (!result.success) {
1413
+ vmLog.warn("workflow-dispatch", `Invalid workflow:step:dispatch payload: ${result.error.issues.map((i) => i.message).join(", ")}`);
1414
+ return;
1415
+ }
1416
+ const data = result.data;
1417
+ const stepCommandPreview = data.stepCommand
1418
+ ? `"${data.stepCommand.slice(0, 80)}..."`
1419
+ : "MISSING";
1420
+ vmLog.cyan("workflow-dispatch", `workflow:step:dispatch for ${data.stepId} — stepCommand: ${stepCommandPreview}, hasSnapshot: ${data.hasSnapshot ?? false}, skillSlug: ${data.skillSlug ?? "MISSING"}`);
1421
+ // Fire-and-forget — never await full agent lifecycle
1422
+ spawnWorkflowStepAgent({
1423
+ stepId: data.stepId,
1424
+ workflowId: data.workflowId,
1425
+ projectId: data.projectId,
1426
+ orgId,
1427
+ skillSlug: data.skillSlug,
1428
+ stepCommand: data.stepCommand,
1429
+ stepTitle: data.stepTitle,
1430
+ conversationId: data.conversationId,
1431
+ repoUrl: data.repoUrl,
1432
+ projectSlug: data.projectSlug,
1433
+ workflowSlug: data.workflowSlug,
1434
+ baseBranch: data.baseBranch,
1435
+ sourceBranch: data.sourceBranch,
1436
+ conversationHistory: data.conversationHistory,
1437
+ hasSnapshot: data.hasSnapshot,
1438
+ }, ctx).catch((err) => {
1439
+ try {
1440
+ vmLog.error("workflow-dispatch", `Failed to spawn agent for step ${data.stepId}: ${err instanceof Error ? err.message : String(err)}`);
1441
+ ctx.socketClient.emitAgentStatus({
1442
+ agentId: data.stepId,
1443
+ status: "ERROR",
1444
+ });
1445
+ }
1446
+ catch (error_) {
1447
+ vmLog.error("workflow-dispatch", `Failed to emit error status: ${error_ instanceof Error ? error_.message : String(error_)}`);
1448
+ }
1449
+ });
1450
+ });
1451
+ }
1452
+ /**
1453
+ * Register workflow:agent:dismiss handler — Cloud tells CLI to clear idle window.
1454
+ * Called when user advances to next step after approving deliverables.
1455
+ */
1456
+ export function registerWorkflowAgentDismissHandler(socket, ctx) {
1457
+ socket.removeAllListeners("workflow:agent:dismiss");
1458
+ socket.on("workflow:agent:dismiss", (payload) => {
1459
+ // Validate payload shape (minimal — just need stepId)
1460
+ if (!payload || typeof payload !== "object")
1461
+ return;
1462
+ const data = payload;
1463
+ const stepId = data.stepId;
1464
+ if (typeof stepId !== "string")
1465
+ return;
1466
+ const agentId = stepId; // In workflow dispatch, agentId === stepId
1467
+ const idleState = idleWindows.get(agentId);
1468
+ if (!idleState) {
1469
+ // Agent might still be RUNNING (dismiss arrived before idle window).
1470
+ // Mark it so onExit skips the idle window and destroys immediately.
1471
+ const reg = getRegistry(ctx.agentSlots);
1472
+ const entry = reg.getByTaskId(agentId);
1473
+ if (entry?.status === "RUNNING") {
1474
+ dismissedWhileRunning.add(agentId);
1475
+ vmLog.cyan("workflow-dispatch", `workflow:agent:dismiss for step ${agentId} — agent still running, will skip idle on exit`);
1476
+ }
1477
+ else {
1478
+ vmLog.dim("workflow-dispatch", `workflow:agent:dismiss for step ${agentId} — no idle window and not running, skipping`);
1479
+ }
1480
+ return;
1481
+ }
1482
+ // Set resumed flag to prevent timeout callback from firing
1483
+ idleState.resumed = true;
1484
+ clearTimeout(idleState.timer);
1485
+ ctx.socketClient.socket.off("workflow:message", idleState.messageHandler);
1486
+ idleWindows.delete(agentId);
1487
+ const reg = getRegistry(ctx.agentSlots);
1488
+ reg.unregister(agentId);
1489
+ // Save code before destroying container (preserves agent's work for next step)
1490
+ void (async () => {
1491
+ try {
1492
+ const { getVmManager } = await import("../vm/vm-manager-facade.js");
1493
+ const sm = getVmManager();
1494
+ const session = sm.getSessionByTaskId(agentId);
1495
+ if (session) {
1496
+ const { autoSaveCode } = await import("../vm/session-snapshot.js");
1497
+ await autoSaveCode(session.containerId, session.id, session.gitCredentials);
1498
+ }
1499
+ await sm.destroySession(agentId);
1500
+ }
1501
+ catch {
1502
+ /* best effort */
1503
+ }
1504
+ })();
1505
+ // Emit fresh heartbeat so browser sees slot freed
1506
+ const cliConfig = getConfig();
1507
+ ctx.socketClient.emitCliHeartbeat({
1508
+ cliId: cliConfig.cliId ?? "unknown",
1509
+ status: "CONNECTED",
1510
+ name: cliConfig.cliName ?? "CLI",
1511
+ agentSlots: ctx.agentSlots,
1512
+ activeAgents: reg.getProvisionedCount(),
1513
+ runningAgents: reg.getActiveCount(),
1514
+ });
1515
+ vmLog.cyan("workflow-dispatch", `Idle agent for step ${agentId} dismissed (step advanced, container destroyed, slot freed)`);
1516
+ });
1517
+ }
1518
+ //# sourceMappingURL=agent-spawner.js.map