@crewx/workflow 0.3.22-rc.19 → 0.3.22-rc.190

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 +1195 -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,30 @@ 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
+ let failWhenReason;
1174
+ const recordProcessResult = (result) => {
1175
+ nodeExitCode = result.status;
1176
+ nodeSignal = result.signal;
1177
+ nodeErrorOrigin = result.errorOrigin;
1178
+ processFailure = toProcessFailureMetadata(result);
1179
+ };
1180
+ const reserveAgentTaskId = async () => {
1181
+ const taskId = (0, sdk_1.generateId)('tsk');
1182
+ await this.atomicUpdateAsync(execId, (run) => {
1183
+ run.tasks = run.tasks ?? {};
1184
+ run.tasks[nodeId] = taskId;
1185
+ });
1186
+ nodeTaskId = taskId;
1187
+ return taskId;
1188
+ };
694
1189
  const buildAudit = (error) => {
695
1190
  const endedAt = new Date();
696
1191
  return {
@@ -706,31 +1201,57 @@ class RunManager {
706
1201
  trigger,
707
1202
  };
708
1203
  };
1204
+ const attempt = await this.reserveNodeAttempt(execId, nodeId);
709
1205
  try {
710
1206
  switch (nodeSpec.type) {
711
1207
  case 'agent_task': {
712
1208
  if (!nodeSpec.agent) {
713
- throw new Error(`Node "${nodeId}" is agent_task but has no agent defined`);
1209
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is agent_task but has no agent defined`);
1210
+ }
1211
+ const agentCtx = { ...snapshot.state, state: snapshot.state };
1212
+ const resolvedAgent = interpolateTemplate(nodeSpec.agent, agentCtx).trim();
1213
+ if (!resolvedAgent) {
1214
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": assignee not resolved (agent field "${nodeSpec.agent}" interpolated to empty)`);
1215
+ }
1216
+ const cwdCheck = (0, shell_security_1.normalizeCwd)(nodeSpec.cwd ? interpolateTemplate(nodeSpec.cwd, agentCtx) : undefined, this.projectRoot);
1217
+ if (!cwdCheck.ok) {
1218
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
714
1219
  }
1220
+ const nodeCwd = cwdCheck.resolved;
1221
+ const rootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1222
+ if (!rootCheck.ok) {
1223
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${rootCheck.error}`);
1224
+ }
1225
+ const workspaceRoot = rootCheck.resolved;
715
1226
  const input = nodeSpec.input
716
- ? interpolateTemplate(nodeSpec.input, { ...snapshot.state, state: snapshot.state })
1227
+ ? interpolateTemplate(nodeSpec.input, agentCtx)
717
1228
  : '';
1229
+ const preamble = process.env.CREWX_WORKFLOW_PREAMBLE === 'off'
1230
+ ? ''
1231
+ : (0, output_format_1.buildWorkflowPreamble)({
1232
+ workflowId: snapshot.workflow_id,
1233
+ nodeId,
1234
+ execId: snapshot.id,
1235
+ });
718
1236
  const finalInput = nodeSpec.output_format === 'json'
719
- ? input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
720
- : input;
1237
+ ? preamble + input + (0, output_format_1.buildJsonOutputSuffix)(nodeSpec.output_schema, nodeSpec.output_strict)
1238
+ : preamble + input;
721
1239
  const mode = nodeSpec.mode === 'query' ? 'q' : 'x';
722
- const prompt = `@${nodeSpec.agent} ${finalInput}`;
1240
+ const prompt = `@${resolvedAgent} ${finalInput}`;
723
1241
  if (opts?.dryRun) {
724
- console.log(`[dry-run] Would execute: crewx ${mode} "@${nodeSpec.agent} ..."`);
1242
+ console.log(`[dry-run] Would execute: crewx ${mode} "@${resolvedAgent} ..."`);
725
1243
  break;
726
1244
  }
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);
1245
+ console.log(`Executing: @${resolvedAgent} (${mode} mode)`);
732
1246
  const nodeTimeout = Number(process.env[mode === 'q' ? 'CREWX_TIMEOUT_QUERY' : 'CREWX_TIMEOUT_EXECUTE']) || 8 * 3600000;
733
- const wfArgs = [...baseArgs, mode, prompt];
1247
+ const childInvocation = resolveCrewxChildInvocation({
1248
+ ...process.env,
1249
+ CREWX_WORKSPACE: workspaceRoot,
1250
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1251
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1252
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1253
+ }, workspaceRoot);
1254
+ const wfArgs = [...childInvocation.args, mode, prompt];
734
1255
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
735
1256
  wfArgs.push(`--thread=workflow:${snapshot.id}`);
736
1257
  }
@@ -743,39 +1264,47 @@ class RunManager {
743
1264
  };
744
1265
  wfArgs.push('--metadata', JSON.stringify(wfMetadata));
745
1266
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
746
- command: bin,
1267
+ command: childInvocation.command,
1268
+ env: childInvocation.env,
1269
+ cwd: nodeCwd,
747
1270
  allowShellFallback: true,
748
1271
  packageName: 'crewx',
749
1272
  });
750
1273
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, wfArgs);
1274
+ const taskId = await reserveAgentTaskId();
751
1275
  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
- },
1276
+ cwd: nodeCwd,
1277
+ env: (0, sdk_1.withoutCrewxRootContext)({
1278
+ ...childInvocation.env,
1279
+ CREWX_TRACE_ID: taskId,
1280
+ }),
759
1281
  timeout: nodeTimeout,
760
1282
  shell: invocation.shell ?? false,
761
1283
  windowsHide: invocation.windowsHide,
1284
+ cancellation: this.processCancellation(execId),
762
1285
  });
763
- nodeStdout = result.stdout;
764
- nodeStderr = result.stderr;
765
- nodeExitCode = result.status;
766
- if (result.error) {
767
- throw result.error;
1286
+ recordProcessResult(result);
1287
+ if (result.cancelled) {
1288
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent execution cancelled for node "${nodeId}"`);
768
1289
  }
769
1290
  if (result.timedOut) {
770
1291
  throw new Error(`Agent execution timed out after ${nodeTimeout}ms`);
771
1292
  }
1293
+ if (result.error) {
1294
+ throw result.error;
1295
+ }
772
1296
  const taskIdMatch = result.stderr.match(/crewx kill (tsk_\w+)/);
773
- nodeTaskId = taskIdMatch?.[1];
1297
+ if (taskIdMatch?.[1])
1298
+ nodeTaskId = taskIdMatch[1];
774
1299
  if (result.status !== 0) {
775
1300
  const stderr = result.stderr.trim();
776
1301
  throw new Error(`Agent execution failed (exit ${result.status}): ${stderr}`);
777
1302
  }
778
1303
  const output = result.stdout.trim();
1304
+ const signal = (0, output_format_1.detectWorkflowSignal)(output);
1305
+ if (signal) {
1306
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${signal.reason ?? '(no reason given)'}`);
1307
+ }
779
1308
  if (nodeSpec.output) {
780
1309
  if (nodeSpec.output_format === 'json') {
781
1310
  const retryMax = nodeSpec.output_retry ?? 1;
@@ -788,36 +1317,41 @@ class RunManager {
788
1317
  retryInput = finalInput +
789
1318
  `\n\n## ⚠️ PREVIOUS OUTPUT FAILED JSON.parse: ${lastError.message}\n` +
790
1319
  `Return STRICT JSON ONLY. First char "{", last char "}". No prose, no fences.`;
791
- const retryPrompt = `@${nodeSpec.agent} ${retryInput}`;
792
- const retryArgs = [...baseArgs, mode, retryPrompt];
1320
+ const retryPrompt = `@${resolvedAgent} ${retryInput}`;
1321
+ const retryArgs = [...childInvocation.args, mode, retryPrompt];
793
1322
  if (process.env.CREWX_WORKFLOW_THREAD === 'on') {
794
1323
  retryArgs.push(`--thread=workflow:${snapshot.id}`);
795
1324
  }
796
1325
  retryArgs.push('--metadata', JSON.stringify(wfMetadata));
797
1326
  const retryInvocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, retryArgs);
1327
+ const retryTaskId = await reserveAgentTaskId();
798
1328
  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
- },
1329
+ cwd: nodeCwd,
1330
+ env: (0, sdk_1.withoutCrewxRootContext)({
1331
+ ...childInvocation.env,
1332
+ CREWX_TRACE_ID: retryTaskId,
1333
+ }),
806
1334
  timeout: nodeTimeout,
807
1335
  shell: retryInvocation.shell ?? false,
808
1336
  windowsHide: retryInvocation.windowsHide,
1337
+ cancellation: this.processCancellation(execId),
809
1338
  });
