agent-relay 4.0.0 → 4.0.2

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 (140) hide show
  1. package/README.md +5 -5
  2. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  3. package/bin/agent-relay-broker-darwin-x64 +0 -0
  4. package/bin/agent-relay-broker-linux-arm64 +0 -0
  5. package/bin/agent-relay-broker-linux-x64 +0 -0
  6. package/dist/index.cjs +1980 -1024
  7. package/dist/src/cli/commands/core.d.ts.map +1 -1
  8. package/dist/src/cli/commands/core.js +4 -3
  9. package/dist/src/cli/commands/core.js.map +1 -1
  10. package/dist/src/cli/commands/on/relayfile-binary.d.ts +2 -0
  11. package/dist/src/cli/commands/on/relayfile-binary.d.ts.map +1 -0
  12. package/dist/src/cli/commands/on/relayfile-binary.js +208 -0
  13. package/dist/src/cli/commands/on/relayfile-binary.js.map +1 -0
  14. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  15. package/dist/src/cli/commands/on/start.js +62 -26
  16. package/dist/src/cli/commands/on/start.js.map +1 -1
  17. package/dist/src/cli/commands/on/workspace.d.ts.map +1 -1
  18. package/dist/src/cli/commands/on/workspace.js +4 -0
  19. package/dist/src/cli/commands/on/workspace.js.map +1 -1
  20. package/dist/src/cli/commands/on.js +1 -1
  21. package/dist/src/cli/commands/on.js.map +1 -1
  22. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  23. package/dist/src/cli/lib/broker-lifecycle.js +15 -8
  24. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  25. package/dist/src/cli/lib/client-factory.d.ts +2 -2
  26. package/dist/src/cli/lib/client-factory.d.ts.map +1 -1
  27. package/dist/src/cli/lib/client-factory.js.map +1 -1
  28. package/dist/src/cost/pricing.d.ts +18 -0
  29. package/dist/src/cost/pricing.d.ts.map +1 -0
  30. package/dist/src/cost/pricing.js +111 -0
  31. package/dist/src/cost/pricing.js.map +1 -0
  32. package/dist/src/cost/tracker.d.ts +13 -0
  33. package/dist/src/cost/tracker.d.ts.map +1 -0
  34. package/dist/src/cost/tracker.js +152 -0
  35. package/dist/src/cost/tracker.js.map +1 -0
  36. package/dist/src/cost/types.d.ts +23 -0
  37. package/dist/src/cost/types.d.ts.map +1 -0
  38. package/dist/src/cost/types.js +2 -0
  39. package/dist/src/cost/types.js.map +1 -0
  40. package/package.json +10 -10
  41. package/packages/acp-bridge/package.json +2 -2
  42. package/packages/brand/package.json +1 -1
  43. package/packages/cloud/package.json +3 -3
  44. package/packages/config/package.json +1 -1
  45. package/packages/hooks/package.json +4 -4
  46. package/packages/memory/package.json +2 -2
  47. package/packages/openclaw/package.json +2 -2
  48. package/packages/policy/package.json +2 -2
  49. package/packages/sdk/dist/broker-path.d.ts +3 -2
  50. package/packages/sdk/dist/broker-path.d.ts.map +1 -1
  51. package/packages/sdk/dist/broker-path.js +119 -32
  52. package/packages/sdk/dist/broker-path.js.map +1 -1
  53. package/packages/sdk/dist/client.d.ts +12 -2
  54. package/packages/sdk/dist/client.d.ts.map +1 -1
  55. package/packages/sdk/dist/client.js +20 -1
  56. package/packages/sdk/dist/client.js.map +1 -1
  57. package/packages/sdk/dist/index.d.ts +1 -1
  58. package/packages/sdk/dist/index.d.ts.map +1 -1
  59. package/packages/sdk/dist/index.js.map +1 -1
  60. package/packages/sdk/dist/relay.d.ts +2 -1
  61. package/packages/sdk/dist/relay.d.ts.map +1 -1
  62. package/packages/sdk/dist/relay.js +1 -1
  63. package/packages/sdk/dist/relay.js.map +1 -1
  64. package/packages/sdk/dist/workflows/__tests__/channel-messenger.test.d.ts +2 -0
  65. package/packages/sdk/dist/workflows/__tests__/channel-messenger.test.d.ts.map +1 -0
  66. package/packages/sdk/dist/workflows/__tests__/channel-messenger.test.js +117 -0
  67. package/packages/sdk/dist/workflows/__tests__/channel-messenger.test.js.map +1 -0
  68. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.js +4 -3
  69. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.js.map +1 -1
  70. package/packages/sdk/dist/workflows/__tests__/step-executor.test.d.ts +2 -0
  71. package/packages/sdk/dist/workflows/__tests__/step-executor.test.d.ts.map +1 -0
  72. package/packages/sdk/dist/workflows/__tests__/step-executor.test.js +378 -0
  73. package/packages/sdk/dist/workflows/__tests__/step-executor.test.js.map +1 -0
  74. package/packages/sdk/dist/workflows/__tests__/template-resolver.test.d.ts +2 -0
  75. package/packages/sdk/dist/workflows/__tests__/template-resolver.test.d.ts.map +1 -0
  76. package/packages/sdk/dist/workflows/__tests__/template-resolver.test.js +145 -0
  77. package/packages/sdk/dist/workflows/__tests__/template-resolver.test.js.map +1 -0
  78. package/packages/sdk/dist/workflows/__tests__/verification.test.d.ts +2 -0
  79. package/packages/sdk/dist/workflows/__tests__/verification.test.d.ts.map +1 -0
  80. package/packages/sdk/dist/workflows/__tests__/verification.test.js +170 -0
  81. package/packages/sdk/dist/workflows/__tests__/verification.test.js.map +1 -0
  82. package/packages/sdk/dist/workflows/builder.d.ts +3 -2
  83. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  84. package/packages/sdk/dist/workflows/builder.js +1 -3
  85. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  86. package/packages/sdk/dist/workflows/channel-messenger.d.ts +28 -0
  87. package/packages/sdk/dist/workflows/channel-messenger.d.ts.map +1 -0
  88. package/packages/sdk/dist/workflows/channel-messenger.js +255 -0
  89. package/packages/sdk/dist/workflows/channel-messenger.js.map +1 -0
  90. package/packages/sdk/dist/workflows/index.d.ts +7 -0
  91. package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
  92. package/packages/sdk/dist/workflows/index.js +7 -0
  93. package/packages/sdk/dist/workflows/index.js.map +1 -1
  94. package/packages/sdk/dist/workflows/process-spawner.d.ts +35 -0
  95. package/packages/sdk/dist/workflows/process-spawner.d.ts.map +1 -0
  96. package/packages/sdk/dist/workflows/process-spawner.js +141 -0
  97. package/packages/sdk/dist/workflows/process-spawner.js.map +1 -0
  98. package/packages/sdk/dist/workflows/run.d.ts +2 -1
  99. package/packages/sdk/dist/workflows/run.d.ts.map +1 -1
  100. package/packages/sdk/dist/workflows/run.js.map +1 -1
  101. package/packages/sdk/dist/workflows/runner.d.ts +6 -6
  102. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  103. package/packages/sdk/dist/workflows/runner.js +443 -719
  104. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  105. package/packages/sdk/dist/workflows/step-executor.d.ts +95 -0
  106. package/packages/sdk/dist/workflows/step-executor.d.ts.map +1 -0
  107. package/packages/sdk/dist/workflows/step-executor.js +393 -0
  108. package/packages/sdk/dist/workflows/step-executor.js.map +1 -0
  109. package/packages/sdk/dist/workflows/template-resolver.d.ts +33 -0
  110. package/packages/sdk/dist/workflows/template-resolver.d.ts.map +1 -0
  111. package/packages/sdk/dist/workflows/template-resolver.js +144 -0
  112. package/packages/sdk/dist/workflows/template-resolver.js.map +1 -0
  113. package/packages/sdk/dist/workflows/verification.d.ts +33 -0
  114. package/packages/sdk/dist/workflows/verification.d.ts.map +1 -0
  115. package/packages/sdk/dist/workflows/verification.js +122 -0
  116. package/packages/sdk/dist/workflows/verification.js.map +1 -0
  117. package/packages/sdk/package.json +2 -2
  118. package/packages/sdk/src/broker-path.ts +136 -30
  119. package/packages/sdk/src/client.ts +37 -3
  120. package/packages/sdk/src/index.ts +1 -0
  121. package/packages/sdk/src/relay.ts +6 -2
  122. package/packages/sdk/src/workflows/__tests__/channel-messenger.test.ts +137 -0
  123. package/packages/sdk/src/workflows/__tests__/run-summary-table.test.ts +4 -3
  124. package/packages/sdk/src/workflows/__tests__/step-executor.test.ts +444 -0
  125. package/packages/sdk/src/workflows/__tests__/template-resolver.test.ts +162 -0
  126. package/packages/sdk/src/workflows/__tests__/verification.test.ts +229 -0
  127. package/packages/sdk/src/workflows/builder.ts +6 -6
  128. package/packages/sdk/src/workflows/channel-messenger.ts +314 -0
  129. package/packages/sdk/src/workflows/index.ts +12 -0
  130. package/packages/sdk/src/workflows/process-spawner.ts +201 -0
  131. package/packages/sdk/src/workflows/run.ts +2 -1
  132. package/packages/sdk/src/workflows/runner.ts +636 -951
  133. package/packages/sdk/src/workflows/step-executor.ts +579 -0
  134. package/packages/sdk/src/workflows/template-resolver.ts +180 -0
  135. package/packages/sdk/src/workflows/verification.ts +184 -0
  136. package/packages/sdk-py/pyproject.toml +1 -1
  137. package/packages/telemetry/package.json +1 -1
  138. package/packages/trajectory/package.json +2 -2
  139. package/packages/user-directory/package.json +2 -2
  140. package/packages/utils/package.json +2 -2
@@ -39,8 +39,21 @@ import {
39
39
  } from './custom-steps.js';
40
40
  import { collectCliSession, type CliSessionReport } from './cli-session-collector.js';
41
41
  import { executeApiStep } from './api-executor.js';
42
+ import { ChannelMessenger } from './channel-messenger.js';
42
43
  import { InMemoryWorkflowDb } from './memory-db.js';
44
+ import { buildCommand as buildProcessCommand, spawnProcess } from './process-spawner.js';
43
45
  import { formatRunSummaryTable } from './run-summary-table.js';
46
+ import {
47
+ StepExecutor as WorkflowStepLifecycleExecutor,
48
+ type StepExecutorDeps as WorkflowStepLifecycleExecutorDeps,
49
+ } from './step-executor.js';
50
+ import {
51
+ interpolateStepTask as interpolateStepTaskTemplate,
52
+ resolveDotPath as resolveTemplateDotPath,
53
+ resolveTemplate,
54
+ TemplateResolver,
55
+ type VariableContext,
56
+ } from './template-resolver.js';
44
57
  import type {
45
58
  AgentCli,
46
59
  AgentDefinition,
@@ -73,6 +86,13 @@ import type {
73
86
  WorkflowStepStatus,
74
87
  } from './types.js';
75
88
  import { WorkflowTrajectory, type StepOutcome } from './trajectory.js';
89
+ import {
90
+ runVerification,
91
+ stripInjectedTaskEcho,
92
+ type VerificationOptions,
93
+ type VerificationResult,
94
+ WorkflowCompletionError,
95
+ } from './verification.js';
76
96
 
77
97
  // ── AgentRelay SDK imports ──────────────────────────────────────────────────
78
98
 
@@ -81,6 +101,59 @@ import { AgentRelay } from '../relay.js';
81
101
  import type { Agent, AgentRelayOptions } from '../relay.js';
82
102
  import { RelayCast, RelayError, type AgentClient } from '@relaycast/sdk';
83
103
 
104
+ // ── Environment filtering ──────────────────────────────────────────────────
105
+
106
+ /** Keys explicitly allowed to propagate to spawned child processes. */
107
+ const ENV_ALLOWLIST = new Set([
108
+ 'PATH',
109
+ 'HOME',
110
+ 'USER',
111
+ 'SHELL',
112
+ 'LANG',
113
+ 'TERM',
114
+ 'TMPDIR',
115
+ 'TZ',
116
+ 'NODE_ENV',
117
+ 'NODE_PATH',
118
+ 'NODE_OPTIONS',
119
+ 'NODE_EXTRA_CA_CERTS',
120
+ 'RUST_LOG',
121
+ 'RUST_BACKTRACE',
122
+ 'RELAY_API_KEY',
123
+ 'RELAYCAST_BASE_URL',
124
+ 'AGENT_RELAY_DASHBOARD_PORT',
125
+ 'AGENT_RELAY_RUN_ID_FILE',
126
+ 'EDITOR',
127
+ 'VISUAL',
128
+ 'GIT_AUTHOR_NAME',
129
+ 'GIT_AUTHOR_EMAIL',
130
+ 'GIT_COMMITTER_NAME',
131
+ 'GIT_COMMITTER_EMAIL',
132
+ 'HTTPS_PROXY',
133
+ 'HTTP_PROXY',
134
+ 'NO_PROXY',
135
+ 'https_proxy',
136
+ 'http_proxy',
137
+ 'no_proxy',
138
+ 'XDG_CONFIG_HOME',
139
+ 'XDG_DATA_HOME',
140
+ 'XDG_CACHE_HOME',
141
+ ]);
142
+
143
+ /** Return a filtered copy of process.env containing only allowlisted keys. */
144
+ function filteredEnv(extra?: Record<string, string | undefined>): Record<string, string | undefined> {
145
+ const env: Record<string, string | undefined> = {};
146
+ for (const key of ENV_ALLOWLIST) {
147
+ if (process.env[key] !== undefined) {
148
+ env[key] = process.env[key];
149
+ }
150
+ }
151
+ if (extra) {
152
+ Object.assign(env, extra);
153
+ }
154
+ return env;
155
+ }
156
+
84
157
  // ── DB adapter interface ────────────────────────────────────────────────────
85
158
 
86
159
  /** Minimal DB adapter so the runner is not coupled to a specific driver. */
@@ -114,27 +187,6 @@ class SpawnExitError extends Error {
114
187
  }
115
188
  }
116
189
 
