@cat-factory/orchestration 0.129.7 → 0.129.10

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 (27) hide show
  1. package/dist/container.d.ts +0 -1
  2. package/dist/container.d.ts.map +1 -1
  3. package/dist/container.js +83 -72
  4. package/dist/container.js.map +1 -1
  5. package/dist/modules/execution/CompanionController.d.ts +7 -0
  6. package/dist/modules/execution/CompanionController.d.ts.map +1 -1
  7. package/dist/modules/execution/CompanionController.js +61 -43
  8. package/dist/modules/execution/CompanionController.js.map +1 -1
  9. package/dist/modules/execution/PrReviewController.d.ts.map +1 -1
  10. package/dist/modules/execution/PrReviewController.js +6 -0
  11. package/dist/modules/execution/PrReviewController.js.map +1 -1
  12. package/dist/modules/execution/PrReviewResolutionController.d.ts +69 -0
  13. package/dist/modules/execution/PrReviewResolutionController.d.ts.map +1 -0
  14. package/dist/modules/execution/PrReviewResolutionController.js +199 -0
  15. package/dist/modules/execution/PrReviewResolutionController.js.map +1 -0
  16. package/dist/modules/execution/RunDispatcher.d.ts +53 -33
  17. package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
  18. package/dist/modules/execution/RunDispatcher.js +188 -258
  19. package/dist/modules/execution/RunDispatcher.js.map +1 -1
  20. package/dist/modules/execution/prReview.logic.d.ts +53 -10
  21. package/dist/modules/execution/prReview.logic.d.ts.map +1 -1
  22. package/dist/modules/execution/prReview.logic.js +131 -17
  23. package/dist/modules/execution/prReview.logic.js.map +1 -1
  24. package/dist/modules/sandbox/SandboxRunService.d.ts.map +1 -1
  25. package/dist/modules/sandbox/SandboxRunService.js +22 -10
  26. package/dist/modules/sandbox/SandboxRunService.js.map +1 -1
  27. package/package.json +11 -11
@@ -1,4 +1,4 @@
1
- import { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, FIXER_AGENT_KIND, getErrorMessage, isAsyncAgentExecutor, NotFoundError, parseLocalModelId, recordGateAttempt, RunContendedError, sameSubtasks, } from '@cat-factory/kernel';
1
+ import { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, getErrorMessage, isAsyncAgentExecutor, NotFoundError, parseLocalModelId, recordGateAttempt, RunContendedError, sameSubtasks, } from '@cat-factory/kernel';
2
2
  import { parseBlueprintService, parseSpecDoc } from '@cat-factory/contracts';
3
3
  import { commitInitiativeTracker, FORK_PROPOSER_KIND, PR_REVIEWER_KIND } from '@cat-factory/agents';
4
4
  import { isDeployStep } from '@cat-factory/integrations';
@@ -15,7 +15,8 @@ import { MergeResolver } from './MergeResolver.js';
15
15
  import { ReviewGateController } from './ReviewGateController.js';
16
16
  import { ForkDecisionController } from './ForkDecisionController.js';
17
17
  import { PrReviewController } from './PrReviewController.js';
18
- import { buildPrReviewPost, initialPrReviewState, renderPrReviewFixerFeedback, } from './prReview.logic.js';
18
+ import { initialPrReviewState } from './prReview.logic.js';
19
+ import { PrReviewResolutionController } from './PrReviewResolutionController.js';
19
20
  import { DEFAULT_FORK_MAX_CHAT_TURNS, resolveForkTriState, shouldProposeForkAuto, } from './forkDecision.logic.js';
20
21
  import { RunStateMachine } from './RunStateMachine.js';
21
22
  import { StepGraph } from './StepGraph.js';
@@ -24,19 +25,6 @@ import { RalphController } from './RalphController.js';
24
25
  import { VisualConfirmationController } from './VisualConfirmationController.js';
25
26
  import {} from './step-handler-registry.js';
