@crewx/workflow 0.3.22-rc.128 → 0.3.22-rc.129

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.
@@ -40,24 +40,46 @@ exports.interpolateTemplate = interpolateTemplate;
40
40
  exports.safeEvaluate = safeEvaluate;
41
41
  exports.safeEvaluateBranchCondition = safeEvaluateBranchCondition;
42
42
  exports.validateSafeBranchCondition = validateSafeBranchCondition;
43
+ exports.normalizeRunStatus = normalizeRunStatus;
43
44
  exports.isRetryableFailure = isRetryableFailure;
44
45
  const fs = __importStar(require("fs"));
45
46
  const path = __importStar(require("path"));
46
47
  const yaml = __importStar(require("js-yaml"));
47
48
  const cp = __importStar(require("child_process"));
48
49
  const sdk_1 = require("@crewx/sdk");
50
+ const failure_1 = require("./failure");
49
51
  const file_lock_1 = require("./file-lock");
50
52
  const output_format_1 = require("./utils/output-format");
51
53
  const shell_security_1 = require("./utils/shell-security");
54
+ const retry_policy_1 = require("./retry-policy");
52
55
  var file_lock_2 = require("./file-lock");
53
56
  Object.defineProperty(exports, "FileLock", { enumerable: true, get: function () { return file_lock_2.FileLock; } });
54
57
  exports.SKILL_TASK_DEFAULT_TIMEOUT = 600000;
55
58
  exports.WORKFLOW_RUN_INTERRUPTED_ERROR = 'Workflow run interrupted: runner process exited before completion';
56
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
+ }
57
66
  const KNOWN_AUTO_NODE_TYPES = new Set([
58
67
  'agent_task', 'skill_task', 'shell_task', 'branch', 'expression', 'join', 'end', 'error',
59
68
  ]);
60
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
+ }
61
83
  function killProcessGroup(child) {
62
84
  try {
63
85
  if (process.platform === 'win32') {
@@ -93,12 +115,23 @@ async function runProcess(command, argv, opts) {
93
115
  });
94
116
  }
95
117
  catch (err) {
96
- 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
+ });
97
127
  return;
98
128
  }
99
129
  let stdout = '';
100
130
  let stderr = '';
101
131
  let settled = false;
132
+ let spawned = false;
133
+ let postSpawnError;
134
+ let stdinError;
102
135
  const finish = (result) => {
103
136
  if (settled)
104
137
  return;
@@ -108,38 +141,120 @@ async function runProcess(command, argv, opts) {
108
141
  };
109
142
  const timer = setTimeout(() => {
110
143
  killProcessGroup(child);
111
- 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
+ });
112
153
  }, opts.timeout);
113
154
  if (typeof timer.unref === 'function')
114
155
  timer.unref();
115
156
  child.stdout?.on('data', (d) => { stdout += d.toString(); });
116
157
  child.stderr?.on('data', (d) => { stderr += d.toString(); });
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
+ });
117
174
  if (opts.stdin !== undefined) {
118
- child.stdin?.end(opts.stdin);
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
+ }
119
184
  }
120
- child.on('error', (err) => finish({ status: null, stdout, stderr, timedOut: false, error: err }));
121
- child.on('close', (code) => finish({ status: code, stdout, stderr, timedOut: false }));
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
+ }));
122
194
  });
123
195
  }
124
- function maskArg(arg) {
125
- 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;
126
202
  }
127
203
  function summarizeNode(node) {
128
204
  switch (node.type) {
129
- case 'agent_task':
130
- return `@${node.agent ?? '?'} (${node.mode === 'query' ? 'q' : 'x'})`;
131
- case 'skill_task':
132
- 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
+ }
133
215
  case 'shell_task':
134
- return (node.command ?? []).map(maskArg).join(' ');
216
+ return 'shell_task';
135
217
  case 'branch':
136
- return `branch: ${node.condition ?? ''}`;
218
+ return 'branch';
137
219
  case 'expression':
138
- return `set ${Object.keys(node.set ?? {}).join(', ')}`;
220
+ return 'expression';
139
221
  default:
140
- return node.type;
222
+ return 'workflow node';
141
223
  }
