@agent-relay/sdk 4.0.0 → 4.0.1

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 (73) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/broker-path.d.ts +3 -2
  6. package/dist/broker-path.d.ts.map +1 -1
  7. package/dist/broker-path.js +119 -32
  8. package/dist/broker-path.js.map +1 -1
  9. package/dist/client.d.ts +12 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +20 -1
  12. package/dist/client.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/relay.d.ts +2 -1
  17. package/dist/relay.d.ts.map +1 -1
  18. package/dist/relay.js +1 -1
  19. package/dist/relay.js.map +1 -1
  20. package/dist/workflows/__tests__/channel-messenger.test.d.ts +2 -0
  21. package/dist/workflows/__tests__/channel-messenger.test.d.ts.map +1 -0
  22. package/dist/workflows/__tests__/channel-messenger.test.js +117 -0
  23. package/dist/workflows/__tests__/channel-messenger.test.js.map +1 -0
  24. package/dist/workflows/__tests__/run-summary-table.test.js +4 -3
  25. package/dist/workflows/__tests__/run-summary-table.test.js.map +1 -1
  26. package/dist/workflows/__tests__/step-executor.test.d.ts +2 -0
  27. package/dist/workflows/__tests__/step-executor.test.d.ts.map +1 -0
  28. package/dist/workflows/__tests__/step-executor.test.js +378 -0
  29. package/dist/workflows/__tests__/step-executor.test.js.map +1 -0
  30. package/dist/workflows/__tests__/template-resolver.test.d.ts +2 -0
  31. package/dist/workflows/__tests__/template-resolver.test.d.ts.map +1 -0
  32. package/dist/workflows/__tests__/template-resolver.test.js +145 -0
  33. package/dist/workflows/__tests__/template-resolver.test.js.map +1 -0
  34. package/dist/workflows/__tests__/verification.test.d.ts +2 -0
  35. package/dist/workflows/__tests__/verification.test.d.ts.map +1 -0
  36. package/dist/workflows/__tests__/verification.test.js +170 -0
  37. package/dist/workflows/__tests__/verification.test.js.map +1 -0
  38. package/dist/workflows/builder.d.ts +3 -2
  39. package/dist/workflows/builder.d.ts.map +1 -1
  40. package/dist/workflows/builder.js +1 -3
  41. package/dist/workflows/builder.js.map +1 -1
  42. package/dist/workflows/channel-messenger.d.ts +28 -0
  43. package/dist/workflows/channel-messenger.d.ts.map +1 -0
  44. package/dist/workflows/channel-messenger.js +255 -0
  45. package/dist/workflows/channel-messenger.js.map +1 -0
  46. package/dist/workflows/index.d.ts +7 -0
  47. package/dist/workflows/index.d.ts.map +1 -1
  48. package/dist/workflows/index.js +7 -0
  49. package/dist/workflows/index.js.map +1 -1
  50. package/dist/workflows/process-spawner.d.ts +35 -0
  51. package/dist/workflows/process-spawner.d.ts.map +1 -0
  52. package/dist/workflows/process-spawner.js +141 -0
  53. package/dist/workflows/process-spawner.js.map +1 -0
  54. package/dist/workflows/run.d.ts +2 -1
  55. package/dist/workflows/run.d.ts.map +1 -1
  56. package/dist/workflows/run.js.map +1 -1
  57. package/dist/workflows/runner.d.ts +6 -6
  58. package/dist/workflows/runner.d.ts.map +1 -1
  59. package/dist/workflows/runner.js +443 -719
  60. package/dist/workflows/runner.js.map +1 -1
  61. package/dist/workflows/step-executor.d.ts +95 -0
  62. package/dist/workflows/step-executor.d.ts.map +1 -0
  63. package/dist/workflows/step-executor.js +393 -0
  64. package/dist/workflows/step-executor.js.map +1 -0
  65. package/dist/workflows/template-resolver.d.ts +33 -0
  66. package/dist/workflows/template-resolver.d.ts.map +1 -0
  67. package/dist/workflows/template-resolver.js +144 -0
  68. package/dist/workflows/template-resolver.js.map +1 -0
  69. package/dist/workflows/verification.d.ts +33 -0
  70. package/dist/workflows/verification.d.ts.map +1 -0
  71. package/dist/workflows/verification.js +122 -0
  72. package/dist/workflows/verification.js.map +1 -0
  73. package/package.json +2 -2
@@ -18,13 +18,68 @@ import { resolveCliSync } from '../cli-resolver.js';
18
18
  import { loadCustomSteps, resolveAllCustomSteps, validateCustomStepsUsage, CustomStepsParseError, CustomStepResolutionError, } from './custom-steps.js';
19
19
  import { collectCliSession } from './cli-session-collector.js';
20
20
  import { executeApiStep } from './api-executor.js';
21
+ import { ChannelMessenger } from './channel-messenger.js';
21
22
  import { InMemoryWorkflowDb } from './memory-db.js';
23
+ import { buildCommand as buildProcessCommand, spawnProcess } from './process-spawner.js';
22
24
  import { formatRunSummaryTable } from './run-summary-table.js';
25
+ import { StepExecutor as WorkflowStepLifecycleExecutor, } from './step-executor.js';
26
+ import { interpolateStepTask as interpolateStepTaskTemplate, resolveDotPath as resolveTemplateDotPath, resolveTemplate, TemplateResolver, } from './template-resolver.js';
23
27
  import { WorkflowTrajectory } from './trajectory.js';
28
+ import { runVerification, stripInjectedTaskEcho, WorkflowCompletionError, } from './verification.js';
24
29
  // ── AgentRelay SDK imports ──────────────────────────────────────────────────
25
30
  // Import from sub-paths to avoid pulling in the full @relaycast/sdk dependency.
26
31
  import { AgentRelay } from '../relay.js';
27
32
  import { RelayCast, RelayError } from '@relaycast/sdk';
33
+ // ── Environment filtering ──────────────────────────────────────────────────
34
+ /** Keys explicitly allowed to propagate to spawned child processes. */
35
+ const ENV_ALLOWLIST = new Set([
36
+ 'PATH',
37
+ 'HOME',
38
+ 'USER',
39
+ 'SHELL',
40
+ 'LANG',
41
+ 'TERM',
42
+ 'TMPDIR',
43
+ 'TZ',
44
+ 'NODE_ENV',
45
+ 'NODE_PATH',
46
+ 'NODE_OPTIONS',
47
+ 'NODE_EXTRA_CA_CERTS',
48
+ 'RUST_LOG',
49
+ 'RUST_BACKTRACE',
50
+ 'RELAY_API_KEY',
51
+ 'RELAYCAST_BASE_URL',
52
+ 'AGENT_RELAY_DASHBOARD_PORT',
53
+ 'AGENT_RELAY_RUN_ID_FILE',
54
+ 'EDITOR',
55
+ 'VISUAL',
56
+ 'GIT_AUTHOR_NAME',
57
+ 'GIT_AUTHOR_EMAIL',
58
+ 'GIT_COMMITTER_NAME',
59
+ 'GIT_COMMITTER_EMAIL',
60
+ 'HTTPS_PROXY',
61
+ 'HTTP_PROXY',
62
+ 'NO_PROXY',
63
+ 'https_proxy',
64
+ 'http_proxy',
65
+ 'no_proxy',
66
+ 'XDG_CONFIG_HOME',
67
+ 'XDG_DATA_HOME',
68
+ 'XDG_CACHE_HOME',
69
+ ]);
70
+ /** Return a filtered copy of process.env containing only allowlisted keys. */
71
+ function filteredEnv(extra) {
72
+ const env = {};
73
+ for (const key of ENV_ALLOWLIST) {
74
+ if (process.env[key] !== undefined) {
75
+ env[key] = process.env[key];
76
+ }
77
+ }
78
+ if (extra) {
79
+ Object.assign(env, extra);
80
+ }
81
+ return env;
82
+ }
28
83
  /** Error carrying exit code/signal from a failed subprocess spawn. */
29
84
  class SpawnExitError extends Error {
30
85
  exitCode;
@@ -36,14 +91,6 @@ class SpawnExitError extends Error {
36
91
  this.exitSignal = exitSignal ?? undefined;
37
92
  }
38
93
  }
39
- class WorkflowCompletionError extends Error {
40
- completionReason;
41
- constructor(message, completionReason) {
42
- super(message);
43
- this.name = 'WorkflowCompletionError';
44
- this.completionReason = completionReason;
45
- }
46
- }
47
94
  // ── CLI resolution ───────────────────────────────────────────────────────────
48
95
  /**
49
96
  * Resolve `cursor` to the concrete cursor agent binary available in PATH.
@@ -63,6 +110,8 @@ export class WorkflowRunner {
63
110
  summaryDir;
64
111
  executor;
65
112
  envSecrets;
113
+ templateResolver;
114
+ channelMessenger;
66
115
  /** @internal exposed for CLI signal-handler shutdown only */
67
116
  relay;
68
117
  relaycast;
@@ -125,6 +174,8 @@ export class WorkflowRunner {
125
174
  this.workersPath = path.join(this.cwd, '.agent-relay', 'team', 'workers.json');
126
175
  this.executor = options.executor;
127
176
  this.envSecrets = options.envSecrets;
177
+ this.templateResolver = new TemplateResolver();
178
+ this.channelMessenger = new ChannelMessenger({ postFn: (text) => this.postToChannel(text) });
128
179
  }
129
180
  // ── Path resolution ─────────────────────────────────────────────────────
130
181
  /** Expand environment variables like $HOME or $VAR in a path string. */
@@ -251,11 +302,10 @@ export class WorkflowRunner {
251
302
  this.rememberStepSignalSender(stepName, 'worker', workerSender);
252
303
  }
