@cat-factory/orchestration 0.129.6 → 0.129.8

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.
@@ -527,65 +527,7 @@ export class RunDispatcher {
527
527
  agentKind: step.agentKind,
528
528
  });
529
529
  if (update.state === 'running') {
530
- // A successful poll proves the container is up, so the cold-boot phase is over
531
- // (defensive: a replay may have left the flag set). Surface live subtask progress
532
- // (e.g. 3/8 todos done) without advancing the step. Only persist + emit when something
533
- // actually changed so an idle poll doesn't churn storage or the event stream.
534
- //
535
- // Fold the poll's delta onto a target instance, returning whether anything changed. The
536
- // captured `update` is the source, so re-applying on a freshly-reloaded instance is
537
- // idempotent for the set-to-latest folds AND correct for the APPEND fold: the harness
538
- // follow-up buffer is drain-on-read (`runner.ts`), so those streamed items exist ONLY on
539
- // this `update` — abort-and-redrive would lose them, whereas re-applying under
540
- // `mutateInstance` (which reloads a clean base each attempt) preserves them.
541
- const applyRunningFold = async (target) => {
542
- const s = target.steps[target.currentStep];
543
- // The step advanced (or the job was superseded) under a concurrent write — nothing to fold.
544
- if (!s || s.jobId !== step.jobId)
545
- return false;
546
- let changed = false;
547
- if (this.applyContainerRunning(s, update))
548
- changed = true;
549
- if (this.applySubtaskProgress(s, update.subtasks))
550
- changed = true;
551
- // The transport reports WHICH backend served the job on the first poll (native host
552
- // process vs. sandboxed container) — record it in the run diagnostics.
553
- if (this.recordBackendDiagnostics(target, update.backend))
554
- changed = true;
555
- // Append any forward-looking items the Coder streamed since the last poll so the
556
- // Follow-up companion lights up + accrues items LIVE while the container still runs.
557
- if (this.followUpGate.appendStreamedFollowUps(s, update.followUps))
558
- changed = true;
559
- // Refresh the env projection so its status transitions (provisioning→ready→
560
- // expired/torn_down) and any error stay live in the run details during the run.
561
- if (await this.deployer.attachEnvironmentProjection(workspaceId, target.blockId, s))
562
- changed = true;
563
- return changed;
564
- };
565
- // Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
566
- // (the common case). The mutation is discarded — the authoritative write re-applies the
567
- // same fold on fresh state under CAS below.
568
- if (await applyRunningFold(instance)) {
569
- try {
570
- const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
571
- await applyRunningFold(fresh);
572
- });
573
- // Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
574
- // LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
575
- // the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
576
- await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
577
- }
578
- catch (error) {
579
- // The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
580
- // past the retry budget (`ConflictError`) — re-drive on fresh state rather than
581
- // failing the run; the next entry no-ops on a gone/terminal run.
582
- if (error instanceof NotFoundError || error instanceof ConflictError) {
583
- throw new RunContendedError(executionId);
584
- }
585
- throw error;
586
- }
587
- }
588
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
530
+ return this.handleRunningPoll(workspaceId, executionId, instance, update, step.jobId);
589
531
  }
590
532
  // A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call)
591
533
  // declares a `resolveHelperCompletion` hook on its definition. When such a helper's job
