@tt-a1i/hive 2.0.2 → 2.1.0

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 (147) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +15 -6
  3. package/README.md +26 -4
  4. package/dist/src/cli/hive.d.ts +4 -0
  5. package/dist/src/cli/hive.js +25 -3
  6. package/dist/src/cli/team.d.ts +8 -1
  7. package/dist/src/cli/team.js +111 -11
  8. package/dist/src/server/action-center-summary.d.ts +193 -0
  9. package/dist/src/server/action-center-summary.js +188 -0
  10. package/dist/src/server/agent-command-resolver.d.ts +6 -0
  11. package/dist/src/server/agent-command-resolver.js +16 -0
  12. package/dist/src/server/agent-manager.js +11 -1
  13. package/dist/src/server/agent-run-starter.js +47 -6
  14. package/dist/src/server/agent-runtime-types.d.ts +4 -0
  15. package/dist/src/server/agent-startup-instructions.d.ts +4 -0
  16. package/dist/src/server/agent-startup-instructions.js +35 -9
  17. package/dist/src/server/agent-stdin-dispatcher.js +17 -9
  18. package/dist/src/server/diagnostics-support-bundle.d.ts +288 -0
  19. package/dist/src/server/diagnostics-support-bundle.js +179 -0
  20. package/dist/src/server/dispatch-ledger-store.d.ts +4 -1
  21. package/dist/src/server/dispatch-ledger-store.js +46 -6
  22. package/dist/src/server/hive-envelope-escape.d.ts +2 -0
  23. package/dist/src/server/hive-envelope-escape.js +2 -0
  24. package/dist/src/server/hive-team-guidance.d.ts +1 -1
  25. package/dist/src/server/hive-team-guidance.js +67 -25
  26. package/dist/src/server/message-log-store.d.ts +1 -1
  27. package/dist/src/server/post-start-input-writer.js +8 -2
  28. package/dist/src/server/preset-launch-support.d.ts +2 -0
  29. package/dist/src/server/preset-launch-support.js +65 -2
  30. package/dist/src/server/protocol-event-stats.d.ts +39 -0
  31. package/dist/src/server/protocol-event-stats.js +84 -0
  32. package/dist/src/server/recovery-summary.js +19 -14
  33. package/dist/src/server/role-template-store.d.ts +1 -1
  34. package/dist/src/server/role-templates.d.ts +1 -0
  35. package/dist/src/server/role-templates.js +43 -29
  36. package/dist/src/server/routes-action-center.d.ts +2 -0
  37. package/dist/src/server/routes-action-center.js +37 -0
  38. package/dist/src/server/routes-diagnostics.d.ts +2 -0
  39. package/dist/src/server/routes-diagnostics.js +17 -0
  40. package/dist/src/server/routes-scenarios.d.ts +25 -0
  41. package/dist/src/server/routes-scenarios.js +89 -0
  42. package/dist/src/server/routes-settings.js +2 -11
  43. package/dist/src/server/routes-team-memory.js +52 -0
  44. package/dist/src/server/routes-team.js +40 -20
  45. package/dist/src/server/routes-workspace-memory-dreams.js +8 -0
  46. package/dist/src/server/routes-workspace-uploads.d.ts +2 -0
  47. package/dist/src/server/routes-workspace-uploads.js +154 -0
  48. package/dist/src/server/routes-workspaces.js +29 -3
  49. package/dist/src/server/routes.js +8 -0
  50. package/dist/src/server/runtime-message-builders.d.ts +0 -1
  51. package/dist/src/server/runtime-message-builders.js +0 -8
  52. package/dist/src/server/runtime-store-contract.d.ts +15 -0
  53. package/dist/src/server/runtime-store-dream.d.ts +14 -1
  54. package/dist/src/server/runtime-store-dream.js +49 -1
  55. package/dist/src/server/runtime-store-helpers.d.ts +7 -0
  56. package/dist/src/server/runtime-store-helpers.js +85 -22
  57. package/dist/src/server/runtime-store-worker-mutations.d.ts +11 -0
  58. package/dist/src/server/runtime-store-worker-mutations.js +46 -0
  59. package/dist/src/server/runtime-store-workflows.js +10 -6
  60. package/dist/src/server/runtime-store.js +34 -42
  61. package/dist/src/server/scenario-presets.d.ts +25 -0
  62. package/dist/src/server/scenario-presets.js +35 -0
  63. package/dist/src/server/sentinel-heartbeat.d.ts +30 -0
  64. package/dist/src/server/sentinel-heartbeat.js +145 -0
  65. package/dist/src/server/spawn-cli-resolver.d.ts +37 -0
  66. package/dist/src/server/spawn-cli-resolver.js +70 -0
  67. package/dist/src/server/spawn-worker-defaults.d.ts +13 -0
  68. package/dist/src/server/spawn-worker-defaults.js +45 -0
  69. package/dist/src/server/sqlite-schema-v32.d.ts +2 -0
  70. package/dist/src/server/sqlite-schema-v32.js +17 -0
  71. package/dist/src/server/sqlite-schema-v33.d.ts +3 -0
  72. package/dist/src/server/sqlite-schema-v33.js +18 -0
  73. package/dist/src/server/sqlite-schema-v34.d.ts +11 -0
  74. package/dist/src/server/sqlite-schema-v34.js +19 -0
  75. package/dist/src/server/sqlite-schema-v35.d.ts +3 -0
  76. package/dist/src/server/sqlite-schema-v35.js +23 -0
  77. package/dist/src/server/sqlite-schema.d.ts +1 -1
  78. package/dist/src/server/sqlite-schema.js +35 -1
  79. package/dist/src/server/system-message.d.ts +5 -2
  80. package/dist/src/server/system-message.js +5 -2
  81. package/dist/src/server/tasks-file-watcher.d.ts +8 -0
  82. package/dist/src/server/tasks-file-watcher.js +31 -2
  83. package/dist/src/server/team-authz.d.ts +9 -1
  84. package/dist/src/server/team-authz.js +24 -0
  85. package/dist/src/server/team-list-serializer.d.ts +2 -2
  86. package/dist/src/server/team-list-serializer.js +2 -1
  87. package/dist/src/server/team-memory-digest.js +4 -4
  88. package/dist/src/server/team-memory-dream-applier.js +24 -3
  89. package/dist/src/server/team-memory-dream-prompt.d.ts +13 -0
  90. package/dist/src/server/team-memory-dream-prompt.js +91 -0
  91. package/dist/src/server/team-memory-dream-run-store.d.ts +2 -0
  92. package/dist/src/server/team-memory-dream-run-store.js +14 -4
  93. package/dist/src/server/team-memory-dream-runner.d.ts +2 -21
  94. package/dist/src/server/team-memory-dream-runner.js +3 -148
  95. package/dist/src/server/team-memory-dream-store.d.ts +1 -1
  96. package/dist/src/server/team-memory-dream-store.js +1 -1
  97. package/dist/src/server/team-operations.d.ts +18 -2
  98. package/dist/src/server/team-operations.js +222 -33
  99. package/dist/src/server/team-recap.d.ts +10 -0
  100. package/dist/src/server/team-recap.js +73 -0
  101. package/dist/src/server/terminal-input-profile.js +88 -9
  102. package/dist/src/server/upload-limits.d.ts +2 -0
  103. package/dist/src/server/upload-limits.js +2 -0
  104. package/dist/src/server/workflow-cli-policy.d.ts +7 -2
  105. package/dist/src/server/workflow-cli-policy.js +15 -3
  106. package/dist/src/server/workflow-run-store.d.ts +1 -0
  107. package/dist/src/server/workflow-run-store.js +11 -1
  108. package/dist/src/server/workflow-runner.d.ts +4 -1
  109. package/dist/src/server/workflow-runner.js +418 -118
  110. package/dist/src/server/workflow-script-loader.d.ts +3 -2
  111. package/dist/src/server/workflow-script-loader.js +161 -0
  112. package/dist/src/server/workspace-store-contract.d.ts +2 -0
  113. package/dist/src/server/workspace-store.d.ts +1 -1
  114. package/dist/src/server/workspace-store.js +40 -30
  115. package/dist/src/server/workspace-upload-store.d.ts +40 -0
  116. package/dist/src/server/workspace-upload-store.js +295 -0
  117. package/dist/src/shared/scenario-presets.d.ts +32 -0
  118. package/dist/src/shared/scenario-presets.js +69 -0
  119. package/dist/src/shared/types.d.ts +12 -1
  120. package/package.json +1 -1
  121. package/web/dist/assets/AddWorkerDialog-DBLhwb91.js +2 -0
  122. package/web/dist/assets/AddWorkspaceFlow-cxvhVAsT.js +1 -0
  123. package/web/dist/assets/FirstRunWizard-DlEPnWWw.js +1 -0
  124. package/web/dist/assets/{MarketplaceDrawer-Dd8WIA8T.js → MarketplaceDrawer-CfSiRi8e.js} +11 -11
  125. package/web/dist/assets/TaskGraphDrawer-C2JufcPs.js +1 -0
  126. package/web/dist/assets/WhatsNewDialog-vP7buLos.js +1 -0
  127. package/web/dist/assets/WorkerModal-CSorwcdP.js +1 -0
  128. package/web/dist/assets/{WorkflowsDrawer-Bjf4olbR.js → WorkflowsDrawer-BXS3w9Uq.js} +1 -1
  129. package/web/dist/assets/WorkspaceMemoryDrawer-D71ivohr.js +1 -0
  130. package/web/dist/assets/{WorkspaceTaskDrawer-BIWwISvA.js → WorkspaceTaskDrawer-CGCTSHKa.js} +1 -1
  131. package/web/dist/assets/index-BcwN8cCw.js +79 -0
  132. package/web/dist/assets/index-StXTPHls.css +1 -0
  133. package/web/dist/assets/{search-Bk2HQvO7.js → search-BZw4T67h.js} +1 -1
  134. package/web/dist/assets/{square-terminal-D93m9hfY.js → square-terminal-B7E57In1.js} +1 -1
  135. package/web/dist/index.html +2 -2
  136. package/web/dist/sw.js +1 -1
  137. package/dist/src/server/env-sync-message.d.ts +0 -9
  138. package/dist/src/server/env-sync-message.js +0 -29
  139. package/web/dist/assets/AddWorkerDialog-CbV75qUX.js +0 -2
  140. package/web/dist/assets/AddWorkspaceFlow-CwV-7wPx.js +0 -1
  141. package/web/dist/assets/FirstRunWizard-a6PWIK3x.js +0 -1
  142. package/web/dist/assets/TaskGraphDrawer-Bk5WFIk_.js +0 -1
  143. package/web/dist/assets/WhatsNewDialog-C2VZaip0.js +0 -1
  144. package/web/dist/assets/WorkerModal-DucW-9YT.js +0 -1
  145. package/web/dist/assets/WorkspaceMemoryDrawer-DglCy_5f.js +0 -1
  146. package/web/dist/assets/index-BAiLYajK.css +0 -1
  147. package/web/dist/assets/index-BV2k9Dts.js +0 -73
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { cpus } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
+ import { Worker } from 'node:worker_threads';
4
5
  import { assertWindowsSafeFilename } from './windows-filename.js';
