@crewx/workflow 0.3.22-rc.13 → 0.3.22-rc.131

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 (38) hide show
  1. package/SKILL.md +38 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +78 -9
  4. package/dist/cli.js.map +1 -1
  5. package/dist/src/engine.d.ts +4 -2
  6. package/dist/src/engine.d.ts.map +1 -1
  7. package/dist/src/engine.js +57 -2
  8. package/dist/src/engine.js.map +1 -1
  9. package/dist/src/failure.d.ts +41 -0
  10. package/dist/src/failure.d.ts.map +1 -0
  11. package/dist/src/failure.js +122 -0
  12. package/dist/src/failure.js.map +1 -0
  13. package/dist/src/mermaid.d.ts.map +1 -1
  14. package/dist/src/mermaid.js +7 -4
  15. package/dist/src/mermaid.js.map +1 -1
  16. package/dist/src/parent-task-sync.d.ts +49 -0
  17. package/dist/src/parent-task-sync.d.ts.map +1 -0
  18. package/dist/src/parent-task-sync.js +132 -0
  19. package/dist/src/parent-task-sync.js.map +1 -0
  20. package/dist/src/retry-policy.d.ts +38 -0
  21. package/dist/src/retry-policy.d.ts.map +1 -0
  22. package/dist/src/retry-policy.js +140 -0
  23. package/dist/src/retry-policy.js.map +1 -0
  24. package/dist/src/run-manager.d.ts +28 -2
  25. package/dist/src/run-manager.d.ts.map +1 -1
  26. package/dist/src/run-manager.js +935 -210
  27. package/dist/src/run-manager.js.map +1 -1
  28. package/dist/src/types.d.ts +39 -4
  29. package/dist/src/types.d.ts.map +1 -1
  30. package/dist/src/types.js +2 -0
  31. package/dist/src/types.js.map +1 -1
  32. package/dist/src/utils/output-format.d.ts +12 -0
  33. package/dist/src/utils/output-format.d.ts.map +1 -1
  34. package/dist/src/utils/output-format.js +103 -5
  35. package/dist/src/utils/output-format.js.map +1 -1
  36. package/dist/workflow-schema.json +34 -5
  37. package/package.json +14 -4
  38. package/workflow-schema.json +34 -5
@@ -33,26 +33,53 @@ 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.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_RUN_INTERRUPTED_ERROR = 'Workflow run interrupted: runner process exited before completion';
59
+ exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS = 10 * 60000;
60
+ const RETRY_WAIT_SLICE_MS = 1000;
61
+ function sleep(ms) {
62
+ return new Promise((resolve) => {
63
+ setTimeout(resolve, ms);
64
+ });
65
+ }
52
66
  const KNOWN_AUTO_NODE_TYPES = new Set([
53
- 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end',
67
+ 'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
54
68
  ]);
55
- const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end']);
69
+ const SELF_ADVANCING_NODE_TYPES = new Set(['branch', 'expression', 'end', 'error']);
70
+ function resolveCrewxChildInvocation(env, cwd) {
71
+ const resolution = (0, sdk_1.resolveCrewxExecutable)({ env, cwd });
72
+ if (!resolution.ok) {
73
+ throw (0, failure_1.createWorkflowFailureError)('CLI_UNRESOLVABLE', (0, sdk_1.formatCrewxExecutableFailure)(resolution));
74
+ }
75
+ return {
76
+ argv: resolution.argv,
77
+ env: {
78
+ ...env,
79
+ CREWX_CLI_ARGV: JSON.stringify(resolution.argv),
80
+ },
81
+ };
82
+ }
56
83
  function killProcessGroup(child) {
57
84
  try {
58
85
  if (process.platform === 'win32') {
@@ -81,19 +108,30 @@ async function runProcess(command, argv, opts) {
81
108
  child = cp.spawn(command, argv, {
82
109
  cwd: opts.cwd,
83
110
  env: opts.env,
84
- stdio: ['ignore', 'pipe', 'pipe'],
111
+ stdio: [opts.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
85
112
  shell: opts.shell ?? false,
86
113
  detached: process.platform !== 'win32',
87
114
  windowsHide: opts.windowsHide ?? true,
88
115
  });
89
116
  }
90
117
  catch (err) {
91
- resolve({ status: null, stdout: '', stderr: '', timedOut: false, error: err });
118
+ resolve({
119
+ status: null,
120
+ stdout: '',
121
+ stderr: '',
122
+ timedOut: false,
123
+ signal: null,
124
+ errorOrigin: 'spawn',
125
+ error: err,
126
+ });
92
127
  return;
93
128
  }
94
129
  let stdout = '';
95
130
  let stderr = '';
96
131
  let settled = false;
132
+ let spawned = false;
133
+ let postSpawnError;
134
+ let stdinError;
97
135
  const finish = (result) => {
98
136
  if (settled)
99
137
  return;
@@ -103,35 +141,120 @@ async function runProcess(command, argv, opts) {
103
141
  };
104
142
  const timer = setTimeout(() => {
105
143
  killProcessGroup(child);
106
- finish({ status: null, stdout, stderr, timedOut: true });
144
+ finish({
145
+ status: null,
146
+ stdout,
147
+ stderr,
148
+ timedOut: true,
149
+ signal: null,
150
+ errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
151
+ ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
152
+ });
107
153
  }, opts.timeout);
108
154
  if (typeof timer.unref === 'function')
109
155
  timer.unref();
110
156
  child.stdout?.on('data', (d) => { stdout += d.toString(); });
111
157
  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 }));
158
+ child.once('spawn', () => { spawned = true; });
159
+ child.on('error', (err) => {
160
+ if (!spawned) {
161
+ finish({
162
+ status: null,
163
+ stdout,
164
+ stderr,
165
+ timedOut: false,
166
+ signal: null,
167
+ errorOrigin: 'spawn',
168
+ error: err,
169
+ });
170
+ return;
171
+ }
172
+ postSpawnError = postSpawnError ?? err;
173
+ });
174
+ if (opts.stdin !== undefined) {
175
+ child.stdin?.on('error', (err) => {
176
+ stdinError = stdinError ?? err;
177
+ });
178
+ try {
179
+ child.stdin?.end(opts.stdin);
180
+ }
181
+ catch (err) {
182
+ stdinError = stdinError ?? err;
183
+ }
184
+ }
185
+ child.on('close', (code, signal) => finish({
186
+ status: code,
187
+ stdout,
188
+ stderr,
189
+ timedOut: false,
190
+ signal,
191
+ errorOrigin: stdinError ? 'stdin' : postSpawnError ? 'post_spawn' : null,
192
+ ...(stdinError || postSpawnError ? { error: stdinError ?? postSpawnError } : {}),
193
+ }));
114
194
  });
115
195
  }
