@cat-factory/orchestration 0.36.5 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import { CONFLICTS_AGENT_KIND, MERGER_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND
11
11
  import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND, followUpsToSendBack, hasPendingFollowUps, renderFollowUpRework, shouldLoopCoder, } from './followUp.logic.js';
12
12
  import { AgentContextBuilder, } from './AgentContextBuilder.js';
13
13
  import { CompanionController } from './CompanionController.js';
14
+ import { StepGraph } from './StepGraph.js';
14
15
  import { inferTechnicalLabel } from './technical.logic.js';
15
16
  import { MergeResolver } from './MergeResolver.js';
16
17
  import { ReviewGateController } from './ReviewGateController.js';
@@ -65,6 +66,16 @@ function parseRepoFromPullUrl(url) {
65
66
  return undefined;
66
67
  return { owner: match[1], repo: match[2] };
67
68
  }
69
+ /**
70
+ * The inline review/brainstorm gate kinds, all driven through the {@link ReviewGateController}
71
+ * by the engine's `review-gate` StepHandler. Kept in sync with the handler's `switch`.
72
+ */
73
+ const REVIEW_GATE_AGENT_KINDS = new Set([
74
+ REQUIREMENTS_REVIEW_AGENT_KIND,
75
+ CLARITY_REVIEW_AGENT_KIND,
76
+ REQUIREMENTS_BRAINSTORM_AGENT_KIND,
77
+ ARCHITECTURE_BRAINSTORM_AGENT_KIND,
78
+ ]);
68
79
  /**
69
80
  * The execution engine. It orchestrates a pipeline of agent-performed steps and
70
81
  * is fully deterministic: `advanceInstance` moves one run forward by exactly one
@@ -83,6 +94,8 @@ export class ExecutionService {
83
94
  accountRepository;
84
95
  idGenerator;
85
96
  clock;
97
+ /** The pure step/cursor mutators (start/finish/park/reset + the companion rework loop). */
98
+ stepGraph;
86
99
  agentExecutor;
87
100
  workRunner;
88
101
  events;
