@tt-a1i/hive 2.1.0 → 2.1.3

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 (93) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.en.md +5 -460
  3. package/README.md +338 -187
  4. package/README.zh.md +389 -0
  5. package/dist/src/cli/hive-update.d.ts +3 -2
  6. package/dist/src/cli/hive-update.js +20 -60
  7. package/dist/src/cli/hive.js +5 -1
  8. package/dist/src/server/agent-manager-support.js +16 -9
  9. package/dist/src/server/agent-runtime-contract.d.ts +4 -0
  10. package/dist/src/server/agent-runtime.js +27 -0
  11. package/dist/src/server/agent-stdin-dispatcher.d.ts +3 -0
  12. package/dist/src/server/agent-stdin-dispatcher.js +3 -0
  13. package/dist/src/server/diagnostics-support-bundle.d.ts +3 -0
  14. package/dist/src/server/diagnostics-support-bundle.js +3 -0
  15. package/dist/src/server/hive-team-guidance.js +34 -4
  16. package/dist/src/server/message-log-store.d.ts +1 -0
  17. package/dist/src/server/message-log-store.js +13 -0
  18. package/dist/src/server/orchestrator-autostart.d.ts +2 -0
  19. package/dist/src/server/orchestrator-autostart.js +30 -11
  20. package/dist/src/server/post-start-input-writer.d.ts +1 -0
  21. package/dist/src/server/post-start-input-writer.js +80 -12
  22. package/dist/src/server/role-templates.d.ts +8 -1
  23. package/dist/src/server/role-templates.js +102 -48
  24. package/dist/src/server/route-types.d.ts +6 -0
  25. package/dist/src/server/routes-scenarios.d.ts +2 -1
  26. package/dist/src/server/routes-scenarios.js +11 -6
  27. package/dist/src/server/routes-team.js +21 -15
  28. package/dist/src/server/routes-workspaces.js +22 -2
  29. package/dist/src/server/runtime-store-contract.d.ts +2 -0
  30. package/dist/src/server/runtime-store-helpers.d.ts +11 -2
  31. package/dist/src/server/runtime-store-helpers.js +29 -5
  32. package/dist/src/server/runtime-store-workflows.js +20 -0
  33. package/dist/src/server/runtime-store.js +2 -0
  34. package/dist/src/server/scenario-presets.d.ts +3 -1
  35. package/dist/src/server/scenario-presets.js +12 -1
  36. package/dist/src/server/spawn-worker-defaults.d.ts +2 -0
  37. package/dist/src/server/spawn-worker-defaults.js +10 -5
  38. package/dist/src/server/team-operations.d.ts +3 -0
  39. package/dist/src/server/team-operations.js +97 -29
  40. package/dist/src/server/terminal-input-profile.d.ts +2 -0
  41. package/dist/src/server/update-install-plan.d.ts +20 -0
  42. package/dist/src/server/update-install-plan.js +151 -0
  43. package/dist/src/server/version-service.d.ts +5 -0
  44. package/dist/src/server/version-service.js +17 -13
  45. package/dist/src/server/workflow-agent-call-executor.d.ts +81 -0
  46. package/dist/src/server/workflow-agent-call-executor.js +259 -0
  47. package/dist/src/server/workflow-dag-layer-tracker.d.ts +14 -0
  48. package/dist/src/server/workflow-dag-layer-tracker.js +37 -0
  49. package/dist/src/server/workflow-dag.d.ts +8 -0
  50. package/dist/src/server/workflow-dag.js +65 -0
  51. package/dist/src/server/workflow-dispatch-awaiter.d.ts +7 -1
  52. package/dist/src/server/workflow-dispatch-awaiter.js +60 -9
  53. package/dist/src/server/workflow-runner.d.ts +5 -38
  54. package/dist/src/server/workflow-runner.js +113 -445
  55. package/dist/src/server/workflow-script-host-dispatcher.d.ts +14 -0
  56. package/dist/src/server/workflow-script-host-dispatcher.js +42 -0
  57. package/dist/src/server/workflow-script-loader.d.ts +2 -2
  58. package/dist/src/server/workflow-script-loader.js +2 -2
  59. package/dist/src/server/workflow-script-worker.d.ts +14 -0
  60. package/dist/src/server/workflow-script-worker.js +81 -0
  61. package/dist/src/server/workflow-vm-worker-source.d.ts +1 -0
  62. package/dist/src/server/workflow-vm-worker-source.js +1 -0
  63. package/dist/src/server/workflow-vm-worker.cjs +238 -0
  64. package/dist/src/server/workspace-ui-language.d.ts +7 -0
  65. package/dist/src/server/workspace-ui-language.js +9 -0
  66. package/dist/src/shared/scenario-presets.d.ts +6 -1
  67. package/dist/src/shared/scenario-presets.js +80 -28
  68. package/dist/src/shared/types.d.ts +11 -7
  69. package/dist/src/shared/ui-language.d.ts +4 -0
  70. package/dist/src/shared/ui-language.js +3 -0
  71. package/package.json +4 -2
  72. package/web/dist/assets/{AddWorkerDialog-DBLhwb91.js → AddWorkerDialog-DHwOZ75i.js} +2 -2
  73. package/web/dist/assets/AddWorkspaceFlow-qOI1CmqC.js +1 -0
  74. package/web/dist/assets/{FirstRunWizard-DlEPnWWw.js → FirstRunWizard-D5kGMD_V.js} +1 -1
  75. package/web/dist/assets/{MarketplaceDrawer-CfSiRi8e.js → MarketplaceDrawer-BuCbtqFk.js} +1 -1
  76. package/web/dist/assets/TaskGraphDrawer-e_bg3f5u.js +1 -0
  77. package/web/dist/assets/{WhatsNewDialog-vP7buLos.js → WhatsNewDialog-BB9ieq3T.js} +1 -1
  78. package/web/dist/assets/{WorkerModal-CSorwcdP.js → WorkerModal-CGeWO3Dg.js} +1 -1
  79. package/web/dist/assets/{WorkflowsDrawer-BXS3w9Uq.js → WorkflowsDrawer-CVvGxxdR.js} +1 -1
  80. package/web/dist/assets/WorkspaceMemoryDrawer-BamHuJEp.js +1 -0
  81. package/web/dist/assets/WorkspaceTaskDrawer-CqntbH9-.js +1 -0
  82. package/web/dist/assets/index-BOpmn5Ij.js +84 -0
  83. package/web/dist/assets/index-rPYd0MUk.css +1 -0
  84. package/web/dist/assets/{search-BZw4T67h.js → search-DKgM9IgX.js} +1 -1
  85. package/web/dist/assets/{square-terminal-B7E57In1.js → square-terminal-DMWazpEv.js} +1 -1
  86. package/web/dist/index.html +2 -2
  87. package/web/dist/sw.js +1 -1
  88. package/web/dist/assets/AddWorkspaceFlow-cxvhVAsT.js +0 -1
  89. package/web/dist/assets/TaskGraphDrawer-C2JufcPs.js +0 -1
  90. package/web/dist/assets/WorkspaceMemoryDrawer-D71ivohr.js +0 -1
  91. package/web/dist/assets/WorkspaceTaskDrawer-CGCTSHKa.js +0 -1
  92. package/web/dist/assets/index-BcwN8cCw.js +0 -79
  93. package/web/dist/assets/index-StXTPHls.css +0 -1