116
- function maskArg(arg) {
117
- return arg.replace(/^(--?[\w-]*(?:token|secret|password|passwd|key|auth)[\w-]*)=(.+)$/i, '$1=***');
196
+ const AUDIT_IDENTIFIER_RE = /^[A-Za-z0-9_.-]{1,128}$/;
197
+ function safeAuditIdentifier(value) {
198
+ if (value === undefined)
199
+ return undefined;
200
+ const match = AUDIT_IDENTIFIER_RE.exec(value);
201
+ return match?.[0] === value ? value : undefined;
118
202
  }
119
203
  function summarizeNode(node) {
120
204
  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();
205
+ case 'agent_task': {
206
+ const agent = safeAuditIdentifier(node.agent);
207
+ return agent
208
+ ? `@${agent} (${node.mode === 'query' ? 'q' : 'x'})`
209
+ : `agent_task (${node.mode === 'query' ? 'q' : 'x'})`;
210
+ }
211
+ case 'skill_task': {
212
+ const skill = safeAuditIdentifier(node.skill);
213
+ return skill ? `skill ${skill}` : 'skill_task';
214
+ }
125
215
  case 'shell_task':
126
- return (node.command ?? []).map(maskArg).join(' ');
216
+ return 'shell_task';
127
217
  case 'branch':
128
- return `branch: ${node.condition ?? ''}`;
218
+ return 'branch';
129
219
  case 'expression':
130
- return `set ${Object.keys(node.set ?? {}).join(', ')}`;
220
+ return 'expression';
131
221
  default:
132
- return node.type;
222
+ return 'workflow node';
133
223
  }
134
224
  }
225
+ function getErrorMessage(error) {
226
+ if (error instanceof Error)
227
+ return error.message;
228
+ return typeof error === 'string' ? error : 'Node execution failed';
229
+ }
230
+ function toProcessFailureMetadata(result) {
231
+ return {
232
+ status: result.status,
233
+ timedOut: result.timedOut,
234
+ signal: result.signal,
235
+ errorOrigin: result.errorOrigin,
236
+ };
237
+ }
238
+ function appendFailureAudit(run, failure, type, trigger, now = new Date().toISOString()) {
239
+ run.audit = run.audit ?? [];
240
+ run.audit.push({
241
+ exec_id: run.id,
242
+ node_id: failure.failedNode,
243
+ type,
244
+ summary: failure.causeMessage,
245
+ started_at: now,
246
+ ended_at: now,
247
+ duration_ms: 0,
248
+ exit_code: failure.exitCode,
249
+ error: failure.causeMessage,
250
+ trigger,
251
+ });
252
+ }
253
+ function assignRunFailure(run, failure, rawError) {
254
+ run.status = 'failed';
255
+ run.error = (0, failure_1.sanitizeLocalRunError)(rawError, failure.causeMessage);
256
+ run.last_failure = failure;
257
+ }
135
258
  async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
136
259
  if (!nodeSpec.output)
137
260
  return;
@@ -140,12 +263,12 @@ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
140
263
  let parsed;
141
264
  try {
142
265
  parsed = JSON.parse((0, output_format_1.extractJson)(trimmed));
266
+ if (nodeSpec.output_schema) {
267
+ await (0, output_format_1.validateJsonSchema)(parsed, nodeSpec.output_schema);
268
+ }
143
269
  }
144
270
  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);
271
+ throw (0, failure_1.createWorkflowFailureError)('OUTPUT_INVALID', `Node "${nodeId}" output_format=json parse or validation failed: ${getErrorMessage(e)}`);
149
272
  }
150
273
  stateUpdates[nodeSpec.output] = parsed;
151
274
  }