26
27
  import { buildStepCompletionInterceptors as buildStepCompletionInterceptorsImpl, buildStepHandlerRegistry as buildStepHandlerRegistryImpl, buildStepResolverRegistry as buildStepResolverRegistryImpl, } from './dispatcher-registries.js';
27
- /**
28
- * The PR number a `review` task targets: the explicit `prNumber` field wins, else parse it from
29
- * the `prUrl` (`…/pull/42` on GitHub, `…/merge_requests/42` on GitLab). Undefined when neither
30
- * yields one — the PR-review `fix`/`post` resolutions then report the PR unresolvable.
31
- */
32
- function reviewPrNumber(block) {
33
- const fields = block?.taskTypeFields;
34
- if (typeof fields?.prNumber === 'number')
35
- return fields.prNumber;
36
- const url = fields?.prUrl?.trim();
37
- const match = url ? /\/(?:pull|merge_requests)\/(\d+)/.exec(url) : null;
38
- return match ? Number(match[1]) : undefined;
39
- }
40
28
  /**
41
29
  * The per-step dispatch + completion spine of the execution engine. It owns the four
42
30
  * registries (step handlers, completion interceptors, post-completion / terminal resolvers,
@@ -104,6 +92,8 @@ export class RunDispatcher {
104
92
  */
105
93
  deployer;
106
94
  repoOps;