@@ -1,177 +1,17 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { cpus } from 'node:os';
3
1
  import { dirname, join } from 'node:path';
4
- import { Worker } from 'node:worker_threads';
5
2
  import { assertWindowsSafeFilename } from './windows-filename.js';
6
- import { resolveWorkflowCli } from './workflow-cli-policy.js';
7
- import { buildSchemaInstruction, extractJsonBlock } from './workflow-output-schema.js';
3
+ import { createWorkflowAgentCallExecutor, } from './workflow-agent-call-executor.js';
4
+ import { createWorkflowDagLayerTracker } from './workflow-dag-layer-tracker.js';
8
5
  import { loadWorkflowScriptFile, loadWorkflowScriptSource } from './workflow-script-loader.js';
6
+ import { runWorkflowScriptWorker } from './workflow-script-worker.js';
7
+ import { WORKFLOW_VM_WORKER_URL } from './workflow-vm-worker-source.js';
9
8
  import { getWorkflowAgentId } from './workspace-store-support.js';
10
- // TIER 2 #2 + #11 — runtime caps. Match Claude Code's documented caps
11
- // (1000 lifetime calls, min(16, cores-2) concurrent) so scripts copied
12
- // from CC docs behave the same way. The duration default is Hive's own
13
- // choice; CC docs don't specify one but workflows that run >60min are
14
- // almost always a runaway loop in practice.
15
- const DEFAULT_MAX_AGENTS_PER_RUN = 1000;
9
+ // TIER 2 #11 — workflow wall-clock budget. Agent call count and concurrency
10
+ // caps live in workflow-agent-call-executor; the duration default is Hive's
11
+ // own choice because Claude Code docs don't specify one.
16
12
  const DEFAULT_MAX_DURATION_MS = 60 * 60 * 1000;