@@ -521,12 +644,91 @@ function safeEvaluateBranchCondition(expr, state) {
521
644
  function validateSafeBranchCondition(expr) {
522
645
  safeEvaluateBranchCondition(expr, {});
523
646
  }
647
+ function readRunEngineState(run) {
648
+ const raw = run.state['__engine'];
649
+ if (!raw || typeof raw !== 'object')
650
+ return { visitCounts: {}, iterations: 0 };
651
+ const record = raw;
652
+ const rawCounts = record['visitCounts'];
653
+ const visitCounts = {};
654
+ if (rawCounts && typeof rawCounts === 'object') {
655
+ for (const [key, value] of Object.entries(rawCounts)) {
656
+ if (typeof value === 'number' && Number.isFinite(value))
657
+ visitCounts[key] = value;
658
+ }
659
+ }
660
+ const iterations = typeof record['iterations'] === 'number' && Number.isFinite(record['iterations'])
661
+ ? record['iterations']
662
+ : 0;
663
+ return { visitCounts, iterations };
664
+ }
665
+ const inFlightAutoRuns = new Set();
666
+ function isProcessAlive(pid) {
667
+ if (!Number.isInteger(pid) || pid <= 0)
668
+ return false;
669
+ if (pid === process.pid)
670
+ return true;
671
+ try {
672
+ process.kill(pid, 0);
673
+ return true;
674
+ }
675
+ catch (error) {
676
+ return error.code === 'EPERM';
677
+ }
678
+ }
679
+ function isTerminalStatus(status) {
680
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
681
+ }
682
+ function isRecentLegacyRetry(run, nowMs) {
683
+ if (run.status !== undefined || run.auto_run)
684
+ return false;
685
+ const lastAudit = run.audit?.at(-1);
686
+ if (lastAudit?.type !== 'retry')
687
+ return false;
688
+ const updatedAt = Date.parse(run.updated_at);
689
+ return Number.isFinite(updatedAt)
690
+ && nowMs - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS;
691
+ }
692
+ function normalizeRunStatus(run, nowMs = Date.now()) {
693
+ if (run.status !== undefined)
694
+ return run;
695
+ let status;
696
+ if (run.current_node === '') {
697
+ status = 'pending';
698
+ }
699
+ else if (run.auto_run) {
700
+ status = 'running';
701
+ }
702
+ else if (isRecentLegacyRetry(run, nowMs)) {
703
+ status = 'retrying';
704
+ }
705
+ else {
706
+ status = 'pending';
707
+ }
708
+ return { ...run, status };
709
+ }
710
+ function isRetryableFailure(run, spec) {
711
+ if (run.status !== 'failed')
712
+ return false;
713
+ if (!run.current_node)
714
+ return false;
715
+ const node = spec.nodes?.[run.current_node];
716
+ if (!node)
717
+ return false;
718
+ return node.type !== 'error';
719
+ }
524
720
  class RunManager {
525
- constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
721
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot, deps) {
526
722
  this.runsDir = runsDir;
723
+ this.sleep = deps?.sleep ?? sleep;
527
724
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
528
725
  this.resolvedDir = path.resolve(base, this.runsDir);
529
726
  this.projectRoot = path.resolve(base);
727
+ const hasConfig = fs.existsSync(path.join(this.projectRoot, 'crewx.yaml'))
728
+ || fs.existsSync(path.join(this.projectRoot, 'crewx.yml'));
729
+ if (!hasConfig) {
730
+ console.warn(`[RunManager] projectRoot "${this.projectRoot}" has no crewx.yaml/crewx.yml — agent_task nodes may fail to resolve agents.`);
731
+ }
530
732
  }
531
733
  ensureRunsDir() {
532
734
  if (!fs.existsSync(this.resolvedDir)) {
@@ -538,8 +740,7 @@ class RunManager {
538
740
  throw new Error(`Invalid execution ID: "${execId}"`);
539
741
  }
540
742
  }
541
- loadRun(execId) {
542
- this.validateExecId(execId);
743
+ readRunRaw(execId) {
543
744
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
544
745
  if (!fs.existsSync(filePath))
545
746
  return null;
@@ -550,6 +751,75 @@ class RunManager {
550
751
  throw new Error(`Failed to parse execution file ${filePath}: ${e.message}`);
551
752
  }
552
753
  }
754
+ readRun(execId) {
755
+ const run = this.readRunRaw(execId);
756
+ return run ? normalizeRunStatus(run) : null;
757
+ }
758
+ isLegacyStatuslessStale(run) {
759
+ if (run.status !== undefined
760
+ || run.auto_run
761
+ || run.manual_resume
762
+ || !run.current_node
763
+ || !run.audit?.length) {
764
+ return false;
765
+ }
766
+ const updatedAt = Date.parse(run.updated_at);
767
+ if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS) {
768
+ return false;
769
+ }
770
+ const doc = this.loadWorkflowYaml(run.workflow_file);
771
+ const spec = doc?.workflows[run.workflow_id];
772
+ const node = spec?.nodes?.[run.current_node];
773
+ return Boolean(node && KNOWN_AUTO_NODE_TYPES.has(node.type));
774
+ }
775
+ hasStaleRunner(run) {
776
+ if (run.auto_run)
777
+ return !isProcessAlive(run.auto_run.pid);
778
+ return this.isLegacyStatuslessStale(run);
779
+ }
780
+ recoverStaleRunner(execId) {
781
+ const filePath = path.join(this.resolvedDir, `${execId}.json`);
782
+ const lock = new file_lock_1.FileLock(filePath);
783
+ lock.acquire();
784
+ try {
785
+ const run = this.readRunRaw(execId);
786
+ if (!run || !this.hasStaleRunner(run))
787
+ return run ? normalizeRunStatus(run) : null;
788
+ if (isTerminalStatus(run.status)) {
789
+ delete run.auto_run;
790
+ delete run.manual_resume;
791
+ this.saveRun(run);
792
+ return run;
793
+ }
794
+ const lease = run.auto_run;
795
+ const nodeId = lease?.node_id || run.current_node;
796
+ const now = new Date().toISOString();
797
+ const failure = (0, failure_1.buildNodeFailure)({
798
+ failedNode: nodeId,
799
+ attempt: (0, failure_1.readNodeAttempt)(run, nodeId),
800
+ causeCode: 'INTERRUPTED',
801
+ exitCode: null,
802
+ signal: null,
803
+ errorOrigin: null,
804
+ });
805
+ assignRunFailure(run, failure, exports.WORKFLOW_RUN_INTERRUPTED_ERROR);
806
+ delete run.auto_run;
807
+ delete run.manual_resume;
808
+ appendFailureAudit(run, failure, 'interrupted', 'auto', now);
809
+ this.saveRun(run);
810
+ return run;
811
+ }
812
+ finally {
813
+ lock.release();
814
+ }
815
+ }
816
+ loadRun(execId) {
817
+ this.validateExecId(execId);
818
+ const run = this.readRunRaw(execId);
819
+ if (!run || !this.hasStaleRunner(run))
820
+ return run ? normalizeRunStatus(run) : null;
821
+ return this.recoverStaleRunner(execId);
822
+ }
553
823
  saveRun(run) {
554
824
  this.ensureRunsDir();
555
825
  run.updated_at = new Date().toISOString();
@@ -563,7 +833,7 @@ class RunManager {
563
833
  const lock = new file_lock_1.FileLock(filePath);
564
834
  lock.acquire();
565
835
  try {
566
- const run = this.loadRun(execId);
836
+ const run = this.readRun(execId);
567
837
  if (!run)
568
838
  throw new Error(`Execution not found: ${execId}`);
569
839
  updater(run);
@@ -581,7 +851,7 @@ class RunManager {
581
851
  const lock = new file_lock_1.FileLock(filePath);
582
852
  await lock.acquireAsync();
583
853
  try {
584
- const run = this.loadRun(execId);
854
+ const run = this.readRun(execId);
585
855
  if (!run)
586
856
  throw new Error(`Execution not found: ${execId}`);
587
857
  updater(run);
@@ -592,6 +862,32 @@ class RunManager {
592
862
  lock.release();
593
863
  }
594
864
  }
865
+ async reserveNodeAttempt(execId, nodeId) {
866
+ let reservedAttempt = 1;
867
+ await this.atomicUpdateAsync(execId, (run) => {
868
+ if (run.status === 'cancelled') {
869
+ reservedAttempt = (0, failure_1.readNodeAttempt)(run, nodeId);
870
+ return;
871
+ }
872
+ const storedAttempt = run.node_attempts?.[nodeId];
873
+ const previousAttempt = typeof storedAttempt === 'number'
874
+ && Number.isInteger(storedAttempt)
875
+ && storedAttempt > 0
876
+ ? storedAttempt
877
+ : 0;
878
+ reservedAttempt = previousAttempt + 1;
879
+ run.node_attempts = run.node_attempts ?? {};
880
+ run.node_attempts[nodeId] = reservedAttempt;
881
+ });
882
+ return reservedAttempt;
883
+ }
884
+ clearNodeAttempt(run, nodeId) {
885
+ if (!run.node_attempts)
886
+ return;
887
+ delete run.node_attempts[nodeId];
888
+ if (Object.keys(run.node_attempts).length === 0)
889
+ delete run.node_attempts;
890
+ }
595
891
  loadWorkflowYaml(yamlPath) {
596
892
  if (!fs.existsSync(yamlPath))
597
893
  return null;
@@ -601,6 +897,12 @@ class RunManager {
601
897
  return null;
602
898
  return doc;
603
899
  }
900
+ getNodeType(workflowFile, workflowId, nodeId) {
901
+ const doc = this.loadWorkflowYaml(workflowFile);
902
+ const spec = doc?.workflows[workflowId];
903
+ const node = spec?.nodes?.[nodeId];
904
+ return typeof node?.type === 'string' ? node.type : null;
905
+ }
604
906
  start(yamlPath, workflowId, overrides) {
605
907
  const doc = this.loadWorkflowYaml(yamlPath);
606
908
  if (!doc) {
@@ -638,6 +940,7 @@ class RunManager {
638
940
  completed_nodes: [],
639
941
  state,
640
942
  initial_state: { ...initialState, ...sets },
943
+ status: 'pending',
641
944
  };
642
945
  this.saveRun(run);
643
946
  return run;
@@ -669,27 +972,36 @@ class RunManager {
669
972
  }
670
973
  const doc = this.loadWorkflowYaml(snapshot.workflow_file);
671
974
  if (!doc) {
672
- throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
975
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${snapshot.workflow_file}`);
673
976
  }
674
977
  const spec = doc.workflows[snapshot.workflow_id];
675
978
  if (!spec) {
676
- throw new Error(`Workflow "${snapshot.workflow_id}" not found in ${snapshot.workflow_file}`);
979
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${snapshot.workflow_id}" not found in ${snapshot.workflow_file}`);
677
980
  }
678
981
  const nodeSpec = spec.nodes?.[nodeId];
679
982
  if (!nodeSpec) {
680
- throw new Error(`Node "${nodeId}" not found in workflow "${snapshot.workflow_id}"`);
983
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" not found in workflow "${snapshot.workflow_id}"`);
681
984
  }
682
985
  const prevNode = snapshot.current_node;
683
986
  const stateUpdates = {};
684
987
  const nodesToComplete = [];
685
988
  let currentNode = nodeId;
686
989
  let status;
990
+ let failureMessage;
991
+ let failureCode;
687
992
  let nodeTaskId;
688
993
  const trigger = opts?.trigger ?? 'manual';
689
994
  const startedAt = new Date();
690
- let nodeStdout = '';
691
- let nodeStderr = '';
692
995
  let nodeExitCode = null;
996
+ let nodeSignal = null;
997
+ let nodeErrorOrigin = null;
998
+ let processFailure;
999
+ const recordProcessResult = (result) => {
1000
+ nodeExitCode = result.status;
1001
+ nodeSignal = result.signal;
1002
+ nodeErrorOrigin = result.errorOrigin;
1003
+ processFailure = toProcessFailureMetadata(result);
1004
+ };
693
1005
  const buildAudit = (error) => {
694
1006
  const endedAt = new Date();
695
1007
  return {
@@ -705,31 +1017,57 @@ class RunManager {
705
1017
  trigger,
706
1018
  };
707
1019
  };
1020
+ const attempt = await this.reserveNodeAttempt(execId, nodeId);
708
1021
  try {
709
1022
  switch (nodeSpec.type) {
710
1023
  case 'agent_task': {
711
1024
  if (!nodeSpec.agent) {
712
- throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
1025
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is agent_task but has no agent defined`);
1026
+ }
1027
+ const agentCtx = { ...snapshot.state, state: snapshot.state };
1028
+ const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
1029
+ if (!resolvedAgent) {
1030
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
713
1031
  }
1032
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
1033
+ if (!cwdCheck.ok) {
1034
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1035
+ }
1036
+ const nodeCwd = cwdCheck.resolved;
1037
+ const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1038
+ if (!rootCheck.ok) {
1039
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${rootCheck.error}`);
1040
+ }
1041
+ const workspaceRoot = rootCheck.resolved;
714
1042
  const input = nodeSpec.input
715
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
1043
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
716
1044
  : '';
1045
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
1046
+ ? ''
1047
+ : (0, output_format_1.buildWorkflowPreamble)({
1048
+ workflowId: snapshot.workflow_id,
1049
+ nodeId,
1050
+ execId: snapshot.id,
1051
+ });
717
1052
  const finalInput = nodeSpec.output_format === 'json'
718
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
719
- : input;
1053
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
1054
+ : preamble + input;
720
1055
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
721
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
1056
+ const prompt = `@${resolvedAgent} ${finalInput}`;
722
1057
  if (opts?.dryRun) {
723
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
1058
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
724
1059
  break;
725
1060
  }
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);
1061
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
731
1062
  const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 8 * 3600000;
732
- const wfArgs = [...baseArgs, mode, prompt];
1063
+ const childInvocation = resolveCrewxChildInvocation({
1064
+ ...process.env,
1065
+ CREWX_WORKSPACE: workspaceRoot,
1066
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1067
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1068
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1069
+ }, nodeCwd);
1070
+ const wfArgs = [...childInvocation.argv.slice(1), mode, prompt];
733
1071
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
734
1072
  wfArgs.push(`--thread=workflow:${snapshot.id}`);
735
1073
  }
@@ -742,32 +1080,27 @@ class RunManager {
742
1080
  };
743
1081
  wfArgs.push('--metadata', JSON.stringify(wfMetadata));
744
1082
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
745
- command: bin,
1083
+ command: childInvocation.argv[0],
1084
+ env: childInvocation.env,
1085
+ cwd: nodeCwd,
746
1086
  allowShellFallback: true,
747
1087
  packageName: 'crewx',
748
1088
  });
749
1089
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
750
1090
  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
- },
1091
+ cwd: nodeCwd,
1092
+ env: childInvocation.env,
758
1093
  timeout: nodeTimeout,
759
1094
  shell: invocation.shell ?? false,
760
1095
  windowsHide: invocation.windowsHide,
761
1096
  });
762
- nodeStdout = result.stdout;
763
- nodeStderr = result.stderr;
764
- nodeExitCode = result.status;
765
- if (result.error) {
766
- throw result.error;
767
- }
1097
+ recordProcessResult(result);
768
1098
  if (result.timedOut) {
769
1099
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
770
1100
  }
1101
+ if (result.error) {
1102
+ throw result.error;
1103
+ }
771
1104
  const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
772
1105
  nodeTaskId = taskIdMatch?.[1];
773
1106
  if (result.status !== 0) {
@@ -775,6 +1108,10 @@ class RunManager {
775
1108
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
776
1109
  }
777
1110
  const output = result.stdout.trim();
1111
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
1112
+ if (signal) {
1113
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
1114
+ }
778
1115
  if (nodeSpec.output) {
779
1116
  if (nodeSpec.output_format === 'json') {
780
1117
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -787,36 +1124,33 @@ class RunManager {
787
1124
  retryInput = finalInput +
788
1125
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
789
1126
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
790
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
791
- const retryArgs = [...baseArgs, mode, retryPrompt];
1127
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
1128
+ const retryArgs = [...childInvocation.argv.slice(1), mode, retryPrompt];
792
1129
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
793
1130
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
794
1131
  }
795
1132
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
796
1133
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
797
1134
  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
- },
1135
+ cwd: nodeCwd,
1136
+ env: childInvocation.env,
805
1137
  timeout: nodeTimeout,
806
1138
  shell: retryInvocation.shell ?? false,
807
1139
  windowsHide: retryInvocation.windowsHide,
808
1140
  });
809
- nodeStdout = retryResult.stdout;
810
- nodeStderr = retryResult.stderr;
811
- nodeExitCode = retryResult.status;
812
- if (retryResult.error)
813
- throw retryResult.error;
1141
+ recordProcessResult(retryResult);
814
1142
  if (retryResult.timedOut)
815
1143
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1144
+ if (retryResult.error)
1145
+ throw retryResult.error;
816
1146
  if (retryResult.status !== 0) {
817
1147
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
818
1148
  }
819
1149
  attemptOutput = retryResult.stdout.trim();
1150
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
1151
+ if (retrySignal) {
1152
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
1153
+ }
820
1154
  }
821
1155
  try {
822
1156
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -830,7 +1164,7 @@ class RunManager {
830
1164
  }
831
1165
  }
832
1166
  if (parsed === null) {
833
- throw new Error(`Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
1167
+ throw (0, failure_1.createWorkflowFailureError)('OUTPUT_INVALID', `Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
834
1168
  }
835
1169
  stateUpdates[nodeSpec.output] = parsed;
836
1170
  console.log(`Result stored in state.${nodeSpec.output} (JSON object)`);
@@ -844,52 +1178,60 @@ class RunManager {
844
1178
  }
845
1179
  case 'skill_task': {
846
1180
  if (!nodeSpec.skill) {
847
- throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
1181
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is skill_task but has no skill defined`);
848
1182
  }
849
1183
  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-]*$)`);
1184
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
851
1185
  }
852
1186
  const ctx = { ...snapshot.state, state: snapshot.state };
853
1187
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
1188
+ const skillStdin = nodeSpec.stdin !== undefined
1189
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1190
+ : undefined;
854
1191
  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}`);
1192
+ if (!cwdCheck.ok) {
1193
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1194
+ }
1195
+ const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1196
+ if (!skillRootCheck.ok) {
1197
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${skillRootCheck.error}`);
1198
+ }
1199
+ const skillWorkspaceRoot = skillRootCheck.resolved;
857
1200
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
858
1201
  if (opts?.dryRun) {
859
1202
  console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
860
1203
  break;
861
1204
  }
862
1205
  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];