810
- nodeStdout = retryResult.stdout;
811
- nodeStderr = retryResult.stderr;
812
- nodeExitCode = retryResult.status;
813
- if (retryResult.error)
814
- throw retryResult.error;
1339
+ recordProcessResult(retryResult);
1340
+ if (retryResult.cancelled) {
1341
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `Agent retry cancelled for node "${nodeId}"`);
1342
+ }
815
1343
  if (retryResult.timedOut)
816
1344
  throw new Error(`Agent retry timed out after ${nodeTimeout}ms`);
1345
+ if (retryResult.error)
1346
+ throw retryResult.error;
817
1347
  if (retryResult.status !== 0) {
818
1348
  throw new Error(`Agent retry failed (exit ${retryResult.status}): ${retryResult.stderr.trim()}`);
819
1349
  }
820
1350
  attemptOutput = retryResult.stdout.trim();
1351
+ const retrySignal = (0, output_format_1.detectWorkflowSignal)(attemptOutput);
1352
+ if (retrySignal) {
1353
+ throw new Error(`Node "${nodeId}" agent signaled failure: ${retrySignal.reason ?? '(no reason given)'}`);
1354
+ }
821
1355
  }
822
1356
  try {
823
1357
  parsed = JSON.parse((0, output_format_1.extractJson)(attemptOutput));
@@ -831,7 +1365,7 @@ class RunManager {
831
1365
  }
832
1366
  }
833
1367
  if (parsed === null) {
834
- throw new Error(`Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
1368
+ throw (0, failure_1.createWorkflowFailureError)('OUTPUT_INVALID', `Node "${nodeId}" output_format=json parse failed after ${retryMax + 1} attempt(s): ${lastError.message}`);
835
1369
  }
836
1370
  stateUpdates[nodeSpec.output] = parsed;
837
1371
  console.log(`Result stored in state.${nodeSpec.output} (JSON object)`);
@@ -845,52 +1379,64 @@ class RunManager {
845
1379
  }
846
1380
  case 'skill_task': {
847
1381
  if (!nodeSpec.skill) {
848
- throw new Error(`Node "${nodeId}" is skill_task but has no skill defined`);
1382
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" is skill_task but has no skill defined`);
849
1383
  }
850
1384
  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-]*$)`);
1385
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}" has invalid skill name "${nodeSpec.skill}" (must match ^[a-z0-9][a-z0-9-]*$)`);
852
1386
  }
853
1387
  const ctx = { ...snapshot.state, state: snapshot.state };
854
1388
  const skillArgs = (nodeSpec.args ?? []).map((a) => interpolateTemplate(String(a), ctx));
1389
+ const skillStdin = nodeSpec.stdin !== undefined
1390
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1391
+ : undefined;
855
1392
  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}`);
1393
+ if (!cwdCheck.ok) {
1394
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1395
+ }
1396
+ const skillRootCheck = (0, shell_security_1.normalizeCwd)(undefined, this.projectRoot);
1397
+ if (!skillRootCheck.ok) {
1398
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${skillRootCheck.error}`);
1399
+ }
1400
+ const skillWorkspaceRoot = skillRootCheck.resolved;
858
1401
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
859
1402
  if (opts?.dryRun) {
860
1403
  console.log(`[dry-run] Would execute: crewx skill ${nodeSpec.skill} ${skillArgs.join(' ')}`.trim());
861
1404
  break;
862
1405
  }
863
1406
  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];