17
- const DEFAULT_MAX_CONCURRENT_AGENTS = Math.min(16, Math.max(2, cpus().length - 2));
18
- const WORKFLOW_VM_WORKER_SOURCE = `
19
- const { parentPort, workerData } = require('node:worker_threads');
20
- const { Script, createContext } = require('node:vm');
21
-
22
- const BRIDGE_FACTORY = new Script(\`"use strict";
23
- ((hostCall) => {
24
- const stringify = JSON.stringify;
25
- const parse = JSON.parse;
26
- const promiseResolve = Promise.resolve.bind(Promise);
27
- const promiseThen = Promise.prototype.then;
28
- return (...args) =>
29
- promiseThen.call(promiseResolve(hostCall(stringify(args))), (payloadJson) => {
30
- const payload = parse(payloadJson);
31
- if (!payload.ok) throw new Error(payload.error || 'Hive workflow host call failed');
32
- return payload.hasValue ? payload.value : undefined;
33
- });
34
- })\`);
35
- const FLOW_FACTORY = new Script(\`"use strict";
36
- ((catchPerItem) => {
37
- const promiseAll = Promise.all.bind(Promise);
38
- const promiseResolve = Promise.resolve.bind(Promise);
39
- const promiseThen = Promise.prototype.then;
40
- const promiseCatch = Promise.prototype.catch;
41
- const parallel = (thunks) =>
42
- promiseAll(Array.from(thunks).map((thunk) =>
43
- promiseCatch.call(promiseThen.call(promiseResolve(), () => thunk()), (err) => catchPerItem(err))
44
- ));
45
- const pipeline = (items, ...stages) =>
46
- promiseAll(Array.from(items).map((item, index) => {
47
- let chain = promiseResolve(item);
48
- for (const stage of stages) {
49
- chain = promiseThen.call(chain, (prev) => stage(prev, item, index));
50
- }
51
- return promiseCatch.call(chain, (err) => catchPerItem(err));
52
- }));
53
- return { parallel, pipeline };
54
- })\`);
55
- const JSON_PARSE = new Script('JSON.parse');
56
-
57
- const encodeSuccess = (value) => {
58
- const payload = { ok: true, hasValue: value !== undefined };
59
- if (value !== undefined) payload.value = value;
60
- return JSON.stringify(payload);
61
- };
62
- const encodeFailure = (error) =>
63
- JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) });
64
-
65
- const createSafeHostCall = (fn) =>
66
- new Proxy(
67
- (serializedArgs) => {
68
- let args;
69
- try {
70
- const parsed = JSON.parse(serializedArgs);
71
- args = Array.isArray(parsed) ? parsed : [];
72
- } catch (error) {
73
- return encodeFailure(error);
74
- }
75
- try {
76
- return Promise.resolve(fn(args)).then(encodeSuccess, encodeFailure);
77
- } catch (error) {
78
- return encodeFailure(error);
79
- }
80
- },
81
- {
82
- get: () => undefined,
83
- getOwnPropertyDescriptor: () => undefined,
84
- getPrototypeOf: () => null,
85
- has: () => false,
86
- ownKeys: () => [],
87
- set: () => false,
88
- }
89
- );
90
-
91
- let nextCallId = 0;
92
- const pending = new Map();
93
-
94
- parentPort.on('message', (message) => {
95
- if (!message || message.type !== 'hostResponse') return;
96
- const entry = pending.get(message.id);
97
- if (!entry) return;
98
- pending.delete(message.id);
99
- if (message.ok) entry.resolve(message.value);
100
- else entry.reject(new Error(message.error || 'Hive workflow host call failed'));
101
- });
102
-
103
- const callHost = (name, args) =>
104
- new Promise((resolve, reject) => {
105
- const id = String(++nextCallId);
106
- pending.set(id, { resolve, reject });
107
- parentPort.postMessage({ type: 'hostCall', id, name, args });
108
- });
109
-
110
- const cloneIntoVm = (context, value) => {
111
- if (value === undefined) return undefined;
112
- const serialized = JSON.stringify(value);
113
- if (serialized === undefined) return undefined;
114
- return JSON_PARSE.runInContext(context)(serialized);
115
- };
116
-
117
- const cloneOutOfVm = (value) => {
118
- if (value === undefined) return undefined;
119
- const serialized = JSON.stringify(value);
120
- return serialized === undefined ? undefined : JSON.parse(serialized);
121
- };
122
-
123
- (async () => {
124
- try {
125
- const context = createContext(Object.create(null), {
126
- codeGeneration: { strings: false, wasm: false },
127
- });
128
- const bridgeFactory = BRIDGE_FACTORY.runInContext(context, { timeout: 1000 });
129
- const bridge = (name) =>
130
- bridgeFactory(createSafeHostCall((args) => callHost(name, args)));
131
- const vmAgent = bridge('agent');
132
- const vmPhase = bridge('phase');
133
- const vmLog = bridge('log');
134
- const vmWorkflow = bridge('workflow');
135
- const vmCatchPerItem = bridge('catchPerItem');
136
- const { parallel, pipeline } = FLOW_FACTORY.runInContext(context, { timeout: 1000 })(
137
- vmCatchPerItem
138
- );
139
- const fn = new Script(\`"use strict";\\n\${workerData.compiledFunctionSource}\\n; __wf\`, {
140
- filename: workerData.scriptPath,
141
- }).runInContext(context, { timeout: 1000 });
142
- const value = cloneOutOfVm(
143
- await fn(
144
- vmAgent,
145
- parallel,
146
- pipeline,
147
- vmPhase,
148
- vmLog,
149
- vmWorkflow,
150
- cloneIntoVm(context, workerData.args)
151
- )
152
- );
153
- parentPort.postMessage({ type: 'done', ok: true, value });
154
- } catch (error) {
155
- parentPort.postMessage({
156
- type: 'done',
157
- ok: false,
158
- error: error instanceof Error ? error.message : String(error),
159
- });
160
- }
161
- })();
162
- `;
13
+ const ACTIVE_AGENT_CALL_CLEANUP_TIMEOUT_MS = 5000;
163
14
  const errorToMessage = (error) => error instanceof Error ? error.message : String(error);
