@crewx/workflow 0.3.22-rc.17 → 0.3.22-rc.170

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