1407
+ const childInvocation = resolveCrewxChildInvocation({
1408
+ ...process.env,
1409
+ CREWX_WORKSPACE: skillWorkspaceRoot,
1410
+ CREWX_WORKFLOW_EXEC_ID: snapshot.id,
1411
+ CREWX_WORKFLOW_NODE_ID: nodeId,
1412
+ CREWX_WORKFLOW_ID: snapshot.workflow_id,
1413
+ }, skillWorkspaceRoot);
1414
+ const skillArgv = [...childInvocation.args, 'skill', nodeSpec.skill, ...skillArgs];
869
1415
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
870
- command: bin,
1416
+ command: childInvocation.command,
1417
+ env: childInvocation.env,
1418
+ cwd: cwdCheck.resolved,
871
1419
  allowShellFallback: true,
872
1420
  packageName: 'crewx',
873
1421
  });
874
1422
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, skillArgv);
875
1423
  const result = await runProcess(invocation.command, invocation.argv, {
876
1424
  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
- },
1425
+ env: childInvocation.env,
883
1426
  timeout,
884
1427
  shell: invocation.shell ?? false,
885
1428
  windowsHide: invocation.windowsHide,
1429
+ stdin: skillStdin,
1430
+ cancellation: this.processCancellation(execId),
886
1431
  });
887
- nodeStdout = result.stdout;
888
- nodeStderr = result.stderr;
889
- nodeExitCode = result.status;
890
- if (result.error)
891
- throw result.error;
1432
+ recordProcessResult(result);
1433
+ if (result.cancelled) {
1434
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `skill_task "${nodeId}" was cancelled`);
1435
+ }
892
1436
  if (result.timedOut)
893
1437
  throw new Error(`skill_task "${nodeId}" timed out after ${timeout}ms`);
1438
+ if (result.error)
1439
+ throw result.error;
894
1440
  if (result.status !== 0) {
895
1441
  throw new Error(`skill_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
896
1442
  }
@@ -904,25 +1450,29 @@ class RunManager {
904
1450
  const shellEnvEnabled = process.env.CREWX_WORKFLOW_SHELL === '1';
905
1451
  const shellMetaAllowed = spec.metadata?.shell_task_allowed === true;
906
1452
  if (!shellEnvEnabled || !shellMetaAllowed) {
907
- throw new Error('shell_task is disabled. Set CREWX_WORKFLOW_SHELL=1 and metadata.shell_task_allowed=true to enable.');
1453
+ 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
1454
  }
909
1455
  const cmdCheck = (0, shell_security_1.validateShellCommand)(nodeSpec.command);
910
- if (!cmdCheck.ok)
911
- throw new Error(`Node "${nodeId}": ${cmdCheck.error}`);
1456
+ if (!cmdCheck.ok) {
1457
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cmdCheck.error}`);
1458
+ }
912
1459
  const envCheck = (0, shell_security_1.validateEnvKeys)(nodeSpec.env);
913
- if (!envCheck.ok)
914
- throw new Error(`Node "${nodeId}": ${envCheck.error}`);
1460
+ if (!envCheck.ok) {
1461
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${envCheck.error}`);
1462
+ }
915
1463
  const ctx = { ...snapshot.state, state: snapshot.state };
916
1464
  const command = nodeSpec.command.map((c) => interpolateTemplate(c, ctx));
917
1465
  const postCheck = (0, shell_security_1.validateShellCommand)(command);
918
- if (!postCheck.ok)
919
- throw new Error(`Node "${nodeId}": ${postCheck.error}`);
1466
+ if (!postCheck.ok) {
1467
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${postCheck.error}`);
1468
+ }
920
1469
  const interpolatedEnv = nodeSpec.env
921
1470
  ? Object.fromEntries(Object.entries(nodeSpec.env).map(([k, v]) => [k, interpolateTemplate(String(v), ctx)]))
922
1471
  : undefined;
923
1472
  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}`);
1473
+ if (!cwdCheck.ok) {
1474
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Node "${nodeId}": ${cwdCheck.error}`);
1475
+ }
926
1476
  const timeout = nodeSpec.timeout ?? exports.SKILL_TASK_DEFAULT_TIMEOUT;