1206
+ const childInvocation = resolveCrewxChildInvocation({
1207
+ ...process.env,
1208
+ CREWX_WORKSPACE: skillWorkspaceRoot,
1209
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1210
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1211
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1212
+ }, cwdCheck.resolved);
1213
+ const skillArgv = [...childInvocation.argv.slice(1), 'skill', nodeSpec.skill, ...skillArgs];
868
1214
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
869
- command: bin,
1215
+ command: childInvocation.argv[0],
1216
+ env: childInvocation.env,
1217
+ cwd: cwdCheck.resolved,
870
1218
  allowShellFallback: true,
871
1219
  packageName: 'crewx',
872
1220
  });
873
1221
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
874
1222
  const result = await runProcess(invocation.command, invocation.argv, {
875
1223
  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
- },
1224
+ env: childInvocation.env,
882
1225
  timeout,
883
1226
  shell: invocation.shell ?? false,
884
1227
  windowsHide: invocation.windowsHide,
1228
+ stdin: skillStdin,
885
1229
  });
886
- nodeStdout = result.stdout;
887
- nodeStderr = result.stderr;
888
- nodeExitCode = result.status;
889
- if (result.error)
890
- throw result.error;
1230
+ recordProcessResult(result);
891
1231
  if (result.timedOut)