95
+ /** Driver-side PR deep-review resolution (`fix` / `post`), extracted as a cohesive collaborator. */
96
+ prReviewResolution;
107
97
  /**
108
98
  * The Follow-up companion gate (the future-looking Coder's streamed items, the
109
99
  * park-until-decided gate, and the human-action API), extracted to
@@ -198,6 +188,13 @@ export class RunDispatcher {
198
188
  resolveRunRepoContext: deps.resolveRunRepoContext,
199
189
  issueWriteback: deps.issueWriteback,
200
190
  });
191
+ this.prReviewResolution = new PrReviewResolutionController({
192
+ runStateMachine: deps.runStateMachine,
193
+ resolveRunRepoContext: deps.resolveRunRepoContext,
194
+ runInitiatorScope: this.runInitiatorScope,
195
+ recordStepResult: (ws, instance, step, isFinalStep, result) => this.recordStepResult(ws, instance, step, isFinalStep, result),
196
+ handleAgentStep: (ctx, dispatchKind, augment) => this.handleAgentStep(ctx, dispatchKind, augment),
197
+ });
201
198
  // Assemble the seam the extracted dispatch-registry builders close over: the collaborators
202
199
  // above + bound call-backs into this dispatcher's completion / gate / phase methods, so the
203
200
  // built-ins resolve everything at call time exactly as the former inline closures did.
@@ -527,42 +524,7 @@ export class RunDispatcher {
527
524
  agentKind: step.agentKind,
528
525
  });
529
526
  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 via {@link applyRunningFold}. The captured
536
- // `update` is the source, so re-applying on a freshly-reloaded instance is idempotent for
537
- // the set-to-latest folds AND correct for the APPEND fold: the harness follow-up buffer is
538
- // drain-on-read (`runner.ts`), so those streamed items exist ONLY on this `update` — abort-
539
- // and-redrive would lose them, whereas re-applying under `mutateInstance` (which reloads a
540
- // clean base each attempt) preserves them.
541
- const foldCtx = { jobId: step.jobId, update, workspaceId };
542
- // Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
543
- // (the common case). The mutation is discarded — the authoritative write re-applies the
544
- // same fold on fresh state under CAS below.
545
- if (await this.applyRunningFold(instance, foldCtx)) {
546
- try {
547
- const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
548
- await this.applyRunningFold(fresh, foldCtx);
549
- });
550
- // Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
551
- // LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
552
- // the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
553
- await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
554
- }
555
- catch (error) {
556
- // The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
557
- // past the retry budget (`ConflictError`) — re-drive on fresh state rather than
558
- // failing the run; the next entry no-ops on a gone/terminal run.
559
- if (error instanceof NotFoundError || error instanceof ConflictError) {
560
- throw new RunContendedError(executionId);
561
- }
562
- throw error;
563
- }
564
- }
565
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
527
+ return this.handleRunningPoll(workspaceId, executionId, instance, update, step.jobId);
566
528
  }
567
529
  // A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call)
568
530
  // declares a `resolveHelperCompletion` hook on its definition. When such a helper's job
@@ -571,34 +533,9 @@ export class RunDispatcher {
571
533
  // budget) and finish the gate step with the output it returns. The gate raises its own
572
534
  // `release_regression` notification + enriches any open incident inside the hook (from the
573
535
  // signals stashed at escalation); the run then completes for a human to act out-of-band.
574
- const completionGate = this.gateFor(step.agentKind);
575
- if (completionGate?.resolveHelperCompletion &&
576
- step.gate?.phase === 'working' &&
577
- (update.state === 'done' || update.state === 'failed')) {
578
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
579
- step.jobId = undefined;
580
- step.subtasks = undefined;
581
- if (!block)
582
- return { kind: 'noop' };
583
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
584
- const jobResult = update.state === 'done'
585
- ? { state: 'done', result: update.result }
586
- : { state: 'failed', error: update.error ?? null };
587
- const resolution = await completionGate.resolveHelperCompletion({
588
- workspaceId,
589
- instance,
590
- block,
591
- step,
592
- result: jobResult,
593
- });
594
- // Preserve the done-result's fields (usage metering etc.) while recording the gate's
595
- // resolved output; a failed investigation has no result to carry.
596
- const base = update.state === 'done' ? update.result : { output: '' };
597
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
598
- ...base,
599
- output: resolution.output,
600
- });
601
- }
536
+ const investigated = await this.resolveInvestigateHelperCompletion(workspaceId, instance, step, update);
537
+ if (investigated)
538
+ return investigated;
602
539
  // A polling gate step's in-flight job is its helper agent (ci-fixer /
603
540
  // conflict-resolver), NOT the step's own work: when it finishes (or fails) we
604
541
  // don't record a result or advance — we drop the handle, return the gate to
@@ -702,6 +639,81 @@ export class RunDispatcher {
702
639
  step.jobId = undefined;
703
640
  return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
704
641
  }
642
+ /**
643
+ * Handle a `running` poll: a successful poll proves the container is up, so surface live subtask
644
+ * progress (e.g. 3/8 todos) without advancing the step. Only persist + emit when something
645
+ * actually changed so an idle poll doesn't churn storage or the event stream. Folds the poll's
646
+ * delta via {@link applyRunningFold} — a cheap pre-check against the loaded snapshot, then the
647
+ * authoritative re-apply on fresh state under CAS (idempotent for the set-to-latest folds and
648
+ * correct for the drain-on-read follow-up append). Split from {@link pollAgentJobInner} to stay
649
+ * under the statement ceiling.
650
+ */
651
+ async handleRunningPoll(workspaceId, executionId, instance, update, jobId) {
652
+ const foldCtx = { jobId, update, workspaceId };
653
+ // Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
654
+ // (the common case). The mutation is discarded — the authoritative write re-applies the
655
+ // same fold on fresh state under CAS below.
656
+ if (await this.applyRunningFold(instance, foldCtx)) {
657
+ try {
658
+ const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
659
+ await this.applyRunningFold(fresh, foldCtx);
660
+ });
661
+ // Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
662
+ // LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
663
+ // the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
664
+ await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
665
+ }
666
+ catch (error) {
667
+ // The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
668
+ // past the retry budget (`ConflictError`) — re-drive on fresh state rather than
669
+ // failing the run; the next entry no-ops on a gone/terminal run.
670
+ if (error instanceof NotFoundError || error instanceof ConflictError) {
671
+ throw new RunContendedError(executionId);
672
+ }
673
+ throw error;
674
+ }
675
+ }
676
+ return { kind: 'awaiting_job', jobId, stepIndex: instance.currentStep };
677
+ }
678
+ /**
679
+ * A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call) declares a
680
+ * `resolveHelperCompletion` hook. When such a helper's job settles — done OR failed — call the
681
+ * hook INSTEAD of re-probing the precheck (re-probing an investigate-don't-fix helper would just
682
+ * regress again and burn the budget) and finish the gate step with the output it returns. Returns
683
+ * the resulting {@link AdvanceResult}, or `null` when this branch doesn't apply (the caller falls
684
+ * through to the re-probe / other completion paths).
685
+ */
686
+ async resolveInvestigateHelperCompletion(workspaceId, instance, step, update) {
687
+ const completionGate = this.gateFor(step.agentKind);
688
+ if (completionGate?.resolveHelperCompletion &&
689
+ step.gate?.phase === 'working' &&
690
+ (update.state === 'done' || update.state === 'failed')) {
691
+ const block = await this.blockRepository.get(workspaceId, instance.blockId);
692
+ step.jobId = undefined;
693
+ step.subtasks = undefined;
694
+ if (!block)
695
+ return { kind: 'noop' };
696
+ const isFinalStep = instance.currentStep === instance.steps.length - 1;
697
+ const jobResult = update.state === 'done'
698
+ ? { state: 'done', result: update.result }
699
+ : { state: 'failed', error: update.error ?? null };
700
+ const resolution = await completionGate.resolveHelperCompletion({
701
+ workspaceId,
702
+ instance,
703
+ block,
704
+ step,
705
+ result: jobResult,
706
+ });
707
+ // Preserve the done-result's fields (usage metering etc.) while recording the gate's
708
+ // resolved output; a failed investigation has no result to carry.
709
+ const base = update.state === 'done' ? update.result : { output: '' };
710
+ return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
711
+ ...base,
712
+ output: resolution.output,
713
+ });
714
+ }
715
+ return null;
716
+ }
705
717
  /**
706
718
  * Fold a running poll's container signals into `step.container`: a successful poll
707
719
  * proves the container is `up`, and the harness's live phase (clone / agent / push)
@@ -1049,12 +1061,7 @@ export class RunDispatcher {
1049
1061
  options: [...result.decision.options],
1050
1062
  chosen: null,
1051
1063
  };
1052
- this.stepGraph.pauseStepForInput(step);
1053
- instance.status = 'blocked';
1054
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1055
- await this.runStateMachine.casPersist(workspaceId, instance);
1056
- await this.runStateMachine.emitInstance(workspaceId, instance);
1057
- return { kind: 'awaiting_decision', decisionId: step.decision.id };
1064
+ return this.parkStepAwaitingInput(workspaceId, instance, step, step.decision.id);
1058
1065
  }
1059
1066
  // Completion-path interceptors short-circuit before the normal finish/advance for the
1060
1067
  // few kinds whose verdict drives run flow: a container-backed companion applies its
@@ -1095,30 +1102,7 @@ export class RunDispatcher {
1095
1102
  // (service-connections phase 3) additionally reports the PRs it opened in the
1096
1103
  // connected involved-service repos; record them beside the own-service PR (they may
1097
1104
  // even arrive when the own service was a no-op and only a peer changed).
1098
- if (result.pullRequest || result.peerPullRequests?.length) {
1099
- // Read the block before the update so we can tell whether this PR is newly
1100
- // opened (vs. the same PR re-reported by a re-run/retry of the coder step).
1101
- const priorBlock = this.issueWriteback
1102
- ? await this.blockRepository.get(workspaceId, instance.blockId)
1103
- : null;
1104
- await this.blockRepository.update(workspaceId, instance.blockId, {
1105
- ...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
1106
- ...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
1107
- });
1108
- // Best-effort writeback: comment on the task's linked tracker issue(s) that a
1109
- // PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
1110
- // retry that re-reports the same PR must not re-comment (the tracker comment is not
1111
- // idempotent). Gated inside the provider by the workspace setting + per-task
1112
- // override; fire-and-forget so a tracker outage never fails the run.
1113
- if (this.issueWriteback &&
1114
- priorBlock &&
1115
- result.pullRequest &&
1116
- priorBlock.pullRequest?.url !== result.pullRequest.url) {
1117
- await this.issueWriteback
1118
- .onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
1119
- .catch(() => { });
1120
- }
1121
- }
1105
+ await this.recordOpenedPullRequests(workspaceId, instance, result);
1122
1106
  // Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
1123
1107
  // ingestion, task-estimate persistence). It reshapes the agent's structured result into
1124
1108
  // domain state and may replace `step.output` (the estimator's readable summary). Its
@@ -1126,19 +1110,7 @@ export class RunDispatcher {
1126
1110
  // reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
1127
1111
  // sits exactly where the old inline ingestion branches did. See
1128
1112
  // {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1129
- const postCompletionResolver = this.stepResolverFor(step.agentKind);
1130
- if (postCompletionResolver?.phase === 'post-completion' &&
1131
- (postCompletionResolver.applies?.(result) ?? true)) {
1132
- const resolution = await postCompletionResolver.resolve({
1133
- workspaceId,
1134
- instance,
1135
- step,
1136
- result,
1137
- isFinalStep,
1138
- });
1139
- if (resolution?.output !== undefined)
1140
- step.output = resolution.output;
1141
- }
1113
+ await this.applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep);
1142
1114
  // A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
1143
1115
  // returns its raw Pi transcript summary as `result.output` — useless for review.
1144
1116
  // Replace the step's reviewable output with a rendering of the artifact ITSELF, so
@@ -1173,12 +1145,7 @@ export class RunDispatcher {
1173
1145
  status: 'pending',
1174
1146
  proposal: step.output,
1175
1147
  };
1176
- this.stepGraph.pauseStepForInput(step);
1177
- instance.status = 'blocked';
1178
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1179
- await this.runStateMachine.casPersist(workspaceId, instance);
1180
- await this.runStateMachine.emitInstance(workspaceId, instance);
1181
- return { kind: 'awaiting_decision', decisionId: step.approval.id };
1148
+ return this.parkStepAwaitingInput(workspaceId, instance, step, step.approval.id);
1182
1149
  }
1183
1150
  // Persist the agent's reported confidence whenever a step reports it, for board
1184
1151
  // transparency. Position-independent: it must NOT be tied to the final step, since a
@@ -1194,23 +1161,7 @@ export class RunDispatcher {
1194
1161
  // — so inserting a later step (post-release-health) can't silently disable it. A
1195
1162
  // resolver that owns the block's terminal status (the merger sets `done`/`pr_ready`)
1196
1163
  // tells `finalizeBlock` to leave it alone.
1197
- const resolver = this.stepResolverFor(step.agentKind);
1198
- let resolverOwnsTerminalStatus = false;
1199
- if (resolver &&
1200
- (resolver.phase ?? 'terminal') === 'terminal' &&
1201
- (resolver.applies?.(result) ?? true)) {
1202
- const resolution = await resolver.resolve({
1203
- workspaceId,
1204
- instance,
1205
- step,
1206
- result,
1207
- isFinalStep,
1208
- });
1209
- if (resolution?.output !== undefined)
1210
- step.output = resolution.output;
1211
- if (resolution?.ownsTerminalStatus)
1212
- resolverOwnsTerminalStatus = true;
1213
- }
1164
+ const resolverOwnsTerminalStatus = await this.applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep);
1214
1165
  // A registered custom kind's POST-ops run deterministic backend repo work from the
1215
1166
  // agent's structured result (coerce its JSON, render artifact files, commit them via
1216
1167
  // the checkout-free RepoFiles port — the blueprint/spec rendering that used to live in
@@ -1251,6 +1202,92 @@ export class RunDispatcher {
1251
1202
  await this.runStateMachine.emitInstance(workspaceId, instance);
1252
1203
  return { kind: 'continue' };
1253
1204
  }
1205
+ /**
1206
+ * Park a step on the durable decision-wait: pause it for input, flip the run + block to
1207
+ * `blocked`, persist under CAS, emit, and report `awaiting_decision` keyed by `decisionId`.
1208
+ * Shared by the raised-decision and human-approval branches of {@link recordStepResult}.
1209
+ */
1210
+ async parkStepAwaitingInput(workspaceId, instance, step, decisionId) {
1211
+ this.stepGraph.pauseStepForInput(step);
1212
+ instance.status = 'blocked';
1213
+ await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1214
+ await this.runStateMachine.casPersist(workspaceId, instance);
1215
+ await this.runStateMachine.emitInstance(workspaceId, instance);
1216
+ return { kind: 'awaiting_decision', decisionId };
1217
+ }
1218
+ /**
1219
+ * Record any PR(s) the step opened onto the block (its own-service PR + any peer-service PRs),
1220
+ * and best-effort write back to the task's linked tracker issue(s) when the own-service PR is
1221
+ * NEWLY opened (a retry that re-reports the same PR must not re-comment). Split from
1222
+ * {@link recordStepResult} to keep it under the statement ceiling; a no-op when no PR was opened.
1223
+ */
1224
+ async recordOpenedPullRequests(workspaceId, instance, result) {
1225
+ if (!(result.pullRequest || result.peerPullRequests?.length))
1226
+ return;
1227
+ // Read the block before the update so we can tell whether this PR is newly
1228
+ // opened (vs. the same PR re-reported by a re-run/retry of the coder step).
1229
+ const priorBlock = this.issueWriteback
1230
+ ? await this.blockRepository.get(workspaceId, instance.blockId)
1231
+ : null;
1232
+ await this.blockRepository.update(workspaceId, instance.blockId, {
1233
+ ...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
1234
+ ...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
1235
+ });
1236
+ // Best-effort writeback: comment on the task's linked tracker issue(s) that a
1237
+ // PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
1238
+ // retry that re-reports the same PR must not re-comment (the tracker comment is not
1239
+ // idempotent). Gated inside the provider by the workspace setting + per-task
1240
+ // override; fire-and-forget so a tracker outage never fails the run.
1241
+ if (this.issueWriteback &&
1242
+ priorBlock &&
1243
+ result.pullRequest &&
1244
+ priorBlock.pullRequest?.url !== result.pullRequest.url) {
1245
+ await this.issueWriteback
1246
+ .onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
1247
+ .catch(() => { });
1248
+ }
1249
+ }
1250
+ /**
1251
+ * Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec ingestion,
1252
+ * task-estimate persistence). It reshapes the agent's structured result into domain state and may
1253
+ * replace `step.output`. A no-op when no post-completion resolver applies. See
1254
+ * {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1255
+ */
1256
+ async applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep) {
1257
+ const postCompletionResolver = this.stepResolverFor(step.agentKind);
1258
+ if (postCompletionResolver?.phase !== 'post-completion' ||
1259
+ !(postCompletionResolver.applies?.(result) ?? true)) {
1260
+ return;
1261
+ }
1262
+ const resolution = await postCompletionResolver.resolve({
1263
+ workspaceId,
1264
+ instance,
1265
+ step,
1266
+ result,
1267
+ isFinalStep,
1268
+ });
1269
+ if (resolution?.output !== undefined)
1270
+ step.output = resolution.output;
1271
+ }
1272
+ /**
1273
+ * Run any DETERMINISTIC terminal-phase resolver for this step kind (e.g. the merger performs the
1274
+ * real GitHub merge with backend-held credentials), mutating `step.output` when it reshapes it.
1275
+ * Position-independent: it fires whenever the step finishes, not only when it's last. Returns
1276
+ * whether the resolver OWNS the block's terminal status (the merger sets `done`/`pr_ready`), so
1277
+ * the advance/finalize path leaves that status alone rather than clobbering it to `in_progress`.
1278
+ */
1279
+ async applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep) {
1280
+ const resolver = this.stepResolverFor(step.agentKind);
1281
+ if (!resolver ||
1282
+ (resolver.phase ?? 'terminal') !== 'terminal' ||
1283
+ !(resolver.applies?.(result) ?? true)) {
1284
+ return false;
1285
+ }
1286
+ const resolution = await resolver.resolve({ workspaceId, instance, step, result, isFinalStep });
1287
+ if (resolution?.output !== undefined)
1288
+ step.output = resolution.output;
1289
+ return resolution?.ownsTerminalStatus ?? false;
1290
+ }
1254
1291
  /**
1255
1292
  * File a tracking issue/ticket for a `tracker` step from the preceding `analysis`
1256
1293
  * output. Non-LLM and best-effort: when no provider is wired or none is configured
@@ -1763,120 +1800,13 @@ export class RunDispatcher {
1763
1800
  return this.handleAgentStep(ctx, FORK_PROPOSER_KIND);
1764
1801
  }
1765
1802
  /**
1766
- * Drive a re-armed PR-review step's RESOLUTION (PR 3). The human resolved a parked review with
1767
- * `fix` or `post`; {@link PrReviewController.resolve} re-armed this step and woke the driver.
1768
- * - `fixing`: dispatch the Fixer against the reviewed PR's head branch with the selected
1769
- * findings folded in (parks on the job; its completion marks the review `done`).
1770
- * - `posting`: publish the selected findings as inline PR review comments, then finish the step.
1771
- */
1772
- async handlePrReviewResolution(ctx) {
1773
- const { step } = ctx;
1774
- if (step.prReview?.status === 'posting')
1775
- return this.postPrReview(ctx);
1776
- return this.dispatchPrReviewFixer(ctx);
1777
- }
1778
- /**
1779
- * Dispatch the Fixer for a PR-review `fix` resolution. A `review` task carries no own work
1780
- * branch — it reviews an EXISTING PR — so resolve the PR's head branch (via the checkout-free
1781
- * `RepoFiles`) and point the Fixer's clone/push at it: fold a synthetic `pullRequest` + an
1782
- * apriori WORKING branch into the dispatch context so the shared `container-coding` +
1783
- * `clone:{branch:'pr'}` fixer body clones + pushes that branch (no new PR), and hand it the
1784
- * selected findings as a prior output (the same injection point the gate helpers use). Fails
1785
- * the run loudly when the PR branch can't be resolved (nothing to push to) rather than pushing
1786
- * blind. On a replay (jobId already set) it re-attaches without re-resolving.
1803
+ * Drive a re-armed PR-review step's RESOLUTION. The human resolved a parked review with `fix` or
1804
+ * `post`; {@link PrReviewController.resolve} re-armed this step and woke the driver. Delegated to
1805
+ * {@link PrReviewResolutionController} (the cohesive driver-side seam); see it for the `fix` /
1806
+ * `post` mechanics.
1787
1807
  */