927
1477
  if (opts?.dryRun) {
928
1478
  console.log(`[dry-run] Would execute: ${command.join(' ')}`);
@@ -933,19 +1483,25 @@ class RunManager {
933
1483
  childEnv.CREWX_WORKFLOW_EXEC_ID = snapshot.id;
934
1484
  childEnv.CREWX_WORKFLOW_NODE_ID = nodeId;
935
1485
  childEnv.CREWX_WORKFLOW_ID = snapshot.workflow_id;
1486
+ const shellStdin = nodeSpec.stdin !== undefined
1487
+ ? interpolateTemplate(String(nodeSpec.stdin), ctx)
1488
+ : undefined;
936
1489
  const result = await runProcess(command[0], command.slice(1), {
937
1490
  cwd: cwdCheck.resolved,
938
1491
  env: childEnv,
939
1492
  timeout,
940
1493
  shell: false,
1494
+ stdin: shellStdin,
1495
+ cancellation: this.processCancellation(execId),
941
1496
  });
942
- nodeStdout = result.stdout;
943
- nodeStderr = result.stderr;
944
- nodeExitCode = result.status;
945
- if (result.error)
946
- throw result.error;
1497
+ recordProcessResult(result);
1498
+ if (result.cancelled) {
1499
+ throw (0, failure_1.createWorkflowFailureError)('CANCELLED', `shell_task "${nodeId}" was cancelled`);
1500
+ }
947
1501
  if (result.timedOut)
948
1502
  throw new Error(`shell_task "${nodeId}" timed out after ${timeout}ms`);
1503
+ if (result.error)
1504
+ throw result.error;
949
1505
  if (result.status !== 0) {
950
1506
  throw new Error(`shell_task "${nodeId}" failed (exit ${result.status}): ${result.stderr.trim()}`);
951
1507
  }
@@ -957,13 +1513,13 @@ class RunManager {
957
1513
  }
958
1514
  case 'branch': {
959
1515
  if (!nodeSpec.condition) {
960
- throw new Error(`Branch node "${nodeId}" has no condition`);
1516
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch node "${nodeId}" has no condition`);
961
1517
  }
962
1518
  const condResult = String(safeEvaluateBranchCondition(String(nodeSpec.condition), snapshot.state));
963
1519
  const branches = nodeSpec.branches;
964
1520
  const targetNode = branches?.[condResult] ?? nodeSpec.default;
965
1521
  if (!targetNode) {
966
- throw new Error(`Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
1522
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Branch "${nodeId}": no matching branch for "${condResult}" and no default`);
967
1523
  }
968
1524
  console.log(`Branch "${nodeId}": condition="${nodeSpec.condition}" → ${condResult} → ${targetNode}`);
969
1525
  nodesToComplete.push(nodeId);
@@ -989,6 +1545,7 @@ class RunManager {
989
1545
  }
990
1546
  case 'error': {
991
1547
  status = 'failed';
1548
+ failureCode = 'UNKNOWN';
992
1549
  const ctx = { ...snapshot.state, state: snapshot.state };
993
1550
  failureMessage = nodeSpec.message
994
1551
  ? interpolateTemplate(nodeSpec.message, ctx)
@@ -998,7 +1555,7 @@ class RunManager {
998
1555
  }
999
1556
  case 'expression': {
1000
1557
  if (!nodeSpec.set || typeof nodeSpec.set !== 'object') {
1001
- throw new Error(`Expression node "${nodeId}" requires "set" field`);
1558
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Expression node "${nodeId}" requires "set" field`);
1002
1559
  }
1003
1560
  for (const [key, expr] of Object.entries(nodeSpec.set)) {
1004
1561
  const result = safeEvaluate(String(expr), snapshot.state);
@@ -1019,30 +1576,73 @@ class RunManager {
1019
1576
  default:
1020
1577
  console.log(`Unknown node type "${nodeSpec.type}" for node "${nodeId}"`);
1021
1578
  }
1579
+ if (nodeSpec.fail_when &&
1580
+ !opts?.dryRun &&
1581
+ (nodeSpec.type === 'agent_task' || nodeSpec.type === 'skill_task' || nodeSpec.type === 'shell_task')) {
1582
+ const judged = safeEvaluateBranchCondition(String(nodeSpec.fail_when), { ...snapshot.state, ...stateUpdates });
1583
+ if (judged === true || judged === 'true') {
1584
+ const output = nodeSpec.output ? stateUpdates[nodeSpec.output] : undefined;
1585
+ if (output && typeof output === 'object' && !Array.isArray(output)) {
1586
+ const reason = output.reason;
1587
+ if (typeof reason === 'string') {
1588
+ const redactedReason = (0, sdk_1.redactTechnicalMessage)(reason);
1589
+ const firstLine = redactedReason
1590
+ ?.split(/\r\n|\r|\n/u)
1591
+ .find((line) => line.trim());
1592
+ if (firstLine)
1593
+ failWhenReason = firstLine.trim().slice(0, 180);
1594
+ }
1595
+ }
1596
+ throw new Error(`Node "${nodeId}" judged failed by fail_when: ${nodeSpec.fail_when}`);
1597
+ }
1598
+ }
1022
1599
  }
1023
1600
  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);
1601
+ const errMessage = getErrorMessage(e);
1602
+ const causeCode = (0, failure_1.classifyWorkflowFailure)({
1603
+ errorCode: (0, failure_1.getWorkflowFailureCode)(e),
1604
+ process: processFailure,
1605
+ });
1606
+ const failure = (0, failure_1.buildNodeFailure)({
1607
+ failedNode: nodeId,
1608
+ attempt,
1609
+ causeCode,
1610
+ exitCode: causeCode === 'NODE_EXIT_NONZERO' && typeof nodeExitCode === 'number'
1611
+ ? nodeExitCode
1612
+ : null,
1613
+ signal: causeCode === 'INTERRUPTED' ? nodeSignal : null,
1614
+ errorOrigin: nodeErrorOrigin,
1615
+ timedOut: processFailure?.timedOut ?? false,
1616
+ });
1617
+ const auditEntry = buildAudit(failWhenReason ?? failure.causeMessage);
1030
1618
  await this.atomicUpdateAsync(execId, (run) => {
1031
1619
  if (run.status === 'cancelled')
1032
1620
  return;
1621
+ Object.assign(run.state, stateUpdates);
1622
+ delete run.manual_resume;
1033
1623
  run.current_node = nodeId;
1034
- run.status = 'failed';
1035
- run.error = errMessage;
1624
+ assignRunFailure(run, failure, failWhenReason ?? errMessage);
1036
1625
  run.audit = run.audit ?? [];
1037
1626
  run.audit.push(auditEntry);
1038
1627
  });
1039
1628
  throw e;
1040
1629
  }