@@ -594,34 +536,9 @@ export class RunDispatcher {
594
536
  // budget) and finish the gate step with the output it returns. The gate raises its own
595
537
  // `release_regression` notification + enriches any open incident inside the hook (from the
596
538
  // signals stashed at escalation); the run then completes for a human to act out-of-band.
597
- const completionGate = this.gateFor(step.agentKind);
598
- if (completionGate?.resolveHelperCompletion &&
599
- step.gate?.phase === 'working' &&
600
- (update.state === 'done' || update.state === 'failed')) {
601
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
602
- step.jobId = undefined;
603
- step.subtasks = undefined;
604
- if (!block)
605
- return { kind: 'noop' };
606
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
607
- const jobResult = update.state === 'done'
608
- ? { state: 'done', result: update.result }
609
- : { state: 'failed', error: update.error ?? null };
610
- const resolution = await completionGate.resolveHelperCompletion({
611
- workspaceId,
612
- instance,
613
- block,
614
- step,
615
- result: jobResult,
616
- });
617
- // Preserve the done-result's fields (usage metering etc.) while recording the gate's
618
- // resolved output; a failed investigation has no result to carry.
619
- const base = update.state === 'done' ? update.result : { output: '' };
620
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
621
- ...base,
622
- output: resolution.output,
623
- });
624
- }
539
+ const investigated = await this.resolveInvestigateHelperCompletion(workspaceId, instance, step, update);
540
+ if (investigated)
541
+ return investigated;
625
542
  // A polling gate step's in-flight job is its helper agent (ci-fixer /
626
543
  // conflict-resolver), NOT the step's own work: when it finishes (or fails) we
627
544
  // don't record a result or advance — we drop the handle, return the gate to