117
- class WorkflowCompletionError extends Error {
118
- completionReason?: WorkflowStepCompletionReason;
119
-
120
- constructor(message: string, completionReason?: WorkflowStepCompletionReason) {
121
- super(message);
122
- this.name = 'WorkflowCompletionError';
123
- this.completionReason = completionReason;
124
- }
125
- }
126
-
127
- interface VerificationResult {
128
- passed: boolean;
129
- completionReason?: WorkflowStepCompletionReason;
130
- error?: string;
131
- }
132
-
133
- interface VerificationOptions {
134
- allowFailure?: boolean;
135
- completionMarkerFound?: boolean;
136
- }
137
-
138
190
  interface CompletionDecisionResult {
139
191
  completionReason: WorkflowStepCompletionReason;
140
192
  ownerDecision?: WorkflowOwnerDecision;
@@ -157,7 +209,14 @@ export type WorkflowEvent =
157
209
  ownerName: string;
158
210
  specialistName: string;
159
211
  }
160
- | { type: 'step:completed'; runId: string; stepName: string; output?: string; exitCode?: number; exitSignal?: string }
212
+ | {
213
+ type: 'step:completed';
214
+ runId: string;
215
+ stepName: string;
216
+ output?: string;
217
+ exitCode?: number;
218
+ exitSignal?: string;
219
+ }
161
220
  | {
162
221
  type: 'step:review-completed';
163
222
  runId: string;
@@ -167,7 +226,14 @@ export type WorkflowEvent =
167
226
  }
168
227
  | { type: 'step:owner-timeout'; runId: string; stepName: string; ownerName: string }
169
228
  | { type: 'step:agent-report'; runId: string; stepName: string; report: CliSessionReport }
170
- | { type: 'step:failed'; runId: string; stepName: string; error: string; exitCode?: number; exitSignal?: string }
229
+ | {
230
+ type: 'step:failed';
231
+ runId: string;
232
+ stepName: string;
233
+ error: string;
234
+ exitCode?: number;
235
+ exitSignal?: string;
236
+ }
171
237
  | { type: 'step:skipped'; runId: string; stepName: string }
172
238
  | { type: 'step:retrying'; runId: string; stepName: string; attempt: number }
173
239
  | { type: 'step:nudged'; runId: string; stepName: string; nudgeCount: number }
@@ -183,7 +249,7 @@ export interface WorkflowRunnerOptions {
183
249
  relay?: AgentRelayOptions;
184
250
  cwd?: string;
185
251
  summaryDir?: string;
186
- executor?: StepExecutor;
252
+ executor?: RunnerStepExecutor;
187
253
  envSecrets?: Record<string, string>;
188
254
  }
189
255
 