142
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
+ }
143
258
  async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
144
259
  if (!nodeSpec.output)
145
260
  return;
@@ -148,12 +263,12 @@ async function storeNodeOutput(nodeSpec, nodeId, stdout, stateUpdates) {
148
263
  let parsed;
149
264
  try {
150
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
+ }
151
269
  }
152
270
  catch (e) {
153
- throw new Error(`Node "${nodeId}" output_format=json parse failed: ${e.message}`);
154
- }
155
- if (nodeSpec.output_schema) {
156
- 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)}`);
157
272
  }
158
273
  stateUpdates[nodeSpec.output] = parsed;
159
274
  }
@@ -564,6 +679,34 @@ function isProcessAlive(pid) {
564
679
  function isTerminalStatus(status) {
565
680
  return status === 'completed' || status === 'failed' || status === 'cancelled';
566
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
+ }
567
710
  function isRetryableFailure(run, spec) {
568
711
  if (run.status !== 'failed')
569
712
  return false;
@@ -575,8 +718,9 @@ function isRetryableFailure(run, spec) {
575
718
  return node.type !== 'error';
576
719
  }
577
720
  class RunManager {
578
- constructor(runsDir = '.crewx/workflow-runs', projectRoot) {
721
+ constructor(runsDir = '.crewx/workflow-runs', projectRoot, deps) {
579
722
  this.runsDir = runsDir;
723
+ this.sleep = deps?.sleep ?? sleep;
580
724
  const base = projectRoot || process.env.CREWX_WORKSPACE || process.cwd();
581
725
  this.resolvedDir = path.resolve(base, this.runsDir);
582
726
  this.projectRoot = path.resolve(base);
@@ -596,7 +740,7 @@ class RunManager {
596
740
  throw new Error(`Invalid execution ID: "${execId}"`);
597
741
  }
598
742
  }
599
- readRun(execId) {
743
+ readRunRaw(execId) {
600
744
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
601
745
  if (!fs.existsSync(filePath))
602
746
  return null;
@@ -607,16 +751,18 @@ class RunManager {
607
751
  throw new Error(`Failed to parse execution file ${filePath}: ${e.message}`);
608
752
  }
609
753
  }
610
- isLegacyRetryStale(run) {
754
+ readRun(execId) {
755
+ const run = this.readRunRaw(execId);
756
+ return run ? normalizeRunStatus(run) : null;
757
+ }
758
+ isLegacyStatuslessStale(run) {
611
759
  if (run.status !== undefined
612
760
  || run.auto_run
613
761
  || run.manual_resume
614
- || !run.current_node) {
762
+ || !run.current_node
763
+ || !run.audit?.length) {
615
764
  return false;
616
765
  }
617
- const lastAudit = run.audit?.at(-1);
618
- if (lastAudit?.type !== 'retry')
619
- return false;
620
766
  const updatedAt = Date.parse(run.updated_at);
621
767
  if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < exports.WORKFLOW_RUN_LEGACY_RECOVERY_GRACE_MS) {
622
768
  return false;
@@ -629,16 +775,16 @@ class RunManager {
629
775
  hasStaleRunner(run) {
630
776
  if (run.auto_run)
631
777
  return !isProcessAlive(run.auto_run.pid);
632
- return this.isLegacyRetryStale(run);
778
+ return this.isLegacyStatuslessStale(run);
633
779
  }
634
780
  recoverStaleRunner(execId) {
635
781
  const filePath = path.join(this.resolvedDir, `${execId}.json`);
636
782
  const lock = new file_lock_1.FileLock(filePath);
637
783
  lock.acquire();
638
784
  try {
639
- const run = this.readRun(execId);
785
+ const run = this.readRunRaw(execId);
640
786
  if (!run || !this.hasStaleRunner(run))
641
- return run;
787
+ return run ? normalizeRunStatus(run) : null;
642
788
  if (isTerminalStatus(run.status)) {
643
789
  delete run.auto_run;
644
790
  delete run.manual_resume;
@@ -648,23 +794,18 @@ class RunManager {
648
794
  const lease = run.auto_run;
649
795
  const nodeId = lease?.node_id || run.current_node;
650
796
  const now = new Date().toISOString();
651
- run.status = 'failed';
652
- run.error = exports.WORKFLOW_RUN_INTERRUPTED_ERROR;
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);
653
806
  delete run.auto_run;
654
807
  delete run.manual_resume;
655
- run.audit = run.audit ?? [];
656
- run.audit.push({
657
- exec_id: run.id,
658
- node_id: nodeId,
659
- type: 'interrupted',
660
- summary: exports.WORKFLOW_RUN_INTERRUPTED_ERROR,
661
- started_at: now,
662
- ended_at: now,
663
- duration_ms: 0,
664
- exit_code: null,
665
- error: exports.WORKFLOW_RUN_INTERRUPTED_ERROR,
666
- trigger: 'auto',
667
- });
808
+ appendFailureAudit(run, failure, 'interrupted', 'auto', now);
668
809
  this.saveRun(run);
669
810
  return run;
670
811
  }
@@ -674,9 +815,9 @@ class RunManager {
674
815
  }
675
816
  loadRun(execId) {
676
817
  this.validateExecId(execId);
677
- const run = this.readRun(execId);
818
+ const run = this.readRunRaw(execId);
678
819
  if (!run || !this.hasStaleRunner(run))
679
- return run;
820
+ return run ? normalizeRunStatus(run) : null;
680
821
  return this.recoverStaleRunner(execId);
681
822
  }
682
823
  saveRun(run) {
@@ -721,6 +862,32 @@ class RunManager {
721
862
  lock.release();
722
863
  }
723
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
+ }
724
891
  loadWorkflowYaml(yamlPath) {
725
892
  if (!fs.existsSync(yamlPath))
726
893
  return null;
@@ -773,6 +940,7 @@ class RunManager {
773
940
  completed_nodes: [],
774
941
  state,
775
942
  initial_state: { ...initialState, ...sets },
943
+ status: 'pending',
776
944
  };
777
945
  this.saveRun(run);
778
946
  return run;
@@ -804,15 +972,15 @@ class RunManager {
804
972
  }
805
973
  const doc = this.loadWorkflowYaml(snapshot.workflow_file);
806
974
  if (!doc) {
807
- 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}`);
808
976
  }