@@ -630,50 +547,7 @@ export class RunDispatcher {
630
547
  // negative, so the next check re-dispatches (until the attempt budget is spent).
631
548
  const reprobeGate = this.gateFor(step.agentKind);
632
549
  if (reprobeGate) {
633
- // A gate may need deterministic GitHub-side bookkeeping to land BEFORE the re-probe
634
- // reads it (the human-review gate replies to + RESOLVES the threads it handed the
635
- // fixer, so the next probe counts them addressed). Run that side-effect hook first;
636
- // it does NOT replace the re-probe (unlike resolveHelperCompletion).
637
- if (reprobeGate.onHelperComplete && step.gate) {
638
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
639
- if (block) {
640
- const jobResult = update.state === 'done'
641
- ? { state: 'done', result: update.result }
642
- : { state: 'failed', error: update.error ?? null };
643
- await this.runInitiatorScope(instance.initiatedBy, () => reprobeGate.onHelperComplete({
644
- workspaceId,
645
- instance,
646
- block,
647
- step,
648
- result: jobResult,
649
- }));
650
- }
651
- }
652
- // Record the just-finished helper attempt before re-probing. The gate's next
653
- // precheck stays the source of truth for pass/fail, but the helper's own account
654
- // (what it did, and for the conflict-resolver which files it left conflicting) is
655
- // otherwise discarded here — leaving the gate window with only a bare attempt
656
- // count. Capture it so the UI can show what each attempt tried.
657
- if (step.gate) {
658
- const attempt = recordGateAttempt(step.gate, update.state === 'done'
659
- ? { state: 'done', output: update.result.output ?? null }
660
- : { state: 'failed', error: update.error ?? null }, this.clock.now());
661
- step.gate.attemptLog = [...(step.gate.attemptLog ?? []), attempt];
662
- // The conflicts gate's precheck carries no failure detail of its own (GitHub
663
- // reports mergeability as a single bit), so surface the resolver's account as
664
- // the gate's last failure summary. CI's probe already sets a richer summary
665
- // (the red checks) — don't clobber it with the fixer's push note.
666
- if (step.agentKind === CONFLICTS_AGENT_KIND && attempt.summary) {
667
- step.gate.lastFailureSummary = attempt.summary;
668
- }
669
- }
670
- step.jobId = undefined;
671
- step.subtasks = undefined;
672
- if (step.gate)
673
- step.gate.phase = 'checking';
674
- await this.runStateMachine.casPersist(workspaceId, instance);
675
- await this.runStateMachine.emitInstance(workspaceId, instance);
676
- return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
550
+ return this.reprobeGateAfterHelper(reprobeGate, { workspaceId, instance, step, update });
677
551
  }
678
552
  // A `tester` step in its `fixing` phase has a Fixer job in flight, NOT the
679
553
  // step's own work: when it finishes (or fails) we drop the handle, return to
@@ -768,6 +642,81 @@ export class RunDispatcher {
768
642
  step.jobId = undefined;
769
643
  return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
770
644
  }
645
+ /**
646
+ * Handle a `running` poll: a successful poll proves the container is up, so surface live subtask
647
+ * progress (e.g. 3/8 todos) without advancing the step. Only persist + emit when something
648
+ * actually changed so an idle poll doesn't churn storage or the event stream. Folds the poll's
649
+ * delta via {@link applyRunningFold} — a cheap pre-check against the loaded snapshot, then the
650
+ * authoritative re-apply on fresh state under CAS (idempotent for the set-to-latest folds and
651
+ * correct for the drain-on-read follow-up append). Split from {@link pollAgentJobInner} to stay
652
+ * under the statement ceiling.
653
+ */
654
+ async handleRunningPoll(workspaceId, executionId, instance, update, jobId) {
655
+ const foldCtx = { jobId, update, workspaceId };
656
+ // Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
657
+ // (the common case). The mutation is discarded — the authoritative write re-applies the
658
+ // same fold on fresh state under CAS below.
659
+ if (await this.applyRunningFold(instance, foldCtx)) {
660
+ try {
661
+ const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
662
+ await this.applyRunningFold(fresh, foldCtx);
663
+ });
664
+ // Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
665
+ // LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
666
+ // the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
667
+ await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
668
+ }
669
+ catch (error) {
670
+ // The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
671
+ // past the retry budget (`ConflictError`) — re-drive on fresh state rather than
672
+ // failing the run; the next entry no-ops on a gone/terminal run.
673
+ if (error instanceof NotFoundError || error instanceof ConflictError) {
674
+ throw new RunContendedError(executionId);
675
+ }
676
+ throw error;
677
+ }
678
+ }
679
+ return { kind: 'awaiting_job', jobId, stepIndex: instance.currentStep };
680
+ }
681
+ /**
682
+ * A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call) declares a
683
+ * `resolveHelperCompletion` hook. When such a helper's job settles — done OR failed — call the
684
+ * hook INSTEAD of re-probing the precheck (re-probing an investigate-don't-fix helper would just
685
+ * regress again and burn the budget) and finish the gate step with the output it returns. Returns
686
+ * the resulting {@link AdvanceResult}, or `null` when this branch doesn't apply (the caller falls
687
+ * through to the re-probe / other completion paths).
688
+ */
689
+ async resolveInvestigateHelperCompletion(workspaceId, instance, step, update) {
690
+ const completionGate = this.gateFor(step.agentKind);
691
+ if (completionGate?.resolveHelperCompletion &&
692
+ step.gate?.phase === 'working' &&
693
+ (update.state === 'done' || update.state === 'failed')) {
694
+ const block = await this.blockRepository.get(workspaceId, instance.blockId);
695
+ step.jobId = undefined;
696
+ step.subtasks = undefined;
697
+ if (!block)
698
+ return { kind: 'noop' };
699
+ const isFinalStep = instance.currentStep === instance.steps.length - 1;
700
+ const jobResult = update.state === 'done'
701
+ ? { state: 'done', result: update.result }
702
+ : { state: 'failed', error: update.error ?? null };
703
+ const resolution = await completionGate.resolveHelperCompletion({
704
+ workspaceId,
705
+ instance,
706
+ block,
707
+ step,
708
+ result: jobResult,
709
+ });
710
+ // Preserve the done-result's fields (usage metering etc.) while recording the gate's
711
+ // resolved output; a failed investigation has no result to carry.
712
+ const base = update.state === 'done' ? update.result : { output: '' };
713
+ return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
714
+ ...base,
715
+ output: resolution.output,
716
+ });
717
+ }
718
+ return null;
719
+ }
771
720
  /**
772
721
  * Fold a running poll's container signals into `step.container`: a successful poll
773
722
  * proves the container is `up`, and the harness's live phase (clone / agent / push)
@@ -775,6 +724,95 @@ export class RunDispatcher {
775
724
  * so the caller only persists + emits on a real transition (an idle poll is a no-op).
776
725
  * Prior id/url/phase are preserved when a poll omits them (drain-on-read semantics).
777
726
  */