892
1232
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1233
+ if (result.error)
1234
+ throw result.error;
893
1235
  if (result.status !== 0) {
894
1236
  throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
895
1237
  }
@@ -903,25 +1245,29 @@ class RunManager {
903
1245
  const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
904
1246
  const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
905
1247
  if (!shellEnvEnabled || !shellMetaAllowed) {
906
- throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
1248
+ 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
1249
  }
908
1250
  const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
909
- if (!cmdCheck.ok)
910
- throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
1251
+ if (!cmdCheck.ok) {
1252
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cmdCheck.error}`);
1253
+ }
911
1254
  const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
912
- if (!envCheck.ok)
913
- throw new Error(`Node "${nodeId}": ${envCheck.error}`);
1255
+ if (!envCheck.ok) {
1256
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${envCheck.error}`);
1257
+ }
914
1258
  const ctx = { ...snapshot.state, state: snapshot.state };
915
1259
  const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
916
1260
  const postCheck = (0, shell_security_1.validateShellCommand)(command);
917
- if (!postCheck.ok)
918
- throw new Error(`Node "${nodeId}": ${postCheck.error}`);
1261
+ if (!postCheck.ok) {
1262
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${postCheck.error}`);
1263
+ }
919
1264
  const interpolatedEnv = nodeSpec.env
920
1265
  ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
921
1266
  : undefined;
922
1267
  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}`);