@@ -153,7 +166,12 @@ export class ExecutionService {
153
166
  * and {@link StepHandler}. Engine-internal (no public registration seam).
154
167
  */
155
168
  stepHandlerCache;
156
- constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, documentUrlResolver, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, brainstormServices, brainstormSessionRepository, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, binaryArtifactStore, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, resolveTesterFallbackDefault, resolveRequireEnvironmentProvider, assertAgentBackendConfigured, runInitiatorScope, }) {
169
+ /**
170
+ * Lazily-built, order-sorted completion-path interceptor list (companion/tester verdict
171
+ * short-circuits). See {@link dispatchStepCompletionInterceptor}. Engine-internal.
172
+ */
173
+ stepCompletionInterceptorCache;
174
+ constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, accountRepository, idGenerator, clock, agentExecutor, workRunner, executionEventPublisher, boardService, spendService, documentRepository, documentUrlResolver, taskRepository, requirementReviewRepository, requirementReviewService, kaizenScheduler, clarityReviewRepository, clarityReviewService, brainstormServices, brainstormSessionRepository, fragmentResolver, environmentProvisioning, environmentTeardown, branchUpdater, blueprintReconciler, notificationService, resolveBinaryArtifactStore, workspaceSettingsService, llmObservability, pullRequestMerger, mergePresetRepository, ticketTrackerProvider, issueWriteback, subscriptionActivationRepository, resolveWorkspaceModelDefault, resolveProviderCapabilities, localTestInfraSupported, resolveRunRepoContext, resolveTesterFallbackDefault, resolveRequireEnvironmentProvider, assertAgentBackendConfigured, runInitiatorScope, }) {
157
175
  this.runInitiatorScope = runInitiatorScope ?? ((_initiatedBy, fn) => fn());
158
176
  this.workspaceRepository = workspaceRepository;
159
177
  this.blockRepository = blockRepository;
@@ -162,6 +180,7 @@ export class ExecutionService {
162
180
  this.accountRepository = accountRepository;
163
181
  this.idGenerator = idGenerator;
164
182
  this.clock = clock;
183
+ this.stepGraph = new StepGraph(clock);
165
184
  this.agentExecutor = agentExecutor;
166
185
  this.workRunner = workRunner;
167
186
  this.events = executionEventPublisher;
@@ -200,9 +219,9 @@ export class ExecutionService {
200
219
  idGenerator,
201
220
  previewStepModel: (ctx) => this.previewStepModel(ctx),
202
221
  runAgent: (ctx, opts) => this.runAgent(ctx, opts),
203
- finishStep: (s) => this.finishStep(s),
204
- startStep: (s) => this.startStep(s),
205
- pauseStepForInput: (s) => this.pauseStepForInput(s),
222
+ finishStep: (s) => this.stepGraph.finishStep(s),
223
+ startStep: (s) => this.stepGraph.startStep(s),
224
+ pauseStepForInput: (s) => this.stepGraph.pauseStepForInput(s),
206
225
  updateBlockProgress: (ws, i, st) => this.updateBlockProgress(ws, i, st),
207
226
  persistInstance: (ws, i) => this.executionRepository.upsert(ws, i),
208
227
  emitInstance: (ws, i) => this.emitInstance(ws, i),
@@ -210,7 +229,7 @@ export class ExecutionService {
210
229
  finalizeBlock: (ws, i, c) => this.finalizeBlock(ws, i, c),
211
230
  parkStepOnDecision: (ws, i, s, p) => this.parkStepOnDecision(ws, i, s, p),
212
231
  raiseDecisionRequired: (ws, i) => this.raiseDecisionRequired(ws, i),
213
- loopCompanionProducer: (i, ci, rw) => this.loopCompanionProducer(i, ci, rw),
232
+ loopCompanionProducer: (i, ci, rw) => this.stepGraph.loopCompanionProducer(i, ci, rw),
214
233
  inferTechnicalLabel: (ws, block, producer, companionStep) => this.inferBlockTechnical(ws, block, producer, companionStep),
215
234
  });
216
235
  this.testerController = new TesterController({
@@ -255,8 +274,8 @@ export class ExecutionService {
255
274
  ...(branchUpdater ? { branchUpdater } : {}),
256
275
  resolveMergePreset: (ws, block) => this.resolveMergePreset(ws, block),
257
276
  parkStepOnDecision: (ws, i, s, p) => this.parkStepOnDecision(ws, i, s, p),
258
- finishStep: (s) => this.finishStep(s),
259
- startStep: (s) => this.startStep(s),
277
+ finishStep: (s) => this.stepGraph.finishStep(s),
278
+ startStep: (s) => this.stepGraph.startStep(s),
260
279
  updateBlockProgress: (ws, i, st) => this.updateBlockProgress(ws, i, st),
261
280
  finalizeBlock: (ws, i, c) => this.finalizeBlock(ws, i, c),
262
281
  stopRunContainer: (ws, i) => this.stopRunContainer(ws, i),
@@ -271,11 +290,11 @@ export class ExecutionService {
271
290
  agentExecutor,
272
291
  contextBuilder: this.contextBuilder,
273
292
  notificationService,
274
- ...(binaryArtifactStore ? { binaryArtifactStore } : {}),
293
+ ...(resolveBinaryArtifactStore ? { resolveBinaryArtifactStore } : {}),
275
294
  resolveMergePreset: (ws, block) => this.resolveMergePreset(ws, block),
276
295
  parkStepOnDecision: (ws, i, s, p) => this.parkStepOnDecision(ws, i, s, p),
277
- finishStep: (s) => this.finishStep(s),
278
- startStep: (s) => this.startStep(s),
296
+ finishStep: (s) => this.stepGraph.finishStep(s),
297
+ startStep: (s) => this.stepGraph.startStep(s),
279
298
  updateBlockProgress: (ws, i, st) => this.updateBlockProgress(ws, i, st),
280
299
  finalizeBlock: (ws, i, c) => this.finalizeBlock(ws, i, c),
281
300
  stopRunContainer: (ws, i) => this.stopRunContainer(ws, i),
@@ -292,8 +311,8 @@ export class ExecutionService {
292
311
  advancePastResolvedGate: (ws, i, idx) => this.advancePastResolvedGate(ws, i, idx),
293
312
  dispatchIterationCap: (ws, blockId, choice, handlers) => this.dispatchIterationCap(ws, blockId, choice, handlers),
294
313
  raiseDecisionRequired: (ws, i) => this.raiseDecisionRequired(ws, i),
295
- finishStep: (s) => this.finishStep(s),
296
- startStep: (s) => this.startStep(s),
314
+ finishStep: (s) => this.stepGraph.finishStep(s),
315
+ startStep: (s) => this.stepGraph.startStep(s),
297
316
  updateBlockProgress: (ws, i, st) => this.updateBlockProgress(ws, i, st),
298
317
  finalizeBlock: (ws, i, c) => this.finalizeBlock(ws, i, c),
299
318
  stopRunContainer: (ws, i) => this.stopRunContainer(ws, i),
@@ -804,7 +823,7 @@ export class ExecutionService {
804
823
  }
805
824
  }
806
825
  }
807
- this.startStep(step);
826
+ this.stepGraph.startStep(step);
808
827
  const block = await this.blockRepository.get(workspaceId, instance.blockId);
809
828
  if (!block)
810
829
  return { kind: 'noop' };
@@ -819,7 +838,7 @@ export class ExecutionService {
819
838
  }
820
839
  // The fixed run-lifecycle preamble is done; hand the per-kind work to the
821
840
  // engine-internal StepHandler registry (the first handler whose `canHandle` claims
822
- // this step). See {@link dispatchStepHandler} / {@link runStepBody}.
841
+ // this step). See {@link dispatchStepHandler} / {@link handleAgentStep}.
823
842
  return this.dispatchStepHandler({
824
843
  workspaceId,
825
844
  instance,
@@ -830,77 +849,16 @@ export class ExecutionService {
830
849
  });
831
850
  }
832
851
  /**
833
- * The per-step-kind body run by the StepHandler registry once {@link stepInstance}'s
834
- * preamble has completed. Phase 0 of the ExecutionService split: a single fallthrough
835
- * handler delegates the ENTIRE body here unchanged (so dispatch is wired with zero
836
- * behaviour change); later phases lift each branch below into its own handler and shrink
837
- * this method until only the generic container/inline-agent tail remains.
852
+ * The generic container/inline-agent step the lowest-priority StepHandler, claiming
853
+ * every step no more-specific handler did (coder, architect, spec-writer, merger,
854
+ * task-estimator, the container-backed companions, …). Builds the agent context, runs the
855
+ * kind's pre-ops, then either dispatches an async container job and parks (the durable
856
+ * driver polls between sleeps) or runs the inline LLM call and records the result. This is
857
+ * what the dispatch chain falls through to; all the deterministic / gate / inline-review
858
+ * kinds are claimed earlier by their own handlers (see {@link buildStepHandlerRegistry}).
838
859
  */
839
- async runStepBody(ctx) {
860
+ async handleAgentStep(ctx) {
840
861
  const { workspaceId, instance, step, block, isFinalStep, options } = ctx;
841
- // (The `deployer` and `tracker` steps are handled by their own StepHandlers — see
842
- // {@link buildStepHandlerRegistry} — so they no longer branch here.)
843
- // A `requirements-review` step runs the inline reviewer and parks for the dedicated
844
- // review window, driving the iterative answer → incorporate → re-review loop. NOT a
845
- // container/prose agent. Pass-through when the reviewer isn't wired. The clarity gate
846
- // shares the SAME flow (only the subject + persisted doc differ); both run through the
847
- // {@link ReviewGateController}, parameterised by their {@link ReviewKind}.
848
- if (step.agentKind === REQUIREMENTS_REVIEW_AGENT_KIND) {
849
- return this.reviewGate.evaluate(this.requirementsKind, workspaceId, instance, step, block, isFinalStep);
850
- }
851
- // A `clarity-review` step triages the block's bug report (optionally enriched by an
852
- // upstream `bug-investigator` step) and parks for the dedicated review window, driving
853
- // the same iterative loop as the requirements gate. NOT a container/prose agent.
854
- // Pass-through when the reviewer isn't wired.
855
- if (step.agentKind === CLARITY_REVIEW_AGENT_KIND) {
856
- return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
857
- }
858
- // The two brainstorm (structured-dialogue) gates run the inline option-generator and park
859
- // for the dedicated brainstorm window, driving the same iterative loop as the requirements
860
- // gate. NOT container/prose agents. Pass-through when the brainstorm module isn't wired.
861
- if (step.agentKind === REQUIREMENTS_BRAINSTORM_AGENT_KIND) {
862
- return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
863
- }
864
- if (step.agentKind === ARCHITECTURE_BRAINSTORM_AGENT_KIND) {
865
- return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
866
- }
867
- // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
868
- // validate the change in a live URL before the run continues — NOT a container/prose
869
- // agent and NOT a programmatic polling gate (the human is the verdict). It also drives
870
- // the same helpers the other gates use on demand: the Tester's `fixer` (from findings)
871
- // and the `conflict-resolver` (after a conflicting pull-main). Degrades to a manual
872
- // (no-env) mode when no ephemeral-environment provider is wired. See {@link HumanTestController}.
873
- if (step.agentKind === HUMAN_TEST_AGENT_KIND) {
874
- return this.humanTestController.evaluate(workspaceId, instance, step, block, isFinalStep);
875
- }
876
- // A `visual-confirmation` gate gathers the UI tester's screenshots + the uploaded
877
- // reference designs and PARKS for a human to review actual-vs-reference, then on demand
878
- // dispatches the Tester's `fixer`. Passes through (auto-advances) when no binary-artifact
879
- // store is wired. See {@link VisualConfirmationController}.
880
- if (step.agentKind === VISUAL_CONFIRM_AGENT_KIND) {
881
- return this.visualConfirmationController.evaluate(workspaceId, instance, step, block, isFinalStep);
882
- }
883
- // A polling gate step (`ci` / `conflicts`) runs a programmatic precheck and only
884
- // escalates to a helper container agent (`ci-fixer` / `conflict-resolver`) on a
885
- // negative verdict — no LLM of its own. Pass-through when the gate's provider is
886
- // not wired. One generic machine drives every gate; see {@link evaluateGate}.
887
- const gate = this.gateFor(step.agentKind);
888
- if (gate) {
889
- return this.evaluateGate(workspaceId, instance, step, block, isFinalStep, gate);
890
- }
891
- // A companion step grades the nearest preceding producer of one of its target
892
- // kinds, looping it back for automatic rework below the threshold (and failing
893
- // the run once the budget is spent) before any human gate. See evaluateCompanion.
894
- //
895
- // INLINE companions (architect-companion / spec-companion) run their LLM grading right
896
- // here. CONTAINER-backed companions (reviewer / doc-reviewer) instead fall through to the
897
- // generic async container dispatch below — they clone the producer's PR branch and review
898
- // the REAL repository — and their verdict is resolved in `recordStepResult` via
899
- // `companionController.resolveContainerVerdict` (which runs the SAME threshold / rework
900
- // loop). A summary-only review is useless; the container reviewer reads the actual diff.
901
- if (isCompanionKind(step.agentKind) && !isContainerBackedCompanion(step.agentKind)) {
902
- return this.companionController.evaluate(workspaceId, instance, step, block, isFinalStep, options);
903
- }
904
862
  // Async (container) steps don't block: dispatch the job and park. The durable
905
863
  // driver polls `pollAgentJob` between sleeps so the run can span far longer
906
864
  // than a single durable step's timeout, while each step stays short. A set
@@ -1434,29 +1392,6 @@ export class ExecutionService {
1434
1392
  return false;
1435
1393
  }
1436
1394
  }
1437
- startStep(step) {
1438
- step.state = 'working';
1439
- if (step.startedAt == null)
1440
- step.startedAt = this.clock.now();
1441
- // (Re)entering `working` means the step is no longer parked on a human: resume
1442
- // its duration clock (see {@link pauseStepForInput}).
1443
- step.pausedAt = null;
1444
- }
1445
- /**
1446
- * Transition a step into `done`, stamping its finish time once. Set-once so the
1447
- * approval-gate flow (which re-asserts `done` after a human approves, long after
1448
- * the agent actually finished) keeps the agent's true completion time, and so a
1449
- * replay doesn't move it. With {@link startStep}'s `startedAt` this yields the
1450
- * step's execution duration. A step finished directly out of a parked approval
1451
- * stopped *working* when it parked, so its duration is billed to the pause instant
1452
- * ({@link pauseStepForInput}), not the (later) moment the human decided.
1453
- */
1454
- finishStep(step) {
1455
- step.state = 'done';
1456
- if (step.finishedAt == null)
1457
- step.finishedAt = step.pausedAt ?? this.clock.now();
1458
- step.pausedAt = null;
1459
- }
1460
1395
  /**
1461
1396
  * Finish a gated step that was skipped (its estimate gate was not satisfied) and either
1462
1397
  * complete the run or advance to the next step — the deterministic finish/advance tail
@@ -1469,7 +1404,7 @@ export class ExecutionService {
1469
1404
  step.output = '';
1470
1405
  step.progress = 1;
1471
1406
  step.subtasks = undefined;
1472
- this.finishStep(step);
1407
+ this.stepGraph.finishStep(step);
1473
1408
  if (isFinalStep) {
1474
1409
  instance.status = 'done';
1475
1410
  await this.finalizeBlock(workspaceId, instance, undefined);
@@ -1481,24 +1416,12 @@ export class ExecutionService {
1481
1416
  instance.currentStep += 1;
1482
1417
  const next = instance.steps[instance.currentStep];
1483
1418
  if (next)
1484
- this.startStep(next);
1419
+ this.stepGraph.startStep(next);
1485
1420
  await this.updateBlockProgress(workspaceId, instance, 'in_progress');
1486
1421
  await this.executionRepository.upsert(workspaceId, instance);
1487
1422
  await this.emitInstance(workspaceId, instance);
1488
1423
  return { kind: 'continue' };
1489
1424
  }
1490
- /**
1491
- * Park a step on a human decision and freeze its duration clock. Records when the
1492
- * step stopped working (`pausedAt`) so elapsed time no longer accrues while it waits
1493
- * for input — the symmetric counterpart of the terminal freeze on `finishedAt`.
1494
- * Set-once (a Workflows replay re-parking keeps the original instant); cleared when
1495
- * the step resumes ({@link startStep}) or finishes ({@link finishStep}).
1496
- */
1497
- pauseStepForInput(step) {
1498
- step.state = 'waiting_decision';
1499
- if (step.pausedAt == null)
1500
- step.pausedAt = this.clock.now();
1501
- }
1502
1425
  /**
1503
1426
  * Infer + persist the block's `technical` label from the settled spec phase (item 5):
1504
1427
  * combine the spec-writer's `noBusinessSpecs` determination (recorded on the producer
@@ -1542,33 +1465,28 @@ export class ExecutionService {
1542
1465
  options: [...result.decision.options],
1543
1466
  chosen: null,
1544
1467
  };
1545
- this.pauseStepForInput(step);
1468
+ this.stepGraph.pauseStepForInput(step);
1546
1469
  instance.status = 'blocked';
1547
1470
  await this.updateBlockProgress(workspaceId, instance, 'blocked');
1548
1471
  await this.executionRepository.upsert(workspaceId, instance);
1549
1472
  await this.emitInstance(workspaceId, instance);
1550
1473
  return { kind: 'awaiting_decision', decisionId: step.decision.id };
1551
1474
  }
1552
- // A container-backed companion (reviewer / doc-reviewer) just finished reviewing the
1553
- // real repository on the producer's PR branch and returned its verdict as `result.custom`.
1554
- // Hand it to the companion loop, which parses the verdict and applies the SAME threshold /
1555
- // rework / human-gate handling an inline companion gets. Routed here (not the normal step
1556
- // completion) so the verdict drives the loop instead of being recorded as plain output.
1557
- if (isCompanionKind(step.agentKind) && isContainerBackedCompanion(step.agentKind)) {
1558
- const companionBlock = await this.blockRepository.get(workspaceId, instance.blockId);
1559
- if (companionBlock) {
1560
- return this.companionController.resolveContainerVerdict(workspaceId, instance, step, companionBlock, isFinalStep, result);
1561
- }
1562
- }
1563
- // A `tester` step returned a structured report. On a withheld greenlight we do
1564
- // NOT finish the step: we loop the `fixer` (within the attempt budget) and
1565
- // re-test, mirroring the CI gate. A greenlight (or no provider) falls through to
1566
- // the normal finish/advance below. Records the report on the step either way.
1567
- if (isTesterKind(step.agentKind) && result.testReport !== undefined) {
1568
- const looped = await this.testerController.resolveTesterResult(workspaceId, instance, step, result);
1569
- if (looped)
1570
- return looped;
1571
- }
1475
+ // Completion-path interceptors short-circuit before the normal finish/advance for the
1476
+ // few kinds whose verdict drives run flow: a container-backed companion applies its
1477
+ // threshold/rework/human-gate loop, and a Tester re-runs its `fixer` on a withheld
1478
+ // greenlight. A non-null outcome replaces the normal completion; null (a Tester
1479
+ // greenlight, or a companion whose block can't be loaded) falls through. See
1480
+ // {@link buildStepCompletionInterceptors}.
1481
+ const intercepted = await this.dispatchStepCompletionInterceptor({
1482
+ workspaceId,
1483
+ instance,
1484
+ step,
1485
+ isFinalStep,
1486
+ result,
1487
+ });
1488
+ if (intercepted)
1489
+ return intercepted;
1572
1490
  // The step completed.
1573
1491
  step.output = result.output ?? '';
1574
1492
  // Surface a registered custom kind's structured JSON on the step so the SPA's
@@ -1579,7 +1497,7 @@ export class ExecutionService {
1579
1497
  if (result.model)
1580
1498
  step.model = result.model;
1581
1499
  step.progress = 1;
1582
- this.finishStep(step);
1500
+ this.stepGraph.finishStep(step);
1583
1501
  // Live subtask counts only describe an in-flight run; drop them now the step
1584
1502
  // is done so the board doesn't show a stale "3/8" against a finished step.
1585
1503
  step.subtasks = undefined;
@@ -1612,38 +1530,25 @@ export class ExecutionService {
1612
1530
  .catch(() => { });
1613
1531
  }
1614
1532
  }
1615
- // A Blueprinter step produced a fresh service decomposition. Validate it with
1616
- // the authoritative schema (a bad payload must never touch the board), then
1617
- // reconcile it in place onto the run's service frame.
1618
- if (result.blueprintService !== undefined) {
1619
- await this.ingestBlueprint(workspaceId, instance.blockId, result.blueprintService);
1620
- }
1621
- // A spec-writer step produced the service's unified specification (`spec.json`)
1622
- // and committed it to the implementation branch. Strict-validate it (a bad payload
1623
- // must never be trusted), then nudge clients to refresh.
1624
- if (result.spec !== undefined) {
1625
- await this.ingestSpec(workspaceId, result.spec);
1626
- }
1627
- // Record the spec-writer's BUSINESS-vs-TECHNICAL determination on the step. "No
1628
- // business specs" (a purely technical task) is a valid outcome; the spec-companion's
1629
- // convergence — the one point both signals coexist — combines this with the
1630
- // companion's `technicalCorroborated` verdict to infer the block's `technical` label
1631
- // (see CompanionController). Recorded even when false so a re-run reflects the latest.
1632
- if (step.agentKind === SPEC_WRITER_AGENT_KIND) {
1633
- step.noBusinessSpecs = result.noBusinessSpecs === true;
1634
- }
1635
- // A `task-estimator` step emits a JSON triage (complexity/risk/impact). Parse it
1636
- // tolerantly, persist it on the block (used to gate consensus steps + surfaced in
1637
- // the UI), and replace the raw JSON output with a readable summary. An unparseable
1638
- // estimate leaves the block untouched and keeps the raw output (no run failure).
1639
- // The estimate works the same whether the single-actor estimator or the consensus
1640
- // ranked-scoring variant produced the JSON — both land here.
1641
- if (step.agentKind === TASK_ESTIMATOR_AGENT_KIND) {
1642
- const estimate = coerceTaskEstimate(step.output, result.model ?? step.model ?? null, this.clock.now());
1643
- if (estimate) {
1644
- await this.blockRepository.update(workspaceId, instance.blockId, { estimate });
1645
- step.output = summarizeEstimate(estimate);
1646
- }
1533
+ // Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
1534
+ // ingestion, task-estimate persistence). It reshapes the agent's structured result into
1535
+ // domain state and may replace `step.output` (the estimator's readable summary). Its
1536
+ // POSITION is load-bearing — it runs after the output is recorded but BEFORE the
1537
+ // reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
1538
+ // sits exactly where the old inline ingestion branches did. See
1539
+ // {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1540
+ const postCompletionResolver = this.stepResolverFor(step.agentKind);
1541
+ if (postCompletionResolver?.phase === 'post-completion' &&
1542
+ (postCompletionResolver.applies?.(result) ?? true)) {
1543
+ const resolution = await postCompletionResolver.resolve({
1544
+ workspaceId,
1545
+ instance,
1546
+ step,
1547
+ result,
1548
+ isFinalStep,
1549
+ });
1550
+ if (resolution?.output !== undefined)
1551
+ step.output = resolution.output;
1647
1552
  }
1648
1553
  // A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
1649
1554
  // returns its raw Pi transcript summary as `result.output` — useless for review.
@@ -1679,7 +1584,7 @@ export class ExecutionService {
1679
1584
  status: 'pending',
1680
1585
  proposal: step.output,
1681
1586
  };
1682
- this.pauseStepForInput(step);
1587
+ this.stepGraph.pauseStepForInput(step);
1683
1588
  instance.status = 'blocked';
1684
1589
  await this.updateBlockProgress(workspaceId, instance, 'blocked');
1685
1590
  await this.executionRepository.upsert(workspaceId, instance);
@@ -1702,7 +1607,9 @@ export class ExecutionService {
1702
1607
  // tells `finalizeBlock` to leave it alone.
1703
1608
  const resolver = this.stepResolverFor(step.agentKind);
1704
1609
  let resolverOwnsTerminalStatus = false;
1705
- if (resolver && (resolver.applies?.(result) ?? true)) {
1610
+ if (resolver &&
1611
+ (resolver.phase ?? 'terminal') === 'terminal' &&
1612
+ (resolver.applies?.(result) ?? true)) {
1706
1613
  const resolution = await resolver.resolve({
1707
1614
  workspaceId,
1708
1615
  instance,
@@ -1740,7 +1647,7 @@ export class ExecutionService {
1740
1647
  instance.currentStep += 1;
1741
1648
  const next = instance.steps[instance.currentStep];
1742
1649
  if (next)
1743
- this.startStep(next);
1650
+ this.stepGraph.startStep(next);
1744
1651
  // A resolver that already set the block's TERMINAL status (the merger flips it to
1745
1652
  // `done`/`pr_ready` mid-pipeline) must not be clobbered back to `in_progress` as we
1746
1653
  // advance to a trailing step — refresh progress only, preserving that status. (The
@@ -1755,74 +1662,6 @@ export class ExecutionService {
1755
1662
  await this.emitInstance(workspaceId, instance);
1756
1663
  return { kind: 'continue' };
1757
1664
  }
1758
- /**
1759
- * Reset a step so the durable driver re-runs it from scratch: clear its live
1760
- * container job handle (so it dispatches FRESH work rather than re-attaching to a
1761
- * finished or evicted job), its timings, approval gate, live subtasks and last
1762
- * output, and drop it back to `pending`. Preserves the step's identity
1763
- * (`agentKind` / `requiresApproval`) and any companion budget/verdict history.
1764
- */
1765
- resetStepForRerun(step) {
1766
- step.state = 'pending';
1767
- step.startedAt = null;
1768
- step.finishedAt = null;
1769
- step.pausedAt = null;
1770
- step.jobId = undefined;
1771
- step.approval = null;
1772
- step.subtasks = undefined;
1773
- step.progress = 0;
1774
- step.output = undefined;
1775
- // Drop the prior run's structured output too, so a re-run that produces no `custom`
1776
- // doesn't leave stale JSON for the `generic-structured` result view to render.
1777
- step.custom = undefined;
1778
- step.rework = undefined;
1779
- }
1780
- /**
1781
- * Loop a producer step back for rework and re-run every step from it up to and
1782
- * including the companion at `companionIndex`: each one is reset (crucially clearing
1783
- * stale container job handles so an intermediate container step re-dispatches fresh
1784
- * work instead of re-attaching to its evicted job), the producer is handed the
1785
- * `rework` feedback + started, and the instance cursor is moved back to the producer.
1786
- * Shared by the automatic companion loop and the human "request changes" path.
1787
- */
1788
- rerunProducerThrough(instance, producerIndex, companionIndex, rework) {
1789
- for (let i = producerIndex; i <= companionIndex; i++) {
1790
- this.resetStepForRerun(instance.steps[i]);
1791
- }
1792
- const producer = instance.steps[producerIndex];
1793
- producer.rework = rework;
1794
- this.startStep(producer);
1795
- instance.currentStep = producerIndex;
1796
- }
1797
- /**
1798
- * The index of the nearest preceding step a companion grades (one of its target
1799
- * producer kinds), or -1 when none precedes it. The single producer-search used by the
1800
- * automatic companion loop, the human "request changes" redirect, and the iteration-cap
1801
- * extra-round resolution.
1802
- */
1803
- companionProducerIndex(instance, companionIndex) {
1804
- const targets = companionTargets(instance.steps[companionIndex].agentKind);
1805
- for (let i = companionIndex - 1; i >= 0; i--) {
1806
- if (targets.includes(instance.steps[i].agentKind))
1807
- return i;
1808
- }
1809
- return -1;
1810
- }
1811
- /**
1812
- * Loop a companion's producer back for one more automatic rework cycle: charge one
1813
- * attempt against the budget, then re-run the producer (and any intermediate steps) up
1814
- * to and including the companion so it re-grades. Shared by the automatic
1815
- * below-threshold loop ({@link evaluateCompanion}) and the human-granted extra round
1816
- * ({@link resolveCompanionExceeded}), so both consume the budget identically.
1817
- */
1818
- loopCompanionProducer(instance, companionIndex, rework) {
1819
- const companionStep = instance.steps[companionIndex];
1820
- const producerIndex = this.companionProducerIndex(instance, companionIndex);
1821
- companionStep.companion.attempts += 1;
1822
- this.rerunProducerThrough(instance, producerIndex, companionIndex, rework);
1823
- if (instance.status === 'blocked')
1824
- instance.status = 'running';
1825
- }
1826
1665
  /**
1827
1666
  * Deterministically provision an ephemeral environment for a deployer step.
1828
1667
  * Produces a human-readable summary as the step output and reports no token
@@ -2118,17 +1957,144 @@ export class ExecutionService {
2118
1957
  return this.recordStepResult(workspaceId, instance, step, isFinalStep, result);
2119
1958
  },
2120
1959
  },
2121
- // The generic container/inline-agent fallthrough claims every step no more-specific
2122
- // handler did (today's `runStepBody` tail). Highest order so it always runs last.
1960
+ // The `requirements-review` / `clarity-review` / `requirements-brainstorm` /
1961
+ // `architecture-brainstorm` steps are inline reviewers that park for their dedicated
1962
+ // window and drive the iterative answer → incorporate → re-review loop — NOT
1963
+ // container/prose agents. All four run through the {@link ReviewGateController},
1964
+ // parameterised by their {@link ReviewKind} (the per-case `switch` binds each kind's
1965
+ // review type so `evaluate`'s generic infers). Pass-through when the service isn't wired.
1966
+ {
1967
+ kind: 'review-gate',
1968
+ order: 120,
1969
+ canHandle: ({ step }) => REVIEW_GATE_AGENT_KINDS.has(step.agentKind),
1970
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => {
1971
+ switch (step.agentKind) {
1972
+ case REQUIREMENTS_REVIEW_AGENT_KIND:
1973
+ return this.reviewGate.evaluate(this.requirementsKind, workspaceId, instance, step, block, isFinalStep);
1974
+ case CLARITY_REVIEW_AGENT_KIND:
1975
+ return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
1976
+ case REQUIREMENTS_BRAINSTORM_AGENT_KIND:
1977
+ return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
1978
+ case ARCHITECTURE_BRAINSTORM_AGENT_KIND:
1979
+ return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
1980
+ // `canHandle` admits only the kinds in REVIEW_GATE_AGENT_KINDS, so every member
1981
+ // must have an explicit case above. Throw loudly if the two ever drift (a new
1982
+ // review kind added to the Set without a case here) rather than silently routing
1983
+ // it to the wrong reviewer.
1984
+ default:
1985
+ throw new Error(`Unhandled review-gate agentKind "${step.agentKind}"`);
1986
+ }
1987
+ },
1988
+ },
1989
+ // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
1990
+ // validate the change in a live URL — NOT a container/prose agent and NOT a
1991
+ // programmatic polling gate (the human is the verdict). Degrades to a manual (no-env)
1992
+ // mode when no ephemeral-environment provider is wired. See {@link HumanTestController}.
2123
1993
  {
2124
- kind: '*',
1994
+ kind: HUMAN_TEST_AGENT_KIND,
1995
+ order: 130,
1996
+ canHandle: ({ step }) => step.agentKind === HUMAN_TEST_AGENT_KIND,
1997
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.humanTestController.evaluate(workspaceId, instance, step, block, isFinalStep),
1998
+ },
1999
+ // A `visual-confirmation` gate gathers the UI tester's screenshots + uploaded reference
2000
+ // designs and PARKS for a human to review actual-vs-reference, then on demand dispatches
2001
+ // the Tester's `fixer`. Passes through when no binary-artifact store is wired.
2002
+ // See {@link VisualConfirmationController}.
2003
+ {
2004
+ kind: VISUAL_CONFIRM_AGENT_KIND,
2005
+ order: 140,
2006
+ canHandle: ({ step }) => step.agentKind === VISUAL_CONFIRM_AGENT_KIND,
2007
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.visualConfirmationController.evaluate(workspaceId, instance, step, block, isFinalStep),
2008
+ },
2009
+ // A polling gate step (`ci` / `conflicts` / `post-release-health` / `human-review`) runs
2010
+ // a programmatic precheck and only escalates to a helper container agent on a negative
2011
+ // verdict — no LLM of its own. Pass-through when the gate's provider is not wired. One
2012
+ // generic machine drives every gate; see {@link evaluateGate}. `canHandle` is the gate
2013
+ // registry lookup, so this claims exactly the registered gate kinds.
2014
+ {
2015
+ kind: 'polling-gate',
2016
+ order: 150,
2017
+ canHandle: ({ step }) => this.gateFor(step.agentKind) !== undefined,
2018
+ handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.evaluateGate(workspaceId, instance, step, block, isFinalStep, this.gateFor(step.agentKind)),
2019
+ },
2020
+ // An INLINE companion (architect-companion / spec-companion) grades the nearest
2021
+ // preceding producer right here and loops it back for automatic rework below the
2022
+ // threshold before any human gate. CONTAINER-backed companions (reviewer / doc-reviewer)
2023
+ // do NOT match — they fall through to the generic async container dispatch and have their
2024
+ // verdict resolved by the completion interceptor instead. See {@link CompanionController}.
2025
+ {
2026
+ kind: 'inline-companion',
2027
+ order: 160,
2028
+ canHandle: ({ step }) => isCompanionKind(step.agentKind) && !isContainerBackedCompanion(step.agentKind),
2029
+ handle: ({ workspaceId, instance, step, block, isFinalStep, options }) => this.companionController.evaluate(workspaceId, instance, step, block, isFinalStep, options),
2030
+ },
2031
+ // The generic container/inline-agent step — claims every step no more-specific handler
2032
+ // did. Highest order so it always runs last. See {@link handleAgentStep}.
2033
+ {
2034
+ kind: 'agent',
2125
2035
  order: FALLTHROUGH_STEP_HANDLER_ORDER,
2126
2036
  canHandle: () => true,
2127
- handle: (ctx) => this.runStepBody(ctx),
2037
+ handle: (ctx) => this.handleAgentStep(ctx),
2128
2038
  },
2129
2039
  ];
2130
2040
  return handlers.sort((a, b) => a.order - b.order);
2131
2041
  }
2042
+ /**
2043
+ * Run the first completion-path interceptor that claims this finished step, returning its
2044
+ * short-circuit {@link AdvanceResult} (the companion verdict loop / tester re-test) or
2045
+ * `null` to let `recordStepResult`'s normal finish/advance spine run. Engine-internal,
2046
+ * mirroring {@link dispatchStepHandler}.
2047
+ */
2048
+ async dispatchStepCompletionInterceptor(ctx) {
2049
+ if (!this.stepCompletionInterceptorCache) {
2050
+ this.stepCompletionInterceptorCache = this.buildStepCompletionInterceptors();
2051
+ }
2052
+ for (const interceptor of this.stepCompletionInterceptorCache) {
2053
+ if (interceptor.canIntercept(ctx)) {
2054
+ const outcome = await interceptor.intercept(ctx);
2055
+ if (outcome)
2056
+ return outcome;
2057
+ }
2058
+ }
2059
+ return null;
2060
+ }
2061
+ /**
2062
+ * Build the order-sorted completion-path interceptors (companion / tester verdict
2063
+ * short-circuits), mirroring {@link buildStepHandlerRegistry} — built-ins constructed
2064
+ * inline closing over `this`, no public registration seam.
2065
+ */
2066
+ buildStepCompletionInterceptors() {
2067
+ const interceptors = [
2068
+ // A container-backed companion (reviewer / doc-reviewer) just finished reviewing the
2069
+ // real repository on the producer's PR branch and returned its verdict as
2070
+ // `result.custom`. Hand it to the companion loop, which parses the verdict and applies
2071
+ // the SAME threshold / rework / human-gate handling an inline companion gets. Routed
2072
+ // here (not the normal step completion) so the verdict drives the loop instead of being
2073
+ // recorded as plain output. Falls through (returns null) when the block can't be loaded.
2074
+ {
2075
+ kind: 'companion-verdict',
2076
+ order: 100,
2077
+ canIntercept: ({ step }) => isCompanionKind(step.agentKind) && isContainerBackedCompanion(step.agentKind),
2078
+ intercept: async ({ workspaceId, instance, step, isFinalStep, result }) => {
2079
+ const companionBlock = await this.blockRepository.get(workspaceId, instance.blockId);
2080
+ if (!companionBlock)
2081
+ return null;
2082
+ return this.companionController.resolveContainerVerdict(workspaceId, instance, step, companionBlock, isFinalStep, result);
2083
+ },
2084
+ },
2085
+ // A `tester` step returned a structured report. On a withheld greenlight we do NOT
2086
+ // finish the step: loop the `fixer` (within the attempt budget) and re-test, mirroring
2087
+ // the CI gate. A greenlight (or no provider) returns null and falls through to the
2088
+ // normal finish/advance below. Records the report on the step either way.
2089
+ {
2090
+ kind: 'tester-verdict',
2091
+ order: 110,
2092
+ canIntercept: ({ step, result }) => isTesterKind(step.agentKind) && result.testReport !== undefined,
2093
+ intercept: ({ workspaceId, instance, step, result }) => this.testerController.resolveTesterResult(workspaceId, instance, step, result),
2094
+ },
2095
+ ];
2096
+ return interceptors.sort((a, b) => a.order - b.order);
2097
+ }
2132
2098
  stepResolverFor(agentKind) {
2133
2099
  if (!this.stepResolverCache)
2134
2100
  this.stepResolverCache = this.buildStepResolverRegistry();
@@ -2151,6 +2117,55 @@ export class ExecutionService {
2151
2117
  return { ownsTerminalStatus: true };
2152
2118
  },
2153
2119
  },
2120
+ // POST-COMPLETION resolvers — run at the early slot (after output is recorded, before
2121
+ // the follow-up/approval gates), reshaping the agent's structured result into domain
2122
+ // state. Lifted verbatim from the old inline `recordStepResult` branches.
2123
+ //
2124
+ // A Blueprinter step produced a fresh service decomposition. Validate it with the
2125
+ // authoritative schema (a bad payload must never touch the board), then reconcile it
2126
+ // in place onto the run's service frame.
2127
+ {
2128
+ kind: BLUEPRINTS_AGENT_KIND,
2129
+ phase: 'post-completion',
2130
+ resolve: async ({ workspaceId, instance, result }) => {
2131
+ if (result.blueprintService !== undefined) {
2132
+ await this.ingestBlueprint(workspaceId, instance.blockId, result.blueprintService);
2133
+ }
2134
+ },
2135
+ },
2136
+ // A spec-writer step produced the service's unified specification (`spec.json`) and
2137
+ // committed it to the implementation branch — strict-validate it then nudge clients —
2138
+ // and reports its BUSINESS-vs-TECHNICAL determination. "No business specs" (a purely
2139
+ // technical task) is a valid outcome the spec-companion's convergence later combines
2140
+ // with its `technicalCorroborated` verdict; recorded even when false so a re-run
2141
+ // reflects the latest.
2142
+ {
2143
+ kind: SPEC_WRITER_AGENT_KIND,
2144
+ phase: 'post-completion',
2145
+ resolve: async ({ workspaceId, step, result }) => {
2146
+ if (result.spec !== undefined)
2147
+ await this.ingestSpec(workspaceId, result.spec);
2148
+ step.noBusinessSpecs = result.noBusinessSpecs === true;
2149
+ },
2150
+ },
2151
+ // A `task-estimator` step emits a JSON triage (complexity/risk/impact). Parse it
2152
+ // tolerantly, persist it on the block (used to gate consensus steps + surfaced in the
2153
+ // UI), and replace the raw JSON output with a readable summary. An unparseable estimate
2154
+ // leaves the block untouched and keeps the raw output (no run failure). Works the same
2155
+ // whether the single-actor estimator or the consensus ranked-scoring variant produced
2156
+ // the JSON. Running at the post-completion slot keeps the summary in `step.output`
2157
+ // before the approval gate reads it as the proposal.
2158
+ {
2159
+ kind: TASK_ESTIMATOR_AGENT_KIND,
2160
+ phase: 'post-completion',
2161
+ resolve: async ({ workspaceId, instance, step, result }) => {
2162
+ const estimate = coerceTaskEstimate(step.output ?? '', result.model ?? step.model ?? null, this.clock.now());
2163
+ if (estimate) {
2164
+ await this.blockRepository.update(workspaceId, instance.blockId, { estimate });
2165
+ return { output: summarizeEstimate(estimate) };
2166
+ }
2167
+ },
2168
+ },
2154
2169
  ];
2155
2170
  const map = new Map(resolvers.map((r) => [r.kind, r]));
2156
2171
  // Merge deployment-registered resolvers, mirroring the gate registry below. A
@@ -2462,9 +2477,9 @@ export class ExecutionService {
2462
2477
  state.loops = (state.loops ?? 0) + 1;
2463
2478
  // Reset the step for a fresh dispatch; `step.followUps` is intentionally preserved
2464
2479
  // (resetStepForRerun doesn't touch it) so the surfaced items survive the loop.
2465
- this.resetStepForRerun(step);
2480
+ this.stepGraph.resetStepForRerun(step);
2466
2481
  step.rework = { previousProposal: '', feedback };
2467
- this.startStep(step);
2482
+ this.stepGraph.startStep(step);
2468
2483
  if (instance.status === 'blocked')
2469
2484
  instance.status = 'running';
2470
2485
  }
@@ -2760,7 +2775,7 @@ export class ExecutionService {
2760
2775
  */
2761
2776
  async parkStepOnDecision(workspaceId, instance, step, proposal = '') {
2762
2777
  step.approval = { id: this.idGenerator.next('appr'), status: 'pending', proposal };
2763
- this.pauseStepForInput(step);
2778
+ this.stepGraph.pauseStepForInput(step);
2764
2779
  instance.status = 'blocked';
2765
2780
  await this.updateBlockProgress(workspaceId, instance, 'blocked');
2766
2781
  await this.executionRepository.upsert(workspaceId, instance);
@@ -3041,8 +3056,8 @@ export class ExecutionService {
3041
3056
  extraRound: async () => {
3042
3057
  step.companion.maxAttempts += 1;
3043
3058
  step.companion.exceeded = undefined;
3044
- const producer = instance.steps[this.companionProducerIndex(instance, stepIndex)];
3045
- this.loopCompanionProducer(instance, stepIndex, {
3059
+ const producer = instance.steps[this.stepGraph.companionProducerIndex(instance, stepIndex)];
3060
+ this.stepGraph.loopCompanionProducer(instance, stepIndex, {
3046
3061
  previousProposal: producer?.output ?? '',
3047
3062
  feedback: step.companion.verdicts.at(-1)?.feedback ?? '',
3048
3063
  });
@@ -3060,7 +3075,7 @@ export class ExecutionService {
3060
3075
  // so infer the block's `technical` label here too — best-effort, human-authority
3061
3076
  // preserved — before advancing.
3062
3077
  if (step.agentKind === 'spec-companion') {
3063
- const producer = instance.steps[this.companionProducerIndex(instance, stepIndex)];
3078
+ const producer = instance.steps[this.stepGraph.companionProducerIndex(instance, stepIndex)];
3064
3079
  const block = await this.blockRepository.get(workspaceId, instance.blockId);
3065
3080
  if (producer && block)
3066
3081
  await this.inferBlockTechnical(workspaceId, block, producer, step);
@@ -3081,7 +3096,7 @@ export class ExecutionService {
3081
3096
  async advancePastResolvedGate(workspaceId, instance, stepIndex) {
3082
3097
  const step = instance.steps[stepIndex];
3083
3098
  const decisionId = step.approval.id;
3084
- this.finishStep(step);
3099
+ this.stepGraph.finishStep(step);
3085
3100
  step.progress = 1;
3086
3101
  const isFinalStep = stepIndex === instance.steps.length - 1;
3087
3102
  if (isFinalStep) {
@@ -3093,7 +3108,7 @@ export class ExecutionService {
3093
3108
  instance.currentStep = stepIndex + 1;
3094
3109
  const next = instance.steps[instance.currentStep];
3095
3110
  if (next)
3096
- this.startStep(next);
3111
+ this.stepGraph.startStep(next);
3097
3112
  if (instance.status === 'blocked')
3098
3113
  instance.status = 'running';
3099
3114
  await this.updateBlockProgress(workspaceId, instance, 'in_progress');
@@ -3541,7 +3556,7 @@ export class ExecutionService {
3541
3556
  if (!step || !step.decision)
3542
3557
  throw new NotFoundError('Decision', decisionId);
3543
3558
  step.decision.chosen = choice;
3544
- this.startStep(step);
3559
+ this.stepGraph.startStep(step);
3545
3560
  if (instance.status === 'blocked')
3546
3561
  instance.status = 'running';
3547
3562
  await this.updateBlockProgress(workspaceId, instance, 'in_progress');
@@ -3630,7 +3645,7 @@ export class ExecutionService {
3630
3645
  // including the companion, then the companion re-grades. Does NOT touch the
3631
3646
  // companion's automatic-rework budget — a human-driven iteration is unbounded.
3632
3647
  const previousProposal = producer.output ?? step.approval.proposal;
3633
- this.rerunProducerThrough(instance, producerIndex, stepIndex, {
3648
+ this.stepGraph.rerunProducerThrough(instance, producerIndex, stepIndex, {
3634
3649
  previousProposal,
3635
3650
  feedback: review.feedback ?? '',
3636
3651
  ...(review.comments?.length ? { comments: review.comments } : {}),
@@ -3650,7 +3665,7 @@ export class ExecutionService {
3650
3665
  // start/finish times this attempt rather than spanning the human gate wait.
3651
3666
  step.startedAt = null;
3652
3667
  step.finishedAt = null;
3653
- this.startStep(step);
3668
+ this.stepGraph.startStep(step);
3654
3669
  if (instance.status === 'blocked')
3655
3670
  instance.status = 'running';
3656
3671
  await this.executionRepository.upsert(workspaceId, instance);