@crewx/workflow 0.3.22-rc.14 → 0.3.22-rc.140

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