809
977
  const spec = doc.workflows[snapshot.workflow_id];
810
978
  if (!spec) {
811
- 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}`);
812
980
  }
813
981
  const nodeSpec = spec.nodes?.[nodeId];
814
982
  if (!nodeSpec) {
815
- 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}"`);
816
984
  }
817
985
  const prevNode = snapshot.current_node;
818
986
  const stateUpdates = {};
@@ -820,12 +988,20 @@ class RunManager {
820
988
  let currentNode = nodeId;
821
989
  let status;
822
990
  let failureMessage;
991
+ let failureCode;
823
992
  let nodeTaskId;
824
993
  const trigger = opts?.trigger ?? 'manual';
825
994
  const startedAt = new Date();
826
- let nodeStdout = '';
827
- let nodeStderr = '';
828
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
+ };
829
1005
  const buildAudit = (error) => {
830
1006
  const endedAt = new Date();
831
1007
  return {
@@ -841,24 +1017,27 @@ class RunManager {
841
1017
  trigger,
842
1018
  };
843
1019
  };
1020
+ const attempt = await this.reserveNodeAttempt(execId, nodeId);
844
1021
  try {
845
1022
  switch (nodeSpec.type) {
846
1023
  case 'agent_task': {
847
1024
  if (!nodeSpec.agent) {
848
- 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`);
849
1026
  }
850
1027
  const agentCtx = { ...snapshot.state, state: snapshot.state };
851
1028
  const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
852
1029
  if (!resolvedAgent) {
853
- throw new Error(`Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
1030
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
854
1031
  }
855
1032
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
856
- if (!cwdCheck.ok)
857
- throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1033
+ if (!cwdCheck.ok) {
1034
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1035
+ }
858
1036
  const nodeCwd = cwdCheck.resolved;
859
1037
  const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
860
- if (!rootCheck.ok)
861
- throw new Error(`Node "${nodeId}": ${rootCheck.error}`);
1038
+ if (!rootCheck.ok) {
1039
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${rootCheck.error}`);
1040
+ }
862
1041
  const workspaceRoot = rootCheck.resolved;
863
1042
  const input = nodeSpec.input
864
1043
  ? interpolateTemplate(nodeSpec.input, agentCtx)
@@ -880,12 +1059,15 @@ class RunManager {
880
1059
  break;
881
1060
  }
882
1061
  console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
883
- const crewxCli = process.env.CREWX_CLI || 'npx crewx';
884
- const cliParts = crewxCli.split(/\s+/);
885
- const bin = cliParts[0];
886
- const baseArgs = cliParts.slice(1);
887
1062
  const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 8 * 3600000;
888
- 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];
889
1071
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
890
1072
  wfArgs.push(`--thread=workflow:${snapshot.id}`);
891
1073
  }
@@ -898,33 +1080,27 @@ class RunManager {
898
1080
  };
899
1081
  wfArgs.push('--metadata', JSON.stringify(wfMetadata));
900
1082
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
901
- command: bin,
1083
+ command: childInvocation.argv[0],
1084
+ env: childInvocation.env,
1085
+ cwd: nodeCwd,
902
1086
  allowShellFallback: true,
903
1087
  packageName: 'crewx',
904
1088
  });
905
1089
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
906
1090
  const result = await runProcess(invocation.command, invocation.argv, {
907
1091
  cwd: nodeCwd,
908
- env: {
909
- ...process.env,
910
- CREWX_WORKSPACE: workspaceRoot,
911
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
912
- CREWX_WORKFLOW_NODE_ID: nodeId,
913
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
914
- },
1092
+ env: childInvocation.env,
915
1093
  timeout: nodeTimeout,
916
1094
  shell: invocation.shell ?? false,
917
1095
  windowsHide: invocation.windowsHide,
918
1096
  });
919
- nodeStdout = result.stdout;
920
- nodeStderr = result.stderr;
921
- nodeExitCode = result.status;
922
- if (result.error) {
923
- throw result.error;
924
- }
1097
+ recordProcessResult(result);
925
1098
  if (result.timedOut) {
926
1099
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
927
1100
  }
1101
+ if (result.error) {
1102
+ throw result.error;
1103
+ }
928
1104
  const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
929
1105
  nodeTaskId = taskIdMatch?.[1];
930
1106
  if (result.status !== 0) {
@@ -949,7 +1125,7 @@ class RunManager {
949
1125
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
950
1126
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
951
1127
  const retryPrompt = `@${resolvedAgent} ${retryInput}`;
952
- const retryArgs = [...baseArgs, mode, retryPrompt];
1128
+ const retryArgs = [...childInvocation.argv.slice(1), mode, retryPrompt];
953
1129
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
954
1130
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
955
1131
  }
@@ -957,24 +1133,16 @@ class RunManager {
957
1133
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
958
1134
  const retryResult = await runProcess(retryInvocation.command, retryInvocation.argv, {
959
1135
  cwd: nodeCwd,
960
- env: {
961
- ...process.env,
962
- CREWX_WORKSPACE: workspaceRoot,
963
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
964
- CREWX_WORKFLOW_NODE_ID: nodeId,
965
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
966
- },
1136
+ env: childInvocation.env,
967
1137
  timeout: nodeTimeout,
968
1138
  shell: retryInvocation.shell ?? false,
969
1139
  windowsHide: retryInvocation.windowsHide,
970
1140
  });
971
- nodeStdout = retryResult.stdout;
972
- nodeStderr = retryResult.stderr;
973
- nodeExitCode = retryResult.status;
974
- if (retryResult.error)
975
- throw retryResult.error;
1141
+ recordProcessResult(retryResult);
976
1142
  if (retryResult.timedOut)
977
1143
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1144
+ if (retryResult.error)
1145
+ throw retryResult.error;
978
1146
  if (retryResult.status !== 0) {
979
1147
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
980
1148
  }
@@ -996,7 +1164,7 @@ class RunManager {
996
1164
  }
997
1165
  }
998
1166
  if (parsed === null) {
999
- 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}`);
1000
1168
  }
1001
1169
  stateUpdates[nodeSpec.output] = parsed;
1002
1170
  console.log(`Result stored in state.${nodeSpec.output} (JSON object)`);
@@ -1010,10 +1178,10 @@ class RunManager {
1010
1178
  }
1011
1179
  case 'skill_task': {
1012
1180
  if (!nodeSpec.skill) {
1013
- 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`);
1014
1182
  }
1015
1183
  if (!shell_security_1.SKILL_NAME_RE.test(nodeSpec.skill)) {
1016
- 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-]*$)`);
1017
1185
  }
1018
1186
  const ctx = { ...snapshot.state, state: snapshot.state };
1019
1187
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
@@ -1021,11 +1189,13 @@ class RunManager {
1021
1189
  ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1022
1190
  : undefined;
1023
1191
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
1024
- if (!cwdCheck.ok)
1025
- throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1192
+ if (!cwdCheck.ok) {
1193
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1194
+ }
1026
1195
  const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1027
- if (!skillRootCheck.ok)
1028
- throw new Error(`Node "${nodeId}": ${skillRootCheck.error}`);
1196
+ if (!skillRootCheck.ok) {
1197
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${skillRootCheck.error}`);
1198
+ }
1029
1199
  const skillWorkspaceRoot = skillRootCheck.resolved;
1030
1200
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
1031
1201
  if (opts?.dryRun) {
@@ -1033,38 +1203,35 @@ class RunManager {
1033
1203
  break;
1034
1204
  }
1035
1205
  console.log(`Executing skill: ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
1036
- const crewxCli = process.env.CREWX_CLI || 'npx crewx';
1037
- const cliParts = crewxCli.split(/\s+/);
1038
- const bin = cliParts[0];
1039
- const baseArgs = cliParts.slice(1);
1040
- 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];
1041
1214
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
1042
- command: bin,
1215
+ command: childInvocation.argv[0],
1216
+ env: childInvocation.env,
1217
+ cwd: cwdCheck.resolved,
1043
1218
  allowShellFallback: true,
1044
1219
  packageName: 'crewx',
1045
1220
  });
1046
1221
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
1047
1222
  const result = await runProcess(invocation.command, invocation.argv, {
1048
1223
  cwd: cwdCheck.resolved,
1049
- env: {
1050
- ...process.env,
1051
- CREWX_WORKSPACE: skillWorkspaceRoot,
1052
- CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1053
- CREWX_WORKFLOW_NODE_ID: nodeId,
1054
- CREWX_WORKFLOW_ID: snapshot.workflow_id,
1055
- },
1224
+ env: childInvocation.env,
1056
1225
  timeout,
1057
1226
  shell: invocation.shell ?? false,
1058
1227
  windowsHide: invocation.windowsHide,
1059
1228
  stdin: skillStdin,
1060
1229
  });
1061
- nodeStdout = result.stdout;
1062
- nodeStderr = result.stderr;
1063
- nodeExitCode = result.status;
1064
- if (result.error)
1065
- throw result.error;
1230
+ recordProcessResult(result);
1066
1231
  if (result.timedOut)
1067
1232
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1233
+ if (result.error)
1234
+ throw result.error;
1068
1235
  if (result.status !== 0) {
1069
1236
  throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
1070
1237
  }
@@ -1078,25 +1245,29 @@ class RunManager {
1078
1245
  const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
1079
1246
  const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
1080
1247
  if (!shellEnvEnabled || !shellMetaAllowed) {
1081
- 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.');
1082
1249
  }
1083
1250
  const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
1084
- if (!cmdCheck.ok)
1085
- throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
1251
+ if (!cmdCheck.ok) {
1252
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cmdCheck.error}`);
1253
+ }
1086
1254
  const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
1087
- if (!envCheck.ok)
1088
- throw new Error(`Node "${nodeId}": ${envCheck.error}`);
1255
+ if (!envCheck.ok) {
1256
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${envCheck.error}`);
1257
+ }
1089
1258
  const ctx = { ...snapshot.state, state: snapshot.state };
1090
1259
  const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
1091
1260
  const postCheck = (0, shell_security_1.validateShellCommand)(command);
1092
- if (!postCheck.ok)
1093
- throw new Error(`Node "${nodeId}": ${postCheck.error}`);
1261
+ if (!postCheck.ok) {
1262
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${postCheck.error}`);
1263
+ }
1094
1264
  const interpolatedEnv = nodeSpec.env
1095
1265
  ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
1096
1266
  : undefined;
1097
1267
  const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, ctx) : undefined, this.projectRoot);
1098
- if (!cwdCheck.ok)
1099
- throw new Error(`Node "${nodeId}": ${cwdCheck.error}`);
1268
+ if (!cwdCheck.ok) {
1269
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1270
+ }
1100
1271
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
1101
1272
  if (opts?.dryRun) {
1102
1273
  console.log(`[dry-run] Would execute: ${command.join(' ')}`);
@@ -1117,13 +1288,11 @@ class RunManager {
1117
1288
  shell: false,
1118
1289
  stdin: shellStdin,
1119
1290
  });
1120
- nodeStdout = result.stdout;
1121
- nodeStderr = result.stderr;
1122
- nodeExitCode = result.status;
1123
- if (result.error)
1124
- throw result.error;
1291
+ recordProcessResult(result);
1125
1292
  if (result.timedOut)
1126
1293
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1294
+ if (result.error)
1295
+ throw result.error;
1127
1296
  if (result.status !== 0) {
1128
1297
  throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
1129
1298
  }
@@ -1135,13 +1304,13 @@ class RunManager {
1135
1304
  }
1136
1305
  case 'branch': {
1137
1306
  if (!nodeSpec.condition) {
1138
- throw new Error(`Branch node "${nodeId}" has no condition`);
1307
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch node "${nodeId}" has no condition`);
1139
1308
  }
