@cat-factory/orchestration 0.39.0 → 0.39.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.
@@ -1,18 +1,17 @@
1
- import { parseBlueprintService, parseSpecDoc, DEFAULT_COMPANION_MAX_ATTEMPTS, isLocalRunner, } from '@cat-factory/contracts';
2
- import { blueprintPostOp, companionFor, companionTargets, isCompanionKind, isContainerBackedCompanion, registeredAgentStep, registeredPreOps, registeredPostOps, runRepoOps, specPostOp, TASK_ESTIMATOR_AGENT_KIND, } from '@cat-factory/agents';
3
- import { coerceTaskEstimate, summarizeEstimate } from '../estimation/estimate.logic.js';
1
+ import { DEFAULT_COMPANION_MAX_ATTEMPTS, isLocalRunner } from '@cat-factory/contracts';
2
+ import { companionFor, companionTargets, isCompanionKind, } from '@cat-factory/agents';
4
3
  import { validatePipelineShape } from '../pipelines/pipelineShape.js';
5
4
  import { shouldRunGatedStep } from './stepGating.logic.js';
6
- import { reviewableArtifactOutput } from './artifact-review.logic.js';
7
5
  import { resolveIndividualVendors, } from './individualVendors.logic.js';
8
- import { assertFound, ConflictError, getErrorMessage, isModelUsable, NotFoundError, parseLocalModelId, resolveModelRef, sameSubtasks, subscriptionOptionFor, ValidationError, } from '@cat-factory/kernel';
6
+ import { assertFound, ConflictError, isModelUsable, NotFoundError, resolveModelRef, subscriptionOptionFor, ValidationError, } from '@cat-factory/kernel';
9
7
  import { DEFAULT_MERGE_PRESET } from '@cat-factory/kernel';
10
- import { CONFLICTS_AGENT_KIND, MERGER_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, TRACKER_AGENT_KIND, ANALYSIS_AGENT_KIND, TESTER_AGENT_KIND, UI_TESTER_AGENT_KIND, isTesterKind, HUMAN_TEST_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, HUMAN_REVIEW_AGENT_KIND, BLUEPRINTS_AGENT_KIND, SPEC_WRITER_AGENT_KIND, } from './ci.logic.js';
11
- import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND, followUpsToSendBack, hasPendingFollowUps, renderFollowUpRework, shouldLoopCoder, } from './followUp.logic.js';
8
+ import { REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, isTesterKind, HUMAN_TEST_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, HUMAN_REVIEW_AGENT_KIND, } from './ci.logic.js';
9
+ import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND } from './followUp.logic.js';
12
10
  import { AgentContextBuilder, } from './AgentContextBuilder.js';
13
11
  import { CompanionController } from './CompanionController.js';
14
12
  import { StepGraph } from './StepGraph.js';
15
13
  import { RunStateMachine } from './RunStateMachine.js';
14
+ import { RunDispatcher } from './RunDispatcher.js';
16
15
  import { inferTechnicalLabel } from './technical.logic.js';
17
16
  import { MergeResolver } from './MergeResolver.js';
18
17
  import { ReviewGateController } from './ReviewGateController.js';
@@ -20,48 +19,11 @@ import { BrainstormActions, ClarityReviewActions, RequirementReviewActions, } fr
20
19
  import { TesterController } from './TesterController.js';
21
20
  import { HumanTestController } from './HumanTestController.js';
22
21
  import { VisualConfirmationController } from './VisualConfirmationController.js';
23
- import { FALLTHROUGH_STEP_HANDLER_ORDER, } from './step-handler-registry.js';
24
- import { getProvider, recordGateAttempt, registeredGateFactories, registeredStepResolverFactories, requireProvider, } from '@cat-factory/kernel';
25
22
  import { isAsyncAgentExecutor } from '@cat-factory/kernel';
26
- import { isDeployStep, DEPLOYER_AGENT_KIND } from '@cat-factory/integrations';
27
23
  import { dependenciesMet, descendantIds, serviceOf, unmetDependencies, } from '../board/board.logic.js';
28
24
  import { requireWorkspace } from '@cat-factory/kernel';
29
25
  import { planResumedSteps, planRestartFromStep } from './retry.logic.js';
30
- import { isContainerEvictionError, isTransientEviction, MAX_EVICTION_RECOVERIES, MAX_TRANSIENT_EVICTION_RECOVERIES, } from './job.logic.js';
31
26
  import { decideTesterInfra, resolveTesterEnvironment, TESTER_INFRA_MESSAGES, } from './tester-infra.logic.js';
32
- /**
33
- * Step kinds whose run details surface the ephemeral-environment lifecycle: the
34
- * `deployer` provisions it and the `tester`/`playwright` exercise it. Used to gate
35
- * the per-poll env projection so the `getByBlock` read never hits the hot path for
36
- * the many container steps that have no env to show (see attachEnvironmentProjection).
37
- */
38
- const ENV_PROJECTION_KINDS = new Set([
39
- 'deployer',
40
- TESTER_AGENT_KIND,
41
- UI_TESTER_AGENT_KIND,
42
- 'playwright',
43
- ]);
44
- /**
45
- * Parse `owner`/`repo` from a GitHub pull-request URL (`https://github.com/o/r/pull/42`).
46
- * Returns undefined for any URL that doesn't carry both segments. Host-agnostic on
47
- * purpose (GitHub Enterprise hosts work too); only the `/owner/repo/...` shape matters.
48
- */
49
- function parseRepoFromPullUrl(url) {
50
- const match = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\//.exec(url);
51
- if (!match)
52
- return undefined;
53
- return { owner: match[1], repo: match[2] };
54
- }
55
- /**
56
- * The inline review/brainstorm gate kinds, all driven through the {@link ReviewGateController}
57
- * by the engine's `review-gate` StepHandler. Kept in sync with the handler's `switch`.
58
- */
59
- const REVIEW_GATE_AGENT_KINDS = new Set([
60
- REQUIREMENTS_REVIEW_AGENT_KIND,
61
- CLARITY_REVIEW_AGENT_KIND,
62
- REQUIREMENTS_BRAINSTORM_AGENT_KIND,
63
- ARCHITECTURE_BRAINSTORM_AGENT_KIND,
64
- ]);
65
27
  /**
66
28
  * The execution engine. It orchestrates a pipeline of agent-performed steps and
67
29
  * is fully deterministic: `advanceInstance` moves one run forward by exactly one
@@ -119,12 +81,13 @@ export class ExecutionService {
119
81
  clarityReviewActions;
120
82
  /** Brainstorm window actions (exposed via {@link brainstorm}). */
121
83
  brainstormActions;
122
- blueprintReconciler;
123
- notificationService;
84
+ // `blueprintReconciler` / `notificationService` / `ticketTrackerProvider` are NOT stored on
85
+ // the engine: their only consumers (the ingest/follow-up/tracker/notification paths) moved to
86
+ // {@link RunDispatcher} (and the controllers / RunStateMachine), so the constructor forwards
87
+ // the destructured params straight to those collaborators.
124
88
  workspaceSettingsService;
125
89
  prMerger;
126
90
  mergePresetRepository;
127
- ticketTrackerProvider;
128
91
  issueWriteback;
129
92
  subscriptionActivations;
130
93
  resolveProviderCapabilities;
@@ -143,23 +106,14 @@ export class ExecutionService {
143
106
  resolveRequireEnvironmentProvider;
144
107
  /** Start-time assertion that a container-agent backend is configured (local-mode pool). */
145
108
  assertAgentBackendConfigured;
146
- /** Lazily-built polling-gate registry, keyed by `agentKind`. See {@link gateFor}. */
147
- gateRegistryCache;
148
- /**
149
- * Lazily-built post-completion resolver registry, keyed by `agentKind`. See
150
- * {@link stepResolverFor} and {@link StepCompletionResolver}.
151
- */
152
- stepResolverCache;
153
109
  /**
154
- * Lazily-built, order-sorted per-step-kind handler list. See {@link dispatchStepHandler}
155
- * and {@link StepHandler}. Engine-internal (no public registration seam).
110
+ * The per-step dispatch + completion spine (the four registries, the completion hub, the
111
+ * gate machinery, the deterministic deployer/tracker steps, the pre/post-op cluster, the
112
+ * structured-artifact ingest, and the follow-up companion gate). `stepInstance` runs the
113
+ * run-lifecycle preamble then delegates the per-kind work here; `pollAgentJob` / `pollGate`
114
+ * / `resolveGatePollExhaustion` + the follow-up human-action API are thin pass-throughs.
156
115
  */
157
- stepHandlerCache;
158
- /**
159
- * Lazily-built, order-sorted completion-path interceptor list (companion/tester verdict
160
- * short-circuits). See {@link dispatchStepCompletionInterceptor}. Engine-internal.
161
- */
162
- stepCompletionInterceptorCache;
116
+ runDispatcher;
163
117
  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, }) {
164
118
  this.runInitiatorScope = runInitiatorScope ?? ((_initiatedBy, fn) => fn());
165
119
  this.workspaceRepository = workspaceRepository;
@@ -216,8 +170,8 @@ export class ExecutionService {
216
170
  contextBuilder: this.contextBuilder,
217
171
  spend: spendService,
218
172
  idGenerator,
219
- previewStepModel: (ctx) => this.previewStepModel(ctx),
220
- runAgent: (ctx, opts) => this.runAgent(ctx, opts),
173
+ previewStepModel: (ctx) => this.runDispatcher.previewStepModel(ctx),
174
+ runAgent: (ctx, opts) => this.runDispatcher.runAgent(ctx, opts),
221
175
  stateMachine: this.runStateMachine,
222
176
  stepGraph: this.stepGraph,
223
177
  inferTechnicalLabel: (ws, block, producer, companionStep) => this.inferBlockTechnical(ws, block, producer, companionStep),
@@ -246,8 +200,8 @@ export class ExecutionService {
246
200
  workspaceId: ws,
247
201
  blockId: block.id,
248
202
  executionId,
249
- inputs: this.deployInputs(block),
250
- context: this.deployContext(block),
203
+ inputs: this.runDispatcher.deployInputs(block),
204
+ context: this.runDispatcher.deployContext(block),
251
205
  }),
252
206
  refreshEnvironment: (ws, id) => environmentProvisioning.refreshStatus(ws, id),
253
207
  }
