@cat-factory/orchestration 0.39.0 → 0.39.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/container.d.ts.map +1 -1
- package/dist/container.js +8 -0
- package/dist/container.js.map +1 -1
- package/dist/modules/execution/ExecutionService.d.ts +19 -373
- package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
- package/dist/modules/execution/ExecutionService.js +91 -1892
- package/dist/modules/execution/ExecutionService.js.map +1 -1
- package/dist/modules/execution/RunDispatcher.d.ts +479 -0
- package/dist/modules/execution/RunDispatcher.d.ts.map +1 -0
- package/dist/modules/execution/RunDispatcher.js +1963 -0
- package/dist/modules/execution/RunDispatcher.js.map +1 -0
- package/package.json +10 -10
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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,
|
|
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 {
|
|
11
|
-
import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND
|
|
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
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
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
|
}
|