1140
1309
  const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
1141
1310
  const branches = nodeSpec.branches;
1142
1311
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
1143
1312
  if (!targetNode) {
1144
- 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`);
1145
1314
  }
1146
1315
  console.log(`Branch "${nodeId}": condition="${nodeSpec.condition}" → ${condResult} → ${targetNode}`);
1147
1316
  nodesToComplete.push(nodeId);
@@ -1167,6 +1336,7 @@ class RunManager {
1167
1336
  }
1168
1337
  case 'error': {
1169
1338
  status = 'failed';
1339
+ failureCode = 'UNKNOWN';
1170
1340
  const ctx = { ...snapshot.state, state: snapshot.state };
1171
1341
  failureMessage = nodeSpec.message
1172
1342
  ? interpolateTemplate(nodeSpec.message, ctx)
@@ -1176,7 +1346,7 @@ class RunManager {
1176
1346
  }
1177
1347
  case 'expression': {
1178
1348
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
1179
- throw new Error(`Expression node "${nodeId}" requires "set" field`);
1349
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Expression node "${nodeId}" requires "set" field`);
1180
1350
  }
1181
1351
  for (const [key, expr] of Object.entries(nodeSpec.set)) {
1182
1352
  const result = safeEvaluate(String(expr), snapshot.state);
@@ -1207,26 +1377,46 @@ class RunManager {
1207
1377
  }
1208
1378
  }
