@tt-a1i/hive 2.1.1 → 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.
- package/CHANGELOG.md +31 -0
- package/README.en.md +5 -460
- package/README.md +338 -187
- package/README.zh.md +389 -0
- package/dist/src/cli/hive-update.d.ts +3 -2
- package/dist/src/cli/hive-update.js +20 -60
- package/dist/src/cli/hive.js +5 -1
- package/dist/src/server/agent-manager-support.js +16 -9
- package/dist/src/server/agent-runtime-contract.d.ts +4 -0
- package/dist/src/server/agent-runtime.js +27 -0
- package/dist/src/server/agent-stdin-dispatcher.d.ts +3 -0
- package/dist/src/server/agent-stdin-dispatcher.js +3 -0
- package/dist/src/server/diagnostics-support-bundle.d.ts +3 -0
- package/dist/src/server/diagnostics-support-bundle.js +3 -0
- package/dist/src/server/hive-team-guidance.js +34 -4
- package/dist/src/server/message-log-store.d.ts +1 -0
- package/dist/src/server/message-log-store.js +13 -0
- package/dist/src/server/orchestrator-autostart.d.ts +2 -0
- package/dist/src/server/orchestrator-autostart.js +30 -11
- package/dist/src/server/post-start-input-writer.d.ts +1 -0
- package/dist/src/server/post-start-input-writer.js +78 -12
- package/dist/src/server/role-templates.d.ts +8 -1
- package/dist/src/server/role-templates.js +102 -48
- package/dist/src/server/route-types.d.ts +6 -0
- package/dist/src/server/routes-scenarios.d.ts +2 -1
- package/dist/src/server/routes-scenarios.js +11 -6
- package/dist/src/server/routes-team.js +21 -15
- package/dist/src/server/routes-workspaces.js +22 -2
- package/dist/src/server/runtime-store-contract.d.ts +2 -0
- package/dist/src/server/runtime-store-helpers.d.ts +11 -2
- package/dist/src/server/runtime-store-helpers.js +29 -5
- package/dist/src/server/runtime-store-workflows.js +20 -0
- package/dist/src/server/runtime-store.js +2 -0
- package/dist/src/server/scenario-presets.d.ts +3 -1
- package/dist/src/server/scenario-presets.js +12 -1
- package/dist/src/server/spawn-worker-defaults.d.ts +2 -0
- package/dist/src/server/spawn-worker-defaults.js +10 -5
- package/dist/src/server/team-operations.d.ts +3 -0
- package/dist/src/server/team-operations.js +97 -29
- package/dist/src/server/terminal-input-profile.d.ts +2 -0
- package/dist/src/server/update-install-plan.d.ts +20 -0
- package/dist/src/server/update-install-plan.js +151 -0
- package/dist/src/server/version-service.d.ts +5 -0
- package/dist/src/server/version-service.js +17 -13
- package/dist/src/server/workflow-agent-call-executor.d.ts +81 -0
- package/dist/src/server/workflow-agent-call-executor.js +259 -0
- package/dist/src/server/workflow-dag-layer-tracker.d.ts +14 -0
- package/dist/src/server/workflow-dag-layer-tracker.js +37 -0
- package/dist/src/server/workflow-dag.d.ts +8 -0
- package/dist/src/server/workflow-dag.js +65 -0
- package/dist/src/server/workflow-dispatch-awaiter.d.ts +7 -1
- package/dist/src/server/workflow-dispatch-awaiter.js +60 -9
- package/dist/src/server/workflow-runner.d.ts +5 -38
- package/dist/src/server/workflow-runner.js +113 -445
- package/dist/src/server/workflow-script-host-dispatcher.d.ts +14 -0
- package/dist/src/server/workflow-script-host-dispatcher.js +42 -0
- package/dist/src/server/workflow-script-loader.d.ts +2 -2
- package/dist/src/server/workflow-script-loader.js +2 -2
- package/dist/src/server/workflow-script-worker.d.ts +14 -0
- package/dist/src/server/workflow-script-worker.js +81 -0
- package/dist/src/server/workflow-vm-worker-source.d.ts +1 -0
- package/dist/src/server/workflow-vm-worker-source.js +1 -0
- package/dist/src/server/workflow-vm-worker.cjs +238 -0
- package/dist/src/server/workspace-ui-language.d.ts +7 -0
- package/dist/src/server/workspace-ui-language.js +9 -0
- package/dist/src/shared/scenario-presets.d.ts +6 -1
- package/dist/src/shared/scenario-presets.js +80 -28
- package/dist/src/shared/types.d.ts +11 -7
- package/dist/src/shared/ui-language.d.ts +4 -0
- package/dist/src/shared/ui-language.js +3 -0
- package/package.json +4 -2
- package/web/dist/assets/{AddWorkerDialog-CTOsPurT.js → AddWorkerDialog-DHwOZ75i.js} +2 -2
- package/web/dist/assets/AddWorkspaceFlow-qOI1CmqC.js +1 -0
- package/web/dist/assets/{FirstRunWizard-C24MUssx.js → FirstRunWizard-D5kGMD_V.js} +1 -1
- package/web/dist/assets/{MarketplaceDrawer-7gVUjgAx.js → MarketplaceDrawer-BuCbtqFk.js} +1 -1
- package/web/dist/assets/TaskGraphDrawer-e_bg3f5u.js +1 -0
- package/web/dist/assets/{WhatsNewDialog-vnShQWjg.js → WhatsNewDialog-BB9ieq3T.js} +1 -1
- package/web/dist/assets/{WorkerModal-DcxIBPlu.js → WorkerModal-CGeWO3Dg.js} +1 -1
- package/web/dist/assets/{WorkflowsDrawer-C8csID1I.js → WorkflowsDrawer-CVvGxxdR.js} +1 -1
- package/web/dist/assets/WorkspaceMemoryDrawer-BamHuJEp.js +1 -0
- package/web/dist/assets/WorkspaceTaskDrawer-CqntbH9-.js +1 -0
- package/web/dist/assets/index-BOpmn5Ij.js +84 -0
- package/web/dist/assets/index-rPYd0MUk.css +1 -0
- package/web/dist/assets/{search-BRQbevR2.js → search-DKgM9IgX.js} +1 -1
- package/web/dist/assets/{square-terminal-Co4pmGzn.js → square-terminal-DMWazpEv.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/AddWorkspaceFlow-D_fSv-cO.js +0 -1
- package/web/dist/assets/TaskGraphDrawer-Beh95yUu.js +0 -1
- package/web/dist/assets/WorkspaceMemoryDrawer-C_Aq9-o6.js +0 -1
- package/web/dist/assets/WorkspaceTaskDrawer-CIMYzlpt.js +0 -1
- package/web/dist/assets/index-3Hc1oXlt.css +0 -1
- package/web/dist/assets/index-D9zUE0W6.js +0 -79
|
@@ -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 {
|
|
7
|
-
import {
|
|
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 #
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 = () =>
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|