@crewx/workflow 0.3.22-rc.15 → 0.3.22-rc.150

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 (50) hide show
  1. package/SKILL.md +40 -4
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +268 -86
  4. package/dist/cli.js.map +1 -1
  5. package/dist/src/card-state.d.ts +20 -0
  6. package/dist/src/card-state.d.ts.map +1 -0
  7. package/dist/src/card-state.js +121 -0
  8. package/dist/src/card-state.js.map +1 -0
  9. package/dist/src/engine.d.ts +9 -2
  10. package/dist/src/engine.d.ts.map +1 -1
  11. package/dist/src/engine.js +70 -22
  12. package/dist/src/engine.js.map +1 -1
  13. package/dist/src/failure.d.ts +41 -0
  14. package/dist/src/failure.d.ts.map +1 -0
  15. package/dist/src/failure.js +122 -0
  16. package/dist/src/failure.js.map +1 -0
  17. package/dist/src/list-view.d.ts +22 -0
  18. package/dist/src/list-view.d.ts.map +1 -0
  19. package/dist/src/list-view.js +188 -0
  20. package/dist/src/list-view.js.map +1 -0
  21. package/dist/src/mermaid.d.ts.map +1 -1
  22. package/dist/src/mermaid.js +7 -4
  23. package/dist/src/mermaid.js.map +1 -1
  24. package/dist/src/parent-task-sync.d.ts +120 -0
  25. package/dist/src/parent-task-sync.d.ts.map +1 -0
  26. package/dist/src/parent-task-sync.js +307 -0
  27. package/dist/src/parent-task-sync.js.map +1 -0
  28. package/dist/src/retry-policy.d.ts +38 -0
  29. package/dist/src/retry-policy.d.ts.map +1 -0
  30. package/dist/src/retry-policy.js +140 -0
  31. package/dist/src/retry-policy.js.map +1 -0
  32. package/dist/src/run-manager.d.ts +35 -2
  33. package/dist/src/run-manager.d.ts.map +1 -1
  34. package/dist/src/run-manager.js +1189 -235
  35. package/dist/src/run-manager.js.map +1 -1
  36. package/dist/src/types.d.ts +39 -4
  37. package/dist/src/types.d.ts.map +1 -1
  38. package/dist/src/types.js +2 -0
  39. package/dist/src/types.js.map +1 -1
  40. package/dist/src/utils/output-format.d.ts +12 -0
  41. package/dist/src/utils/output-format.d.ts.map +1 -1
  42. package/dist/src/utils/output-format.js +103 -5
  43. package/dist/src/utils/output-format.js.map +1 -1
  44. package/dist/src/workflow-persistence.d.ts +48 -0
  45. package/dist/src/workflow-persistence.d.ts.map +1 -0
  46. package/dist/src/workflow-persistence.js +224 -0
  47. package/dist/src/workflow-persistence.js.map +1 -0
  48. package/dist/workflow-schema.json +34 -5
  49. package/package.json +23 -4
  50. package/workflow-schema.json +34 -5
@@ -33,105 +33,316 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RunManager = exports.SKILL_TASK_DEFAULT_TIMEOUT = void 0;
36
+ exports.RunManager = exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = exports.WORKFLOW_RUN_INTERRUPTED_ERROR = exports.WORKFLOW_CANCEL_GRACE_MS = exports.SKILL_TASK_DEFAULT_TIMEOUT = exports.FileLock = void 0;
37
37
  exports.parseSetArgs = parseSetArgs;
38
38
  exports.readStdin = readStdin;
39
39
  exports.interpolateTemplate = interpolateTemplate;
40
40
  exports.safeEvaluate = safeEvaluate;
41
41
  exports.safeEvaluateBranchCondition = safeEvaluateBranchCondition;
42
42
  exports.validateSafeBranchCondition = validateSafeBranchCondition;
43
+ exports.normalizeRunStatus = normalizeRunStatus;
44
+ exports.isRetryableFailure = isRetryableFailure;
43
45
  const fs = __importStar(require("fs"));
44
46
  const path = __importStar(require("path"));
45
47
  const yaml = __importStar(require("js-yaml"));
46
48
  const cp = __importStar(require("child_process"));
47
49
  const sdk_1 = require("@crewx/sdk");
50
+ const failure_1 = require("./failure");
48
51
  const file_lock_1 = require("./file-lock");
49
52
  const output_format_1 = require("./utils/output-format");
50
53
  const shell_security_1 = require("./utils/shell-security");
54
+ const retry_policy_1 = require("./retry-policy");
55
+ var file_lock_2 = require("./file-lock");
56
+ Object.defineProperty(exports, "FileLock", { enumerable: true, get: function () { return file_lock_2.FileLock; } });
51
57
  exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
58
+ exports.WORKFLOW_CANCEL_GRACE_MS = 2000;
59
+ const WORKFLOW_CANCEL_POLL_INTERVAL_MS = 100;
60
+ exports.WORKFLOW_RUN_INTERRUPTED_ERROR = 'Workflow run interrupted: runner process exited before completion';
61
+ exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = 10 * 60000;
62
+ const RETRY_WAIT_SLICE_MS = 1000;
63
+ function sleep(ms) {
64
+ return new Promise((resolve) => {
65
+ setTimeout(resolve, ms);
66
+ });
67
+ }
52
68
  const KNOWN_AUTO_NODE_TYPES = new Set([
53
- 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end',
69
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
54
70
  ]);