5
6
  import { resolveWorkflowCli } from './workflow-cli-policy.js';
6
7
  import { buildSchemaInstruction, extractJsonBlock } from './workflow-output-schema.js';
@@ -14,6 +15,152 @@ import { getWorkflowAgentId } from './workspace-store-support.js';
14
15
  const DEFAULT_MAX_AGENTS_PER_RUN = 1000;
15
16
  const DEFAULT_MAX_DURATION_MS = 60 * 60 * 1000;
16
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
+ `;
163
+ const errorToMessage = (error) => error instanceof Error ? error.message : String(error);
17
164
  const BUILT_IN_WORKER_ROLES = new Set(['coder', 'reviewer', 'tester', 'custom']);
18
165
  const isBuiltInWorkerRole = (value) => BUILT_IN_WORKER_ROLES.has(value);
19
166
  const buildModelArgs = (_cli, model) => {
@@ -36,14 +183,21 @@ const toNestedWorkflowFilename = (scriptName) => {
36
183
  export const createWorkflowRunner = (deps) => {
37
184
  const { store, workflowRunStore, awaiter, dispatchPort, resolveWorkspacePath, roleTemplateResolver, logStore, resolveCliLaunchConfig, getWorkflowCliPolicy, } = deps;
38
185
  const stoppedRuns = new Set();
186
+ const activeScriptWorkers = new Map();
39
187
  // In-memory map: runId → triggering agent. Lost on restart; the spec already
40
188
  // doesn't auto-resume interrupted runs, so this is consistent.
41
189
  const triggeringAgentByRun = new Map();
190
+ const isRunStopped = (runId) => stoppedRuns.has(runId) || workflowRunStore.getRun(runId)?.status === 'stopped';
191
+ const assertRunActive = (runId) => {
192
+ if (isRunStopped(runId))
193
+ throw new Error('Stopped by user');
194
+ };
42
195
  const executeWorkflow = async (run, loaded, args, hivePort) => {
43
196
  const workspaceId = run.workspaceId;
44
197
  const workflowAgentId = getWorkflowAgentId(workspaceId);
45
198
  let stepCounter = 0;
46
199
  const spawnedWorkers = [];
200
+ const activeAgentCalls = new Set();
47
201
  // Read the CLI policy once per run so a 1000-way fan-out doesn't hit the
48
202
  // app_state table per agent() call.
49
203
  const cliPolicy = getWorkflowCliPolicy();
@@ -94,99 +248,123 @@ export const createWorkflowRunner = (deps) => {
94
248
  workflowRunStore.updateRun(run.id, { phase: currentPhaseTitle });
95
249
  };
96
250
  const agent = async (prompt, opts = {}) => {
97
- // TIER 2 #11 — hard ceiling so a runaway `while (true) await agent()`
98
- // can't spawn unbounded PTY subprocesses. The check is BEFORE the
99
- // step counter increments so the error message names the cap, not
100
- // the over-cap step.
101
- if (stepCounter >= maxAgents) {
102
- throw new Error(`Workflow agent cap exceeded: ${maxAgents} calls (set meta.maxAgentCalls to raise)`);
103
- }
104
- const myStep = ++stepCounter;
105
- // TIER 2 #4 — resolve agentType. If it's a built-in WorkerRole
106
- // (coder/reviewer/tester/custom) we honour it directly with the
107
- // CLI default. Otherwise look up a workspace custom role template
108
- // by name: on hit we clone its command + args; on miss we throw a
109
- // clear error (silent fallback to 'coder' would mask typos and
110
- // make the Hive-distinctive custom-role library invisible).
111
- const requestedType = opts.agentType ?? 'coder';
112
- let role;
113
- let command;
114
- let templateArgs = [];
115
- if (typeof requestedType === 'string' && !isBuiltInWorkerRole(requestedType)) {
116
- const template = roleTemplateResolver.findByName(requestedType);
117
- if (!template) {
118
- 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. ` +
119
- `Create one via Add Worker → custom role, or use a built-in role.`);
120
- }
121
- /* Templates can carry roleType='orchestrator' for the system-level
122
- Orchestrator template; that's not a valid workflow worker role,
123
- so we collapse it to 'custom'. Anything else maps through as-is. */
124
- role = template.roleType === 'orchestrator' ? 'custom' : template.roleType;
125
- command = resolveWorkflowCli({
126
- ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
127
- isCustomTemplate: true,
128
- templateDefaultCommand: template.defaultCommand,
129
- policy: cliPolicy,
130
- });
131
- templateArgs = template.defaultArgs;
132
- }
133
- else {
134
- role = requestedType;
135
- command = resolveWorkflowCli({
136
- ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
137
- isCustomTemplate: false,
138
- policy: cliPolicy,
139
- });
140
- }
141
- const name = opts.label ?? `${requestedType}-${myStep}-${randomUUID()}`;
142
- const baseLaunchConfig = resolveCliLaunchConfig(command) ?? { command, args: [] };
143
- /* Model flag goes AFTER the template's own args so an explicit
144
- opts.model overrides any --model the template baked in. */
145
- const launchArgs = [
146
- ...(baseLaunchConfig.args ?? []),
147
- ...templateArgs,
148
- ...buildModelArgs(baseLaunchConfig.command, opts.model),
149
- ];
150
- // TIER 2 #2 — semaphore. Holding the slot across the full
151
- // dispatch+await means a parallel(100) fan-out gets paced at
152
- // min(16, cores-2) concurrent PTYs instead of 100 simultaneous
153
- // process spawns.
154
- const releaseSlot = await acquireSlot();
155
- const worker = store.addWorkerWithLaunch(workspaceId, { name, role, ephemeral: true, spawnedBy: 'workflow' }, { ...baseLaunchConfig, args: launchArgs });
156
- spawnedWorkers.push(worker.id);
251
+ let markAgentCallDone;
252
+ const agentCallDone = new Promise((resolve) => {
253
+ markAgentCallDone = resolve;
254
+ });
255
+ activeAgentCalls.add(agentCallDone);
157
256
  try {
158
- await store.startAgent(workspaceId, worker.id, { hivePort });
159
- const dispatchPrompt = opts.outputSchema
160
- ? prompt + buildSchemaInstruction(opts.outputSchema)
161
- : prompt;
162
- const dispatch = await store.dispatchTaskByWorkerName(workspaceId, name, dispatchPrompt, {
163
- fromAgentId: workflowAgentId,
164
- hivePort,
165
- workflowRunId: run.id,
166
- stepIndex: myStep,
167
- ...(currentPhaseTitle ? { phase: currentPhaseTitle } : {}),
168
- label: opts.label ?? name,
169
- });
170
- const report = await awaiter.awaitReport(dispatch.id, opts.timeoutMs);
171
- // Structured output: hand back the parsed object, or { text } on a
172
- // parse miss so the script can still branch (treating an absent field
173
- // as the safe default).
174
- if (opts.outputSchema) {
175
- return extractJsonBlock(report.text) ?? { text: report.text };
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)`);
176
264
  }
177
- return report.text;
178
- }
179
- finally {
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;
180
320
  try {
181
- store.deleteWorker(workspaceId, worker.id);
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;
182
349
  }
183
- catch {
184
- /* idempotent — worker may already be gone via cascade or boot cleanup */
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();
185
363
  }
186
- const idx = spawnedWorkers.indexOf(worker.id);
187
- if (idx !== -1)
188
- spawnedWorkers.splice(idx, 1);
189
- releaseSlot();
364
+ }
365
+ finally {
366
+ markAgentCallDone();
367
+ activeAgentCalls.delete(agentCallDone);
190
368
  }
191
369
  };
192
370
  // TIER 1 #3 — per-item rejections become null (preserves the
@@ -197,24 +375,13 @@ export const createWorkflowRunner = (deps) => {
197
375
  // they're not entirely invisible (TIER 2 #3 will pipe these into the
198
376
  // run timeline via log()).
199
377
  const catchPerItem = (value) => {
200
- if (stoppedRuns.has(run.id))
378
+ if (isRunStopped(run.id))
201
379
  throw value;
202
380
  console.warn(`[workflow ${loaded.meta.name}] item failed:`, value);
203
381
  return null;
204
382
  };
205
- const parallel = async (thunks) => {
206
- return Promise.all(thunks.map((thunk) => thunk().catch((err) => catchPerItem(err))));
207
- };
208
- const pipeline = async (items, ...stages) => {
209
- return Promise.all(items.map((item, index) => {
210
- let chain = Promise.resolve(item);
211
- for (const stage of stages) {
212
- chain = chain.then((prev) => stage(prev, item, index));
213
- }
214
- return chain.catch((err) => catchPerItem(err));
215
- }));
216
- };
217
383
  const log = (message) => {
384
+ assertRunActive(run.id);
218
385
  // TIER 2 #3 — persist + still echo to stdout for server-log
219
386
  // visibility. Authors expect `log()` to surface in the Drawer's
220
387
  // narrator lane and in the orchestrator's completion reminder.
@@ -242,6 +409,7 @@ export const createWorkflowRunner = (deps) => {
242
409
  // sibling-script location.
243
410
  const isSyntheticParentPath = run.scriptPath.startsWith('<inline');
244
411
  const workflow = async (scriptName, childArgs) => {
412
+ assertRunActive(run.id);
245
413
  if (typeof scriptName !== 'string' || !scriptName.trim()) {
246
414
  throw new Error('workflow(scriptName): scriptName must be a non-empty string');
247
415
  }
@@ -249,7 +417,7 @@ export const createWorkflowRunner = (deps) => {
249
417
  const childPath = isSyntheticParentPath
250
418
  ? join(resolveWorkspacePath(workspaceId), '.hive', 'workflows', filename)
251
419
  : join(dirname(run.scriptPath), filename);
252
- return runWorkflow({
420
+ const child = await runWorkflow({
253
421
  workspaceId,
254
422
  scriptPath: childPath,
255
423
  hivePort,
@@ -258,7 +426,112 @@ export const createWorkflowRunner = (deps) => {
258
426
  parentRunId: run.id,
259
427
  ...(childArgs !== undefined ? { args: childArgs } : {}),
260
428
  });
429
+ assertRunActive(run.id);
430
+ return child;
261
431
  };
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
+ });
534
+ });
262
535
  // TIER 2 #11 — wall-clock budget timer. Triggers stopRun on
263
536
  // expiry, which routes through the same path as a user-initiated
264
537
  // stop (in-flight awaiters reject; outer catch records 'stopped').
@@ -272,9 +545,8 @@ export const createWorkflowRunner = (deps) => {
272
545
  }, maxDurationMs);
273
546
  budgetTimer.unref?.();
274
547
  try {
275
- const factory = new Function(`${loaded.compiledFunctionSource}; return __wf`);
276
- const fn = factory();
277
- const returnValue = await fn(agent, parallel, pipeline, phase, log, workflow, args);
548
+ assertRunActive(run.id);
549
+ const returnValue = await runScriptWorker();
278
550
  // TIER 1 #2 — if stop was called DURING the run, parallel/pipeline may
279
551
  // have caught the cancel rejections (one per in-flight thunk) before
280
552
  // the per-item catch could re-throw, e.g. when the user stops AFTER
@@ -283,8 +555,7 @@ export const createWorkflowRunner = (deps) => {
283
555
  // degraded result (often a list of nulls), which both lies to the UI
284
556
  // and lies to the orchestrator's completion notification. Check the
285
557
  // marker after fn returns and record the truth instead.
286
- if (stoppedRuns.has(run.id)) {
287
- stoppedRuns.delete(run.id);
558
+ if (stoppedRuns.has(run.id) || workflowRunStore.getRun(run.id)?.status === 'stopped') {
288
559
  workflowRunStore.updateRun(run.id, {
289
560
  status: 'stopped',
290
561
  finishedAt: Date.now(),
@@ -292,7 +563,6 @@ export const createWorkflowRunner = (deps) => {
292
563
  });
293
564
  }
294
565
  else {
295
- stoppedRuns.delete(run.id);
296
566
  // M10: capture the script's return value so the UI can render a single
297
567
  // canonical "Result" panel and the orchestrator notification can quote
298
568
  // it. `undefined` (no explicit return) stays null on the row.
@@ -304,17 +574,22 @@ export const createWorkflowRunner = (deps) => {
304
574
  }
305
575
  }
306
576
  catch (error) {
307
- const wasStopped = stoppedRuns.has(run.id);
577
+ if (stoppedRuns.has(run.id)) {
578
+ return;
579
+ }
580
+ const wasStopped = workflowRunStore.getRun(run.id)?.status === 'stopped';
581
+ if (wasStopped)
582
+ return;
308
583
  const message = error instanceof Error ? error.message : String(error);
309
584
  workflowRunStore.updateRun(run.id, {
310
- status: wasStopped ? 'stopped' : 'failed',
585
+ status: 'failed',
311
586
  finishedAt: Date.now(),
312
- error: wasStopped ? 'Stopped by user' : message,
587
+ error: message,
313
588
  });
314
- stoppedRuns.delete(run.id);
315
589
  }
316
590
  finally {
317
591
  clearTimeout(budgetTimer);
592
+ await Promise.allSettled(activeAgentCalls);
318
593
  // Belt-and-suspenders: dismiss any ephemeral worker still alive. The
319
594
  // per-call try/finally should already have cleaned each one up; this is
320
595
  // an idempotent safety net for unexpected paths.
@@ -341,6 +616,7 @@ export const createWorkflowRunner = (deps) => {
341
616
  }
342
617
  }
343
618
  }
619
+ stoppedRuns.delete(run.id);
344
620
  }
345
621
  };
346
622
  const buildCreateInput = (input, loaded) => {
@@ -360,8 +636,17 @@ export const createWorkflowRunner = (deps) => {
360
636
  if (triggeredByAgentId)
361
637
  triggeringAgentByRun.set(runId, triggeredByAgentId);
362
638
  };
639
+ const assertParentRunStillRunning = (input) => {
640
+ if (!input.parentRunId)
641
+ return;
642
+ const parent = workflowRunStore.getRun(input.parentRunId);
643
+ if (stoppedRuns.has(input.parentRunId) || parent?.status === 'stopped') {
644
+ throw new Error('Stopped by user');
645
+ }
646
+ };
363
647
  const runWorkflow = async (input) => {
364
648
  const loaded = await loadWorkflowScriptFile(input.scriptPath);
649
+ assertParentRunStillRunning(input);
365
650
  const run = workflowRunStore.createRun(buildCreateInput(input, loaded));
366
651
  rememberTrigger(run.id, input.triggeredByAgentId);
367
652
  await executeWorkflow(run, loaded, input.args, input.hivePort);
@@ -372,6 +657,7 @@ export const createWorkflowRunner = (deps) => {
372
657
  };
373
658
  const startWorkflow = async (input) => {
374
659
  const loaded = await loadWorkflowScriptFile(input.scriptPath);
660
+ assertParentRunStillRunning(input);
375
661
  const run = workflowRunStore.createRun(buildCreateInput(input, loaded));
376
662
  rememberTrigger(run.id, input.triggeredByAgentId);
377
663
  queueMicrotask(() => {
@@ -393,24 +679,38 @@ export const createWorkflowRunner = (deps) => {
393
679
  });
394
680
  return run;
395
681
  };
396
- const stopRun = (runId) => {
682
+ const stopRunAndChildren = (runId, visited) => {
683
+ if (visited.has(runId))
684
+ return false;
685
+ visited.add(runId);
397
686
  const current = workflowRunStore.getRun(runId);
398
687
  if (!current || current.status !== 'running')
399
688
  return false;
400
689
  stoppedRuns.add(runId);
690
+ void activeScriptWorkers
691
+ .get(runId)
692
+ ?.terminate()
693
+ .catch(() => { });
401
694
  // Cancel every open workflow dispatch tied to this run; this rejects the
402
695
  // runner's pending `awaitReport` promises, which propagates up the
403
696
  // executeWorkflow try → its catch sets status='stopped'.
404
697
  for (const dispatchId of dispatchPort.listOpenDispatchIdsForRun(runId)) {
405
698
  awaiter.notifyCancel(dispatchId, 'Stopped by user');
406
699
  }
700
+ for (const child of workflowRunStore.listChildRuns(runId)) {
701
+ if (child.status === 'running')
702
+ stopRunAndChildren(child.id, visited);
703
+ }
704
+ workflowRunStore.updateRun(runId, {
705
+ status: 'stopped',
706
+ finishedAt: Date.now(),
707
+ error: 'Stopped by user',
708
+ });
407
709
  // If the script had no in-flight agent() call when stop was requested,
408
- // the workflow may simply continue to its natural end. We still record
409
- // the intent so a subsequent agent() call rejects immediately. To make
410
- // single-step or no-agent() scripts also reflect 'stopped', mark the row
411
- // synchronously as a hint to UI — the runner will overwrite to 'stopped'
412
- // (or 'completed' if it really did finish) when executeWorkflow exits.
710
+ // it may never reject on its own. Persist the stopped state immediately
711
+ // so UI/API truth does not depend on the script reaching a later awaiter.
413
712
  return true;
414
713
  };
714
+ const stopRun = (runId) => stopRunAndChildren(runId, new Set());
415
715
  return { runWorkflow, startWorkflow, startWorkflowInline, stopRun };
416
716
  };