1788
- async dispatchPrReviewFixer(ctx) {
1789
- const { workspaceId, instance, step, block } = ctx;
1790
- const review = step.prReview;
1791
- const selected = (review.findings ?? []).filter((f) => review.selectedFindingIds?.includes(f.id));
1792
- let headRef = null;
1793
- let prNumber;
1794
- if (!step.jobId) {
1795
- prNumber = reviewPrNumber(block);
1796
- const runRepo = prNumber != null ? await this.resolveRunRepoContext?.(workspaceId, block.id) : null;
1797
- const repo = runRepo?.repo;
1798
- headRef =
1799
- prNumber != null && repo?.pullRequestHeadRef
1800
- ? await this.runInitiatorScope(instance.initiatedBy, () => repo.pullRequestHeadRef(prNumber))
1801
- : null;
1802
- if (prNumber == null || !headRef) {
1803
- return {
1804
- kind: 'job_failed',
1805
- failureKind: 'preflight',
1806
- error: "Can't resolve the reviewed pull request's head branch to push fixes to. The " +
1807
- "'fix' resolution needs a same-repo pull request on this service's linked repository " +
1808
- '(a cross-repo or fork PR is not yet supported — post the findings as comments instead).',
1809
- };
1810
- }
1811
- }
1812
- const resolvedHeadRef = headRef;
1813
- const resolvedPrNumber = prNumber;
1814
- return this.handleAgentStep(ctx, FIXER_AGENT_KIND, (context) => {
1815
- if (resolvedHeadRef && resolvedPrNumber != null) {
1816
- context.block.pullRequest = {
1817
- number: resolvedPrNumber,
1818
- branch: resolvedHeadRef,
1819
- url: review.prUrl ?? '',
1820
- };
1821
- // Build inside the PR head branch (probed, never created) so the work-branch machinery
1822
- // targets it rather than minting a stray `cat-factory/<blockId>` off base.
1823
- context.aprioriBranches = [{ name: resolvedHeadRef, mode: 'working' }];
1824
- }
1825
- context.priorOutputs = [
1826
- ...context.priorOutputs,
1827
- { agentKind: FIXER_AGENT_KIND, output: renderPrReviewFixerFeedback(selected) },
1828
- ];
1829
- });
1830
- }
1831
- /**
1832
- * Post a PR-review `post` resolution: publish the human-selected findings as a single advisory
1833
- * (`COMMENT`) inline review on the reviewed PR via the checkout-free `RepoFiles.createReview`,
1834
- * then finish the step. At-most-once: the `pendingPrReviewPost` marker is consumed (cleared +
1835
- * persisted) BEFORE the (side-effecting) post so a Workflows retry can't submit it twice. When
1836
- * no VCS review write is wired (tests / no GitHub) the findings are still recorded and the step
1837
- * finishes — the review pipeline never reaches this without GitHub in practice.
1838
- *
1839
- * If `createReview` itself throws (GitHub rejects the batched review — e.g. a finding anchored
1840
- * to a line outside the PR diff 422s the WHOLE review — or a transient network/5xx error), the
1841
- * marker is already consumed, so a driver retry would NOT re-post. Rather than silently
1842
- * completing the step as `done` with nothing actually posted (the human would believe the
1843
- * comments landed), fail the step LOUDLY so the failure surfaces on the board — mirroring the
1844
- * `fix` resolution's loud preflight failure. `post` is a fallback resolution, so a hard failure
1845
- * is visible and the human can re-run or choose `fix`/`finish` instead.
1846
- */
1847
- async postPrReview(ctx) {
1848
- const { workspaceId, instance, step, block, isFinalStep } = ctx;
1849
- const review = step.prReview;
1850
- const selected = (review.findings ?? []).filter((f) => review.selectedFindingIds?.includes(f.id));
1851
- let posted = false;
1852
- if (step.pendingPrReviewPost) {
1853
- step.pendingPrReviewPost = null;
1854
- await this.runStateMachine.casPersist(workspaceId, instance);
1855
- const prNumber = reviewPrNumber(block);
1856
- const runRepo = prNumber != null ? await this.resolveRunRepoContext?.(workspaceId, block.id) : null;
1857
- const repo = runRepo?.repo;
1858
- if (prNumber != null && repo?.createReview) {
1859
- try {
1860
- await this.runInitiatorScope(instance.initiatedBy, () => repo.createReview(prNumber, buildPrReviewPost(selected, review.summary)));
1861
- }
1862
- catch (error) {
1863
- return {
1864
- kind: 'job_failed',
1865
- failureKind: 'agent',
1866
- error: `Failed to post the ${selected.length} selected finding` +
1867
- `${selected.length === 1 ? '' : 's'} as a pull-request review: ${getErrorMessage(error)}. ` +
1868
- 'GitHub rejects a review whose inline comment anchors a line outside the PR diff — ' +
1869
- "try the 'fix' resolution, or re-run to post again.",
1870
- };
1871
- }
1872
- posted = true;
1873
- }
1874
- }
1875
- step.prReview = { ...review, status: 'done' };
1876
- const output = posted
1877
- ? `Posted ${selected.length} review comment${selected.length === 1 ? '' : 's'} to the pull request.`
1878
- : `Recorded ${selected.length} selected finding${selected.length === 1 ? '' : 's'}.`;
1879
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, { output });
1808
+ handlePrReviewResolution(ctx) {
1809
+ return this.prReviewResolution.handle(ctx);
1880
1810
  }
1881
1811
  /** Read a run's active implementation-fork decision state, or null. */
1882
1812
  getForkDecision(workspaceId, executionId) {