@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.
- package/LICENSE +201 -0
- package/README.md +80 -0
- package/dist/bin/forgeai.d.ts +3 -0
- package/dist/bin/forgeai.js +8 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +180 -0
- package/dist/commands/auth-login.d.ts +4 -0
- package/dist/commands/auth-login.js +202 -0
- package/dist/commands/auth-logout.d.ts +2 -0
- package/dist/commands/auth-logout.js +41 -0
- package/dist/commands/auth-status.d.ts +4 -0
- package/dist/commands/auth-status.js +81 -0
- package/dist/commands/cli-list.d.ts +4 -0
- package/dist/commands/cli-list.js +40 -0
- package/dist/commands/cli-unlink.d.ts +4 -0
- package/dist/commands/cli-unlink.js +45 -0
- package/dist/commands/config-set.d.ts +5 -0
- package/dist/commands/config-set.js +63 -0
- package/dist/commands/connect.d.ts +9 -0
- package/dist/commands/connect.js +165 -0
- package/dist/commands/disconnect.d.ts +5 -0
- package/dist/commands/disconnect.js +126 -0
- package/dist/commands/journal-clear.d.ts +2 -0
- package/dist/commands/journal-clear.js +16 -0
- package/dist/commands/journal-list.d.ts +2 -0
- package/dist/commands/journal-list.js +20 -0
- package/dist/commands/journal-status.d.ts +2 -0
- package/dist/commands/journal-status.js +18 -0
- package/dist/commands/mcp-serve.d.ts +13 -0
- package/dist/commands/mcp-serve.js +74 -0
- package/dist/commands/org-list.d.ts +4 -0
- package/dist/commands/org-list.js +56 -0
- package/dist/commands/org-unlink.d.ts +4 -0
- package/dist/commands/org-unlink.js +63 -0
- package/dist/commands/reconnect.d.ts +4 -0
- package/dist/commands/reconnect.js +46 -0
- package/dist/commands/release-open.d.ts +2 -0
- package/dist/commands/release-open.js +43 -0
- package/dist/commands/session-respond.d.ts +2 -0
- package/dist/commands/session-respond.js +29 -0
- package/dist/commands/task-open.d.ts +2 -0
- package/dist/commands/task-open.js +42 -0
- package/dist/commands/vm.d.ts +21 -0
- package/dist/commands/vm.js +122 -0
- package/dist/commands/workflow-open.d.ts +2 -0
- package/dist/commands/workflow-open.js +43 -0
- package/dist/config/config-manager.d.ts +31 -0
- package/dist/config/config-manager.js +70 -0
- package/dist/config/token-manager.d.ts +11 -0
- package/dist/config/token-manager.js +87 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +22 -0
- package/dist/lib/auth-client.d.ts +1670 -0
- package/dist/lib/auth-client.js +10 -0
- package/dist/lib/branch-manager.d.ts +79 -0
- package/dist/lib/branch-manager.js +620 -0
- package/dist/lib/clone-and-checkout.d.ts +6 -0
- package/dist/lib/clone-and-checkout.js +41 -0
- package/dist/lib/connection-manager.d.ts +58 -0
- package/dist/lib/connection-manager.js +436 -0
- package/dist/lib/daemon-entry.d.ts +2 -0
- package/dist/lib/daemon-entry.js +210 -0
- package/dist/lib/daemon.d.ts +38 -0
- package/dist/lib/daemon.js +148 -0
- package/dist/lib/event-journal.d.ts +24 -0
- package/dist/lib/event-journal.js +76 -0
- package/dist/lib/ipc-client.d.ts +10 -0
- package/dist/lib/ipc-client.js +70 -0
- package/dist/lib/ipc-server.d.ts +36 -0
- package/dist/lib/ipc-server.js +70 -0
- package/dist/lib/node-version-guard.d.ts +8 -0
- package/dist/lib/node-version-guard.js +60 -0
- package/dist/lib/open-directory.d.ts +7 -0
- package/dist/lib/open-directory.js +41 -0
- package/dist/lib/repo-manager.d.ts +29 -0
- package/dist/lib/repo-manager.js +103 -0
- package/dist/lib/resilient-emitter.d.ts +53 -0
- package/dist/lib/resilient-emitter.js +160 -0
- package/dist/lib/rest-client.d.ts +164 -0
- package/dist/lib/rest-client.js +139 -0
- package/dist/lib/skill-manager.d.ts +23 -0
- package/dist/lib/skill-manager.js +167 -0
- package/dist/lib/socket-client.d.ts +68 -0
- package/dist/lib/socket-client.js +160 -0
- package/dist/mcp/mcp-probe.d.ts +11 -0
- package/dist/mcp/mcp-probe.js +106 -0
- package/dist/mcp/mcp-response.d.ts +29 -0
- package/dist/mcp/mcp-response.js +67 -0
- package/dist/mcp/mcp-server.d.ts +19 -0
- package/dist/mcp/mcp-server.js +76 -0
- package/dist/mcp/resources/project-conventions.d.ts +4 -0
- package/dist/mcp/resources/project-conventions.js +50 -0
- package/dist/mcp/resources/task-current.d.ts +4 -0
- package/dist/mcp/resources/task-current.js +51 -0
- package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
- package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
- package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
- package/dist/mcp/tools/forge-ask-human.js +80 -0
- package/dist/mcp/tools/forge-column-done.d.ts +4 -0
- package/dist/mcp/tools/forge-column-done.js +46 -0
- package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
- package/dist/mcp/tools/forge-complete-step.js +139 -0
- package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task-group.js +22 -0
- package/dist/mcp/tools/forge-create-task.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task.js +28 -0
- package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task-group.js +17 -0
- package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task.js +17 -0
- package/dist/mcp/tools/forge-get-task.d.ts +4 -0
- package/dist/mcp/tools/forge-get-task.js +17 -0
- package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
- package/dist/mcp/tools/forge-merge-back.js +179 -0
- package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-release-merge.js +159 -0
- package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
- package/dist/mcp/tools/forge-release-notes.js +51 -0
- package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
- package/dist/mcp/tools/forge-release-tag.js +125 -0
- package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
- package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
- package/dist/mcp/tools/forge-requeue.d.ts +4 -0
- package/dist/mcp/tools/forge-requeue.js +39 -0
- package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
- package/dist/mcp/tools/forge-submit-pr.js +68 -0
- package/dist/mcp/tools/forge-update-status.d.ts +4 -0
- package/dist/mcp/tools/forge-update-status.js +77 -0
- package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task-group.js +21 -0
- package/dist/mcp/tools/forge-update-task.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task.js +25 -0
- package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-validate-merge.js +194 -0
- package/dist/mcp/types.d.ts +39 -0
- package/dist/mcp/types.js +8 -0
- package/dist/methodology/artifact-manager.d.ts +14 -0
- package/dist/methodology/artifact-manager.js +55 -0
- package/dist/orchestrator/agent-registry.d.ts +53 -0
- package/dist/orchestrator/agent-registry.js +96 -0
- package/dist/orchestrator/agent-spawner.d.ts +112 -0
- package/dist/orchestrator/agent-spawner.js +1518 -0
- package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
- package/dist/orchestrator/connectors/connector.d.ts +95 -0
- package/dist/orchestrator/connectors/connector.js +25 -0
- package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
- package/dist/orchestrator/connectors/docker-connector.js +337 -0
- package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
- package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
- package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/opencode-connector.js +39 -0
- package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/vm-connector.js +139 -0
- package/dist/orchestrator/dispatch-queue.d.ts +43 -0
- package/dist/orchestrator/dispatch-queue.js +93 -0
- package/dist/orchestrator/resume-handler.d.ts +34 -0
- package/dist/orchestrator/resume-handler.js +159 -0
- package/dist/orchestrator/stream-batcher.d.ts +16 -0
- package/dist/orchestrator/stream-batcher.js +50 -0
- package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
- package/dist/orchestrator/stream-json-extractor.js +579 -0
- package/dist/vm/claude-token-manager.d.ts +36 -0
- package/dist/vm/claude-token-manager.js +223 -0
- package/dist/vm/docker-executor.d.ts +131 -0
- package/dist/vm/docker-executor.js +360 -0
- package/dist/vm/docker-process-proxy.d.ts +31 -0
- package/dist/vm/docker-process-proxy.js +105 -0
- package/dist/vm/forge-vm-agent.d.ts +11 -0
- package/dist/vm/forge-vm-agent.js +159 -0
- package/dist/vm/git-credentials.d.ts +43 -0
- package/dist/vm/git-credentials.js +53 -0
- package/dist/vm/image-manager.d.ts +7 -0
- package/dist/vm/image-manager.js +61 -0
- package/dist/vm/log.d.ts +9 -0
- package/dist/vm/log.js +18 -0
- package/dist/vm/mcp-bridge.d.ts +63 -0
- package/dist/vm/mcp-bridge.js +243 -0
- package/dist/vm/mcp-stub.d.ts +15 -0
- package/dist/vm/mcp-stub.js +67 -0
- package/dist/vm/native-addon.d.ts +69 -0
- package/dist/vm/native-addon.js +68 -0
- package/dist/vm/provider-resolver.d.ts +7 -0
- package/dist/vm/provider-resolver.js +27 -0
- package/dist/vm/providers/lima.d.ts +17 -0
- package/dist/vm/providers/lima.js +191 -0
- package/dist/vm/providers/qemu.d.ts +28 -0
- package/dist/vm/providers/qemu.js +260 -0
- package/dist/vm/providers/vfkit.d.ts +40 -0
- package/dist/vm/providers/vfkit.js +371 -0
- package/dist/vm/providers/wsl2.d.ts +27 -0
- package/dist/vm/providers/wsl2.js +226 -0
- package/dist/vm/session-manager.d.ts +130 -0
- package/dist/vm/session-manager.js +494 -0
- package/dist/vm/session-snapshot.d.ts +31 -0
- package/dist/vm/session-snapshot.js +180 -0
- package/dist/vm/types.d.ts +73 -0
- package/dist/vm/types.js +2 -0
- package/dist/vm/vm-helper-client.d.ts +81 -0
- package/dist/vm/vm-helper-client.js +335 -0
- package/dist/vm/vm-helper.d.ts +11 -0
- package/dist/vm/vm-helper.js +156 -0
- package/dist/vm/vm-ipc-protocol.d.ts +84 -0
- package/dist/vm/vm-ipc-protocol.js +9 -0
- package/dist/vm/vm-manager-facade.d.ts +23 -0
- package/dist/vm/vm-manager-facade.js +43 -0
- package/dist/vm/vm-manager.d.ts +74 -0
- package/dist/vm/vm-manager.js +515 -0
- package/dist/vm/vm-process-proxy.d.ts +34 -0
- package/dist/vm/vm-process-proxy.js +64 -0
- package/dist/vm/vsock-bridge.d.ts +44 -0
- package/dist/vm/vsock-bridge.js +140 -0
- package/dist/vm/vsock-protocol.d.ts +98 -0
- package/dist/vm/vsock-protocol.js +57 -0
- package/package.json +65 -0
- package/rootfs/Dockerfile +50 -0
- 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
|