253
304
  rememberStepSignalSender(stepName, participant, ...senders) {
254
- const participants = this.stepSignalParticipants.get(stepName) ??
255
- {
256
- ownerSenders: new Set(),
257
- workerSenders: new Set(),
258
- };
305
+ const participants = this.stepSignalParticipants.get(stepName) ?? {
306
+ ownerSenders: new Set(),
307
+ workerSenders: new Set(),
308
+ };
259
309
  this.stepSignalParticipants.set(stepName, participants);
260
310
  const target = participant === 'owner' ? participants.ownerSenders : participants.workerSenders;
261
311
  for (const sender of senders) {
@@ -275,11 +325,7 @@ export class WorkflowRunner {
275
325
  return undefined;
276
326
  }
277
327
  isSignalFromExpectedSender(stepName, signal) {
278
- const expectedParticipant = signal.kind === 'worker_done'
279
- ? 'worker'
280
- : signal.kind === 'lead_done'
281
- ? 'owner'
282
- : undefined;
328
+ const expectedParticipant = signal.kind === 'worker_done' ? 'worker' : signal.kind === 'lead_done' ? 'owner' : undefined;
283
329
  if (!expectedParticipant)
284
330
  return true;
285
331
  const participants = this.stepSignalParticipants.get(stepName);
@@ -499,7 +545,9 @@ export class WorkflowRunner {
499
545
  return undefined;
500
546
  }
501
547
  uniqueEvidenceRoots(roots) {
502
- return [...new Set(roots.filter((root) => Boolean(root)).map((root) => path.resolve(root)))];
548
+ return [
549
+ ...new Set(roots.filter((root) => Boolean(root)).map((root) => path.resolve(root))),
550
+ ];
503
551
  }
504
552
  captureFileSnapshot(root) {
505
553
  const snapshot = new Map();
@@ -611,9 +659,7 @@ export class WorkflowRunner {
611
659
  const evidence = this.getStepCompletionEvidence(stepName);
612
660
  if (!evidence)
613
661
  return undefined;
614
- const signals = evidence.coordinationSignals
615
- .slice(-6)
616
- .map((signal) => signal.value ?? signal.text);
662
+ const signals = evidence.coordinationSignals.slice(-6).map((signal) => signal.value ?? signal.text);
617
663
  const channelPosts = evidence.channelPosts
618
664
  .filter((post) => post.completionRelevant)
619
665
  .slice(-3)
@@ -709,7 +755,7 @@ export class WorkflowRunner {
709
755
  return this.relayOptions.env;
710
756
  }
711
757
  return {
712
- ...(this.relayOptions.env ?? process.env),
758
+ ...(this.relayOptions.env ?? filteredEnv()),
713
759
  RELAY_API_KEY: this.relayApiKey,
714
760
  };
715
761
  }
@@ -1253,71 +1299,13 @@ export class WorkflowRunner {
1253
1299
  // ── Template variable resolution ────────────────────────────────────────
1254
1300
  /** Resolve {{variable}} placeholders in all task strings. */
1255
1301
  resolveVariables(config, vars) {
1256
- const resolved = structuredClone(config);
1257
- for (const agent of resolved.agents) {
1258
- if (agent.task) {
1259
- agent.task = this.interpolate(agent.task, vars);
1260
- }
1261
- }
1262
- if (resolved.workflows) {
1263
- for (const wf of resolved.workflows) {
1264
- for (const step of wf.steps) {
1265
- // Resolve variables in task (agent steps) and command (deterministic steps)
1266
- if (step.task) {
1267
- step.task = this.interpolate(step.task, vars);
1268
- }
1269
- if (step.command) {
1270
- step.command = this.interpolate(step.command, vars);
1271
- }
1272
- // Resolve variables in integration step params
1273
- if (step.params && typeof step.params === 'object') {
1274
- for (const key of Object.keys(step.params)) {
1275
- const val = step.params[key];
1276
- if (typeof val === 'string') {
1277
- step.params[key] = this.interpolate(val, vars);
1278
- }
1279
- }
1280
- }
1281
- }
1282
- }
1283
- }
1284
- return resolved;
1302
+ return this.templateResolver.resolveVariables(config, vars);
1285
1303
  }
1286
1304
  interpolate(template, vars) {
1287
- return template.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key) => {
1288
- // Skip step-output placeholders — they are resolved at execution time by interpolateStepTask()
1289
- if (key.startsWith('steps.')) {
1290
- return _match;
1291
- }
1292
- // Resolve dot-path variables like steps.plan.output
1293
- const value = this.resolveDotPath(key, vars);
1294
- if (value === undefined) {
1295
- throw new Error(`Unresolved variable: {{${key}}}`);
1296
- }
1297
- return String(value);
1298
- });
1305
+ return resolveTemplate(template, vars);
1299
1306
  }
1300
1307
  resolveDotPath(key, vars) {
1301
- // Simple key — direct lookup
1302
- if (!key.includes('.')) {
1303
- return vars[key];
1304
- }
1305
- // Dot-path — walk into nested context
1306
- const parts = key.split('.');
1307
- let current = vars;
1308
- for (const part of parts) {
1309
- if (current === null || current === undefined || typeof current !== 'object') {
1310
- return undefined;
1311
- }
1312
- current = current[part];
1313
- }
1314
- if (current === undefined || current === null) {
1315
- return undefined;
1316
- }
1317
- if (typeof current === 'string' || typeof current === 'number' || typeof current === 'boolean') {
1318
- return current;
1319
- }
1320
- return String(current);
1308
+ return resolveTemplateDotPath(key, vars);
1321
1309
  }
1322
1310
  /** Build a nested context from completed step outputs for {{steps.X.output}} resolution. */
1323
1311
  buildStepOutputContext(stepStates, runId) {
@@ -1339,14 +1327,84 @@ export class WorkflowRunner {
1339
1327
  }
1340
1328
  /** Interpolate step-output variables, silently skipping unresolved ones (they may be user vars). */
1341
1329
  interpolateStepTask(template, context) {
1342
- return template.replace(/\{\{(steps\.[\w\-]+\.output)\}\}/g, (_match, key) => {
1343
- const value = this.resolveDotPath(key, context);
1344
- if (value === undefined) {
1345
- // Leave unresolved may not be an error if the template doesn't depend on prior steps
1346
- return _match;
1347
- }
1348
- return String(value);
1349
- });
1330
+ return interpolateStepTaskTemplate(template, context);
1331
+ }
1332
+ createStepLifecycleExecutor(workflow, stepStates, agentMap, errorHandling, runId) {
1333
+ // eslint-disable-next-line prefer-const -- circular: deps closure captures lifecycle before assignment
1334
+ let lifecycle;
1335
+ const deps = {
1336
+ cwd: this.cwd,
1337
+ runId,
1338
+ templateResolver: this.templateResolver,
1339
+ channelMessenger: this.channelMessenger,
1340
+ verificationRunner: (check, output, stepName, injectedTaskText, options) => this.runVerification(check, output, stepName, injectedTaskText, options),
1341
+ postToChannel: (text) => this.postToChannel(text),
1342
+ persistStepRow: async (stepId, patch) => this.db.updateStep(stepId, patch),
1343
+ persistStepOutput: async (lifecycleRunId, stepName, output) => this.persistStepOutput(lifecycleRunId, stepName, output),
1344
+ loadStepOutput: (lifecycleRunId, stepName) => this.loadStepOutput(lifecycleRunId, stepName),
1345
+ checkAborted: () => this.checkAborted(),
1346
+ waitIfPaused: () => this.waitIfPaused(),
1347
+ log: (message) => this.log(message),
1348
+ onStepStarted: async (step) => {
1349
+ this.emit({ type: 'step:started', runId, stepName: step.name });
1350
+ },
1351
+ onStepCompleted: async (step, state, result) => {
1352
+ this.emit({
1353
+ type: 'step:completed',
1354
+ runId,
1355
+ stepName: step.name,
1356
+ output: result.output,
1357
+ exitCode: result.exitCode,
1358
+ exitSignal: result.exitSignal,
1359
+ });
1360
+ this.finalizeStepEvidence(step.name, result.status, state.row.completedAt, result.completionReason);
1361
+ },
1362
+ onStepFailed: async (step, state, result) => {
1363
+ this.captureStepTerminalEvidence(step.name, {}, {
1364
+ exitCode: result.exitCode,
1365
+ exitSignal: result.exitSignal,
1366
+ });
1367
+ this.emit({
1368
+ type: 'step:failed',
1369
+ runId,
1370
+ stepName: step.name,
1371
+ error: result.error ?? 'Unknown error',
1372
+ exitCode: result.exitCode,
1373
+ exitSignal: result.exitSignal,
1374
+ });
1375
+ this.finalizeStepEvidence(step.name, 'failed', state.row.completedAt, result.completionReason);
1376
+ },
1377
+ executeStep: async (step, state) => {
1378
+ await this.executeStep(step, state, stepStates, agentMap, errorHandling, runId, lifecycle);
1379
+ return {
1380
+ status: state.row.status,
1381
+ output: state.row.output ?? '',
1382
+ completionReason: state.row.completionReason,
1383
+ retries: state.row.retryCount,
1384
+ error: state.row.error,
1385
+ };
1386
+ },
1387
+ onBeginTrack: async (steps) => {
1388
+ if (steps.length > 1 && this.trajectory) {
1389
+ await this.trajectory.beginTrack(steps.map((step) => step.name).join(', '));
1390
+ }
1391
+ },
1392
+ onConverge: async (readySteps, batchOutcomes) => {
1393
+ if (readySteps.length <= 1 || !this.trajectory?.shouldReflectOnConverge()) {
1394
+ return;
1395
+ }
1396
+ const completedNames = new Set(batchOutcomes.filter((outcome) => outcome.status === 'completed').map((outcome) => outcome.name));
1397
+ const unblocked = workflow.steps
1398
+ .filter((step) => step.dependsOn?.some((dependency) => completedNames.has(dependency)))
1399
+ .filter((step) => stepStates.get(step.name)?.row.status === 'pending')
1400
+ .map((step) => step.name);
1401
+ await this.trajectory.synthesizeAndReflect(readySteps.map((step) => step.name).join(' + '), batchOutcomes, unblocked.length > 0 ? unblocked : undefined);
1402
+ },
1403
+ markDownstreamSkipped: async (failedStepName) => this.markDownstreamSkipped(failedStepName, workflow.steps, stepStates, runId),
1404
+ buildCompletionMode: (stepName, completionReason) => completionReason ? this.buildStepCompletionDecision(stepName, completionReason)?.mode : undefined,
1405
+ };
1406
+ lifecycle = new WorkflowStepLifecycleExecutor(deps);
1407
+ return lifecycle;
1350
1408
  }
1351
1409
  // ── Execution ───────────────────────────────────────────────────────────
1352
1410
  /** Execute a named workflow from a validated config. */
@@ -1409,7 +1467,7 @@ export class WorkflowRunner {
1409
1467
  : step.type === 'worktree'
1410
1468
  ? (step.branch ?? '')
1411
1469
  : step.type === 'integration'
1412
- ? (`${step.integration}.${step.action}`)
1470
+ ? `${step.integration}.${step.action}`
1413
1471
  : (step.task ?? ''),
1414
1472
  dependsOn: step.dependsOn ?? [],
1415
1473
  retryCount: 0,
@@ -1429,8 +1487,7 @@ export class WorkflowRunner {
1429
1487
  const transitiveDeps = this.collectTransitiveDeps(startFromName, resolvedWorkflow.steps);
1430
1488
  const skippedCount = transitiveDeps.size;
1431
1489
  // Determine which run ID to load cached outputs from
1432
- const cacheRunId = executeOptions.previousRunId
1433
- ?? this.findMostRecentRunWithSteps(transitiveDeps);
1490
+ const cacheRunId = executeOptions.previousRunId ?? this.findMostRecentRunWithSteps(transitiveDeps);
1434
1491
  for (const depName of transitiveDeps) {
1435
1492
  const state = stepStates.get(depName);
1436
1493
  if (!state)
@@ -1920,8 +1977,6 @@ export class WorkflowRunner {
1920
1977
  // ── Step execution engine ─────────────────────────────────────────────
1921
1978
  async executeSteps(workflow, stepStates, agentMap, errorHandling, runId) {
1922
1979
  const rawStrategy = errorHandling?.strategy ?? workflow.onError ?? 'fail-fast';
1923
- // Map shorthand onError values to canonical strategy names.
1924
- // 'retry' maps to 'fail-fast' so downstream steps are properly skipped after retries exhaust.
1925
1980
  const strategy = rawStrategy === 'fail'
1926
1981
  ? 'fail-fast'
1927
1982
  : rawStrategy === 'skip'
@@ -1929,89 +1984,11 @@ export class WorkflowRunner {
1929
1984
  : rawStrategy === 'retry'
1930
1985
  ? 'fail-fast'
1931
1986
  : rawStrategy;
1932
- // DAG-based execution: repeatedly find ready steps and run them in parallel
1933
- while (true) {
1934
- this.checkAborted();
1935
- await this.waitIfPaused();
1936
- const readySteps = this.findReadySteps(workflow.steps, stepStates);
1937
- if (readySteps.length === 0) {
1938
- // No steps ready — either all done or blocked
1939
- break;
1940
- }
1941
- // Begin a track chapter if multiple parallel steps are starting
1942
- if (readySteps.length > 1 && this.trajectory) {
1943
- const trackNames = readySteps.map((s) => s.name).join(', ');
1944
- await this.trajectory.beginTrack(trackNames);
1945
- }
1946
- // Stagger spawns when many steps are ready simultaneously.
1947
- // All agents still run concurrently once spawned — this only delays when
1948
- // each step's executeStep() begins, preventing Relaycast from receiving
1949
- // N simultaneous registration requests which causes spawn timeouts.
1950
- const STAGGER_THRESHOLD = 3;
1951
- const STAGGER_DELAY_MS = 2_000;
1952
- const results = await Promise.allSettled(readySteps.map((step, i) => {
1953
- const delay = readySteps.length > STAGGER_THRESHOLD ? i * STAGGER_DELAY_MS : 0;
1954
- if (delay === 0) {
1955
- return this.executeStep(step, stepStates, agentMap, errorHandling, runId);
1956
- }
1957
- return new Promise((resolve) => setTimeout(resolve, delay)).then(() => this.executeStep(step, stepStates, agentMap, errorHandling, runId));
1958
- }));
1959
- // Collect outcomes from this batch for convergence reflection
1960
- const batchOutcomes = [];
1961
- for (let i = 0; i < results.length; i++) {
1962
- const result = results[i];
1963
- const step = readySteps[i];
1964
- const state = stepStates.get(step.name);
1965
- if (result.status === 'rejected') {
1966
- const error = result.reason instanceof Error ? result.reason.message : String(result.reason);
1967
- if (state && state.row.status !== 'failed') {
1968
- await this.markStepFailed(state, error, runId);
1969
- }
1970
- batchOutcomes.push({
1971
- name: step.name,
1972
- agent: step.agent ?? 'deterministic',
1973
- status: 'failed',
1974
- attempts: (state?.row.retryCount ?? 0) + 1,
1975
- error,
1976
- });
1977
- if (strategy === 'fail-fast') {
1978
- // Mark all pending downstream steps as skipped
1979
- await this.markDownstreamSkipped(step.name, workflow.steps, stepStates, runId);
1980
- throw new Error(`Step "${step.name}" failed: ${error}`);
1981
- }
1982
- if (strategy === 'continue') {
1983
- await this.markDownstreamSkipped(step.name, workflow.steps, stepStates, runId);
1984
- }
1985
- }
1986
- else {
1987
- batchOutcomes.push({
1988
- name: step.name,
1989
- agent: step.agent ?? 'deterministic',
1990
- status: state?.row.status === 'completed' ? 'completed' : 'failed',
1991
- attempts: (state?.row.retryCount ?? 0) + 1,
1992
- output: state?.row.output,
1993
- verificationPassed: state?.row.status === 'completed' && step.verification !== undefined,
1994
- completionMode: state?.row.completionReason
1995
- ? this.buildStepCompletionDecision(step.name, state.row.completionReason)?.mode
1996
- : undefined,
1997
- });
1998
- }
1999
- }
2000
- // Reflect at convergence when a parallel batch completes
2001
- if (readySteps.length > 1 && this.trajectory?.shouldReflectOnConverge()) {
2002
- const label = readySteps.map((s) => s.name).join(' + ');
2003
- // Find steps that this batch unblocks
2004
- const completedNames = new Set(batchOutcomes.filter((o) => o.status === 'completed').map((o) => o.name));
2005
- const unblocked = workflow.steps
2006
- .filter((s) => s.dependsOn?.some((dep) => completedNames.has(dep)))
2007
- .filter((s) => {
2008
- const st = stepStates.get(s.name);
2009
- return st && st.row.status === 'pending';
2010
- })
2011
- .map((s) => s.name);
2012
- await this.trajectory.synthesizeAndReflect(label, batchOutcomes, unblocked.length > 0 ? unblocked : undefined);
2013
- }
2014
- }
1987
+ const lifecycle = this.createStepLifecycleExecutor(workflow, stepStates, agentMap, errorHandling, runId);
1988
+ await lifecycle.executeAll(workflow.steps, agentMap, {
1989
+ ...(errorHandling ?? { strategy: 'fail-fast' }),
1990
+ strategy,
1991
+ }, stepStates);
2015
1992
  }
2016
1993
  findReadySteps(steps, stepStates) {
2017
1994
  return steps.filter((step) => {
@@ -2040,7 +2017,7 @@ export class WorkflowRunner {
2040
2017
  const child = cpSpawn('sh', ['-c', check.command], {
2041
2018
  stdio: 'pipe',
2042
2019
  cwd: this.cwd,
2043
- env: { ...process.env },
2020
+ env: filteredEnv(),
2044
2021
  });
2045
2022
  const stdoutChunks = [];
2046
2023
  const stderrChunks = [];
@@ -2135,18 +2112,18 @@ export class WorkflowRunner {
2135
2112
  isIntegrationStep(step) {
2136
2113
  return step.type === 'integration';
2137
2114
  }
2138
- async executeStep(step, stepStates, agentMap, errorHandling, runId) {
2115
+ async executeStep(step, state, stepStates, agentMap, errorHandling, runId, lifecycle) {
2139
2116
  // Branch: deterministic steps execute shell commands
2140
2117
  if (this.isDeterministicStep(step)) {
2141
- return this.executeDeterministicStep(step, stepStates, runId, errorHandling);
2118
+ return this.executeDeterministicStep(step, state, stepStates, runId, errorHandling, lifecycle);
2142
2119
  }
2143
2120
  // Branch: worktree steps set up git worktrees
2144
2121
  if (this.isWorktreeStep(step)) {
2145
- return this.executeWorktreeStep(step, stepStates, runId);
2122
+ return this.executeWorktreeStep(step, state, stepStates, runId, lifecycle);
2146
2123
  }
2147
2124
  // Branch: integration steps interact with external services
2148
2125
  if (this.isIntegrationStep(step)) {
2149
- return this.executeIntegrationStep(step, stepStates, runId);
2126
+ return this.executeIntegrationStep(step, state, stepStates, runId, lifecycle);
2150
2127
  }
2151
2128
  // Agent step execution
2152
2129
  return this.executeAgentStep(step, stepStates, agentMap, errorHandling, runId);
@@ -2155,92 +2132,56 @@ export class WorkflowRunner {
2155
2132
  * Execute a deterministic step (shell command).
2156
2133
  * Fast, reliable, $0 LLM cost.
2157
2134
  */
2158
- async executeDeterministicStep(step, stepStates, runId, errorHandling) {
2159
- const state = stepStates.get(step.name);
2160
- if (!state)
2161
- throw new Error(`Step state not found: ${step.name}`);
2135
+ async executeDeterministicStep(step, state, stepStates, runId, errorHandling, lifecycle) {
2162
2136
  const maxRetries = step.retries ?? errorHandling?.maxRetries ?? 0;
2163
2137
  const retryDelay = errorHandling?.retryDelayMs ?? 1000;
2164
- let lastError;
2138
+ let lastError = 'Unknown error';
2165
2139
  let lastCompletionReason;
2166
2140
  let lastExitCode;
2167
2141
  let lastExitSignal;
2168
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
2169
- this.checkAborted();
2170
- lastExitCode = undefined;
2171
- lastExitSignal = undefined;
2172
- if (attempt > 0) {
2142
+ const result = await lifecycle.monitorStep(step, state, {
2143
+ maxRetries,
2144
+ retryDelayMs: retryDelay,
2145
+ startMessage: `**[${step.name}]** Started (deterministic)`,
2146
+ onRetry: async (attempt, total) => {
2173
2147
  this.emit({ type: 'step:retrying', runId, stepName: step.name, attempt });
2174
- this.postToChannel(`**[${step.name}]** Retrying (attempt ${attempt + 1}/${maxRetries + 1})`);
2148
+ this.postToChannel(`**[${step.name}]** Retrying (attempt ${attempt + 1}/${total + 1})`);
2175
2149
  this.recordStepToolSideEffect(step.name, {
2176
2150
  type: 'retry',
2177
- detail: `Retrying attempt ${attempt + 1}/${maxRetries + 1}`,
2178
- raw: { attempt, maxRetries },
2151
+ detail: `Retrying attempt ${attempt + 1}/${total + 1}`,
2152
+ raw: { attempt, maxRetries: total },
2179
2153
  });
2180
- state.row.retryCount = attempt;
2181
- await this.db.updateStep(state.row.id, {
2182
- retryCount: attempt,
2183
- updatedAt: new Date().toISOString(),
2154
+ },
2155
+ execute: async () => {
2156
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2157
+ let resolvedCommand = this.interpolateStepTask(step.command ?? '', stepOutputContext);
2158
+ resolvedCommand = resolvedCommand.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key) => {
2159
+ if (key.startsWith('steps.'))
2160
+ return _match;
2161
+ const value = this.resolveDotPath(key, stepOutputContext);
2162
+ return value !== undefined ? String(value) : _match;
2184
2163
  });
2185
- await this.delay(retryDelay);
2186
- }
2187
- // Mark step as running
2188
- state.row.status = 'running';
2189
- state.row.error = undefined;
2190
- state.row.completionReason = undefined;
2191
- state.row.startedAt = new Date().toISOString();
2192
- await this.db.updateStep(state.row.id, {
2193
- status: 'running',
2194
- error: undefined,
2195
- completionReason: undefined,
2196
- startedAt: state.row.startedAt,
2197
- updatedAt: new Date().toISOString(),
2198
- });
2199
- this.emit({ type: 'step:started', runId, stepName: step.name });
2200
- this.postToChannel(`**[${step.name}]** Started (deterministic)`);
2201
- // Resolve variables in the command (e.g., {{steps.plan.output}}, {{branch-name}})
2202
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2203
- let resolvedCommand = this.interpolateStepTask(step.command ?? '', stepOutputContext);
2204
- // Also resolve simple {{variable}} placeholders (already resolved in top-level config but safe to re-run)
2205
- resolvedCommand = resolvedCommand.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key) => {
2206
- if (key.startsWith('steps.'))
2207
- return _match; // Already handled above
2208
- const value = this.resolveDotPath(key, stepOutputContext);
2209
- return value !== undefined ? String(value) : _match;
2210
- });
2211
- // Resolve step workdir (named path reference) for deterministic steps
2212
- const stepCwd = this.resolveEffectiveCwd(step);
2213
- this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2214
- try {
2215
- // Delegate to executor if present
2164
+ const stepCwd = this.resolveEffectiveCwd(step);
2165
+ this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2216
2166
  if (this.executor?.executeDeterministicStep) {
2217
- const result = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
2218
- lastExitCode = result.exitCode;
2167
+ const executorResult = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
2168
+ lastExitCode = executorResult.exitCode;
2169
+ lastExitSignal = undefined;
2219
2170
  const failOnError = step.failOnError !== false;
2220
- if (failOnError && result.exitCode !== 0) {
2221
- throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output.slice(0, 500)}`);
2171
+ if (failOnError && executorResult.exitCode !== 0) {
2172
+ throw new Error(`Command failed with exit code ${executorResult.exitCode}: ${executorResult.output.slice(0, 500)}`);
2222
2173
  }
2223
- const output = step.captureOutput !== false ? result.output : `Command completed (exit code ${result.exitCode})`;
2224
- this.captureStepTerminalEvidence(step.name, { stdout: result.output, combined: result.output }, { exitCode: result.exitCode });
2174
+ const output = step.captureOutput !== false
2175
+ ? executorResult.output
2176
+ : `Command completed (exit code ${executorResult.exitCode})`;
2177
+ this.captureStepTerminalEvidence(step.name, { stdout: executorResult.output, combined: executorResult.output }, { exitCode: executorResult.exitCode });
2225
2178
  const verificationResult = step.verification
2226
2179
  ? this.runVerification(step.verification, output, step.name)
2227
2180
  : undefined;
2228
- // Mark completed
2229
- state.row.status = 'completed';
2230
- state.row.output = output;
2231
- state.row.completionReason = verificationResult?.completionReason;
2232
- state.row.completedAt = new Date().toISOString();
2233
- await this.db.updateStep(state.row.id, {
2234
- status: 'completed',
2181
+ return {
2235
2182
  output,
2236
2183
  completionReason: verificationResult?.completionReason,
2237
- completedAt: state.row.completedAt,
2238
- updatedAt: new Date().toISOString(),
2239
- });
2240
- await this.persistStepOutput(runId, step.name, output);
2241
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
2242
- this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt, verificationResult?.completionReason);
2243
- return;
2184
+ };
2244
2185
  }
2245
2186
  let commandStdout = '';
2246
2187
  let commandStderr = '';
@@ -2248,11 +2189,10 @@ export class WorkflowRunner {
2248
2189
  const child = cpSpawn('sh', ['-c', resolvedCommand], {
2249
2190
  stdio: 'pipe',
2250
2191
  cwd: stepCwd,
2251
- env: { ...process.env },
2192
+ env: filteredEnv(),
2252
2193
  });
2253
2194
  const stdoutChunks = [];
2254
2195
  const stderrChunks = [];
2255
- // Wire abort signal
2256
2196
  const abortSignal = this.abortController?.signal;
2257
2197
  let abortHandler;
2258
2198
  if (abortSignal && !abortSignal.aborted) {
@@ -2262,7 +2202,6 @@ export class WorkflowRunner {
2262
2202
  };
2263
2203
  abortSignal.addEventListener('abort', abortHandler, { once: true });
2264
2204
  }
2265
- // Handle timeout
2266
2205
  let timedOut = false;
2267
2206
  let timer;
2268
2207
  if (step.timeoutMs) {
@@ -2298,7 +2237,6 @@ export class WorkflowRunner {
2298
2237
  commandStderr = stderr;
2299
2238
  lastExitCode = code ?? undefined;
2300
2239
  lastExitSignal = signal ?? undefined;
2301
- // Check exit code unless failOnError is explicitly false
2302
2240
  const failOnError = step.failOnError !== false;
2303
2241
  if (failOnError && code !== 0 && code !== null) {
2304
2242
  reject(new Error(`Command failed with exit code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ''}`));
@@ -2323,282 +2261,233 @@ export class WorkflowRunner {
2323
2261
  const verificationResult = step.verification
2324
2262
  ? this.runVerification(step.verification, output, step.name)
2325
2263
  : undefined;
2326
- // Mark completed
2327
- state.row.status = 'completed';
2328
- state.row.output = output;
2329
- state.row.completionReason = verificationResult?.completionReason;
2330
- state.row.completedAt = new Date().toISOString();
2331
- await this.db.updateStep(state.row.id, {
2332
- status: 'completed',
2264
+ return {
2333
2265
  output,
2334
2266
  completionReason: verificationResult?.completionReason,
2335
- completedAt: state.row.completedAt,
2336
- updatedAt: new Date().toISOString(),
2337
- });
2338
- // Persist step output
2339
- await this.persistStepOutput(runId, step.name, output);
2340
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
2341
- this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt, verificationResult?.completionReason);
2342
- return;
2343
- }
2344
- catch (err) {
2345
- lastError = err instanceof Error ? err.message : String(err);
2346
- lastCompletionReason =
2347
- err instanceof WorkflowCompletionError ? err.completionReason : undefined;
2348
- }
2267
+ };
2268
+ },
2269
+ toCompletionResult: ({ output, completionReason }, attempt) => ({
2270
+ status: 'completed',
2271
+ output,
2272
+ completionReason,
2273
+ retries: attempt,
2274
+ exitCode: lastExitCode,
2275
+ exitSignal: lastExitSignal,
2276
+ }),
2277
+ onAttemptFailed: async (error) => {
2278
+ lastError = error instanceof Error ? error.message : String(error);
2279
+ lastCompletionReason = error instanceof WorkflowCompletionError ? error.completionReason : undefined;
2280
+ },
2281
+ getFailureResult: () => ({
2282
+ status: 'failed',
2283
+ output: '',
2284
+ error: lastError,
2285
+ retries: state.row.retryCount,
2286
+ exitCode: lastExitCode,
2287
+ exitSignal: lastExitSignal,
2288
+ completionReason: lastCompletionReason,
2289
+ }),
2290
+ });
2291
+ if (result.status === 'failed') {
2292
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
2293
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
2349
2294
  }
2350
- const errorMsg = lastError ?? 'Unknown error';
2351
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
2352
- await this.markStepFailed(state, errorMsg, runId, { exitCode: lastExitCode, exitSignal: lastExitSignal }, lastCompletionReason);
2353
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
2354
2295
  }
2355
2296
  /**
2356
2297
  * Execute a worktree step (git worktree setup).
2357
2298
  * Fast, reliable, $0 LLM cost.
2358
2299
  * Outputs the worktree path for downstream steps to use.
2359
2300
  */
2360
- async executeWorktreeStep(step, stepStates, runId) {
2361
- const state = stepStates.get(step.name);
2362
- if (!state)
2363
- throw new Error(`Step state not found: ${step.name}`);
2301
+ async executeWorktreeStep(step, state, stepStates, runId, lifecycle) {
2364
2302
  let lastExitCode;
2365
2303
  let lastExitSignal;
2366
- this.checkAborted();
2367
- // Mark step as running
2368
- state.row.status = 'running';
2369
- state.row.error = undefined;
2370
- state.row.completionReason = undefined;
2371
- state.row.startedAt = new Date().toISOString();
2372
- await this.db.updateStep(state.row.id, {
2373
- status: 'running',
2374
- error: undefined,
2375
- completionReason: undefined,
2376
- startedAt: state.row.startedAt,
2377
- updatedAt: new Date().toISOString(),
2378
- });
2379
- this.emit({ type: 'step:started', runId, stepName: step.name });
2380
- this.postToChannel(`**[${step.name}]** Started (worktree setup)`);
2381
- // Resolve variables in branch name and path
2382
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2383
- const branch = this.interpolateStepTask(step.branch ?? '', stepOutputContext);
2384
- const baseBranch = step.baseBranch
2385
- ? this.interpolateStepTask(step.baseBranch, stepOutputContext)
2386
- : 'HEAD';
2387
- const worktreePath = step.path
2388
- ? this.interpolateStepTask(step.path, stepOutputContext)
2389
- : path.join('.worktrees', step.name);
2390
- const createBranch = step.createBranch !== false;
2391
- // Resolve workdir for worktree steps (same as deterministic/agent steps)
2392
- const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
2393
- this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2394
- if (!branch) {
2395
- const errorMsg = 'Worktree step missing required "branch" field';
2396
- await this.markStepFailed(state, errorMsg, runId);
2397
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
2398
- }
2399
- try {
2400
- // Build the git worktree command
2401
- // If createBranch is true and branch doesn't exist, use -b flag
2402
- const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
2403
- // First, check if the branch already exists
2404
- const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
2405
- let branchExists = false;
2406
- await new Promise((resolve) => {
2407
- const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
2408
- stdio: 'pipe',
2409
- cwd: stepCwd,
2410
- env: { ...process.env },
2411
- });
2412
- checkChild.on('close', (code) => {
2413
- branchExists = code === 0;
2414
- resolve();
2415
- });
2416
- checkChild.on('error', () => resolve());
2417
- });
2418
- // Build appropriate worktree add command
2419
- let worktreeCmd;
2420
- if (branchExists) {
2421
- // Branch exists, just checkout into worktree
2422
- worktreeCmd = `git worktree add "${absoluteWorktreePath}" ${branch}`;
2423
- }
2424
- else if (createBranch) {
2425
- // Create new branch from baseBranch
2426
- worktreeCmd = `git worktree add -b ${branch} "${absoluteWorktreePath}" ${baseBranch}`;
2427
- }
2428
- else {
2429
- // Branch doesn't exist and we're not creating it
2430
- const errorMsg = `Branch "${branch}" does not exist and createBranch is false`;
2431
- await this.markStepFailed(state, errorMsg, runId);
2432
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
2433
- }
2434
- let commandStdout = '';
2435
- let commandStderr = '';
2436
- let commandExitCode;
2437
- let commandExitSignal;
2438
- const output = await new Promise((resolve, reject) => {
2439
- const child = cpSpawn('sh', ['-c', worktreeCmd], {
2440
- stdio: 'pipe',
2441
- cwd: stepCwd,
2442
- env: { ...process.env },
2304
+ let worktreeBranch = '';
2305
+ let createdBranch = false;
2306
+ const result = await lifecycle.monitorStep(step, state, {
2307
+ startMessage: `**[${step.name}]** Started (worktree setup)`,
2308
+ execute: async () => {
2309
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2310
+ const branch = this.interpolateStepTask(step.branch ?? '', stepOutputContext);
2311
+ const baseBranch = step.baseBranch
2312
+ ? this.interpolateStepTask(step.baseBranch, stepOutputContext)
2313
+ : 'HEAD';
2314
+ const worktreePath = step.path
2315
+ ? this.interpolateStepTask(step.path, stepOutputContext)
2316
+ : path.join('.worktrees', step.name);
2317
+ const createBranch = step.createBranch !== false;
2318
+ const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
2319
+ this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2320
+ if (!branch) {
2321
+ throw new Error('Worktree step missing required "branch" field');
2322
+ }
2323
+ const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
2324
+ let branchExists = false;
2325
+ await new Promise((resolve) => {
2326
+ const checkChild = cpSpawn('git', ['rev-parse', '--verify', '--quiet', branch], {
2327
+ stdio: 'pipe',
2328
+ cwd: stepCwd,
2329
+ env: filteredEnv(),
2330
+ });
2331
+ checkChild.on('close', (code) => {
2332
+ branchExists = code === 0;
2333
+ resolve();
2334
+ });
2335
+ checkChild.on('error', () => resolve());
2443
2336
  });
2444
- const stdoutChunks = [];
2445
- const stderrChunks = [];
2446
- // Wire abort signal
2447
- const abortSignal = this.abortController?.signal;
2448
- let abortHandler;
2449
- if (abortSignal && !abortSignal.aborted) {
2450
- abortHandler = () => {
2451
- child.kill('SIGTERM');
2452
- setTimeout(() => child.kill('SIGKILL'), 5000);
2453
- };
2454
- abortSignal.addEventListener('abort', abortHandler, { once: true });
2337
+ let worktreeArgs;
2338
+ if (branchExists) {
2339
+ worktreeArgs = ['worktree', 'add', absoluteWorktreePath, branch];
2455
2340
  }
2456
- // Handle timeout
2457
- let timedOut = false;
2458
- let timer;
2459
- if (step.timeoutMs) {
2460
- timer = setTimeout(() => {
2461
- timedOut = true;
2462
- child.kill('SIGTERM');
2463
- setTimeout(() => child.kill('SIGKILL'), 5000);
2464
- }, step.timeoutMs);
2341
+ else if (createBranch) {
2342
+ worktreeArgs = ['worktree', 'add', '-b', branch, absoluteWorktreePath, baseBranch];
2465
2343
  }
2466
- child.stdout?.on('data', (chunk) => {
2467
- stdoutChunks.push(chunk.toString());
2468
- });
2469
- child.stderr?.on('data', (chunk) => {
2470
- stderrChunks.push(chunk.toString());
2471
- });
2472
- child.on('close', (code, signal) => {
2473
- if (timer)
2474
- clearTimeout(timer);
2475
- if (abortHandler && abortSignal) {
2476
- abortSignal.removeEventListener('abort', abortHandler);
2477
- }
2478
- if (abortSignal?.aborted) {
2479
- reject(new Error(`Step "${step.name}" aborted`));
2480
- return;
2481
- }
2482
- if (timedOut) {
2483
- reject(new Error(`Step "${step.name}" timed out (no step timeout set, check global swarm.timeoutMs)`));
2484
- return;
2485
- }
2486
- commandStdout = stdoutChunks.join('');
2487
- const stderr = stderrChunks.join('');
2488
- commandStderr = stderr;
2489
- commandExitCode = code ?? undefined;
2490
- commandExitSignal = signal ?? undefined;
2491
- lastExitCode = commandExitCode;
2492
- lastExitSignal = commandExitSignal;
2493
- if (code !== 0 && code !== null) {
2494
- reject(new Error(`git worktree add failed with exit code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ''}`));
2495
- return;
2344
+ else {
2345
+ throw new Error(`Branch "${branch}" does not exist and createBranch is false`);
2346
+ }
2347
+ let commandStdout = '';
2348
+ let commandStderr = '';
2349
+ const output = await new Promise((resolve, reject) => {
2350
+ const child = cpSpawn('git', worktreeArgs, {
2351
+ stdio: 'pipe',
2352
+ cwd: stepCwd,
2353
+ env: filteredEnv(),
2354
+ });
2355
+ const stdoutChunks = [];
2356
+ const stderrChunks = [];
2357
+ const abortSignal = this.abortController?.signal;
2358
+ let abortHandler;
2359
+ if (abortSignal && !abortSignal.aborted) {
2360
+ abortHandler = () => {
2361
+ child.kill('SIGTERM');
2362
+ setTimeout(() => child.kill('SIGKILL'), 5000);
2363
+ };
2364
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
2496
2365
  }
2497
- // Output the worktree path for downstream steps
2498
- resolve(absoluteWorktreePath);
2499
- });
2500
- child.on('error', (err) => {
2501
- if (timer)
2502
- clearTimeout(timer);
2503
- if (abortHandler && abortSignal) {
2504
- abortSignal.removeEventListener('abort', abortHandler);
2366
+ let timedOut = false;
2367
+ let timer;
2368
+ if (step.timeoutMs) {
2369
+ timer = setTimeout(() => {
2370
+ timedOut = true;
2371
+ child.kill('SIGTERM');
2372
+ setTimeout(() => child.kill('SIGKILL'), 5000);
2373
+ }, step.timeoutMs);
2505
2374
  }
2506
- reject(new Error(`Failed to execute git worktree command: ${err.message}`));
2375
+ child.stdout?.on('data', (chunk) => {
2376
+ stdoutChunks.push(chunk.toString());
2377
+ });
2378
+ child.stderr?.on('data', (chunk) => {
2379
+ stderrChunks.push(chunk.toString());
2380
+ });
2381
+ child.on('close', (code, signal) => {
2382
+ if (timer)
2383
+ clearTimeout(timer);
2384
+ if (abortHandler && abortSignal) {
2385
+ abortSignal.removeEventListener('abort', abortHandler);
2386
+ }
2387
+ if (abortSignal?.aborted) {
2388
+ reject(new Error(`Step "${step.name}" aborted`));
2389
+ return;
2390
+ }
2391
+ if (timedOut) {
2392
+ reject(new Error(`Step "${step.name}" timed out (no step timeout set, check global swarm.timeoutMs)`));
2393
+ return;
2394
+ }
2395
+ commandStdout = stdoutChunks.join('');
2396
+ commandStderr = stderrChunks.join('');
2397
+ lastExitCode = code ?? undefined;
2398
+ lastExitSignal = signal ?? undefined;
2399
+ if (code !== 0 && code !== null) {
2400
+ reject(new Error(`git worktree add failed with exit code ${code}${commandStderr ? `: ${commandStderr.slice(0, 500)}` : ''}`));
2401
+ return;
2402
+ }
2403
+ resolve(absoluteWorktreePath);
2404
+ });
2405
+ child.on('error', (err) => {
2406
+ if (timer)
2407
+ clearTimeout(timer);
2408
+ if (abortHandler && abortSignal) {
2409
+ abortSignal.removeEventListener('abort', abortHandler);
2410
+ }
2411
+ reject(new Error(`Failed to execute git worktree command: ${err.message}`));
2412
+ });
2507
2413
  });
2508
- });
2509
- this.captureStepTerminalEvidence(step.name, {
2510
- stdout: commandStdout || output,
2511
- stderr: commandStderr,
2512
- combined: [commandStdout || output, commandStderr].filter(Boolean).join('\n'),
2513
- }, { exitCode: commandExitCode, exitSignal: commandExitSignal });
2514
- // Mark completed
2515
- state.row.status = 'completed';
2516
- state.row.output = output;
2517
- state.row.completedAt = new Date().toISOString();
2518
- await this.db.updateStep(state.row.id, {
2414
+ this.captureStepTerminalEvidence(step.name, {
2415
+ stdout: commandStdout || output,
2416
+ stderr: commandStderr,
2417
+ combined: [commandStdout || output, commandStderr].filter(Boolean).join('\n'),
2418
+ }, { exitCode: lastExitCode, exitSignal: lastExitSignal });
2419
+ worktreeBranch = branch;
2420
+ createdBranch = !branchExists && createBranch;
2421
+ return { output };
2422
+ },
2423
+ toCompletionResult: ({ output }, attempt) => ({
2519
2424
  status: 'completed',
2520
2425
  output,
2521
- completedAt: state.row.completedAt,
2522
- updatedAt: new Date().toISOString(),
2523
- });
2524
- // Persist step output
2525
- await this.persistStepOutput(runId, step.name, output);
2526
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
2527
- this.postToChannel(`**[${step.name}]** Worktree created at: ${output}\n Branch: ${branch}${!branchExists && createBranch ? ' (created)' : ''}`);
2528
- this.recordStepToolSideEffect(step.name, {
2529
- type: 'worktree_created',
2530
- detail: `Worktree created at ${output}`,
2531
- raw: { branch, createdBranch: !branchExists && createBranch },
2532
- });
2533
- this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt);
2534
- }
2535
- catch (err) {
2536
- const errorMsg = err instanceof Error ? err.message : String(err);
2537
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
2538
- await this.markStepFailed(state, errorMsg, runId, {
2426
+ retries: attempt,
2539
2427
  exitCode: lastExitCode,
2540
2428
  exitSignal: lastExitSignal,
2541
- });
2542
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
2429
+ }),
2430
+ getFailureResult: (error) => ({
2431
+ status: 'failed',
2432
+ output: '',
2433
+ error: error instanceof Error ? error.message : String(error),
2434
+ retries: state.row.retryCount,
2435
+ exitCode: lastExitCode,
2436
+ exitSignal: lastExitSignal,
2437
+ }),
2438
+ });
2439
+ if (result.status === 'failed') {
2440
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
2441
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
2543
2442
  }
2443
+ this.postToChannel(`**[${step.name}]** Worktree created at: ${result.output}\n Branch: ${worktreeBranch}${createdBranch ? ' (created)' : ''}`);
2444
+ this.recordStepToolSideEffect(step.name, {
2445
+ type: 'worktree_created',
2446
+ detail: `Worktree created at ${result.output}`,
2447
+ raw: { branch: worktreeBranch, createdBranch },
2448
+ });
2544
2449
  }
2545
2450
  /**
2546
2451
  * Execute an integration step (external service interaction via executor).
2547
2452
  */
2548
- async executeIntegrationStep(step, stepStates, runId) {
2549
- const state = stepStates.get(step.name);
2550
- if (!state)
2551
- throw new Error(`Step state not found: ${step.name}`);
2552
- this.checkAborted();
2553
- // Mark step as running
2554
- state.row.status = 'running';
2555
- state.row.error = undefined;
2556
- state.row.completionReason = undefined;
2557
- state.row.startedAt = new Date().toISOString();
2558
- await this.db.updateStep(state.row.id, {
2559
- status: 'running',
2560
- error: undefined,
2561
- completionReason: undefined,
2562
- startedAt: state.row.startedAt,
2563
- updatedAt: new Date().toISOString(),
2564
- });
2565
- this.emit({ type: 'step:started', runId, stepName: step.name });
2566
- this.postToChannel(`**[${step.name}]** Started (integration: ${step.integration}.${step.action})`);
2567
- // Resolve {{steps.X.output}} in params
2568
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2569
- const resolvedParams = {};
2570
- for (const [key, value] of Object.entries(step.params ?? {})) {
2571
- resolvedParams[key] = this.interpolateStepTask(value, stepOutputContext);
2572
- }
2573
- try {
2574
- if (!this.executor?.executeIntegrationStep) {
2575
- throw new Error(`Integration steps require a cloud executor. Step "${step.name}" cannot run locally. ` +
2576
- `Use "cloud run" to execute workflows with integration steps.`);
2577
- }
2578
- const result = await this.executor.executeIntegrationStep(step, resolvedParams, { workspaceId: this.workspaceId });
2579
- if (!result.success) {
2580
- throw new Error(`Integration step "${step.name}" failed: ${result.output}`);
2581
- }
2582
- // Mark completed
2583
- state.row.status = 'completed';
2584
- state.row.output = result.output;
2585
- state.row.completedAt = new Date().toISOString();
2586
- await this.db.updateStep(state.row.id, {
2453
+ async executeIntegrationStep(step, state, stepStates, runId, lifecycle) {
2454
+ const result = await lifecycle.monitorStep(step, state, {
2455
+ startMessage: `**[${step.name}]** Started (integration: ${step.integration}.${step.action})`,
2456
+ execute: async () => {
2457
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2458
+ const resolvedParams = {};
2459
+ for (const [key, value] of Object.entries(step.params ?? {})) {
2460
+ resolvedParams[key] = this.interpolateStepTask(value, stepOutputContext);
2461
+ }
2462
+ if (!this.executor?.executeIntegrationStep) {
2463
+ throw new Error(`Integration steps require a cloud executor. Step "${step.name}" cannot run locally. ` +
2464
+ `Use "cloud run" to execute workflows with integration steps.`);
2465
+ }
2466
+ const integrationResult = await this.executor.executeIntegrationStep(step, resolvedParams, {
2467
+ workspaceId: this.workspaceId,
2468
+ });
2469
+ if (!integrationResult.success) {
2470
+ throw new Error(`Integration step "${step.name}" failed: ${integrationResult.output}`);
2471
+ }
2472
+ return { output: integrationResult.output };
2473
+ },
2474
+ toCompletionResult: ({ output }, attempt) => ({
2587
2475
  status: 'completed',
2588
- output: result.output,
2589
- completedAt: state.row.completedAt,
2590
- updatedAt: new Date().toISOString(),
2591
- });
2592
- await this.persistStepOutput(runId, step.name, result.output);
2593
- this.emit({ type: 'step:completed', runId, stepName: step.name, output: result.output });
2594
- this.postToChannel(`**[${step.name}]** Completed (integration: ${step.integration}.${step.action})`);
2595
- }
2596
- catch (err) {
2597
- const errorMsg = err instanceof Error ? err.message : String(err);
2598
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
2599
- await this.markStepFailed(state, errorMsg, runId);
2600
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
2476
+ output,
2477
+ retries: attempt,
2478
+ }),
2479
+ getFailureResult: (error) => ({
2480
+ status: 'failed',
2481
+ output: '',
2482
+ error: error instanceof Error ? error.message : String(error),
2483
+ retries: state.row.retryCount,
2484
+ }),
2485
+ });
2486
+ if (result.status === 'failed') {
2487
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
2488
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
2601
2489
  }
2490
+ this.postToChannel(`**[${step.name}]** Completed (integration: ${step.integration}.${step.action})`);
2602
2491
  }
2603
2492
  /**
2604
2493
  * Execute an agent step (LLM-powered).
@@ -2630,7 +2519,11 @@ export class WorkflowRunner {
2630
2519
  this.emit({ type: 'step:started', runId, stepName: step.name });
2631
2520
  this.postToChannel(`**[${step.name}]** Started (api)`);
2632
2521
  try {
2633
- const output = await executeApiStep(specialistDef.constraints?.model ?? 'claude-sonnet-4-20250514', resolvedTask, { envSecrets: this.envSecrets, skills: specialistDef.skills, defaultMaxTokens: specialistDef.constraints?.maxTokens });
2522
+ const output = await executeApiStep(specialistDef.constraints?.model ?? 'claude-sonnet-4-20250514', resolvedTask, {
2523
+ envSecrets: this.envSecrets,
2524
+ skills: specialistDef.skills,
2525
+ defaultMaxTokens: specialistDef.constraints?.maxTokens,
2526
+ });
2634
2527
  state.row.status = 'completed';
2635
2528
  state.row.output = output;
2636
2529
  state.row.completedAt = new Date().toISOString();
@@ -2819,7 +2712,7 @@ export class WorkflowRunner {
2819
2712
  : await this.spawnAndWait(effectiveOwner, resolvedStep, timeoutMs, {
2820
2713
  evidenceStepName: step.name,
2821
2714
  evidenceRole: usesOwnerFlow ? 'owner' : 'specialist',
2822
- preserveOnIdle: (!isHubPattern || !this.isLeadLikeAgent(effectiveOwner)) ? false : undefined,
2715
+ preserveOnIdle: !isHubPattern || !this.isLeadLikeAgent(effectiveOwner) ? false : undefined,
2823
2716
  logicalName: effectiveOwner.name,
2824
2717
  onSpawned: explicitInteractiveWorker
2825
2718
  ? ({ agent }) => {
@@ -2843,7 +2736,7 @@ export class WorkflowRunner {
2843
2736
  ? effectiveOwner.interactive === false
2844
2737
  ? undefined
2845
2738
  : ownerTask
2846
- : spawnResult.promptTaskText ?? ownerTask;
2739
+ : (spawnResult.promptTaskText ?? ownerTask);
2847
2740
  lastExitCode = typeof spawnResult === 'string' ? undefined : spawnResult.exitCode;
2848
2741
  lastExitSignal = typeof spawnResult === 'string' ? undefined : spawnResult.exitSignal;
2849
2742
  ownerElapsed = Date.now() - ownerStartTime;
@@ -2931,15 +2824,21 @@ export class WorkflowRunner {
2931
2824
  });
2932
2825
  // Persist step output to disk so it survives restarts and is inspectable
2933
2826
  await this.persistStepOutput(runId, step.name, combinedOutput);
2934
- this.emit({ type: 'step:completed', runId, stepName: step.name, output: combinedOutput, exitCode: lastExitCode, exitSignal: lastExitSignal });
2827
+ this.emit({
2828
+ type: 'step:completed',
2829
+ runId,
2830
+ stepName: step.name,
2831
+ output: combinedOutput,
2832
+ exitCode: lastExitCode,
2833
+ exitSignal: lastExitSignal,
2834
+ });
2935
2835
  this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt, completionReason);
2936
2836
  await this.trajectory?.stepCompleted(step, combinedOutput, attempt + 1);
2937
2837
  return;
2938
2838
  }
2939
2839
  catch (err) {
2940
2840
  lastError = err instanceof Error ? err.message : String(err);
2941
- lastCompletionReason =
2942
- err instanceof WorkflowCompletionError ? err.completionReason : undefined;
2841
+ lastCompletionReason = err instanceof WorkflowCompletionError ? err.completionReason : undefined;
2943
2842
  if (lastCompletionReason === 'retry_requested_by_owner' && attempt >= maxRetries) {
2944
2843
  lastError = this.buildOwnerRetryBudgetExceededMessage(step.name, maxRetries, lastError);
2945
2844
  }
@@ -2979,9 +2878,7 @@ export class WorkflowRunner {
2979
2878
  const normalizedDecision = ownerDecisionError?.startsWith(prefix)
2980
2879
  ? ownerDecisionError.slice(prefix.length).trim()
2981
2880
  : ownerDecisionError?.trim();
2982
- const decisionSuffix = normalizedDecision
2983
- ? ` Latest owner decision: ${normalizedDecision}`
2984
- : '';
2881
+ const decisionSuffix = normalizedDecision ? ` Latest owner decision: ${normalizedDecision}` : '';
2985
2882
  if (maxRetries === 0) {
2986
2883
  return (`Step "${stepName}" owner requested another attempt, but no retries are configured ` +
2987
2884
  `(maxRetries=0). Configure retries > 0 to allow OWNER_DECISION: INCOMPLETE_RETRY.` +
@@ -3402,7 +3299,7 @@ export class WorkflowRunner {
3402
3299
  }
3403
3300
  hasOwnerCompletionMarker(step, output, injectedTaskText) {
3404
3301
  const marker = `STEP_COMPLETE:${step.name}`;
3405
- const strippedOutput = this.stripInjectedTaskEcho(output, injectedTaskText);
3302
+ const strippedOutput = stripInjectedTaskEcho(output, injectedTaskText);
3406
3303
  if (strippedOutput.includes(marker)) {
3407
3304
  return true;
3408
3305
  }
@@ -3478,28 +3375,11 @@ export class WorkflowRunner {
3478
3375
  .filter((line) => patterns.every((pattern) => !pattern.test(line)))
3479
3376
  .join('\n');
3480
3377
  }
3481
- stripInjectedTaskEcho(output, injectedTaskText) {
3482
- if (!injectedTaskText) {
3483
- return output;
3484
- }
3485
- const candidates = [
3486
- injectedTaskText,
3487
- injectedTaskText.replace(/\r\n/g, '\n'),
3488
- injectedTaskText.replace(/\n/g, '\r\n'),
3489
- ].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
3490
- for (const candidate of candidates) {
3491
- const start = output.indexOf(candidate);
3492
- if (start !== -1) {
3493
- return output.slice(0, start) + output.slice(start + candidate.length);
3494
- }
3495
- }
3496
- return output;
3497
- }
3498
3378
  outputContainsVerificationToken(output, token, injectedTaskText) {
3499
3379
  if (!token) {
3500
3380
  return false;
3501
3381
  }
3502
- return this.stripInjectedTaskEcho(output, injectedTaskText).includes(token);
3382
+ return stripInjectedTaskEcho(output, injectedTaskText).includes(token);
3503
3383
  }
3504
3384
  prepareInteractiveSpawnTask(agentName, taskText) {
3505
3385
  if (Buffer.byteLength(taskText, 'utf8') <= WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT) {
@@ -3572,7 +3452,8 @@ export class WorkflowRunner {
3572
3452
  if (gracePeriodMs === 0)
3573
3453
  return null;
3574
3454
  // Never infer completion when the owner explicitly requested retry/fail/clarification.
3575
- if (ownerOutput && /OWNER_DECISION:\s*(?:INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATION)\b/i.test(ownerOutput)) {
3455
+ if (ownerOutput &&
3456
+ /OWNER_DECISION:\s*(?:INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATION)\b/i.test(ownerOutput)) {
3576
3457
  return null;
3577
3458
  }
3578
3459
  const evidence = this.getStepCompletionEvidence(step.name);
@@ -3860,17 +3741,10 @@ export class WorkflowRunner {
3860
3741
  * Delegates to the consolidated CLI registry for per-CLI arg formats.
3861
3742
  */
3862
3743
  static buildNonInteractiveCommand(cli, task, extraArgs = []) {
3863
- if (cli === 'api') {
3864
- throw new Error('cli "api" uses direct API calls, not a subprocess command');
3865
- }
3866
- const resolvedCli = cli === 'cursor' ? resolveCursorCli() : cli;
3867
- const def = getCliDefinition(resolvedCli);
3868
- if (!def || def.binaries.length === 0) {
3869
- throw new Error(`Unknown or non-executable CLI: ${resolvedCli}`);
3870
- }
3744
+ const [cmd, ...args] = buildProcessCommand(cli, extraArgs, task);
3871
3745
  return {
3872
- cmd: def.binaries[0],
3873
- args: def.nonInteractiveArgs(task, extraArgs),
3746
+ cmd,
3747
+ args,
3874
3748
  };
3875
3749
  }
3876
3750
  /**
@@ -3966,11 +3840,11 @@ export class WorkflowRunner {
3966
3840
  const stdoutChunks = [];
3967
3841
  const stderrChunks = [];
3968
3842
  try {
3969
- const { stdout: output, exitCode, exitSignal } = await new Promise((resolve, reject) => {
3970
- const child = cpSpawn(cmd, args, {
3843
+ const { stdout: output, exitCode, exitSignal, } = await new Promise((resolve, reject) => {
3844
+ const child = spawnProcess([cmd, ...args], {
3971
3845
  stdio: ['ignore', 'pipe', 'pipe'],
3972
3846
  cwd: this.resolveEffectiveCwd(step, agentDef),
3973
- env: this.getRelayEnv() ?? { ...process.env },
3847
+ env: this.getRelayEnv() ?? filteredEnv(),
3974
3848
  });
3975
3849
  // Update workers.json with PID now that we have it
3976
3850
  this.registerWorker(agentName, agentDef.cli, step.task ?? '', child.pid, false);
@@ -4244,12 +4118,12 @@ export class WorkflowRunner {
4244
4118
  if (verificationResult.passed) {
4245
4119
  this.log(`[${step.name}] Agent timed out but verification passed — treating as complete`);
4246
4120
  this.postToChannel(`**[${step.name}]** Agent idle after completing work — verification passed, releasing`);
4247
- await agent.release();
4121
+ await agent.release().catch(() => undefined);
4248
4122
  timeoutRecovered = true;
4249
4123
  }
4250
4124
  }
4251
4125
  if (!timeoutRecovered) {
4252
- await agent.release();
4126
+ await agent.release().catch(() => undefined);
4253
4127
  throw new Error(`Step "${step.name}" timed out after ${timeoutMs ?? 'unknown'}ms`);
4254
4128
  }
4255
4129
  }
@@ -4346,8 +4220,7 @@ export class WorkflowRunner {
4346
4220
  return true;
4347
4221
  const role = (roleOverride ?? agentDef.role ?? '').toLowerCase();
4348
4222
  const nameLC = agentDef.name.toLowerCase();
4349
- return [...WorkflowRunner.HUB_ROLES].some((hubRole) => new RegExp(`\\b${hubRole}\\b`, 'i').test(nameLC) ||
4350
- new RegExp(`\\b${hubRole}\\b`, 'i').test(role));
4223
+ return [...WorkflowRunner.HUB_ROLES].some((hubRole) => new RegExp(`\\b${hubRole}\\b`, 'i').test(nameLC) || new RegExp(`\\b${hubRole}\\b`, 'i').test(role));
4351
4224
  }
4352
4225
  shouldPreserveIdleSupervisor(agentDef, step, evidenceRole) {
4353
4226
  if (evidenceRole && /\bowner\b/i.test(evidenceRole)) {
@@ -4411,13 +4284,13 @@ export class WorkflowRunner {
4411
4284
  // Release and let upstream executeAgentStep handle verification.
4412
4285
  this.log(`[${step.name}] Agent "${agent.name}" still idle after ${idleGraceSecs}s grace — releasing`);
4413
4286
  this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` idle — releasing (verification pending)`);
4414
- await agent.release();
4287
+ await agent.release().catch(() => undefined);
4415
4288
  return 'released';
4416
4289
  }
4417
4290
  }
4418
4291
  this.log(`[${step.name}] Agent "${agent.name}" went idle — treating as complete`);
4419
4292
  this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` idle — treating as complete`);
4420
- await agent.release();
4293
+ await agent.release().catch(() => undefined);
4421
4294
  return 'released';
4422
4295
  }
4423
4296
  // Exit won the race, or idle returned 'exited'/'timeout' — pass through.
@@ -4474,7 +4347,7 @@ export class WorkflowRunner {
4474
4347
  // Exhausted nudges — force-release
4475
4348
  this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` still idle after ${nudgeCount} nudge(s) — force-releasing`);
4476
4349
  this.emit({ type: 'step:force-released', runId: this.currentRunId ?? '', stepName: step.name });
4477
- await agent.release();
4350
+ await agent.release().catch(() => undefined);
4478
4351
  return 'force-released';
4479
4352
  }
4480
4353
  }
@@ -4541,74 +4414,11 @@ export class WorkflowRunner {
4541
4414
  }
4542
4415
  // ── Verification ────────────────────────────────────────────────────────
4543
4416
  runVerification(check, output, stepName, injectedTaskText, options) {
4544
- const fail = (message) => {
4545
- const observedAt = new Date().toISOString();
4546
- this.recordStepToolSideEffect(stepName, {
4547
- type: 'verification_observed',
4548
- detail: message,
4549
- observedAt,
4550
- raw: { passed: false, type: check.type, value: check.value },
4551
- });
4552
- this.getOrCreateStepEvidenceRecord(stepName).evidence.coordinationSignals.push({
4553
- kind: 'verification_failed',
4554
- source: 'verification',
4555
- text: message,
4556
- observedAt,
4557
- value: check.value,
4558
- });
4559
- if (options?.allowFailure) {
4560
- return {
4561
- passed: false,
4562
- completionReason: 'failed_verification',
4563
- error: message,
4564
- };
4565
- }
4566
- throw new WorkflowCompletionError(message, 'failed_verification');
4567
- };
4568
- switch (check.type) {
4569
- case 'output_contains': {
4570
- const token = check.value;
4571
- if (!this.outputContainsVerificationToken(output, token, injectedTaskText)) {
4572
- return fail(`Verification failed for "${stepName}": output does not contain "${token}"`);
4573
- }
4574
- break;
4575
- }
4576
- case 'exit_code':
4577
- // exit_code verification is implicitly satisfied if the agent exited successfully
4578
- break;
4579
- case 'file_exists':
4580
- if (!existsSync(path.resolve(this.cwd, check.value))) {
4581
- return fail(`Verification failed for "${stepName}": file "${check.value}" does not exist`);
4582
- }
4583
- break;
4584
- case 'custom':
4585
- // Custom verifications are evaluated by callers; no-op here
4586
- return { passed: false };
4587
- }
4588
- if (options?.completionMarkerFound === false) {
4589
- this.log(`[${stepName}] Verification passed without legacy STEP_COMPLETE marker; allowing completion`);
4590
- }
4591
- const successMessage = options?.completionMarkerFound === false
4592
- ? `Verification passed without legacy STEP_COMPLETE marker`
4593
- : `Verification passed`;
4594
- const observedAt = new Date().toISOString();
4595
- this.recordStepToolSideEffect(stepName, {
4596
- type: 'verification_observed',
4597
- detail: successMessage,
4598
- observedAt,
4599
- raw: { passed: true, type: check.type, value: check.value },
4600
- });
4601
- this.getOrCreateStepEvidenceRecord(stepName).evidence.coordinationSignals.push({
4602
- kind: 'verification_passed',
4603
- source: 'verification',
4604
- text: successMessage,
4605
- observedAt,
4606
- value: check.value,
4417
+ return runVerification(check, output, stepName, injectedTaskText, { ...options, cwd: this.cwd }, {
4418
+ recordStepToolSideEffect: (name, effect) => this.recordStepToolSideEffect(name, effect),
4419
+ getOrCreateStepEvidenceRecord: (name) => this.getOrCreateStepEvidenceRecord(name),
4420
+ log: (message) => this.log(message),
4607
4421
  });
4608
- return {
4609
- passed: true,
4610
- completionReason: 'completed_verified',
4611
- };
4612
4422
  }
4613
4423
  // ── State helpers ─────────────────────────────────────────────────────
4614
4424
  async updateRunStatus(runId, status, error) {
@@ -4743,29 +4553,7 @@ export class WorkflowRunner {
4743
4553
  * Returns undefined if there are no non-interactive agents.
4744
4554
  */
4745
4555
  buildNonInteractiveAwareness(agentMap, stepStates) {
4746
- const nonInteractive = [...agentMap.values()].filter((a) => a.interactive === false);
4747
- if (nonInteractive.length === 0)
4748
- return undefined;
4749
- // Map agent names to their step names so the lead knows exact {{steps.X.output}} references
4750
- const agentToSteps = new Map();
4751
- for (const [stepName, state] of stepStates) {
4752
- const agentName = state.row.agentName;
4753
- if (!agentName)
4754
- continue; // Skip deterministic steps
4755
- if (!agentToSteps.has(agentName))
4756
- agentToSteps.set(agentName, []);
4757
- agentToSteps.get(agentName).push(stepName);
4758
- }
4759
- const lines = nonInteractive.map((a) => {
4760
- const steps = agentToSteps.get(a.name) ?? [];
4761
- const stepRefs = steps.map((s) => `{{steps.${s}.output}}`).join(', ');
4762
- return `- ${a.name} (${a.cli}) — will return output when complete${stepRefs ? `. Access via: ${stepRefs}` : ''}`;
4763
- });
4764
- return ('\n\n---\n' +
4765
- 'Note: The following agents are non-interactive workers and cannot receive messages:\n' +
4766
- lines.join('\n') +
4767
- '\n' +
4768
- 'Do NOT attempt to message these agents. Use the {{steps.<name>.output}} references above to access their results.');
4556
+ return this.channelMessenger.buildNonInteractiveAwareness(agentMap, stepStates);
4769
4557
  }
4770
4558
  /**
4771
4559
  * Build guidance that encourages agents to autonomously delegate subtasks
@@ -4780,46 +4568,10 @@ export class WorkflowRunner {
4780
4568
  * key, but they won't call `register` unless explicitly told to.
4781
4569
  */
4782
4570
  buildRelayRegistrationNote(cli, agentName) {
4783
- if (cli === 'claude')
4784
- return '';
4785
- return ('---\n' +
4786
- 'RELAY SETUP — do this FIRST before any other relay tool:\n' +
4787
- `1. Call: register(name="${agentName}")\n` +
4788
- ' This authenticates you in the Relaycast workspace.\n' +
4789
- ' ALL relay tools (mcp__relaycast__message_dm_send, mcp__relaycast__message_inbox_check, mcp__relaycast__message_post, etc.) require\n' +
4790
- ' registration first — they will fail with "Not registered" otherwise.\n' +
4791
- `2. Your agent name is "${agentName}" — use this exact name when registering.`);
4571
+ return this.channelMessenger.buildRelayRegistrationNote(cli, agentName);
4792
4572
  }
4793
4573
  buildDelegationGuidance(cli, timeoutMs) {
4794
- const timeoutNote = timeoutMs
4795
- ? `You have approximately ${Math.round(timeoutMs / 60000)} minutes before this step times out. ` +
4796
- 'Plan accordingly — delegate early if the work is substantial.\n\n'
4797
- : '';
4798
- // Option 2 (sub-agents via Task tool) is only available in Claude
4799
- const subAgentOption = cli === 'claude'
4800
- ? 'Option 2 — Use built-in sub-agents (Task tool) for research or scoped work:\n' +
4801
- ' - Good for exploring code, reading files, or making targeted changes\n' +
4802
- ' - Can run multiple sub-agents in parallel\n\n'
4803
- : '';
4804
- return ('---\n' +
4805
- 'AUTONOMOUS DELEGATION — READ THIS BEFORE STARTING:\n' +
4806
- timeoutNote +
4807
- 'Before diving in, assess whether this task is too large or complex for a single agent. ' +
4808
- 'If it involves multiple independent subtasks, touches many files, or could take a long time, ' +
4809
- 'you should break it down and delegate to helper agents to avoid timeouts.\n\n' +
4810
- 'Option 1 — Spawn relay agents (for real parallel coding work):\n' +
4811
- ' - mcp__relaycast__agent_add(name="helper-1", cli="claude", task="Specific subtask description")\n' +
4812
- ' - Coordinate via mcp__relaycast__message_dm_send(to="helper-1", text="...")\n' +
4813
- ' - Check on them with mcp__relaycast__message_inbox_check()\n' +
4814
- ' - Clean up when done: mcp__relaycast__agent_remove(name="helper-1")\n\n' +
4815
- subAgentOption +
4816
- 'Guidelines:\n' +
4817
- '- You are the lead — delegate but stay in control, track progress, integrate results\n' +
4818
- '- Give each helper a clear, self-contained task with enough context to work independently\n' +
4819
- "- For simple or quick work, just do it yourself — don't over-delegate\n" +
4820
- '- Always release spawned relay agents when their work is complete\n' +
4821
- '- When spawning non-claude agents (codex, gemini, etc.), prepend to their task:\n' +
4822
- ' "RELAY SETUP: First call register(name=\'<exact-agent-name>\') before any other relay tool."');
4574
+ return this.channelMessenger.buildDelegationGuidance(cli, timeoutMs);
4823
4575
  }
4824
4576
  /** Post a message to the workflow channel. Fire-and-forget — never throws or blocks. */
4825
4577
  postToChannel(text, options = {}) {
@@ -4847,43 +4599,11 @@ export class WorkflowRunner {
4847
4599
  }
4848
4600
  /** Post a rich completion report to the channel. */
4849
4601
  postCompletionReport(workflowName, outcomes, summary, confidence) {
4850
- const completed = outcomes.filter((o) => o.status === 'completed');
4851
- const skipped = outcomes.filter((o) => o.status === 'skipped');
4852
- const retried = outcomes.filter((o) => o.attempts > 1);
4853
- const lines = [
4854
- `## Workflow **${workflowName}** — Complete`,
4855
- '',
4856
- summary,
4857
- `Confidence: ${Math.round(confidence * 100)}%`,
4858
- '',
4859
- '### Steps',
4860
- ...completed.map((o) => `- **${o.name}** (${o.agent}) — passed${o.verificationPassed ? ' (verified)' : ''}${o.attempts > 1 ? ` after ${o.attempts} attempts` : ''}`),
4861
- ...skipped.map((o) => `- **${o.name}** — skipped`),
4862
- ];
4863
- if (retried.length > 0) {
4864
- lines.push('', '### Retries');
4865
- for (const o of retried) {
4866
- lines.push(`- ${o.name}: ${o.attempts} attempts`);
4867
- }
4868
- }
4869
- this.postToChannel(lines.join('\n'));
4602
+ this.channelMessenger.postCompletionReport(workflowName, outcomes, summary, confidence);
4870
4603
  }
4871
4604
  /** Post a failure report to the channel. */
4872
4605
  postFailureReport(workflowName, outcomes, errorMsg) {
4873
- const completed = outcomes.filter((o) => o.status === 'completed');
4874
- const failed = outcomes.filter((o) => o.status === 'failed');
4875
- const skipped = outcomes.filter((o) => o.status === 'skipped');
4876
- const lines = [
4877
- `## Workflow **${workflowName}** — Failed`,
4878
- '',
4879
- `${completed.length}/${outcomes.length} steps passed. Error: ${errorMsg}`,
4880
- '',
4881
- '### Steps',
4882
- ...completed.map((o) => `- **${o.name}** (${o.agent}) — passed`),
4883
- ...failed.map((o) => `- **${o.name}** (${o.agent}) — FAILED: ${o.error ?? 'unknown'}`),
4884
- ...skipped.map((o) => `- **${o.name}** — skipped`),
4885
- ];
4886
- this.postToChannel(lines.join('\n'));
4606
+ this.channelMessenger.postFailureReport(workflowName, outcomes, errorMsg);
4887
4607
  }
4888
4608
  /**
4889
4609
  * Log a human-readable run summary to the console after completion or failure.
@@ -5209,7 +4929,7 @@ export class WorkflowRunner {
5209
4929
  if (!stat.isDirectory())
5210
4930
  continue;
5211
4931
  // Check if this directory has at least one of the needed step files
5212
- const hasAny = [...stepNames].some(name => existsSync(path.join(dirPath, `${name}.md`)));
4932
+ const hasAny = [...stepNames].some((name) => existsSync(path.join(dirPath, `${name}.md`)));
5213
4933
  if (!hasAny)
5214
4934
  continue;
5215
4935
  if (!best || stat.mtimeMs > best.mtime) {
@@ -5338,7 +5058,11 @@ export class WorkflowRunner {
5338
5058
  for (const step of workflow.steps) {
5339
5059
  const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
5340
5060
  const cachedOutput = completedSteps.has(step.name) ? this.loadStepOutput(runId, step.name) : undefined;
5341
- const status = completedSteps.has(step.name) ? 'completed' : step.name === failedStepName ? 'failed' : 'pending';
5061
+ const status = completedSteps.has(step.name)
5062
+ ? 'completed'
5063
+ : step.name === failedStepName
5064
+ ? 'failed'
5065
+ : 'pending';
5342
5066
  const stepRow = {
5343
5067
  id: this.generateId(),
5344
5068
  runId,
@@ -5351,7 +5075,7 @@ export class WorkflowRunner {
5351
5075
  : step.type === 'worktree'
5352
5076
  ? (step.branch ?? '')
5353
5077
  : step.type === 'integration'
5354
- ? (`${step.integration}.${step.action}`)
5078
+ ? `${step.integration}.${step.action}`
5355
5079
  : (step.task ?? ''),
5356
5080
  dependsOn: step.dependsOn ?? [],
5357
5081
  output: cachedOutput,