1268
+ if (!cwdCheck.ok) {
1269
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1270
+ }
925
1271
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
926
1272
  if (opts?.dryRun) {
927
1273
  console.log(`[dry-run] Would execute: ${command.join(' ')}`);
@@ -932,19 +1278,21 @@ class RunManager {
932
1278
  childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
933
1279
  childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
934
1280
  childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1281
+ const shellStdin = nodeSpec.stdin !== undefined
1282
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1283
+ : undefined;
935
1284
  const result = await runProcess(command[0], command.slice(1), {
936
1285
  cwd: cwdCheck.resolved,
937
1286
  env: childEnv,
938
1287
  timeout,
939
1288
  shell: false,
1289
+ stdin: shellStdin,
940
1290
  });
941
- nodeStdout = result.stdout;
942
- nodeStderr = result.stderr;
943
- nodeExitCode = result.status;
944
- if (result.error)
945
- throw result.error;
1291
+ recordProcessResult(result);
946
1292
  if (result.timedOut)
947
1293
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1294
+ if (result.error)
1295
+ throw result.error;
948
1296
  if (result.status !== 0) {
949
1297
  throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
950
1298
  }
@@ -956,13 +1304,13 @@ class RunManager {
956
1304
  }
957
1305
  case 'branch': {
958
1306
  if (!nodeSpec.condition) {
959
- throw new Error(`Branch node "${nodeId}" has no condition`);
1307
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch node "${nodeId}" has no condition`);
960
1308
  }
961
1309
  const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
962
1310
  const branches = nodeSpec.branches;
963
1311
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
964
1312
  if (!targetNode) {
965
- throw new Error(`Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
1313
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
966
1314
  }
967
1315
  console.log(`Branch "${nodeId}": condition="${nodeSpec.condition}" → ${condResult} → ${targetNode}`);
968
1316
  nodesToComplete.push(nodeId);
@@ -986,9 +1334,19 @@ class RunManager {
986
1334
  console.log(`End "${nodeId}": workflow execution completed`);
987
1335
  break;
988
1336
  }
1337
+ case 'error': {
1338
+ status = 'failed';
1339
+ failureCode = 'UNKNOWN';
1340
+ const ctx = { ...snapshot.state, state: snapshot.state };
1341
+ failureMessage = nodeSpec.message
1342
+ ? interpolateTemplate(nodeSpec.message, ctx)
1343
+ : `Workflow failed at error node "${nodeId}"`;
1344
+ console.log(`Error "${nodeId}": workflow execution failed — ${failureMessage}`);
1345
+ break;
1346
+ }
989
1347
  case 'expression': {
990
1348
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
991
- throw new Error(`Expression node "${nodeId}" requires "set" field`);
1349
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Expression node "${nodeId}" requires "set" field`);
992
1350
  }
993
1351
  for (const [key, expr] of Object.entries(nodeSpec.set)) {
994
1352
  const result = safeEvaluate(String(expr), snapshot.state);
@@ -1009,30 +1367,61 @@ class RunManager {
1009
1367
  default:
1010
1368
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
1011
1369
  }
1370
+ if (nodeSpec.fail_when &&
1371
+ !opts?.dryRun &&
1372
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1373
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1374
+ if (judged === true || judged === 'true') {
1375
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1376
+ }
1377
+ }
1012
1378
  }
1013
1379
  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);
1380
+ const errMessage = getErrorMessage(e);
1381
+ const causeCode = (0, failure_1.classifyWorkflowFailure)({
1382
+ errorCode: (0, failure_1.getWorkflowFailureCode)(e),
1383
+ process: processFailure,
1384
+ });
1385
+ const failure = (0, failure_1.buildNodeFailure)({
1386
+ failedNode: nodeId,
1387
+ attempt,
1388
+ causeCode,
1389
+ exitCode: causeCode === 'NODE_EXIT_NONZERO' && typeof nodeExitCode === 'number'
1390
+ ? nodeExitCode
1391
+ : null,
1392
+ signal: causeCode === 'INTERRUPTED' ? nodeSignal : null,
1393
+ errorOrigin: nodeErrorOrigin,
1394
+ timedOut: processFailure?.timedOut ?? false,
1395
+ });
1396
+ const auditEntry = buildAudit(failure.causeMessage);
1020
1397
  await this.atomicUpdateAsync(execId, (run) => {
1021
1398
  if (run.status === 'cancelled')
1022
1399
  return;
1400
+ Object.assign(run.state, stateUpdates);
1401
+ delete run.manual_resume;
1023
1402
  run.current_node = nodeId;
1024
- run.status = 'failed';
1025
- run.error = errMessage;
1403
+ assignRunFailure(run, failure, errMessage);
1026
1404
  run.audit = run.audit ?? [];
1027
1405
  run.audit.push(auditEntry);
1028
1406
  });
1029
1407
  throw e;
1030
1408
  }
1031
- const successAudit = buildAudit();
1409
+ const successFailure = failureCode
1410
+ ? (0, failure_1.buildNodeFailure)({
1411
+ failedNode: nodeId,
1412
+ attempt,
1413
+ causeCode: failureCode,
1414
+ exitCode: null,
1415
+ signal: null,
1416
+ errorOrigin: null,
1417
+ })
1418
+ : null;
1419
+ const successAudit = buildAudit(successFailure?.causeMessage);
1032
1420
  return this.atomicUpdateAsync(execId, (run) => {
1033
1421
  if (run.status === 'cancelled')
1034
1422
  return;
1035
1423
  Object.assign(run.state, stateUpdates);
1424
+ delete run.manual_resume;
1036
1425
  for (const node of nodesToComplete) {
1037
1426
  if (!run.completed_nodes.includes(node)) {
1038
1427
  run.completed_nodes.push(node);
@@ -1042,8 +1431,20 @@ class RunManager {
1042
1431
  run.completed_nodes.push(prevNode);
1043
1432
  }
1044
1433
  run.current_node = currentNode;
1045
- if (status)
1046
- run.status = status;
1434
+ if (successFailure) {
1435
+ assignRunFailure(run, successFailure, failureMessage ?? successFailure.causeMessage);
1436
+ }
1437
+ else {
1438
+ delete run.error;
1439
+ delete run.last_failure;
1440
+ this.clearNodeAttempt(run, nodeId);
1441
+ if (status) {
1442
+ run.status = status;
1443
+ }
1444
+ else if (run.status !== 'completed') {
1445
+ run.status = 'running';
1446
+ }
1447
+ }
1047
1448
  if (nodeTaskId) {
1048
1449
  run.tasks = run.tasks ?? {};
1049
1450
  run.tasks[nodeId] = nodeTaskId;
@@ -1052,92 +1453,348 @@ class RunManager {
1052
1453
  run.audit.push(successAudit);
1053
1454
  });
1054
1455
  }
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,
1456
+ async claimAutoRunLease(execId, nodeId, preferredToken) {
1457
+ let claimed = false;
1458
+ const updated = await this.atomicUpdateAsync(execId, (run) => {
1459
+ if (isTerminalStatus(run.status))
1460
+ return;
1461
+ const existing = run.auto_run;
1462
+ const ownedByThisProcess = existing?.pid === process.pid && typeof existing.token === 'string';
1463
+ if (existing && !ownedByThisProcess && isProcessAlive(existing.pid)) {
1464
+ throw new Error(`Workflow run "${execId}" is already owned by another runner process`);
1465
+ }
1466
+ const token = preferredToken ?? (ownedByThisProcess ? existing.token : (0, sdk_1.generateId)('wfr'));
1467
+ if (!isTerminalStatus(run.status)) {
1468
+ run.status = 'running';
1469
+ }
1470
+ run.auto_run = {
1471
+ pid: process.pid,
1472
+ token,
1473
+ node_id: nodeId,
1474
+ started_at: ownedByThisProcess && existing.token === token
1475
+ ? existing.started_at
1476
+ : new Date().toISOString(),
1073
1477
  };
1478
+ delete run.manual_resume;
1479
+ claimed = true;
1480
+ });
1481
+ return claimed ? updated.auto_run?.token ?? null : null;
1482
+ }
1483
+ async releaseAutoRunLease(execId, token) {
1484
+ await this.atomicUpdateAsync(execId, (run) => {
1485
+ if (run.auto_run?.token === token)
1486
+ delete run.auto_run;
1487
+ });
1488
+ }
1489
+ async failAutoConfiguration(execId, nodeId, reason) {
1490
+ return this.atomicUpdateAsync(execId, (run) => {
1491
+ if (run.status === 'cancelled')
1492
+ return;
1493
+ const failure = (0, failure_1.buildNodeFailure)({
1494
+ failedNode: nodeId,
1495
+ attempt: (0, failure_1.readNodeAttempt)(run, nodeId),
1496
+ causeCode: 'CONFIG_INVALID',
1497
+ exitCode: null,
1498
+ signal: null,
1499
+ errorOrigin: null,
1500
+ });
1501
+ assignRunFailure(run, failure, reason);
1502
+ appendFailureAudit(run, failure, 'config', 'auto');
1503
+ });
1504
+ }
1505
+ async reserveAutoRetry(execId, nodeId, runnerToken, policy, delayMs) {
1506
+ let reserved = false;
1507
+ const run = await this.atomicUpdateAsync(execId, (draft) => {
1508
+ if (draft.status === 'cancelled')
1509
+ return;
1510
+ if (draft.status !== 'failed')
1511
+ return;
1512
+ if (draft.auto_run?.token !== runnerToken)
1513
+ return;
1514
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run: draft, nodeId });
1515
+ if (!decision.retry || decision.attempt === undefined)
1516
+ return;
1517
+ const now = new Date().toISOString();
1518
+ draft.status = 'retrying';
1519
+ delete draft.error;
1520
+ delete draft.manual_resume;
1521
+ draft.audit = draft.audit ?? [];
1522
+ draft.audit.push({
1523
+ exec_id: draft.id,
1524
+ node_id: nodeId,
1525
+ type: 'retry',
1526
+ summary: `auto retry ${decision.attempt + 1}/${policy.max + 1} in ${delayMs}ms`,
1527
+ started_at: now,
1528
+ ended_at: now,
1529
+ duration_ms: 0,
1530
+ exit_code: null,
1531
+ trigger: 'auto',
1532
+ });
1533
+ reserved = true;
1534
+ });
1535
+ return { run, reserved };
1536
+ }
1537
+ async waitForRetry(execId, runnerToken, delayMs) {
1538
+ const inspect = () => {
1539
+ const run = this.loadRun(execId);
1540
+ if (!run)
1541
+ throw new Error(`Execution not found: ${execId}`);
1542
+ if (run.status === 'cancelled') {
1543
+ return { run, outcome: 'cancelled', reason: 'run cancelled during retry delay' };
1544
+ }
1545
+ if (run.auto_run?.token !== runnerToken) {
1546
+ return { run, outcome: 'paused', reason: 'auto-run lease lost during retry delay' };
1547
+ }
1548
+ if (run.status !== 'retrying') {
1549
+ return { run, outcome: 'paused', reason: 'run state changed during retry delay' };
1550
+ }
1551
+ return null;
1552
+ };
1553
+ let remaining = Math.max(0, delayMs);
1554
+ while (remaining > 0) {
1555
+ const slice = Math.min(remaining, RETRY_WAIT_SLICE_MS);
1556
+ await this.sleep(slice);
1557
+ remaining -= slice;
1558
+ const stopped = inspect();
1559
+ if (stopped)
1560
+ return stopped;
1074
1561
  }
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;
1562
+ const stopped = inspect();
1563
+ if (stopped)
1564
+ return stopped;
1565
+ const run = this.loadRun(execId);
1566
+ if (!run)
1567
+ throw new Error(`Execution not found: ${execId}`);
1568
+ return { run, outcome: 'ready' };
1569
+ }
1570
+ async runNodeWithRetry(execId, nodeId, runnerToken, policy) {
1571
+ let token = runnerToken;
1082
1572
  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 };
1573
+ try {
1574
+ const run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1575
+ return { run, runnerToken: token, outcome: 'success' };
1101
1576
  }
1102
- if (node.type === 'approval') {
1103
- return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1577
+ catch (caught) {
1578
+ const run = this.loadRun(execId);
1579
+ if (!run)
1580
+ throw new Error(`Execution not found: ${execId}`);
1581
+ if (run.status === 'cancelled') {
1582
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1583
+ }
1584
+ if (run.status !== 'failed' || !policy) {
1585
+ return { run, runnerToken: token, outcome: run.status === 'failed' ? 'failed' : 'paused', reason: getErrorMessage(caught) };
1586
+ }
1587
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run, nodeId });
1588
+ if (!decision.retry || decision.attempt === undefined) {
1589
+ return { run, runnerToken: token, outcome: 'failed', reason: getErrorMessage(caught) };
1590
+ }
1591
+ const delayMs = (0, retry_policy_1.computeRetryDelayMs)(policy, decision.attempt);
1592
+ const reservation = await this.reserveAutoRetry(execId, nodeId, token, policy, delayMs);
1593
+ if (!reservation.reserved) {
1594
+ const latest = reservation.run;
1595
+ if (latest.status === 'cancelled') {
1596
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1597
+ }
1598
+ return {
1599
+ run: latest,
1600
+ runnerToken: token,
1601
+ outcome: latest.status === 'failed' ? 'failed' : 'paused',
1602
+ reason: getErrorMessage(caught),
1603
+ };
1604
+ }
1605
+ const waited = await this.waitForRetry(execId, token, delayMs);
1606
+ if (waited.outcome !== 'ready') {
1607
+ return { run: waited.run, runnerToken: token, outcome: waited.outcome, reason: waited.reason };
1608
+ }
1609
+ const nextToken = await this.claimAutoRunLease(execId, nodeId, token);
1610
+ if (!nextToken) {
1611
+ const latest = this.loadRun(execId);
1612
+ if (!latest)
1613
+ throw new Error(`Execution not found: ${execId}`);
1614
+ if (latest.status === 'cancelled') {
1615
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1616
+ }
1617
+ return {
1618
+ run: latest,
1619
+ runnerToken: token,
1620
+ outcome: 'paused',
1621
+ reason: 'run stopped before retry lease could be reclaimed',
1622
+ };
1623
+ }
1624
+ token = nextToken;
1104
1625
  }
1105
- if (node.type === 'parallel') {
1106
- return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1626
+ }
1627
+ }
1628
+ async runAuto(execId) {
1629
+ const leaseKey = `${this.resolvedDir}::${execId}`;
1630
+ if (inFlightAutoRuns.has(leaseKey)) {
1631
+ const run = this.loadRun(execId);
1632
+ if (!run)
1633
+ throw new Error(`Execution not found: ${execId}`);
1634
+ console.log(`runAuto "${execId}": already in-flight — skipping duplicate auto-run (single-flight guard)`);
1635
+ return { run, outcome: 'paused', reason: 'execution already in-flight (single-flight guard)', executed: [] };
1636
+ }
1637
+ inFlightAutoRuns.add(leaseKey);
1638
+ let runnerToken;
1639
+ try {
1640
+ const initial = this.loadRun(execId);
1641
+ if (!initial)
1642
+ throw new Error(`Execution not found: ${execId}`);
1643
+ if (initial.auto_run?.pid === process.pid) {
1644
+ runnerToken = initial.auto_run.token;
1107
1645
  }
1108
- if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1109
- return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1646
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1647
+ if (!doc) {
1648
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${initial.workflow_file}`);
1110
1649
  }
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 };
1650
+ const spec = doc.workflows[initial.workflow_id];
1651
+ if (!spec) {
1652
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1119
1653
  }
1120
- try {
1121
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1122
- executed.push(nodeId);
1654
+ const executed = [];
1655
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1656
+ return {
1657
+ run: initial,
1658
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1659
+ reason: `run already ${initial.status}`,
1660
+ executed,
1661
+ };
1123
1662
  }
1124
- catch (e) {
1663
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1664
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1665
+ ? spec.max_iterations
1666
+ : 30;
1667
+ let run = initial;
1668
+ while (true) {
1125
1669
  run = this.loadRun(execId);
1126
- return { run, outcome: 'failed', reason: e.message, executed };
1670
+ if (run.status === 'completed')
1671
+ return { run, outcome: 'completed', executed };
1672
+ if (run.status === 'failed')
1673
+ return { run, outcome: 'failed', reason: run.error, executed };
1674
+ if (run.status === 'cancelled')
1675
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1676
+ const nodeId = run.current_node || firstNode;
1677
+ if (!nodeId) {
1678
+ run = await this.atomicUpdateAsync(execId, (r) => {
1679
+ const failure = (0, failure_1.buildNodeFailure)({
1680
+ failedNode: r.current_node || '',
1681
+ attempt: (0, failure_1.readNodeAttempt)(r, r.current_node || ''),
1682
+ causeCode: 'CONFIG_INVALID',
1683
+ exitCode: null,
1684
+ signal: null,
1685
+ errorOrigin: null,
1686
+ });
1687
+ assignRunFailure(r, failure, 'workflow has no nodes');
1688
+ appendFailureAudit(r, failure, 'config', 'auto');
1689
+ });
1690
+ return { run, outcome: 'failed', reason: run.error, executed };
1691
+ }
1692
+ const node = spec.nodes[nodeId];
1693
+ if (!node) {
1694
+ run = await this.atomicUpdateAsync(execId, (r) => {
1695
+ r.current_node = nodeId;
1696
+ const failure = (0, failure_1.buildNodeFailure)({
1697
+ failedNode: nodeId,
1698
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1699
+ causeCode: 'CONFIG_INVALID',
1700
+ exitCode: null,
1701
+ signal: null,
1702
+ errorOrigin: null,
1703
+ });
1704
+ assignRunFailure(r, failure, `node "${nodeId}" not found in workflow`);
1705
+ appendFailureAudit(r, failure, 'config', 'auto');
1706
+ });
1707
+ return { run, outcome: 'failed', reason: run.error, executed };
1708
+ }
1709
+ const parsedRetryPolicy = (0, retry_policy_1.parseRetryPolicy)(node);
1710
+ if (!parsedRetryPolicy.ok) {
1711
+ run = await this.failAutoConfiguration(execId, nodeId, parsedRetryPolicy.reason);
1712
+ if (run.status === 'cancelled') {
1713
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1714
+ }
1715
+ return { run, outcome: 'failed', reason: run.error, executed };
1716
+ }
1717
+ if (node.type === 'approval') {
1718
+ if (run.current_node !== nodeId) {
1719
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1720
+ }
1721
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1722
+ }
1723
+ if (node.type === 'parallel') {
1724
+ if (run.current_node !== nodeId) {
1725
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1726
+ }
1727
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1728
+ }
1729
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1730
+ if (run.current_node !== nodeId) {
1731
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1732
+ }
1733
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1734
+ }
1735
+ const claimedToken = await this.claimAutoRunLease(execId, nodeId, runnerToken);
1736
+ if (!claimedToken) {
1737
+ run = this.loadRun(execId);
1738
+ return {
1739
+ run,
1740
+ outcome: run.status === 'cancelled' ? 'cancelled' : 'paused',
1741
+ reason: run.status === 'cancelled' ? run.error : 'run stopped before auto-run lease could be claimed',
1742
+ executed,
1743
+ };
1744
+ }
1745
+ runnerToken = claimedToken;
1746
+ run = await this.atomicUpdateAsync(execId, (r) => {
1747
+ const engine = readRunEngineState(r);
1748
+ engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1749
+ engine.iterations += 1;
1750
+ r.state['__engine'] = engine;
1751
+ if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1752
+ const failure = (0, failure_1.buildNodeFailure)({
1753
+ failedNode: nodeId,
1754
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1755
+ causeCode: 'CONFIG_INVALID',
1756
+ exitCode: null,
1757
+ signal: null,
1758
+ errorOrigin: null,
1759
+ });
1760
+ assignRunFailure(r, failure, `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`);
1761
+ appendFailureAudit(r, failure, 'config', 'auto');
1762
+ }
1763
+ });
1764
+ if (run.status === 'failed') {
1765
+ return { run, outcome: 'failed', reason: run.error, executed };
1766
+ }
1767
+ const nodeResult = await this.runNodeWithRetry(execId, nodeId, runnerToken, parsedRetryPolicy.policy);
1768
+ run = nodeResult.run;
1769
+ runnerToken = nodeResult.runnerToken;
1770
+ if (nodeResult.outcome !== 'success') {
1771
+ return { run, outcome: nodeResult.outcome, reason: nodeResult.reason, executed };
1772
+ }
1773
+ executed.push(nodeId);
1774
+ if (run.status === 'completed')
1775
+ return { run, outcome: 'completed', executed };
1776
+ if (run.status === 'failed')
1777
+ return { run, outcome: 'failed', reason: run.error, executed };
1778
+ if (run.status === 'cancelled')
1779
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1780
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
1781
+ const next = node.next;
1782
+ if (!next) {
1783
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
1784
+ }
1785
+ run = this.moveNode(execId, next);
1786
+ }
1127
1787
  }
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 };
1788
+ }
1789
+ finally {
1790
+ if (runnerToken) {
1791
+ try {
1792
+ await this.releaseAutoRunLease(execId, runnerToken);
1793
+ }
1794
+ catch {
1138
1795
  }
1139
- run = this.moveNode(execId, next);
1140
1796
  }
1797
+ inFlightAutoRuns.delete(leaseKey);
1141
1798
  }