727
+ /**
728
+ * Fold a running poll's live delta (container status/phase, subtask counts, backend, streamed
729
+ * follow-ups, env projection) onto `target`, returning whether anything changed. Idempotent for
730
+ * the set-to-latest folds and correct under CAS retry for the drain-on-read follow-up append —
731
+ * see the call site in {@link pollAgentJobInner}. A concurrent write that advanced the step (or
732
+ * superseded the job) makes it a no-op.
733
+ */
734
+ async applyRunningFold(target, ctx) {
735
+ const { jobId, update, workspaceId } = ctx;
736
+ const s = target.steps[target.currentStep];
737
+ // The step advanced (or the job was superseded) under a concurrent write — nothing to fold.
738
+ if (!s || s.jobId !== jobId)
739
+ return false;
740
+ let changed = false;
741
+ if (this.applyContainerRunning(s, update))
742
+ changed = true;
743
+ if (this.applySubtaskProgress(s, update.subtasks))
744
+ changed = true;
745
+ // The transport reports WHICH backend served the job on the first poll (native host
746
+ // process vs. sandboxed container) — record it in the run diagnostics.
747
+ if (this.recordBackendDiagnostics(target, update.backend))
748
+ changed = true;
749
+ // Append any forward-looking items the Coder streamed since the last poll so the
750
+ // Follow-up companion lights up + accrues items LIVE while the container still runs.
751
+ if (this.followUpGate.appendStreamedFollowUps(s, update.followUps))
752
+ changed = true;
753
+ // Refresh the env projection so its status transitions (provisioning→ready→
754
+ // expired/torn_down) and any error stay live in the run details during the run.
755
+ if (await this.deployer.attachEnvironmentProjection(workspaceId, target.blockId, s)) {
756
+ changed = true;
757
+ }
758
+ return changed;
759
+ }
760
+ /**
761
+ * A polling gate step's in-flight job is its helper agent (ci-fixer / conflict-resolver / the
762
+ * human-review fixer), NOT the step's own work: when it finishes (or fails) we don't record a
763
+ * result or advance — we run any deterministic post-helper bookkeeping hook, record the attempt,
764
+ * drop the handle, return the gate to `checking`, and re-run the precheck (the helper's push
765
+ * triggers a fresh CI run / updates mergeability). A helper that failed without pushing leaves the
766
+ * precheck negative, so the next check re-dispatches (until the attempt budget is spent). Split
767
+ * from {@link pollAgentJobInner} to keep it under the complexity ceiling.
768
+ */
769
+ async reprobeGateAfterHelper(gate, ctx) {
770
+ const { workspaceId, instance, step, update } = ctx;
771
+ // A gate may need deterministic GitHub-side bookkeeping to land BEFORE the re-probe
772
+ // reads it (the human-review gate replies to + RESOLVES the threads it handed the
773
+ // fixer, so the next probe counts them addressed). Run that side-effect hook first;
774
+ // it does NOT replace the re-probe (unlike resolveHelperCompletion).
775
+ if (gate.onHelperComplete && step.gate) {
776
+ const block = await this.blockRepository.get(workspaceId, instance.blockId);
777
+ if (block) {
778
+ const jobResult = update.state === 'done'
779
+ ? { state: 'done', result: update.result }
780
+ : { state: 'failed', error: update.error ?? null };
781
+ await this.runInitiatorScope(instance.initiatedBy, () => gate.onHelperComplete({
782
+ workspaceId,
783
+ instance,
784
+ block,
785
+ step,
786
+ result: jobResult,
787
+ }));
788
+ }
789
+ }
790
+ // Record the just-finished helper attempt before re-probing. The gate's next
791
+ // precheck stays the source of truth for pass/fail, but the helper's own account
792
+ // (what it did, and for the conflict-resolver which files it left conflicting) is
793
+ // otherwise discarded here — leaving the gate window with only a bare attempt
794
+ // count. Capture it so the UI can show what each attempt tried.
795
+ if (step.gate) {
796
+ const attempt = recordGateAttempt(step.gate, update.state === 'done'
797
+ ? { state: 'done', output: update.result.output ?? null }
798
+ : { state: 'failed', error: update.error ?? null }, this.clock.now());
799
+ step.gate.attemptLog = [...(step.gate.attemptLog ?? []), attempt];
800
+ // The conflicts gate's precheck carries no failure detail of its own (GitHub
801
+ // reports mergeability as a single bit), so surface the resolver's account as
802
+ // the gate's last failure summary. CI's probe already sets a richer summary
803
+ // (the red checks) — don't clobber it with the fixer's push note.
804
+ if (step.agentKind === CONFLICTS_AGENT_KIND && attempt.summary) {
805
+ step.gate.lastFailureSummary = attempt.summary;
806
+ }
807
+ }
808
+ step.jobId = undefined;
809
+ step.subtasks = undefined;
810
+ if (step.gate)
811
+ step.gate.phase = 'checking';
812
+ await this.runStateMachine.casPersist(workspaceId, instance);
813
+ await this.runStateMachine.emitInstance(workspaceId, instance);
814
+ return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
815
+ }
778
816
  applyContainerRunning(step, update) {
779
817
  const prev = step.container ?? undefined;
780
818
  const next = {
@@ -1026,12 +1064,7 @@ export class RunDispatcher {
1026
1064
  options: [...result.decision.options],
1027
1065
  chosen: null,
1028
1066
  };
1029
- this.stepGraph.pauseStepForInput(step);
1030
- instance.status = 'blocked';
1031
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1032
- await this.runStateMachine.casPersist(workspaceId, instance);
1033
- await this.runStateMachine.emitInstance(workspaceId, instance);
1034
- return { kind: 'awaiting_decision', decisionId: step.decision.id };
1067
+ return this.parkStepAwaitingInput(workspaceId, instance, step, step.decision.id);
1035
1068
  }
1036
1069
  // Completion-path interceptors short-circuit before the normal finish/advance for the
1037
1070
  // few kinds whose verdict drives run flow: a container-backed companion applies its
@@ -1072,30 +1105,7 @@ export class RunDispatcher {
1072
1105
  // (service-connections phase 3) additionally reports the PRs it opened in the
1073
1106
  // connected involved-service repos; record them beside the own-service PR (they may
1074
1107
  // even arrive when the own service was a no-op and only a peer changed).
1075
- if (result.pullRequest || result.peerPullRequests?.length) {
1076
- // Read the block before the update so we can tell whether this PR is newly
1077
- // opened (vs. the same PR re-reported by a re-run/retry of the coder step).
1078
- const priorBlock = this.issueWriteback
1079
- ? await this.blockRepository.get(workspaceId, instance.blockId)
1080
- : null;
1081
- await this.blockRepository.update(workspaceId, instance.blockId, {
1082
- ...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
1083
- ...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
1084
- });
1085
- // Best-effort writeback: comment on the task's linked tracker issue(s) that a
1086
- // PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
1087
- // retry that re-reports the same PR must not re-comment (the tracker comment is not
1088
- // idempotent). Gated inside the provider by the workspace setting + per-task
1089
- // override; fire-and-forget so a tracker outage never fails the run.
1090
- if (this.issueWriteback &&
1091
- priorBlock &&
1092
- result.pullRequest &&
1093
- priorBlock.pullRequest?.url !== result.pullRequest.url) {
1094
- await this.issueWriteback
1095
- .onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
1096
- .catch(() => { });
1097
- }
1098
- }
1108
+ await this.recordOpenedPullRequests(workspaceId, instance, result);
1099
1109
  // Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
1100
1110
  // ingestion, task-estimate persistence). It reshapes the agent's structured result into
1101
1111
  // domain state and may replace `step.output` (the estimator's readable summary). Its
@@ -1103,19 +1113,7 @@ export class RunDispatcher {
1103
1113
  // reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
1104
1114
  // sits exactly where the old inline ingestion branches did. See
1105
1115
  // {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1106
- const postCompletionResolver = this.stepResolverFor(step.agentKind);
1107
- if (postCompletionResolver?.phase === 'post-completion' &&
1108
- (postCompletionResolver.applies?.(result) ?? true)) {
1109
- const resolution = await postCompletionResolver.resolve({
1110
- workspaceId,
1111
- instance,
1112
- step,
1113
- result,
1114
- isFinalStep,
1115
- });
1116
- if (resolution?.output !== undefined)
1117
- step.output = resolution.output;
1118
- }
1116
+ await this.applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep);
1119
1117
  // A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
1120
1118
  // returns its raw Pi transcript summary as `result.output` — useless for review.
1121
1119
  // Replace the step's reviewable output with a rendering of the artifact ITSELF, so
@@ -1150,12 +1148,7 @@ export class RunDispatcher {
1150
1148
  status: 'pending',
1151
1149
  proposal: step.output,
1152
1150
  };
1153
- this.stepGraph.pauseStepForInput(step);
1154
- instance.status = 'blocked';
1155
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1156
- await this.runStateMachine.casPersist(workspaceId, instance);
1157
- await this.runStateMachine.emitInstance(workspaceId, instance);
1158
- return { kind: 'awaiting_decision', decisionId: step.approval.id };
1151
+ return this.parkStepAwaitingInput(workspaceId, instance, step, step.approval.id);
1159
1152
  }
1160
1153
  // Persist the agent's reported confidence whenever a step reports it, for board
1161
1154
  // transparency. Position-independent: it must NOT be tied to the final step, since a
@@ -1171,23 +1164,7 @@ export class RunDispatcher {
1171
1164
  // — so inserting a later step (post-release-health) can't silently disable it. A
1172
1165
  // resolver that owns the block's terminal status (the merger sets `done`/`pr_ready`)
1173
1166
  // tells `finalizeBlock` to leave it alone.
1174
- const resolver = this.stepResolverFor(step.agentKind);
1175
- let resolverOwnsTerminalStatus = false;
1176
- if (resolver &&
1177
- (resolver.phase ?? 'terminal') === 'terminal' &&
1178
- (resolver.applies?.(result) ?? true)) {
1179
- const resolution = await resolver.resolve({
1180
- workspaceId,
1181
- instance,
1182
- step,
1183
- result,
1184
- isFinalStep,
1185
- });
1186
- if (resolution?.output !== undefined)
1187
- step.output = resolution.output;
1188
- if (resolution?.ownsTerminalStatus)
1189
- resolverOwnsTerminalStatus = true;
1190
- }
1167
+ const resolverOwnsTerminalStatus = await this.applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep);
1191
1168
  // A registered custom kind's POST-ops run deterministic backend repo work from the
1192
1169
  // agent's structured result (coerce its JSON, render artifact files, commit them via
1193
1170
  // the checkout-free RepoFiles port — the blueprint/spec rendering that used to live in
@@ -1228,6 +1205,92 @@ export class RunDispatcher {
1228
1205
  await this.runStateMachine.emitInstance(workspaceId, instance);
1229
1206
  return { kind: 'continue' };
1230
1207
  }
1208
+ /**
1209
+ * Park a step on the durable decision-wait: pause it for input, flip the run + block to
1210
+ * `blocked`, persist under CAS, emit, and report `awaiting_decision` keyed by `decisionId`.
1211
+ * Shared by the raised-decision and human-approval branches of {@link recordStepResult}.
1212
+ */
1213
+ async parkStepAwaitingInput(workspaceId, instance, step, decisionId) {
1214
+ this.stepGraph.pauseStepForInput(step);
1215
+ instance.status = 'blocked';
1216
+ await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1217
+ await this.runStateMachine.casPersist(workspaceId, instance);
1218
+ await this.runStateMachine.emitInstance(workspaceId, instance);
1219
+ return { kind: 'awaiting_decision', decisionId };
1220
+ }
1221
+ /**
1222
+ * Record any PR(s) the step opened onto the block (its own-service PR + any peer-service PRs),
1223
+ * and best-effort write back to the task's linked tracker issue(s) when the own-service PR is
1224
+ * NEWLY opened (a retry that re-reports the same PR must not re-comment). Split from
1225
+ * {@link recordStepResult} to keep it under the statement ceiling; a no-op when no PR was opened.
1226
+ */
1227
+ async recordOpenedPullRequests(workspaceId, instance, result) {
1228
+ if (!(result.pullRequest || result.peerPullRequests?.length))
1229
+ return;
1230
+ // Read the block before the update so we can tell whether this PR is newly
1231
+ // opened (vs. the same PR re-reported by a re-run/retry of the coder step).
1232
+ const priorBlock = this.issueWriteback
1233
+ ? await this.blockRepository.get(workspaceId, instance.blockId)
1234
+ : null;
1235
+ await this.blockRepository.update(workspaceId, instance.blockId, {
1236
+ ...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
1237
+ ...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
1238
+ });
1239
+ // Best-effort writeback: comment on the task's linked tracker issue(s) that a
1240
+ // PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
1241
+ // retry that re-reports the same PR must not re-comment (the tracker comment is not
1242
+ // idempotent). Gated inside the provider by the workspace setting + per-task
1243
+ // override; fire-and-forget so a tracker outage never fails the run.
1244
+ if (this.issueWriteback &&
1245
+ priorBlock &&
1246
+ result.pullRequest &&
1247
+ priorBlock.pullRequest?.url !== result.pullRequest.url) {
1248
+ await this.issueWriteback
1249
+ .onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
1250
+ .catch(() => { });
1251
+ }
1252
+ }
1253
+ /**
1254
+ * Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec ingestion,
1255
+ * task-estimate persistence). It reshapes the agent's structured result into domain state and may
1256
+ * replace `step.output`. A no-op when no post-completion resolver applies. See
1257
+ * {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1258
+ */
1259
+ async applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep) {
1260
+ const postCompletionResolver = this.stepResolverFor(step.agentKind);
1261
+ if (postCompletionResolver?.phase !== 'post-completion' ||
1262
+ !(postCompletionResolver.applies?.(result) ?? true)) {
1263
+ return;
1264
+ }
1265
+ const resolution = await postCompletionResolver.resolve({
1266
+ workspaceId,
1267
+ instance,
1268
+ step,
1269
+ result,
1270
+ isFinalStep,
1271
+ });
1272
+ if (resolution?.output !== undefined)
1273
+ step.output = resolution.output;
1274
+ }
1275
+ /**
1276
+ * Run any DETERMINISTIC terminal-phase resolver for this step kind (e.g. the merger performs the
1277
+ * real GitHub merge with backend-held credentials), mutating `step.output` when it reshapes it.
1278
+ * Position-independent: it fires whenever the step finishes, not only when it's last. Returns
1279
+ * whether the resolver OWNS the block's terminal status (the merger sets `done`/`pr_ready`), so
1280
+ * the advance/finalize path leaves that status alone rather than clobbering it to `in_progress`.
1281
+ */
1282
+ async applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep) {
1283
+ const resolver = this.stepResolverFor(step.agentKind);
1284
+ if (!resolver ||
1285
+ (resolver.phase ?? 'terminal') !== 'terminal' ||
1286
+ !(resolver.applies?.(result) ?? true)) {
1287
+ return false;
1288
+ }
1289
+ const resolution = await resolver.resolve({ workspaceId, instance, step, result, isFinalStep });
1290
+ if (resolution?.output !== undefined)
1291
+ step.output = resolution.output;
1292
+ return resolution?.ownsTerminalStatus ?? false;
1293
+ }
1231
1294
  /**
1232
1295
  * File a tracking issue/ticket for a `tracker` step from the preceding `analysis`
1233
1296
  * output. Non-LLM and best-effort: when no provider is wired or none is configured