1209
1379
  catch (e) {
1210
- const errMessage = e.message;
1211
- const partial = [
1212
- nodeStdout.trim() ? `stdout: ${nodeStdout.trim().slice(-500)}` : '',
1213
- nodeStderr.trim() ? `stderr: ${nodeStderr.trim().slice(-500)}` : '',
1214
- ].filter(Boolean).join(' | ');
1215
- 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);
1216
1397
  await this.atomicUpdateAsync(execId, (run) => {
1217
1398
  if (run.status === 'cancelled')
1218
1399
  return;
1219
1400
  Object.assign(run.state, stateUpdates);
1220
1401
  delete run.manual_resume;
1221
1402
  run.current_node = nodeId;
1222
- run.status = 'failed';
1223
- run.error = errMessage;
1403
+ assignRunFailure(run, failure, errMessage);
1224
1404
  run.audit = run.audit ?? [];
1225
1405
  run.audit.push(auditEntry);
1226
1406
  });
1227
1407
  throw e;
1228
1408
  }
1229
- 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);
1230
1420
  return this.atomicUpdateAsync(execId, (run) => {
1231
1421
  if (run.status === 'cancelled')
1232
1422
  return;
@@ -1241,18 +1431,20 @@ class RunManager {
1241
1431
  run.completed_nodes.push(prevNode);
1242
1432
  }
1243
1433
  run.current_node = currentNode;
1244
- if (status) {
1245
- run.status = status;
1246
- if (status !== 'failed') {
1247
- delete run.error;
1248
- }
1434
+ if (successFailure) {
1435
+ assignRunFailure(run, successFailure, failureMessage ?? successFailure.causeMessage);
1249
1436
  }
1250
- else if (run.status === 'failed') {
1251
- run.status = 'running';
1437
+ else {
1252
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
+ }
1253
1447
  }
1254
- if (failureMessage)
1255
- run.error = failureMessage;
1256
1448
  if (nodeTaskId) {
1257
1449
  run.tasks = run.tasks ?? {};
1258
1450
  run.tasks[nodeId] = nodeTaskId;
@@ -1262,13 +1454,19 @@ class RunManager {
1262
1454
  });
1263
1455
  }
1264
1456
  async claimAutoRunLease(execId, nodeId, preferredToken) {
1457
+ let claimed = false;
1265
1458
  const updated = await this.atomicUpdateAsync(execId, (run) => {
1459
+ if (isTerminalStatus(run.status))
1460
+ return;
1266
1461
  const existing = run.auto_run;
1267
1462
  const ownedByThisProcess = existing?.pid === process.pid && typeof existing.token === 'string';
1268
1463
  if (existing && !ownedByThisProcess && isProcessAlive(existing.pid)) {
1269
1464
  throw new Error(`Workflow run "${execId}" is already owned by another runner process`);
1270
1465
  }
1271
1466
  const token = preferredToken ?? (ownedByThisProcess ? existing.token : (0, sdk_1.generateId)('wfr'));
1467
+ if (!isTerminalStatus(run.status)) {
1468
+ run.status = 'running';
1469
+ }
1272
1470
  run.auto_run = {
1273
1471
  pid: process.pid,
1274
1472
  token,
@@ -1278,8 +1476,9 @@ class RunManager {
1278
1476
  : new Date().toISOString(),
1279
1477
  };
1280
1478
  delete run.manual_resume;
1479
+ claimed = true;
1281
1480
  });
1282
- return updated.auto_run.token;
1481
+ return claimed ? updated.auto_run?.token ?? null : null;
1283
1482
  }
1284
1483
  async releaseAutoRunLease(execId, token) {
1285
1484
  await this.atomicUpdateAsync(execId, (run) => {
@@ -1287,6 +1486,145 @@ class RunManager {
1287
1486
  delete run.auto_run;
1288
1487
  });
1289
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;
1561
+ }
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;
1572
+ while (true) {
1573
+ try {
1574
+ const run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1575
+ return { run, runnerToken: token, outcome: 'success' };
1576
+ }
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;
1625
+ }
1626
+ }
1627
+ }
1290
1628
  async runAuto(execId) {
1291
1629
  const leaseKey = `${this.resolvedDir}::${execId}`;
1292
1630
  if (inFlightAutoRuns.has(leaseKey)) {
@@ -1306,11 +1644,12 @@ class RunManager {
1306
1644
  runnerToken = initial.auto_run.token;
1307
1645
  }
1308
1646
  const doc = this.loadWorkflowYaml(initial.workflow_file);
1309
- if (!doc)
1310
- throw new Error(`Failed to load workflow file: ${initial.workflow_file}`);
1647
+ if (!doc) {
1648
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${initial.workflow_file}`);
1649
+ }
1311
1650
  const spec = doc.workflows[initial.workflow_id];
1312
1651
  if (!spec) {
1313
- throw new Error(`Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1652
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1314
1653
  }
1315
1654
  const executed = [];
1316
1655
  if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
@@ -1335,17 +1674,46 @@ class RunManager {
1335
1674
  if (run.status === 'cancelled')
1336
1675
  return { run, outcome: 'cancelled', reason: run.error, executed };
1337
1676
  const nodeId = run.current_node || firstNode;
1338
- if (!nodeId)
1339
- return { run, outcome: 'failed', reason: 'workflow has no nodes', executed };
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
+ }
1340
1692
  const node = spec.nodes[nodeId];
1341
1693
  if (!node) {
1342
1694
  run = await this.atomicUpdateAsync(execId, (r) => {
1343
1695
  r.current_node = nodeId;
1344
- r.status = 'failed';
1345
- r.error = `node "${nodeId}" not found in workflow`;
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');
1346
1706
  });
1347
1707
  return { run, outcome: 'failed', reason: run.error, executed };
1348
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
+ }
1349
1717
  if (node.type === 'approval') {
1350
1718
  if (run.current_node !== nodeId) {
1351
1719
  run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
@@ -1364,28 +1732,45 @@ class RunManager {
1364
1732
  }
1365
1733
  return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1366
1734
  }
1367
- runnerToken = await this.claimAutoRunLease(execId, nodeId, runnerToken);
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;
1368
1746
  run = await this.atomicUpdateAsync(execId, (r) => {
1369
1747
  const engine = readRunEngineState(r);
1370
1748
  engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1371
1749
  engine.iterations += 1;
1372
1750
  r.state['__engine'] = engine;
1373
1751
  if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1374
- r.status = 'failed';
1375
- r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
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');
1376
1762
  }
1377
1763
  });
1378
1764
  if (run.status === 'failed') {
1379
1765
  return { run, outcome: 'failed', reason: run.error, executed };
1380
1766
  }
1381
- try {
1382
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1383
- executed.push(nodeId);
1384
- }
1385
- catch (e) {
1386
- run = this.loadRun(execId);
1387
- return { run, outcome: 'failed', reason: e.message, executed };
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 };
1388
1772
  }
1773
+ executed.push(nodeId);
1389
1774
  if (run.status === 'completed')
1390
1775
  return { run, outcome: 'completed', executed };
1391
1776
  if (run.status === 'failed')
@@ -1418,23 +1803,19 @@ class RunManager {
1418
1803
  return;
1419
1804
  }
1420
1805
  const now = new Date();
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,
1813
+ });
1814
+ assignRunFailure(run, failure, reason);
1421
1815
  run.status = 'cancelled';
1422
- run.error = reason;
1423
1816
  delete run.auto_run;
1424
1817
  delete run.manual_resume;
1425
- run.audit = run.audit ?? [];
1426
- run.audit.push({
1427
- exec_id: run.id,
1428
- node_id: run.current_node || '',
1429
- type: 'cancel',
1430
- summary: reason,
1431
- started_at: now.toISOString(),
1432
- ended_at: now.toISOString(),
1433
- duration_ms: 0,
1434
- exit_code: null,
1435
- error: reason,
1436
- trigger: 'manual',
1437
- });
1818
+ appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
1438
1819
  });