1142
1799
  }
1143
1800
  cancelRun(execId, reason = 'Cancelled by user') {
@@ -1146,21 +1803,19 @@ class RunManager {
1146
1803
  return;
1147
1804
  }
1148
1805
  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',
1806
+ const failure = (0, failure_1.buildNodeFailure)({
1807
+ failedNode: run.current_node || '',
1808
+ attempt: (0, failure_1.readNodeAttempt)(run, run.current_node || ''),
1809
+ causeCode: 'CANCELLED',
1810
+ exitCode: null,
1811
+ signal: null,
1812
+ errorOrigin: null,
1163
1813
  });
1814
+ assignRunFailure(run, failure, reason);
1815
+ run.status = 'cancelled';
1816
+ delete run.auto_run;
1817
+ delete run.manual_resume;
1818
+ appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
1164
1819
  });
1165
1820
  }
1166
1821
  reset(execId) {
@@ -1168,6 +1823,73 @@ class RunManager {
1168
1823
  run.state = JSON.parse(JSON.stringify(run.initial_state));
1169
1824
  run.current_node = '';
1170
1825
  run.completed_nodes = [];
1826
+ run.status = 'pending';
1827
+ delete run.error;
1828
+ delete run.last_failure;
1829
+ delete run.node_attempts;
1830
+ delete run.auto_run;
1831
+ delete run.manual_resume;
1832
+ });
1833
+ }
1834
+ retry(execId, options = {}) {
1835
+ const snapshot = this.loadRun(execId);
1836
+ if (!snapshot)
1837
+ throw new Error(`Execution not found: ${execId}`);
1838
+ const doc = this.loadWorkflowYaml(snapshot.workflow_file);
1839
+ if (!doc)
1840
+ throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
1841
+ const spec = doc.workflows[snapshot.workflow_id];
1842
+ if (!spec)
1843
+ throw new Error(`Workflow "${snapshot.workflow_id}" not found`);
1844
+ if (snapshot.status !== 'failed') {
1845
+ throw new Error(`Run "${execId}" is not failed (status=${snapshot.status ?? 'running'}); only failed runs can be retried`);
1846
+ }
1847
+ if (!isRetryableFailure(snapshot, spec)) {
1848
+ throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
1849
+ }
1850
+ return this.atomicUpdate(execId, (run) => {
1851
+ if (run.status !== 'failed') {
1852
+ throw new Error(`Run "${execId}" is not failed (status=${run.status ?? 'pending'}); only failed runs can be retried`);
1853
+ }
1854
+ const failedNode = run.current_node;
1855
+ if (failedNode && run.last_failure) {
1856
+ const lastAttempt = run.last_failure.attempt;
1857
+ const storedAttempt = run.node_attempts?.[failedNode];
1858
+ if (typeof lastAttempt === 'number' && Number.isInteger(lastAttempt) && lastAttempt > 0
1859
+ && (typeof storedAttempt !== 'number' || storedAttempt < lastAttempt)) {
1860
+ run.node_attempts = run.node_attempts ?? {};
1861
+ run.node_attempts[failedNode] = lastAttempt;
1862
+ }
1863
+ }
1864
+ run.status = 'retrying';
1865
+ delete run.error;
1866
+ delete run.manual_resume;
1867
+ run.retry_count = (run.retry_count ?? 0) + 1;
1868
+ run.audit = run.audit ?? [];
1869
+ const now = new Date().toISOString();
1870
+ if (options.autoRun === false) {
1871
+ delete run.auto_run;
1872
+ run.manual_resume = { node_id: failedNode, requested_at: now };
1873
+ }
1874
+ else {
1875
+ run.auto_run = {
1876
+ pid: process.pid,
1877
+ token: (0, sdk_1.generateId)('wfr'),
1878
+ node_id: failedNode,
1879
+ started_at: now,
1880
+ };
1881
+ }
1882
+ run.audit.push({
1883
+ exec_id: run.id,
1884
+ node_id: failedNode,
1885
+ type: 'retry',
1886
+ summary: `manual retry #${run.retry_count}`,
1887
+ started_at: now,
1888
+ ended_at: now,
1889
+ duration_ms: 0,
1890
+ exit_code: null,
1891
+ trigger: 'manual',
1892
+ });
1171
1893
  });
1172
1894
  }
1173
1895
  listRuns(opts) {
@@ -1176,7 +1898,10 @@ class RunManager {
1176
1898
  const runs = [];
1177
1899
  for (const file of files) {
1178
1900
  try {
1179
- const run = JSON.parse(fs.readFileSync(path.join(this.resolvedDir, file), 'utf-8'));
1901
+ const execId = file.slice(0, -'.json'.length);
1902
+ const run = this.loadRun(execId);
1903
+ if (!run)
1904
+ continue;
1180
1905
  if (opts?.workflowId && run.workflow_id !== opts.workflowId)
1181
1906
  continue;
1182
1907
  runs.push(run);