164
- const BUILT_IN_WORKER_ROLES = new Set(['coder', 'reviewer', 'tester', 'custom']);
165
- const isBuiltInWorkerRole = (value) => BUILT_IN_WORKER_ROLES.has(value);
166
- const buildModelArgs = (_cli, model) => {
167
- if (!model?.trim())
168
- return [];
169
- /* All supported CLIs accept
170
- `--model <id>` as a positional flag. Keeping the mapping centralised
171
- here so future CLI quirks (e.g. opencode wanting `-m` instead) can
172
- be patched without touching the runner body. */
173
- return ['--model', model];
174
- };
175
15
  const toNestedWorkflowFilename = (scriptName) => {
176
16
  const trimmed = scriptName.trim();
177
17
  if (!trimmed)
@@ -183,6 +23,7 @@ const toNestedWorkflowFilename = (scriptName) => {
183
23
  export const createWorkflowRunner = (deps) => {
184
24
  const { store, workflowRunStore, awaiter, dispatchPort, resolveWorkspacePath, roleTemplateResolver, logStore, resolveCliLaunchConfig, getWorkflowCliPolicy, } = deps;
185
25
  const stoppedRuns = new Set();
26
+ const abortedRuns = new Map();
186
27
  const activeScriptWorkers = new Map();
187
28
  // In-memory map: runId → triggering agent. Lost on restart; the spec already
188
29
  // doesn't auto-resume interrupted runs, so this is consistent.
@@ -191,50 +32,60 @@ export const createWorkflowRunner = (deps) => {
191
32
  const assertRunActive = (runId) => {
192
33
  if (isRunStopped(runId))
193
34
  throw new Error('Stopped by user');
35
+ const abortReason = abortedRuns.get(runId);
36
+ if (abortReason)
37
+ throw new Error(abortReason);
38
+ const current = workflowRunStore.getRun(runId);
39
+ if (current && current.status !== 'running') {
40
+ throw new Error(current.error || `Workflow run is ${current.status}`);
41
+ }
42
+ };
43
+ const cancelOpenDispatch = (workspaceId, dispatchId, reason) => {
44
+ const cancelled = dispatchPort.cancelOpenDispatchForRun(workspaceId, dispatchId, reason);
45
+ if (cancelled)
46
+ awaiter.notifyCancel(dispatchId, reason);
47
+ };
48
+ const cancelOpenDispatchesForRun = (runId, workspaceId, reason) => {
49
+ const errors = [];
50
+ for (const dispatchId of dispatchPort.listOpenDispatchIdsForRun(runId)) {
51
+ try {
52
+ cancelOpenDispatch(workspaceId, dispatchId, reason);
53
+ }
54
+ catch (error) {
55
+ errors.push(errorToMessage(error));
56
+ console.error('[hive] swallowed:workflow.cancelOpenDispatch', error);
57
+ }
58
+ }
59
+ return errors;
60
+ };
61
+ const appendTerminalRunError = (runId, message) => {
62
+ const current = workflowRunStore.getRun(runId);
63
+ if (!current || current.status === 'completed')
64
+ return;
65
+ const nextError = current.error ? `${current.error}; ${message}` : message;
66
+ workflowRunStore.updateRun(runId, { error: nextError });
194
67
  };
195
68
  const executeWorkflow = async (run, loaded, args, hivePort) => {
196
69
  const workspaceId = run.workspaceId;
197
70
  const workflowAgentId = getWorkflowAgentId(workspaceId);
198
- let stepCounter = 0;
199
- const spawnedWorkers = [];
200
- const activeAgentCalls = new Set();
201
71
  // Read the CLI policy once per run so a 1000-way fan-out doesn't hit the
202
72
  // app_state table per agent() call.
203
73
  const cliPolicy = getWorkflowCliPolicy();
74
+ let agentExecutor;
75
+ const dagLayerTracker = createWorkflowDagLayerTracker({
76
+ awaiter,
77
+ cancelQueuedAgentCallsForDagLayer: (layerId, reason) => agentExecutor.cancelQueuedAgentCallsForDagLayer(layerId, reason),
78
+ dispatchPort,
79
+ workspaceId,
80
+ });
204
81
  // TIER 2 #2 + #11 — runtime caps. Per-run agent ceiling, per-call
205
82
  // concurrency throttle, and a hard wall-clock budget. meta can
206
83
  // override the defaults per script. The budget timer arms here and
207
84
  // gets cleared in the finally branch so a fast-completing run
208
85
  // doesn't leave a dangling timer in the event loop.
209
- const maxAgents = typeof loaded.meta.maxAgentCalls === 'number' && loaded.meta.maxAgentCalls > 0
210
- ? loaded.meta.maxAgentCalls
211
- : DEFAULT_MAX_AGENTS_PER_RUN;
212
86
  const maxDurationMs = typeof loaded.meta.maxDurationMs === 'number' && loaded.meta.maxDurationMs > 0
213
87
  ? loaded.meta.maxDurationMs
214
88
  : DEFAULT_MAX_DURATION_MS;
215
- // Simple semaphore — a FIFO queue of resolvers. Each acquire() returns
216
- // a release() callback that pumps the next waiter. Default
217
- // concurrency is min(16, cores-2), matching CC's documented cap;
218
- // intentionally shared across all parallel/pipeline calls in this
219
- // run so nested fan-outs don't blow through the cap.
220
- const concurrencyLimit = DEFAULT_MAX_CONCURRENT_AGENTS;
221
- let inFlight = 0;
222
- const waitQueue = [];
223
- const acquireSlot = async () => {
224
- if (inFlight < concurrencyLimit) {
225
- inFlight++;
226
- return () => {
227
- inFlight--;
228
- waitQueue.shift()?.();
229
- };
230
- }
231
- await new Promise((resolve) => waitQueue.push(resolve));
232
- inFlight++;
233
- return () => {
234
- inFlight--;
235
- waitQueue.shift()?.();
236
- };
237
- };
238
89
  // R1/R2 auto-rounding: a phase('Find') called twice in the same run gets
239
90
  // rendered as "Find" then "Find R2" etc. Mirrors Claude Code's /workflows
240
91
  // view — keeps the UI readable when a workflow loops phases.
@@ -247,139 +98,27 @@ export const createWorkflowRunner = (deps) => {
247
98
  currentPhaseTitle = next > 1 ? `${trimmed} R${next}` : trimmed;
248
99
  workflowRunStore.updateRun(run.id, { phase: currentPhaseTitle });
249
100
  };
250
- const agent = async (prompt, opts = {}) => {
251
- let markAgentCallDone;
252
- const agentCallDone = new Promise((resolve) => {
253
- markAgentCallDone = resolve;
254
- });
255
- activeAgentCalls.add(agentCallDone);
256
- try {
257
- assertRunActive(run.id);
258
- // TIER 2 #11 — hard ceiling so a runaway `while (true) await agent()`
259
- // can't spawn unbounded PTY subprocesses. The check is BEFORE the
260
- // step counter increments so the error message names the cap, not
261
- // the over-cap step.
262
- if (stepCounter >= maxAgents) {
263
- throw new Error(`Workflow agent cap exceeded: ${maxAgents} calls (set meta.maxAgentCalls to raise)`);
264
- }
265
- const myStep = ++stepCounter;
266
- // TIER 2 #4 — resolve agentType. If it's a built-in WorkerRole
267
- // (coder/reviewer/tester/custom) we honour it directly with the
268
- // CLI default. Otherwise look up a workspace custom role template
269
- // by name: on hit we clone its command + args; on miss we throw a
270
- // clear error (silent fallback to 'coder' would mask typos and
271
- // make the Hive-distinctive custom-role library invisible).
272
- const requestedType = opts.agentType ?? 'coder';
273
- let role;
274
- let command;
275
- let templateArgs = [];
276
- if (typeof requestedType === 'string' && !isBuiltInWorkerRole(requestedType)) {
277
- const template = roleTemplateResolver.findByName(requestedType);
278
- if (!template) {
279
- throw new Error(`Workflow agentType '${requestedType}' is not a built-in role (coder/reviewer/tester/custom) and no matching role template exists in this workspace. ` +
280
- `Use a built-in role or an existing dispatchable role template.`);
281
- }
282
- if (template.roleType === 'sentinel') {
283
- throw new Error(`Workflow agentType '${requestedType}' resolves to a sentinel role, but sentinels are read-only observers and cannot receive workflow dispatches. Use coder, reviewer, tester, custom, or a dispatchable role template.`);
284
- }
285
- /* Templates can carry roleType='orchestrator' for the system-level
286
- Orchestrator template; that's not a valid workflow worker role,
287
- so we collapse it to 'custom'. Anything else maps through as-is. */
288
- role = template.roleType === 'orchestrator' ? 'custom' : template.roleType;
289
- command = resolveWorkflowCli({
290
- ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
291
- isCustomTemplate: true,
292
- templateDefaultCommand: template.defaultCommand,
293
- policy: cliPolicy,
294
- });
295
- templateArgs = template.defaultArgs;
296
- }
297
- else {
298
- role = requestedType;
299
- command = resolveWorkflowCli({
300
- ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
301
- isCustomTemplate: false,
302
- policy: cliPolicy,
303
- });
304
- }
305
- const name = opts.label ?? `${requestedType}-${myStep}-${randomUUID()}`;
306
- const baseLaunchConfig = resolveCliLaunchConfig(command) ?? { command, args: [] };
307
- /* Model flag goes AFTER the template's own args so an explicit
308
- opts.model overrides any --model the template baked in. */
309
- const launchArgs = [
310
- ...(baseLaunchConfig.args ?? []),
311
- ...templateArgs,
312
- ...buildModelArgs(baseLaunchConfig.command, opts.model),
313
- ];
314
- // TIER 2 #2 — semaphore. Holding the slot across the full
315
- // dispatch+await means a parallel(100) fan-out gets paced at
316
- // min(16, cores-2) concurrent PTYs instead of 100 simultaneous
317
- // process spawns.
318
- const releaseSlot = await acquireSlot();
319
- let worker;
320
- try {
321
- assertRunActive(run.id);
322
- worker = store.addWorkerWithLaunch(workspaceId, { name, role, ephemeral: true, spawnedBy: 'workflow' }, { ...baseLaunchConfig, args: launchArgs });
323
- spawnedWorkers.push(worker.id);
324
- assertRunActive(run.id);
325
- const liveRun = await store.startAgent(workspaceId, worker.id, { hivePort });
326
- await liveRun.postStartInputReady;
327
- assertRunActive(run.id);
328
- const dispatchPrompt = opts.outputSchema
329
- ? prompt + buildSchemaInstruction(opts.outputSchema)
330
- : prompt;
331
- const dispatch = await store.dispatchTaskByWorkerName(workspaceId, name, dispatchPrompt, {
332
- fromAgentId: workflowAgentId,
333
- hivePort,
334
- workflowRunId: run.id,
335
- stepIndex: myStep,
336
- ...(currentPhaseTitle ? { phase: currentPhaseTitle } : {}),
337
- label: opts.label ?? name,
338
- });
339
- assertRunActive(run.id);
340
- const report = await awaiter.awaitReport(dispatch.id, opts.timeoutMs);
341
- assertRunActive(run.id);
342
- // Structured output: hand back the parsed object, or { text } on a
343
- // parse miss so the script can still branch (treating an absent field
344
- // as the safe default).
345
- if (opts.outputSchema) {
346
- return extractJsonBlock(report.text) ?? { text: report.text };
347
- }
348
- return report.text;
349
- }
350
- finally {
351
- if (worker) {
352
- try {
353
- store.deleteWorker(workspaceId, worker.id);
354
- }
355
- catch {
356
- /* idempotent — worker may already be gone via cascade or boot cleanup */
357
- }
358
- const idx = spawnedWorkers.indexOf(worker.id);
359
- if (idx !== -1)
360
- spawnedWorkers.splice(idx, 1);
361
- }
362
- releaseSlot();
363
- }
364
- }
365
- finally {
366
- markAgentCallDone();
367
- activeAgentCalls.delete(agentCallDone);
368
- }
369
- };
370
- // TIER 1 #3 — per-item rejections become null (preserves the
371
- // documented contract: callers use `.filter(Boolean)` patterns), BUT
372
- // if the run is being stopped we re-throw so the outer catch handles
373
- // it as `status='stopped'` instead of silently completing with a
374
- // result array full of nulls. Per-item failures still get logged so
375
- // they're not entirely invisible (TIER 2 #3 will pipe these into the
376
- // run timeline via log()).
377
- const catchPerItem = (value) => {
378
- if (isRunStopped(run.id))
379
- throw value;
380
- console.warn(`[workflow ${loaded.meta.name}] item failed:`, value);
381
- return null;
382
- };
101
+ agentExecutor = createWorkflowAgentCallExecutor({
102
+ assertRunActive: () => assertRunActive(run.id),
103
+ awaiter,
104
+ cancelOpenDispatch: (dispatchId, reason) => cancelOpenDispatch(workspaceId, dispatchId, reason),
105
+ cliPolicy,
106
+ getCurrentPhaseTitle: () => currentPhaseTitle,
107
+ hivePort,
108
+ isRunStopped: () => isRunStopped(run.id),
109
+ ...(typeof loaded.meta.maxAgentCalls === 'number'
110
+ ? { maxAgentCalls: loaded.meta.maxAgentCalls }
111
+ : {}),
112
+ registerDagDispatch: dagLayerTracker.registerDispatch,
113
+ resolveCliLaunchConfig,
114
+ roleTemplateResolver,
115
+ runId: run.id,
116
+ store,
117
+ workflowAgentId,
118
+ workflowName: loaded.meta.name,
119
+ workspaceId,
120
+ });
121
+ const cancelDagLayerAgents = dagLayerTracker.cancelLayerAgents;
383
122
  const log = (message) => {
384
123
  assertRunActive(run.id);
385
124
  // TIER 2 #3 — persist + still echo to stdout for server-log
@@ -429,108 +168,22 @@ export const createWorkflowRunner = (deps) => {
429
168
  assertRunActive(run.id);
430
169
  return child;
431
170
  };
432
- const runScriptWorker = () => new Promise((resolve, reject) => {
433
- const worker = new Worker(WORKFLOW_VM_WORKER_SOURCE, {
434
- eval: true,
435
- workerData: {
436
- args,
437
- compiledFunctionSource: loaded.compiledFunctionSource,
438
- scriptPath: loaded.scriptPath,
439
- },
440
- });
441
- activeScriptWorkers.set(run.id, worker);
442
- let settled = false;
443
- const activeHostCalls = new Set();
444
- const settle = (fn) => {
445
- if (settled)
446
- return;
447
- settled = true;
448
- activeScriptWorkers.delete(run.id);
449
- void (async () => {
450
- await Promise.allSettled(activeHostCalls);
451
- fn();
452
- void worker.terminate().catch(() => { });
453
- })();
454
- };
455
- const respond = (id, response) => {
456
- try {
457
- worker.postMessage({ type: 'hostResponse', id, ...response });
458
- }
459
- catch {
460
- /* worker already terminated */
461
- }
462
- };
463
- worker.on('message', (message) => {
464
- const record = message;
465
- if (!record)
466
- return;
467
- if (record.type === 'done') {
468
- settle(() => {
469
- if (record.ok)
470
- resolve(record.value);
471
- else
472
- reject(new Error(record.error || 'Hive workflow script failed'));
473
- });
474
- return;
475
- }
476
- if (record.type !== 'hostCall' || typeof record.id !== 'string')
477
- return;
478
- const callId = record.id;
479
- if (settled) {
480
- respond(callId, { ok: false, error: 'Stopped by user' });
481
- return;
482
- }
483
- const hostCall = (async () => {
484
- try {
485
- assertRunActive(run.id);
486
- const callArgs = Array.isArray(record.args) ? record.args : [];
487
- let value;
488
- switch (record.name) {
489
- case 'agent': {
490
- const [prompt, opts] = callArgs;
491
- value = await agent(typeof prompt === 'string' ? prompt : String(prompt), (opts ?? {}));
492
- break;
493
- }
494
- case 'phase': {
495
- const [title] = callArgs;
496
- phase(typeof title === 'string' ? title : String(title ?? ''));
497
- break;
498
- }
499
- case 'log': {
500
- const [message] = callArgs;
501
- log(typeof message === 'string' ? message : String(message));
502
- break;
503
- }
504
- case 'workflow': {
505
- const [scriptName, childArgs] = callArgs;
506
- value = await workflow(typeof scriptName === 'string' ? scriptName : String(scriptName), childArgs);
507
- break;
508
- }
509
- case 'catchPerItem': {
510
- const [item] = callArgs;
511
- value = catchPerItem(item);
512
- break;
513
- }
514
- default:
515
- throw new Error(`Unknown workflow host call: ${record.name}`);
516
- }
517
- assertRunActive(run.id);
518
- respond(callId, { ok: true, value });
519
- }
520
- catch (error) {
521
- respond(callId, { ok: false, error: errorToMessage(error) });
522
- }
523
- })();
524
- activeHostCalls.add(hostCall);
525
- void hostCall.finally(() => activeHostCalls.delete(hostCall));
526
- });
527
- worker.on('error', (error) => settle(() => reject(error)));
528
- worker.on('exit', (code) => {
529
- if (settled)
530
- return;
531
- activeScriptWorkers.delete(run.id);
532
- reject(new Error(`Hive workflow VM worker exited before completion (code ${code})`));
533
- });
171
+ const runScriptWorker = () => runWorkflowScriptWorker({
172
+ args,
173
+ compiledFunctionSource: loaded.compiledFunctionSource,
174
+ scriptPath: loaded.scriptPath,
175
+ workerSource: WORKFLOW_VM_WORKER_URL,
176
+ onWorkerCreated: (worker) => activeScriptWorkers.set(run.id, worker),
177
+ onWorkerSettled: () => activeScriptWorkers.delete(run.id),
178
+ handlers: {
179
+ agent: agentExecutor.agent,
180
+ phase,
181
+ log,
182
+ workflow,
183
+ catchPerItem: agentExecutor.catchPerItem,
184
+ cancelDagLayerAgents,
185
+ assertRunActive: () => assertRunActive(run.id),
186
+ },
534
187
  });
535
188
  // TIER 2 #11 — wall-clock budget timer. Triggers stopRun on
536
189
  // expiry, which routes through the same path as a user-initiated
@@ -547,6 +200,10 @@ export const createWorkflowRunner = (deps) => {
547
200
  try {
548
201
  assertRunActive(run.id);
549
202
  const returnValue = await runScriptWorker();
203
+ const abortReason = abortedRuns.get(run.id);
204
+ if (abortReason) {
205
+ throw new Error(abortReason);
206
+ }
550
207
  // TIER 1 #2 — if stop was called DURING the run, parallel/pipeline may
551
208
  // have caught the cancel rejections (one per in-flight thunk) before
552
209
  // the per-item catch could re-throw, e.g. when the user stops AFTER
@@ -580,7 +237,8 @@ export const createWorkflowRunner = (deps) => {
580
237
  const wasStopped = workflowRunStore.getRun(run.id)?.status === 'stopped';
581
238
  if (wasStopped)
582
239
  return;
583
- const message = error instanceof Error ? error.message : String(error);
240
+ const message = errorToMessage(error);
241
+ abortedRuns.set(run.id, message);
584
242
  workflowRunStore.updateRun(run.id, {
585
243
  status: 'failed',
586
244
  finishedAt: Date.now(),
@@ -589,18 +247,29 @@ export const createWorkflowRunner = (deps) => {
589
247
  }
590
248
  finally {
591
249
  clearTimeout(budgetTimer);
592
- await Promise.allSettled(activeAgentCalls);
250
+ const finalRecord = workflowRunStore.getRun(run.id);
251
+ let terminalCleanup = false;
252
+ if (finalRecord && finalRecord.status !== 'completed') {
253
+ terminalCleanup = true;
254
+ const cleanupReason = finalRecord.error || abortedRuns.get(run.id) || 'Workflow run stopped';
255
+ agentExecutor.forceCancelActiveDispatchWaiters(cleanupReason);
256
+ const cancelErrors = cancelOpenDispatchesForRun(run.id, finalRecord.workspaceId, cleanupReason);
257
+ if (cancelErrors.length > 0) {
258
+ appendTerminalRunError(run.id, `cleanup failed to cancel ${cancelErrors.length} workflow dispatch${cancelErrors.length === 1 ? '' : 'es'}: ${cancelErrors.join('; ')}`);
259
+ }
260
+ }
261
+ const activeCalls = await agentExecutor.waitForActiveCalls(terminalCleanup ? ACTIVE_AGENT_CALL_CLEANUP_TIMEOUT_MS : undefined);
262
+ if (!activeCalls.settled) {
263
+ const message = `cleanup timed out after ${ACTIVE_AGENT_CALL_CLEANUP_TIMEOUT_MS}ms waiting for ${activeCalls.activeCount} active workflow agent call${activeCalls.activeCount === 1 ? '' : 's'} to settle`;
264
+ console.error(`[hive] workflow.${message}`);
265
+ appendTerminalRunError(run.id, message);
266
+ agentExecutor.forceCancelActiveDispatchWaiters(message);
267
+ await agentExecutor.waitForActiveCalls(1000);
268
+ }
593
269
  // Belt-and-suspenders: dismiss any ephemeral worker still alive. The
594
270
  // per-call try/finally should already have cleaned each one up; this is
595
271
  // an idempotent safety net for unexpected paths.
596
- for (const workerId of spawnedWorkers.splice(0)) {
597
- try {
598
- store.deleteWorker(workspaceId, workerId);
599
- }
600
- catch {
601
- /* swallow */
602
- }
603
- }
272
+ agentExecutor.deleteSpawnedWorkers();
604
273
  // Notify the triggering agent (orchestrator) that the run reached a
605
274
  // terminal state. Mirrors Claude Code's <task-notification> envelope.
606
275
  const triggeredByAgentId = triggeringAgentByRun.get(run.id);
@@ -617,6 +286,7 @@ export const createWorkflowRunner = (deps) => {
617
286
  }
618
287
  }
619
288
  stoppedRuns.delete(run.id);
289
+ abortedRuns.delete(run.id);
620
290
  }
621
291
  };
622
292
  const buildCreateInput = (input, loaded) => {
@@ -694,9 +364,7 @@ export const createWorkflowRunner = (deps) => {
694
364
  // Cancel every open workflow dispatch tied to this run; this rejects the
695
365
  // runner's pending `awaitReport` promises, which propagates up the
696
366
  // executeWorkflow try → its catch sets status='stopped'.
697
- for (const dispatchId of dispatchPort.listOpenDispatchIdsForRun(runId)) {
698
- awaiter.notifyCancel(dispatchId, 'Stopped by user');
699
- }
367
+ cancelOpenDispatchesForRun(runId, current.workspaceId, 'Stopped by user');
700
368
  for (const child of workflowRunStore.listChildRuns(runId)) {
701
369
  if (child.status === 'running')
702
370
  stopRunAndChildren(child.id, visited);
@@ -0,0 +1,14 @@
1
+ export interface WorkflowScriptHostHandlers<AgentOptions> {
2
+ agent: (prompt: string, opts: AgentOptions) => Promise<unknown>;
3
+ assertRunActive: () => void;
4
+ cancelDagLayerAgents: (layerId: unknown, reason: unknown) => void;
5
+ catchPerItem: (value: unknown) => unknown;
6
+ log: (message: string) => void;
7
+ phase: (title: string) => void;
8
+ workflow: (scriptName: string, childArgs?: unknown) => Promise<unknown>;
9
+ }
10
+ export interface WorkflowScriptHostDispatchResult {
11
+ skipPostActiveAssert?: boolean;
12
+ value?: unknown;
13
+ }
14
+ export declare const dispatchWorkflowScriptHostCall: <AgentOptions>(name: string | undefined, callArgs: unknown[], handlers: WorkflowScriptHostHandlers<AgentOptions>) => Promise<WorkflowScriptHostDispatchResult>;