1439
1820
  }
1440
1821
  reset(execId) {
@@ -1442,6 +1823,10 @@ class RunManager {
1442
1823
  run.state = JSON.parse(JSON.stringify(run.initial_state));
1443
1824
  run.current_node = '';
1444
1825
  run.completed_nodes = [];
1826
+ run.status = 'pending';
1827
+ delete run.error;
1828
+ delete run.last_failure;
1829
+ delete run.node_attempts;
1445
1830
  delete run.auto_run;
1446
1831
  delete run.manual_resume;
1447
1832
  });
@@ -1463,8 +1848,20 @@ class RunManager {
1463
1848
  throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
1464
1849
  }
1465
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
+ }
1466
1854
  const failedNode = run.current_node;
1467
- delete run.status;
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';
1468
1865
  delete run.error;
1469
1866
  delete run.manual_resume;
1470
1867
  run.retry_count = (run.retry_count ?? 0) + 1;
@@ -1486,7 +1883,7 @@ class RunManager {
1486
1883
  exec_id: run.id,
1487
1884
  node_id: failedNode,
1488
1885
  type: 'retry',
1489
- summary: `manual retry #${run.retry_count} of node "${failedNode}"`,
1886
+ summary: `manual retry #${run.retry_count}`,
1490
1887
  started_at: now,
1491
1888
  ended_at: now,
1492
1889
  duration_ms: 0,