@@ -291,18 +245,53 @@ export class ExecutionService {
291
245
  this.clarityKind = this.buildClarityKind();
292
246
  this.requirementsBrainstormKind = this.buildBrainstormKind('requirements', REQUIREMENTS_BRAINSTORM_AGENT_KIND);
293
247
  this.architectureBrainstormKind = this.buildBrainstormKind('architecture', ARCHITECTURE_BRAINSTORM_AGENT_KIND);
248
+ // The per-step dispatch + completion spine. Composes the collaborators built above; the
249
+ // merge subgraph stays on the engine, reached only through the injected `resolveMergePreset`
250
+ // callback + the MergeResolver (which closes over the engine's `finalizeMerge`). The
251
+ // controllers' `runAgent`/`previewStepModel`/`deployInputs`/`deployContext` closures resolve
252
+ // through `this.runDispatcher` lazily, so this assignment trailing their construction is safe.
253
+ this.runDispatcher = new RunDispatcher({
254
+ blockRepository,
255
+ executionRepository,
256
+ agentExecutor,
257
+ workRunner,
258
+ events: executionEventPublisher,
259
+ idGenerator,
260
+ clock,
261
+ spend: spendService,
262
+ stepGraph: this.stepGraph,
263
+ runStateMachine: this.runStateMachine,
264
+ contextBuilder: this.contextBuilder,
265
+ mergeResolver: this.mergeResolver,
266
+ companionController: this.companionController,
267
+ testerController: this.testerController,
268
+ humanTestController: this.humanTestController,
269
+ visualConfirmationController: this.visualConfirmationController,
270
+ reviewGate: this.reviewGate,
271
+ requirementsKind: this.requirementsKind,
272
+ clarityKind: this.clarityKind,
273
+ requirementsBrainstormKind: this.requirementsBrainstormKind,
274
+ architectureBrainstormKind: this.architectureBrainstormKind,
275
+ runInitiatorScope: this.runInitiatorScope,
276
+ environmentProvisioning,
277
+ ticketTrackerProvider,
278
+ issueWriteback,
279
+ notificationService,
280
+ blueprintReconciler,
281
+ resolveRunRepoContext,
282
+ resolveProviderCapabilities,
283
+ resolveMergePreset: (ws, block) => this.resolveMergePreset(ws, block),
284
+ modelIdIsMetered: (id, caps) => this.modelIdIsMetered(id, caps),
285
+ });
294
286
  // Group the per-feature gate-window actions into cohesive sub-facades (exposed as
295
287
  // getters below) so they stop bloating the engine's public surface as ~30 near-identical
296
288
  // 3-line delegations. They close over the same shared collaborators the handlers use.
297
289
  this.requirementsReviewActions = new RequirementReviewActions(this.reviewGate, this.requirementsKind);
298
290
  this.clarityReviewActions = new ClarityReviewActions(this.reviewGate, this.clarityKind);
299
291
  this.brainstormActions = new BrainstormActions(this.reviewGate, (stage) => this.brainstormKindFor(stage));
300
- this.blueprintReconciler = blueprintReconciler;
301
- this.notificationService = notificationService;
302
292
  this.workspaceSettingsService = workspaceSettingsService;
303
293
  this.prMerger = pullRequestMerger;
304
294
  this.mergePresetRepository = mergePresetRepository;
305
- this.ticketTrackerProvider = ticketTrackerProvider;
306
295
  this.issueWriteback = issueWriteback;
307
296
  this.subscriptionActivations = subscriptionActivationRepository;
308
297
  this.resolveWorkspaceModelDefault = resolveWorkspaceModelDefault;
@@ -777,7 +766,7 @@ export class ExecutionService {
777
766
  // exhausted. This is what lets a deliberately local-only / subscription-only workspace
778
767
  // keep running at a `0` budget (see the spend-budget docs).
779
768
  if (await this.spend.isOverBudget(workspaceId)) {
780
- if (!(await this.currentStepIsNonMetered(workspaceId, instance, step))) {
769
+ if (!(await this.runDispatcher.currentStepIsNonMetered(workspaceId, instance, step))) {
781
770
  if (instance.status !== 'paused') {
782
771
  instance.status = 'paused';
783
772
  await this.executionRepository.upsert(workspaceId, instance);
@@ -831,12 +820,12 @@ export class ExecutionService {
831
820
  // finishes as `skipped` and the run advances. Evaluated here (not at build time)
832
821
  // because the estimate only exists once the estimator step has run.
833
822
  if (step.gating?.enabled && !shouldRunGatedStep(block.estimate, step.gating)) {
834
- return this.skipGatedStep(workspaceId, instance, step, isFinalStep);
823
+ return this.runDispatcher.skipGatedStep(workspaceId, instance, step, isFinalStep);
835
824
  }
836
825
  // The fixed run-lifecycle preamble is done; hand the per-kind work to the
837
826
  // engine-internal StepHandler registry (the first handler whose `canHandle` claims
838
827
  // this step). See {@link dispatchStepHandler} / {@link handleAgentStep}.
839
- return this.dispatchStepHandler({
828
+ return this.runDispatcher.dispatchStepHandler({
840
829
  workspaceId,
841
830
  instance,
842
831
  step,
@@ -845,579 +834,42 @@ export class ExecutionService {
845
834
  options,
846
835
  });
847
836
  }
848
- /**
849
- * The generic container/inline-agent step the lowest-priority StepHandler, claiming
850
- * every step no more-specific handler did (coder, architect, spec-writer, merger,
851
- * task-estimator, the container-backed companions, …). Builds the agent context, runs the
852
- * kind's pre-ops, then either dispatches an async container job and parks (the durable
853
- * driver polls between sleeps) or runs the inline LLM call and records the result. This is
854
- * what the dispatch chain falls through to; all the deterministic / gate / inline-review
855
- * kinds are claimed earlier by their own handlers (see {@link buildStepHandlerRegistry}).
856
- */
857
- async handleAgentStep(ctx) {
858
- const { workspaceId, instance, step, block, isFinalStep, options } = ctx;
859
- // Async (container) steps don't block: dispatch the job and park. The durable
860
- // driver polls `pollAgentJob` between sleeps so the run can span far longer
861
- // than a single durable step's timeout, while each step stays short. A set
862
- // `jobId` means a prior (possibly replayed) dispatch already started the job,
863
- // so we re-attach instead of starting a duplicate.
864
- const context = await this.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block);
865
- // A registered custom kind's PRE-ops run deterministic backend repo work before the
866
- // agent dispatches (e.g. read a baseline `spec/` shard into the prompt). Gated on the
867
- // step not having dispatched yet so a Workflows replay (jobId already set) doesn't
868
- // re-run them; a no-op for built-in kinds and when GitHub isn't wired.
869
- if (!step.jobId) {
870
- await this.runRegisteredPreOps(workspaceId, block, step, context);
871
- }
872
- const executor = this.agentExecutor;
873
- if (isAsyncAgentExecutor(executor) && executor.runsAsync(context)) {
874
- if (!step.jobId) {
875
- // The model is fixed the moment its ref resolves (block pin > workspace
876
- // default > env routing) — long before the container is up — so name it on
877
- // the very first "spinning up container" emit instead of waiting for the
878
- // dispatch to return. startJob confirms the same value below.
879
- const previewModel = await this.previewStepModel(context);
880
- if (previewModel)
881
- step.model = previewModel;
882
- // Surface an explicit "spinning up container" phase for the cold-boot
883
- // window: dispatch blocks until the per-run container is up and has
884
- // accepted the job, so emitting before it lets the board show the boot
885
- // instead of a blank "working" state.
886
- step.startingContainer = true;
887
- // Surface the block's ephemeral environment (if any) alongside the cold-boot
888
- // phase, so a run's details show the env spinning up next to the container.
889
- await this.attachEnvironmentProjection(workspaceId, instance.blockId, step);
890
- await this.executionRepository.upsert(workspaceId, instance);
891
- await this.runStateMachine.emitInstance(workspaceId, instance);
892
- let handle;
893
- try {
894
- handle = await executor.startJob(context);
895
- }
896
- catch (error) {
897
- // The container/runner never accepted the job (a dispatch HTTP error, a
898
- // missing backend, a capacity blip). Surface the EXACT provider/runtime
899
- // response and classify it as a `dispatch` failure ("container failed to
900
- // start") so the run details say the container never started — not a generic
901
- // "run failed". A dispatch-time eviction still routes to the evicted framing.
902
- step.startingContainer = false;
903
- await this.executionRepository.upsert(workspaceId, instance);
904
- await this.runStateMachine.emitInstance(workspaceId, instance);
905
- const message = getErrorMessage(error);
906
- const evicted = isContainerEvictionError(message);
907
- return {
908
- kind: 'job_failed',
909
- error: evicted ? message : 'The container failed to start.',
910
- failureKind: evicted ? 'evicted' : 'dispatch',
911
- detail: message,
912
- };
913
- }
914
- step.jobId = handle.jobId;
915
- // Record the model at dispatch — the poll site can't resolve it later.
916
- if (handle.model)
917
- step.model = handle.model;
918
- // The dispatch returned, so the container is up and execution has begun.
919
- step.startingContainer = false;
920
- await this.executionRepository.upsert(workspaceId, instance);
921
- await this.runStateMachine.emitInstance(workspaceId, instance);
922
- }
923
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
924
- }
925
- // Inline path: the model is resolved before the (blocking) LLM call, so surface
926
- // it now — the board names the model while the step is querying instead of only
927
- // once the result lands. recordStepResult re-asserts it from the result.
928
- const previewModel = await this.previewStepModel(context);
929
- if (previewModel && previewModel !== step.model) {
930
- step.model = previewModel;
931
- await this.executionRepository.upsert(workspaceId, instance);
932
- await this.runStateMachine.emitInstance(workspaceId, instance);
933
- }
934
- const result = await this.runAgent(context, options);
935
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, result);
837
+ // ---- durable-driver + follow-up pass-throughs ---------------------------
838
+ // The durable drivers (Cloudflare ExecutionWorkflow / Node driveExecution) and the
839
+ // FollowUpController call these on `executionService`; the per-step dispatch + completion
840
+ // spine + the follow-up companion gate live on {@link RunDispatcher}, so these are thin
841
+ // delegations (the public API is unchanged by the extraction).
842
+ /** @see RunDispatcher.pollAgentJob */
843
+ pollAgentJob(workspaceId, executionId) {
844
+ return this.runDispatcher.pollAgentJob(workspaceId, executionId);
936
845
  }
937
- /**
938
- * Preview the model a step will run (`provider:model`) ahead of the work, so the
939
- * board can show it during the inline query / container cold-boot rather than only
940
- * once the result or job handle lands. Best-effort: the executor may not implement
941
- * a preview, and a resolution failure (e.g. an unwired container kind that fails at
942
- * dispatch anyway) must never break the run — both yield undefined.
943
- */
944
- async previewStepModel(context) {
945
- if (!this.agentExecutor.resolveModel)
946
- return undefined;
947
- try {
948
- return await this.agentExecutor.resolveModel(context);
949
- }
950
- catch {
951
- return undefined;
952
- }
846
+ /** @see RunDispatcher.pollGate */
847
+ pollGate(workspaceId, executionId) {
848
+ return this.runDispatcher.pollGate(workspaceId, executionId);
953
849
  }
954
- /**
955
- * Whether the current step incurs NO metered monetary LLM cost, so the spend gate can
956
- * let it proceed even when the budget is exhausted. Two non-metered cases:
957
- * - a flat-rate SUBSCRIPTION (quota) model — Claude Code / Codex on a pooled token;
958
- * resolved through the executor (the authority on "subscriptions always win").
959
- * - a LOCAL-runner model (Ollama / LM Studio / …) — keyless, runs on the user's own
960
- * endpoint, so it costs the deployment nothing; detected off the resolved model id.
961
- * This is what makes a `0` budget mean "no PAID spend" without bricking a workspace that
962
- * deliberately runs only local models or subscriptions (see the spend-budget docs).
963
- *
964
- * Once the executor resolves the step's concrete model id, the metered/non-metered
965
- * decision is delegated to the SAME {@link modelIdIsMetered} predicate the up-front
966
- * {@link assertBudgetAllowsPipeline} gate uses, so the two gates can't classify a model
967
- * differently (a divergence would let a run pass the start gate then immediately pause,
968
- * or vice versa). The executor's `isQuotaBased` is still consulted first as the
969
- * authoritative subscription-routing signal; the shared predicate covers local-runner +
970
- * subscription-by-capability + Cloudflare classification identically to the start gate.
971
- * Falls back to a bare local-id check when no capability resolver is wired.
972
- *
973
- * Best-effort and side-effect-free: an executor without the capability, a missing block,
974
- * or any resolution error all report false (treated as budget-metered, the prior
975
- * behaviour). Only consulted on the over-budget path, so it never touches the happy path.
976
- */
977
- async currentStepIsNonMetered(workspaceId, instance, step) {
978
- try {
979
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
980
- if (!block)
981
- return false;
982
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
983
- const context = await this.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block);
984
- if (this.agentExecutor.isQuotaBased && (await this.agentExecutor.isQuotaBased(context))) {
985
- return true;
986
- }
987
- if (this.agentExecutor.resolveModel) {
988
- const modelId = await this.agentExecutor.resolveModel(context);
989
- // Classify the resolved id through the shared predicate (same as the start gate)
990
- // when capabilities are wired; else fall back to the bare local-runner check.
991
- if (this.resolveProviderCapabilities) {
992
- const caps = await this.resolveProviderCapabilities(workspaceId, instance.initiatedBy);
993
- if (!this.modelIdIsMetered(modelId, caps))
994
- return true;
995
- }
996
- else if (parseLocalModelId(modelId)) {
997
- return true;
998
- }
999
- }
1000
- return false;
1001
- }
1002
- catch {
1003
- return false;
1004
- }
850
+ /** @see RunDispatcher.resolveGatePollExhaustion */
851
+ resolveGatePollExhaustion(workspaceId, executionId) {
852
+ return this.runDispatcher.resolveGatePollExhaustion(workspaceId, executionId);
1005
853
  }
1006
- /**
1007
- * Poll the asynchronous job a parked step dispatched. Returns `awaiting_job`
1008
- * while it runs (the driver keeps polling), records the result and advances on
1009
- * success, or reports `job_failed` so the driver can fail the run. Reading run
1010
- * state from storage on every call keeps it safe under Workflows replay/retry:
1011
- * once a job's result is recorded the step's `jobId` is cleared, so a re-poll
1012
- * simply lets the driver advance the now-current step.
1013
- */
1014
- async pollAgentJob(workspaceId, executionId) {
1015
- const instance = await this.executionRepository.get(workspaceId, executionId);
1016
- if (!instance || (instance.status !== 'running' && instance.status !== 'paused')) {
1017
- return { kind: 'noop' };
1018
- }
1019
- const step = instance.steps[instance.currentStep];
1020
- if (!step)
1021
- return { kind: 'noop' };
1022
- // No job in flight: a prior poll already recorded it (and advanced). Let the
1023
- // driver loop and advance whatever step is now current.
1024
- if (!step.jobId)
1025
- return { kind: 'continue' };
1026
- const executor = this.agentExecutor;
1027
- if (!isAsyncAgentExecutor(executor))
1028
- return { kind: 'noop' };
1029
- // Re-supply the run id alongside the per-step job id so the executor can address
1030
- // the same per-run container at the poll site (it only stored the per-step jobId).
1031
- // The agent kind is supplied too: the container executor maps a migrated
1032
- // `merger`/`on-call`'s structured result into `mergeAssessment`/`onCallAssessment`
1033
- // KIND-AWARE in `toRunResult`, so without it that coercion no-ops and the merge gate /
1034
- // post-release-health gate would see no assessment.
1035
- const update = await executor.pollJob({
1036
- jobId: step.jobId,
1037
- runId: executionId,
1038
- workspaceId,
1039
- agentKind: step.agentKind,
1040
- });
1041
- if (update.state === 'running') {
1042
- // A successful poll proves the container is up, so the cold-boot phase is
1043
- // over (defensive: a replay may have left the flag set). Surface live subtask
1044
- // progress (e.g. 3/8 todos done) without advancing the step. Only persist +
1045
- // emit when something actually changed so an idle poll doesn't churn storage
1046
- // or the event stream.
1047
- let changed = false;
1048
- if (step.startingContainer) {
1049
- step.startingContainer = false;
1050
- changed = true;
1051
- }
1052
- if (update.subtasks && !sameSubtasks(step.subtasks, update.subtasks)) {
1053
- step.subtasks = update.subtasks;
1054
- step.progress =
1055
- update.subtasks.total > 0 ? update.subtasks.completed / update.subtasks.total : 0;
1056
- changed = true;
1057
- }
1058
- // Append any forward-looking items the Coder streamed since the last poll so the
1059
- // Follow-up companion lights up + accrues items LIVE while the container still runs.
1060
- if (this.appendStreamedFollowUps(step, update.followUps))
1061
- changed = true;
1062
- // Refresh the env projection so its status transitions (provisioning→ready→
1063
- // expired/torn_down) and any error stay live in the run details during the run.
1064
- if (await this.attachEnvironmentProjection(workspaceId, instance.blockId, step)) {
1065
- changed = true;
1066
- }
1067
- if (changed) {
1068
- await this.executionRepository.upsert(workspaceId, instance);
1069
- await this.runStateMachine.emitInstance(workspaceId, instance);
1070
- }
1071
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
1072
- }
1073
- // A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call)
1074
- // declares a `resolveHelperCompletion` hook on its definition. When such a helper's job
1075
- // settles — done OR failed — we call the hook INSTEAD of re-probing the precheck
1076
- // (re-probing an investigate-don't-fix helper would just regress again and burn the
1077
- // budget) and finish the gate step with the output it returns. The gate raises its own
1078
- // `release_regression` notification + enriches any open incident inside the hook (from the
1079
- // signals stashed at escalation); the run then completes for a human to act out-of-band.
1080
- const completionGate = this.gateFor(step.agentKind);
1081
- if (completionGate?.resolveHelperCompletion &&
1082
- step.gate?.phase === 'working' &&
1083
- (update.state === 'done' || update.state === 'failed')) {
1084
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1085
- step.jobId = undefined;
1086
- step.subtasks = undefined;
1087
- if (!block)
1088
- return { kind: 'noop' };
1089
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
1090
- const jobResult = update.state === 'done'
1091
- ? { state: 'done', result: update.result }
1092
- : { state: 'failed', error: update.error ?? null };
1093
- const resolution = await completionGate.resolveHelperCompletion({
1094
- workspaceId,
1095
- instance,
1096
- block,
1097
- step,
1098
- result: jobResult,
1099
- });
1100
- // Preserve the done-result's fields (usage metering etc.) while recording the gate's
1101
- // resolved output; a failed investigation has no result to carry.
1102
- const base = update.state === 'done' ? update.result : { output: '' };
1103
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
1104
- ...base,
1105
- output: resolution.output,
1106
- });
1107
- }
1108
- // A polling gate step's in-flight job is its helper agent (ci-fixer /
1109
- // conflict-resolver), NOT the step's own work: when it finishes (or fails) we
1110
- // don't record a result or advance — we drop the handle, return the gate to
1111
- // `checking`, and re-run the precheck (the helper's push triggers a fresh CI run /
1112
- // updates mergeability). A helper that failed without pushing leaves the precheck
1113
- // negative, so the next check re-dispatches (until the attempt budget is spent).
1114
- const reprobeGate = this.gateFor(step.agentKind);
1115
- if (reprobeGate) {
1116
- // A gate may need deterministic GitHub-side bookkeeping to land BEFORE the re-probe
1117
- // reads it (the human-review gate replies to + RESOLVES the threads it handed the
1118
- // fixer, so the next probe counts them addressed). Run that side-effect hook first;
1119
- // it does NOT replace the re-probe (unlike resolveHelperCompletion).
1120
- if (reprobeGate.onHelperComplete && step.gate) {
1121
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1122
- if (block) {
1123
- const jobResult = update.state === 'done'
1124
- ? { state: 'done', result: update.result }
1125
- : { state: 'failed', error: update.error ?? null };
1126
- await this.runInitiatorScope(instance.initiatedBy, () => reprobeGate.onHelperComplete({
1127
- workspaceId,
1128
- instance,
1129
- block,
1130
- step,
1131
- result: jobResult,
1132
- }));
1133
- }
1134
- }
1135
- // Record the just-finished helper attempt before re-probing. The gate's next
1136
- // precheck stays the source of truth for pass/fail, but the helper's own account
1137
- // (what it did, and for the conflict-resolver which files it left conflicting) is
1138
- // otherwise discarded here — leaving the gate window with only a bare attempt
1139
- // count. Capture it so the UI can show what each attempt tried.
1140
- if (step.gate) {
1141
- const attempt = recordGateAttempt(step.gate, update.state === 'done'
1142
- ? { state: 'done', output: update.result.output ?? null }
1143
- : { state: 'failed', error: update.error ?? null }, this.clock.now());
1144
- step.gate.attemptLog = [...(step.gate.attemptLog ?? []), attempt];
1145
- // The conflicts gate's precheck carries no failure detail of its own (GitHub
1146
- // reports mergeability as a single bit), so surface the resolver's account as
1147
- // the gate's last failure summary. CI's probe already sets a richer summary
1148
- // (the red checks) — don't clobber it with the fixer's push note.
1149
- if (step.agentKind === CONFLICTS_AGENT_KIND && attempt.summary) {
1150
- step.gate.lastFailureSummary = attempt.summary;
1151
- }
1152
- }
1153
- step.jobId = undefined;
1154
- step.subtasks = undefined;
1155
- if (step.gate)
1156
- step.gate.phase = 'checking';
1157
- await this.executionRepository.upsert(workspaceId, instance);
1158
- await this.runStateMachine.emitInstance(workspaceId, instance);
1159
- return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
1160
- }
1161
- // A `tester` step in its `fixing` phase has a Fixer job in flight, NOT the
1162
- // step's own work: when it finishes (or fails) we drop the handle, return to
1163
- // `testing`, and re-dispatch the Tester against the (now-fixed) branch — its
1164
- // fresh report then drives greenlight-or-loop again. Mirrors the CI gate.
1165
- if (isTesterKind(step.agentKind) && step.test?.phase === 'fixing') {
1166
- step.jobId = undefined;
1167
- step.subtasks = undefined;
1168
- step.test.phase = 'testing';
1169
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1170
- if (!block)
1171
- return { kind: 'noop' };
1172
- // Reclaim the finished Fixer container before re-dispatching the Tester so it
1173
- // boots fresh against the just-pushed fixes (rather than re-attaching to the
1174
- // completed job by run id).
1175
- await this.runStateMachine.stopRunContainer(workspaceId, instance);
1176
- return this.testerController.dispatchTester(workspaceId, instance, step, block);
1177
- }
1178
- // A `human-test` gate in its `fixing` / `resolving_conflicts` phase has a helper job
1179
- // (fixer / conflict-resolver) in flight, NOT the step's own work: when it settles —
1180
- // done OR failed — record the round's outcome, rebuild the environment against the
1181
- // (now-updated) branch and re-park the human. We never fail the run here; the human is
1182
- // in control. Mirrors the Tester→Fixer loop.
1183
- if (step.agentKind === HUMAN_TEST_AGENT_KIND &&
1184
- (step.humanTest?.phase === 'fixing' || step.humanTest?.phase === 'resolving_conflicts')) {
1185
- return this.humanTestController.onHelperComplete(workspaceId, instance, step, {
1186
- state: update.state === 'failed' ? 'failed' : 'done',
1187
- });
1188
- }
1189
- // A `visual-confirmation` gate in its `fixing` phase has a `fixer` job in flight: when it
1190
- // settles, record the round, refresh the screenshot pairs, and re-park the human.
1191
- if (step.agentKind === VISUAL_CONFIRM_AGENT_KIND && step.visualConfirm?.phase === 'fixing') {
1192
- return this.visualConfirmationController.onHelperComplete(workspaceId, instance, step, {
1193
- state: update.state === 'failed' ? 'failed' : 'done',
1194
- });
1195
- }
1196
- if (update.state === 'failed') {
1197
- // A container eviction (the per-run container vanished, its in-memory job is
1198
- // gone) is usually transient. Recover it by dropping the dead handle and
1199
- // returning `continue`: the driver loops back into `advanceInstance`, which
1200
- // re-dispatches the SAME step to a fresh container (a new instance boots under
1201
- // the same id). Two flavours, with separate budgets:
1202
- // - one the runtime facade flagged as transient infra churn (e.g. a deploy
1203
- // draining the sandbox) is not a sick run, and can recur several times in a
1204
- // short window, so it gets the larger MAX_TRANSIENT_EVICTION_RECOVERIES
1205
- // budget (recoveries are naturally spaced by the job poll interval, riding
1206
- // out the window);
1207
- // - any other eviction (crash/OOM) gets the tight MAX_EVICTION_RECOVERIES.
1208
- // Once a budget is spent the eviction is treated as deterministic and fails the
1209
- // run as `evicted`. A genuine agent/job failure is never recovered.
1210
- if (isContainerEvictionError(update.error)) {
1211
- const transient = isTransientEviction(update.error);
1212
- const limit = transient ? MAX_TRANSIENT_EVICTION_RECOVERIES : MAX_EVICTION_RECOVERIES;
1213
- const recoveries = transient
1214
- ? (step.transientEvictionRecoveries ?? 0)
1215
- : (step.evictionRecoveries ?? 0);
1216
- if (recoveries < limit) {
1217
- if (transient)
1218
- step.transientEvictionRecoveries = recoveries + 1;
1219
- else
1220
- step.evictionRecoveries = recoveries + 1;
1221
- step.jobId = undefined;
1222
- step.subtasks = undefined;
1223
- step.progress = 0;
1224
- await this.executionRepository.upsert(workspaceId, instance);
1225
- await this.runStateMachine.emitInstance(workspaceId, instance);
1226
- return { kind: 'continue' };
1227
- }
1228
- return {
1229
- kind: 'job_evicted',
1230
- error: transient
1231
- ? `${update.error} (still evicting after ${recoveries} automatic restarts through the infrastructure churn — treating as deterministic)`
1232
- : `${update.error ?? 'Container evicted'} (still evicting after ${recoveries} automatic container restart${recoveries === 1 ? '' : 's'} — treating as deterministic)`,
1233
- };
1234
- }
1235
- return { kind: 'job_failed', error: update.error };
1236
- }
1237
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1238
- if (!block)
1239
- return { kind: 'noop' };
1240
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
1241
- // Capture any final burst of follow-up items the harness drained on the SAME poll that
1242
- // observed completion (the tailer is flushed before the job is marked done), so the
1243
- // completion gate below sees the last items — notably a question that must hold the run.
1244
- this.appendStreamedFollowUps(step, update.followUps);
1245
- // Clear the handle before recording so a replay re-attaches to nothing.
1246
- step.jobId = undefined;
1247
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
854
+ /** @see RunDispatcher.getFollowUps */
855
+ getFollowUps(workspaceId, executionId) {
856
+ return this.runDispatcher.getFollowUps(workspaceId, executionId);
1248
857
  }
1249
- /**
1250
- * Re-run a polling gate step's precheck from the durable driver's `awaiting_gate`
1251
- * loop: which gate (ci / conflicts) is resolved from the current step's `agentKind`,
1252
- * and it returns the same outcomes as the initial evaluation (precheck passes →
1253
- * advance, still computing → keep polling, fails → dispatch a helper or give up).
1254
- * Safe under replay: reads run state fresh each call. A no-op unless the current
1255
- * step is a gate actively in its `checking` phase.
1256
- */
1257
- async pollGate(workspaceId, executionId) {
1258
- const instance = await this.executionRepository.get(workspaceId, executionId);
1259
- if (!instance || (instance.status !== 'running' && instance.status !== 'paused')) {
1260
- return { kind: 'noop' };
1261
- }
1262
- const step = instance.steps[instance.currentStep];
1263
- // The human-testing gate rides the same `awaiting_gate` poll loop while its ephemeral
1264
- // environment provisions — re-poll the env status (ready → park the human; still
1265
- // provisioning → keep polling; failed → degrade to manual mode).
1266
- if (step?.agentKind === HUMAN_TEST_AGENT_KIND) {
1267
- return this.humanTestController.pollEnvironment(workspaceId, instance);
1268
- }
1269
- const gate = step ? this.gateFor(step.agentKind) : undefined;
1270
- if (!step || !gate)
1271
- return { kind: 'continue' };
1272
- // A helper job is in flight — the driver should be polling it, not the gate; let
1273
- // the job-poll loop drive (defensive; a replay could route here).
1274
- if (step.jobId)
1275
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
1276
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1277
- if (!block)
1278
- return { kind: 'noop' };
1279
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
1280
- return this.evaluateGate(workspaceId, instance, step, block, isFinalStep, gate);
858
+ /** @see RunDispatcher.fileFollowUp */
859
+ fileFollowUp(workspaceId, executionId, itemId) {
860
+ return this.runDispatcher.fileFollowUp(workspaceId, executionId, itemId);
1281
861
  }
1282
- /**
1283
- * Decide what happens when the durable driver's GATE poll budget (ciMaxPolls ×
1284
- * ciPollInterval) is spent while a gate is still `pending` — called by both runtime
1285
- * drivers (Cloudflare ExecutionWorkflow / Node `driveExecution`) instead of failing
1286
- * the run directly, so the per-gate policy lives in one place. Most gates `fail`
1287
- * (CI never went green / the PR never became mergeable). A time-windowed watch gate
1288
- * (post-release-health, `pollExhaustion: 'pass'`) instead PASSES: the watch window
1289
- * simply outlasted the poll budget with no regression observed, which is healthy — not
1290
- * a timeout. Returns the result the driver should act on (it never re-fails for a fail
1291
- * gate; it returns a `job_failed` the driver funnels through its single `failRun`).
1292
- */
1293
- async resolveGatePollExhaustion(workspaceId, executionId) {
1294
- const instance = await this.executionRepository.get(workspaceId, executionId);
1295
- if (!instance || (instance.status !== 'running' && instance.status !== 'paused')) {
1296
- return { kind: 'noop' };
1297
- }
1298
- const step = instance.steps[instance.currentStep];
1299
- // The human-testing gate never times the RUN out while provisioning: instead of failing,
1300
- // park the human in degraded mode so they can wait, recreate, or test by hand.
1301
- if (step?.agentKind === HUMAN_TEST_AGENT_KIND) {
1302
- return this.humanTestController.onProvisionTimeout(workspaceId, instance);
1303
- }
1304
- const gate = step ? this.gateFor(step.agentKind) : undefined;
1305
- const timeoutError = 'Gate precheck did not settle within its polling budget';
1306
- // An unbounded human-wait gate (human-review, `pollExhaustion: 'rearm'`) has no deadline:
1307
- // running out of polls is never a verdict. Always re-arm another poll cycle — the waiting
1308
- // is surfaced via the gate's notification (escalated by the severity sweep), not by killing
1309
- // the run.
1310
- if (step && gate && gate.pollExhaustion === 'rearm') {
1311
- if (step.gate)
1312
- step.gate.phase = 'checking';
1313
- await this.executionRepository.upsert(workspaceId, instance);
1314
- return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
1315
- }
1316
- if (!step || !gate || gate.pollExhaustion !== 'pass') {
1317
- return { kind: 'job_failed', error: timeoutError, failureKind: 'timeout' };
1318
- }
1319
- // A time-windowed watch gate (post-release-health) may be configured to watch LONGER
1320
- // than the driver's single gate-poll budget (ciMaxPolls × ciPollInterval). Running out
1321
- // of polls before the window has actually elapsed is NOT a healthy pass — the release
1322
- // could still regress later in the window. Re-arm another poll cycle (the driver loops
1323
- // back into the gate-poll loop on `awaiting_gate`) so the full configured window is
1324
- // honoured rather than silently truncated to the poll budget.
1325
- const watchSince = step.gate?.watchSince;
1326
- const windowMinutes = step.gate?.watchWindowMinutes;
1327
- if (watchSince != null && windowMinutes != null) {
1328
- const windowElapsed = this.clock.now() - watchSince >= windowMinutes * 60_000;
1329
- if (!windowElapsed) {
1330
- if (step.gate)
1331
- step.gate.phase = 'checking';
1332
- await this.executionRepository.upsert(workspaceId, instance);
1333
- return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
1334
- }
1335
- }
1336
- // Window genuinely elapsed (or a non-windowed pass gate): finish as a healthy pass.
1337
- const isFinalStep = instance.currentStep === instance.steps.length - 1;
1338
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
1339
- output: `${gate.kind} gate passed: watch window elapsed with no regression observed.`,
1340
- });
862
+ /** @see RunDispatcher.queueFollowUp */
863
+ queueFollowUp(workspaceId, executionId, itemId) {
864
+ return this.runDispatcher.queueFollowUp(workspaceId, executionId, itemId);
1341
865
  }
1342
- /**
1343
- * Transition a step into `working`, stamping its start time the first time it
1344
- * actually begins. Set-once so a Workflows replay (which re-runs `advance`)
1345
- * preserves the original start rather than resetting it on every replay. An
1346
- * explicit re-run clears `startedAt` first (see {@link requestStepChanges}) so
1347
- * the fresh attempt is timed from scratch.
1348
- */
1349
- /**
1350
- * Stamp `step.environment` from the block's live ephemeral environment so a run's
1351
- * details show its spinning-up / running / shut-down / errored state + the exact
1352
- * error. Best-effort: a no-op when the env integration isn't wired, and never
1353
- * throws (a projection failure must not break the run). Returns whether it changed,
1354
- * so the poll path can fold it into its single emit. The `human-test` gate keeps
1355
- * its own `humanTest.environment`, so this is for the other env-consuming steps
1356
- * (tester/coder/deployer).
1357
- */
1358
- async attachEnvironmentProjection(workspaceId, blockId, step) {
1359
- if (!this.environmentProvisioning)
1360
- return false;
1361
- // Only the env-aware kinds run against an ephemeral environment (the `deployer`
1362
- // provisions it; the `tester`/`playwright` exercise it). Gating here keeps the
1363
- // per-poll `getByBlock` read off the hot path for the many container steps
1364
- // (coder/merger/ci-fixer/…) that never have an env to surface.
1365
- if (!ENV_PROJECTION_KINDS.has(step.agentKind))
1366
- return false;
1367
- try {
1368
- const handle = await this.environmentProvisioning.getHandleForBlock(workspaceId, blockId);
1369
- const next = handle
1370
- ? {
1371
- id: handle.id,
1372
- url: handle.url,
1373
- status: handle.status,
1374
- expiresAt: handle.expiresAt,
1375
- lastError: handle.lastError,
1376
- }
1377
- : null;
1378
- const prev = step.environment ?? null;
1379
- if (prev?.id === next?.id &&
1380
- prev?.status === next?.status &&
1381
- prev?.url === next?.url &&
1382
- (prev?.lastError ?? null) === (next?.lastError ?? null)) {
1383
- return false;
1384
- }
1385
- step.environment = next;
1386
- return true;
1387
- }
1388
- catch {
1389
- return false;
1390
- }
866
+ /** @see RunDispatcher.answerFollowUp */
867
+ answerFollowUp(workspaceId, executionId, itemId, answer) {
868
+ return this.runDispatcher.answerFollowUp(workspaceId, executionId, itemId, answer);
1391
869
  }
1392
- /**
1393
- * Finish a gated step that was skipped (its estimate gate was not satisfied) and either
1394
- * complete the run or advance to the next step — the deterministic finish/advance tail
1395
- * of {@link recordStepResult}, minus all the agent-result handling (no LLM ran, so there
1396
- * is no usage / decision / PR / artifact / approval / resolver to process). The step is
1397
- * marked `skipped` with empty output so the UI renders "skipped (gated)".
1398
- */
1399
- async skipGatedStep(workspaceId, instance, step, isFinalStep) {
1400
- step.skipped = true;
1401
- step.output = '';
1402
- step.progress = 1;
1403
- step.subtasks = undefined;
1404
- this.stepGraph.finishStep(step);
1405
- if (isFinalStep) {
1406
- instance.status = 'done';
1407
- await this.runStateMachine.finalizeBlock(workspaceId, instance, undefined);
1408
- await this.executionRepository.upsert(workspaceId, instance);
1409
- await this.runStateMachine.emitInstance(workspaceId, instance);
1410
- await this.runStateMachine.stopRunContainer(workspaceId, instance);
1411
- return { kind: 'done' };
1412
- }
1413
- instance.currentStep += 1;
1414
- const next = instance.steps[instance.currentStep];
1415
- if (next)
1416
- this.stepGraph.startStep(next);
1417
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'in_progress');
1418
- await this.executionRepository.upsert(workspaceId, instance);
1419
- await this.runStateMachine.emitInstance(workspaceId, instance);
1420
- return { kind: 'continue' };
870
+ /** @see RunDispatcher.dismissFollowUp */
871
+ dismissFollowUp(workspaceId, executionId, itemId) {
872
+ return this.runDispatcher.dismissFollowUp(workspaceId, executionId, itemId);
1421
873
  }
1422
874
  /**
1423
875
  * Infer + persist the block's `technical` label from the settled spec phase (item 5):
@@ -1436,1259 +888,6 @@ export class ExecutionService {
1436
888
  return;
1437
889
  await this.blockRepository.update(workspaceId, block.id, { technical }).catch(() => { });
1438
890
  }
1439
- /**
1440
- * Record a completed agent step's result and report what the driver should do
1441
- * next: meter token usage, park on a raised decision, or persist the output
1442
- * (and any opened PR) and either finish the run or advance to the next step.
1443
- * Shared by the inline path and the async-job poll path.
1444
- */
1445
- async recordStepResult(workspaceId, instance, step, isFinalStep, result) {
1446
- // Meter the LLM call against the spend budget. Recorded whether the step
1447
- // completed or raised a decision — both consumed tokens.
1448
- if (result.usage) {
1449
- await this.spend.record({
1450
- workspaceId,
1451
- executionId: instance.id,
1452
- agentKind: step.agentKind,
1453
- model: result.model ?? 'unknown',
1454
- usage: result.usage,
1455
- });
1456
- }
1457
- // The agent asked for a human decision and this step hasn't resolved one yet.
1458
- if (result.decision && !step.decision?.chosen) {
1459
- step.decision = {
1460
- id: this.idGenerator.next('dec'),
1461
- question: result.decision.question,
1462
- options: [...result.decision.options],
1463
- chosen: null,
1464
- };
1465
- this.stepGraph.pauseStepForInput(step);
1466
- instance.status = 'blocked';
1467
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1468
- await this.executionRepository.upsert(workspaceId, instance);
1469
- await this.runStateMachine.emitInstance(workspaceId, instance);
1470
- return { kind: 'awaiting_decision', decisionId: step.decision.id };
1471
- }
1472
- // Completion-path interceptors short-circuit before the normal finish/advance for the
1473
- // few kinds whose verdict drives run flow: a container-backed companion applies its
1474
- // threshold/rework/human-gate loop, and a Tester re-runs its `fixer` on a withheld
1475
- // greenlight. A non-null outcome replaces the normal completion; null (a Tester
1476
- // greenlight, or a companion whose block can't be loaded) falls through. See
1477
- // {@link buildStepCompletionInterceptors}.
1478
- const intercepted = await this.dispatchStepCompletionInterceptor({
1479
- workspaceId,
1480
- instance,
1481
- step,
1482
- isFinalStep,
1483
- result,
1484
- });
1485
- if (intercepted)
1486
- return intercepted;
1487
- // The step completed.
1488
- step.output = result.output ?? '';
1489
- // Surface a registered custom kind's structured JSON on the step so the SPA's
1490
- // `generic-structured` result view can render it (a post-op consumes the same value
1491
- // server-side). Built-in / prose kinds leave it undefined.
1492
- if (result.custom !== undefined)
1493
- step.custom = result.custom;
1494
- if (result.model)
1495
- step.model = result.model;
1496
- step.progress = 1;
1497
- this.stepGraph.finishStep(step);
1498
- // Live subtask counts only describe an in-flight run; drop them now the step
1499
- // is done so the board doesn't show a stale "3/8" against a finished step.
1500
- step.subtasks = undefined;
1501
- // A companion-driven rework was just consumed by this re-run; clear it so a later
1502
- // unrelated re-run doesn't re-apply stale feedback (the companion sets fresh
1503
- // feedback if it still rejects the new output).
1504
- step.rework = undefined;
1505
- // A repo-operating step (the container "implementer" agent) opened a PR for
1506
- // its work. Record it on the block so the board can surface and link to it,
1507
- // regardless of whether this is the final step.
1508
- if (result.pullRequest) {
1509
- // Read the block before the update so we can tell whether this PR is newly
1510
- // opened (vs. the same PR re-reported by a re-run/retry of the coder step).
1511
- const priorBlock = this.issueWriteback
1512
- ? await this.blockRepository.get(workspaceId, instance.blockId)
1513
- : null;
1514
- await this.blockRepository.update(workspaceId, instance.blockId, {
1515
- pullRequest: result.pullRequest,
1516
- });
1517
- // Best-effort writeback: comment on the task's linked tracker issue(s) that a
1518
- // PR opened. Only when the PR is newly recorded — a retry that re-reports the
1519
- // same PR must not re-comment (the tracker comment is not idempotent). Gated
1520
- // inside the provider by the workspace setting + per-task override;
1521
- // fire-and-forget so a tracker outage never fails the run.
1522
- if (this.issueWriteback &&
1523
- priorBlock &&
1524
- priorBlock.pullRequest?.url !== result.pullRequest.url) {
1525
- await this.issueWriteback
1526
- .onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
1527
- .catch(() => { });
1528
- }
1529
- }
1530
- // Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
1531
- // ingestion, task-estimate persistence). It reshapes the agent's structured result into
1532
- // domain state and may replace `step.output` (the estimator's readable summary). Its
1533
- // POSITION is load-bearing — it runs after the output is recorded but BEFORE the
1534
- // reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
1535
- // sits exactly where the old inline ingestion branches did. See
1536
- // {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
1537
- const postCompletionResolver = this.stepResolverFor(step.agentKind);
1538
- if (postCompletionResolver?.phase === 'post-completion' &&
1539
- (postCompletionResolver.applies?.(result) ?? true)) {
1540
- const resolution = await postCompletionResolver.resolve({
1541
- workspaceId,
1542
- instance,
1543
- step,
1544
- result,
1545
- isFinalStep,
1546
- });
1547
- if (resolution?.output !== undefined)
1548
- step.output = resolution.output;
1549
- }
1550
- // A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
1551
- // returns its raw Pi transcript summary as `result.output` — useless for review.
1552
- // Replace the step's reviewable output with a rendering of the artifact ITSELF, so
1553
- // its companion grades the PRODUCT (and the SPA reader + downstream steps see it),
1554
- // not the agent's chatter. Grading the transcript is what made the spec-companion
1555
- // declare every pass "unreviewable" and loop the producer to its rework cap on every
1556
- // spec task — a trap for ANY artifact-producing agent with a companion, now and
1557
- // future, which is why this is keyed off the artifact, not a specific agentKind.
1558
- const reviewable = reviewableArtifactOutput(result);
1559
- if (reviewable !== undefined)
1560
- step.output = reviewable;
1561
- // Follow-up companion gate: the future-looking Coder surfaced forward-looking items.
1562
- // Hold the pipeline until every item is decided (an undecided follow-up or an unanswered
1563
- // question parks the run), then loop the Coder for the items the human queued / answered
1564
- // (within the loop budget) before the following steps may start. Runs BEFORE the approval
1565
- // gate so the Coder's follow-ups settle first. A no-op when nothing was surfaced.
1566
- if (step.followUps?.enabled) {
1567
- const gated = await this.evaluateFollowUpGate(workspaceId, instance, step);
1568
- if (gated)
1569
- return gated;
1570
- }
1571
- // Human approval gate: a step the pipeline marked `requiresApproval` pauses
1572
- // here once its proposal is ready, so a human can review (and edit) it before
1573
- // the next step runs. We reuse the durable decision wait — returning
1574
- // `awaiting_decision` keyed by the approval id parks the run on the same named
1575
- // event the workflow already listens for; `approveStep` / `requestStepChanges`
1576
- // wake it. Never gates the final step (nothing downstream to feed) and is
1577
- // idempotent: an already-approved step falls through to advance/finish.
1578
- if (step.requiresApproval && !isFinalStep && step.approval?.status !== 'approved') {
1579
- step.approval = {
1580
- id: this.idGenerator.next('appr'),
1581
- status: 'pending',
1582
- proposal: step.output,
1583
- };
1584
- this.stepGraph.pauseStepForInput(step);
1585
- instance.status = 'blocked';
1586
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
1587
- await this.executionRepository.upsert(workspaceId, instance);
1588
- await this.runStateMachine.emitInstance(workspaceId, instance);
1589
- return { kind: 'awaiting_decision', decisionId: step.approval.id };
1590
- }
1591
- // Persist the agent's reported confidence whenever a step reports it, for board
1592
- // transparency. Position-independent: it must NOT be tied to the final step, since a
1593
- // confidence-reporting producer (e.g. the merger) may now be followed by a gate.
1594
- if (result.confidence !== undefined) {
1595
- await this.blockRepository.update(workspaceId, instance.blockId, {
1596
- confidence: result.confidence,
1597
- });
1598
- }
1599
- // Run any DETERMINISTIC post-completion logic registered for this agent kind (e.g.
1600
- // the merger performs the real GitHub merge with backend-held credentials). This is
1601
- // POSITION-INDEPENDENT — it fires whenever the step finishes, not only when it's last
1602
- // — so inserting a later step (post-release-health) can't silently disable it. A
1603
- // resolver that owns the block's terminal status (the merger sets `done`/`pr_ready`)
1604
- // tells `finalizeBlock` to leave it alone.
1605
- const resolver = this.stepResolverFor(step.agentKind);
1606
- let resolverOwnsTerminalStatus = false;
1607
- if (resolver &&
1608
- (resolver.phase ?? 'terminal') === 'terminal' &&
1609
- (resolver.applies?.(result) ?? true)) {
1610
- const resolution = await resolver.resolve({
1611
- workspaceId,
1612
- instance,
1613
- step,
1614
- result,
1615
- isFinalStep,
1616
- });
1617
- if (resolution?.output !== undefined)
1618
- step.output = resolution.output;
1619
- if (resolution?.ownsTerminalStatus)
1620
- resolverOwnsTerminalStatus = true;
1621
- }
1622
- // A registered custom kind's POST-ops run deterministic backend repo work from the
1623
- // agent's structured result (coerce its JSON, render artifact files, commit them via
1624
- // the checkout-free RepoFiles port — the blueprint/spec rendering that used to live in
1625
- // the harness). Position-independent like the resolver above; a no-op for built-ins
1626
- // and when GitHub isn't wired. A throwing op propagates to fail the step/run.
1627
- await this.runRegisteredPostOps(workspaceId, instance, step, isFinalStep, result);
1628
- if (isFinalStep) {
1629
- instance.status = 'done';
1630
- // Merge resolution (and confidence persistence) already happened above,
1631
- // POSITION-INDEPENDENTLY: confidence at the top of recordStepResult and the merger's
1632
- // real merge via the step-completion resolver registry (so a trailing
1633
- // post-release-health gate doesn't disable auto-merge). Nothing merge-specific here.
1634
- await this.runStateMachine.finalizeBlock(workspaceId, instance, result.confidence);
1635
- await this.executionRepository.upsert(workspaceId, instance);
1636
- await this.runStateMachine.emitInstance(workspaceId, instance);
1637
- // The run is finished: reclaim its per-run container now instead of letting it
1638
- // idle out its sleepAfter window (~10 min of billed-but-useless compute). All
1639
- // pipeline steps share the one container keyed by the execution id, so this is
1640
- // only safe on the FINAL step — never between steps. Best-effort/idempotent.
1641
- await this.runStateMachine.stopRunContainer(workspaceId, instance);
1642
- return { kind: 'done' };
1643
- }
1644
- instance.currentStep += 1;
1645
- const next = instance.steps[instance.currentStep];
1646
- if (next)
1647
- this.stepGraph.startStep(next);
1648
- // A resolver that already set the block's TERMINAL status (the merger flips it to
1649
- // `done`/`pr_ready` mid-pipeline) must not be clobbered back to `in_progress` as we
1650
- // advance to a trailing step — refresh progress only, preserving that status. (The
1651
- // final step's `finalizeBlock` then leaves a `done` block alone.)
1652
- if (resolverOwnsTerminalStatus) {
1653
- await this.runStateMachine.refreshBlockProgress(workspaceId, instance);
1654
- }
1655
- else {
1656
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'in_progress');
1657
- }
1658
- await this.executionRepository.upsert(workspaceId, instance);
1659
- await this.runStateMachine.emitInstance(workspaceId, instance);
1660
- return { kind: 'continue' };
1661
- }
1662
- /**
1663
- * Deterministically provision an ephemeral environment for a deployer step.
1664
- * Produces a human-readable summary as the step output and reports no token
1665
- * usage (it incurs no LLM cost). Errors are swallowed into the output unless
1666
- * the durable driver wants them surfaced for its per-step retry.
1667
- */
1668
- async runDeployer(workspaceId, instance, block, options = {}) {
1669
- try {
1670
- const handle = await this.environmentProvisioning.provision({
1671
- workspaceId,
1672
- blockId: block.id,
1673
- executionId: instance.id,
1674
- inputs: this.deployInputs(block),
1675
- context: this.deployContext(block),
1676
- });
1677
- const lines = [
1678
- `Provisioned ephemeral environment via '${handle.providerId}'.`,
1679
- `Status: ${handle.status}`,
1680
- `URL: ${handle.url ?? '(pending)'}`,
1681
- ];
1682
- if (handle.expiresAt)
1683
- lines.push(`Expires: ${new Date(handle.expiresAt).toISOString()}`);
1684
- return { output: lines.join('\n'), model: `environment:${handle.providerId}` };
1685
- }
1686
- catch (error) {
1687
- if (options.rethrowAgentErrors)
1688
- throw error;
1689
- return {
1690
- output: `Deployer error: ${getErrorMessage(error)}`,
1691
- };
1692
- }
1693
- }
1694
- /**
1695
- * File a tracking issue/ticket for a `tracker` step from the preceding `analysis`
1696
- * output. Non-LLM and best-effort: when no provider is wired or none is configured
1697
- * for the workspace it simply notes the skip; a filing error is folded into the
1698
- * step output rather than failing the run (the implementation still proceeds).
1699
- */
1700
- async runTracker(workspaceId, instance, block) {
1701
- if (!this.ticketTrackerProvider) {
1702
- return { output: 'No issue tracker configured; skipped ticket creation.' };
1703
- }
1704
- // The report to file is the closest preceding `analysis` output, falling back
1705
- // to the block description when the pipeline has no analysis step.
1706
- const analysis = instance.steps
1707
- .slice(0, instance.currentStep)
1708
- .filter((s) => s.agentKind === ANALYSIS_AGENT_KIND && s.output)
1709
- .map((s) => s.output)
1710
- .pop();
1711
- const body = (analysis ?? block.description ?? '').trim() || 'Automated tech-debt remediation.';
1712
- const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, block.id)) ?? block.id;
1713
- try {
1714
- const ticket = await this.ticketTrackerProvider.createTicket({
1715
- workspaceId,
1716
- frameId,
1717
- title: `Tech debt: ${block.title}`,
1718
- body,
1719
- });
1720
- if (!ticket) {
1721
- return { output: 'No issue tracker configured; skipped ticket creation.' };
1722
- }
1723
- return { output: `Filed tracking ticket ${ticket.externalId}: ${ticket.url}` };
1724
- }
1725
- catch (error) {
1726
- return { output: `Could not file a tracking ticket: ${getErrorMessage(error)}` };
1727
- }
1728
- }
1729
- /**
1730
- * The polling-gate registry, keyed by `agentKind`. A gate runs a programmatic
1731
- * precheck against a provider and only escalates to a helper container agent on a
1732
- * negative verdict. Built lazily (the closures capture `this`, so the providers /
1733
- * merge preset / notification helpers resolve at call time) and cached per instance.
1734
- * The registry merges deployment-registered gates ({@link registeredGateFactories}),
1735
- * which are a STARTUP import side effect — a gate registered after this cache is first
1736
- * built is invisible to this instance, so register at startup, before serving. Returns
1737
- * undefined for a non-gate kind. See {@link GateDefinition} and {@link evaluateGate}.
1738
- */
1739
- gateFor(agentKind) {
1740
- if (!this.gateRegistryCache)
1741
- this.gateRegistryCache = this.buildGateRegistry();
1742
- return this.gateRegistryCache.get(agentKind);
1743
- }
1744
- /**
1745
- * Resolve the concrete branch a registered kind's pre/post-op reads or writes, from
1746
- * its declared clone target — mirroring the container executor's mapping so a backend
1747
- * op and the container agent operate on the SAME branch:
1748
- * - `base` → the repo default branch (the ONLY way a committing op targets `main`).
1749
- * - `pr` → the block's PR branch (the coder's branch); when no PR is open, the
1750
- * per-block work branch (created from base if missing) — NOT base, so a
1751
- * committing post-op can't silently land on the default branch.
1752
- * - `work` (default) → the per-block work branch, ENSURED to exist exactly as
1753
- * {@link ContainerAgentExecutor}'s `ensureWorkBranch` does. The old code
1754
- * returned base here whenever no PR was open yet, diverging from the
1755
- * container agent (which clones `cat-factory/<blockId>`) and letting a
1756
- * post-op commit onto the default branch.
1757
- * The work-branch name (`cat-factory/<blockId>`) is the same convention
1758
- * {@link ContainerAgentExecutor} uses.
1759
- */
1760
- async resolveRepoOpBranch(step, block, runRepo) {
1761
- const { repo, baseBranch } = runRepo;
1762
- const prBranch = block.pullRequest?.branch;
1763
- const workBranch = `cat-factory/${block.id}`;
1764
- switch (step?.clone?.branch) {
1765
- case 'base':
1766
- return baseBranch;
1767
- case 'pr':
1768
- return prBranch ?? (await this.ensureWorkBranch(repo, workBranch, baseBranch));
1769
- default:
1770
- // 'work' (or unspecified): the work branch the container agent operates on. A PR
1771
- // is normally opened on that branch, but even before one exists we ensure it so
1772
- // the backend op and the container agent share the same branch.
1773
- return prBranch && prBranch !== workBranch
1774
- ? prBranch
1775
- : await this.ensureWorkBranch(repo, workBranch, baseBranch);
1776
- }
1777
- }
1778
- /**
1779
- * Ensure the per-block work branch `cat-factory/<blockId>` exists — creating it from the
1780
- * repo default branch's head when absent — and return it. The checkout-free analogue of
1781
- * {@link ContainerAgentExecutor}'s `ensureWorkBranch`, so a backend pre/post-op writes
1782
- * the SAME branch the container agent does instead of the default branch. Falls back to
1783
- * the base branch only when the repo has no default-branch head to fork from (an empty
1784
- * repo), so the caller always gets a real branch.
1785
- */
1786
- async ensureWorkBranch(repo, workBranch, baseBranch) {
1787
- if (await repo.headSha(workBranch))
1788
- return workBranch;
1789
- const baseSha = await repo.headSha(baseBranch);
1790
- if (!baseSha)
1791
- return baseBranch;
1792
- await repo.createBranch(workBranch, baseSha);
1793
- return workBranch;
1794
- }
1795
- /**
1796
- * Run a registered kind's PRE-op hooks before its agent step dispatches: deterministic
1797
- * backend work (read a baseline artifact into the prompt, etc.) over a checkout-free
1798
- * {@link RepoFiles}. No-op for built-in / unregistered kinds, when the kind declares no
1799
- * pre-ops, or when GitHub isn't wired (no `resolveRunRepoContext`) — so the engine runs
1800
- * unchanged without the feature. A throwing op propagates to fail the step.
1801
- */
1802
- async runRegisteredPreOps(workspaceId, block, step, context) {
1803
- const ops = registeredPreOps(step.agentKind);
1804
- if (ops.length === 0)
1805
- return;
1806
- const runRepo = await this.resolveRunRepo(workspaceId, block.id);
1807
- if (!runRepo)
1808
- return;
1809
- const branch = await this.resolveRepoOpBranch(registeredAgentStep(step.agentKind), block, runRepo);
1810
- await runRepoOps(ops, { repo: runRepo.repo, context, branch });
1811
- }
1812
- /**
1813
- * Resolve a block's run-repo context for its pre/post-op hooks. Returns null only when
1814
- * the resolver is UNWIRED (tests / GitHub not connected) so a deployment without the
1815
- * feature simply skips the hooks. When the resolver IS wired, its result — including a
1816
- * THROW from `resolveRepoTarget` for a block that isn't under a linked service — is
1817
- * propagated as-is: a registered kind with repo hooks run on a misconfigured block fails
1818
- * the run loudly rather than silently committing nothing (or guessing a repo), the same
1819
- * way a container custom kind fails at dispatch.
1820
- */
1821
- async resolveRunRepo(workspaceId, blockId) {
1822
- if (!this.resolveRunRepoContext)
1823
- return null;
1824
- return this.resolveRunRepoContext(workspaceId, blockId);
1825
- }
1826
- /**
1827
- * Run a registered kind's POST-op hooks after its agent step's result is recorded:
1828
- * deterministic backend work that consumes the agent's structured output (coerce its
1829
- * JSON, render artifact files, commit them via {@link RepoFiles}) — the
1830
- * blueprint/spec rendering that used to live in the harness. Same gating + symmetry as
1831
- * {@link runRegisteredPreOps}; the agent's {@link AgentRunResult} is threaded through.
1832
- */
1833
- async runRegisteredPostOps(workspaceId, instance, step, isFinalStep, result) {
1834
- const registered = registeredPostOps(step.agentKind);
1835
- const builtIn = this.builtInPostOps(step.agentKind);
1836
- if (registered.length === 0 && builtIn.length === 0)
1837
- return;
1838
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
1839
- if (!block)
1840
- return;
1841
- const runRepo = await this.resolveRunRepo(workspaceId, block.id);
1842
- if (!runRepo)
1843
- return;
1844
- const context = await this.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block);
1845
- // Registered (custom) kinds resolve their branch from their declared clone target.
1846
- if (registered.length > 0) {
1847
- const branch = await this.resolveRepoOpBranch(registeredAgentStep(step.agentKind), block, runRepo);
1848
- await runRepoOps(registered, { repo: runRepo.repo, context, branch, result });
1849
- }
1850
- // Built-in (migrated) kinds resolve their branch to MATCH their container dispatch
1851
- // exactly (see {@link builtInRepoOpBranch}), which differs from the generic clone
1852
- // resolution for the no-PR case — so the post-op commits where the agent read.
1853
- if (builtIn.length > 0) {
1854
- const branch = await this.builtInRepoOpBranch(step.agentKind, block, runRepo);
1855
- await runRepoOps(builtIn, { repo: runRepo.repo, context, branch, result });
1856
- }
1857
- }
1858
- /**
1859
- * The BUILT-IN (non-registry) post-ops for a migrated built-in kind, keyed by agent
1860
- * kind — the deterministic render + commit lifted out of the executor-harness. Kept
1861
- * OUT of the agent-kind registry on purpose: registering the built-ins would leak them
1862
- * into `customAgentKinds` / the SPA palette. Empty for every other kind.
1863
- */
1864
- builtInPostOps(agentKind) {
1865
- return ExecutionService.BUILT_IN_POST_OPS[agentKind] ?? [];
1866
- }
1867
- /**
1868
- * The built-in (NON-registry) post-ops keyed by kind. A small map rather than an
1869
- * `if`-chain so each migrated built-in is one entry as the strangler converts more
1870
- * kinds; deliberately NOT the agent-kind registry (that would leak the built-ins into
1871
- * `customAgentKinds` / the SPA palette).
1872
- */
1873
- static BUILT_IN_POST_OPS = {
1874
- [BLUEPRINTS_AGENT_KIND]: [blueprintPostOp],
1875
- [SPEC_WRITER_AGENT_KIND]: [specPostOp],
1876
- };
1877
- /**
1878
- * The branch a built-in kind's post-op reads/commits, resolved to MATCH the kind's
1879
- * container dispatch (so the post-op commits onto exactly the branch the explore agent
1880
- * cloned).
1881
- * - blueprints clones the PR branch when one is open, else the repo's default branch —
1882
- * so the initial bootstrap map lands directly on the default branch, mirroring
1883
- * {@link ContainerAgentExecutor}'s `pr`-clone resolution (`prBranch ?? baseBranch`).
1884
- * Deliberately NOT {@link resolveRepoOpBranch}, whose `pr` case ensures a work branch
1885
- * for the no-PR case — correct for a committing CUSTOM kind, wrong for the blueprint.
1886
- * - spec-writer commits onto the per-block WORK branch (`cat-factory/<blockId>`), created
1887
- * from base when absent. It is a WRITER (not read-only), so its container dispatch
1888
- * always ensures + clones that work branch ({@link ContainerAgentExecutor}'s
1889
- * `workBranchReady ? workBranch : …` resolves to the work branch). We mirror that
1890
- * DETERMINISTICALLY here — NOT via {@link resolveRepoOpBranch}'s `work` case, whose
1891
- * PR-preferring branch would commit onto a divergent PR branch (read one tree, write
1892
- * another) if a PR were ever open on a branch other than `cat-factory/<blockId>`.
1893
- */
1894
- async builtInRepoOpBranch(agentKind, block, runRepo) {
1895
- if (agentKind === SPEC_WRITER_AGENT_KIND) {
1896
- return this.ensureWorkBranch(runRepo.repo, `cat-factory/${block.id}`, runRepo.baseBranch);
1897
- }
1898
- return block.pullRequest?.branch ?? runRepo.baseBranch;
1899
- }
1900
- /**
1901
- * The post-completion resolver for an agent kind, or undefined when the kind has none.
1902
- * A resolver runs DETERMINISTIC backend follow-up once the step's agent finishes — e.g.
1903
- * the merger performs the real GitHub merge — independent of the step's position in the
1904
- * pipeline. Built lazily (closures capture `this`) and cached per instance; the registry
1905
- * merges deployment-registered resolvers ({@link registeredStepResolverFactories}), a
1906
- * startup import side effect (see {@link gateFor} for the same caching caveat). See
1907
- * {@link StepCompletionResolver}.
1908
- */
1909
- /**
1910
- * Dispatch a step (whose preamble already ran in {@link stepInstance}) to the first
1911
- * registered {@link StepHandler} whose `canHandle` claims it, ordered by `order`. The
1912
- * fallthrough handler claims everything, so this always resolves to a handler.
1913
- */
1914
- dispatchStepHandler(ctx) {
1915
- if (!this.stepHandlerCache)
1916
- this.stepHandlerCache = this.buildStepHandlerRegistry();
1917
- const handler = this.stepHandlerCache.find((h) => h.canHandle(ctx));
1918
- // The fallthrough handler's `canHandle` is unconditional, so this is unreachable; it
1919
- // exists only to satisfy the type and to fail loudly if that invariant is ever broken.
1920
- if (!handler)
1921
- throw new Error(`No step handler for agentKind "${ctx.step.agentKind}"`);
1922
- return handler.handle(ctx);
1923
- }
1924
- /**
1925
- * Build the order-sorted per-step-kind handler list, mirroring
1926
- * {@link buildStepResolverRegistry} (built-ins constructed inline, closing over `this`).
1927
- * Engine-internal: there is no public `registerStepHandler` seam. Phase 0 registers only
1928
- * the generic fallthrough; later phases prepend more-specific handlers with lower `order`.
1929
- */
1930
- buildStepHandlerRegistry() {
1931
- const handlers = [
1932
- // A `deployer` step provisions an ephemeral environment deterministically via the
1933
- // provider — no LLM, no token usage — when the integration is wired. Unwired, its
1934
- // `canHandle` is false so the step falls through to the generic agent path.
1935
- {
1936
- kind: DEPLOYER_AGENT_KIND,
1937
- order: 100,
1938
- canHandle: ({ step }) => !!this.environmentProvisioning && isDeployStep(step.agentKind),
1939
- handle: async ({ workspaceId, instance, step, block, isFinalStep, options }) => {
1940
- const result = await this.runDeployer(workspaceId, instance, block, options);
1941
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, result);
1942
- },
1943
- },
1944
- // A `tracker` step files a GitHub issue / Jira ticket from the preceding `analysis`
1945
- // output (the tech-debt pipeline) — no LLM of its own. It is a pass-through when no
1946
- // tracker provider is wired or none is configured for the workspace (handled inside
1947
- // {@link runTracker}, which still records a result), so it always claims the step.
1948
- {
1949
- kind: TRACKER_AGENT_KIND,
1950
- order: 110,
1951
- canHandle: ({ step }) => step.agentKind === TRACKER_AGENT_KIND,
1952
- handle: async ({ workspaceId, instance, step, block, isFinalStep }) => {
1953
- const result = await this.runTracker(workspaceId, instance, block);
1954
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, result);
1955
- },
1956
- },
1957
- // The `requirements-review` / `clarity-review` / `requirements-brainstorm` /
1958
- // `architecture-brainstorm` steps are inline reviewers that park for their dedicated
1959
- // window and drive the iterative answer → incorporate → re-review loop — NOT
1960
- // container/prose agents. All four run through the {@link ReviewGateController},
1961
- // parameterised by their {@link ReviewKind} (the per-case `switch` binds each kind's
1962
- // review type so `evaluate`'s generic infers). Pass-through when the service isn't wired.
1963
- {
1964
- kind: 'review-gate',
1965
- order: 120,
1966
- canHandle: ({ step }) => REVIEW_GATE_AGENT_KINDS.has(step.agentKind),
1967
- handle: ({ workspaceId, instance, step, block, isFinalStep }) => {
1968
- switch (step.agentKind) {
1969
- case REQUIREMENTS_REVIEW_AGENT_KIND:
1970
- return this.reviewGate.evaluate(this.requirementsKind, workspaceId, instance, step, block, isFinalStep);
1971
- case CLARITY_REVIEW_AGENT_KIND:
1972
- return this.reviewGate.evaluate(this.clarityKind, workspaceId, instance, step, block, isFinalStep);
1973
- case REQUIREMENTS_BRAINSTORM_AGENT_KIND:
1974
- return this.reviewGate.evaluate(this.requirementsBrainstormKind, workspaceId, instance, step, block, isFinalStep);
1975
- case ARCHITECTURE_BRAINSTORM_AGENT_KIND:
1976
- return this.reviewGate.evaluate(this.architectureBrainstormKind, workspaceId, instance, step, block, isFinalStep);
1977
- // `canHandle` admits only the kinds in REVIEW_GATE_AGENT_KINDS, so every member
1978
- // must have an explicit case above. Throw loudly if the two ever drift (a new
1979
- // review kind added to the Set without a case here) rather than silently routing
1980
- // it to the wrong reviewer.
1981
- default:
1982
- throw new Error(`Unhandled review-gate agentKind "${step.agentKind}"`);
1983
- }
1984
- },
1985
- },
1986
- // A `human-test` gate spins up an ephemeral environment and PARKS for a human to
1987
- // validate the change in a live URL — NOT a container/prose agent and NOT a
1988
- // programmatic polling gate (the human is the verdict). Degrades to a manual (no-env)
1989
- // mode when no ephemeral-environment provider is wired. See {@link HumanTestController}.
1990
- {
1991
- kind: HUMAN_TEST_AGENT_KIND,
1992
- order: 130,
1993
- canHandle: ({ step }) => step.agentKind === HUMAN_TEST_AGENT_KIND,
1994
- handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.humanTestController.evaluate(workspaceId, instance, step, block, isFinalStep),
1995
- },
1996
- // A `visual-confirmation` gate gathers the UI tester's screenshots + uploaded reference
1997
- // designs and PARKS for a human to review actual-vs-reference, then on demand dispatches
1998
- // the Tester's `fixer`. Passes through when no binary-artifact store is wired.
1999
- // See {@link VisualConfirmationController}.
2000
- {
2001
- kind: VISUAL_CONFIRM_AGENT_KIND,
2002
- order: 140,
2003
- canHandle: ({ step }) => step.agentKind === VISUAL_CONFIRM_AGENT_KIND,
2004
- handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.visualConfirmationController.evaluate(workspaceId, instance, step, block, isFinalStep),
2005
- },
2006
- // A polling gate step (`ci` / `conflicts` / `post-release-health` / `human-review`) runs
2007
- // a programmatic precheck and only escalates to a helper container agent on a negative
2008
- // verdict — no LLM of its own. Pass-through when the gate's provider is not wired. One
2009
- // generic machine drives every gate; see {@link evaluateGate}. `canHandle` is the gate
2010
- // registry lookup, so this claims exactly the registered gate kinds.
2011
- {
2012
- kind: 'polling-gate',
2013
- order: 150,
2014
- canHandle: ({ step }) => this.gateFor(step.agentKind) !== undefined,
2015
- handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.evaluateGate(workspaceId, instance, step, block, isFinalStep, this.gateFor(step.agentKind)),
2016
- },
2017
- // An INLINE companion (architect-companion / spec-companion) grades the nearest
2018
- // preceding producer right here and loops it back for automatic rework below the
2019
- // threshold before any human gate. CONTAINER-backed companions (reviewer / doc-reviewer)
2020
- // do NOT match — they fall through to the generic async container dispatch and have their
2021
- // verdict resolved by the completion interceptor instead. See {@link CompanionController}.
2022
- {
2023
- kind: 'inline-companion',
2024
- order: 160,
2025
- canHandle: ({ step }) => isCompanionKind(step.agentKind) && !isContainerBackedCompanion(step.agentKind),
2026
- handle: ({ workspaceId, instance, step, block, isFinalStep, options }) => this.companionController.evaluate(workspaceId, instance, step, block, isFinalStep, options),
2027
- },
2028
- // The generic container/inline-agent step — claims every step no more-specific handler
2029
- // did. Highest order so it always runs last. See {@link handleAgentStep}.
2030
- {
2031
- kind: 'agent',
2032
- order: FALLTHROUGH_STEP_HANDLER_ORDER,
2033
- canHandle: () => true,
2034
- handle: (ctx) => this.handleAgentStep(ctx),
2035
- },
2036
- ];
2037
- return handlers.sort((a, b) => a.order - b.order);
2038
- }
2039
- /**
2040
- * Run the first completion-path interceptor that claims this finished step, returning its
2041
- * short-circuit {@link AdvanceResult} (the companion verdict loop / tester re-test) or
2042
- * `null` to let `recordStepResult`'s normal finish/advance spine run. Engine-internal,
2043
- * mirroring {@link dispatchStepHandler}.
2044
- */
2045
- async dispatchStepCompletionInterceptor(ctx) {
2046
- if (!this.stepCompletionInterceptorCache) {
2047
- this.stepCompletionInterceptorCache = this.buildStepCompletionInterceptors();
2048
- }
2049
- for (const interceptor of this.stepCompletionInterceptorCache) {
2050
- if (interceptor.canIntercept(ctx)) {
2051
- const outcome = await interceptor.intercept(ctx);
2052
- if (outcome)
2053
- return outcome;
2054
- }
2055
- }
2056
- return null;
2057
- }
2058
- /**
2059
- * Build the order-sorted completion-path interceptors (companion / tester verdict
2060
- * short-circuits), mirroring {@link buildStepHandlerRegistry} — built-ins constructed
2061
- * inline closing over `this`, no public registration seam.
2062
- */
2063
- buildStepCompletionInterceptors() {
2064
- const interceptors = [
2065
- // A container-backed companion (reviewer / doc-reviewer) just finished reviewing the
2066
- // real repository on the producer's PR branch and returned its verdict as
2067
- // `result.custom`. Hand it to the companion loop, which parses the verdict and applies
2068
- // the SAME threshold / rework / human-gate handling an inline companion gets. Routed
2069
- // here (not the normal step completion) so the verdict drives the loop instead of being
2070
- // recorded as plain output. Falls through (returns null) when the block can't be loaded.
2071
- {
2072
- kind: 'companion-verdict',
2073
- order: 100,
2074
- canIntercept: ({ step }) => isCompanionKind(step.agentKind) && isContainerBackedCompanion(step.agentKind),
2075
- intercept: async ({ workspaceId, instance, step, isFinalStep, result }) => {
2076
- const companionBlock = await this.blockRepository.get(workspaceId, instance.blockId);
2077
- if (!companionBlock)
2078
- return null;
2079
- return this.companionController.resolveContainerVerdict(workspaceId, instance, step, companionBlock, isFinalStep, result);
2080
- },
2081
- },
2082
- // A `tester` step returned a structured report. On a withheld greenlight we do NOT
2083
- // finish the step: loop the `fixer` (within the attempt budget) and re-test, mirroring
2084
- // the CI gate. A greenlight (or no provider) returns null and falls through to the
2085
- // normal finish/advance below. Records the report on the step either way.
2086
- {
2087
- kind: 'tester-verdict',
2088
- order: 110,
2089
- canIntercept: ({ step, result }) => isTesterKind(step.agentKind) && result.testReport !== undefined,
2090
- intercept: ({ workspaceId, instance, step, result }) => this.testerController.resolveTesterResult(workspaceId, instance, step, result),
2091
- },
2092
- ];
2093
- return interceptors.sort((a, b) => a.order - b.order);
2094
- }
2095
- stepResolverFor(agentKind) {
2096
- if (!this.stepResolverCache)
2097
- this.stepResolverCache = this.buildStepResolverRegistry();
2098
- return this.stepResolverCache.get(agentKind);
2099
- }
2100
- buildStepResolverRegistry() {
2101
- const resolvers = [
2102
- // The `merger` agent OWNS the merge decision, but the merge itself is mechanical
2103
- // and uses backend-held GitHub credentials the sandboxed agent never sees — so the
2104
- // engine performs it deterministically from the agent's assessment here, the moment
2105
- // the merger step finishes (NOT only when it is the pipeline's last step, which is
2106
- // why a trailing `post-release-health` step no longer disables auto-merge).
2107
- {
2108
- kind: MERGER_AGENT_KIND,
2109
- applies: (result) => result.mergeAssessment !== undefined,
2110
- resolve: async ({ workspaceId, instance, result }) => {
2111
- // The real merge runs the engine GitHub client under the run initiator's
2112
- // ambient context, so a per-user PAT (when set) authors the merge.
2113
- await this.runInitiatorScope(instance.initiatedBy, () => this.mergeResolver.resolveMergerStep(workspaceId, instance, result.mergeAssessment));
2114
- return { ownsTerminalStatus: true };
2115
- },
2116
- },
2117
- // POST-COMPLETION resolvers — run at the early slot (after output is recorded, before
2118
- // the follow-up/approval gates), reshaping the agent's structured result into domain
2119
- // state. Lifted verbatim from the old inline `recordStepResult` branches.
2120
- //
2121
- // A Blueprinter step produced a fresh service decomposition. Validate it with the
2122
- // authoritative schema (a bad payload must never touch the board), then reconcile it
2123
- // in place onto the run's service frame.
2124
- {
2125
- kind: BLUEPRINTS_AGENT_KIND,
2126
- phase: 'post-completion',
2127
- resolve: async ({ workspaceId, instance, result }) => {
2128
- if (result.blueprintService !== undefined) {
2129
- await this.ingestBlueprint(workspaceId, instance.blockId, result.blueprintService);
2130
- }
2131
- },
2132
- },
2133
- // A spec-writer step produced the service's unified specification (`spec.json`) and
2134
- // committed it to the implementation branch — strict-validate it then nudge clients —
2135
- // and reports its BUSINESS-vs-TECHNICAL determination. "No business specs" (a purely
2136
- // technical task) is a valid outcome the spec-companion's convergence later combines
2137
- // with its `technicalCorroborated` verdict; recorded even when false so a re-run
2138
- // reflects the latest.
2139
- {
2140
- kind: SPEC_WRITER_AGENT_KIND,
2141
- phase: 'post-completion',
2142
- resolve: async ({ workspaceId, step, result }) => {
2143
- if (result.spec !== undefined)
2144
- await this.ingestSpec(workspaceId, result.spec);
2145
- step.noBusinessSpecs = result.noBusinessSpecs === true;
2146
- },
2147
- },
2148
- // A `task-estimator` step emits a JSON triage (complexity/risk/impact). Parse it
2149
- // tolerantly, persist it on the block (used to gate consensus steps + surfaced in the
2150
- // UI), and replace the raw JSON output with a readable summary. An unparseable estimate
2151
- // leaves the block untouched and keeps the raw output (no run failure). Works the same
2152
- // whether the single-actor estimator or the consensus ranked-scoring variant produced
2153
- // the JSON. Running at the post-completion slot keeps the summary in `step.output`
2154
- // before the approval gate reads it as the proposal.
2155
- {
2156
- kind: TASK_ESTIMATOR_AGENT_KIND,
2157
- phase: 'post-completion',
2158
- resolve: async ({ workspaceId, instance, step, result }) => {
2159
- const estimate = coerceTaskEstimate(step.output ?? '', result.model ?? step.model ?? null, this.clock.now());
2160
- if (estimate) {
2161
- await this.blockRepository.update(workspaceId, instance.blockId, { estimate });
2162
- return { output: summarizeEstimate(estimate) };
2163
- }
2164
- },
2165
- },
2166
- ];
2167
- const map = new Map(resolvers.map((r) => [r.kind, r]));
2168
- // Merge deployment-registered resolvers, mirroring the gate registry below. A
2169
- // registered resolver of the same kind replaces the built-in (last registration wins).
2170
- const ctx = this.makeResolverContext();
2171
- for (const { kind, factory } of registeredStepResolverFactories())
2172
- map.set(kind, factory(ctx));
2173
- return map;
2174
- }
2175
- /** The shared engine seams handed to a deployment-registered step resolver's factory. */
2176
- makeResolverContext() {
2177
- return { runInitiatorScope: this.runInitiatorScope };
2178
- }
2179
- buildGateRegistry() {
2180
- // The built-in gate suite (ci / conflicts / post-release-health) is no longer inline:
2181
- // it ships as `@cat-factory/gates`, registered through the SAME public `registerGate`
2182
- // seam any deployment uses (the dogfood — if the platform's own gates can be authored
2183
- // as an external package, so can anyone's). The engine merely builds whatever gates were
2184
- // registered at startup. A facade that forgot to `import '@cat-factory/gates'` then has
2185
- // no gates and those steps fail — which the cross-runtime conformance suite catches.
2186
- const map = new Map();
2187
- const ctx = this.makeGateContext();
2188
- for (const { kind, factory } of registeredGateFactories())
2189
- map.set(kind, factory(ctx));
2190
- return map;
2191
- }
2192
- /** The shared engine seams handed to a deployment-registered gate's factory. */
2193
- makeGateContext() {
2194
- return {
2195
- clock: this.clock,
2196
- getBlock: (workspaceId, blockId) => this.blockRepository.get(workspaceId, blockId),
2197
- runInitiatorScope: this.runInitiatorScope,
2198
- raiseNotification: async (workspaceId, input) => {
2199
- await this.notificationService?.raise(workspaceId, input);
2200
- },
2201
- // A gate reaches its deployment-wired provider through the typed registry rather than
2202
- // closing over a hand-authored module global; the engine just forwards to it.
2203
- getProvider,
2204
- requireProvider,
2205
- };
2206
- }
2207
- /**
2208
- * Evaluate a polling gate step once and decide (shared by the initial advance and the
2209
- * durable `awaiting_gate` re-poll):
2210
- * - no provider wired → pass-through (advance; nothing to gate);
2211
- * - precheck passes → advance to the next step (the helper agent is NEVER spun up);
2212
- * - still computing → `awaiting_gate` (the driver sleeps then calls {@link pollGate});
2213
- * - fails, budget left → dispatch the helper container agent (`awaiting_job`);
2214
- * - fails, budget spent → the gate's exhaustion handler, then fail the run.
2215
- */
2216
- async evaluateGate(workspaceId, instance, step, block, isFinalStep, gate) {
2217
- // Re-attach after a replay: a helper is already in flight for this gate.
2218
- if (step.gate?.phase === 'working' && step.jobId) {
2219
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
2220
- }
2221
- // Provider not wired: the gate is a pass-through so the engine works without it.
2222
- if (!gate.wired()) {
2223
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
2224
- output: gate.unwiredOutput,
2225
- });
2226
- }
2227
- // Initialise the gate's state on first entry, resolving the attempt budget from the
2228
- // task's merge preset (stable across polls once set).
2229
- if (!step.gate) {
2230
- const preset = await this.resolveMergePreset(workspaceId, block);
2231
- step.gate = {
2232
- phase: 'checking',
2233
- attempts: 0,
2234
- maxAttempts: gate.attemptBudget ? gate.attemptBudget(preset) : preset.ciMaxAttempts,
2235
- headSha: null,
2236
- // Stash the watch window once (read on every poll by a time-windowed gate's
2237
- // probe; harmless/unused for the CI/conflicts gates).
2238
- watchWindowMinutes: preset.releaseWatchWindowMinutes,
2239
- // Stash the human-review grace window once (read by the human-review gate's probe;
2240
- // harmless/unused for the other gates).
2241
- humanReviewGraceMinutes: preset.humanReviewGraceMinutes,
2242
- };
2243
- }
2244
- // A human-initiated fix request (an in-app freeform prompt, or a GitHub-comment
2245
- // instruction) parked on the gate is dispatched immediately — bypassing the precheck +
2246
- // grace window. Consume it at-most-once: clear + persist BEFORE the (side-effecting)
2247
- // dispatch so a retried driver step can't re-dispatch a second fixer. Falls through to
2248
- // the normal probe when there is no async executor to escalate to.
2249
- if (step.gate.pendingFix && isAsyncAgentExecutor(this.agentExecutor)) {
2250
- const fix = step.gate.pendingFix;
2251
- step.gate.pendingFix = null;
2252
- await this.executionRepository.upsert(workspaceId, instance);
2253
- return this.dispatchGateHelper(workspaceId, instance, step, block, isFinalStep, gate, fix.instructions);
2254
- }
2255
- // A time-windowed gate (post-release-health) marks when it began watching, on first
2256
- // entry, so its probe knows whether the monitoring window has elapsed. Harmless for
2257
- // the CI/conflicts gates, which ignore it.
2258
- if (step.gate.watchSince == null)
2259
- step.gate.watchSince = this.clock.now();
2260
- // Resolve the gate's GitHub reads (CI checks / mergeability) under the run
2261
- // initiator's ambient context, so a per-user PAT (when set) is preferred over the
2262
- // deployment's App/env token — see PatPreferringAppRegistry.
2263
- const gateState = step.gate;
2264
- const probe = await this.runInitiatorScope(instance.initiatedBy, () => gate.probe(workspaceId, block.id, gateState));
2265
- step.gate.headSha = probe.headSha;
2266
- // Persist the precheck outcome so the run-detail UI can surface why the gate is
2267
- // looping (the failing checks / conflict reason) — detail that was previously fed
2268
- // only to the helper agent and then discarded.
2269
- step.gate.lastVerdict = probe.status;
2270
- step.gate.lastFailureSummary = probe.failureSummary ?? null;
2271
- step.gate.failingChecks = probe.failingChecks ?? null;
2272
- if (probe.status === 'pass') {
2273
- // Stop the moment the precheck passes — finish the step and advance.
2274
- return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
2275
- output: probe.passOutput ?? `${gate.kind} gate passed.`,
2276
- });
2277
- }
2278
- if (probe.status === 'pending') {
2279
- // Keep polling. Persist the head sha + phase so the board can reflect it.
2280
- step.gate.phase = 'checking';
2281
- await this.executionRepository.upsert(workspaceId, instance);
2282
- await this.runStateMachine.emitInstance(workspaceId, instance);
2283
- return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
2284
- }
2285
- // probe.status === 'fail'.
2286
- const canEscalate = isAsyncAgentExecutor(this.agentExecutor);
2287
- if (canEscalate && step.gate.attempts < step.gate.maxAttempts) {
2288
- return this.dispatchGateHelper(workspaceId, instance, step, block, isFinalStep, gate, probe.failureSummary);
2289
- }
2290
- // Budget spent (or no async executor to escalate to): give up.
2291
- const { error } = await gate.onExhausted({
2292
- workspaceId,
2293
- instance,
2294
- block,
2295
- step,
2296
- summary: probe.failureSummary,
2297
- });
2298
- return { kind: 'job_failed', error };
2299
- }
2300
- /**
2301
- * Dispatch a gate's helper container agent on a failed precheck: build the agent
2302
- * context with the kind overridden to the helper (it clones the PR head branch and
2303
- * pushes — no new PR), park on the job, and flip the gate to `working`. Idempotent
2304
- * under replay via the step's `jobId` (re-attach handled in {@link evaluateGate}).
2305
- */
2306
- async dispatchGateHelper(workspaceId, instance, step, block, isFinalStep, gate, failureSummary) {
2307
- const executor = this.agentExecutor;
2308
- if (!isAsyncAgentExecutor(executor)) {
2309
- // Defensive: evaluateGate only calls this when async-capable.
2310
- return { kind: 'job_failed', error: `No async executor available for the ${gate.kind} gate.` };
2311
- }
2312
- const base = await this.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block);
2313
- // A gate may build richer helper context asynchronously (the on-call agent gets the
2314
- // full Datadog evidence bundle); otherwise fall back to the simple summary prior.
2315
- const extras = gate.gatherHelperPriorOutputs
2316
- ? await gate.gatherHelperPriorOutputs(workspaceId, block.id, step.gate ?? { phase: 'checking', attempts: 0, maxAttempts: 0 })
2317
- : [gate.helperPriorOutput?.(failureSummary ?? '')].filter((o) => o != null);
2318
- const context = {
2319
- ...base,
2320
- agentKind: gate.helperKind,
2321
- priorOutputs: [...base.priorOutputs, ...extras],
2322
- };
2323
- const handle = await executor.startJob(context);
2324
- step.jobId = handle.jobId;
2325
- if (handle.model)
2326
- step.model = handle.model;
2327
- step.gate = {
2328
- // Preserve the recorded verdict/failure detail (set in evaluateGate) so the UI
2329
- // keeps showing what the helper is fixing while it works.
2330
- ...step.gate,
2331
- phase: 'working',
2332
- attempts: (step.gate?.attempts ?? 0) + 1,
2333
- maxAttempts: step.gate?.maxAttempts ?? DEFAULT_MERGE_PRESET.ciMaxAttempts,
2334
- headSha: step.gate?.headSha ?? null,
2335
- };
2336
- await this.executionRepository.upsert(workspaceId, instance);
2337
- await this.runStateMachine.emitInstance(workspaceId, instance);
2338
- return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
2339
- }
2340
- // ---- Follow-up companion (future-looking Coder) -------------------------
2341
- // The Coder streams forward-looking items (loose ends / side-tasks / questions) which
2342
- // accrue on its `step.followUps` live (see pollAgentJob). At the Coder's completion the
2343
- // run parks while any item is undecided, then loops the Coder for the items the human
2344
- // queued / answered (within the loop budget) before the following steps may start.
2345
- /**
2346
- * Append the items the harness streamed since the last poll onto the Coder step's
2347
- * follow-up state as fresh `pending` items. A no-op when the companion is off or nothing
2348
- * was streamed. Returns whether anything was added (so the poller persists + emits).
2349
- */
2350
- appendStreamedFollowUps(step, streamed) {
2351
- if (!step.followUps?.enabled || !streamed || streamed.length === 0)
2352
- return false;
2353
- const now = this.clock.now();
2354
- for (const s of streamed) {
2355
- const title = (s.title ?? '').trim();
2356
- if (!title)
2357
- continue;
2358
- step.followUps.items.push({
2359
- id: this.idGenerator.next('fu'),
2360
- kind: s.kind === 'question' ? 'question' : 'follow_up',
2361
- title,
2362
- detail: s.detail ?? '',
2363
- ...(s.suggestedAction ? { suggestedAction: s.suggestedAction } : {}),
2364
- status: 'pending',
2365
- createdAt: now,
2366
- updatedAt: now,
2367
- });
2368
- }
2369
- return true;
2370
- }
2371
- /**
2372
- * The Follow-up companion gate, evaluated when the Coder step completes: park the run on
2373
- * a durable decision while any item is undecided; else loop the Coder for the queued /
2374
- * answered items (within the budget); else fall through (return undefined) so the normal
2375
- * advance/finish logic runs. Returns an {@link AdvanceResult} only when it parks or loops.
2376
- */
2377
- async evaluateFollowUpGate(workspaceId, instance, step) {
2378
- const state = step.followUps;
2379
- if (!state?.enabled)
2380
- return undefined;
2381
- if (hasPendingFollowUps(state)) {
2382
- await this.raiseFollowUpPending(workspaceId, instance, state);
2383
- return this.runStateMachine.parkStepOnDecision(workspaceId, instance, step);
2384
- }
2385
- if (shouldLoopCoder(state)) {
2386
- this.loopCoderForFollowUps(instance, step);
2387
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'in_progress');
2388
- await this.executionRepository.upsert(workspaceId, instance);
2389
- await this.runStateMachine.emitInstance(workspaceId, instance);
2390
- return { kind: 'continue' };
2391
- }
2392
- return undefined;
2393
- }
2394
- /**
2395
- * Reset the Coder step and fold the human's queued follow-ups / answered questions into
2396
- * its rework so the next pass extends the prior work. Marks those items `sentToCoder` so
2397
- * a later completion doesn't re-loop them, and counts the loop against the budget. Shared
2398
- * by the at-completion path ({@link evaluateFollowUpGate}) and the parked-resume path.
2399
- */
2400
- loopCoderForFollowUps(instance, step) {
2401
- const state = step.followUps;
2402
- const sending = followUpsToSendBack(state);
2403
- const feedback = renderFollowUpRework(sending);
2404
- for (const item of sending) {
2405
- item.sentToCoder = true;
2406
- item.updatedAt = this.clock.now();
2407
- }
2408
- state.loops = (state.loops ?? 0) + 1;
2409
- // Reset the step for a fresh dispatch; `step.followUps` is intentionally preserved
2410
- // (resetStepForRerun doesn't touch it) so the surfaced items survive the loop.
2411
- this.stepGraph.resetStepForRerun(step);
2412
- step.rework = { previousProposal: '', feedback };
2413
- this.stepGraph.startStep(step);
2414
- if (instance.status === 'blocked')
2415
- instance.status = 'running';
2416
- }
2417
- /** Raise the "follow-ups need decisions" inbox card when the Coder parks on undecided items. */
2418
- async raiseFollowUpPending(workspaceId, instance, state) {
2419
- if (!this.notificationService)
2420
- return;
2421
- const block = await this.blockRepository.get(workspaceId, instance.blockId);
2422
- if (!block)
2423
- return;
2424
- const pending = state.items.filter((i) => i.status === 'pending').length;
2425
- await this.notificationService.raise(workspaceId, {
2426
- type: 'followup_pending',
2427
- blockId: block.id,
2428
- executionId: instance.id,
2429
- title: `"${block.title}" surfaced ${pending} follow-up${pending === 1 ? '' : 's'} to decide`,
2430
- body: 'The Coder flagged forward-looking follow-ups / questions. Open the task to file ' +
2431
- 'each as an issue, send it back to the Coder, answer it, or dismiss it — the ' +
2432
- 'pipeline continues once every item is decided.',
2433
- payload: { pipelineName: instance.pipelineName, findingCount: pending },
2434
- });
2435
- }
2436
- /**
2437
- * The run's "active" follow-up companion step for a read with no item context (the GET /
2438
- * the inbox-card open). A pipeline may carry MORE THAN ONE follow-up-enabled Coder step,
2439
- * so this must not blindly pick the first: prefer the step the run is currently on (a Coder
2440
- * parked on its follow-up gate), else the latest enabled step that has surfaced items, else
2441
- * the first enabled one.
2442
- */
2443
- activeFollowUpStep(instance) {
2444
- const current = instance.steps[instance.currentStep];
2445
- if (current?.followUps?.enabled)
2446
- return { step: current, index: instance.currentStep };
2447
- for (let i = instance.steps.length - 1; i >= 0; i--) {
2448
- const s = instance.steps[i];
2449
- if (s.followUps?.enabled && s.followUps.items.length > 0)
2450
- return { step: s, index: i };
2451
- }
2452
- const index = instance.steps.findIndex((s) => s.followUps?.enabled);
2453
- return index >= 0 ? { step: instance.steps[index], index } : undefined;
2454
- }
2455
- /** Read a run's live follow-up companion state (the active Coder step's items), or null. */
2456
- async getFollowUps(workspaceId, executionId) {
2457
- const instance = await this.executionRepository.get(workspaceId, executionId);
2458
- if (!instance)
2459
- throw new NotFoundError('Execution', executionId);
2460
- return this.activeFollowUpStep(instance)?.step.followUps ?? null;
2461
- }
2462
- /**
2463
- * Locate the run + the Coder step that OWNS the addressed item + the item, throwing 404
2464
- * when absent. Routes by item id (not "the first enabled step") so a pipeline carrying more
2465
- * than one follow-up-enabled Coder step decides each item on the step that surfaced it —
2466
- * otherwise a later Coder's items 404 and its gate can never be cleared.
2467
- */
2468
- async loadFollowUpItem(workspaceId, executionId, itemId) {
2469
- const instance = await this.executionRepository.get(workspaceId, executionId);
2470
- if (!instance)
2471
- throw new NotFoundError('Execution', executionId);
2472
- const index = instance.steps.findIndex((s) => s.followUps?.enabled && s.followUps.items.some((i) => i.id === itemId));
2473
- if (index < 0)
2474
- throw new NotFoundError('Follow-up item', itemId);
2475
- const step = instance.steps[index];
2476
- const item = step.followUps.items.find((i) => i.id === itemId);
2477
- return { instance, step, index, item };
2478
- }
2479
- /** File a `follow_up` item as a tracker issue (GitHub / Jira), recording the ticket ref. */
2480
- async fileFollowUp(workspaceId, executionId, itemId) {
2481
- const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2482
- if (item.kind !== 'follow_up') {
2483
- throw new ConflictError('Only follow-up items can be filed as issues');
2484
- }
2485
- if (!this.ticketTrackerProvider) {
2486
- throw new ConflictError('No issue tracker is configured for this workspace');
2487
- }
2488
- const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, instance.blockId)) ??
2489
- instance.blockId;
2490
- const body = [
2491
- item.detail,
2492
- item.suggestedAction ? `\n\nSuggested approach: ${item.suggestedAction}` : '',
2493
- ]
2494
- .join('')
2495
- .trim();
2496
- const ticket = await this.ticketTrackerProvider.createTicket({
2497
- workspaceId,
2498
- frameId,
2499
- title: item.title,
2500
- body: body || item.title,
2501
- });
2502
- if (!ticket) {
2503
- throw new ConflictError('No issue tracker is configured for this workspace');
2504
- }
2505
- item.status = 'filed';
2506
- item.ticketExternalId = ticket.externalId;
2507
- item.ticketUrl = ticket.url;
2508
- item.updatedAt = this.clock.now();
2509
- await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2510
- return step.followUps;
2511
- }
2512
- /** Queue a `follow_up` item to send back to the Coder on its next pass. */
2513
- async queueFollowUp(workspaceId, executionId, itemId) {
2514
- const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2515
- if (item.kind !== 'follow_up') {
2516
- throw new ConflictError('Only follow-up items can be sent back to the Coder');
2517
- }
2518
- item.status = 'queued';
2519
- item.sentToCoder = false;
2520
- item.updatedAt = this.clock.now();
2521
- await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2522
- return step.followUps;
2523
- }
2524
- /** Answer a `question` item; the answer is folded into the Coder's next pass. */
2525
- async answerFollowUp(workspaceId, executionId, itemId, answer) {
2526
- const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2527
- if (item.kind !== 'question') {
2528
- throw new ConflictError('Only question items can be answered');
2529
- }
2530
- item.status = 'answered';
2531
- item.answer = answer;
2532
- item.sentToCoder = false;
2533
- item.updatedAt = this.clock.now();
2534
- await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2535
- return step.followUps;
2536
- }
2537
- /** Dismiss a follow-up / question item without acting on it. */
2538
- async dismissFollowUp(workspaceId, executionId, itemId) {
2539
- const { instance, step, index, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
2540
- item.status = 'dismissed';
2541
- item.updatedAt = this.clock.now();
2542
- await this.driveFollowUpsAfterDecision(workspaceId, instance, step, index);
2543
- return step.followUps;
2544
- }
2545
- /**
2546
- * Persist an item decision and, when the run is PARKED on this step's follow-up gate and
2547
- * every item is now decided, drive it forward: loop the Coder for the queued / answered
2548
- * items (within the budget), else advance past the gate. When the run is not parked (the
2549
- * Coder is still running, or it already moved on) this only persists + emits the change.
2550
- */
2551
- async driveFollowUpsAfterDecision(workspaceId, instance, step, index) {
2552
- const parkedHere = instance.status === 'blocked' &&
2553
- step.approval?.status === 'pending' &&
2554
- instance.currentStep === index;
2555
- if (!parkedHere || hasPendingFollowUps(step.followUps)) {
2556
- // Still collecting decisions (or the run isn't parked on this gate): just record it.
2557
- await this.executionRepository.upsert(workspaceId, instance);
2558
- await this.runStateMachine.emitInstance(workspaceId, instance);
2559
- return;
2560
- }
2561
- // Every item is decided and the run is parked here: clear the waiting card and either
2562
- // loop the Coder for the send-back items or advance past the gate.
2563
- await this.runStateMachine.clearWaitingNotification(workspaceId, instance);
2564
- if (shouldLoopCoder(step.followUps)) {
2565
- const decisionId = step.approval.id;
2566
- this.loopCoderForFollowUps(instance, step);
2567
- await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'in_progress');
2568
- await this.executionRepository.upsert(workspaceId, instance);
2569
- await this.workRunner.signalDecision(workspaceId, instance.id, decisionId, 'approved');
2570
- await this.runStateMachine.emitInstance(workspaceId, instance);
2571
- return;
2572
- }
2573
- // The follow-up gate is settled and we won't loop. If this step ALSO carries a human
2574
- // approval gate, hand off to it now instead of advancing — the follow-up park reused
2575
- // `step.approval`, so advancing here would silently SKIP the approval. Keep the same
2576
- // parked decision id (the durable driver is already waiting on it), refresh the proposal
2577
- // to the step output, and re-raise the standard "waiting for input" card (we just cleared
2578
- // the follow-up one). The human then resolves it through the normal approve / request-
2579
- // changes path. The follow-up gate already ran BEFORE the approval gate in
2580
- // recordStepResult, so this preserves that exact ordering across the park.
2581
- const isFinalStep = index === instance.steps.length - 1;
2582
- if (step.requiresApproval && !isFinalStep && step.approval?.status === 'pending') {
2583
- step.approval = { ...step.approval, proposal: step.output ?? '' };
2584
- await this.executionRepository.upsert(workspaceId, instance);
2585
- await this.runStateMachine.ensureWaitingNotification(workspaceId, instance);
2586
- await this.runStateMachine.emitInstance(workspaceId, instance);
2587
- return;
2588
- }
2589
- await this.runStateMachine.advancePastResolvedGate(workspaceId, instance, index);
2590
- }
2591
- /** Provision inputs (`{{input.*}}`) derived from the block under deployment. */
2592
- deployInputs(block) {
2593
- const inputs = {
2594
- blockId: block.id,
2595
- title: block.title,
2596
- type: block.type,
2597
- description: block.description,
2598
- };
2599
- return inputs;
2600
- }
2601
- /**
2602
- * Typed git/PR/repo context for the deployer, derived from the block's PR ref. A
2603
- * PR-environment provider (e.g. an in-house adapter) needs the branch/repo to target
2604
- * the right environment; the same values are also flattened into `{{input.*}}` for
2605
- * the manifest path. `owner`/`repo` are parsed from the PR url when present.
2606
- */
2607
- deployContext(block) {
2608
- const context = { blockId: block.id };
2609
- const pr = block.pullRequest;
2610
- if (!pr)
2611
- return context;
2612
- if (pr.branch)
2613
- context.branch = pr.branch;
2614
- if (pr.number !== undefined)
2615
- context.pullNumber = pr.number;
2616
- if (pr.url) {
2617
- context.pullUrl = pr.url;
2618
- const repo = parseRepoFromPullUrl(pr.url);
2619
- if (repo) {
2620
- context.repoOwner = repo.owner;
2621
- context.repoName = repo.repo;
2622
- }
2623
- }
2624
- return context;
2625
- }
2626
- /**
2627
- * Invoke the agent for an already-built context. Failures are swallowed into the
2628
- * step output so a run never wedges — unless `rethrowAgentErrors` is set (the
2629
- * durable path), in which case the error propagates so the driver's per-step
2630
- * retry can take over.
2631
- */
2632
- async runAgent(context, options = {}) {
2633
- try {
2634
- return await this.agentExecutor.run(context);
2635
- }
2636
- catch (error) {
2637
- // The durable driver wants real failures to surface so its per-step retry
2638
- // can kick in (and the error gets persisted after retries are exhausted).
2639
- if (options.rethrowAgentErrors)
2640
- throw error;
2641
- // Otherwise a failed agent must not wedge the run; record and complete.
2642
- return {
2643
- output: `Agent error: ${getErrorMessage(error)}`,
2644
- };
2645
- }
2646
- }
2647
- /**
2648
- * Strictly parse a Blueprinter step's tree and reconcile it onto the board. The
2649
- * blueprint maps the whole repository, so it is reconciled onto the run block's
2650
- * **service frame** (walked up from the block), not the task the run targeted.
2651
- * Best-effort and reconciler-gated: a parse/reconcile failure is logged-by-throw
2652
- * upstream only when the reconciler is wired; with no reconciler it is a no-op so
2653
- * the blueprint's in-repo files still land.
2654
- */
2655
- async ingestBlueprint(workspaceId, blockId, rawService) {
2656
- if (!this.blueprintReconciler)
2657
- return;
2658
- let service;
2659
- try {
2660
- service = parseBlueprintService(rawService);
2661
- }
2662
- catch {
2663
- // A malformed tree must not fail the step (the in-repo files are already
2664
- // committed); skip the board reconcile.
2665
- return;
2666
- }
2667
- const frameId = await this.contextBuilder.resolveServiceFrameId(workspaceId, blockId);
2668
- await this.blueprintReconciler.reconcileBlueprint(workspaceId, frameId, service);
2669
- // The reconcile may have created/updated module + task blocks that aren't
2670
- // individually pushed; nudge clients to refresh the board so they appear. Name the service
2671
- // frame so the refresh fans out to every board mounting this shared service.
2672
- await this.events.boardChanged(workspaceId, 'blueprint-reconciled', frameId);
2673
- }
2674
- /**
2675
- * Strictly validate a spec-writer step's unified specification. The canonical record
2676
- * is the in-repo `spec/` files the harness already committed; this is the trust
2677
- * boundary (a malformed payload is dropped, never trusted) plus a client refresh
2678
- * nudge. A persisted board projection is a deliberate later phase.
2679
- */
2680
- async ingestSpec(workspaceId, rawDoc) {
2681
- try {
2682
- parseSpecDoc(rawDoc);
2683
- }
2684
- catch {
2685
- // A malformed doc must not fail the step (the in-repo files are already
2686
- // committed); skip the refresh.
2687
- return;
2688
- }
2689
- // Nudge clients to refresh so they can re-read the service's spec files.
2690
- await this.events.boardChanged(workspaceId, 'requirements-updated');
2691
- }
2692
891
  // ---- iterative review gates (requirements + clarity) --------------------
2693
892
  // The two gate flows live in {@link ReviewGateController}, parameterised by a
2694
893
  // {@link ReviewKind}. The public methods below are thin delegators (the HTTP controllers
@@ -2974,7 +1173,7 @@ export class ExecutionService {
2974
1173
  // when the gate's provider is wired AND there is an async executor to escalate to. Reject up
2975
1174
  // front when neither holds, instead of silently parking a pendingFix the gate would discard
2976
1175
  // on its pass-through (an unwired gate advances) — the caller must see the failure, not a 200.
2977
- const gate = this.gateFor(step.agentKind);
1176
+ const gate = this.runDispatcher.gateFor(step.agentKind);
2978
1177
  if (!gate?.wired() || !isAsyncAgentExecutor(this.agentExecutor)) {
2979
1178
  throw new ConflictError('The human-review gate cannot dispatch a fix on this deployment (no review provider or async executor configured)');
2980
1179
  }