@@ -194,7 +260,7 @@ export interface WorkflowRunnerOptions {
194
260
  * (e.g. Daytona sandboxes) while keeping the runner's DAG/retry/verification
195
261
  * machinery intact.
196
262
  */
197
- export interface StepExecutor {
263
+ export interface RunnerStepExecutor {
198
264
  executeAgentStep(
199
265
  step: WorkflowStep,
200
266
  agentDef: AgentDefinition,
@@ -215,12 +281,6 @@ export interface StepExecutor {
215
281
  ): Promise<{ output: string; success: boolean }>;
216
282
  }
217
283
 
218
- // ── Variable context for template resolution ────────────────────────────────
219
-
220
- export interface VariableContext {
221
- [key: string]: string | number | boolean | undefined;
222
- }
223
-
224
284
  // ── Internal step state ─────────────────────────────────────────────────────
225
285
 
226
286
  interface StepState {
@@ -307,8 +367,10 @@ export class WorkflowRunner {
307
367
  private readonly relayOptions: AgentRelayOptions;
308
368
  private readonly cwd: string;
309
369
  private readonly summaryDir: string;
310
- private readonly executor?: StepExecutor;
370
+ private readonly executor?: RunnerStepExecutor;
311
371
  private readonly envSecrets?: Record<string, string>;
372
+ private readonly templateResolver: TemplateResolver;
373
+ private readonly channelMessenger: ChannelMessenger;
312
374
 
313
375
  /** @internal exposed for CLI signal-handler shutdown only */
314
376
  relay?: AgentRelay;
@@ -378,6 +440,8 @@ export class WorkflowRunner {
378
440
  this.workersPath = path.join(this.cwd, '.agent-relay', 'team', 'workers.json');
379
441
  this.executor = options.executor;
380
442
  this.envSecrets = options.envSecrets;
443
+ this.templateResolver = new TemplateResolver();
444
+ this.channelMessenger = new ChannelMessenger({ postFn: (text) => this.postToChannel(text) });
381
445
  }
382
446
 
383
447
  // ── Path resolution ─────────────────────────────────────────────────────
@@ -531,16 +595,13 @@ export class WorkflowRunner {
531
595
  participant: 'owner' | 'worker',
532
596
  ...senders: Array<string | undefined>
533
597
  ): void {
534
- const participants =
535
- this.stepSignalParticipants.get(stepName) ??
536
- {
537
- ownerSenders: new Set<string>(),
538
- workerSenders: new Set<string>(),
539
- };
598
+ const participants = this.stepSignalParticipants.get(stepName) ?? {
599
+ ownerSenders: new Set<string>(),
600
+ workerSenders: new Set<string>(),
601
+ };
540
602
  this.stepSignalParticipants.set(stepName, participants);
541
603
 
542
- const target =
543
- participant === 'owner' ? participants.ownerSenders : participants.workerSenders;
604
+ const target = participant === 'owner' ? participants.ownerSenders : participants.workerSenders;
544
605
  for (const sender of senders) {
545
606
  const trimmed = sender?.trim();
546
607
  if (trimmed) target.add(trimmed);
@@ -557,11 +618,7 @@ export class WorkflowRunner {
557
618
 
558
619
  private isSignalFromExpectedSender(stepName: string, signal: CompletionEvidenceSignal): boolean {
559
620
  const expectedParticipant =
560
- signal.kind === 'worker_done'
561
- ? 'worker'
562
- : signal.kind === 'lead_done'
563
- ? 'owner'
564
- : undefined;
621
+ signal.kind === 'worker_done' ? 'worker' : signal.kind === 'lead_done' ? 'owner' : undefined;
565
622
  if (!expectedParticipant) return true;
566
623
 
567
624
  const participants = this.stepSignalParticipants.get(stepName);
@@ -589,9 +646,7 @@ export class WorkflowRunner {
589
646
  evidence: StepCompletionEvidence
590
647
  ): StepCompletionEvidence {
591
648
  evidence.channelPosts = evidence.channelPosts.map((post) => {
592
- const signals = post.signals.filter((signal) =>
593
- this.isSignalFromExpectedSender(stepName, signal)
594
- );
649
+ const signals = post.signals.filter((signal) => this.isSignalFromExpectedSender(stepName, signal));
595
650
  return {
596
651
  ...post,
597
652
  completionRelevant: signals.length > 0,
@@ -772,11 +827,7 @@ export class WorkflowRunner {
772
827
  ): CompletionEvidenceSignal[] {
773
828
  const signals: CompletionEvidenceSignal[] = [];
774
829
  const seen = new Set<string>();
775
- const add = (
776
- kind: CompletionEvidenceSignalKind,
777
- signalText: string,
778
- value?: string
779
- ): void => {
830
+ const add = (kind: CompletionEvidenceSignalKind, signalText: string, value?: string): void => {
780
831
  const trimmed = signalText.trim().slice(0, 280);
781
832
  if (!trimmed) return;
782
833
  const key = `${kind}:${trimmed}:${value ?? ''}`;
@@ -839,7 +890,9 @@ export class WorkflowRunner {
839
890
  }
840
891
 
841
892
  private uniqueEvidenceRoots(roots: Array<string | undefined>): string[] {
842
- return [...new Set(roots.filter((root): root is string => Boolean(root)).map((root) => path.resolve(root)))];
893
+ return [
894
+ ...new Set(roots.filter((root): root is string => Boolean(root)).map((root) => path.resolve(root))),
895
+ ];
843
896
  }
844
897
 
845
898
  private captureFileSnapshot(root: string): Map<string, FileSnapshotEntry> {
@@ -947,7 +1000,9 @@ export class WorkflowRunner {
947
1000
  break;
948
1001
  case 'completed_by_owner_decision': {
949
1002
  const evidence = this.getStepCompletionEvidence(stepName);
950
- const markerObserved = evidence?.coordinationSignals.some((signal) => signal.kind === 'step_complete');
1003
+ const markerObserved = evidence?.coordinationSignals.some(
1004
+ (signal) => signal.kind === 'step_complete'
1005
+ );
951
1006
  mode = markerObserved ? 'marker' : 'owner_decision';
952
1007
  reason = markerObserved ? 'Legacy STEP_COMPLETE marker observed' : 'Owner approved completion';
953
1008
  break;
@@ -969,9 +1024,7 @@ export class WorkflowRunner {
969
1024
  const evidence = this.getStepCompletionEvidence(stepName);
970
1025
  if (!evidence) return undefined;
971
1026
 
972
- const signals = evidence.coordinationSignals
973
- .slice(-6)
974
- .map((signal) => signal.value ?? signal.text);
1027
+ const signals = evidence.coordinationSignals.slice(-6).map((signal) => signal.value ?? signal.text);
975
1028
  const channelPosts = evidence.channelPosts
976
1029
  .filter((post) => post.completionRelevant)
977
1030
  .slice(-3)
@@ -1080,7 +1133,7 @@ export class WorkflowRunner {
1080
1133
  }
1081
1134
 
1082
1135
  return {
1083
- ...(this.relayOptions.env ?? process.env),
1136
+ ...(this.relayOptions.env ?? filteredEnv()),
1084
1137
  RELAY_API_KEY: this.relayApiKey,
1085
1138
  };
1086
1139
  }
@@ -1366,9 +1419,7 @@ export class WorkflowRunner {
1366
1419
  // Validate workdir references on steps
1367
1420
  for (const step of resolvedSteps) {
1368
1421
  if (step.workdir && !dryRunPaths.has(step.workdir)) {
1369
- errors.push(
1370
- `Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`
1371
- );
1422
+ errors.push(`Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`);
1372
1423
  }
1373
1424
  }
1374
1425
 
@@ -1701,79 +1752,15 @@ export class WorkflowRunner {
1701
1752
 
1702
1753
  /** Resolve {{variable}} placeholders in all task strings. */
1703
1754
  resolveVariables(config: RelayYamlConfig, vars: VariableContext): RelayYamlConfig {
1704
- const resolved = structuredClone(config);
1705
-
1706
- for (const agent of resolved.agents) {
1707
- if (agent.task) {
1708
- agent.task = this.interpolate(agent.task, vars);
1709
- }
1710
- }
1711
-
1712
- if (resolved.workflows) {
1713
- for (const wf of resolved.workflows) {
1714
- for (const step of wf.steps) {
1715
- // Resolve variables in task (agent steps) and command (deterministic steps)
1716
- if (step.task) {
1717
- step.task = this.interpolate(step.task, vars);
1718
- }
1719
- if (step.command) {
1720
- step.command = this.interpolate(step.command, vars);
1721
- }
1722
- // Resolve variables in integration step params
1723
- if (step.params && typeof step.params === 'object') {
1724
- for (const key of Object.keys(step.params)) {
1725
- const val = (step.params as Record<string, unknown>)[key];
1726
- if (typeof val === 'string') {
1727
- (step.params as Record<string, string>)[key] = this.interpolate(val, vars);
1728
- }
1729
- }
1730
- }
1731
- }
1732
- }
1733
- }
1734
-
1735
- return resolved;
1755
+ return this.templateResolver.resolveVariables(config, vars);
1736
1756
  }
1737
1757
 
1738
1758
  private interpolate(template: string, vars: VariableContext): string {
1739
- return template.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key: string) => {
1740
- // Skip step-output placeholders — they are resolved at execution time by interpolateStepTask()
1741
- if (key.startsWith('steps.')) {
1742
- return _match;
1743
- }
1744
-
1745
- // Resolve dot-path variables like steps.plan.output
1746
- const value = this.resolveDotPath(key, vars);
1747
- if (value === undefined) {
1748
- throw new Error(`Unresolved variable: {{${key}}}`);
1749
- }
1750
- return String(value);
1751
- });
1759
+ return resolveTemplate(template, vars);
1752
1760
  }
1753
1761
 
1754
1762
  private resolveDotPath(key: string, vars: VariableContext): string | number | boolean | undefined {
1755
- // Simple key — direct lookup
1756
- if (!key.includes('.')) {
1757
- return vars[key];
1758
- }
1759
-
1760
- // Dot-path — walk into nested context
1761
- const parts = key.split('.');
1762
- let current: unknown = vars;
1763
- for (const part of parts) {
1764
- if (current === null || current === undefined || typeof current !== 'object') {
1765
- return undefined;
1766
- }
1767
- current = (current as Record<string, unknown>)[part];
1768
- }
1769
-
1770
- if (current === undefined || current === null) {
1771
- return undefined;
1772
- }
1773
- if (typeof current === 'string' || typeof current === 'number' || typeof current === 'boolean') {
1774
- return current;
1775
- }
1776
- return String(current);
1763
+ return resolveTemplateDotPath(key, vars);
1777
1764
  }
1778
1765
 
1779
1766
  /** Build a nested context from completed step outputs for {{steps.X.output}} resolution. */
@@ -1796,14 +1783,108 @@ export class WorkflowRunner {
1796
1783
 
1797
1784
  /** Interpolate step-output variables, silently skipping unresolved ones (they may be user vars). */
1798
1785
  private interpolateStepTask(template: string, context: VariableContext): string {
1799
- return template.replace(/\{\{(steps\.[\w\-]+\.output)\}\}/g, (_match, key: string) => {
1800
- const value = this.resolveDotPath(key, context);
1801
- if (value === undefined) {
1802
- // Leave unresolved — may not be an error if the template doesn't depend on prior steps
1803
- return _match;
1804
- }
1805
- return String(value);
1806
- });
1786
+ return interpolateStepTaskTemplate(template, context);
1787
+ }
1788
+
1789
+ private createStepLifecycleExecutor(
1790
+ workflow: WorkflowDefinition,
1791
+ stepStates: Map<string, StepState>,
1792
+ agentMap: Map<string, AgentDefinition>,
1793
+ errorHandling: ErrorHandlingConfig | undefined,
1794
+ runId: string
1795
+ ): WorkflowStepLifecycleExecutor<StepState> {
1796
+ // eslint-disable-next-line prefer-const -- circular: deps closure captures lifecycle before assignment
1797
+ let lifecycle!: WorkflowStepLifecycleExecutor<StepState>;
1798
+ const deps: WorkflowStepLifecycleExecutorDeps<StepState> = {
1799
+ cwd: this.cwd,
1800
+ runId,
1801
+ templateResolver: this.templateResolver,
1802
+ channelMessenger: this.channelMessenger,
1803
+ verificationRunner: (check, output, stepName, injectedTaskText, options) =>
1804
+ this.runVerification(check, output, stepName, injectedTaskText, options),
1805
+ postToChannel: (text) => this.postToChannel(text),
1806
+ persistStepRow: async (stepId, patch) => this.db.updateStep(stepId, patch),
1807
+ persistStepOutput: async (lifecycleRunId, stepName, output) =>
1808
+ this.persistStepOutput(lifecycleRunId, stepName, output),
1809
+ loadStepOutput: (lifecycleRunId, stepName) => this.loadStepOutput(lifecycleRunId, stepName),
1810
+ checkAborted: () => this.checkAborted(),
1811
+ waitIfPaused: () => this.waitIfPaused(),
1812
+ log: (message) => this.log(message),
1813
+ onStepStarted: async (step) => {
1814
+ this.emit({ type: 'step:started', runId, stepName: step.name });
1815
+ },
1816
+ onStepCompleted: async (step, state, result) => {
1817
+ this.emit({
1818
+ type: 'step:completed',
1819
+ runId,
1820
+ stepName: step.name,
1821
+ output: result.output,
1822
+ exitCode: result.exitCode,
1823
+ exitSignal: result.exitSignal,
1824
+ });
1825
+ this.finalizeStepEvidence(step.name, result.status, state.row.completedAt, result.completionReason);
1826
+ },
1827
+ onStepFailed: async (step, state, result) => {
1828
+ this.captureStepTerminalEvidence(
1829
+ step.name,
1830
+ {},
1831
+ {
1832
+ exitCode: result.exitCode,
1833
+ exitSignal: result.exitSignal,
1834
+ }
1835
+ );
1836
+ this.emit({
1837
+ type: 'step:failed',
1838
+ runId,
1839
+ stepName: step.name,
1840
+ error: result.error ?? 'Unknown error',
1841
+ exitCode: result.exitCode,
1842
+ exitSignal: result.exitSignal,
1843
+ });
1844
+ this.finalizeStepEvidence(step.name, 'failed', state.row.completedAt, result.completionReason);
1845
+ },
1846
+ executeStep: async (step, state) => {
1847
+ await this.executeStep(step, state, stepStates, agentMap, errorHandling, runId, lifecycle);
1848
+ return {
1849
+ status: state.row.status,
1850
+ output: state.row.output ?? '',
1851
+ completionReason: state.row.completionReason,
1852
+ retries: state.row.retryCount,
1853
+ error: state.row.error,
1854
+ };
1855
+ },
1856
+ onBeginTrack: async (steps) => {
1857
+ if (steps.length > 1 && this.trajectory) {
1858
+ await this.trajectory.beginTrack(steps.map((step) => step.name).join(', '));
1859
+ }
1860
+ },
1861
+ onConverge: async (readySteps, batchOutcomes) => {
1862
+ if (readySteps.length <= 1 || !this.trajectory?.shouldReflectOnConverge()) {
1863
+ return;
1864
+ }
1865
+
1866
+ const completedNames = new Set(
1867
+ batchOutcomes.filter((outcome) => outcome.status === 'completed').map((outcome) => outcome.name)
1868
+ );
1869
+ const unblocked = workflow.steps
1870
+ .filter((step) => step.dependsOn?.some((dependency) => completedNames.has(dependency)))
1871
+ .filter((step) => stepStates.get(step.name)?.row.status === 'pending')
1872
+ .map((step) => step.name);
1873
+
1874
+ await this.trajectory.synthesizeAndReflect(
1875
+ readySteps.map((step) => step.name).join(' + '),
1876
+ batchOutcomes,
1877
+ unblocked.length > 0 ? unblocked : undefined
1878
+ );
1879
+ },
1880
+ markDownstreamSkipped: async (failedStepName) =>
1881
+ this.markDownstreamSkipped(failedStepName, workflow.steps, stepStates, runId),
1882
+ buildCompletionMode: (stepName, completionReason) =>
1883
+ completionReason ? this.buildStepCompletionDecision(stepName, completionReason)?.mode : undefined,
1884
+ };
1885
+
1886
+ lifecycle = new WorkflowStepLifecycleExecutor<StepState>(deps);
1887
+ return lifecycle;
1807
1888
  }
1808
1889
 
1809
1890
  // ── Execution ───────────────────────────────────────────────────────────
@@ -1873,7 +1954,8 @@ export class WorkflowRunner {
1873
1954
  const stepStates = new Map<string, StepState>();
1874
1955
  for (const step of resolvedWorkflow.steps) {
1875
1956
  // Handle agent, deterministic, worktree, and integration steps
1876
- const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
1957
+ const isNonAgent =
1958
+ step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
1877
1959
 
1878
1960
  const stepRow: WorkflowStepRow = {
1879
1961
  id: this.generateId(),
@@ -1888,7 +1970,7 @@ export class WorkflowRunner {
1888
1970
  : step.type === 'worktree'
1889
1971
  ? (step.branch ?? '')
1890
1972
  : step.type === 'integration'
1891
- ? (`${step.integration}.${step.action}`)
1973
+ ? `${step.integration}.${step.action}`
1892
1974
  : (step.task ?? ''),
1893
1975
  dependsOn: step.dependsOn ?? [],
1894
1976
  retryCount: 0,
@@ -1913,8 +1995,7 @@ export class WorkflowRunner {
1913
1995
  const skippedCount = transitiveDeps.size;
1914
1996
 
1915
1997
  // Determine which run ID to load cached outputs from
1916
- const cacheRunId = executeOptions.previousRunId
1917
- ?? this.findMostRecentRunWithSteps(transitiveDeps);
1998
+ const cacheRunId = executeOptions.previousRunId ?? this.findMostRecentRunWithSteps(transitiveDeps);
1918
1999
 
1919
2000
  for (const depName of transitiveDeps) {
1920
2001
  const state = stepStates.get(depName);
@@ -2082,11 +2163,12 @@ export class WorkflowRunner {
2082
2163
  config.swarm.channel = channel;
2083
2164
  await this.db.updateRun(runId, { config });
2084
2165
  }
2085
- const relaycastDisabled =
2086
- this.relayOptions.env?.AGENT_RELAY_WORKFLOW_DISABLE_RELAYCAST === '1';
2166
+ const relaycastDisabled = this.relayOptions.env?.AGENT_RELAY_WORKFLOW_DISABLE_RELAYCAST === '1';
2087
2167
  const requiresBroker =
2088
2168
  !this.executor &&
2089
- workflow.steps.some((step) => step.type !== 'deterministic' && step.type !== 'worktree' && step.type !== 'integration');
2169
+ workflow.steps.some(
2170
+ (step) => step.type !== 'deterministic' && step.type !== 'worktree' && step.type !== 'integration'
2171
+ );
2090
2172
  // Skip broker/relay init when an external executor handles agent spawning
2091
2173
  if (requiresBroker) {
2092
2174
  if (!relaycastDisabled) {
@@ -2324,10 +2406,8 @@ export class WorkflowRunner {
2324
2406
  this.log(`Executing ${workflow.steps.length} steps (pattern: ${config.swarm.pattern})`);
2325
2407
  await this.executeSteps(workflow, stepStates, agentMap, config.errorHandling, runId);
2326
2408
 
2327
- const errorStrategy =
2328
- config.errorHandling?.strategy ?? workflow.onError ?? 'fail-fast';
2329
- const continueOnError =
2330
- errorStrategy === 'continue' || errorStrategy === 'skip';
2409
+ const errorStrategy = config.errorHandling?.strategy ?? workflow.onError ?? 'fail-fast';
2410
+ const continueOnError = errorStrategy === 'continue' || errorStrategy === 'skip';
2331
2411
  const allCompleted = [...stepStates.values()].every(
2332
2412
  (s) =>
2333
2413
  s.row.status === 'completed' ||
@@ -2479,8 +2559,6 @@ export class WorkflowRunner {
2479
2559
  runId: string
2480
2560
  ): Promise<void> {
2481
2561
  const rawStrategy = errorHandling?.strategy ?? workflow.onError ?? 'fail-fast';
2482
- // Map shorthand onError values to canonical strategy names.
2483
- // 'retry' maps to 'fail-fast' so downstream steps are properly skipped after retries exhaust.
2484
2562
  const strategy =
2485
2563
  rawStrategy === 'fail'
2486
2564
  ? 'fail-fast'
@@ -2490,109 +2568,17 @@ export class WorkflowRunner {
2490
2568
  ? 'fail-fast'
2491
2569
  : rawStrategy;
2492
2570
 
2493
- // DAG-based execution: repeatedly find ready steps and run them in parallel
2494
- while (true) {
2495
- this.checkAborted();
2496
- await this.waitIfPaused();
2497
-
2498
- const readySteps = this.findReadySteps(workflow.steps, stepStates);
2499
- if (readySteps.length === 0) {
2500
- // No steps ready — either all done or blocked
2501
- break;
2502
- }
2571
+ const lifecycle = this.createStepLifecycleExecutor(workflow, stepStates, agentMap, errorHandling, runId);
2503
2572
 
2504
- // Begin a track chapter if multiple parallel steps are starting
2505
- if (readySteps.length > 1 && this.trajectory) {
2506
- const trackNames = readySteps.map((s) => s.name).join(', ');
2507
- await this.trajectory.beginTrack(trackNames);
2508
- }
2509
-
2510
- // Stagger spawns when many steps are ready simultaneously.
2511
- // All agents still run concurrently once spawned — this only delays when
2512
- // each step's executeStep() begins, preventing Relaycast from receiving
2513
- // N simultaneous registration requests which causes spawn timeouts.
2514
- const STAGGER_THRESHOLD = 3;
2515
- const STAGGER_DELAY_MS = 2_000;
2516
- const results = await Promise.allSettled(
2517
- readySteps.map((step, i) => {
2518
- const delay = readySteps.length > STAGGER_THRESHOLD ? i * STAGGER_DELAY_MS : 0;
2519
- if (delay === 0) {
2520
- return this.executeStep(step, stepStates, agentMap, errorHandling, runId);
2521
- }
2522
- return new Promise<void>((resolve) => setTimeout(resolve, delay)).then(() =>
2523
- this.executeStep(step, stepStates, agentMap, errorHandling, runId)
2524
- );
2525
- })
2526
- );
2527
-
2528
- // Collect outcomes from this batch for convergence reflection
2529
- const batchOutcomes: StepOutcome[] = [];
2530
-
2531
- for (let i = 0; i < results.length; i++) {
2532
- const result = results[i];
2533
- const step = readySteps[i];
2534
- const state = stepStates.get(step.name);
2535
-
2536
- if (result.status === 'rejected') {
2537
- const error = result.reason instanceof Error ? result.reason.message : String(result.reason);
2538
- if (state && state.row.status !== 'failed') {
2539
- await this.markStepFailed(state, error, runId);
2540
- }
2541
-
2542
- batchOutcomes.push({
2543
- name: step.name,
2544
- agent: step.agent ?? 'deterministic',
2545
- status: 'failed',
2546
- attempts: (state?.row.retryCount ?? 0) + 1,
2547
- error,
2548
- });
2549
-
2550
- if (strategy === 'fail-fast') {
2551
- // Mark all pending downstream steps as skipped
2552
- await this.markDownstreamSkipped(step.name, workflow.steps, stepStates, runId);
2553
- throw new Error(`Step "${step.name}" failed: ${error}`);
2554
- }
2555
-
2556
- if (strategy === 'continue') {
2557
- await this.markDownstreamSkipped(step.name, workflow.steps, stepStates, runId);
2558
- }
2559
- } else {
2560
- batchOutcomes.push({
2561
- name: step.name,
2562
- agent: step.agent ?? 'deterministic',
2563
- status: state?.row.status === 'completed' ? 'completed' : 'failed',
2564
- attempts: (state?.row.retryCount ?? 0) + 1,
2565
- output: state?.row.output,
2566
- verificationPassed: state?.row.status === 'completed' && step.verification !== undefined,
2567
- completionMode: state?.row.completionReason
2568
- ? this.buildStepCompletionDecision(step.name, state.row.completionReason)?.mode
2569
- : undefined,
2570
- });
2571
- }
2572
- }
2573
-
2574
- // Reflect at convergence when a parallel batch completes
2575
- if (readySteps.length > 1 && this.trajectory?.shouldReflectOnConverge()) {
2576
- const label = readySteps.map((s) => s.name).join(' + ');
2577
- // Find steps that this batch unblocks
2578
- const completedNames = new Set(
2579
- batchOutcomes.filter((o) => o.status === 'completed').map((o) => o.name)
2580
- );
2581
- const unblocked = workflow.steps
2582
- .filter((s) => s.dependsOn?.some((dep) => completedNames.has(dep)))
2583
- .filter((s) => {
2584
- const st = stepStates.get(s.name);
2585
- return st && st.row.status === 'pending';
2586
- })
2587
- .map((s) => s.name);
2588
-
2589
- await this.trajectory.synthesizeAndReflect(
2590
- label,
2591
- batchOutcomes,
2592
- unblocked.length > 0 ? unblocked : undefined
2593
- );
2594
- }
2595
- }
2573
+ await lifecycle.executeAll(
2574
+ workflow.steps,
2575
+ agentMap,
2576
+ {
2577
+ ...(errorHandling ?? { strategy: 'fail-fast' }),
2578
+ strategy,
2579
+ },
2580
+ stepStates
2581
+ );
2596
2582
  }
2597
2583
 
2598
2584
  private findReadySteps(steps: WorkflowStep[], stepStates: Map<string, StepState>): WorkflowStep[] {
@@ -2626,7 +2612,7 @@ export class WorkflowRunner {
2626
2612
  const child = cpSpawn('sh', ['-c', check.command], {
2627
2613
  stdio: 'pipe',
2628
2614
  cwd: this.cwd,
2629
- env: { ...process.env },
2615
+ env: filteredEnv(),
2630
2616
  });
2631
2617
 
2632
2618
  const stdoutChunks: string[] = [];
@@ -2742,24 +2728,26 @@ export class WorkflowRunner {
2742
2728
 
2743
2729
  private async executeStep(
2744
2730
  step: WorkflowStep,
2731
+ state: StepState,
2745
2732
  stepStates: Map<string, StepState>,
2746
2733
  agentMap: Map<string, AgentDefinition>,
2747
2734
  errorHandling: ErrorHandlingConfig | undefined,
2748
- runId: string
2735
+ runId: string,
2736
+ lifecycle: WorkflowStepLifecycleExecutor<StepState>
2749
2737
  ): Promise<void> {
2750
2738
  // Branch: deterministic steps execute shell commands
2751
2739
  if (this.isDeterministicStep(step)) {
2752
- return this.executeDeterministicStep(step, stepStates, runId, errorHandling);
2740
+ return this.executeDeterministicStep(step, state, stepStates, runId, errorHandling, lifecycle);
2753
2741
  }
2754
2742
 
2755
2743
  // Branch: worktree steps set up git worktrees
2756
2744
  if (this.isWorktreeStep(step)) {
2757
- return this.executeWorktreeStep(step, stepStates, runId);
2745
+ return this.executeWorktreeStep(step, state, stepStates, runId, lifecycle);
2758
2746
  }
2759
2747
 
2760
2748
  // Branch: integration steps interact with external services
2761
2749
  if (this.isIntegrationStep(step)) {
2762
- return this.executeIntegrationStep(step, stepStates, runId);
2750
+ return this.executeIntegrationStep(step, state, stepStates, runId, lifecycle);
2763
2751
  }
2764
2752
 
2765
2753
  // Agent step execution
@@ -2772,115 +2760,71 @@ export class WorkflowRunner {
2772
2760
  */
2773
2761
  private async executeDeterministicStep(
2774
2762
  step: WorkflowStep,
2763
+ state: StepState,
2775
2764
  stepStates: Map<string, StepState>,
2776
2765
  runId: string,
2777
- errorHandling: ErrorHandlingConfig | undefined
2766
+ errorHandling: ErrorHandlingConfig | undefined,
2767
+ lifecycle: WorkflowStepLifecycleExecutor<StepState>
2778
2768
  ): Promise<void> {
2779
- const state = stepStates.get(step.name);
2780
- if (!state) throw new Error(`Step state not found: ${step.name}`);
2781
-
2782
2769
  const maxRetries = step.retries ?? errorHandling?.maxRetries ?? 0;
2783
2770
  const retryDelay = errorHandling?.retryDelayMs ?? 1000;
2784
- let lastError: string | undefined;
2771
+ let lastError = 'Unknown error';
2785
2772
  let lastCompletionReason: WorkflowStepCompletionReason | undefined;
2786
2773
  let lastExitCode: number | undefined;
2787
2774
  let lastExitSignal: string | undefined;
2788
2775
 
2789
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
2790
- this.checkAborted();
2791
-
2792
- lastExitCode = undefined;
2793
- lastExitSignal = undefined;
2794
-
2795
- if (attempt > 0) {
2776
+ const result = await lifecycle.monitorStep(step, state, {
2777
+ maxRetries,
2778
+ retryDelayMs: retryDelay,
2779
+ startMessage: `**[${step.name}]** Started (deterministic)`,
2780
+ onRetry: async (attempt, total) => {
2796
2781
  this.emit({ type: 'step:retrying', runId, stepName: step.name, attempt });
2797
- this.postToChannel(`**[${step.name}]** Retrying (attempt ${attempt + 1}/${maxRetries + 1})`);
2782
+ this.postToChannel(`**[${step.name}]** Retrying (attempt ${attempt + 1}/${total + 1})`);
2798
2783
  this.recordStepToolSideEffect(step.name, {
2799
2784
  type: 'retry',
2800
- detail: `Retrying attempt ${attempt + 1}/${maxRetries + 1}`,
2801
- raw: { attempt, maxRetries },
2802
- });
2803
- state.row.retryCount = attempt;
2804
- await this.db.updateStep(state.row.id, {
2805
- retryCount: attempt,
2806
- updatedAt: new Date().toISOString(),
2785
+ detail: `Retrying attempt ${attempt + 1}/${total + 1}`,
2786
+ raw: { attempt, maxRetries: total },
2807
2787
  });
2808
- await this.delay(retryDelay);
2809
- }
2810
-
2811
- // Mark step as running
2812
- state.row.status = 'running';
2813
- state.row.error = undefined;
2814
- state.row.completionReason = undefined;
2815
- state.row.startedAt = new Date().toISOString();
2816
- await this.db.updateStep(state.row.id, {
2817
- status: 'running',
2818
- error: undefined,
2819
- completionReason: undefined,
2820
- startedAt: state.row.startedAt,
2821
- updatedAt: new Date().toISOString(),
2822
- });
2823
- this.emit({ type: 'step:started', runId, stepName: step.name });
2824
- this.postToChannel(`**[${step.name}]** Started (deterministic)`);
2825
-
2826
- // Resolve variables in the command (e.g., {{steps.plan.output}}, {{branch-name}})
2827
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2828
- let resolvedCommand = this.interpolateStepTask(step.command ?? '', stepOutputContext);
2788
+ },
2789
+ execute: async () => {
2790
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2791
+ let resolvedCommand = this.interpolateStepTask(step.command ?? '', stepOutputContext);
2829
2792
 
2830
- // Also resolve simple {{variable}} placeholders (already resolved in top-level config but safe to re-run)
2831
- resolvedCommand = resolvedCommand.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key: string) => {
2832
- if (key.startsWith('steps.')) return _match; // Already handled above
2833
- const value = this.resolveDotPath(key, stepOutputContext);
2834
- return value !== undefined ? String(value) : _match;
2835
- });
2793
+ resolvedCommand = resolvedCommand.replace(/\{\{([\w][\w.\-]*)\}\}/g, (_match, key: string) => {
2794
+ if (key.startsWith('steps.')) return _match;
2795
+ const value = this.resolveDotPath(key, stepOutputContext);
2796
+ return value !== undefined ? String(value) : _match;
2797
+ });
2836
2798
 
2837
- // Resolve step workdir (named path reference) for deterministic steps
2838
- const stepCwd = this.resolveEffectiveCwd(step);
2839
- this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2799
+ const stepCwd = this.resolveEffectiveCwd(step);
2800
+ this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2840
2801
 
2841
- try {
2842
- // Delegate to executor if present
2843
2802
  if (this.executor?.executeDeterministicStep) {
2844
- const result = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
2845
- lastExitCode = result.exitCode;
2803
+ const executorResult = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
2804
+ lastExitCode = executorResult.exitCode;
2805
+ lastExitSignal = undefined;
2846
2806
  const failOnError = step.failOnError !== false;
2847
- if (failOnError && result.exitCode !== 0) {
2807
+ if (failOnError && executorResult.exitCode !== 0) {
2848
2808
  throw new Error(
2849
- `Command failed with exit code ${result.exitCode}: ${result.output.slice(0, 500)}`
2809
+ `Command failed with exit code ${executorResult.exitCode}: ${executorResult.output.slice(0, 500)}`
2850
2810
  );
2851
2811
  }
2852
2812
  const output =
2853
- step.captureOutput !== false ? result.output : `Command completed (exit code ${result.exitCode})`;
2813
+ step.captureOutput !== false
2814
+ ? executorResult.output
2815
+ : `Command completed (exit code ${executorResult.exitCode})`;
2854
2816
  this.captureStepTerminalEvidence(
2855
2817
  step.name,
2856
- { stdout: result.output, combined: result.output },
2857
- { exitCode: result.exitCode }
2818
+ { stdout: executorResult.output, combined: executorResult.output },
2819
+ { exitCode: executorResult.exitCode }
2858
2820
  );
2859
2821
  const verificationResult = step.verification
2860
2822
  ? this.runVerification(step.verification, output, step.name)
2861
2823
  : undefined;
2862
-
2863
- // Mark completed
2864
- state.row.status = 'completed';
2865
- state.row.output = output;
2866
- state.row.completionReason = verificationResult?.completionReason;
2867
- state.row.completedAt = new Date().toISOString();
2868
- await this.db.updateStep(state.row.id, {
2869
- status: 'completed',
2824
+ return {
2870
2825
  output,
2871
2826
  completionReason: verificationResult?.completionReason,
2872
- completedAt: state.row.completedAt,
2873
- updatedAt: new Date().toISOString(),
2874
- });
2875
- await this.persistStepOutput(runId, step.name, output);
2876
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
2877
- this.finalizeStepEvidence(
2878
- step.name,
2879
- 'completed',
2880
- state.row.completedAt,
2881
- verificationResult?.completionReason
2882
- );
2883
- return;
2827
+ };
2884
2828
  }
2885
2829
 
2886
2830
  let commandStdout = '';
@@ -2889,13 +2833,11 @@ export class WorkflowRunner {
2889
2833
  const child = cpSpawn('sh', ['-c', resolvedCommand], {
2890
2834
  stdio: 'pipe',
2891
2835
  cwd: stepCwd,
2892
- env: { ...process.env },
2836
+ env: filteredEnv(),
2893
2837
  });
2894
2838
 
2895
2839
  const stdoutChunks: string[] = [];
2896
2840
  const stderrChunks: string[] = [];
2897
-
2898
- // Wire abort signal
2899
2841
  const abortSignal = this.abortController?.signal;
2900
2842
  let abortHandler: (() => void) | undefined;
2901
2843
  if (abortSignal && !abortSignal.aborted) {
@@ -2906,7 +2848,6 @@ export class WorkflowRunner {
2906
2848
  abortSignal.addEventListener('abort', abortHandler, { once: true });
2907
2849
  }
2908
2850
 
2909
- // Handle timeout
2910
2851
  let timedOut = false;
2911
2852
  let timer: ReturnType<typeof setTimeout> | undefined;
2912
2853
  if (step.timeoutMs) {
@@ -2950,13 +2891,10 @@ export class WorkflowRunner {
2950
2891
  lastExitCode = code ?? undefined;
2951
2892
  lastExitSignal = signal ?? undefined;
2952
2893
 
2953
- // Check exit code unless failOnError is explicitly false
2954
2894
  const failOnError = step.failOnError !== false;
2955
2895
  if (failOnError && code !== 0 && code !== null) {
2956
2896
  reject(
2957
- new Error(
2958
- `Command failed with exit code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ''}`
2959
- )
2897
+ new Error(`Command failed with exit code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ''}`)
2960
2898
  );
2961
2899
  return;
2962
2900
  }
@@ -2972,6 +2910,7 @@ export class WorkflowRunner {
2972
2910
  reject(new Error(`Failed to execute command: ${err.message}`));
2973
2911
  });
2974
2912
  });
2913
+
2975
2914
  this.captureStepTerminalEvidence(
2976
2915
  step.name,
2977
2916
  {
@@ -2986,47 +2925,38 @@ export class WorkflowRunner {
2986
2925
  ? this.runVerification(step.verification, output, step.name)
2987
2926
  : undefined;
2988
2927
 
2989
- // Mark completed
2990
- state.row.status = 'completed';
2991
- state.row.output = output;
2992
- state.row.completionReason = verificationResult?.completionReason;
2993
- state.row.completedAt = new Date().toISOString();
2994
- await this.db.updateStep(state.row.id, {
2995
- status: 'completed',
2928
+ return {
2996
2929
  output,
2997
2930
  completionReason: verificationResult?.completionReason,
2998
- completedAt: state.row.completedAt,
2999
- updatedAt: new Date().toISOString(),
3000
- });
3001
-
3002
- // Persist step output
3003
- await this.persistStepOutput(runId, step.name, output);
2931
+ };
2932
+ },
2933
+ toCompletionResult: ({ output, completionReason }, attempt) => ({
2934
+ status: 'completed',
2935
+ output,
2936
+ completionReason,
2937
+ retries: attempt,
2938
+ exitCode: lastExitCode,
2939
+ exitSignal: lastExitSignal,
2940
+ }),
2941
+ onAttemptFailed: async (error) => {
2942
+ lastError = error instanceof Error ? error.message : String(error);
2943
+ lastCompletionReason = error instanceof WorkflowCompletionError ? error.completionReason : undefined;
2944
+ },
2945
+ getFailureResult: () => ({
2946
+ status: 'failed',
2947
+ output: '',
2948
+ error: lastError,
2949
+ retries: state.row.retryCount,
2950
+ exitCode: lastExitCode,
2951
+ exitSignal: lastExitSignal,
2952
+ completionReason: lastCompletionReason,
2953
+ }),
2954
+ });
3004
2955
 
3005
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
3006
- this.finalizeStepEvidence(
3007
- step.name,
3008
- 'completed',
3009
- state.row.completedAt,
3010
- verificationResult?.completionReason
3011
- );
3012
- return;
3013
- } catch (err) {
3014
- lastError = err instanceof Error ? err.message : String(err);
3015
- lastCompletionReason =
3016
- err instanceof WorkflowCompletionError ? err.completionReason : undefined;
3017
- }
2956
+ if (result.status === 'failed') {
2957
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
2958
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
3018
2959
  }
3019
-
3020
- const errorMsg = lastError ?? 'Unknown error';
3021
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
3022
- await this.markStepFailed(
3023
- state,
3024
- errorMsg,
3025
- runId,
3026
- { exitCode: lastExitCode, exitSignal: lastExitSignal },
3027
- lastCompletionReason
3028
- );
3029
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3030
2960
  }
3031
2961
 
3032
2962
  /**
@@ -3036,223 +2966,188 @@ export class WorkflowRunner {
3036
2966
  */
3037
2967
  private async executeWorktreeStep(
3038
2968
  step: WorkflowStep,
2969
+ state: StepState,
3039
2970
  stepStates: Map<string, StepState>,
3040
- runId: string
2971
+ runId: string,
2972
+ lifecycle: WorkflowStepLifecycleExecutor<StepState>
3041
2973
  ): Promise<void> {
3042
- const state = stepStates.get(step.name);
3043
- if (!state) throw new Error(`Step state not found: ${step.name}`);
3044
2974
  let lastExitCode: number | undefined;
3045
2975
  let lastExitSignal: string | undefined;
2976
+ let worktreeBranch = '';
2977
+ let createdBranch = false;
3046
2978
 
3047
- this.checkAborted();
2979
+ const result = await lifecycle.monitorStep(step, state, {
2980
+ startMessage: `**[${step.name}]** Started (worktree setup)`,
2981
+ execute: async () => {
2982
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
2983
+ const branch = this.interpolateStepTask(step.branch ?? '', stepOutputContext);
2984
+ const baseBranch = step.baseBranch
2985
+ ? this.interpolateStepTask(step.baseBranch, stepOutputContext)
2986
+ : 'HEAD';
2987
+ const worktreePath = step.path
2988
+ ? this.interpolateStepTask(step.path, stepOutputContext)
2989
+ : path.join('.worktrees', step.name);
2990
+ const createBranch = step.createBranch !== false;
2991
+ const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
2992
+
2993
+ this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
2994
+
2995
+ if (!branch) {
2996
+ throw new Error('Worktree step missing required "branch" field');
2997
+ }
3048
2998
 
3049
- // Mark step as running
3050
- state.row.status = 'running';
3051
- state.row.error = undefined;
3052
- state.row.completionReason = undefined;
3053
- state.row.startedAt = new Date().toISOString();
3054
- await this.db.updateStep(state.row.id, {
3055
- status: 'running',
3056
- error: undefined,
3057
- completionReason: undefined,
3058
- startedAt: state.row.startedAt,
3059
- updatedAt: new Date().toISOString(),
3060
- });
3061
- this.emit({ type: 'step:started', runId, stepName: step.name });
3062
- this.postToChannel(`**[${step.name}]** Started (worktree setup)`);
3063
-
3064
- // Resolve variables in branch name and path
3065
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
3066
- const branch = this.interpolateStepTask(step.branch ?? '', stepOutputContext);
3067
- const baseBranch = step.baseBranch
3068
- ? this.interpolateStepTask(step.baseBranch, stepOutputContext)
3069
- : 'HEAD';
3070
- const worktreePath = step.path
3071
- ? this.interpolateStepTask(step.path, stepOutputContext)
3072
- : path.join('.worktrees', step.name);
3073
- const createBranch = step.createBranch !== false;
3074
-
3075
- // Resolve workdir for worktree steps (same as deterministic/agent steps)
3076
- const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
3077
- this.beginStepEvidence(step.name, [stepCwd], state.row.startedAt);
3078
-
3079
- if (!branch) {
3080
- const errorMsg = 'Worktree step missing required "branch" field';
3081
- await this.markStepFailed(state, errorMsg, runId);
3082
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3083
- }
2999
+ const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
3000
+ let branchExists = false;
3084
3001
 
3085
- try {
3086
- // Build the git worktree command
3087
- // If createBranch is true and branch doesn't exist, use -b flag
3088
- const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
3089
-
3090
- // First, check if the branch already exists
3091
- const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
3092
- let branchExists = false;
3093
-
3094
- await new Promise<void>((resolve) => {
3095
- const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
3096
- stdio: 'pipe',
3097
- cwd: stepCwd,
3098
- env: { ...process.env },
3099
- });
3100
- checkChild.on('close', (code) => {
3101
- branchExists = code === 0;
3102
- resolve();
3002
+ await new Promise<void>((resolve) => {
3003
+ const checkChild = cpSpawn('git', ['rev-parse', '--verify', '--quiet', branch], {
3004
+ stdio: 'pipe',
3005
+ cwd: stepCwd,
3006
+ env: filteredEnv(),
3007
+ });
3008
+ checkChild.on('close', (code) => {
3009
+ branchExists = code === 0;
3010
+ resolve();
3011
+ });
3012
+ checkChild.on('error', () => resolve());
3103
3013
  });
3104
- checkChild.on('error', () => resolve());
3105
- });
3106
3014
 
3107
- // Build appropriate worktree add command
3108
- let worktreeCmd: string;
3109
- if (branchExists) {
3110
- // Branch exists, just checkout into worktree
3111
- worktreeCmd = `git worktree add "${absoluteWorktreePath}" ${branch}`;
3112
- } else if (createBranch) {
3113
- // Create new branch from baseBranch
3114
- worktreeCmd = `git worktree add -b ${branch} "${absoluteWorktreePath}" ${baseBranch}`;
3115
- } else {
3116
- // Branch doesn't exist and we're not creating it
3117
- const errorMsg = `Branch "${branch}" does not exist and createBranch is false`;
3118
- await this.markStepFailed(state, errorMsg, runId);
3119
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3120
- }
3015
+ let worktreeArgs: string[];
3016
+ if (branchExists) {
3017
+ worktreeArgs = ['worktree', 'add', absoluteWorktreePath, branch];
3018
+ } else if (createBranch) {
3019
+ worktreeArgs = ['worktree', 'add', '-b', branch, absoluteWorktreePath, baseBranch];
3020
+ } else {
3021
+ throw new Error(`Branch "${branch}" does not exist and createBranch is false`);
3022
+ }
3121
3023
 
3122
- let commandStdout = '';
3123
- let commandStderr = '';
3124
- let commandExitCode: number | undefined;
3125
- let commandExitSignal: string | undefined;
3126
- const output = await new Promise<string>((resolve, reject) => {
3127
- const child = cpSpawn('sh', ['-c', worktreeCmd], {
3128
- stdio: 'pipe',
3129
- cwd: stepCwd,
3130
- env: { ...process.env },
3131
- });
3024
+ let commandStdout = '';
3025
+ let commandStderr = '';
3026
+ const output = await new Promise<string>((resolve, reject) => {
3027
+ const child = cpSpawn('git', worktreeArgs, {
3028
+ stdio: 'pipe',
3029
+ cwd: stepCwd,
3030
+ env: filteredEnv(),
3031
+ });
3132
3032
 
3133
- const stdoutChunks: string[] = [];
3134
- const stderrChunks: string[] = [];
3033
+ const stdoutChunks: string[] = [];
3034
+ const stderrChunks: string[] = [];
3035
+ const abortSignal = this.abortController?.signal;
3036
+ let abortHandler: (() => void) | undefined;
3037
+ if (abortSignal && !abortSignal.aborted) {
3038
+ abortHandler = () => {
3039
+ child.kill('SIGTERM');
3040
+ setTimeout(() => child.kill('SIGKILL'), 5000);
3041
+ };
3042
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
3043
+ }
3135
3044
 
3136
- // Wire abort signal
3137
- const abortSignal = this.abortController?.signal;
3138
- let abortHandler: (() => void) | undefined;
3139
- if (abortSignal && !abortSignal.aborted) {
3140
- abortHandler = () => {
3141
- child.kill('SIGTERM');
3142
- setTimeout(() => child.kill('SIGKILL'), 5000);
3143
- };
3144
- abortSignal.addEventListener('abort', abortHandler, { once: true });
3145
- }
3045
+ let timedOut = false;
3046
+ let timer: ReturnType<typeof setTimeout> | undefined;
3047
+ if (step.timeoutMs) {
3048
+ timer = setTimeout(() => {
3049
+ timedOut = true;
3050
+ child.kill('SIGTERM');
3051
+ setTimeout(() => child.kill('SIGKILL'), 5000);
3052
+ }, step.timeoutMs);
3053
+ }
3146
3054
 
3147
- // Handle timeout
3148
- let timedOut = false;
3149
- let timer: ReturnType<typeof setTimeout> | undefined;
3150
- if (step.timeoutMs) {
3151
- timer = setTimeout(() => {
3152
- timedOut = true;
3153
- child.kill('SIGTERM');
3154
- setTimeout(() => child.kill('SIGKILL'), 5000);
3155
- }, step.timeoutMs);
3156
- }
3055
+ child.stdout?.on('data', (chunk: Buffer) => {
3056
+ stdoutChunks.push(chunk.toString());
3057
+ });
3157
3058
 
3158
- child.stdout?.on('data', (chunk: Buffer) => {
3159
- stdoutChunks.push(chunk.toString());
3160
- });
3059
+ child.stderr?.on('data', (chunk: Buffer) => {
3060
+ stderrChunks.push(chunk.toString());
3061
+ });
3161
3062
 
3162
- child.stderr?.on('data', (chunk: Buffer) => {
3163
- stderrChunks.push(chunk.toString());
3164
- });
3063
+ child.on('close', (code, signal) => {
3064
+ if (timer) clearTimeout(timer);
3065
+ if (abortHandler && abortSignal) {
3066
+ abortSignal.removeEventListener('abort', abortHandler);
3067
+ }
3165
3068
 
3166
- child.on('close', (code, signal) => {
3167
- if (timer) clearTimeout(timer);
3168
- if (abortHandler && abortSignal) {
3169
- abortSignal.removeEventListener('abort', abortHandler);
3170
- }
3069
+ if (abortSignal?.aborted) {
3070
+ reject(new Error(`Step "${step.name}" aborted`));
3071
+ return;
3072
+ }
3171
3073
 
3172
- if (abortSignal?.aborted) {
3173
- reject(new Error(`Step "${step.name}" aborted`));
3174
- return;
3175
- }
3074
+ if (timedOut) {
3075
+ reject(
3076
+ new Error(`Step "${step.name}" timed out (no step timeout set, check global swarm.timeoutMs)`)
3077
+ );
3078
+ return;
3079
+ }
3176
3080
 
3177
- if (timedOut) {
3178
- reject(
3179
- new Error(`Step "${step.name}" timed out (no step timeout set, check global swarm.timeoutMs)`)
3180
- );
3181
- return;
3182
- }
3081
+ commandStdout = stdoutChunks.join('');
3082
+ commandStderr = stderrChunks.join('');
3083
+ lastExitCode = code ?? undefined;
3084
+ lastExitSignal = signal ?? undefined;
3183
3085
 
3184
- commandStdout = stdoutChunks.join('');
3185
- const stderr = stderrChunks.join('');
3186
- commandStderr = stderr;
3187
- commandExitCode = code ?? undefined;
3188
- commandExitSignal = signal ?? undefined;
3189
- lastExitCode = commandExitCode;
3190
- lastExitSignal = commandExitSignal;
3086
+ if (code !== 0 && code !== null) {
3087
+ reject(
3088
+ new Error(
3089
+ `git worktree add failed with exit code ${code}${commandStderr ? `: ${commandStderr.slice(0, 500)}` : ''}`
3090
+ )
3091
+ );
3092
+ return;
3093
+ }
3191
3094
 
3192
- if (code !== 0 && code !== null) {
3193
- reject(
3194
- new Error(
3195
- `git worktree add failed with exit code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ''}`
3196
- )
3197
- );
3198
- return;
3199
- }
3095
+ resolve(absoluteWorktreePath);
3096
+ });
3200
3097
 
3201
- // Output the worktree path for downstream steps
3202
- resolve(absoluteWorktreePath);
3098
+ child.on('error', (err) => {
3099
+ if (timer) clearTimeout(timer);
3100
+ if (abortHandler && abortSignal) {
3101
+ abortSignal.removeEventListener('abort', abortHandler);
3102
+ }
3103
+ reject(new Error(`Failed to execute git worktree command: ${err.message}`));
3104
+ });
3203
3105
  });
3204
3106
 
3205
- child.on('error', (err) => {
3206
- if (timer) clearTimeout(timer);
3207
- if (abortHandler && abortSignal) {
3208
- abortSignal.removeEventListener('abort', abortHandler);
3209
- }
3210
- reject(new Error(`Failed to execute git worktree command: ${err.message}`));
3211
- });
3212
- });
3213
- this.captureStepTerminalEvidence(
3214
- step.name,
3215
- {
3216
- stdout: commandStdout || output,
3217
- stderr: commandStderr,
3218
- combined: [commandStdout || output, commandStderr].filter(Boolean).join('\n'),
3219
- },
3220
- { exitCode: commandExitCode, exitSignal: commandExitSignal }
3221
- );
3107
+ this.captureStepTerminalEvidence(
3108
+ step.name,
3109
+ {
3110
+ stdout: commandStdout || output,
3111
+ stderr: commandStderr,
3112
+ combined: [commandStdout || output, commandStderr].filter(Boolean).join('\n'),
3113
+ },
3114
+ { exitCode: lastExitCode, exitSignal: lastExitSignal }
3115
+ );
3222
3116
 
3223
- // Mark completed
3224
- state.row.status = 'completed';
3225
- state.row.output = output;
3226
- state.row.completedAt = new Date().toISOString();
3227
- await this.db.updateStep(state.row.id, {
3117
+ worktreeBranch = branch;
3118
+ createdBranch = !branchExists && createBranch;
3119
+ return { output };
3120
+ },
3121
+ toCompletionResult: ({ output }, attempt) => ({
3228
3122
  status: 'completed',
3229
3123
  output,
3230
- completedAt: state.row.completedAt,
3231
- updatedAt: new Date().toISOString(),
3232
- });
3233
-
3234
- // Persist step output
3235
- await this.persistStepOutput(runId, step.name, output);
3236
-
3237
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
3238
- this.postToChannel(
3239
- `**[${step.name}]** Worktree created at: ${output}\n Branch: ${branch}${!branchExists && createBranch ? ' (created)' : ''}`
3240
- );
3241
- this.recordStepToolSideEffect(step.name, {
3242
- type: 'worktree_created',
3243
- detail: `Worktree created at ${output}`,
3244
- raw: { branch, createdBranch: !branchExists && createBranch },
3245
- });
3246
- this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt);
3247
- } catch (err) {
3248
- const errorMsg = err instanceof Error ? err.message : String(err);
3249
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
3250
- await this.markStepFailed(state, errorMsg, runId, {
3124
+ retries: attempt,
3251
3125
  exitCode: lastExitCode,
3252
3126
  exitSignal: lastExitSignal,
3253
- });
3254
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3127
+ }),
3128
+ getFailureResult: (error) => ({
3129
+ status: 'failed',
3130
+ output: '',
3131
+ error: error instanceof Error ? error.message : String(error),
3132
+ retries: state.row.retryCount,
3133
+ exitCode: lastExitCode,
3134
+ exitSignal: lastExitSignal,
3135
+ }),
3136
+ });
3137
+
3138
+ if (result.status === 'failed') {
3139
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
3140
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
3255
3141
  }
3142
+
3143
+ this.postToChannel(
3144
+ `**[${step.name}]** Worktree created at: ${result.output}\n Branch: ${worktreeBranch}${createdBranch ? ' (created)' : ''}`
3145
+ );
3146
+ this.recordStepToolSideEffect(step.name, {
3147
+ type: 'worktree_created',
3148
+ detail: `Worktree created at ${result.output}`,
3149
+ raw: { branch: worktreeBranch, createdBranch },
3150
+ });
3256
3151
  }
3257
3152
 
3258
3153
  /**
@@ -3260,69 +3155,56 @@ export class WorkflowRunner {
3260
3155
  */
3261
3156
  private async executeIntegrationStep(
3262
3157
  step: WorkflowStep,
3158
+ state: StepState,
3263
3159
  stepStates: Map<string, StepState>,
3264
- runId: string
3160
+ runId: string,
3161
+ lifecycle: WorkflowStepLifecycleExecutor<StepState>
3265
3162
  ): Promise<void> {
3266
- const state = stepStates.get(step.name);
3267
- if (!state) throw new Error(`Step state not found: ${step.name}`);
3268
-
3269
- this.checkAborted();
3270
-
3271
- // Mark step as running
3272
- state.row.status = 'running';
3273
- state.row.error = undefined;
3274
- state.row.completionReason = undefined;
3275
- state.row.startedAt = new Date().toISOString();
3276
- await this.db.updateStep(state.row.id, {
3277
- status: 'running',
3278
- error: undefined,
3279
- completionReason: undefined,
3280
- startedAt: state.row.startedAt,
3281
- updatedAt: new Date().toISOString(),
3282
- });
3283
- this.emit({ type: 'step:started', runId, stepName: step.name });
3284
- this.postToChannel(`**[${step.name}]** Started (integration: ${step.integration}.${step.action})`);
3285
-
3286
- // Resolve {{steps.X.output}} in params
3287
- const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
3288
- const resolvedParams: Record<string, string> = {};
3289
- for (const [key, value] of Object.entries(step.params ?? {})) {
3290
- resolvedParams[key] = this.interpolateStepTask(value, stepOutputContext);
3291
- }
3163
+ const result = await lifecycle.monitorStep(step, state, {
3164
+ startMessage: `**[${step.name}]** Started (integration: ${step.integration}.${step.action})`,
3165
+ execute: async () => {
3166
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
3167
+ const resolvedParams: Record<string, string> = {};
3168
+ for (const [key, value] of Object.entries(step.params ?? {})) {
3169
+ resolvedParams[key] = this.interpolateStepTask(value, stepOutputContext);
3170
+ }
3292
3171
 
3293
- try {
3294
- if (!this.executor?.executeIntegrationStep) {
3295
- throw new Error(
3296
- `Integration steps require a cloud executor. Step "${step.name}" cannot run locally. ` +
3297
- `Use "cloud run" to execute workflows with integration steps.`
3298
- );
3299
- }
3172
+ if (!this.executor?.executeIntegrationStep) {
3173
+ throw new Error(
3174
+ `Integration steps require a cloud executor. Step "${step.name}" cannot run locally. ` +
3175
+ `Use "cloud run" to execute workflows with integration steps.`
3176
+ );
3177
+ }
3300
3178
 
3301
- const result = await this.executor.executeIntegrationStep(step, resolvedParams, { workspaceId: this.workspaceId });
3179
+ const integrationResult = await this.executor.executeIntegrationStep(step, resolvedParams, {
3180
+ workspaceId: this.workspaceId,
3181
+ });
3302
3182
 
3303
- if (!result.success) {
3304
- throw new Error(`Integration step "${step.name}" failed: ${result.output}`);
3305
- }
3183
+ if (!integrationResult.success) {
3184
+ throw new Error(`Integration step "${step.name}" failed: ${integrationResult.output}`);
3185
+ }
3306
3186
 
3307
- // Mark completed
3308
- state.row.status = 'completed';
3309
- state.row.output = result.output;
3310
- state.row.completedAt = new Date().toISOString();
3311
- await this.db.updateStep(state.row.id, {
3187
+ return { output: integrationResult.output };
3188
+ },
3189
+ toCompletionResult: ({ output }, attempt) => ({
3312
3190
  status: 'completed',
3313
- output: result.output,
3314
- completedAt: state.row.completedAt,
3315
- updatedAt: new Date().toISOString(),
3316
- });
3317
- await this.persistStepOutput(runId, step.name, result.output);
3318
- this.emit({ type: 'step:completed', runId, stepName: step.name, output: result.output });
3319
- this.postToChannel(`**[${step.name}]** Completed (integration: ${step.integration}.${step.action})`);
3320
- } catch (err) {
3321
- const errorMsg = err instanceof Error ? err.message : String(err);
3322
- this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
3323
- await this.markStepFailed(state, errorMsg, runId);
3324
- throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3191
+ output,
3192
+ retries: attempt,
3193
+ }),
3194
+ getFailureResult: (error) => ({
3195
+ status: 'failed',
3196
+ output: '',
3197
+ error: error instanceof Error ? error.message : String(error),
3198
+ retries: state.row.retryCount,
3199
+ }),
3200
+ });
3201
+
3202
+ if (result.status === 'failed') {
3203
+ this.postToChannel(`**[${step.name}]** Failed: ${result.error ?? 'Unknown error'}`);
3204
+ throw new Error(`Step "${step.name}" failed: ${result.error ?? 'Unknown error'}`);
3325
3205
  }
3206
+
3207
+ this.postToChannel(`**[${step.name}]** Completed (integration: ${step.integration}.${step.action})`);
3326
3208
  }
3327
3209
 
3328
3210
  /**
@@ -3367,7 +3249,11 @@ export class WorkflowRunner {
3367
3249
  const output = await executeApiStep(
3368
3250
  specialistDef.constraints?.model ?? 'claude-sonnet-4-20250514',
3369
3251
  resolvedTask,
3370
- { envSecrets: this.envSecrets, skills: specialistDef.skills, defaultMaxTokens: specialistDef.constraints?.maxTokens },
3252
+ {
3253
+ envSecrets: this.envSecrets,
3254
+ skills: specialistDef.skills,
3255
+ defaultMaxTokens: specialistDef.constraints?.maxTokens,
3256
+ }
3371
3257
  );
3372
3258
 
3373
3259
  state.row.status = 'completed';
@@ -3402,7 +3288,8 @@ export class WorkflowRunner {
3402
3288
  const usesOwnerFlow = specialistDef.interactive !== false;
3403
3289
  const currentPattern = this.currentConfig?.swarm?.pattern ?? '';
3404
3290
  const isHubPattern = WorkflowRunner.HUB_PATTERNS.has(currentPattern);
3405
- const usesAutoHardening = usesOwnerFlow && isHubPattern && !this.isExplicitInteractiveWorker(specialistDef);
3291
+ const usesAutoHardening =
3292
+ usesOwnerFlow && isHubPattern && !this.isExplicitInteractiveWorker(specialistDef);
3406
3293
  const ownerDef = usesAutoHardening ? this.resolveAutoStepOwner(specialistDef, agentMap) : specialistDef;
3407
3294
  // Reviewer resolution is deferred to just before the review gate runs (see below)
3408
3295
  // so that activeReviewers is up-to-date for concurrent steps.
@@ -3479,9 +3366,7 @@ export class WorkflowRunner {
3479
3366
  updatedAt: new Date().toISOString(),
3480
3367
  });
3481
3368
  this.emit({ type: 'step:started', runId, stepName: step.name });
3482
- this.log(
3483
- `[${step.name}] Started (owner: ${ownerDef.name}, specialist: ${specialistDef.name})`
3484
- );
3369
+ this.log(`[${step.name}] Started (owner: ${ownerDef.name}, specialist: ${specialistDef.name})`);
3485
3370
  this.initializeStepSignalParticipants(step.name, ownerDef.name, specialistDef.name);
3486
3371
  await this.trajectory?.stepStarted(step, ownerDef.name, {
3487
3372
  role: usesDedicatedOwner ? 'owner' : 'specialist',
@@ -3569,13 +3454,20 @@ export class WorkflowRunner {
3569
3454
  ownerElapsed = result.ownerElapsed;
3570
3455
  completionReason = result.completionReason;
3571
3456
  } else {
3572
- const ownerTask = this.injectStepOwnerContract(step, resolvedTask, effectiveOwner, effectiveSpecialist);
3457
+ const ownerTask = this.injectStepOwnerContract(
3458
+ step,
3459
+ resolvedTask,
3460
+ effectiveOwner,
3461
+ effectiveSpecialist
3462
+ );
3573
3463
  const explicitInteractiveWorker = this.isExplicitInteractiveWorker(effectiveOwner);
3574
3464
  let explicitWorkerHandle: Agent | undefined;
3575
3465
  let explicitWorkerCompleted = false;
3576
3466
  let explicitWorkerOutput = '';
3577
3467
 
3578
- this.log(`[${step.name}] Spawning owner "${effectiveOwner.name}" (cli: ${effectiveOwner.cli})${step.workdir ? ` [workdir: ${step.workdir}]` : ''}`);
3468
+ this.log(
3469
+ `[${step.name}] Spawning owner "${effectiveOwner.name}" (cli: ${effectiveOwner.cli})${step.workdir ? ` [workdir: ${step.workdir}]` : ''}`
3470
+ );
3579
3471
  const resolvedStep = { ...step, task: ownerTask };
3580
3472
  const ownerStartTime = Date.now();
3581
3473
  const spawnResult = this.executor
@@ -3583,7 +3475,7 @@ export class WorkflowRunner {
3583
3475
  : await this.spawnAndWait(effectiveOwner, resolvedStep, timeoutMs, {
3584
3476
  evidenceStepName: step.name,
3585
3477
  evidenceRole: usesOwnerFlow ? 'owner' : 'specialist',
3586
- preserveOnIdle: (!isHubPattern || !this.isLeadLikeAgent(effectiveOwner)) ? false : undefined,
3478
+ preserveOnIdle: !isHubPattern || !this.isLeadLikeAgent(effectiveOwner) ? false : undefined,
3587
3479
  logicalName: effectiveOwner.name,
3588
3480
  onSpawned: explicitInteractiveWorker
3589
3481
  ? ({ agent }) => {
@@ -3614,7 +3506,7 @@ export class WorkflowRunner {
3614
3506
  ? effectiveOwner.interactive === false
3615
3507
  ? undefined
3616
3508
  : ownerTask
3617
- : spawnResult.promptTaskText ?? ownerTask;
3509
+ : (spawnResult.promptTaskText ?? ownerTask);
3618
3510
  lastExitCode = typeof spawnResult === 'string' ? undefined : spawnResult.exitCode;
3619
3511
  lastExitSignal = typeof spawnResult === 'string' ? undefined : spawnResult.exitSignal;
3620
3512
  ownerElapsed = Date.now() - ownerStartTime;
@@ -3745,19 +3637,20 @@ export class WorkflowRunner {
3745
3637
  // Persist step output to disk so it survives restarts and is inspectable
3746
3638
  await this.persistStepOutput(runId, step.name, combinedOutput);
3747
3639
 
3748
- this.emit({ type: 'step:completed', runId, stepName: step.name, output: combinedOutput, exitCode: lastExitCode, exitSignal: lastExitSignal });
3749
- this.finalizeStepEvidence(
3750
- step.name,
3751
- 'completed',
3752
- state.row.completedAt,
3753
- completionReason
3754
- );
3640
+ this.emit({
3641
+ type: 'step:completed',
3642
+ runId,
3643
+ stepName: step.name,
3644
+ output: combinedOutput,
3645
+ exitCode: lastExitCode,
3646
+ exitSignal: lastExitSignal,
3647
+ });
3648
+ this.finalizeStepEvidence(step.name, 'completed', state.row.completedAt, completionReason);
3755
3649
  await this.trajectory?.stepCompleted(step, combinedOutput, attempt + 1);
3756
3650
  return;
3757
3651
  } catch (err) {
3758
3652
  lastError = err instanceof Error ? err.message : String(err);
3759
- lastCompletionReason =
3760
- err instanceof WorkflowCompletionError ? err.completionReason : undefined;
3653
+ lastCompletionReason = err instanceof WorkflowCompletionError ? err.completionReason : undefined;
3761
3654
  if (lastCompletionReason === 'retry_requested_by_owner' && attempt >= maxRetries) {
3762
3655
  lastError = this.buildOwnerRetryBudgetExceededMessage(step.name, maxRetries, lastError);
3763
3656
  }
@@ -3795,10 +3688,16 @@ export class WorkflowRunner {
3795
3688
  verificationValue,
3796
3689
  });
3797
3690
  this.postToChannel(`**[${step.name}]** Failed: ${lastError ?? 'Unknown error'}`);
3798
- await this.markStepFailed(state, lastError ?? 'Unknown error', runId, {
3799
- exitCode: lastExitCode,
3800
- exitSignal: lastExitSignal,
3801
- }, lastCompletionReason);
3691
+ await this.markStepFailed(
3692
+ state,
3693
+ lastError ?? 'Unknown error',
3694
+ runId,
3695
+ {
3696
+ exitCode: lastExitCode,
3697
+ exitSignal: lastExitSignal,
3698
+ },
3699
+ lastCompletionReason
3700
+ );
3802
3701
  throw new Error(
3803
3702
  `Step "${step.name}" failed after ${maxRetries} retries: ${lastError ?? 'Unknown error'}`
3804
3703
  );
@@ -3814,9 +3713,7 @@ export class WorkflowRunner {
3814
3713
  const normalizedDecision = ownerDecisionError?.startsWith(prefix)
3815
3714
  ? ownerDecisionError.slice(prefix.length).trim()
3816
3715
  : ownerDecisionError?.trim();
3817
- const decisionSuffix = normalizedDecision
3818
- ? ` Latest owner decision: ${normalizedDecision}`
3819
- : '';
3716
+ const decisionSuffix = normalizedDecision ? ` Latest owner decision: ${normalizedDecision}` : '';
3820
3717
 
3821
3718
  if (maxRetries === 0) {
3822
3719
  return (
@@ -4025,13 +3922,7 @@ export class WorkflowRunner {
4025
3922
  }
4026
3923
  },
4027
3924
  onChunk: ({ agentName, chunk }) => {
4028
- this.forwardAgentChunkToChannel(
4029
- step.name,
4030
- 'Worker',
4031
- agentName,
4032
- chunk,
4033
- supervised.specialist.name
4034
- );
3925
+ this.forwardAgentChunkToChannel(step.name, 'Worker', agentName, chunk, supervised.specialist.name);
4035
3926
  },
4036
3927
  }).catch((error) => {
4037
3928
  if (!workerSpawned) {
@@ -4053,11 +3944,7 @@ export class WorkflowRunner {
4053
3944
  });
4054
3945
  if (
4055
3946
  step.verification?.type === 'output_contains' &&
4056
- this.outputContainsVerificationToken(
4057
- result.output,
4058
- step.verification.value,
4059
- result.promptTaskText
4060
- )
3947
+ this.outputContainsVerificationToken(result.output, step.verification.value, result.promptTaskText)
4061
3948
  ) {
4062
3949
  this.log(
4063
3950
  `[${step.name}] Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`
@@ -4340,9 +4227,7 @@ export class WorkflowRunner {
4340
4227
  const evidenceReason = this.judgeOwnerCompletionByEvidence(step.name, ownerOutput);
4341
4228
  if (evidenceReason) {
4342
4229
  if (!hasMarker) {
4343
- this.log(
4344
- `[${step.name}] Evidence-based completion resolved without legacy STEP_COMPLETE marker`
4345
- );
4230
+ this.log(`[${step.name}] Evidence-based completion resolved without legacy STEP_COMPLETE marker`);
4346
4231
  }
4347
4232
  return {
4348
4233
  completionReason: 'completed_by_evidence',
@@ -4354,7 +4239,12 @@ export class WorkflowRunner {
4354
4239
  // Process-exit fallback: if the agent exited cleanly (code 0) and verification
4355
4240
  // passes (or no verification is configured), infer completion rather than failing.
4356
4241
  // This reduces dependence on agents posting exact coordination signals.
4357
- const processExitFallback = this.tryProcessExitFallback(step, specialistOutput, verificationTaskText, ownerOutput);
4242
+ const processExitFallback = this.tryProcessExitFallback(
4243
+ step,
4244
+ specialistOutput,
4245
+ verificationTaskText,
4246
+ ownerOutput
4247
+ );
4358
4248
  if (processExitFallback) {
4359
4249
  this.log(
4360
4250
  `[${step.name}] Completion inferred from clean process exit (code 0)` +
@@ -4384,13 +4274,9 @@ export class WorkflowRunner {
4384
4274
  }
4385
4275
  }
4386
4276
 
4387
- private hasOwnerCompletionMarker(
4388
- step: WorkflowStep,
4389
- output: string,
4390
- injectedTaskText: string
4391
- ): boolean {
4277
+ private hasOwnerCompletionMarker(step: WorkflowStep, output: string, injectedTaskText: string): boolean {
4392
4278
  const marker = `STEP_COMPLETE:${step.name}`;
4393
- const strippedOutput = this.stripInjectedTaskEcho(output, injectedTaskText);
4279
+ const strippedOutput = stripInjectedTaskEcho(output, injectedTaskText);
4394
4280
  if (strippedOutput.includes(marker)) {
4395
4281
  return true;
4396
4282
  }
@@ -4483,36 +4369,11 @@ export class WorkflowRunner {
4483
4369
  .join('\n');
4484
4370
  }
4485
4371
 
4486
- private stripInjectedTaskEcho(output: string, injectedTaskText?: string): string {
4487
- if (!injectedTaskText) {
4488
- return output;
4489
- }
4490
-
4491
- const candidates = [
4492
- injectedTaskText,
4493
- injectedTaskText.replace(/\r\n/g, '\n'),
4494
- injectedTaskText.replace(/\n/g, '\r\n'),
4495
- ].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
4496
-
4497
- for (const candidate of candidates) {
4498
- const start = output.indexOf(candidate);
4499
- if (start !== -1) {
4500
- return output.slice(0, start) + output.slice(start + candidate.length);
4501
- }
4502
- }
4503
-
4504
- return output;
4505
- }
4506
-
4507
- private outputContainsVerificationToken(
4508
- output: string,
4509
- token: string,
4510
- injectedTaskText?: string
4511
- ): boolean {
4372
+ private outputContainsVerificationToken(output: string, token: string, injectedTaskText?: string): boolean {
4512
4373
  if (!token) {
4513
4374
  return false;
4514
4375
  }
4515
- return this.stripInjectedTaskEcho(output, injectedTaskText).includes(token);
4376
+ return stripInjectedTaskEcho(output, injectedTaskText).includes(token);
4516
4377
  }
4517
4378
 
4518
4379
  private prepareInteractiveSpawnTask(
@@ -4565,13 +4426,9 @@ export class WorkflowRunner {
4565
4426
  if (!sanitized) return null;
4566
4427
 
4567
4428
  const hasExplicitSelfRelease =
4568
- /Calling\s+(?:[\w.-]+\.)?remove_agent\(\{[^<\n]*"reason":"task completed"/i.test(
4569
- sanitized
4570
- );
4429
+ /Calling\s+(?:[\w.-]+\.)?remove_agent\(\{[^<\n]*"reason":"task completed"/i.test(sanitized);
4571
4430
  const hasPositiveConclusion =
4572
- /\b(complete(?:d)?|done|verified|looks correct|safe handoff|artifact verified)\b/i.test(
4573
- sanitized
4574
- ) ||
4431
+ /\b(complete(?:d)?|done|verified|looks correct|safe handoff|artifact verified)\b/i.test(sanitized) ||
4575
4432
  /\bartifacts?\b.*\b(correct|verified|complete)\b/i.test(sanitized) ||
4576
4433
  hasExplicitSelfRelease;
4577
4434
  const evidence = this.getStepCompletionEvidence(stepName);
@@ -4616,15 +4473,18 @@ export class WorkflowRunner {
4616
4473
  if (gracePeriodMs === 0) return null;
4617
4474
 
4618
4475
  // Never infer completion when the owner explicitly requested retry/fail/clarification.
4619
- if (ownerOutput && /OWNER_DECISION:\s*(?:INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATION)\b/i.test(ownerOutput)) {
4476
+ if (
4477
+ ownerOutput &&
4478
+ /OWNER_DECISION:\s*(?:INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATION)\b/i.test(ownerOutput)
4479
+ ) {
4620
4480
  return null;
4621
4481
  }
4622
4482
 
4623
4483
  const evidence = this.getStepCompletionEvidence(step.name);
4624
- const hasCleanExit = evidence?.coordinationSignals.some(
4625
- (signal) =>
4626
- signal.kind === 'process_exit' && signal.value === '0'
4627
- ) ?? false;
4484
+ const hasCleanExit =
4485
+ evidence?.coordinationSignals.some(
4486
+ (signal) => signal.kind === 'process_exit' && signal.value === '0'
4487
+ ) ?? false;
4628
4488
 
4629
4489
  if (!hasCleanExit) return null;
4630
4490
 
@@ -4744,9 +4604,7 @@ export class WorkflowRunner {
4744
4604
  let reviewerHandle: Agent | undefined;
4745
4605
  let reviewerReleased = false;
4746
4606
  let reviewOutput = '';
4747
- let completedReview:
4748
- | { decision: 'approved' | 'rejected'; reason?: string }
4749
- | undefined;
4607
+ let completedReview: { decision: 'approved' | 'rejected'; reason?: string } | undefined;
4750
4608
  let reviewCompletionPromise: Promise<void> | undefined;
4751
4609
  const reviewCompletionStarted = { value: false };
4752
4610
 
@@ -4785,9 +4643,7 @@ export class WorkflowRunner {
4785
4643
  const message = error instanceof Error ? error.message : String(error);
4786
4644
  if (/\btimed out\b/i.test(message)) {
4787
4645
  this.log(`[${step.name}] Review safety backstop timeout fired after ${safetyTimeoutMs}ms`);
4788
- throw new Error(
4789
- `Step "${step.name}" review safety backstop timed out after ${safetyTimeoutMs}ms`
4790
- );
4646
+ throw new Error(`Step "${step.name}" review safety backstop timed out after ${safetyTimeoutMs}ms`);
4791
4647
  }
4792
4648
  throw error;
4793
4649
  }
@@ -4995,17 +4851,10 @@ export class WorkflowRunner {
4995
4851
  task: string,
4996
4852
  extraArgs: string[] = []
4997
4853
  ): { cmd: string; args: string[] } {
4998
- if (cli === 'api') {
4999
- throw new Error('cli "api" uses direct API calls, not a subprocess command');
5000
- }
5001
- const resolvedCli: AgentCli = cli === 'cursor' ? resolveCursorCli() : cli;
5002
- const def = getCliDefinition(resolvedCli);
5003
- if (!def || def.binaries.length === 0) {
5004
- throw new Error(`Unknown or non-executable CLI: ${resolvedCli}`);
5005
- }
4854
+ const [cmd, ...args] = buildProcessCommand(cli, extraArgs, task);
5006
4855
  return {
5007
- cmd: def.binaries[0],
5008
- args: def.nonInteractiveArgs(task, extraArgs),
4856
+ cmd,
4857
+ args,
5009
4858
  };
5010
4859
  }
5011
4860
 
@@ -5130,11 +4979,15 @@ export class WorkflowRunner {
5130
4979
  const stderrChunks: string[] = [];
5131
4980
 
5132
4981
  try {
5133
- const { stdout: output, exitCode, exitSignal } = await new Promise<{ stdout: string; exitCode?: number; exitSignal?: string }>((resolve, reject) => {
5134
- const child = cpSpawn(cmd, args, {
4982
+ const {
4983
+ stdout: output,
4984
+ exitCode,
4985
+ exitSignal,
4986
+ } = await new Promise<{ stdout: string; exitCode?: number; exitSignal?: string }>((resolve, reject) => {
4987
+ const child = spawnProcess([cmd, ...args], {
5135
4988
  stdio: ['ignore', 'pipe', 'pipe'],
5136
4989
  cwd: this.resolveEffectiveCwd(step, agentDef),
5137
- env: this.getRelayEnv() ?? { ...process.env },
4990
+ env: this.getRelayEnv() ?? filteredEnv(),
5138
4991
  });
5139
4992
 
5140
4993
  // Update workers.json with PID now that we have it
@@ -5250,14 +5103,11 @@ export class WorkflowRunner {
5250
5103
  const stderr = stderrChunks.join('');
5251
5104
  const combinedOutput = stdout + stderr;
5252
5105
  this.lastFailedStepOutput.set(step.name, combinedOutput);
5253
- this.captureStepTerminalEvidence(
5254
- step.name,
5255
- {
5256
- stdout,
5257
- stderr,
5258
- combined: combinedOutput,
5259
- }
5260
- );
5106
+ this.captureStepTerminalEvidence(step.name, {
5107
+ stdout,
5108
+ stderr,
5109
+ combined: combinedOutput,
5110
+ });
5261
5111
  stopHeartbeat?.();
5262
5112
  logStream.end();
5263
5113
  this.unregisterWorker(agentName);
@@ -5485,18 +5335,16 @@ export class WorkflowRunner {
5485
5335
  { allowFailure: true }
5486
5336
  );
5487
5337
  if (verificationResult.passed) {
5488
- this.log(
5489
- `[${step.name}] Agent timed out but verification passed — treating as complete`
5490
- );
5338
+ this.log(`[${step.name}] Agent timed out but verification passed — treating as complete`);
5491
5339
  this.postToChannel(
5492
5340
  `**[${step.name}]** Agent idle after completing work — verification passed, releasing`
5493
5341
  );
5494
- await agent.release();
5342
+ await agent.release().catch(() => undefined);
5495
5343
  timeoutRecovered = true;
5496
5344
  }
5497
5345
  }
5498
5346
  if (!timeoutRecovered) {
5499
- await agent.release();
5347
+ await agent.release().catch(() => undefined);
5500
5348
  throw new Error(`Step "${step.name}" timed out after ${timeoutMs ?? 'unknown'}ms`);
5501
5349
  }
5502
5350
  }
@@ -5614,8 +5462,7 @@ export class WorkflowRunner {
5614
5462
  const nameLC = agentDef.name.toLowerCase();
5615
5463
  return [...WorkflowRunner.HUB_ROLES].some(
5616
5464
  (hubRole) =>
5617
- new RegExp(`\\b${hubRole}\\b`, 'i').test(nameLC) ||
5618
- new RegExp(`\\b${hubRole}\\b`, 'i').test(role)
5465
+ new RegExp(`\\b${hubRole}\\b`, 'i').test(nameLC) || new RegExp(`\\b${hubRole}\\b`, 'i').test(role)
5619
5466
  );
5620
5467
  }
5621
5468
 
@@ -5676,18 +5523,16 @@ export class WorkflowRunner {
5676
5523
  if (step.verification && step.verification.type === 'output_contains') {
5677
5524
  const token = step.verification.value;
5678
5525
  const ptyOutput = (this.ptyOutputBuffers.get(agent.name) ?? []).join('');
5679
- const verificationPassed = this.outputContainsVerificationToken(
5680
- ptyOutput,
5681
- token,
5682
- promptTaskText
5683
- );
5526
+ const verificationPassed = this.outputContainsVerificationToken(ptyOutput, token, promptTaskText);
5684
5527
  if (!verificationPassed) {
5685
5528
  // The broker fires agent_idle only once per idle transition.
5686
5529
  // If the agent is still working (will produce output then idle again),
5687
5530
  // continuing the loop works. But if the agent is permanently idle,
5688
5531
  // waitForIdle won't resolve again. Wait briefly for new output,
5689
5532
  // then release and let upstream verification handle the result.
5690
- this.log(`[${step.name}] Agent "${agent.name}" went idle but verification not yet passed — waiting for more output`);
5533
+ this.log(
5534
+ `[${step.name}] Agent "${agent.name}" went idle but verification not yet passed — waiting for more output`
5535
+ );
5691
5536
  const idleGraceSecs = 15;
5692
5537
  const graceResult = await Promise.race([
5693
5538
  agent.waitForExit(idleGraceSecs * 1000).then((r) => ({ kind: 'exit' as const, result: r })),
@@ -5702,15 +5547,19 @@ export class WorkflowRunner {
5702
5547
  }
5703
5548
  // Grace period timed out — agent is permanently idle without verification.
5704
5549
  // Release and let upstream executeAgentStep handle verification.
5705
- this.log(`[${step.name}] Agent "${agent.name}" still idle after ${idleGraceSecs}s grace — releasing`);
5706
- this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` idle — releasing (verification pending)`);
5707
- await agent.release();
5550
+ this.log(
5551
+ `[${step.name}] Agent "${agent.name}" still idle after ${idleGraceSecs}s grace — releasing`
5552
+ );
5553
+ this.postToChannel(
5554
+ `**[${step.name}]** Agent \`${agent.name}\` idle — releasing (verification pending)`
5555
+ );
5556
+ await agent.release().catch(() => undefined);
5708
5557
  return 'released';
5709
5558
  }
5710
5559
  }
5711
5560
  this.log(`[${step.name}] Agent "${agent.name}" went idle — treating as complete`);
5712
5561
  this.postToChannel(`**[${step.name}]** Agent \`${agent.name}\` idle — treating as complete`);
5713
- await agent.release();
5562
+ await agent.release().catch(() => undefined);
5714
5563
  return 'released';
5715
5564
  }
5716
5565
  // Exit won the race, or idle returned 'exited'/'timeout' — pass through.
@@ -5783,7 +5632,7 @@ export class WorkflowRunner {
5783
5632
  `**[${step.name}]** Agent \`${agent.name}\` still idle after ${nudgeCount} nudge(s) — force-releasing`
5784
5633
  );
5785
5634
  this.emit({ type: 'step:force-released', runId: this.currentRunId ?? '', stepName: step.name });
5786
- await agent.release();
5635
+ await agent.release().catch(() => undefined);
5787
5636
  return 'force-released';
5788
5637
  }
5789
5638
  }
@@ -5865,84 +5714,18 @@ export class WorkflowRunner {
5865
5714
  injectedTaskText?: string,
5866
5715
  options?: VerificationOptions
5867
5716
  ): VerificationResult {
5868
- const fail = (message: string): VerificationResult => {
5869
- const observedAt = new Date().toISOString();
5870
- this.recordStepToolSideEffect(stepName, {
5871
- type: 'verification_observed',
5872
- detail: message,
5873
- observedAt,
5874
- raw: { passed: false, type: check.type, value: check.value },
5875
- });
5876
- this.getOrCreateStepEvidenceRecord(stepName).evidence.coordinationSignals.push({
5877
- kind: 'verification_failed',
5878
- source: 'verification',
5879
- text: message,
5880
- observedAt,
5881
- value: check.value,
5882
- });
5883
- if (options?.allowFailure) {
5884
- return {
5885
- passed: false,
5886
- completionReason: 'failed_verification',
5887
- error: message,
5888
- };
5889
- }
5890
- throw new WorkflowCompletionError(message, 'failed_verification');
5891
- };
5892
-
5893
- switch (check.type) {
5894
- case 'output_contains': {
5895
- const token = check.value;
5896
- if (!this.outputContainsVerificationToken(output, token, injectedTaskText)) {
5897
- return fail(`Verification failed for "${stepName}": output does not contain "${token}"`);
5898
- }
5899
- break;
5717
+ return runVerification(
5718
+ check,
5719
+ output,
5720
+ stepName,
5721
+ injectedTaskText,
5722
+ { ...options, cwd: this.cwd },
5723
+ {
5724
+ recordStepToolSideEffect: (name, effect) => this.recordStepToolSideEffect(name, effect),
5725
+ getOrCreateStepEvidenceRecord: (name) => this.getOrCreateStepEvidenceRecord(name),
5726
+ log: (message) => this.log(message),
5900
5727
  }
5901
-
5902
- case 'exit_code':
5903
- // exit_code verification is implicitly satisfied if the agent exited successfully
5904
- break;
5905
-
5906
- case 'file_exists':
5907
- if (!existsSync(path.resolve(this.cwd, check.value))) {
5908
- return fail(`Verification failed for "${stepName}": file "${check.value}" does not exist`);
5909
- }
5910
- break;
5911
-
5912
- case 'custom':
5913
- // Custom verifications are evaluated by callers; no-op here
5914
- return { passed: false };
5915
- }
5916
-
5917
- if (options?.completionMarkerFound === false) {
5918
- this.log(
5919
- `[${stepName}] Verification passed without legacy STEP_COMPLETE marker; allowing completion`
5920
- );
5921
- }
5922
-
5923
- const successMessage =
5924
- options?.completionMarkerFound === false
5925
- ? `Verification passed without legacy STEP_COMPLETE marker`
5926
- : `Verification passed`;
5927
- const observedAt = new Date().toISOString();
5928
- this.recordStepToolSideEffect(stepName, {
5929
- type: 'verification_observed',
5930
- detail: successMessage,
5931
- observedAt,
5932
- raw: { passed: true, type: check.type, value: check.value },
5933
- });
5934
- this.getOrCreateStepEvidenceRecord(stepName).evidence.coordinationSignals.push({
5935
- kind: 'verification_passed',
5936
- source: 'verification',
5937
- text: successMessage,
5938
- observedAt,
5939
- value: check.value,
5940
- });
5941
-
5942
- return {
5943
- passed: true,
5944
- completionReason: 'completed_verified',
5945
- };
5728
+ );
5946
5729
  }
5947
5730
 
5948
5731
  // ── State helpers ─────────────────────────────────────────────────────
@@ -6116,30 +5899,7 @@ export class WorkflowRunner {
6116
5899
  agentMap: Map<string, AgentDefinition>,
6117
5900
  stepStates: Map<string, StepState>
6118
5901
  ): string | undefined {
6119
- const nonInteractive = [...agentMap.values()].filter((a) => a.interactive === false);
6120
- if (nonInteractive.length === 0) return undefined;
6121
-
6122
- // Map agent names to their step names so the lead knows exact {{steps.X.output}} references
6123
- const agentToSteps = new Map<string, string[]>();
6124
- for (const [stepName, state] of stepStates) {
6125
- const agentName = state.row.agentName;
6126
- if (!agentName) continue; // Skip deterministic steps
6127
- if (!agentToSteps.has(agentName)) agentToSteps.set(agentName, []);
6128
- agentToSteps.get(agentName)!.push(stepName);
6129
- }
6130
-
6131
- const lines = nonInteractive.map((a) => {
6132
- const steps = agentToSteps.get(a.name) ?? [];
6133
- const stepRefs = steps.map((s) => `{{steps.${s}.output}}`).join(', ');
6134
- return `- ${a.name} (${a.cli}) — will return output when complete${stepRefs ? `. Access via: ${stepRefs}` : ''}`;
6135
- });
6136
- return (
6137
- '\n\n---\n' +
6138
- 'Note: The following agents are non-interactive workers and cannot receive messages:\n' +
6139
- lines.join('\n') +
6140
- '\n' +
6141
- 'Do NOT attempt to message these agents. Use the {{steps.<name>.output}} references above to access their results.'
6142
- );
5902
+ return this.channelMessenger.buildNonInteractiveAwareness(agentMap, stepStates);
6143
5903
  }
6144
5904
 
6145
5905
  /**
@@ -6155,53 +5915,11 @@ export class WorkflowRunner {
6155
5915
  * key, but they won't call `register` unless explicitly told to.
6156
5916
  */
6157
5917
  private buildRelayRegistrationNote(cli: string, agentName: string): string {
6158
- if (cli === 'claude') return '';
6159
- return (
6160
- '---\n' +
6161
- 'RELAY SETUP — do this FIRST before any other relay tool:\n' +
6162
- `1. Call: register(name="${agentName}")\n` +
6163
- ' This authenticates you in the Relaycast workspace.\n' +
6164
- ' ALL relay tools (mcp__relaycast__message_dm_send, mcp__relaycast__message_inbox_check, mcp__relaycast__message_post, etc.) require\n' +
6165
- ' registration first — they will fail with "Not registered" otherwise.\n' +
6166
- `2. Your agent name is "${agentName}" — use this exact name when registering.`
6167
- );
5918
+ return this.channelMessenger.buildRelayRegistrationNote(cli, agentName);
6168
5919
  }
6169
5920
 
6170
5921
  private buildDelegationGuidance(cli: string, timeoutMs?: number): string {
6171
- const timeoutNote = timeoutMs
6172
- ? `You have approximately ${Math.round(timeoutMs / 60000)} minutes before this step times out. ` +
6173
- 'Plan accordingly — delegate early if the work is substantial.\n\n'
6174
- : '';
6175
-
6176
- // Option 2 (sub-agents via Task tool) is only available in Claude
6177
- const subAgentOption =
6178
- cli === 'claude'
6179
- ? 'Option 2 — Use built-in sub-agents (Task tool) for research or scoped work:\n' +
6180
- ' - Good for exploring code, reading files, or making targeted changes\n' +
6181
- ' - Can run multiple sub-agents in parallel\n\n'
6182
- : '';
6183
-
6184
- return (
6185
- '---\n' +
6186
- 'AUTONOMOUS DELEGATION — READ THIS BEFORE STARTING:\n' +
6187
- timeoutNote +
6188
- 'Before diving in, assess whether this task is too large or complex for a single agent. ' +
6189
- 'If it involves multiple independent subtasks, touches many files, or could take a long time, ' +
6190
- 'you should break it down and delegate to helper agents to avoid timeouts.\n\n' +
6191
- 'Option 1 — Spawn relay agents (for real parallel coding work):\n' +
6192
- ' - mcp__relaycast__agent_add(name="helper-1", cli="claude", task="Specific subtask description")\n' +
6193
- ' - Coordinate via mcp__relaycast__message_dm_send(to="helper-1", text="...")\n' +
6194
- ' - Check on them with mcp__relaycast__message_inbox_check()\n' +
6195
- ' - Clean up when done: mcp__relaycast__agent_remove(name="helper-1")\n\n' +
6196
- subAgentOption +
6197
- 'Guidelines:\n' +
6198
- '- You are the lead — delegate but stay in control, track progress, integrate results\n' +
6199
- '- Give each helper a clear, self-contained task with enough context to work independently\n' +
6200
- "- For simple or quick work, just do it yourself — don't over-delegate\n" +
6201
- '- Always release spawned relay agents when their work is complete\n' +
6202
- '- When spawning non-claude agents (codex, gemini, etc.), prepend to their task:\n' +
6203
- ' "RELAY SETUP: First call register(name=\'<exact-agent-name>\') before any other relay tool."'
6204
- );
5922
+ return this.channelMessenger.buildDelegationGuidance(cli, timeoutMs);
6205
5923
  }
6206
5924
 
6207
5925
  /** Post a message to the workflow channel. Fire-and-forget — never throws or blocks. */
@@ -6237,52 +5955,12 @@ export class WorkflowRunner {
6237
5955
  summary: string,
6238
5956
  confidence: number
6239
5957
  ): void {
6240
- const completed = outcomes.filter((o) => o.status === 'completed');
6241
- const skipped = outcomes.filter((o) => o.status === 'skipped');
6242
- const retried = outcomes.filter((o) => o.attempts > 1);
6243
-
6244
- const lines: string[] = [
6245
- `## Workflow **${workflowName}** — Complete`,
6246
- '',
6247
- summary,
6248
- `Confidence: ${Math.round(confidence * 100)}%`,
6249
- '',
6250
- '### Steps',
6251
- ...completed.map(
6252
- (o) =>
6253
- `- **${o.name}** (${o.agent}) — passed${o.verificationPassed ? ' (verified)' : ''}${o.attempts > 1 ? ` after ${o.attempts} attempts` : ''}`
6254
- ),
6255
- ...skipped.map((o) => `- **${o.name}** — skipped`),
6256
- ];
6257
-
6258
- if (retried.length > 0) {
6259
- lines.push('', '### Retries');
6260
- for (const o of retried) {
6261
- lines.push(`- ${o.name}: ${o.attempts} attempts`);
6262
- }
6263
- }
6264
-
6265
- this.postToChannel(lines.join('\n'));
5958
+ this.channelMessenger.postCompletionReport(workflowName, outcomes, summary, confidence);
6266
5959
  }
6267
5960
 
6268
5961
  /** Post a failure report to the channel. */
6269
5962
  private postFailureReport(workflowName: string, outcomes: StepOutcome[], errorMsg: string): void {
6270
- const completed = outcomes.filter((o) => o.status === 'completed');
6271
- const failed = outcomes.filter((o) => o.status === 'failed');
6272
- const skipped = outcomes.filter((o) => o.status === 'skipped');
6273
-
6274
- const lines: string[] = [
6275
- `## Workflow **${workflowName}** — Failed`,
6276
- '',
6277
- `${completed.length}/${outcomes.length} steps passed. Error: ${errorMsg}`,
6278
- '',
6279
- '### Steps',
6280
- ...completed.map((o) => `- **${o.name}** (${o.agent}) — passed`),
6281
- ...failed.map((o) => `- **${o.name}** (${o.agent}) — FAILED: ${o.error ?? 'unknown'}`),
6282
- ...skipped.map((o) => `- **${o.name}** — skipped`),
6283
- ];
6284
-
6285
- this.postToChannel(lines.join('\n'));
5963
+ this.channelMessenger.postFailureReport(workflowName, outcomes, errorMsg);
6286
5964
  }
6287
5965
 
6288
5966
  /**
@@ -6296,7 +5974,9 @@ export class WorkflowRunner {
6296
5974
 
6297
5975
  console.log('');
6298
5976
  console.log(chalk.dim('━'.repeat(70)));
6299
- console.log(` Workflow "${workflowName}" — ${failed.length === 0 ? chalk.green('COMPLETED') : chalk.red('FAILED')}`);
5977
+ console.log(
5978
+ ` Workflow "${workflowName}" — ${failed.length === 0 ? chalk.green('COMPLETED') : chalk.red('FAILED')}`
5979
+ );
6300
5980
  console.log(
6301
5981
  ` ${chalk.green(`${completed.length} passed`)}, ${chalk.red(`${failed.length} failed`)}, ${chalk.dim(`${skipped.length} skipped`)}`
6302
5982
  );
@@ -6628,9 +6308,7 @@ export class WorkflowRunner {
6628
6308
  if (!stat.isDirectory()) continue;
6629
6309
 
6630
6310
  // Check if this directory has at least one of the needed step files
6631
- const hasAny = [...stepNames].some(name =>
6632
- existsSync(path.join(dirPath, `${name}.md`))
6633
- );
6311
+ const hasAny = [...stepNames].some((name) => existsSync(path.join(dirPath, `${name}.md`)));
6634
6312
  if (!hasAny) continue;
6635
6313
 
6636
6314
  if (!best || stat.mtimeMs > best.mtime) {
@@ -6750,10 +6428,13 @@ export class WorkflowRunner {
6750
6428
  }
6751
6429
  const fallbackTime = new Date().toISOString();
6752
6430
 
6753
- const completedSteps = new Set(workflow.steps.filter((step) => cachedStepNames.has(step.name)).map((step) => step.name));
6431
+ const completedSteps = new Set(
6432
+ workflow.steps.filter((step) => cachedStepNames.has(step.name)).map((step) => step.name)
6433
+ );
6754
6434
  // Heuristic: mark the first eligible non-completed step as failed (the likely failure point)
6755
6435
  const failedStepName = workflow.steps.find(
6756
- (step) => !completedSteps.has(step.name) && (step.dependsOn ?? []).every((dep) => completedSteps.has(dep))
6436
+ (step) =>
6437
+ !completedSteps.has(step.name) && (step.dependsOn ?? []).every((dep) => completedSteps.has(dep))
6757
6438
  )?.name;
6758
6439
 
6759
6440
  const runStartedAt = new Date(earliestMtime).toISOString();
@@ -6771,10 +6452,14 @@ export class WorkflowRunner {
6771
6452
 
6772
6453
  const stepStates = new Map<string, StepState>();
6773
6454
  for (const step of workflow.steps) {
6774
- const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
6455
+ const isNonAgent =
6456
+ step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
6775
6457
  const cachedOutput = completedSteps.has(step.name) ? this.loadStepOutput(runId, step.name) : undefined;
6776
- const status: WorkflowStepStatus =
6777
- completedSteps.has(step.name) ? 'completed' : step.name === failedStepName ? 'failed' : 'pending';
6458
+ const status: WorkflowStepStatus = completedSteps.has(step.name)
6459
+ ? 'completed'
6460
+ : step.name === failedStepName
6461
+ ? 'failed'
6462
+ : 'pending';
6778
6463
 
6779
6464
  const stepRow: WorkflowStepRow = {
6780
6465
  id: this.generateId(),
@@ -6789,7 +6474,7 @@ export class WorkflowRunner {
6789
6474
  : step.type === 'worktree'
6790
6475
  ? (step.branch ?? '')
6791
6476
  : step.type === 'integration'
6792
- ? (`${step.integration}.${step.action}`)
6477
+ ? `${step.integration}.${step.action}`
6793
6478
  : (step.task ?? ''),
6794
6479
  dependsOn: step.dependsOn ?? [],
6795
6480
  output: cachedOutput,