1041
- const successAudit = buildAudit();
1630
+ const successFailure = failureCode
1631
+ ? (0, failure_1.buildNodeFailure)({
1632
+ failedNode: nodeId,
1633
+ attempt,
1634
+ causeCode: failureCode,
1635
+ exitCode: null,
1636
+ signal: null,
1637
+ errorOrigin: null,
1638
+ })
1639
+ : null;
1640
+ const successAudit = buildAudit(successFailure?.causeMessage);
1042
1641
  return this.atomicUpdateAsync(execId, (run) => {
1043
1642
  if (run.status === 'cancelled')
1044
1643
  return;
1045
1644
  Object.assign(run.state, stateUpdates);
1645
+ delete run.manual_resume;
1046
1646
  for (const node of nodesToComplete) {
1047
1647
  if (!run.completed_nodes.includes(node)) {
1048
1648
  run.completed_nodes.push(node);
@@ -1052,10 +1652,20 @@ class RunManager {
1052
1652
  run.completed_nodes.push(prevNode);
1053
1653
  }
1054
1654
  run.current_node = currentNode;
1055
- if (status)
1056
- run.status = status;
1057
- if (failureMessage)
1058
- run.error = failureMessage;
1655
+ if (successFailure) {
1656
+ assignRunFailure(run, successFailure, failureMessage ?? successFailure.causeMessage);
1657
+ }
1658
+ else {
1659
+ delete run.error;
1660
+ delete run.last_failure;
1661
+ this.clearNodeAttempt(run, nodeId);
1662
+ if (status) {
1663
+ run.status = status;
1664
+ }
1665
+ else if (run.status !== 'completed') {
1666
+ run.status = 'running';
1667
+ }
1668
+ }
1059
1669
  if (nodeTaskId) {
1060
1670
  run.tasks = run.tasks ?? {};
1061
1671
  run.tasks[nodeId] = nodeTaskId;
@@ -1064,123 +1674,471 @@ class RunManager {
1064
1674
  run.audit.push(successAudit);
1065
1675
  });
1066
1676
  }
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,
1677
+ async claimAutoRunLease(execId, nodeId, preferredToken) {
1678
+ let claimed = false;
1679
+ const updated = await this.atomicUpdateAsync(execId, (run) => {
1680
+ if (isTerminalStatus(run.status))
1681
+ return;
1682
+ const existing = run.auto_run;
1683
+ const ownedByThisProcess = existing?.pid === process.pid && typeof existing.token === 'string';
1684
+ if (existing && !ownedByThisProcess && isProcessAlive(existing.pid)) {
1685
+ throw new Error(`Workflow run "${execId}" is already owned by another runner process`);
1686
+ }
1687
+ const token = preferredToken ?? (ownedByThisProcess ? existing.token : (0, sdk_1.generateId)('wfr'));
1688
+ if (!isTerminalStatus(run.status)) {
1689
+ run.status = 'running';
1690
+ }
1691
+ if (!run.current_node)
1692
+ run.current_node = nodeId;
1693
+ run.auto_run = {
1694
+ pid: process.pid,
1695
+ token,
1696
+ node_id: nodeId,
1697
+ started_at: ownedByThisProcess && existing.token === token
1698
+ ? existing.started_at
1699
+ : new Date().toISOString(),
1085
1700
  };
1701
+ delete run.manual_resume;
1702
+ claimed = true;
1703
+ });
1704
+ return claimed ? updated.auto_run?.token ?? null : null;
1705
+ }
1706
+ async releaseAutoRunLease(execId, token) {
1707
+ await this.atomicUpdateAsync(execId, (run) => {
1708
+ if (run.auto_run?.token === token)
1709
+ delete run.auto_run;
1710
+ });
1711
+ }
1712
+ async failAutoConfiguration(execId, nodeId, reason) {
1713
+ return this.atomicUpdateAsync(execId, (run) => {
1714
+ if (run.status === 'cancelled')
1715
+ return;
1716
+ const failure = (0, failure_1.buildNodeFailure)({
1717
+ failedNode: nodeId,
1718
+ attempt: (0, failure_1.readNodeAttempt)(run, nodeId),
1719
+ causeCode: 'CONFIG_INVALID',
1720
+ exitCode: null,
1721
+ signal: null,
1722
+ errorOrigin: null,
1723
+ });
1724
+ assignRunFailure(run, failure, reason);
1725
+ appendFailureAudit(run, failure, 'config', 'auto');
1726
+ });
1727
+ }
1728
+ async reserveAutoRetry(execId, nodeId, runnerToken, policy, delayMs) {
1729
+ let reserved = false;
1730
+ const run = await this.atomicUpdateAsync(execId, (draft) => {
1731
+ if (draft.status === 'cancelled')
1732
+ return;
1733
+ if (draft.status !== 'failed')
1734
+ return;
1735
+ if (draft.auto_run?.token !== runnerToken)
1736
+ return;
1737
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run: draft, nodeId });
1738
+ if (!decision.retry || decision.attempt === undefined)
1739
+ return;
1740
+ const now = new Date().toISOString();
1741
+ draft.status = 'retrying';
1742
+ delete draft.error;
1743
+ delete draft.manual_resume;
1744
+ draft.audit = draft.audit ?? [];
1745
+ draft.audit.push({
1746
+ exec_id: draft.id,
1747
+ node_id: nodeId,
1748
+ type: 'retry',
1749
+ summary: `auto retry ${decision.attempt + 1}/${policy.max + 1} in ${delayMs}ms`,
1750
+ started_at: now,
1751
+ ended_at: now,
1752
+ duration_ms: 0,
1753
+ exit_code: null,
1754
+ trigger: 'auto',
1755
+ });
1756
+ reserved = true;
1757
+ });
1758
+ return { run, reserved };
1759
+ }
1760
+ async waitForRetry(execId, runnerToken, delayMs) {
1761
+ const inspect = () => {
1762
+ const run = this.loadRun(execId);
1763
+ if (!run)
1764
+ throw new Error(`Execution not found: ${execId}`);
1765
+ if (run.status === 'cancelled') {
1766
+ return { run, outcome: 'cancelled', reason: 'run cancelled during retry delay' };
1767
+ }
1768
+ if (run.auto_run?.token !== runnerToken) {
1769
+ return { run, outcome: 'paused', reason: 'auto-run lease lost during retry delay' };
1770
+ }
1771
+ if (run.status !== 'retrying') {
1772
+ return { run, outcome: 'paused', reason: 'run state changed during retry delay' };
1773
+ }
1774
+ return null;
1775
+ };
1776
+ let remaining = Math.max(0, delayMs);
1777
+ while (remaining > 0) {
1778
+ const slice = Math.min(remaining, RETRY_WAIT_SLICE_MS);
1779
+ await this.sleep(slice);
1780
+ remaining -= slice;
1781
+ const stopped = inspect();
1782
+ if (stopped)
1783
+ return stopped;
1086
1784
  }
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;
1785
+ const stopped = inspect();
1786
+ if (stopped)
1787
+ return stopped;
1788
+ const run = this.loadRun(execId);
1789
+ if (!run)
1790
+ throw new Error(`Execution not found: ${execId}`);
1791
+ return { run, outcome: 'ready' };
1792
+ }
1793
+ async runNodeWithRetry(execId, nodeId, runnerToken, policy) {
1794
+ let token = runnerToken;
1094
1795
  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 };
1796
+ try {
1797
+ const run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1798
+ if (run.status === 'cancelled') {
1799
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1800
+ }
1801
+ return { run, runnerToken: token, outcome: 'success' };
1802
+ }
1803
+ catch (caught) {
1804
+ const run = this.loadRun(execId);
1805
+ if (!run)
1806
+ throw new Error(`Execution not found: ${execId}`);
1807
+ if (run.status === 'cancelled') {
1808
+ return { run, runnerToken: token, outcome: 'cancelled', reason: run.error };
1809
+ }
1810
+ if (run.status !== 'failed' || !policy) {
1811
+ return { run, runnerToken: token, outcome: run.status === 'failed' ? 'failed' : 'paused', reason: getErrorMessage(caught) };
1812
+ }
1813
+ const decision = (0, retry_policy_1.decideRetry)({ policy, run, nodeId });
1814
+ if (!decision.retry || decision.attempt === undefined) {
1815
+ return { run, runnerToken: token, outcome: 'failed', reason: getErrorMessage(caught) };
1816
+ }
1817
+ const delayMs = (0, retry_policy_1.computeRetryDelayMs)(policy, decision.attempt);
1818
+ const reservation = await this.reserveAutoRetry(execId, nodeId, token, policy, delayMs);
1819
+ if (!reservation.reserved) {
1820
+ const latest = reservation.run;
1821
+ if (latest.status === 'cancelled') {
1822
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1823
+ }
1824
+ return {
1825
+ run: latest,
1826
+ runnerToken: token,
1827
+ outcome: latest.status === 'failed' ? 'failed' : 'paused',
1828
+ reason: getErrorMessage(caught),
1829
+ };
1830
+ }
1831
+ const waited = await this.waitForRetry(execId, token, delayMs);
1832
+ if (waited.outcome !== 'ready') {
1833
+ return { run: waited.run, runnerToken: token, outcome: waited.outcome, reason: waited.reason };
1834
+ }
1835
+ const nextToken = await this.claimAutoRunLease(execId, nodeId, token);
1836
+ if (!nextToken) {
1837
+ const latest = this.loadRun(execId);
1838
+ if (!latest)
1839
+ throw new Error(`Execution not found: ${execId}`);
1840
+ if (latest.status === 'cancelled') {
1841
+ return { run: latest, runnerToken: token, outcome: 'cancelled', reason: latest.error };
1842
+ }
1843
+ return {
1844
+ run: latest,
1845
+ runnerToken: token,
1846
+ outcome: 'paused',
1847
+ reason: 'run stopped before retry lease could be reclaimed',
1848
+ };
1849
+ }
1850
+ token = nextToken;
1851
+ }
1852
+ }
1853
+ }
1854
+ async runAuto(execId) {
1855
+ const leaseKey = `${this.resolvedDir}::${execId}`;
1856
+ if (inFlightAutoRuns.has(leaseKey)) {
1857
+ const run = this.loadRun(execId);
1858
+ if (!run)
1859
+ throw new Error(`Execution not found: ${execId}`);
1860
+ console.log(`runAuto "${execId}": already in-flight — skipping duplicate auto-run (single-flight guard)`);
1861
+ return { run, outcome: 'paused', reason: 'execution already in-flight (single-flight guard)', executed: [] };
1862
+ }
1863
+ inFlightAutoRuns.add(leaseKey);
1864
+ let runnerToken;
1865
+ try {
1866
+ const initial = this.loadRun(execId);
1867
+ if (!initial)
1868
+ throw new Error(`Execution not found: ${execId}`);
1869
+ if (initial.auto_run?.pid === process.pid) {
1870
+ runnerToken = initial.auto_run.token;
1113
1871
  }
1114
- if (node.type === 'approval') {
1115
- return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1872
+ const doc = this.loadWorkflowYaml(initial.workflow_file);
1873
+ if (!doc) {
1874
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Failed to load workflow file: ${initial.workflow_file}`);
1116
1875
  }
1117
- if (node.type === 'parallel') {
1118
- return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1876
+ const spec = doc.workflows[initial.workflow_id];
1877
+ if (!spec) {
1878
+ throw (0, failure_1.createWorkflowFailureError)('CONFIG_INVALID', `Workflow "${initial.workflow_id}" not found in ${initial.workflow_file}`);
1119
1879
  }
1120
- if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1121
- return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1880
+ const executed = [];
1881
+ if (initial.status === 'completed' || initial.status === 'failed' || initial.status === 'cancelled') {
1882
+ return {
1883
+ run: initial,
1884
+ outcome: initial.status === 'completed' ? 'completed' : initial.status === 'cancelled' ? 'cancelled' : 'failed',
1885
+ reason: `run already ${initial.status}`,
1886
+ executed,
1887
+ };
1122
1888
  }
1123
- visitCounts[nodeId] = (visitCounts[nodeId] ?? 0) + 1;
1124
- iterations++;
1125
- if (visitCounts[nodeId] > maxIterations || iterations > maxIterations) {
1889
+ const firstNode = Object.keys(spec.nodes ?? {})[0];
1890
+ const maxIterations = (typeof spec.max_iterations === 'number' && spec.max_iterations > 0)
1891
+ ? spec.max_iterations
1892
+ : 30;
1893
+ let run = initial;
1894
+ while (true) {
1895
+ run = this.loadRun(execId);
1896
+ if (run.status === 'completed')
1897
+ return { run, outcome: 'completed', executed };
1898
+ if (run.status === 'failed')
1899
+ return { run, outcome: 'failed', reason: run.error, executed };
1900
+ if (run.status === 'cancelled')
1901
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1902
+ const nodeId = run.current_node || firstNode;
1903
+ if (!nodeId) {
1904
+ run = await this.atomicUpdateAsync(execId, (r) => {
1905
+ const failure = (0, failure_1.buildNodeFailure)({
1906
+ failedNode: r.current_node || '',
1907
+ attempt: (0, failure_1.readNodeAttempt)(r, r.current_node || ''),
1908
+ causeCode: 'CONFIG_INVALID',
1909
+ exitCode: null,
1910
+ signal: null,
1911
+ errorOrigin: null,
1912
+ });
1913
+ assignRunFailure(r, failure, 'workflow has no nodes');
1914
+ appendFailureAudit(r, failure, 'config', 'auto');
1915
+ });
1916
+ return { run, outcome: 'failed', reason: run.error, executed };
1917
+ }
1918
+ const node = spec.nodes[nodeId];
1919
+ if (!node) {
1920
+ run = await this.atomicUpdateAsync(execId, (r) => {
1921
+ r.current_node = nodeId;
1922
+ const failure = (0, failure_1.buildNodeFailure)({
1923
+ failedNode: nodeId,
1924
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1925
+ causeCode: 'CONFIG_INVALID',
1926
+ exitCode: null,
1927
+ signal: null,
1928
+ errorOrigin: null,
1929
+ });
1930
+ assignRunFailure(r, failure, `node "${nodeId}" not found in workflow`);
1931
+ appendFailureAudit(r, failure, 'config', 'auto');
1932
+ });
1933
+ return { run, outcome: 'failed', reason: run.error, executed };
1934
+ }
1935
+ const parsedRetryPolicy = (0, retry_policy_1.parseRetryPolicy)(node);
1936
+ if (!parsedRetryPolicy.ok) {
1937
+ run = await this.failAutoConfiguration(execId, nodeId, parsedRetryPolicy.reason);
1938
+ if (run.status === 'cancelled') {
1939
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1940
+ }
1941
+ return { run, outcome: 'failed', reason: run.error, executed };
1942
+ }
1943
+ if (node.type === 'approval') {
1944
+ if (run.current_node !== nodeId) {
1945
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1946
+ }
1947
+ return { run, outcome: 'paused', reason: `approval node "${nodeId}" requires manual decision`, executed };
1948
+ }
1949
+ if (node.type === 'parallel') {
1950
+ if (run.current_node !== nodeId) {
1951
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1952
+ }
1953
+ return { run, outcome: 'paused', reason: `parallel auto-run not implemented (node "${nodeId}")`, executed };
1954
+ }
1955
+ if (!KNOWN_AUTO_NODE_TYPES.has(node.type)) {
1956
+ if (run.current_node !== nodeId) {
1957
+ run = await this.atomicUpdateAsync(execId, (r) => { r.current_node = nodeId; });
1958
+ }
1959
+ return { run, outcome: 'paused', reason: `unknown node type "${node.type}" (node "${nodeId}")`, executed };
1960
+ }
1961
+ const claimedToken = await this.claimAutoRunLease(execId, nodeId, runnerToken);
1962
+ if (!claimedToken) {
1963
+ run = this.loadRun(execId);
1964
+ return {
1965
+ run,
1966
+ outcome: run.status === 'cancelled' ? 'cancelled' : 'paused',
1967
+ reason: run.status === 'cancelled' ? run.error : 'run stopped before auto-run lease could be claimed',
1968
+ executed,
1969
+ };
1970
+ }
1971
+ runnerToken = claimedToken;
1126
1972
  run = await this.atomicUpdateAsync(execId, (r) => {
1127
- r.status = 'failed';
1128
- r.error = `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`;
1973
+ if (r.status === 'cancelled')
1974
+ return;
1975
+ const engine = readRunEngineState(r);
1976
+ engine.visitCounts[nodeId] = (engine.visitCounts[nodeId] ?? 0) + 1;
1977
+ engine.iterations += 1;
1978
+ r.state['__engine'] = engine;
1979
+ if (engine.visitCounts[nodeId] > maxIterations || engine.iterations > maxIterations) {
1980
+ const failure = (0, failure_1.buildNodeFailure)({
1981
+ failedNode: nodeId,
1982
+ attempt: (0, failure_1.readNodeAttempt)(r, nodeId),
1983
+ causeCode: 'CONFIG_INVALID',
1984
+ exitCode: null,
1985
+ signal: null,
1986
+ errorOrigin: null,
1987
+ });
1988
+ assignRunFailure(r, failure, `auto runner exceeded max_iterations (${maxIterations}); possible loop at "${nodeId}"`);
1989
+ appendFailureAudit(r, failure, 'config', 'auto');
1990
+ }
1129
1991
  });
1130
- return { run, outcome: 'failed', reason: run.error, executed };
1131
- }
1132
- try {
1133
- run = await this.executeNode(execId, nodeId, { trigger: 'auto' });
1992
+ if (run.status === 'cancelled') {
1993
+ return { run, outcome: 'cancelled', reason: run.error, executed };
1994
+ }
1995
+ if (run.status === 'failed') {
1996
+ return { run, outcome: 'failed', reason: run.error, executed };
1997
+ }
1998
+ const nodeResult = await this.runNodeWithRetry(execId, nodeId, runnerToken, parsedRetryPolicy.policy);
1999
+ run = nodeResult.run;
2000
+ runnerToken = nodeResult.runnerToken;
2001
+ if (nodeResult.outcome !== 'success') {
2002
+ return { run, outcome: nodeResult.outcome, reason: nodeResult.reason, executed };
2003
+ }
1134
2004
  executed.push(nodeId);
2005
+ if (run.status === 'completed')
2006
+ return { run, outcome: 'completed', executed };
2007
+ if (run.status === 'failed')
2008
+ return { run, outcome: 'failed', reason: run.error, executed };
2009
+ if (run.status === 'cancelled')
2010
+ return { run, outcome: 'cancelled', reason: run.error, executed };
2011
+ if (!SELF_ADVANCING_NODE_TYPES.has(node.type)) {
2012
+ const next = node.next;
2013
+ if (!next) {
2014
+ return { run, outcome: 'paused', reason: `node "${nodeId}" has no "next"; stopping`, executed };
2015
+ }
2016
+ run = this.moveNode(execId, next);
2017
+ }
1135
2018
  }
1136
- catch (e) {
1137
- run = this.loadRun(execId);
1138
- return { run, outcome: 'failed', reason: e.message, executed };
1139
- }
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 };
2019
+ }
2020
+ finally {
2021
+ if (runnerToken) {
2022
+ try {
2023
+ await this.releaseAutoRunLease(execId, runnerToken);
2024
+ }
2025
+ catch {
1150
2026
  }
1151
- run = this.moveNode(execId, next);
1152
2027
  }
2028
+ inFlightAutoRuns.delete(leaseKey);
1153
2029
  }
1154
2030
  }
1155
2031
  cancelRun(execId, reason = 'Cancelled by user') {
1156
- return this.atomicUpdate(execId, (run) => {
2032
+ const cancelled = this.atomicUpdate(execId, (run) => {
1157
2033
  if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
1158
2034
  return;
1159
2035
  }
1160
2036
  const now = new Date();
2037
+ const activeNodeId = run.auto_run?.node_id || run.current_node || '';
2038
+ const failure = (0, failure_1.buildNodeFailure)({
2039
+ failedNode: activeNodeId,
2040
+ attempt: (0, failure_1.readNodeAttempt)(run, activeNodeId),
2041
+ causeCode: 'CANCELLED',
2042
+ exitCode: null,
2043
+ signal: null,
2044
+ errorOrigin: null,
2045
+ });
2046
+ assignRunFailure(run, failure, reason);
1161
2047
  run.status = 'cancelled';
1162
- run.error = reason;
2048
+ delete run.auto_run;
2049
+ delete run.manual_resume;
2050
+ appendFailureAudit(run, failure, 'cancel', 'manual', now.toISOString());
2051
+ });
2052
+ if (cancelled.status === 'cancelled') {
2053
+ requestActiveProcessCancellation(this.resolvedDir, execId);
2054
+ }
2055
+ return cancelled;
2056
+ }
2057
+ reset(execId) {
2058
+ return this.atomicUpdate(execId, (run) => {
2059
+ run.state = JSON.parse(JSON.stringify(run.initial_state));
2060
+ run.current_node = '';
2061
+ run.completed_nodes = [];
2062
+ run.status = 'pending';
2063
+ delete run.error;
2064
+ delete run.last_failure;
2065
+ delete run.node_attempts;
2066
+ delete run.auto_run;
2067
+ delete run.manual_resume;
2068
+ });
2069
+ }
2070
+ retry(execId, options = {}) {
2071
+ const snapshot = this.loadRun(execId);
2072
+ if (!snapshot)
2073
+ throw new Error(`Execution not found: ${execId}`);
2074
+ const doc = this.loadWorkflowYaml(snapshot.workflow_file);
2075
+ if (!doc)
2076
+ throw new Error(`Failed to load workflow file: ${snapshot.workflow_file}`);
2077
+ const spec = doc.workflows[snapshot.workflow_id];
2078
+ if (!spec)
2079
+ throw new Error(`Workflow "${snapshot.workflow_id}" not found`);
2080
+ if (snapshot.status !== 'failed') {
2081
+ throw new Error(`Run "${execId}" is not failed (status=${snapshot.status ?? 'running'}); only failed runs can be retried`);
2082
+ }
2083
+ if (!isRetryableFailure(snapshot, spec)) {
2084
+ throw new Error(`Run "${execId}" failed at a non-retryable node "${snapshot.current_node}" (error node); use reset to restart`);
2085
+ }
2086
+ return this.atomicUpdate(execId, (run) => {
2087
+ if (run.status !== 'failed') {
2088
+ throw new Error(`Run "${execId}" is not failed (status=${run.status ?? 'pending'}); only failed runs can be retried`);
2089
+ }
2090
+ const failedNode = run.current_node;
2091
+ if (failedNode && run.last_failure) {
2092
+ const lastAttempt = run.last_failure.attempt;
2093
+ const storedAttempt = run.node_attempts?.[failedNode];
2094
+ if (typeof lastAttempt === 'number' && Number.isInteger(lastAttempt) && lastAttempt > 0
2095
+ && (typeof storedAttempt !== 'number' || storedAttempt < lastAttempt)) {
2096
+ run.node_attempts = run.node_attempts ?? {};
2097
+ run.node_attempts[failedNode] = lastAttempt;
2098
+ }
2099
+ }
2100
+ run.status = 'retrying';
2101
+ delete run.error;
2102
+ delete run.manual_resume;
2103
+ run.retry_count = (run.retry_count ?? 0) + 1;
1163
2104
  run.audit = run.audit ?? [];
2105
+ const now = new Date().toISOString();
2106
+ if (options.autoRun === false) {
2107
+ delete run.auto_run;
2108
+ run.manual_resume = { node_id: failedNode, requested_at: now };
2109
+ }
2110
+ else {
2111
+ run.auto_run = {
2112
+ pid: process.pid,
2113
+ token: (0, sdk_1.generateId)('wfr'),
2114
+ node_id: failedNode,
2115
+ started_at: now,
2116
+ };
2117
+ }
1164
2118
  run.audit.push({
1165
2119
  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(),
2120
+ node_id: failedNode,
2121
+ type: 'retry',
2122
+ summary: `manual retry #${run.retry_count}`,
2123
+ started_at: now,
2124
+ ended_at: now,
1171
2125
  duration_ms: 0,
1172
2126
  exit_code: null,
1173
- error: reason,
1174
2127
  trigger: 'manual',
1175
2128
  });
1176
2129
  });
1177
2130
  }
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
- });
2131
+ listRunIds() {
2132
+ try {
2133
+ return fs.readdirSync(this.resolvedDir, { withFileTypes: true })
2134
+ .filter((entry) => entry.isFile() && !entry.name.startsWith('.') && entry.name.endsWith('.json'))
2135
+ .map((entry) => entry.name.slice(0, -'.json'.length));
2136
+ }
2137
+ catch (error) {
2138
+ if (error.code === 'ENOENT')
2139
+ return [];
2140
+ throw error;
2141
+ }
1184
2142
  }
1185
2143
  listRuns(opts) {
1186
2144
  this.ensureRunsDir();
@@ -1188,7 +2146,10 @@ class RunManager {
1188
2146
  const runs = [];
1189
2147
  for (const file of files) {
1190
2148
  try {
1191
- const run = JSON.parse(fs.readFileSync(path.join(this.resolvedDir, file), 'utf-8'));
2149
+ const execId = file.slice(0, -'.json'.length);
2150
+ const run = this.loadRun(execId);
2151
+ if (!run)
2152
+ continue;
1192
2153
  if (opts?.workflowId && run.workflow_id !== opts.workflowId)
1193
2154
  continue;
1194
2155
  runs.push(run);