55
- const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end']);
56
- function killProcessGroup(child) {
71
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
72
+ function resolveCrewxChildInvocation(env, cwd) {
73
+ const resolution = (0, sdk_1.resolveCrewxExecutable)({ env, cwd });
74
+ if (!resolution.ok) {
75
+ throw (0, failure_1.createWorkflowFailureError)('CLI_UNRESOLVABLE', (0, sdk_1.formatCrewxExecutableFailure)(resolution));
76
+ }
77
+ return {
78
+ argv: resolution.argv,
79
+ env: {
80
+ ...env,
81
+ CREWX_CLI_ARGV: JSON.stringify(resolution.argv),
82
+ },
83
+ };
84
+ }
85
+ async function killProcessGroup(child, signal = 'SIGKILL') {
86
+ if (!child.pid)
87
+ return;
57
88
  try {
58
- if (process.platform === 'win32') {
59
- if (child.pid) {
60
- cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
61
- }
62
- }
63
- else if (child.pid) {
89
+ const result = await (0, sdk_1.terminateProcessTree)(child.pid, { signal });
90
+ if (result.status === 'failed' || result.status === 'not_found') {
64
91
  try {
65
- process.kill(-child.pid, 'SIGKILL');
66
- }
67
- catch {
68
- try {
69
- child.kill('SIGKILL');
70
- }
71
- catch { }
92
+ child.kill(signal);
72
93
  }
94
+ catch { }
95
+ }
96
+ }
97
+ catch {
98
+ try {
99
+ child.kill(signal);
73
100
  }
101
+ catch { }
74
102
  }
75
- catch { }
76
103
  }
77
104
  async function runProcess(command, argv, opts) {
78
105
  return new Promise((resolve) => {
106
+ const isCancelled = () => {
107
+ try {
108
+ return opts.cancellation?.isCancelled() === true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ };
114
+ if (isCancelled()) {
115
+ resolve({
116
+ status: null,
117
+ stdout: '',
118
+ stderr: '',
119
+ timedOut: false,
120
+ cancelled: true,
121
+ signal: null,
122
+ errorOrigin: null,
123
+ });
124
+ return;
125
+ }
79
126
  let child;
80
127
  try {
81
128
  child = cp.spawn(command, argv, {
82
129
  cwd: opts.cwd,
83
130
  env: opts.env,
84
- stdio: ['ignore', 'pipe', 'pipe'],
131
+ stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
85
132
  shell: opts.shell ?? false,
86
133
  detached: process.platform !== 'win32',
87
134
  windowsHide: opts.windowsHide ?? true,
88
135
  });
89
136
  }
90
137
  catch (err) {
91
- resolve({ status: null, stdout: '', stderr: '', timedOut: false, error: err });
138
+ resolve({
139
+ status: null,
140
+ stdout: '',
141
+ stderr: '',
142
+ timedOut: false,
143
+ signal: null,
144
+ errorOrigin: 'spawn',
145
+ error: err,
146
+ });
92
147
  return;
93
148
  }
94
149
  let stdout = '';
95
150
  let stderr = '';
96
151
  let settled = false;
152
+ let finishing = false;
153
+ let spawned = false;
154
+ let cancellationRequested = false;
155
+ let postSpawnError;
156
+ let stdinError;
157
+ let timeoutTimer;
158
+ let cancellationEscalationTimer;
159
+ let cancellationPollTimer;
160
+ let activeController;
161
+ const outDecoder = new sdk_1.ConsoleDecoder();
162
+ const errDecoder = new sdk_1.ConsoleDecoder();
163
+ let stdoutDecodeChain = Promise.resolve();
164
+ let stderrDecodeChain = Promise.resolve();
165
+ const cleanup = () => {
166
+ if (timeoutTimer)
167
+ clearTimeout(timeoutTimer);
168
+ if (cancellationEscalationTimer)
169
+ clearTimeout(cancellationEscalationTimer);
170
+ if (cancellationPollTimer)
171
+ clearInterval(cancellationPollTimer);
172
+ if (opts.cancellation
173
+ && activeController
174
+ && activeProcessControllers.get(opts.cancellation.key) === activeController) {
175
+ activeProcessControllers.delete(opts.cancellation.key);
176
+ }
177
+ };
97
178
  const finish = (result) => {
98
179
  if (settled)
99
180
  return;
100
181
  settled = true;
101
- clearTimeout(timer);
102
- resolve(result);
182
+ cleanup();
183
+ resolve(cancellationRequested ? { ...result, cancelled: true, timedOut: false } : result);
103
184
  };
104
- const timer = setTimeout(() => {
105
- killProcessGroup(child);
106
- finish({ status: null, stdout, stderr, timedOut: true });
185
+ const requestCancellation = () => {
186
+ if (settled || cancellationRequested)
187
+ return;
188
+ cancellationRequested = true;
189
+ void killProcessGroup(child, 'SIGTERM');
190
+ cancellationEscalationTimer = setTimeout(() => {
191
+ if (!settled)
192
+ void killProcessGroup(child, 'SIGKILL');
193
+ }, exports.WORKFLOW_CANCEL_GRACE_MS);
194
+ if (typeof cancellationEscalationTimer.unref === 'function') {
195
+ cancellationEscalationTimer.unref();
196
+ }
197
+ };
198
+ if (opts.cancellation) {
199
+ activeController = { cancel: requestCancellation };
200
+ activeProcessControllers.set(opts.cancellation.key, activeController);
201
+ cancellationPollTimer = setInterval(() => {
202
+ if (isCancelled())
203
+ requestCancellation();
204
+ }, WORKFLOW_CANCEL_POLL_INTERVAL_MS);
205
+ if (typeof cancellationPollTimer.unref === 'function')
206
+ cancellationPollTimer.unref();
207
+ }
208
+ timeoutTimer = setTimeout(() => {
209
+ void killProcessGroup(child);
210
+ finishAfterDecode(() => ({
211
+ status: null,
212
+ stdout,
213
+ stderr,
214
+ timedOut: true,
215
+ signal: null,
216
+ errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
217
+ ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
218
+ }));
107
219
  }, opts.timeout);
108
- if (typeof timer.unref === 'function')
109
- timer.unref();
110
- child.stdout?.on('data', (d) => { stdout += d.toString(); });
111
- child.stderr?.on('data', (d) => { stderr += d.toString(); });
112
- child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
113
- child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
220
+ if (typeof timeoutTimer.unref === 'function')
221
+ timeoutTimer.unref();
222
+ child.stdout?.on('data', (d) => {
223
+ stdoutDecodeChain = stdoutDecodeChain.then(async () => {
224
+ stdout += await outDecoder.write(d);
225
+ });
226
+ });
227
+ child.stderr?.on('data', (d) => {
228
+ stderrDecodeChain = stderrDecodeChain.then(async () => {
229
+ stderr += await errDecoder.write(d);
230
+ });
231
+ });
232
+ child.once('spawn', () => { spawned = true; });
233
+ child.on('error', (err) => {
234
+ if (!spawned) {
235
+ finish({
236
+ status: null,
237
+ stdout,
238
+ stderr,
239
+ timedOut: false,
240
+ signal: null,
241
+ errorOrigin: 'spawn',
242
+ error: err,
243
+ });
244
+ return;
245
+ }
246
+ postSpawnError = postSpawnError ?? err;
247
+ });
248
+ if (opts.stdin !== undefined) {
249
+ child.stdin?.on('error', (err) => {
250
+ stdinError = stdinError ?? err;
251
+ });
252
+ try {
253
+ child.stdin?.end(opts.stdin);
254
+ }
255
+ catch (err) {
256
+ stdinError = stdinError ?? err;
257
+ }
258
+ }
259
+ const finishAfterDecode = (createResult) => {
260
+ if (settled || finishing)
261
+ return;
262
+ finishing = true;
263
+ void (async () => {
264
+ await Promise.all([stdoutDecodeChain, stderrDecodeChain]);
265
+ const [tailOut, tailErr] = await Promise.all([outDecoder.end(), errDecoder.end()]);
266
+ stdout += tailOut;
267
+ stderr += tailErr;
268
+ finish(createResult());
269
+ })().catch(() => finish(createResult()));
270
+ };
271
+ child.on('close', (code, signal) => finishAfterDecode(() => ({
272
+ status: code,
273
+ stdout,
274
+ stderr,
275
+ timedOut: false,
276
+ signal,
277
+ errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
278
+ ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
279
+ })));
280
+ if (isCancelled())
281
+ requestCancellation();
114
282
  });
115
283
  }
116
- function maskArg(arg) {
117
- return arg.replace(/^(--?[\w-]*(?:token|secret|password|passwd|key|auth)[\w-]*)=(.+)$/i, '$1=***');
284
+ const AUDIT_IDENTIFIER_RE = /^[A-Za-z0-9_.-]{1,128}$/;
285
+ function safeAuditIdentifier(value) {
286
+ if (value === undefined)
287
+ return undefined;
288
+ const match = AUDIT_IDENTIFIER_RE.exec(value);
289
+ return match?.[0] === value ? value : undefined;
118
290
  }
119
291
  function summarizeNode(node) {
120
292
  switch (node.type) {
121
- case 'agent_task':
122
- return `@${node.agent ?? '?'} (${node.mode === 'query' ? 'q' : 'x'})`;
123
- case 'skill_task':
124
- return `skill ${node.skill ?? '?'} ${(node.args ?? []).map(maskArg).join(' ')}`.trim();
293
+ case 'agent_task': {
294
+ const agent = safeAuditIdentifier(node.agent);
295
+ return agent
296
+ ? `@${agent} (${node.mode === 'query' ? 'q' : 'x'})`
297
+ : `agent_task (${node.mode === 'query' ? 'q' : 'x'})`;
298
+ }
299
+ case 'skill_task': {
300
+ const skill = safeAuditIdentifier(node.skill);
301
+ return skill ? `skill ${skill}` : 'skill_task';
302
+ }
125
303
  case 'shell_task':
126
- return (node.command ?? []).map(maskArg).join(' ');
304
+ return 'shell_task';
127
305
  case 'branch':
128
- return `branch: ${node.condition ?? ''}`;
306
+ return 'branch';
129
307
  case 'expression':
130
- return `set ${Object.keys(node.set ?? {}).join(', ')}`;
308
+ return 'expression';
131
309
  default:
132
- return node.type;
310
+ return 'workflow node';
133
311
  }
134
312
  }
313
+ function getErrorMessage(error) {
314
+ if (error instanceof Error)
315
+ return error.message;
316
+ return typeof error === 'string' ? error : 'Node execution failed';
317
+ }
318
+ function toProcessFailureMetadata(result) {
319
+ return {
320
+ status: result.status,
321
+ timedOut: result.timedOut,
322
+ signal: result.signal,
323
+ errorOrigin: result.errorOrigin,
324
+ };
325
+ }
326
+ function appendFailureAudit(run, failure, type, trigger, now = new Date().toISOString()) {
327
+ run.audit = run.audit ?? [];
328
+ run.audit.push({
329
+ exec_id: run.id,
330
+ node_id: failure.failedNode,
331
+ type,
332
+ summary: failure.causeMessage,
333
+ started_at: now,
334
+ ended_at: now,
335
+ duration_ms: 0,
336
+ exit_code: failure.exitCode,
337
+ error: failure.causeMessage,
338
+ trigger,
339
+ });
340
+ }
341
+ function assignRunFailure(run, failure, rawError) {
342
+ run.status = 'failed';
343
+ run.error = (0, failure_1.sanitizeLocalRunError)(rawError, failure.causeMessage);
344
+ run.last_failure = failure;
345
+ }
135
346
  async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
136
347
  if (!nodeSpec.output)
137
348
  return;
@@ -140,12 +351,12 @@ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
140
351
  let parsed;
141
352
  try {
142
353
  parsed = JSON.parse((0, output_format_1.extractJson)(trimmed));
354
+ if (nodeSpec.output_schema) {
355
+ await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
356
+ }
143
357
  }
144
358
  catch (e) {
145
- throw new Error(`Node "${nodeId}" output_format=json parse failed: ${e.message}`);
146
- }
147
- if (nodeSpec.output_schema) {
148
- await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
359
+ throw (0, failure_1.createWorkflowFailureError)('OUTPUT_INVALID', `Node "${nodeId}" output_format=json parse or validation failed: ${getErrorMessage(e)}`);
149
360
  }
150
361
  stateUpdates[nodeSpec.output] = parsed;
151
362
  }
@@ -521,12 +732,99 @@ function safeEvaluateBranchCondition(expr, state) {
521
732
  function validateSafeBranchCondition(expr) {
522
733
  safeEvaluateBranchCondition(expr, {});
523
734
  }
735
+ function readRunEngineState(run) {
736
+ const raw = run.state['__engine'];
737
+ if (!raw || typeof raw !== 'object')
738
+ return { visitCounts: {}, iterations: 0 };
739
+ const record = raw;
740
+ const rawCounts = record['visitCounts'];
741
+ const visitCounts = {};
742
+ if (rawCounts && typeof rawCounts === 'object') {
743
+ for (const [key, value] of Object.entries(rawCounts)) {
744
+ if (typeof value === 'number' && Number.isFinite(value))
745
+ visitCounts[key] = value;
746
+ }
747
+ }
748
+ const iterations = typeof record['iterations'] === 'number' && Number.isFinite(record['iterations'])
749
+ ? record['iterations']
750
+ : 0;
751
+ return { visitCounts, iterations };
752
+ }
753
+ const inFlightAutoRuns = new Set();
754
+ const activeProcessControllers = new Map();
755
+ function processControllerKey(resolvedDir, execId) {
756
+ return `${resolvedDir}::${execId}`;
757
+ }
758
+ function requestActiveProcessCancellation(resolvedDir, execId) {
759
+ activeProcessControllers.get(processControllerKey(resolvedDir, execId))?.cancel();
760
+ }
761
+ function isProcessAlive(pid) {
762
+ if (!Number.isInteger(pid) || pid <= 0)
763
+ return false;
764
+ if (pid === process.pid)
765
+ return true;
766
+ try {
767
+ process.kill(pid, 0);
768
+ return true;
769
+ }
770
+ catch (error) {
771
+ return error.code === 'EPERM';
772
+ }
773
+ }
774
+ function isTerminalStatus(status) {
775
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
776
+ }
777
+ function isRecentLegacyRetry(run, nowMs) {
778
+ if (run.status !== undefined || run.auto_run)
779
+ return false;
780
+ const lastAudit = run.audit?.at(-1);
781
+ if (lastAudit?.type !== 'retry')
782
+ return false;
783
+ const updatedAt = Date.parse(run.updated_at);
784
+ return Number.isFinite(updatedAt)
785
+ && nowMs - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS;
786
+ }
787
+ function normalizeRunStatus(run, nowMs = Date.now()) {
788
+ if (run.status !== undefined)
789
+ return run;
790
+ let status;
791
+ if (run.current_node === '') {
792
+ status = 'pending';
793
+ }
794
+ else if (run.auto_run) {
795
+ status = 'running';
796
+ }
797
+ else if (isRecentLegacyRetry(run, nowMs)) {
798
+ status = 'retrying';
799
+ }
800
+ else {
801
+ status = 'pending';
802
+ }
803
+ return { ...run, status };
804
+ }
805
+ function isRetryableFailure(run, spec) {
806
+ if (run.status !== 'failed')
807
+ return false;
808
+ if (!run.current_node)
809
+ return false;
810
+ const node = spec.nodes?.[run.current_node];
811
+ if (!node)
812
+ return false;
813
+ return node.type !== 'error';
814
+ }
524
815
  class RunManager {
525
- constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
816
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot, deps) {
526
817
  this.runsDir = runsDir;
818
+ this.sleep = deps?.sleep ?? sleep;
819
+ this.onRunPersisted = deps?.onRunPersisted;
527
820
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
528
821
  this.resolvedDir = path.resolve(base, this.runsDir);
529
822
  this.projectRoot = path.resolve(base);
823
+ const hasConfig = fs.existsSync(path.join(this.projectRoot, 'crewx.yaml'))
824
+ || fs.existsSync(path.join(this.projectRoot, 'crewx.yml'));
825
+ if (!hasConfig) {
826
+ console.warn(`[RunManager] projectRoot "${this.projectRoot}" has no crewx.yaml/crewx.yml — agent_task nodes may fail to resolve agents.`);
827
+ }
530
828
  }
531
829
  ensureRunsDir() {
532
830
  if (!fs.existsSync(this.resolvedDir)) {
@@ -538,8 +836,21 @@ class RunManager {
538
836
  throw new Error(`Invalid execution ID: "${execId}"`);
539
837
  }
540
838
  }
541
- loadRun(execId) {
542
- this.validateExecId(execId);
839
+ isRunCancelled(execId) {
840
+ try {
841
+ return this.readRunRaw(execId)?.status === 'cancelled';
842
+ }
843
+ catch {
844
+ return false;
845
+ }
846
+ }
847
+ processCancellation(execId) {
848
+ return {
849
+ key: processControllerKey(this.resolvedDir, execId),
850
+ isCancelled: () => this.isRunCancelled(execId),
851
+ };
852
+ }
853
+ readRunRaw(execId) {
543
854
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
544
855
  if (!fs.existsSync(filePath))
545
856
  return null;
@@ -550,11 +861,145 @@ class RunManager {
550
861
  throw new Error(`Failed to parse execution file ${filePath}: ${e.message}`);
551
862
  }
552
863
  }
864
+ readRun(execId) {
865
+ this.validateExecId(execId);
866
+ const run = this.readRunRaw(execId);
867
+ return run ? normalizeRunStatus(run) : null;
868
+ }
869
+ isLegacyStatuslessStale(run) {
870
+ if (run.status !== undefined
871
+ || run.auto_run
872
+ || run.manual_resume
873
+ || !run.current_node
874
+ || !run.audit?.length) {
875
+ return false;
876
+ }
877
+ const updatedAt = Date.parse(run.updated_at);
878
+ if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS) {
879
+ return false;
880
+ }
881
+ const doc = this.loadWorkflowYaml(run.workflow_file);
882
+ const spec = doc?.workflows[run.workflow_id];
883
+ const node = spec?.nodes?.[run.current_node];
884
+ return Boolean(node && KNOWN_AUTO_NODE_TYPES.has(node.type));
885
+ }
886
+ withRunLock(execId, callback) {
887
+ this.validateExecId(execId);
888
+ const filePath = path.join(this.resolvedDir, `${execId}.json`);
889
+ if (!fs.existsSync(filePath)) {
890
+ throw new Error(`Execution not found: ${execId}`);
891
+ }
892
+ const lock = new file_lock_1.FileLock(filePath);
893
+ lock.acquire();
894
+ try {
895
+ const run = this.readRunRaw(execId);
896
+ if (!run)
897
+ throw new Error(`Execution not found: ${execId}`);
898
+ return callback(normalizeRunStatus(run));
899
+ }
900
+ finally {
901
+ lock.release();
902
+ }
903
+ }
904
+ hasStaleRunner(run) {
905
+ if (run.auto_run)
906
+ return !isProcessAlive(run.auto_run.pid);
907
+ return this.isLegacyStatuslessStale(run);
908
+ }
909
+ recoverStaleRunner(execId) {
910
+ const filePath = path.join(this.resolvedDir, `${execId}.json`);
911
+ const lock = new file_lock_1.FileLock(filePath);
912
+ lock.acquire();
913
+ try {
914
+ const run = this.readRunRaw(execId);
915
+ if (!run || !this.hasStaleRunner(run))
916
+ return run ? normalizeRunStatus(run) : null;
917
+ if (isTerminalStatus(run.status)) {
918
+ delete run.auto_run;
919
+ delete run.manual_resume;
920
+ this.saveRun(run);
921
+ return run;
922
+ }
923
+ const lease = run.auto_run;
924
+ const nodeId = lease?.node_id || run.current_node;
925
+ const now = new Date().toISOString();
926
+ const failure = (0, failure_1.buildNodeFailure)({
927
+ failedNode: nodeId,
928
+ attempt: (0, failure_1.readNodeAttempt)(run, nodeId),
929
+ causeCode: 'INTERRUPTED',
930
+ exitCode: null,
931
+ signal: null,
932
+ errorOrigin: null,
933
+ });
934
+ assignRunFailure(run, failure, exports.WORKFLOW_RUN_INTERRUPTED_ERROR);
935
+ delete run.auto_run;
936
+ delete run.manual_resume;
937
+ appendFailureAudit(run, failure, 'interrupted', 'auto', now);
938
+ this.saveRun(run);
939
+ return run;
940
+ }
941
+ finally {
942
+ lock.release();
943
+ }
944
+ }
945
+ loadRun(execId) {
946
+ this.validateExecId(execId);
947
+ const run = this.readRunRaw(execId);
948
+ if (!run || !this.hasStaleRunner(run))
949
+ return run ? normalizeRunStatus(run) : null;
950
+ return this.recoverStaleRunner(execId);
951
+ }
553
952
  saveRun(run) {
554
953
  this.ensureRunsDir();
555
954
  run.updated_at = new Date().toISOString();
556
955
  const filePath = path.join(this.resolvedDir, `${run.id}.json`);
557
- fs.writeFileSync(filePath, JSON.stringify(run, null, 2) + '\n', 'utf-8');
956
+ let existingMode;
957
+ try {
958
+ existingMode = fs.statSync(filePath).mode & 0o7777;
959
+ }
960
+ catch (error) {
961
+ if (error.code !== 'ENOENT')
962
+ throw error;
963
+ }
964
+ const tempPath = path.join(this.resolvedDir, `.${run.id}.tmp-${process.pid}-${(0, sdk_1.generateId)('wfr')}`);
965
+ const serialized = JSON.stringify(run, null, 2) + '\n';
966
+ let tempFd;
967
+ let tempCreated = false;
968
+ try {
969
+ tempFd = fs.openSync(tempPath, 'wx');
970
+ tempCreated = true;
971
+ fs.writeFileSync(tempFd, serialized, 'utf-8');
972
+ if (existingMode !== undefined)
973
+ fs.fchmodSync(tempFd, existingMode);
974
+ fs.closeSync(tempFd);
975
+ tempFd = undefined;
976
+ fs.renameSync(tempPath, filePath);
977
+ tempCreated = false;
978
+ }
979
+ finally {
980
+ if (tempFd !== undefined) {
981
+ try {
982
+ fs.closeSync(tempFd);
983
+ }
984
+ catch {
985
+ }
986
+ }
987
+ if (tempCreated) {
988
+ try {
989
+ fs.unlinkSync(tempPath);
990
+ }
991
+ catch {
992
+ }
993
+ }
994
+ }
995
+ if (!this.onRunPersisted)
996
+ return;
997
+ try {
998
+ this.onRunPersisted(run);
999
+ }
1000
+ catch (error) {
1001
+ console.warn(`[RunManager] onRunPersisted failed for ${run.id}: ${getErrorMessage(error)}`);
1002
+ }
558
1003
  }
559
1004
  atomicUpdate(execId, updater) {
560
1005
  this.validateExecId(execId);
@@ -563,7 +1008,7 @@ class RunManager {
563
1008
  const lock = new file_lock_1.FileLock(filePath);
564
1009
  lock.acquire();
565
1010
  try {
566
- const run = this.loadRun(execId);
1011
+ const run = this.readRun(execId);
567
1012
  if (!run)
568
1013
  throw new Error(`Execution not found: ${execId}`);
569
1014
  updater(run);
@@ -581,7 +1026,7 @@ class RunManager {
581
1026
  const lock = new file_lock_1.FileLock(filePath);
582
1027
  await lock.acquireAsync();
583
1028
  try {
584
- const run = this.loadRun(execId);
1029
+ const run = this.readRun(execId);
585
1030
  if (!run)
586
1031
  throw new Error(`Execution not found: ${execId}`);
587
1032
  updater(run);
@@ -592,6 +1037,32 @@ class RunManager {
592
1037
  lock.release();
593
1038
  }
594
1039
  }
1040
+ async reserveNodeAttempt(execId, nodeId) {
1041
+ let reservedAttempt = 1;
1042
+ await this.atomicUpdateAsync(execId, (run) => {
1043
+ if (run.status === 'cancelled') {
1044
+ reservedAttempt = (0, failure_1.readNodeAttempt)(run, nodeId);
1045
+ return;
1046
+ }
1047
+ const storedAttempt = run.node_attempts?.[nodeId];
1048
+ const previousAttempt = typeof storedAttempt === 'number'
1049
+ && Number.isInteger(storedAttempt)
1050
+ && storedAttempt > 0
1051
+ ? storedAttempt
1052
+ : 0;
1053
+ reservedAttempt = previousAttempt + 1;
1054
+ run.node_attempts = run.node_attempts ?? {};
1055
+ run.node_attempts[nodeId] = reservedAttempt;
1056
+ });
1057
+ return reservedAttempt;
1058
+ }
1059
+ clearNodeAttempt(run, nodeId) {
1060
+ if (!run.node_attempts)
1061
+ return;
1062
+ delete run.node_attempts[nodeId];
1063
+ if (Object.keys(run.node_attempts).length === 0)
1064
+ delete run.node_attempts;
1065
+ }
595
1066
  loadWorkflowYaml(yamlPath) {
596
1067
  if (!fs.existsSync(yamlPath))
597
1068
  return null;
@@ -601,6 +1072,12 @@ class RunManager {
601
1072
  return null;
602
1073
  return doc;
603
1074
  }
1075
+ getNodeType(workflowFile, workflowId, nodeId) {
1076
+ const doc = this.loadWorkflowYaml(workflowFile);
1077
+ const spec = doc?.workflows[workflowId];
1078
+ const node = spec?.nodes?.[nodeId];
1079
+ return typeof node?.type === 'string' ? node.type : null;
1080
+ }
604
1081
  start(yamlPath, workflowId, overrides) {
605
1082
  const doc = this.loadWorkflowYaml(yamlPath);
606
1083
  if (!doc) {
@@ -638,6 +1115,7 @@ class RunManager {
638
1115
  completed_nodes: [],
639
1116
  state,
640
1117
  initial_state: { ...initialState, ...sets },
1118
+ status: 'pending',
641
1119
  };
642
1120
  this.saveRun(run);
643
1121
  return run;
@@ -656,6 +1134,8 @@ class RunManager {
656
1134
  }
657
1135
  moveNode(execId, nodeId) {
658
1136
  return this.atomicUpdate(execId, (run) => {
1137
+ if (run.status === 'cancelled')
1138
+ return;
659
1139
  if (run.current_node && !run.completed_nodes.includes(run.current_node)) {
660
1140
  run.completed_nodes.push(run.current_node);
661
1141
  }
@@ -667,29 +1147,50 @@ class RunManager {
667
1147
  if (!snapshot) {
668
1148
  throw new Error(`Execution not found: ${execId}`);
669
1149
  }
1150
+ if (snapshot.status === 'cancelled') {
1151
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Workflow run "${execId}" is already cancelled`);
1152
+ }
670
1153
  const doc = this.loadWorkflowYaml(snapshot.workflow_file);
671
1154
  if (!doc) {
672
- throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
1155
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${snapshot.workflow_file}`);
673
1156
  }
674
1157
  const spec = doc.workflows[snapshot.workflow_id];
675
1158
  if (!spec) {
676
- throw new Error(`Workflow "${snapshot.workflow_id}" not found in ${snapshot.workflow_file}`);
1159
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${snapshot.workflow_id}" not found in ${snapshot.workflow_file}`);
677
1160
  }
678
1161
  const nodeSpec = spec.nodes?.[nodeId];
679
1162
  if (!nodeSpec) {
680
- throw new Error(`Node "${nodeId}" not found in workflow "${snapshot.workflow_id}"`);
1163
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" not found in workflow "${snapshot.workflow_id}"`);
681
1164
  }
682
1165
  const prevNode = snapshot.current_node;
683
1166
  const stateUpdates = {};
684
1167
  const nodesToComplete = [];
685
1168
  let currentNode = nodeId;
686
1169
  let status;
1170
+ let failureMessage;
1171
+ let failureCode;
687
1172
  let nodeTaskId;
688
1173
  const trigger = opts?.trigger ?? 'manual';
689
1174
  const startedAt = new Date();
690
- let nodeStdout = '';
691
- let nodeStderr = '';
692
1175
  let nodeExitCode = null;
1176
+ let nodeSignal = null;
1177
+ let nodeErrorOrigin = null;
1178
+ let processFailure;
1179
+ const recordProcessResult = (result) => {
1180
+ nodeExitCode = result.status;
1181
+ nodeSignal = result.signal;
1182
+ nodeErrorOrigin = result.errorOrigin;
1183
+ processFailure = toProcessFailureMetadata(result);
1184
+ };
1185
+ const reserveAgentTaskId = async () => {
1186
+ const taskId = (0, sdk_1.generateId)('tsk');
1187
+ await this.atomicUpdateAsync(execId, (run) => {
1188
+ run.tasks = run.tasks ?? {};
1189
+ run.tasks[nodeId] = taskId;
1190
+ });
1191
+ nodeTaskId = taskId;
1192
+ return taskId;
1193
+ };
693
1194
  const buildAudit = (error) => {
694
1195
  const endedAt = new Date();
695
1196
  return {
@@ -705,31 +1206,57 @@ class RunManager {
705
1206
  trigger,
706
1207
  };
707
1208
  };
1209
+ const attempt = await this.reserveNodeAttempt(execId, nodeId);
708
1210
  try {
709
1211
  switch (nodeSpec.type) {
710
1212
  case 'agent_task': {
711
1213
  if (!nodeSpec.agent) {
712
- throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
1214
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is agent_task but has no agent defined`);
1215
+ }
1216
+ const agentCtx = { ...snapshot.state, state: snapshot.state };
1217
+ const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
1218
+ if (!resolvedAgent) {
1219
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
1220
+ }
1221
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
1222
+ if (!cwdCheck.ok) {
1223
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1224
+ }
1225
+ const nodeCwd = cwdCheck.resolved;
1226
+ const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1227
+ if (!rootCheck.ok) {
1228
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${rootCheck.error}`);
713
1229
  }
1230
+ const workspaceRoot = rootCheck.resolved;
714
1231
  const input = nodeSpec.input
715
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
1232
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
716
1233
  : '';
1234
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
1235
+ ? ''
1236
+ : (0, output_format_1.buildWorkflowPreamble)({
1237
+ workflowId: snapshot.workflow_id,
1238
+ nodeId,
1239
+ execId: snapshot.id,
1240
+ });
717
1241
  const finalInput = nodeSpec.output_format === 'json'
718
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
719
- : input;
1242
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
1243
+ : preamble + input;
720
1244
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
721
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
1245
+ const prompt = `@${resolvedAgent} ${finalInput}`;
722
1246
  if (opts?.dryRun) {
723
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
1247
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
724
1248
  break;
725
1249
  }
726
- console.log(`Executing: @${nodeSpec.agent} (${mode} mode)`);
727
- const crewxCli = process.env.CREWX_CLI || 'npx crewx';
728
- const cliParts = crewxCli.split(/\s+/);
729
- const bin = cliParts[0];
730
- const baseArgs = cliParts.slice(1);
1250
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
731
1251
  const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 8 * 3600000;
732
- const wfArgs = [...baseArgs, mode, prompt];
1252
+ const childInvocation = resolveCrewxChildInvocation({
1253
+ ...process.env,
1254
+ CREWX_WORKSPACE: workspaceRoot,
1255
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1256
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1257
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1258
+ }, nodeCwd);
1259
+ const wfArgs = [...childInvocation.argv.slice(1), mode, prompt];
733
1260
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
734
1261
  wfArgs.push(`--thread=workflow:${snapshot.id}`);
735
1262
  }
@@ -742,39 +1269,47 @@ class RunManager {
742
1269
  };
743
1270
  wfArgs.push('--metadata', JSON.stringify(wfMetadata));
744
1271
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
745
- command: bin,
1272
+ command: childInvocation.argv[0],
1273
+ env: childInvocation.env,
1274
+ cwd: nodeCwd,
746
1275
  allowShellFallback: true,
747
1276
  packageName: 'crewx',
748
1277
  });
749
1278
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
1279
+ const taskId = await reserveAgentTaskId();
750
1280
  const result = await runProcess(invocation.command, invocation.argv, {
751
- cwd: process.cwd(),
752
- env: {
753
- ...process.env,
754
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
755
- CREWX_WORKFLOW_NODE_ID: nodeId,
756
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
757
- },
1281
+ cwd: nodeCwd,
1282
+ env: (0, sdk_1.withoutCrewxRootContext)({
1283
+ ...childInvocation.env,
1284
+ CREWX_TRACE_ID: taskId,
1285
+ }),
758
1286
  timeout: nodeTimeout,
759
1287
  shell: invocation.shell ?? false,
760
1288
  windowsHide: invocation.windowsHide,
1289
+ cancellation: this.processCancellation(execId),
761
1290
  });
762
- nodeStdout = result.stdout;
763
- nodeStderr = result.stderr;
764
- nodeExitCode = result.status;
765
- if (result.error) {
766
- throw result.error;
1291
+ recordProcessResult(result);
1292
+ if (result.cancelled) {
1293
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent execution cancelled for node "${nodeId}"`);
767
1294
  }
768
1295
  if (result.timedOut) {
769
1296
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
770
1297
  }
1298
+ if (result.error) {
1299
+ throw result.error;
1300
+ }
771
1301
  const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
772
- nodeTaskId = taskIdMatch?.[1];
1302
+ if (taskIdMatch?.[1])
1303
+ nodeTaskId = taskIdMatch[1];
773
1304
  if (result.status !== 0) {
774
1305
  const stderr = result.stderr.trim();
775
1306
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
776
1307
  }
777
1308
  const output = result.stdout.trim();
1309
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
1310
+ if (signal) {
1311
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
1312
+ }
778
1313
  if (nodeSpec.output) {
779
1314
  if (nodeSpec.output_format === 'json') {
780
1315
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -787,36 +1322,41 @@ class RunManager {
787
1322
  retryInput = finalInput +
788
1323
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
789
1324
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
790
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
791
- const retryArgs = [...baseArgs, mode, retryPrompt];
1325
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
1326
+ const retryArgs = [...childInvocation.argv.slice(1), mode, retryPrompt];
792
1327
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
793
1328
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
794
1329
  }
795
1330
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
796
1331
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
1332
+ const retryTaskId = await reserveAgentTaskId();
797
1333
  const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
798
- cwd: process.cwd(),
799
- env: {
800
- ...process.env,
801
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
802
- CREWX_WORKFLOW_NODE_ID: nodeId,
803
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
804
- },
1334
+ cwd: nodeCwd,
1335
+ env: (0, sdk_1.withoutCrewxRootContext)({
1336
+ ...childInvocation.env,
1337
+ CREWX_TRACE_ID: retryTaskId,
1338
+ }),
805
1339
  timeout: nodeTimeout,
806
1340
  shell: retryInvocation.shell ?? false,
807
1341
  windowsHide: retryInvocation.windowsHide,
1342
+ cancellation: this.processCancellation(execId),
808
1343
  });
809
- nodeStdout = retryResult.stdout;
810
- nodeStderr = retryResult.stderr;
811
- nodeExitCode = retryResult.status;
812
- if (retryResult.error)
813
- throw retryResult.error;
1344
+ recordProcessResult(retryResult);
1345
+ if (retryResult.cancelled) {
1346
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent retry cancelled for node "${nodeId}"`);
1347
+ }
814
1348
  if (retryResult.timedOut)
815
1349
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1350
+ if (retryResult.error)
1351
+ throw retryResult.error;
816
1352
  if (retryResult.status !== 0) {
817
1353
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
818
1354
  }
819
1355
  attemptOutput = retryResult.stdout.trim();
1356
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
1357
+ if (retrySignal) {
1358
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
1359
+ }
820
1360
  }
821
1361
  try {
822
1362
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -830,7 +1370,7 @@ class RunManager {
830
1370
  }
831
1371
  }
832
1372
  if (parsed === null) {
833
- throw new Error(`Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
1373
+ throw (0, failure_1.createWorkflowFailureError)('OUTPUT_INVALID', `Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
834
1374
  }
835
1375
  stateUpdates[nodeSpec.output] = parsed;
836
1376
  console.log(`Result stored in state.${nodeSpec.output} (JSON object)`);
@@ -844,52 +1384,64 @@ class RunManager {
844
1384
  }
845
1385
  case 'skill_task': {
846
1386
  if (!nodeSpec.skill) {
847
- throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
1387
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is skill_task but has no skill defined`);
848
1388
  }
849
1389
  if (!shell_security_1.SKILL_NAME_RE.test(nodeSpec.skill)) {
850
- throw new Error(`Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
1390
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
851
1391
  }
852
1392
  const ctx = { ...snapshot.state, state: snapshot.state };
853
1393
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
1394
+ const skillStdin = nodeSpec.stdin !== undefined
1395
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1396
+ : undefined;
854
1397
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
855
- if (!cwdCheck.ok)
856
- throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1398
+ if (!cwdCheck.ok) {
1399
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1400
+ }
1401
+ const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1402
+ if (!skillRootCheck.ok) {
1403
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${skillRootCheck.error}`);
1404
+ }
1405
+ const skillWorkspaceRoot = skillRootCheck.resolved;
857
1406
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
858
1407
  if (opts?.dryRun) {
859
1408
  console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
860
1409
  break;
861
1410
  }
862
1411
  console.log(`Executing skill: ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
863
- const crewxCli = process.env.CREWX_CLI || 'npx crewx';
864
- const cliParts = crewxCli.split(/\s+/);
865
- const bin = cliParts[0];
866
- const baseArgs = cliParts.slice(1);
867
- const skillArgv = [...baseArgs, 'skill', nodeSpec.skill, ...skillArgs];
1412
+ const childInvocation = resolveCrewxChildInvocation({
1413
+ ...process.env,
1414
+ CREWX_WORKSPACE: skillWorkspaceRoot,
1415
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1416
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1417
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1418
+ }, cwdCheck.resolved);
1419
+ const skillArgv = [...childInvocation.argv.slice(1), 'skill', nodeSpec.skill, ...skillArgs];
868
1420
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
869
- command: bin,
1421
+ command: childInvocation.argv[0],
1422
+ env: childInvocation.env,
1423
+ cwd: cwdCheck.resolved,
870
1424
  allowShellFallback: true,
871
1425
  packageName: 'crewx',
872
1426
  });
873
1427
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
874
1428
  const result = await runProcess(invocation.command, invocation.argv, {
875
1429
  cwd: cwdCheck.resolved,
876
- env: {
877
- ...process.env,
878
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
879
- CREWX_WORKFLOW_NODE_ID: nodeId,
880
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
881
- },
1430
+ env: childInvocation.env,
882
1431
  timeout,
883
1432
  shell: invocation.shell ?? false,
884
1433
  windowsHide: invocation.windowsHide,
1434
+ stdin: skillStdin,
1435
+ cancellation: this.processCancellation(execId),
885
1436
  });
886
- nodeStdout = result.stdout;
887
- nodeStderr = result.stderr;
888
- nodeExitCode = result.status;
889
- if (result.error)
890
- throw result.error;
1437
+ recordProcessResult(result);
1438
+ if (result.cancelled) {
1439
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `skill_task "${nodeId}" was cancelled`);
1440
+ }
891
1441
  if (result.timedOut)
892
1442
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1443
+ if (result.error)
1444
+ throw result.error;
893
1445
  if (result.status !== 0) {
894
1446
  throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
895
1447
  }
@@ -903,25 +1455,29 @@ class RunManager {
903
1455
  const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
904
1456
  const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
905
1457
  if (!shellEnvEnabled || !shellMetaAllowed) {
906
- throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
1458
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', 'shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
907
1459
  }
908
1460
  const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
909
- if (!cmdCheck.ok)
910
- throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
1461
+ if (!cmdCheck.ok) {
1462
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cmdCheck.error}`);
1463
+ }
911
1464
  const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
912
- if (!envCheck.ok)
913
- throw new Error(`Node "${nodeId}": ${envCheck.error}`);
1465
+ if (!envCheck.ok) {
1466
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${envCheck.error}`);
1467
+ }
914
1468
  const ctx = { ...snapshot.state, state: snapshot.state };
915
1469
  const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
916
1470
  const postCheck = (0, shell_security_1.validateShellCommand)(command);
917
- if (!postCheck.ok)
918
- throw new Error(`Node "${nodeId}": ${postCheck.error}`);
1471
+ if (!postCheck.ok) {
1472
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${postCheck.error}`);
1473
+ }
919
1474
  const interpolatedEnv = nodeSpec.env
920
1475
  ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
921
1476
  : undefined;
922
1477
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
923
- if (!cwdCheck.ok)
924
- throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1478
+ if (!cwdCheck.ok) {
1479
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1480
+ }
925
1481
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
926
1482
  if (opts?.dryRun) {
927
1483
  console.log(`[dry-run] Would execute: ${command.join(' ')}`);
@@ -932,19 +1488,25 @@ class RunManager {
932
1488
  childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
933
1489
  childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
934
1490
  childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1491
+ const shellStdin = nodeSpec.stdin !== undefined
1492
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1493
+ : undefined;
935
1494
  const result = await runProcess(command[0], command.slice(1), {
936
1495
  cwd: cwdCheck.resolved,
937
1496
  env: childEnv,
938
1497
  timeout,
939
1498
  shell: false,
1499
+ stdin: shellStdin,
1500
+ cancellation: this.processCancellation(execId),
940
1501
  });
941
- nodeStdout = result.stdout;
942
- nodeStderr = result.stderr;
943
- nodeExitCode = result.status;
944
- if (result.error)
945
- throw result.error;
1502
+ recordProcessResult(result);
1503
+ if (result.cancelled) {
1504
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `shell_task "${nodeId}" was cancelled`);
1505
+ }
946
1506
  if (result.timedOut)
947
1507
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1508
+ if (result.error)
1509
+ throw result.error;
948
1510
  if (result.status !== 0) {
949
1511
  throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
950
1512
  }
@@ -956,13 +1518,13 @@ class RunManager {
956
1518
  }
957
1519
  case 'branch': {
958
1520
  if (!nodeSpec.condition) {
959
- throw new Error(`Branch node "${nodeId}" has no condition`);
1521
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch node "${nodeId}" has no condition`);
960
1522
  }
961
1523
  const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
962
1524
  const branches = nodeSpec.branches;
963
1525
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
964
1526
  if (!targetNode) {
965
- throw new Error(`Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
1527
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
966
1528
  }
967
1529
  console.log(`Branch "${nodeId}": condition="${nodeSpec.condition}" → ${condResult} → ${targetNode}`);
968
1530
  nodesToComplete.push(nodeId);
@@ -986,9 +1548,19 @@ class RunManager {
986
1548
  console.log(`End "${nodeId}": workflow execution completed`);
987
1549
  break;
988
1550
  }
1551
+ case 'error': {
1552
+ status = 'failed';
1553
+ failureCode = 'UNKNOWN';
1554
+ const ctx = { ...snapshot.state, state: snapshot.state };
1555
+ failureMessage = nodeSpec.message
1556
+ ? interpolateTemplate(nodeSpec.message, ctx)
1557
+ : `Workflow failed at error node "${nodeId}"`;
1558
+ console.log(`Error "${nodeId}": workflow execution failed — ${failureMessage}`);
1559
+ break;
1560
+ }
989
1561
  case 'expression': {
990
1562
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
991
- throw new Error(`Expression node "${nodeId}" requires "set" field`);
1563
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Expression node "${nodeId}" requires "set" field`);
992
1564
  }
993
1565
  for (const [key, expr] of Object.entries(nodeSpec.set)) {
994
1566
  const result = safeEvaluate(String(expr), snapshot.state);
@@ -1009,30 +1581,61 @@ class RunManager {
1009
1581
  default:
1010
1582
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
1011
1583
  }
1584
+ if (nodeSpec.fail_when &&
1585
+ !opts?.dryRun &&
1586
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1587
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1588
+ if (judged === true || judged === 'true') {
1589
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1590
+ }
1591
+ }
1012
1592
  }
1013
1593
  catch (e) {
1014
- const errMessage = e.message;
1015
- const partial = [
1016
- nodeStdout.trim() ? `stdout: ${nodeStdout.trim().slice(-500)}` : '',
1017
- nodeStderr.trim() ? `stderr: ${nodeStderr.trim().slice(-500)}` : '',
1018
- ].filter(Boolean).join(' | ');
1019
- const auditEntry = buildAudit(partial ? `${errMessage} (${partial})` : errMessage);
1594
+ const errMessage = getErrorMessage(e);
1595
+ const causeCode = (0, failure_1.classifyWorkflowFailure)({
1596
+ errorCode: (0, failure_1.getWorkflowFailureCode)(e),
1597
+ process: processFailure,
1598
+ });
1599
+ const failure = (0, failure_1.buildNodeFailure)({
1600
+ failedNode: nodeId,
1601
+ attempt,
1602
+ causeCode,
1603
+ exitCode: causeCode === 'NODE_EXIT_NONZERO' && typeof nodeExitCode === 'number'
1604
+ ? nodeExitCode
1605
+ : null,
1606
+ signal: causeCode === 'INTERRUPTED' ? nodeSignal : null,
1607
+ errorOrigin: nodeErrorOrigin,
1608
+ timedOut: processFailure?.timedOut ?? false,
1609
+ });
1610
+ const auditEntry = buildAudit(failure.causeMessage);
1020
1611
  await this.atomicUpdateAsync(execId, (run) => {
1021
1612
  if (run.status === 'cancelled')
1022
1613
  return;
1614
+ Object.assign(run.state, stateUpdates);
1615
+ delete run.manual_resume;
1023
1616
  run.current_node = nodeId;
1024
- run.status = 'failed';
1025
- run.error = errMessage;
1617
+ assignRunFailure(run, failure, errMessage);
1026
1618
  run.audit = run.audit ?? [];
1027
1619
  run.audit.push(auditEntry);
1028
1620
  });
1029
1621
  throw e;
1030
1622
  }
1031
- const successAudit = buildAudit();
1623
+ const successFailure = failureCode
1624
+ ? (0, failure_1.buildNodeFailure)({
1625
+ failedNode: nodeId,
1626
+ attempt,
1627
+ causeCode: failureCode,
1628
+ exitCode: null,
1629
+ signal: null,
1630
+ errorOrigin: null,
1631
+ })
1632
+ : null;
1633
+ const successAudit = buildAudit(successFailure?.causeMessage);
1032
1634
  return this.atomicUpdateAsync(execId, (run) => {
1033
1635
  if (run.status === 'cancelled')
1034
1636
  return;
1035
1637
  Object.assign(run.state, stateUpdates);
1638
+ delete run.manual_resume;
1036
1639
  for (const node of nodesToComplete) {
1037
1640
  if (!run.completed_nodes.includes(node)) {
1038
1641
  run.completed_nodes.push(node);
@@ -1042,8 +1645,20 @@ class RunManager {
1042
1645
  run.completed_nodes.push(prevNode);
1043
1646
  }
1044
1647
  run.current_node = currentNode;
1045
- if (status)
1046
- run.status = status;
1648
+ if (successFailure) {
1649
+ assignRunFailure(run, successFailure, failureMessage ?? successFailure.causeMessage);
1650
+ }
1651
+ else {
1652
+ delete run.error;
1653
+ delete run.last_failure;
1654
+ this.clearNodeAttempt(run, nodeId);
1655
+ if (status) {
1656
+ run.status = status;
1657
+ }
1658
+ else if (run.status !== 'completed') {
1659
+ run.status = 'running';
1660
+ }
1661
+ }
1047
1662
  if (nodeTaskId) {
1048
1663
  run.tasks = run.tasks ?? {};
1049
1664
  run.tasks[nodeId] = nodeTaskId;
@@ -1052,122 +1667,458 @@ class RunManager {
1052
1667
  run.audit.push(successAudit);
1053
1668
  });
1054
1669
  }
1055
- async runAuto(execId) {
1056
- const initial = this.loadRun(execId);
1057
- if (!initial)
1058
- throw new Error(`Execution not found: ${execId}`);
1059
- const doc = this.loadWorkflowYaml(initial.workflow_file);
1060
- if (!doc)
1061
- throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1062
- const spec = doc.workflows[initial.workflow_id];
1063
- if (!spec) {
1064
- throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1065
- }
1066
- const executed = [];
1067
- if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1068
- return {
1069
- run: initial,
1070
- outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1071
- reason: `run already ${initial.status}`,
1072
- executed,
1670
+ async claimAutoRunLease(execId, nodeId, preferredToken) {
1671
+ let claimed = false;
1672
+ const updated = await this.atomicUpdateAsync(execId, (run) => {
1673
+ if (isTerminalStatus(run.status))
1674
+ return;
1675
+ const existing = run.auto_run;
1676
+ const ownedByThisProcess = existing?.pid === process.pid && typeof existing.token === 'string';
1677
+ if (existing && !ownedByThisProcess && isProcessAlive(existing.pid)) {
1678
+ throw new Error(`Workflow run "${execId}" is already owned by another runner process`);
1679
+ }
1680
+ const token = preferredToken ?? (ownedByThisProcess ? existing.token : (0, sdk_1.generateId)('wfr'));
1681
+ if (!isTerminalStatus(run.status)) {
1682
+ run.status = 'running';
1683
+ }
1684
+ if (!run.current_node)
1685
+ run.current_node = nodeId;
1686
+ run.auto_run = {
1687
+ pid: process.pid,
1688
+ token,
1689
+ node_id: nodeId,
1690
+ started_at: ownedByThisProcess && existing.token === token
1691
+ ? existing.started_at
1692
+ : new Date().toISOString(),
1073
1693
  };
1694
+ delete run.manual_resume;
1695
+ claimed = true;
1696
+ });
1697
+ return claimed ? updated.auto_run?.token ?? null : null;
1698
+ }
1699
+ async releaseAutoRunLease(execId, token) {
1700
+ await this.atomicUpdateAsync(execId, (run) => {
1701
+ if (run.auto_run?.token === token)
1702
+ delete run.auto_run;
1703
+ });
1704
+ }
1705
+ async failAutoConfiguration(execId, nodeId, reason) {
1706
+ return this.atomicUpdateAsync(execId, (run) => {
1707
+ if (run.status === 'cancelled')
1708
+ return;
1709
+ const failure = (0, failure_1.buildNodeFailure)({
1710
+ failedNode: nodeId,
1711
+ attempt: (0, failure_1.readNodeAttempt)(run, nodeId),
1712
+ causeCode: 'CONFIG_INVALID',
1713
+ exitCode: null,
1714
+ signal: null,
1715
+ errorOrigin: null,
1716
+ });
1717
+ assignRunFailure(run, failure, reason);
1718
+ appendFailureAudit(run, failure, 'config', 'auto');
1719
+ });
1720
+ }
1721
+ async reserveAutoRetry(execId, nodeId, runnerToken, policy, delayMs) {
1722
+ let reserved = false;
1723
+ const run = await this.atomicUpdateAsync(execId, (draft) => {
1724
+ if (draft.status === 'cancelled')
1725
+ return;
1726
+ if (draft.status !== 'failed')
1727
+ return;
1728
+ if (draft.auto_run?.token !== runnerToken)
1729
+ return;
1730
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run: draft, nodeId });
1731
+ if (!decision.retry || decision.attempt === undefined)
1732
+ return;
1733
+ const now = new Date().toISOString();
1734
+ draft.status = 'retrying';
1735
+ delete draft.error;
1736
+ delete draft.manual_resume;
1737
+ draft.audit = draft.audit ?? [];
1738
+ draft.audit.push({
1739
+ exec_id: draft.id,
1740
+ node_id: nodeId,
1741
+ type: 'retry',
1742
+ summary: `auto retry ${decision.attempt + 1}/${policy.max + 1} in ${delayMs}ms`,
1743
+ started_at: now,
1744
+ ended_at: now,
1745
+ duration_ms: 0,
1746
+ exit_code: null,
1747
+ trigger: 'auto',
1748
+ });
1749
+ reserved = true;
1750
+ });
1751
+ return { run, reserved };
1752
+ }
1753
+ async waitForRetry(execId, runnerToken, delayMs) {
1754
+ const inspect = () => {
1755
+ const run = this.loadRun(execId);
1756
+ if (!run)
1757
+ throw new Error(`Execution not found: ${execId}`);
1758
+ if (run.status === 'cancelled') {
1759
+ return { run, outcome: 'cancelled', reason: 'run cancelled during retry delay' };
1760
+ }
1761
+ if (run.auto_run?.token !== runnerToken) {
1762
+ return { run, outcome: 'paused', reason: 'auto-run lease lost during retry delay' };
1763
+ }
1764
+ if (run.status !== 'retrying') {
1765
+ return { run, outcome: 'paused', reason: 'run state changed during retry delay' };
1766
+ }
1767
+ return null;
1768
+ };
1769
+ let remaining = Math.max(0, delayMs);
1770
+ while (remaining > 0) {
1771
+ const slice = Math.min(remaining, RETRY_WAIT_SLICE_MS);
1772
+ await this.sleep(slice);
1773
+ remaining -= slice;
1774
+ const stopped = inspect();
1775
+ if (stopped)
1776
+ return stopped;
1074
1777
  }
1075
- const firstNode = Object.keys(spec.nodes ?? {})[0];
1076
- const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1077
- ? spec.max_iterations
1078
- : 30;
1079
- const visitCounts = {};
1080
- let iterations = 0;
1081
- let run = initial;
1778
+ const stopped = inspect();
1779
+ if (stopped)
1780
+ return stopped;
1781
+ const run = this.loadRun(execId);
1782
+ if (!run)
1783
+ throw new Error(`Execution not found: ${execId}`);
1784
+ return { run, outcome: 'ready' };
1785
+ }
1786
+ async runNodeWithRetry(execId, nodeId, runnerToken, policy) {
1787
+ let token = runnerToken;
1082
1788
  while (true) {
1083
- run = this.loadRun(execId);
1084
- if (run.status === 'completed')
1085
- return { run, outcome: 'completed', executed };
1086
- if (run.status === 'failed')
1087
- return { run, outcome: 'failed', reason: run.error, executed };
1088
- if (run.status === 'cancelled')
1089
- return { run, outcome: 'cancelled', reason: run.error, executed };
1090
- const nodeId = run.current_node || firstNode;
1091
- if (!nodeId)
1092
- return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
1093
- const node = spec.nodes[nodeId];
1094
- if (!node) {
1095
- run = await this.atomicUpdateAsync(execId, (r) => {
1096
- r.current_node = nodeId;
1097
- r.status = 'failed';
1098
- r.error = `node "${nodeId}" not found in workflow`;
1099
- });
1100
- return { run, outcome: 'failed', reason: run.error, executed };
1789
+ try {
1790
+ const run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1791
+ if (run.status === 'cancelled') {
1792
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1793
+ }
1794
+ return { run, runnerToken: token, outcome: 'success' };
1795
+ }
1796
+ catch (caught) {
1797
+ const run = this.loadRun(execId);
1798
+ if (!run)
1799
+ throw new Error(`Execution not found: ${execId}`);
1800
+ if (run.status === 'cancelled') {
1801
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1802
+ }
1803
+ if (run.status !== 'failed' || !policy) {
1804
+ return { run, runnerToken: token, outcome: run.status === 'failed' ? 'failed' : 'paused', reason: getErrorMessage(caught) };
1805
+ }
1806
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run, nodeId });
1807
+ if (!decision.retry || decision.attempt === undefined) {
1808
+ return { run, runnerToken: token, outcome: 'failed', reason: getErrorMessage(caught) };
1809
+ }
1810
+ const delayMs = (0, retry_policy_1.computeRetryDelayMs)(policy, decision.attempt);
1811
+ const reservation = await this.reserveAutoRetry(execId, nodeId, token, policy, delayMs);
1812
+ if (!reservation.reserved) {
1813
+ const latest = reservation.run;
1814
+ if (latest.status === 'cancelled') {
1815
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1816
+ }
1817
+ return {
1818
+ run: latest,
1819
+ runnerToken: token,
1820
+ outcome: latest.status === 'failed' ? 'failed' : 'paused',
1821
+ reason: getErrorMessage(caught),
1822
+ };
1823
+ }
1824
+ const waited = await this.waitForRetry(execId, token, delayMs);
1825
+ if (waited.outcome !== 'ready') {
1826
+ return { run: waited.run, runnerToken: token, outcome: waited.outcome, reason: waited.reason };
1827
+ }
1828
+ const nextToken = await this.claimAutoRunLease(execId, nodeId, token);
1829
+ if (!nextToken) {
1830
+ const latest = this.loadRun(execId);
1831
+ if (!latest)
1832
+ throw new Error(`Execution not found: ${execId}`);
1833
+ if (latest.status === 'cancelled') {
1834
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1835
+ }
1836
+ return {
1837
+ run: latest,
1838
+ runnerToken: token,
1839
+ outcome: 'paused',
1840
+ reason: 'run stopped before retry lease could be reclaimed',
1841
+ };
1842
+ }
1843
+ token = nextToken;
1844
+ }
1845
+ }
1846
+ }
1847
+ async runAuto(execId) {
1848
+ const leaseKey = `${this.resolvedDir}::${execId}`;
1849
+ if (inFlightAutoRuns.has(leaseKey)) {
1850
+ const run = this.loadRun(execId);
1851
+ if (!run)
1852
+ throw new Error(`Execution not found: ${execId}`);
1853
+ console.log(`runAuto "${execId}": already in-flight — skipping duplicate auto-run (single-flight guard)`);
1854
+ return { run, outcome: 'paused', reason: 'execution already in-flight (single-flight guard)', executed: [] };
1855
+ }
1856
+ inFlightAutoRuns.add(leaseKey);
1857
+ let runnerToken;
1858
+ try {
1859
+ const initial = this.loadRun(execId);
1860
+ if (!initial)
1861
+ throw new Error(`Execution not found: ${execId}`);
1862
+ if (initial.auto_run?.pid === process.pid) {
1863
+ runnerToken = initial.auto_run.token;
1101
1864
  }
1102
- if (node.type === 'approval') {
1103
- return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1865
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1866
+ if (!doc) {
1867
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${initial.workflow_file}`);
1104
1868
  }
1105
- if (node.type === 'parallel') {
1106
- return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1869
+ const spec = doc.workflows[initial.workflow_id];
1870
+ if (!spec) {
1871
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1107
1872
  }
1108
- if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1109
- return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1873
+ const executed = [];
1874
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1875
+ return {
1876
+ run: initial,
1877
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1878
+ reason: `run already ${initial.status}`,
1879
+ executed,
1880
+ };
1110
1881
  }
1111
- visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1112
- iterations++;
1113
- if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1882
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1883
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1884
+ ? spec.max_iterations
1885
+ : 30;
1886
+ let run = initial;
1887
+ while (true) {
1888
+ run = this.loadRun(execId);
1889
+ if (run.status === 'completed')
1890
+ return { run, outcome: 'completed', executed };
1891
+ if (run.status === 'failed')
1892
+ return { run, outcome: 'failed', reason: run.error, executed };
1893
+ if (run.status === 'cancelled')
1894
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1895
+ const nodeId = run.current_node || firstNode;
1896
+ if (!nodeId) {
1897
+ run = await this.atomicUpdateAsync(execId, (r) => {
1898
+ const failure = (0, failure_1.buildNodeFailure)({
1899
+ failedNode: r.current_node || '',
1900
+ attempt: (0, failure_1.readNodeAttempt)(r, r.current_node || ''),
1901
+ causeCode: 'CONFIG_INVALID',
1902
+ exitCode: null,
1903
+ signal: null,
1904
+ errorOrigin: null,
1905
+ });
1906
+ assignRunFailure(r, failure, 'workflow has no nodes');
1907
+ appendFailureAudit(r, failure, 'config', 'auto');
1908
+ });
1909
+ return { run, outcome: 'failed', reason: run.error, executed };
1910
+ }
1911
+ const node = spec.nodes[nodeId];
1912
+ if (!node) {
1913
+ run = await this.atomicUpdateAsync(execId, (r) => {
1914
+ r.current_node = nodeId;
1915
+ const failure = (0, failure_1.buildNodeFailure)({
1916
+ failedNode: nodeId,
1917
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1918
+ causeCode: 'CONFIG_INVALID',
1919
+ exitCode: null,
1920
+ signal: null,
1921
+ errorOrigin: null,
1922
+ });
1923
+ assignRunFailure(r, failure, `node "${nodeId}" not found in workflow`);
1924
+ appendFailureAudit(r, failure, 'config', 'auto');
1925
+ });
1926
+ return { run, outcome: 'failed', reason: run.error, executed };
1927
+ }
1928
+ const parsedRetryPolicy = (0, retry_policy_1.parseRetryPolicy)(node);
1929
+ if (!parsedRetryPolicy.ok) {
1930
+ run = await this.failAutoConfiguration(execId, nodeId, parsedRetryPolicy.reason);
1931
+ if (run.status === 'cancelled') {
1932
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1933
+ }
1934
+ return { run, outcome: 'failed', reason: run.error, executed };
1935
+ }
1936
+ if (node.type === 'approval') {
1937
+ if (run.current_node !== nodeId) {
1938
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1939
+ }
1940
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1941
+ }
1942
+ if (node.type === 'parallel') {
1943
+ if (run.current_node !== nodeId) {
1944
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1945
+ }
1946
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1947
+ }
1948
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1949
+ if (run.current_node !== nodeId) {
1950
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1951
+ }
1952
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1953
+ }
1954
+ const claimedToken = await this.claimAutoRunLease(execId, nodeId, runnerToken);
1955
+ if (!claimedToken) {
1956
+ run = this.loadRun(execId);
1957
+ return {
1958
+ run,
1959
+ outcome: run.status === 'cancelled' ? 'cancelled' : 'paused',
1960
+ reason: run.status === 'cancelled' ? run.error : 'run stopped before auto-run lease could be claimed',
1961
+ executed,
1962
+ };
1963
+ }
1964
+ runnerToken = claimedToken;
1114
1965
  run = await this.atomicUpdateAsync(execId, (r) => {
1115
- r.status = 'failed';
1116
- r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1966
+ if (r.status === 'cancelled')
1967
+ return;
1968
+ const engine = readRunEngineState(r);
1969
+ engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1970
+ engine.iterations += 1;
1971
+ r.state['__engine'] = engine;
1972
+ if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1973
+ const failure = (0, failure_1.buildNodeFailure)({
1974
+ failedNode: nodeId,
1975
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1976
+ causeCode: 'CONFIG_INVALID',
1977
+ exitCode: null,
1978
+ signal: null,
1979
+ errorOrigin: null,
1980
+ });
1981
+ assignRunFailure(r, failure, `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`);
1982
+ appendFailureAudit(r, failure, 'config', 'auto');
1983
+ }
1117
1984
  });
1118
- return { run, outcome: 'failed', reason: run.error, executed };
1119
- }
1120
- try {
1121
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1985
+ if (run.status === 'cancelled') {
1986
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1987
+ }
1988
+ if (run.status === 'failed') {
1989
+ return { run, outcome: 'failed', reason: run.error, executed };
1990
+ }
1991
+ const nodeResult = await this.runNodeWithRetry(execId, nodeId, runnerToken, parsedRetryPolicy.policy);
1992
+ run = nodeResult.run;
1993
+ runnerToken = nodeResult.runnerToken;
1994
+ if (nodeResult.outcome !== 'success') {
1995
+ return { run, outcome: nodeResult.outcome, reason: nodeResult.reason, executed };
1996
+ }
1122
1997
  executed.push(nodeId);
1998
+ if (run.status === 'completed')
1999
+ return { run, outcome: 'completed', executed };
2000
+ if (run.status === 'failed')
2001
+ return { run, outcome: 'failed', reason: run.error, executed };
2002
+ if (run.status === 'cancelled')
2003
+ return { run, outcome: 'cancelled', reason: run.error, executed };
2004
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
2005
+ const next = node.next;
2006
+ if (!next) {
2007
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
2008
+ }
2009
+ run = this.moveNode(execId, next);
2010
+ }
1123
2011
  }
1124
- catch (e) {
1125
- run = this.loadRun(execId);
1126
- return { run, outcome: 'failed', reason: e.message, executed };
1127
- }
1128
- if (run.status === 'completed')
1129
- return { run, outcome: 'completed', executed };
1130
- if (run.status === 'failed')
1131
- return { run, outcome: 'failed', reason: run.error, executed };
1132
- if (run.status === 'cancelled')
1133
- return { run, outcome: 'cancelled', reason: run.error, executed };
1134
- if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1135
- const next = node.next;
1136
- if (!next) {
1137
- return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
2012
+ }
2013
+ finally {
2014
+ if (runnerToken) {
2015
+ try {
2016
+ await this.releaseAutoRunLease(execId, runnerToken);
2017
+ }
2018
+ catch {
1138
2019
  }
1139
- run = this.moveNode(execId, next);
1140
2020
  }
2021
+ inFlightAutoRuns.delete(leaseKey);
1141
2022
  }
1142
2023
  }
1143
2024
  cancelRun(execId, reason = 'Cancelled by user') {
1144
- return this.atomicUpdate(execId, (run) => {
2025
+ const cancelled = this.atomicUpdate(execId, (run) => {
1145
2026
  if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1146
2027
  return;
1147
2028
  }
1148
2029
  const now = new Date();
1149
- run.status = 'cancelled';
1150
- run.error = reason;
1151
- run.audit = run.audit ?? [];
1152
- run.audit.push({
1153
- exec_id: run.id,
1154
- node_id: run.current_node || '',
1155
- type: 'cancel',
1156
- summary: reason,
1157
- started_at: now.toISOString(),
1158
- ended_at: now.toISOString(),
1159
- duration_ms: 0,
1160
- exit_code: null,
1161
- error: reason,
1162
- trigger: 'manual',
2030
+ const activeNodeId = run.auto_run?.node_id || run.current_node || '';
2031
+ const failure = (0, failure_1.buildNodeFailure)({
2032
+ failedNode: activeNodeId,
2033
+ attempt: (0, failure_1.readNodeAttempt)(run, activeNodeId),
2034
+ causeCode: 'CANCELLED',
2035
+ exitCode: null,
2036
+ signal: null,
2037
+ errorOrigin: null,
1163
2038
  });
2039
+ assignRunFailure(run, failure, reason);
2040
+ run.status = 'cancelled';
2041
+ delete run.auto_run;
2042
+ delete run.manual_resume;
2043
+ appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
1164
2044
  });
2045
+ if (cancelled.status === 'cancelled') {
2046
+ requestActiveProcessCancellation(this.resolvedDir, execId);
2047
+ }
2048
+ return cancelled;
1165
2049
  }
1166
2050
  reset(execId) {
1167
2051
  return this.atomicUpdate(execId, (run) => {
1168
2052
  run.state = JSON.parse(JSON.stringify(run.initial_state));
1169
2053
  run.current_node = '';
1170
2054
  run.completed_nodes = [];
2055
+ run.status = 'pending';
2056
+ delete run.error;
2057
+ delete run.last_failure;
2058
+ delete run.node_attempts;
2059
+ delete run.auto_run;
2060
+ delete run.manual_resume;
2061
+ });
2062
+ }
2063
+ retry(execId, options = {}) {
2064
+ const snapshot = this.loadRun(execId);
2065
+ if (!snapshot)
2066
+ throw new Error(`Execution not found: ${execId}`);
2067
+ const doc = this.loadWorkflowYaml(snapshot.workflow_file);
2068
+ if (!doc)
2069
+ throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
2070
+ const spec = doc.workflows[snapshot.workflow_id];
2071
+ if (!spec)
2072
+ throw new Error(`Workflow "${snapshot.workflow_id}" not found`);
2073
+ if (snapshot.status !== 'failed') {
2074
+ throw new Error(`Run "${execId}" is not failed (status=${snapshot.status ?? 'running'}); only failed runs can be retried`);
2075
+ }
2076
+ if (!isRetryableFailure(snapshot, spec)) {
2077
+ throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
2078
+ }
2079
+ return this.atomicUpdate(execId, (run) => {
2080
+ if (run.status !== 'failed') {
2081
+ throw new Error(`Run "${execId}" is not failed (status=${run.status ?? 'pending'}); only failed runs can be retried`);
2082
+ }
2083
+ const failedNode = run.current_node;
2084
+ if (failedNode && run.last_failure) {
2085
+ const lastAttempt = run.last_failure.attempt;
2086
+ const storedAttempt = run.node_attempts?.[failedNode];
2087
+ if (typeof lastAttempt === 'number' && Number.isInteger(lastAttempt) && lastAttempt > 0
2088
+ && (typeof storedAttempt !== 'number' || storedAttempt < lastAttempt)) {
2089
+ run.node_attempts = run.node_attempts ?? {};
2090
+ run.node_attempts[failedNode] = lastAttempt;
2091
+ }
2092
+ }
2093
+ run.status = 'retrying';
2094
+ delete run.error;
2095
+ delete run.manual_resume;
2096
+ run.retry_count = (run.retry_count ?? 0) + 1;
2097
+ run.audit = run.audit ?? [];
2098
+ const now = new Date().toISOString();
2099
+ if (options.autoRun === false) {
2100
+ delete run.auto_run;
2101
+ run.manual_resume = { node_id: failedNode, requested_at: now };
2102
+ }
2103
+ else {
2104
+ run.auto_run = {
2105
+ pid: process.pid,
2106
+ token: (0, sdk_1.generateId)('wfr'),
2107
+ node_id: failedNode,
2108
+ started_at: now,
2109
+ };
2110
+ }
2111
+ run.audit.push({
2112
+ exec_id: run.id,
2113
+ node_id: failedNode,
2114
+ type: 'retry',
2115
+ summary: `manual retry #${run.retry_count}`,
2116
+ started_at: now,
2117
+ ended_at: now,
2118
+ duration_ms: 0,
2119
+ exit_code: null,
2120
+ trigger: 'manual',
2121
+ });
1171
2122
  });
1172
2123
  }
1173
2124
  listRuns(opts) {
@@ -1176,7 +2127,10 @@ class RunManager {
1176
2127
  const runs = [];
1177
2128
  for (const file of files) {
1178
2129
  try {
1179
- const run = JSON.parse(fs.readFileSync(path.join(this.resolvedDir, file), 'utf-8'));
2130
+ const execId = file.slice(0, -'.json'.length);
2131
+ const run = this.loadRun(execId);
2132
+ if (!run)
2133
+ continue;
1180
2134
  if (opts?.workflowId && run.workflow_id !== opts.workflowId)
1181
2135
  continue;
1182
2136
  runs.push(run);