@cat-factory/orchestration 0.123.4 → 0.123.6
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/modules/execution/AgentContextBuilder.d.ts.map +1 -1
- package/dist/modules/execution/AgentContextBuilder.js +14 -13
- package/dist/modules/execution/AgentContextBuilder.js.map +1 -1
- package/dist/modules/execution/DeployerStepController.d.ts +185 -0
- package/dist/modules/execution/DeployerStepController.d.ts.map +1 -0
- package/dist/modules/execution/DeployerStepController.js +670 -0
- package/dist/modules/execution/DeployerStepController.js.map +1 -0
- package/dist/modules/execution/ExecutionService.d.ts +8 -211
- package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
- package/dist/modules/execution/ExecutionService.js +59 -805
- package/dist/modules/execution/ExecutionService.js.map +1 -1
- package/dist/modules/execution/FollowUpGateController.d.ts +104 -0
- package/dist/modules/execution/FollowUpGateController.d.ts.map +1 -0
- package/dist/modules/execution/FollowUpGateController.js +317 -0
- package/dist/modules/execution/FollowUpGateController.js.map +1 -0
- package/dist/modules/execution/RunAdmission.d.ts +204 -0
- package/dist/modules/execution/RunAdmission.d.ts.map +1 -0
- package/dist/modules/execution/RunAdmission.js +571 -0
- package/dist/modules/execution/RunAdmission.js.map +1 -0
- package/dist/modules/execution/RunDispatcher.d.ts +24 -196
- package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
- package/dist/modules/execution/RunDispatcher.js +70 -929
- package/dist/modules/execution/RunDispatcher.js.map +1 -1
- package/dist/modules/execution/review-kinds.d.ts +41 -0
- package/dist/modules/execution/review-kinds.d.ts.map +1 -0
- package/dist/modules/execution/review-kinds.js +241 -0
- package/dist/modules/execution/review-kinds.js.map +1 -0
- package/package.json +8 -8
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, FIXER_AGENT_KIND, getErrorMessage,
|
|
2
|
-
import {
|
|
3
|
-
import { blueprintPostOp, commitInitiativeTracker, FORK_PROPOSER_KIND, PR_REVIEWER_KIND, hasTrait, isCompanionKind, isContainerBackedCompanion, INTERVIEW_GATE_TRAIT,
|
|
1
|
+
import { ConflictError, DEFAULT_RISK_POLICY, failureKindFromHarnessCause, FIXER_AGENT_KIND, getErrorMessage, getProvider, INITIATIVE_ANALYST_AGENT_KIND, INITIATIVE_COMMITTER_AGENT_KIND, INITIATIVE_PLANNER_AGENT_KIND, isAsyncAgentExecutor, NotFoundError, parseLocalModelId, recordGateAttempt, registeredGateFactories, registeredStepResolverFactories, requireProvider, RunContendedError, sameSubtasks, } from '@cat-factory/kernel';
|
|
2
|
+
import { parseBlueprintService, parseSpecDoc, resolveAprioriWorkingBranch, } from '@cat-factory/contracts';
|
|
3
|
+
import { blueprintPostOp, commitInitiativeTracker, FORK_PROPOSER_KIND, PR_REVIEWER_KIND, hasTrait, isCompanionKind, isContainerBackedCompanion, INTERVIEW_GATE_TRAIT, runRepoOps, specPostOp, TASK_ESTIMATOR_AGENT_KIND, } from '@cat-factory/agents';
|
|
4
4
|
import { DEPLOYER_AGENT_KIND, isDeployStep } from '@cat-factory/integrations';
|
|
5
5
|
import { BUG_INTAKE_AGENT_KIND } from '../pipelines/pipelineShape.js';
|
|
6
6
|
import { coerceTaskEstimate, summarizeEstimate } from '../estimation/estimate.logic.js';
|
|
7
7
|
import { reviewableArtifactOutput } from './artifact-review.logic.js';
|
|
8
|
-
import { deployEvictionEpoch, deployJobId, orderProvisionTargets } from './deployer.logic.js';
|
|
9
8
|
import { renderInvestigationDigest } from './bugInvestigation.logic.js';
|
|
10
9
|
import { renderReproDigest } from './reproTest.logic.js';
|
|
11
|
-
import {
|
|
12
|
-
import { ANALYSIS_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BLUEPRINTS_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, CONFLICTS_AGENT_KIND, HUMAN_TEST_AGENT_KIND, isTesterKind, MERGER_AGENT_KIND, REPRO_TEST_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND, SPEC_WRITER_AGENT_KIND, TESTER_AGENT_KIND, TRACKER_AGENT_KIND, UI_TESTER_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, } from './ci.logic.js';
|
|
13
|
-
import { followUpsToSendBack, hasPendingFollowUps, renderFollowUpRework, shouldLoopCoder, } from './followUp.logic.js';
|
|
10
|
+
import { ANALYSIS_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BLUEPRINTS_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, CONFLICTS_AGENT_KIND, HUMAN_TEST_AGENT_KIND, isTesterKind, MERGER_AGENT_KIND, REPRO_TEST_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, REQUIREMENTS_REVIEW_AGENT_KIND, SPEC_WRITER_AGENT_KIND, TRACKER_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, } from './ci.logic.js';
|
|
14
11
|
import { classifyDispatchFailure, MAX_EVICTION_RECOVERIES, MAX_TRANSIENT_EVICTION_RECOVERIES, } from './job.logic.js';
|
|
15
12
|
import { AgentContextBuilder } from './AgentContextBuilder.js';
|
|
13
|
+
import { DeployerStepController } from './DeployerStepController.js';
|
|
14
|
+
import { FollowUpGateController } from './FollowUpGateController.js';
|
|
16
15
|
import { CompanionController } from './CompanionController.js';
|
|
17
16
|
import { HumanTestController } from './HumanTestController.js';
|
|
18
17
|
import { MergeResolver } from './MergeResolver.js';
|
|
@@ -28,18 +27,6 @@ import { RalphController } from './RalphController.js';
|
|
|
28
27
|
import { isRalphKind } from './ralph.logic.js';
|
|
29
28
|
import { VisualConfirmationController } from './VisualConfirmationController.js';
|
|
30
29
|
import { FALLTHROUGH_STEP_HANDLER_ORDER, } from './step-handler-registry.js';
|
|
31
|
-
/**
|
|
32
|
-
* Step kinds whose run details surface the ephemeral-environment lifecycle: the
|
|
33
|
-
* `deployer` provisions it and the `tester`/`playwright` exercise it. Used to gate
|
|
34
|
-
* the per-poll env projection so the `getByBlock` read never hits the hot path for
|
|
35
|
-
* the many container steps that have no env to show (see attachEnvironmentProjection).
|
|
36
|
-
*/
|
|
37
|
-
const ENV_PROJECTION_KINDS = new Set([
|
|
38
|
-
'deployer',
|
|
39
|
-
TESTER_AGENT_KIND,
|
|
40
|
-
UI_TESTER_AGENT_KIND,
|
|
41
|
-
'playwright',
|
|
42
|
-
]);
|
|
43
30
|
/**
|
|
44
31
|
* The inline review/brainstorm gate kinds, all driven through the {@link ReviewGateController}
|
|
45
32
|
* by the dispatcher's `review-gate` StepHandler. Kept in sync with the handler's `switch`.
|
|
@@ -60,17 +47,6 @@ const REVIEW_GATE_AGENT_KINDS = new Set([
|
|
|
60
47
|
function runOpensPr(instance) {
|
|
61
48
|
return instance.steps.some((s) => s.agentKind === MERGER_AGENT_KIND);
|
|
62
49
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Parse `owner`/`repo` from a GitHub pull-request URL (`https://github.com/o/r/pull/42`).
|
|
65
|
-
* Returns undefined for any URL that doesn't carry both segments. Host-agnostic on
|
|
66
|
-
* purpose (GitHub Enterprise hosts work too); only the `/owner/repo/...` shape matters.
|
|
67
|
-
*/
|
|
68
|
-
function parseRepoFromPullUrl(url) {
|
|
69
|
-
const match = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\//.exec(url);
|
|
70
|
-
if (!match)
|
|
71
|
-
return undefined;
|
|
72
|
-
return { owner: match[1], repo: match[2] };
|
|
73
|
-
}
|
|
74
50
|
/**
|
|
75
51
|
* The PR number a `review` task targets: the explicit `prNumber` field wins, else parse it from
|
|
76
52
|
* the `prUrl` (`…/pull/42` on GitHub, `…/merge_requests/42` on GitLab). Undefined when neither
|
|
@@ -84,31 +60,6 @@ function reviewPrNumber(block) {
|
|
|
84
60
|
const match = url ? /\/(?:pull|merge_requests)\/(\d+)/.exec(url) : null;
|
|
85
61
|
return match ? Number(match[1]) : undefined;
|
|
86
62
|
}
|
|
87
|
-
/**
|
|
88
|
-
* The `peerEnvUrls` provision input for the frame about to be provisioned: a comma-joined set of
|
|
89
|
-
* `slug=url` pairs for every target frame whose env is ALREADY ready this run — so a later
|
|
90
|
-
* provider (own frame, provisioned last in provider-before-consumer order) can template a
|
|
91
|
-
* connected service's URL into its manifest via `{{input.peerEnvUrls}}`. Empty when no peer is
|
|
92
|
-
* ready yet. Documented limitation: a provider needing its consumer's URL (a cyclic env
|
|
93
|
-
* dependency) is out of scope — there is no reconfigure pass.
|
|
94
|
-
*/
|
|
95
|
-
function buildPeerEnvUrls(targets, done) {
|
|
96
|
-
const parts = [];
|
|
97
|
-
const seen = new Map();
|
|
98
|
-
for (const target of targets) {
|
|
99
|
-
const env = done[target.frameId];
|
|
100
|
-
if (env?.status !== 'ready' || !env.url)
|
|
101
|
-
continue;
|
|
102
|
-
// Two ready providers can slugify to the same name; suffix the collision with an ordinal so a
|
|
103
|
-
// second provider's URL isn't silently dropped (or its entry made ambiguous) in the joined set.
|
|
104
|
-
const base = moduleSlug(target.frame.title);
|
|
105
|
-
const count = seen.get(base) ?? 0;
|
|
106
|
-
seen.set(base, count + 1);
|
|
107
|
-
const slug = count === 0 ? base : `${base}-${count + 1}`;
|
|
108
|
-
parts.push(`${slug}=${env.url}`);
|
|
109
|
-
}
|
|
110
|
-
return parts.join(',');
|
|
111
|
-
}
|
|
112
63
|
/**
|
|
113
64
|
* The per-step dispatch + completion spine of the execution engine. It owns the four
|
|
114
65
|
* registries (step handlers, completion interceptors, post-completion / terminal resolvers,
|
|
@@ -165,6 +116,21 @@ export class RunDispatcher {
|
|
|
165
116
|
resolveProviderCapabilities;
|
|
166
117
|
resolveRiskPolicy;
|
|
167
118
|
modelIdIsMetered;
|
|
119
|
+
/**
|
|
120
|
+
* The deterministic `deployer` step family (the multi-frame provision fan-out, the async
|
|
121
|
+
* deploy-job poll, and the environment projection env-aware steps surface), extracted to
|
|
122
|
+
* {@link DeployerStepController}. The completion hub + the shared poll folds are injected
|
|
123
|
+
* back as callbacks so the agent and deployer paths share one implementation of each.
|
|
124
|
+
*/
|
|
125
|
+
deployer;
|
|
126
|
+
/**
|
|
127
|
+
* The Follow-up companion gate (the future-looking Coder's streamed items, the
|
|
128
|
+
* park-until-decided gate, and the human-action API), extracted to
|
|
129
|
+
* {@link FollowUpGateController}. The dispatcher folds streamed items on each poll and
|
|
130
|
+
* evaluates the gate at Coder completion through this; the public follow-up methods below
|
|
131
|
+
* are thin pass-throughs the execution service re-exports.
|
|
132
|
+
*/
|
|
133
|
+
followUpGate;
|
|
168
134
|
/** Lazily-built polling-gate registry, keyed by `agentKind`. See {@link gateFor}. */
|
|
169
135
|
gateRegistryCache;
|
|
170
136
|
/** Lazily-built post-completion resolver registry, keyed by `agentKind`. */
|
|
@@ -212,6 +178,28 @@ export class RunDispatcher {
|
|
|
212
178
|
this.resolveProviderCapabilities = deps.resolveProviderCapabilities;
|
|
213
179
|
this.resolveRiskPolicy = deps.resolveRiskPolicy;
|
|
214
180
|
this.modelIdIsMetered = deps.modelIdIsMetered;
|
|
181
|
+
this.deployer = new DeployerStepController({
|
|
182
|
+
blockRepository: deps.blockRepository,
|
|
183
|
+
contextBuilder: deps.contextBuilder,
|
|
184
|
+
runStateMachine: deps.runStateMachine,
|
|
185
|
+
environmentProvisioning: deps.environmentProvisioning,
|
|
186
|
+
recordStepResult: (ws, instance, step, isFinalStep, result) => this.recordStepResult(ws, instance, step, isFinalStep, result),
|
|
187
|
+
applyContainerRunning: (step, update) => this.applyContainerRunning(step, update),
|
|
188
|
+
applySubtaskProgress: (step, counts) => this.applySubtaskProgress(step, counts),
|
|
189
|
+
recoverContainerEviction: (ws, instance, step, error, evicted, onBeforeRedispatch) => this.recoverContainerEviction(ws, instance, step, error, evicted, onBeforeRedispatch),
|
|
190
|
+
});
|
|
191
|
+
this.followUpGate = new FollowUpGateController({
|
|
192
|
+
executionRepository: deps.executionRepository,
|
|
193
|
+
blockRepository: deps.blockRepository,
|
|
194
|
+
contextBuilder: deps.contextBuilder,
|
|
195
|
+
stepGraph: deps.stepGraph,
|
|
196
|
+
runStateMachine: deps.runStateMachine,
|
|
197
|
+
workRunner: deps.workRunner,
|
|
198
|
+
idGenerator: deps.idGenerator,
|
|
199
|
+
clock: deps.clock,
|
|
200
|
+
notificationService: deps.notificationService,
|
|
201
|
+
ticketTrackerProvider: deps.ticketTrackerProvider,
|
|
202
|
+
});
|
|
215
203
|
}
|
|
216
204
|
/**
|
|
217
205
|
* Run a durable-driver entry point, turning a lost optimistic-concurrency race into a
|
|
@@ -284,7 +272,7 @@ export class RunDispatcher {
|
|
|
284
272
|
step.container = { status: 'starting' };
|
|
285
273
|
// Surface the block's ephemeral environment (if any) alongside the cold-boot
|
|
286
274
|
// phase, so a run's details show the env spinning up next to the container.
|
|
287
|
-
await this.attachEnvironmentProjection(workspaceId, instance.blockId, step);
|
|
275
|
+
await this.deployer.attachEnvironmentProjection(workspaceId, instance.blockId, step);
|
|
288
276
|
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
289
277
|
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
290
278
|
let handle;
|
|
@@ -475,7 +463,7 @@ export class RunDispatcher {
|
|
|
475
463
|
// through the environment provisioning service — NOT the agent executor. Route it before
|
|
476
464
|
// the executor resolution below (the deployer never goes through the agent executor).
|
|
477
465
|
if (isDeployStep(step.agentKind) && this.environmentProvisioning) {
|
|
478
|
-
return this.pollDeployerJob(workspaceId, instance, step);
|
|
466
|
+
return this.deployer.pollDeployerJob(workspaceId, instance, step);
|
|
479
467
|
}
|
|
480
468
|
const executor = this.agentExecutor;
|
|
481
469
|
if (!isAsyncAgentExecutor(executor))
|
|
@@ -520,11 +508,11 @@ export class RunDispatcher {
|
|
|
520
508
|
changed = true;
|
|
521
509
|
// Append any forward-looking items the Coder streamed since the last poll so the
|
|
522
510
|
// Follow-up companion lights up + accrues items LIVE while the container still runs.
|
|
523
|
-
if (this.appendStreamedFollowUps(s, update.followUps))
|
|
511
|
+
if (this.followUpGate.appendStreamedFollowUps(s, update.followUps))
|
|
524
512
|
changed = true;
|
|
525
513
|
// Refresh the env projection so its status transitions (provisioning→ready→
|
|
526
514
|
// expired/torn_down) and any error stay live in the run details during the run.
|
|
527
|
-
if (await this.attachEnvironmentProjection(workspaceId, target.blockId, s))
|
|
515
|
+
if (await this.deployer.attachEnvironmentProjection(workspaceId, target.blockId, s))
|
|
528
516
|
changed = true;
|
|
529
517
|
return changed;
|
|
530
518
|
};
|
|
@@ -729,7 +717,7 @@ export class RunDispatcher {
|
|
|
729
717
|
// Capture any final burst of follow-up items the harness drained on the SAME poll that
|
|
730
718
|
// observed completion (the tailer is flushed before the job is marked done), so the
|
|
731
719
|
// completion gate below sees the last items — notably a question that must hold the run.
|
|
732
|
-
this.appendStreamedFollowUps(step, update.followUps);
|
|
720
|
+
this.followUpGate.appendStreamedFollowUps(step, update.followUps);
|
|
733
721
|
// Clear the handle before recording so a replay re-attaches to nothing.
|
|
734
722
|
step.jobId = undefined;
|
|
735
723
|
return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
|
|
@@ -761,7 +749,8 @@ export class RunDispatcher {
|
|
|
761
749
|
/**
|
|
762
750
|
* Apply an async step's live subtask counts to the step (and the derived 0..1 progress
|
|
763
751
|
* fraction), returning whether anything changed. Shared by {@link pollAgentJob} (the agent
|
|
764
|
-
* executor's `update.subtasks`) and {@link
|
|
752
|
+
* executor's `update.subtasks`) and the {@link DeployerStepController} poll (the deploy job's
|
|
753
|
+
* `view.progress`)
|
|
765
754
|
* so the progress-fraction math lives in one place.
|
|
766
755
|
*/
|
|
767
756
|
applySubtaskProgress(step, counts) {
|
|
@@ -930,57 +919,6 @@ export class RunDispatcher {
|
|
|
930
919
|
output: `${gate.kind} gate passed: watch window elapsed with no regression observed.`,
|
|
931
920
|
});
|
|
932
921
|
}
|
|
933
|
-
/**
|
|
934
|
-
* Stamp `step.environment` from the block's live ephemeral environment so a run's
|
|
935
|
-
* details show its spinning-up / running / shut-down / errored state + the exact
|
|
936
|
-
* error. Best-effort: a no-op when the env integration isn't wired, and never
|
|
937
|
-
* throws (a projection failure must not break the run). Returns whether it changed,
|
|
938
|
-
* so the poll path can fold it into its single emit. The `human-test` gate keeps
|
|
939
|
-
* its own `humanTest.environment`, so this is for the other env-consuming steps
|
|
940
|
-
* (tester/coder/deployer).
|
|
941
|
-
*/
|
|
942
|
-
async attachEnvironmentProjection(workspaceId, blockId, step, frameId) {
|
|
943
|
-
if (!this.environmentProvisioning)
|
|
944
|
-
return false;
|
|
945
|
-
// Only the env-aware kinds run against an ephemeral environment (the `deployer`
|
|
946
|
-
// provisions it; the `tester`/`playwright` exercise it). Gating here keeps the
|
|
947
|
-
// per-poll `getByBlock` read off the hot path for the many container steps
|
|
948
|
-
// (coder/merger/ci-fixer/…) that never have an env to surface.
|
|
949
|
-
if (!ENV_PROJECTION_KINDS.has(step.agentKind))
|
|
950
|
-
return false;
|
|
951
|
-
try {
|
|
952
|
-
// Project the SPECIFIED service frame's env when given (the in-flight / failed frame of a
|
|
953
|
-
// multi-env deploy); otherwise the task's OWN frame (a task provisions several envs under
|
|
954
|
-
// one block, so an un-keyed newest-wins read could surface a peer's). Absent frame ⇒ own.
|
|
955
|
-
const resolvedFrameId = frameId ??
|
|
956
|
-
(await this.contextBuilder.resolveServiceFrameId(workspaceId, blockId)) ??
|
|
957
|
-
undefined;
|
|
958
|
-
const handle = await this.environmentProvisioning.getHandleForBlock(workspaceId, blockId, resolvedFrameId);
|
|
959
|
-
const next = handle
|
|
960
|
-
? {
|
|
961
|
-
id: handle.id,
|
|
962
|
-
url: handle.url,
|
|
963
|
-
status: handle.status,
|
|
964
|
-
expiresAt: handle.expiresAt,
|
|
965
|
-
lastError: handle.lastError,
|
|
966
|
-
provisionType: handle.provisionType ?? null,
|
|
967
|
-
engine: handle.engine ?? null,
|
|
968
|
-
}
|
|
969
|
-
: null;
|
|
970
|
-
const prev = step.environment ?? null;
|
|
971
|
-
if (prev?.id === next?.id &&
|
|
972
|
-
prev?.status === next?.status &&
|
|
973
|
-
prev?.url === next?.url &&
|
|
974
|
-
(prev?.lastError ?? null) === (next?.lastError ?? null)) {
|
|
975
|
-
return false;
|
|
976
|
-
}
|
|
977
|
-
step.environment = next;
|
|
978
|
-
return true;
|
|
979
|
-
}
|
|
980
|
-
catch {
|
|
981
|
-
return false;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
922
|
/**
|
|
985
923
|
* Finish a gated step that was skipped (its estimate gate was not satisfied) and either
|
|
986
924
|
* complete the run or advance to the next step — the deterministic finish/advance tail
|
|
@@ -1149,7 +1087,7 @@ export class RunDispatcher {
|
|
|
1149
1087
|
// (within the loop budget) before the following steps may start. Runs BEFORE the approval
|
|
1150
1088
|
// gate so the Coder's follow-ups settle first. A no-op when nothing was surfaced.
|
|
1151
1089
|
if (step.followUps?.enabled) {
|
|
1152
|
-
const gated = await this.evaluateFollowUpGate(workspaceId, instance, step);
|
|
1090
|
+
const gated = await this.followUpGate.evaluateFollowUpGate(workspaceId, instance, step);
|
|
1153
1091
|
if (gated)
|
|
1154
1092
|
return gated;
|
|
1155
1093
|
}
|
|
@@ -1244,506 +1182,6 @@ export class RunDispatcher {
|
|
|
1244
1182
|
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1245
1183
|
return { kind: 'continue' };
|
|
1246
1184
|
}
|
|
1247
|
-
/**
|
|
1248
|
-
* Deterministically provision an ephemeral environment for a `deployer` step and turn the
|
|
1249
|
-
* outcome into the step's advance result (no LLM, no token usage). On success the env
|
|
1250
|
-
* summary is recorded as the step output. On a provisioning failure — the provider threw
|
|
1251
|
-
* OR returned `status:'failed'` — the breakage is surfaced as a real, DISPLAYED step
|
|
1252
|
-
* failure rather than a green step with the error buried in its prose output: `step.environment`
|
|
1253
|
-
* is stamped with the errored env (its `lastError` renders in the step's Environment panel)
|
|
1254
|
-
* and a structured `environment` failure is returned (the board's failure card). A deployer
|
|
1255
|
-
* that can't provision IS failed — the downstream tester/coder steps need that environment.
|
|
1256
|
-
*
|
|
1257
|
-
* The failure is TERMINAL and surfaced for a human/`Retry`, NOT auto-retried by the durable
|
|
1258
|
-
* driver — DELIBERATELY, and symmetric with {@link handleAgentStep}'s dispatch-failure path
|
|
1259
|
-
* (a container that never started is likewise terminal regardless of `rethrowAgentErrors`).
|
|
1260
|
-
* Environment provisioning is infra spin-up, not agent execution: treating it like the
|
|
1261
|
-
* `dispatch` failure (surface the verbatim cause + one-click retry) keeps the `environment`
|
|
1262
|
-
* classification and the provider's real error visible, where rethrowing for the driver's
|
|
1263
|
-
* per-step retry would re-collapse it into a generic `agent` failure on exhaustion and bury
|
|
1264
|
-
* the root cause. So do NOT reintroduce a `rethrowAgentErrors` branch here.
|
|
1265
|
-
*/
|
|
1266
|
-
async runDeployerStep(workspaceId, instance, step, block, isFinalStep) {
|
|
1267
|
-
// A set `jobId` means a prior (possibly replayed) dispatch already started an async deploy
|
|
1268
|
-
// job for the IN-FLIGHT frame — re-attach by polling instead of re-provisioning (mirrors
|
|
1269
|
-
// {@link handleAgentStep}). Short-circuit BEFORE resolving targets so a parked re-attach
|
|
1270
|
-
// skips the workspace block-list read.
|
|
1271
|
-
if (step.jobId) {
|
|
1272
|
-
return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
|
|
1273
|
-
}
|
|
1274
|
-
// Fan out over every service frame this run provisions an env for — the task's OWN frame plus
|
|
1275
|
-
// each still-valid involved-service frame (the connections initiative), ordered provider-
|
|
1276
|
-
// before-consumer. Resolve the target set ONCE here (one workspace block-list read); the
|
|
1277
|
-
// synchronous/infraless recursion threads it rather than re-reading per frame.
|
|
1278
|
-
const targets = await this.resolveDeployTargets(workspaceId, block, step.deployPrimaryFrameId);
|
|
1279
|
-
// Pin the primary (own) frame once, so every later re-entry/replay classifies it identically
|
|
1280
|
-
// regardless of a mid-flight reparent (see {@link resolveDeployTargets}). Persisted with the
|
|
1281
|
-
// first synchronous-settle / async-park upsert below.
|
|
1282
|
-
step.deployPrimaryFrameId ??= targets.find((t) => t.isPrimary)?.frameId;
|
|
1283
|
-
return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
|
|
1284
|
-
}
|
|
1285
|
-
/**
|
|
1286
|
-
* Advance a `deployer` fan-out over its already-resolved `targets`: dispatch the first un-settled
|
|
1287
|
-
* frame (parking on an async deploy job) or, once every frame has settled, complete the step. One
|
|
1288
|
-
* deploy job per frame, dispatched SEQUENTIALLY (parking between) so a later provider can receive
|
|
1289
|
-
* the already-ready peers' URLs. `step.deployEnvs` records each frame's TERMINAL outcome, so a
|
|
1290
|
-
* replay resumes at the first un-settled frame. Re-entered (with the SAME targets) after each
|
|
1291
|
-
* synchronous/infraless/failed-peer frame settles — never re-reading the block list per frame.
|
|
1292
|
-
*/
|
|
1293
|
-
async advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets) {
|
|
1294
|
-
const done = step.deployEnvs ?? {};
|
|
1295
|
-
const next = targets.find((t) => !done[t.frameId]);
|
|
1296
|
-
if (!next) {
|
|
1297
|
-
// Every frame settled: finish the step (all ready → done; a primary failure short-circuited).
|
|
1298
|
-
return this.completeDeployerStep(workspaceId, instance, step, isFinalStep, targets);
|
|
1299
|
-
}
|
|
1300
|
-
// The deployer is the SINGLE environment provisioner: it stands the frame's env up whenever
|
|
1301
|
-
// there is genuinely one to stand up, so every downstream consumer (tester / human-test /
|
|
1302
|
-
// playwright) can depend on a pre-provisioned env rather than standing infra up itself:
|
|
1303
|
-
// - a DECLARED `kubernetes`/`custom` type (resolved through its per-type handler), OR
|
|
1304
|
-
// - a DECLARED `docker-compose` type on a workspace with a compose handler configured (the
|
|
1305
|
-
// setup wizard saves one) — the per-PR compose stack is provisioned HERE (attaching shared
|
|
1306
|
-
// stacks / running preflights), and the tester then targets that provisioned env (see
|
|
1307
|
-
// `testerInfraSpec`). A compose chain that reaches a tester with no resolvable handler is now
|
|
1308
|
-
// refused at run start (`assertTesterInfraConfigured`), so this stays the sole compose path, OR
|
|
1309
|
-
// - an UNDECLARED frame on a workspace with a legacy single-connection registered (the compat
|
|
1310
|
-
// bridge — preserved so existing single-connection deployments keep provisioning).
|
|
1311
|
-
// Every other frame stands nothing up HERE — `infraless`/none, an undeclared frame with NO
|
|
1312
|
-
// connection, or a frontend frame — so the deployer records `{status:'skipped'}` and re-enters
|
|
1313
|
-
// for the next frame. This makes the deployer a safe NO-OP prefix that can be injected before
|
|
1314
|
-
// every tester/human-test step without failing services that never configured provisioning.
|
|
1315
|
-
// A `library` frame (not `deployable`) is never deployed — a declared compose path is repo-local
|
|
1316
|
-
// TEST infra, not an environment — so it stands nothing up here regardless of its provisioning.
|
|
1317
|
-
// Gating every env branch on `deployable` forces the skip record below (mirroring `infraless`).
|
|
1318
|
-
const deployable = frameProfile(next.frame.type).deployable;
|
|
1319
|
-
const provisionType = next.provisioning?.type;
|
|
1320
|
-
const declaresEnv = deployable && (provisionType === 'kubernetes' || provisionType === 'custom');
|
|
1321
|
-
const composeEnv = deployable &&
|
|
1322
|
-
provisionType === 'docker-compose' &&
|
|
1323
|
-
next.provisioning !== undefined &&
|
|
1324
|
-
// Thread the run initiator so a local per-user handler OVERRIDE resolves exactly as it does at
|
|
1325
|
-
// provision time (and in the start-time gate) — else an override-only compose setup that
|
|
1326
|
-
// passed `assertDeployerConfigured` would silently no-op here (the very dead-end the gate closes).
|
|
1327
|
-
((await this.environmentProvisioning?.canProvision(workspaceId, next.provisioning, instance.initiatedBy))?.ok ??
|
|
1328
|
-
false);
|
|
1329
|
-
const legacyEnv = deployable &&
|
|
1330
|
-
provisionType === undefined &&
|
|
1331
|
-
(await this.environmentProvisioning?.hasLegacyConnection(workspaceId));
|
|
1332
|
-
if (!declaresEnv && !composeEnv && !legacyEnv) {
|
|
1333
|
-
await this.environmentProvisioning?.supersedeForBlock(workspaceId, block.id, next.frameId);
|
|
1334
|
-
step.deployEnvs = { ...done, [next.frameId]: { status: 'skipped' } };
|
|
1335
|
-
// Persist this frame's TERMINAL outcome BEFORE processing the next frame, so a crash/replay
|
|
1336
|
-
// mid-fan-out resumes at the first un-settled frame rather than re-doing an already-settled
|
|
1337
|
-
// one (which, on the synchronous REST path, would re-hit the provider — no idempotency guard
|
|
1338
|
-
// there, unlike the deterministic async job ref).
|
|
1339
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1340
|
-
return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
|
|
1341
|
-
}
|
|
1342
|
-
// Start provisioning the next frame: a raw-manifest config provisions SYNCHRONOUSLY over REST
|
|
1343
|
-
// (a final handle); a config that needs rendering dispatches a CONTAINER-backed deploy job we
|
|
1344
|
-
// park on and poll. The job ref is DETERMINISTIC (run id + deployer kind + FRAME + eviction
|
|
1345
|
-
// epoch), so a Workflows replay reproduces the same id and the transport re-attaches instead
|
|
1346
|
-
// of double-dispatching. The frame discriminator keeps each fanned-out job distinct.
|
|
1347
|
-
const ref = {
|
|
1348
|
-
runId: instance.id,
|
|
1349
|
-
jobId: deployJobId(instance.id, deployEvictionEpoch(step), next.frameId),
|
|
1350
|
-
};
|
|
1351
|
-
const peerEnvUrls = buildPeerEnvUrls(targets, done);
|
|
1352
|
-
let dispatch;
|
|
1353
|
-
try {
|
|
1354
|
-
dispatch = await this.environmentProvisioning.startProvision(await this.deployerProvisionArgs(workspaceId, instance, block, next, peerEnvUrls), ref);
|
|
1355
|
-
}
|
|
1356
|
-
catch (error) {
|
|
1357
|
-
return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, next, null, getErrorMessage(error),
|
|
1358
|
-
// Propagate the provider's machine-readable cause (e.g. `deploy_runner_unwired`) so the
|
|
1359
|
-
// SPA can render precise, runtime-specific guidance rather than string-matching the prose.
|
|
1360
|
-
getErrorReason(error));
|
|
1361
|
-
}
|
|
1362
|
-
if (dispatch.kind === 'completed') {
|
|
1363
|
-
// Synchronous provision: record this frame's outcome, then continue to the next frame.
|
|
1364
|
-
return this.settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, next, dispatch.handle);
|
|
1365
|
-
}
|
|
1366
|
-
// An async deploy job was dispatched: park on this frame. `dispatch` blocked until the job was
|
|
1367
|
-
// accepted, so the container is up; the live phase + the provisioned outcome arrive on the
|
|
1368
|
-
// deployer poll branch. Surface the frame's env spinning up alongside the parked step.
|
|
1369
|
-
step.jobId = dispatch.ref.jobId;
|
|
1370
|
-
step.deployFrameId = next.frameId;
|
|
1371
|
-
step.container = { status: 'up' };
|
|
1372
|
-
// Pin the provisioning config the container was built from, so the later poll/finalize maps
|
|
1373
|
-
// the job against THIS config rather than a fresh read of the frame (which a person may edit
|
|
1374
|
-
// mid-flight). Absent for the undeclared legacy path, which re-resolution handles harmlessly.
|
|
1375
|
-
step.deployProvisioning = next.provisioning;
|
|
1376
|
-
await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, next.frameId);
|
|
1377
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1378
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1379
|
-
return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
|
|
1380
|
-
}
|
|
1381
|
-
/**
|
|
1382
|
-
* Resolve the ordered set of service frames a `deployer` step provisions environments for: the
|
|
1383
|
-
* task's OWN service frame (always, `isPrimary`) plus each involved-service frame (read-time
|
|
1384
|
-
* stale-filtered to ids that are still a connection neighbour AND resolve to a `service` frame
|
|
1385
|
-
* WITH declared provisioning — an involved frame with none stands nothing up here). Ordered
|
|
1386
|
-
* PROVIDER-before-CONSUMER over the connection edges among the targets (see
|
|
1387
|
-
* {@link orderProvisionTargets}) so a later provision can receive its ready peers' URLs. One
|
|
1388
|
-
* workspace block-list read; no per-frame point read.
|
|
1389
|
-
*
|
|
1390
|
-
* `pinnedPrimaryFrameId` (from {@link PipelineStep.deployPrimaryFrameId}, set on the first
|
|
1391
|
-
* resolution) keeps the OWN/primary frame STABLE across re-entries: once the fan-out has started,
|
|
1392
|
-
* a mid-flight reparent must not re-classify which frame is primary — that would flip an
|
|
1393
|
-
* own-frame failure from terminal to a non-terminal peer failure. Prefer the pinned frame when it
|
|
1394
|
-
* still resolves; fall back to a fresh `frameOf` walk otherwise.
|
|
1395
|
-
*/
|
|
1396
|
-
async resolveDeployTargets(workspaceId, block, pinnedPrimaryFrameId) {
|
|
1397
|
-
const blocks = await this.blockRepository.listByWorkspace(workspaceId);
|
|
1398
|
-
const byId = new Map(blocks.map((b) => [b.id, b]));
|
|
1399
|
-
const ownFrame = (pinnedPrimaryFrameId ? byId.get(pinnedPrimaryFrameId) : undefined) ??
|
|
1400
|
-
frameOf(byId, block.id) ??
|
|
1401
|
-
block;
|
|
1402
|
-
const targets = [
|
|
1403
|
-
{
|
|
1404
|
-
frameId: ownFrame.id,
|
|
1405
|
-
isPrimary: true,
|
|
1406
|
-
provisioning: ownFrame.provisioning,
|
|
1407
|
-
frame: ownFrame,
|
|
1408
|
-
},
|
|
1409
|
-
];
|
|
1410
|
-
// The connected involved-service frames, read-time stale-filtered by the shared helper (kept in
|
|
1411
|
-
// sync with `AgentContextBuilder.resolveInvolvedServices`). Include each regardless of declared
|
|
1412
|
-
// provisioning — like the OWN frame, an undeclared service falls through to the legacy
|
|
1413
|
-
// single-connection compat bridge, and an `infraless` one is skipped by the dispatch loop. Only
|
|
1414
|
-
// the dispatch decides what actually stands up.
|
|
1415
|
-
for (const frame of validInvolvedServiceFrames(blocks, block, ownFrame.id)) {
|
|
1416
|
-
if (targets.some((t) => t.frameId === frame.id))
|
|
1417
|
-
continue;
|
|
1418
|
-
targets.push({
|
|
1419
|
-
frameId: frame.id,
|
|
1420
|
-
isPrimary: false,
|
|
1421
|
-
provisioning: frame.provisioning,
|
|
1422
|
-
frame,
|
|
1423
|
-
});
|
|
1424
|
-
}
|
|
1425
|
-
const targetIds = new Set(targets.map((t) => t.frameId));
|
|
1426
|
-
const providersOf = new Map();
|
|
1427
|
-
for (const target of targets) {
|
|
1428
|
-
const providers = new Set();
|
|
1429
|
-
for (const connection of target.frame.serviceConnections ?? []) {
|
|
1430
|
-
if (connection.serviceBlockId !== target.frameId &&
|
|
1431
|
-
targetIds.has(connection.serviceBlockId)) {
|
|
1432
|
-
providers.add(connection.serviceBlockId);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
providersOf.set(target.frameId, providers);
|
|
1436
|
-
}
|
|
1437
|
-
const order = orderProvisionTargets(targets.map((t) => ({ frameId: t.frameId, isPrimary: t.isPrimary })), providersOf);
|
|
1438
|
-
const byFrame = new Map(targets.map((t) => [t.frameId, t]));
|
|
1439
|
-
return order.map((id) => byFrame.get(id));
|
|
1440
|
-
}
|
|
1441
|
-
/**
|
|
1442
|
-
* Record one frame's TERMINAL deploy outcome onto `step.deployEnvs`, then continue the fan-out.
|
|
1443
|
-
* A `ready` handle records the env and re-enters {@link advanceDeployerFrames} for the next
|
|
1444
|
-
* frame; a `failed` handle routes to {@link settleDeployerFailure} (terminal only for the own
|
|
1445
|
-
* frame). Shared by the synchronous-provision and async-finalized paths.
|
|
1446
|
-
*/
|
|
1447
|
-
async settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, target, handle) {
|
|
1448
|
-
if (handle.status === 'failed') {
|
|
1449
|
-
return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, handle.url, handle.lastError ?? 'Provisioning failed.');
|
|
1450
|
-
}
|
|
1451
|
-
if (handle.status !== 'ready' && !target.isPrimary) {
|
|
1452
|
-
// A PEER env that isn't `ready` (`provisioning`, `expired`, `tearing_down`, …) is not usable
|
|
1453
|
-
// context: `deployEnvs` can only record `ready`/`failed`/`skipped`, and recording it `ready`
|
|
1454
|
-
// would BOTH advertise it "Provisioned involved-service environment …" AND inject its not-live
|
|
1455
|
-
// URL into a consumer's `peerEnvUrls`. Drop it as a non-terminal peer failure instead. (The
|
|
1456
|
-
// OWN frame keeps the historical behaviour — its env is the deploy's product; its live status
|
|
1457
|
-
// is surfaced via the Environment projection, and the run proceeds as before.)
|
|
1458
|
-
return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, handle.url, `Environment not ready (status: ${handle.status}).`);
|
|
1459
|
-
}
|
|
1460
|
-
const done = step.deployEnvs ?? {};
|
|
1461
|
-
step.deployEnvs = { ...done, [target.frameId]: { status: 'ready', url: handle.url } };
|
|
1462
|
-
// Persist this frame's TERMINAL outcome BEFORE provisioning the next frame (see the infraless
|
|
1463
|
-
// branch) so a crash/replay resumes at the first un-settled frame, not re-provisioning this one.
|
|
1464
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1465
|
-
return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
|
|
1466
|
-
}
|
|
1467
|
-
/**
|
|
1468
|
-
* Record a frame's FAILED deploy outcome and decide whether it is terminal. The task's OWN
|
|
1469
|
-
* (primary) service frame failing fails the whole deploy step (unchanged from the single-env
|
|
1470
|
-
* path). An involved PEER frame failing is NON-terminal — the peer's env is best-effort context
|
|
1471
|
-
* enrichment, so the run proceeds to the remaining frames without that peer's URL rather than
|
|
1472
|
-
* failing a task because a service it merely "involves" has a misconfigured provider. The failed
|
|
1473
|
-
* outcome is still recorded (surfaced in {@link completeDeployerStep}).
|
|
1474
|
-
*/
|
|
1475
|
-
async settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, url, error,
|
|
1476
|
-
/** Machine-readable cause (e.g. `deploy_runner_unwired`) carried to the failure record. */
|
|
1477
|
-
reason) {
|
|
1478
|
-
const done = step.deployEnvs ?? {};
|
|
1479
|
-
step.deployEnvs = { ...done, [target.frameId]: { status: 'failed', url: url ?? null, error } };
|
|
1480
|
-
if (target.isPrimary) {
|
|
1481
|
-
return this.failDeployerStep(workspaceId, instance, step, target.frameId, error, reason);
|
|
1482
|
-
}
|
|
1483
|
-
// A PEER failure is non-terminal — persist it BEFORE moving to the next frame so a replay
|
|
1484
|
-
// doesn't re-attempt this failed peer (same rationale as the ready/infraless settle paths).
|
|
1485
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1486
|
-
return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Poll a `deployer` step's dispatched CONTAINER-backed deploy job (the async kustomize/helm
|
|
1490
|
-
* path) through the environment provisioning service — NOT the agent executor. Mirrors
|
|
1491
|
-
* {@link pollAgentJob}: surfaces live container/subtask progress while running, recovers a
|
|
1492
|
-
* container eviction by re-dispatching a fresh deploy job (within the same budgets), and on a
|
|
1493
|
-
* genuine terminal state finalizes the job into an environment record + the step result.
|
|
1494
|
-
*/
|
|
1495
|
-
async pollDeployerJob(workspaceId, instance, step) {
|
|
1496
|
-
const ref = { runId: instance.id, jobId: step.jobId };
|
|
1497
|
-
// The service frame this in-flight deploy job is provisioning (a multi-env fan-out dispatches
|
|
1498
|
-
// one job per frame). Falls back to the own frame for a single-frame deploy that predates the
|
|
1499
|
-
// discriminator / never fanned out.
|
|
1500
|
-
const inFlightFrameId = step.deployFrameId ?? undefined;
|
|
1501
|
-
// Let a status-read failure THROW to the driver, exactly as `pollAgentJob` lets
|
|
1502
|
-
// `executor.pollJob` throw: the driver counts consecutive read failures and fast-fails the
|
|
1503
|
-
// run as `timeout` once `jobPollFailureTolerance` is hit. Swallowing it here would hide every
|
|
1504
|
-
// read failure from that counter, so an unreachable deploy container would only stop at the
|
|
1505
|
-
// full `jobMaxPolls` budget with a misleading "did not finish" message.
|
|
1506
|
-
const view = await this.environmentProvisioning.pollProvisionJob(workspaceId, ref);
|
|
1507
|
-
if (view.state === 'running') {
|
|
1508
|
-
let changed = false;
|
|
1509
|
-
if (this.applyContainerRunning(step, view))
|
|
1510
|
-
changed = true;
|
|
1511
|
-
if (this.applySubtaskProgress(step, view.progress))
|
|
1512
|
-
changed = true;
|
|
1513
|
-
if (await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, inFlightFrameId)) {
|
|
1514
|
-
changed = true;
|
|
1515
|
-
}
|
|
1516
|
-
if (changed) {
|
|
1517
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1518
|
-
// Progress-only deploy-job fold: skip the LLM-metrics rollup (same reason as the
|
|
1519
|
-
// agent running fold above — a deploy job makes no LLM calls anyway).
|
|
1520
|
-
await this.runStateMachine.emitInstance(workspaceId, instance, { rollUpMetrics: false });
|
|
1521
|
-
}
|
|
1522
|
-
return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
|
|
1523
|
-
}
|
|
1524
|
-
// The deploy container vanished (evicted/crashed). The shared recovery re-dispatches a fresh
|
|
1525
|
-
// deploy job (the driver loops back into `runDeployerStep`, which re-provisions the same
|
|
1526
|
-
// un-settled frame since `step.jobId` is cleared) within the same per-flavour budgets as the
|
|
1527
|
-
// agent path, reclaiming the dead job's runner first. Null for a non-eviction failure.
|
|
1528
|
-
if (view.state === 'failed') {
|
|
1529
|
-
const recovered = await this.recoverContainerEviction(workspaceId, instance, step, view.error, view.evicted, () => this.environmentProvisioning.releaseProvisionJob(workspaceId, ref).catch(() => { }));
|
|
1530
|
-
if (recovered)
|
|
1531
|
-
return recovered;
|
|
1532
|
-
}
|
|
1533
|
-
// Genuine terminal (done, or a non-eviction failure): finalize the deploy job into an
|
|
1534
|
-
// environment record and record this frame's outcome. A `failed` view maps to a failed env,
|
|
1535
|
-
// which `settleDeployerFrame` surfaces as a displayed step failure.
|
|
1536
|
-
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
1537
|
-
if (!block)
|
|
1538
|
-
return { kind: 'noop' };
|
|
1539
|
-
const isFinalStep = instance.currentStep === instance.steps.length - 1;
|
|
1540
|
-
// Resolve the full target set once (also drives the remaining-frames fan-out after this one
|
|
1541
|
-
// settles), honouring the pinned primary frame. Derive the own/primary frame id from that set
|
|
1542
|
-
// rather than a SECOND `resolveServiceFrameId` point-read walk — the primary target's frame id
|
|
1543
|
-
// is the own frame (pinned, so a mid-flight reparent can't flip an own failure to a peer one).
|
|
1544
|
-
const targets = await this.resolveDeployTargets(workspaceId, block, step.deployPrimaryFrameId);
|
|
1545
|
-
const ownFrameId = step.deployPrimaryFrameId ?? targets.find((t) => t.isPrimary)?.frameId ?? block.id;
|
|
1546
|
-
const frameId = inFlightFrameId ?? ownFrameId;
|
|
1547
|
-
// Recover the in-flight frame's real service-frame block from the target set so finalize
|
|
1548
|
-
// provisions with the FRAME's identity/inputs (a peer's env must not reuse the task block's —
|
|
1549
|
-
// see {@link deployerProvisionArgs}); fall back to a point-read (then the task block) if a
|
|
1550
|
-
// connection was removed mid-flight so the frame is no longer a target.
|
|
1551
|
-
const known = targets.find((t) => t.frameId === frameId);
|
|
1552
|
-
const frame = known?.frame ?? (await this.blockRepository.get(workspaceId, frameId)) ?? block;
|
|
1553
|
-
// Map the job against the provisioning config the container was BUILT from (pinned at
|
|
1554
|
-
// dispatch), not a fresh read of the frame a person may have edited mid-flight — else a
|
|
1555
|
-
// config flip (e.g. → `infraless`) would fail a deploy whose container already succeeded. The
|
|
1556
|
-
// pinned config is the in-flight frame's; the fallback resolution is only ever hit for the
|
|
1557
|
-
// undeclared-own compat path (which resolves the own frame correctly).
|
|
1558
|
-
const provisioning = step.deployProvisioning ?? (await this.resolveServiceProvisioning(workspaceId, block));
|
|
1559
|
-
const target = { frameId, isPrimary: frameId === ownFrameId, provisioning, frame };
|
|
1560
|
-
step.jobId = undefined;
|
|
1561
|
-
step.deployFrameId = undefined;
|
|
1562
|
-
step.subtasks = undefined;
|
|
1563
|
-
// The one-shot deploy container reached a terminal state: reclaim its runner now rather than
|
|
1564
|
-
// letting it idle out its sleepAfter window (billed-but-useless compute) / leak a self-hosted
|
|
1565
|
-
// pool slot. The deploy job is dispatched SEPARATELY from the shared per-run container, so the
|
|
1566
|
-
// agent path's `stopRunContainer` (final step only, run-id keyed) never reclaims it.
|
|
1567
|
-
// Best-effort/idempotent.
|
|
1568
|
-
await this.environmentProvisioning.releaseProvisionJob(workspaceId, ref).catch(() => { });
|
|
1569
|
-
let handle;
|
|
1570
|
-
try {
|
|
1571
|
-
handle = await this.environmentProvisioning.finalizeProvision(await this.deployerProvisionArgs(workspaceId, instance, block, target, ''), view);
|
|
1572
|
-
}
|
|
1573
|
-
catch (error) {
|
|
1574
|
-
// The deploy container is gone (released above) but finalize failed: stamp the container
|
|
1575
|
-
// errored so the failed details don't keep showing it "up". A primary failure is terminal; a
|
|
1576
|
-
// peer's is not (the fan-out proceeds), so route through `settleDeployerFailure`.
|
|
1577
|
-
if (step.container)
|
|
1578
|
-
step.container = { ...step.container, status: 'errored' };
|
|
1579
|
-
step.deployProvisioning = undefined;
|
|
1580
|
-
return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, null, getErrorMessage(error));
|
|
1581
|
-
}
|
|
1582
|
-
step.deployProvisioning = undefined;
|
|
1583
|
-
// Reflect the container's terminal state from the RESOLVED outcome, not the raw view: a `done`
|
|
1584
|
-
// view the provider maps to a FAILED env (e.g. the harness exited 0 but the namespace is
|
|
1585
|
-
// missing) must still show the container errored — keying off `view.state` alone missed that.
|
|
1586
|
-
if (handle.status === 'failed' && step.container) {
|
|
1587
|
-
step.container = { ...step.container, status: 'errored' };
|
|
1588
|
-
}
|
|
1589
|
-
return this.settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, target, handle);
|
|
1590
|
-
}
|
|
1591
|
-
/**
|
|
1592
|
-
* The {@link ProvisionArgs} for provisioning ONE target frame's environment (synchronous or
|
|
1593
|
-
* async). The env is keyed by the task `block.id` + the target `frameId` — so a task's own env
|
|
1594
|
-
* and each involved-service env coexist under the same block, discriminated by frame (see the
|
|
1595
|
-
* per-`(blockId, frameId)` supersede). The repo/clone the provider resolves is the TARGET
|
|
1596
|
-
* FRAME's (via `frameId`), so an involved-service env clones that peer's repo at its default
|
|
1597
|
-
* branch, while the OWN frame targets the task's PR branch (its git/PR context); a peer carries
|
|
1598
|
-
* no PR context. The `{{input.*}}` identity (blockId/title/…) is the TARGET FRAME's for a peer
|
|
1599
|
-
* (see {@link deployTargetInputs}) so each peer's provider namespace is distinct — the task-
|
|
1600
|
-
* scoped inputs would collapse every peer onto one namespace. Injects `frontendOrigins` (the
|
|
1601
|
-
* browser origins binding this service) and `peerEnvUrls` (the already-ready peers) too.
|
|
1602
|
-
*/
|
|
1603
|
-
async deployerProvisionArgs(workspaceId, instance, block, target, peerEnvUrls) {
|
|
1604
|
-
const frontendOrigins = await this.frontendOriginsInput(workspaceId, target.frameId);
|
|
1605
|
-
// The OWN frame deploys the task's PR branch (its git/PR context); an involved peer carries no
|
|
1606
|
-
// PR context, so its clone target falls back to that repo's default branch.
|
|
1607
|
-
const context = target.isPrimary ? this.deployContext(block) : { blockId: block.id };
|
|
1608
|
-
return {
|
|
1609
|
-
workspaceId,
|
|
1610
|
-
blockId: block.id,
|
|
1611
|
-
frameId: target.frameId,
|
|
1612
|
-
executionId: instance.id,
|
|
1613
|
-
inputs: {
|
|
1614
|
-
...this.deployTargetInputs(block, target),
|
|
1615
|
-
...(frontendOrigins ? { frontendOrigins } : {}),
|
|
1616
|
-
...(peerEnvUrls ? { peerEnvUrls } : {}),
|
|
1617
|
-
},
|
|
1618
|
-
context,
|
|
1619
|
-
...(target.provisioning ? { serviceProvisioning: target.provisioning } : {}),
|
|
1620
|
-
initiatedBy: instance.initiatedBy,
|
|
1621
|
-
};
|
|
1622
|
-
}
|
|
1623
|
-
/**
|
|
1624
|
-
* The `{{input.*}}` identity a target frame provisions with. The OWN frame keeps the historical
|
|
1625
|
-
* task-scoped inputs (its namespace is uniquified by the task's PR repo/number). An involved PEER
|
|
1626
|
-
* frame is scoped to the PEER FRAME's identity, with a `(task, peer)` composite `blockId` — so
|
|
1627
|
-
* the provider namespace derived from `{{input.blockId}}` is distinct per peer AND per task,
|
|
1628
|
-
* where the task-scoped inputs would collapse every peer of a task onto ONE namespace (each
|
|
1629
|
-
* clobbering the previous, teardown deleting the wrong one).
|
|
1630
|
-
*/
|
|
1631
|
-
deployTargetInputs(block, target) {
|
|
1632
|
-
if (target.isPrimary)
|
|
1633
|
-
return this.deployInputs(block);
|
|
1634
|
-
return {
|
|
1635
|
-
blockId: `${block.id}-${target.frameId}`,
|
|
1636
|
-
title: target.frame.title,
|
|
1637
|
-
type: target.frame.type,
|
|
1638
|
-
description: target.frame.description,
|
|
1639
|
-
};
|
|
1640
|
-
}
|
|
1641
|
-
/**
|
|
1642
|
-
* The `frontendOrigins` provision input for a service frame: the comma-joined browser origins
|
|
1643
|
-
* of every `frontend` frame that binds this service (see `frontendOriginsForService`), for a
|
|
1644
|
-
* manifest to fold into the backend's CORS allow-list via `{{input.frontendOrigins}}`. Empty
|
|
1645
|
-
* string when no frontend binds it (the key is then omitted). One workspace block-list read —
|
|
1646
|
-
* no per-frame point read (mirrors the visual-pipeline gate).
|
|
1647
|
-
*/
|
|
1648
|
-
async frontendOriginsInput(workspaceId, serviceFrameId) {
|
|
1649
|
-
const blocks = await this.blockRepository.listByWorkspace(workspaceId);
|
|
1650
|
-
return frontendOriginsForService(serviceFrameId, blocks).join(',');
|
|
1651
|
-
}
|
|
1652
|
-
/**
|
|
1653
|
-
* Turn a provisioned environment handle into the `deployer` step's advance result: a `failed`
|
|
1654
|
-
* env is surfaced as a displayed step failure (its `lastError` renders in the Environment
|
|
1655
|
-
* panel); otherwise the env summary (status / URL / provision type / engine) is recorded as the
|
|
1656
|
-
* step output. Shared by the synchronous and async-finalized provision paths.
|
|
1657
|
-
*/
|
|
1658
|
-
async completeDeployerStep(workspaceId, instance, step, isFinalStep, targets) {
|
|
1659
|
-
const byFrame = new Map(targets.map((t) => [t.frameId, t]));
|
|
1660
|
-
const primaryFrameId = step.deployPrimaryFrameId ?? targets.find((t) => t.isPrimary)?.frameId;
|
|
1661
|
-
// Re-project the now-final OWN environment (ready/expired + URL) so the deployer step's
|
|
1662
|
-
// Environment panel + the downstream tester see the task's own service env, not a peer's or the
|
|
1663
|
-
// dispatch-time `provisioning` snapshot the async poll last wrote. Pass the pinned primary frame
|
|
1664
|
-
// so it needn't re-walk the tree to find the own frame.
|
|
1665
|
-
await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, primaryFrameId);
|
|
1666
|
-
// Summarise from the recorded per-frame OUTCOMES (`deployEnvs`), NOT the current `targets`: a
|
|
1667
|
-
// mid-flight involved-services / connection edit can drop a frame from `targets` while its env
|
|
1668
|
-
// is still recorded and live, so iterating outcomes keeps that env visible (never silently
|
|
1669
|
-
// orphaned). Titles come from the target set when the frame is still resolvable, else the id.
|
|
1670
|
-
const done = step.deployEnvs ?? {};
|
|
1671
|
-
const titleOf = (frameId) => byFrame.get(frameId)?.frame.title ?? frameId;
|
|
1672
|
-
const isPrimaryFrame = (frameId) => byFrame.get(frameId)?.isPrimary ?? frameId === primaryFrameId;
|
|
1673
|
-
const readyEntries = Object.entries(done).filter(([, env]) => env.status === 'ready');
|
|
1674
|
-
if (readyEntries.length === 0) {
|
|
1675
|
-
// Every target was `infraless`/library/skipped — nothing stood up (the single-service
|
|
1676
|
-
// infraless case plus the all-infraless fan-out). A `library` frame reports its own reason
|
|
1677
|
-
// (it is never deployed) so the run timeline stays explainable, per the frame profile.
|
|
1678
|
-
const primaryFrame = primaryFrameId ? byFrame.get(primaryFrameId)?.frame : undefined;
|
|
1679
|
-
const output = primaryFrame && !frameProfile(primaryFrame.type).deployable
|
|
1680
|
-
? 'Library frame; no deployment or environment provisioned.'
|
|
1681
|
-
: 'Service is infraless; no environment provisioned.';
|
|
1682
|
-
return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
|
|
1683
|
-
output,
|
|
1684
|
-
model: 'environment:none',
|
|
1685
|
-
});
|
|
1686
|
-
}
|
|
1687
|
-
const own = step.environment;
|
|
1688
|
-
const lines = [];
|
|
1689
|
-
for (const [frameId, env] of readyEntries) {
|
|
1690
|
-
const url = env.url ?? '(pending)';
|
|
1691
|
-
lines.push(isPrimaryFrame(frameId)
|
|
1692
|
-
? `Provisioned ephemeral environment for '${titleOf(frameId)}': ${url}`
|
|
1693
|
-
: `Provisioned involved-service environment for '${titleOf(frameId)}': ${url}`);
|
|
1694
|
-
}
|
|
1695
|
-
// A PEER frame that failed is non-terminal (the own deploy proceeded); note it so the failure
|
|
1696
|
-
// is visible rather than silently absent from the fan-out summary. (A primary failure never
|
|
1697
|
-
// reaches here — it fails the step in `settleDeployerFailure`.)
|
|
1698
|
-
for (const [frameId, env] of Object.entries(done)) {
|
|
1699
|
-
if (env.status !== 'failed' || isPrimaryFrame(frameId))
|
|
1700
|
-
continue;
|
|
1701
|
-
lines.push(`Involved-service environment for '${titleOf(frameId)}' failed: ${env.error ?? 'unknown error'}`);
|
|
1702
|
-
}
|
|
1703
|
-
if (own?.expiresAt)
|
|
1704
|
-
lines.push(`Expires: ${new Date(own.expiresAt).toISOString()}`);
|
|
1705
|
-
if (own?.provisionType)
|
|
1706
|
-
lines.push(`Provision type: ${own.provisionType}`);
|
|
1707
|
-
if (own?.engine)
|
|
1708
|
-
lines.push(`Engine: ${own.engine}`);
|
|
1709
|
-
return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
|
|
1710
|
-
output: lines.join('\n'),
|
|
1711
|
-
model: `environment:${readyEntries.length > 1 ? 'multi' : (own?.engine ?? 'single')}`,
|
|
1712
|
-
});
|
|
1713
|
-
}
|
|
1714
|
-
/**
|
|
1715
|
-
* Resolve the SERVICE frame's declared provisioning for a run block. The run may target a
|
|
1716
|
-
* task/module nested under the frame, so walk up to the frame (mirrors the blueprint /
|
|
1717
|
-
* tester-gate resolution) and read its `provisioning`. Returns null when undeclared.
|
|
1718
|
-
*/
|
|
1719
|
-
async resolveServiceProvisioning(workspaceId, block) {
|
|
1720
|
-
const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, block.id)) ?? block.id;
|
|
1721
|
-
const frame = frameId === block.id ? block : await this.blockRepository.get(workspaceId, frameId);
|
|
1722
|
-
return frame?.provisioning;
|
|
1723
|
-
}
|
|
1724
|
-
/**
|
|
1725
|
-
* Stamp the errored environment onto the deployer step (so its details show the verbatim
|
|
1726
|
-
* `lastError`), persist + emit, then return a structured `environment` failure carrying the
|
|
1727
|
-
* provider's message as the detail. Mirrors {@link handleAgentStep}'s dispatch-failure path.
|
|
1728
|
-
*/
|
|
1729
|
-
async failDeployerStep(workspaceId, instance, step, frameId, message,
|
|
1730
|
-
/** Machine-readable cause (e.g. `deploy_runner_unwired`) surfaced on the failure so the SPA
|
|
1731
|
-
* renders precise guidance without string-matching the prose. */
|
|
1732
|
-
reason) {
|
|
1733
|
-
// Project the FAILED frame's env (so its `lastError` renders in the Environment panel) — for a
|
|
1734
|
-
// single-frame deploy that is the own env; for a failed involved-service env it surfaces the
|
|
1735
|
-
// peer's error rather than a sibling's healthy env.
|
|
1736
|
-
await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, frameId);
|
|
1737
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1738
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1739
|
-
return {
|
|
1740
|
-
kind: 'job_failed',
|
|
1741
|
-
error: 'Environment provisioning failed.',
|
|
1742
|
-
failureKind: 'environment',
|
|
1743
|
-
detail: message,
|
|
1744
|
-
...(reason ? { reason } : {}),
|
|
1745
|
-
};
|
|
1746
|
-
}
|
|
1747
1185
|
/**
|
|
1748
1186
|
* File a tracking issue/ticket for a `tracker` step from the preceding `analysis`
|
|
1749
1187
|
* output. Non-LLM and best-effort: when no provider is wired or none is configured
|
|
@@ -2196,7 +1634,7 @@ export class RunDispatcher {
|
|
|
2196
1634
|
kind: DEPLOYER_AGENT_KIND,
|
|
2197
1635
|
order: 100,
|
|
2198
1636
|
canHandle: ({ step }) => !!this.environmentProvisioning && isDeployStep(step.agentKind),
|
|
2199
|
-
handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.runDeployerStep(workspaceId, instance, step, block, isFinalStep),
|
|
1637
|
+
handle: ({ workspaceId, instance, step, block, isFinalStep }) => this.deployer.runDeployerStep(workspaceId, instance, step, block, isFinalStep),
|
|
2200
1638
|
},
|
|
2201
1639
|
// A `tracker` step files a GitHub issue / Jira ticket from the preceding `analysis`
|
|
2202
1640
|
// output (the tech-debt pipeline) — no LLM of its own. It is a pass-through when no
|
|
@@ -2817,128 +2255,6 @@ export class RunDispatcher {
|
|
|
2817
2255
|
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
2818
2256
|
return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
|
|
2819
2257
|
}
|
|
2820
|
-
// ---- Follow-up companion (future-looking Coder) -------------------------
|
|
2821
|
-
// The Coder streams forward-looking items (loose ends / side-tasks / questions) which
|
|
2822
|
-
// accrue on its `step.followUps` live (see pollAgentJob). At the Coder's completion the
|
|
2823
|
-
// run parks while any item is undecided, then loops the Coder for the items the human
|
|
2824
|
-
// queued / answered (within the loop budget) before the following steps may start.
|
|
2825
|
-
/**
|
|
2826
|
-
* Append the items the harness streamed since the last poll onto the Coder step's
|
|
2827
|
-
* follow-up state as fresh `pending` items. A no-op when the companion is off or nothing
|
|
2828
|
-
* was streamed. Returns whether anything was added (so the poller persists + emits).
|
|
2829
|
-
*/
|
|
2830
|
-
appendStreamedFollowUps(step, streamed) {
|
|
2831
|
-
if (!step.followUps?.enabled || !streamed || streamed.length === 0)
|
|
2832
|
-
return false;
|
|
2833
|
-
const now = this.clock.now();
|
|
2834
|
-
for (const s of streamed) {
|
|
2835
|
-
const title = (s.title ?? '').trim();
|
|
2836
|
-
if (!title)
|
|
2837
|
-
continue;
|
|
2838
|
-
step.followUps.items.push({
|
|
2839
|
-
id: this.idGenerator.next('fu'),
|
|
2840
|
-
kind: s.kind === 'question' ? 'question' : 'follow_up',
|
|
2841
|
-
title,
|
|
2842
|
-
detail: s.detail ?? '',
|
|
2843
|
-
...(s.suggestedAction ? { suggestedAction: s.suggestedAction } : {}),
|
|
2844
|
-
status: 'pending',
|
|
2845
|
-
createdAt: now,
|
|
2846
|
-
updatedAt: now,
|
|
2847
|
-
});
|
|
2848
|
-
}
|
|
2849
|
-
return true;
|
|
2850
|
-
}
|
|
2851
|
-
/**
|
|
2852
|
-
* The Follow-up companion gate, evaluated when the Coder step completes: park the run on
|
|
2853
|
-
* a durable decision while any item is undecided; else loop the Coder for the queued /
|
|
2854
|
-
* answered items (within the budget); else fall through (return undefined) so the normal
|
|
2855
|
-
* advance/finish logic runs. Returns an {@link AdvanceResult} only when it parks or loops.
|
|
2856
|
-
*/
|
|
2857
|
-
async evaluateFollowUpGate(workspaceId, instance, step) {
|
|
2858
|
-
const state = step.followUps;
|
|
2859
|
-
if (!state?.enabled)
|
|
2860
|
-
return undefined;
|
|
2861
|
-
if (hasPendingFollowUps(state)) {
|
|
2862
|
-
await this.raiseFollowUpPending(workspaceId, instance, state);
|
|
2863
|
-
return this.runStateMachine.parkStepOnDecision(workspaceId, instance, step);
|
|
2864
|
-
}
|
|
2865
|
-
if (shouldLoopCoder(state)) {
|
|
2866
|
-
this.loopCoderForFollowUps(instance, step);
|
|
2867
|
-
await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'in_progress');
|
|
2868
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
2869
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
2870
|
-
return { kind: 'continue' };
|
|
2871
|
-
}
|
|
2872
|
-
return undefined;
|
|
2873
|
-
}
|
|
2874
|
-
/**
|
|
2875
|
-
* Reset the Coder step and fold the human's queued follow-ups / answered questions into
|
|
2876
|
-
* its rework so the next pass extends the prior work. Marks those items `sentToCoder` so
|
|
2877
|
-
* a later completion doesn't re-loop them, and counts the loop against the budget. Shared
|
|
2878
|
-
* by the at-completion path ({@link evaluateFollowUpGate}) and the parked-resume path.
|
|
2879
|
-
*/
|
|
2880
|
-
loopCoderForFollowUps(instance, step) {
|
|
2881
|
-
const state = step.followUps;
|
|
2882
|
-
const sending = followUpsToSendBack(state);
|
|
2883
|
-
const feedback = renderFollowUpRework(sending);
|
|
2884
|
-
for (const item of sending) {
|
|
2885
|
-
item.sentToCoder = true;
|
|
2886
|
-
item.updatedAt = this.clock.now();
|
|
2887
|
-
}
|
|
2888
|
-
state.loops = (state.loops ?? 0) + 1;
|
|
2889
|
-
// Reset the step for a fresh dispatch; `step.followUps` is intentionally preserved
|
|
2890
|
-
// (resetStepForRerun doesn't touch it) so the surfaced items survive the loop.
|
|
2891
|
-
this.stepGraph.resetStepForRerun(step);
|
|
2892
|
-
step.rework = { previousProposal: '', feedback };
|
|
2893
|
-
this.stepGraph.startStep(step);
|
|
2894
|
-
if (instance.status === 'blocked')
|
|
2895
|
-
instance.status = 'running';
|
|
2896
|
-
}
|
|
2897
|
-
/** Raise the "follow-ups need decisions" inbox card when the Coder parks on undecided items. */
|
|
2898
|
-
async raiseFollowUpPending(workspaceId, instance, state) {
|
|
2899
|
-
if (!this.notificationService)
|
|
2900
|
-
return;
|
|
2901
|
-
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
2902
|
-
if (!block)
|
|
2903
|
-
return;
|
|
2904
|
-
const pending = state.items.filter((i) => i.status === 'pending').length;
|
|
2905
|
-
await this.notificationService.raise(workspaceId, {
|
|
2906
|
-
type: 'followup_pending',
|
|
2907
|
-
blockId: block.id,
|
|
2908
|
-
executionId: instance.id,
|
|
2909
|
-
title: `"${block.title}" surfaced ${pending} follow-up${pending === 1 ? '' : 's'} to decide`,
|
|
2910
|
-
body: 'The Coder flagged forward-looking follow-ups / questions. Open the task to file ' +
|
|
2911
|
-
'each as an issue, send it back to the Coder, answer it, or dismiss it — the ' +
|
|
2912
|
-
'pipeline continues once every item is decided.',
|
|
2913
|
-
payload: { pipelineName: instance.pipelineName, findingCount: pending },
|
|
2914
|
-
});
|
|
2915
|
-
}
|
|
2916
|
-
/**
|
|
2917
|
-
* The run's "active" follow-up companion step for a read with no item context (the GET /
|
|
2918
|
-
* the inbox-card open). A pipeline may carry MORE THAN ONE follow-up-enabled Coder step,
|
|
2919
|
-
* so this must not blindly pick the first: prefer the step the run is currently on (a Coder
|
|
2920
|
-
* parked on its follow-up gate), else the latest enabled step that has surfaced items, else
|
|
2921
|
-
* the first enabled one.
|
|
2922
|
-
*/
|
|
2923
|
-
activeFollowUpStep(instance) {
|
|
2924
|
-
const current = instance.steps[instance.currentStep];
|
|
2925
|
-
if (current?.followUps?.enabled)
|
|
2926
|
-
return { step: current, index: instance.currentStep };
|
|
2927
|
-
for (let i = instance.steps.length - 1; i >= 0; i--) {
|
|
2928
|
-
const s = instance.steps[i];
|
|
2929
|
-
if (s.followUps?.enabled && s.followUps.items.length > 0)
|
|
2930
|
-
return { step: s, index: i };
|
|
2931
|
-
}
|
|
2932
|
-
const index = instance.steps.findIndex((s) => s.followUps?.enabled);
|
|
2933
|
-
return index >= 0 ? { step: instance.steps[index], index } : undefined;
|
|
2934
|
-
}
|
|
2935
|
-
/** Read a run's live follow-up companion state (the active Coder step's items), or null. */
|
|
2936
|
-
async getFollowUps(workspaceId, executionId) {
|
|
2937
|
-
const instance = await this.executionRepository.get(workspaceId, executionId);
|
|
2938
|
-
if (!instance)
|
|
2939
|
-
throw new NotFoundError('Execution', executionId);
|
|
2940
|
-
return this.activeFollowUpStep(instance)?.step.followUps ?? null;
|
|
2941
|
-
}
|
|
2942
2258
|
// ---- Implementation-fork decision phase (Phase A dispatch) --------------
|
|
2943
2259
|
// The proposer explore job runs as a HELPER off the coder step; its completion is handled
|
|
2944
2260
|
// by the `fork-proposal` interceptor + {@link ForkDecisionController.recordProposal}, and the
|
|
@@ -3126,203 +2442,28 @@ export class RunDispatcher {
|
|
|
3126
2442
|
resolvePrReview(workspaceId, executionId, input) {
|
|
3127
2443
|
return this.prReviewController.resolve(workspaceId, executionId, input);
|
|
3128
2444
|
}
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
async loadFollowUpItem(workspaceId, executionId, itemId) {
|
|
3136
|
-
const instance = await this.executionRepository.get(workspaceId, executionId);
|
|
3137
|
-
if (!instance)
|
|
3138
|
-
throw new NotFoundError('Execution', executionId);
|
|
3139
|
-
const index = instance.steps.findIndex((s) => s.followUps?.enabled && s.followUps.items.some((i) => i.id === itemId));
|
|
3140
|
-
if (index < 0)
|
|
3141
|
-
throw new NotFoundError('Follow-up item', itemId);
|
|
3142
|
-
const step = instance.steps[index];
|
|
3143
|
-
const item = step.followUps.items.find((i) => i.id === itemId);
|
|
3144
|
-
return { instance, step, index, item };
|
|
2445
|
+
// ---- Follow-up companion pass-throughs ----------------------------------
|
|
2446
|
+
// The follow-up gate + its human-action API live on {@link FollowUpGateController}; these
|
|
2447
|
+
// thin delegations keep the dispatcher the single surface `ExecutionService` re-exports.
|
|
2448
|
+
/** @see FollowUpGateController.getFollowUps */
|
|
2449
|
+
getFollowUps(workspaceId, executionId) {
|
|
2450
|
+
return this.followUpGate.getFollowUps(workspaceId, executionId);
|
|
3145
2451
|
}
|
|
3146
|
-
/**
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
// creation; the item's ticket refs are then recorded under CAS in applyFollowUpDecision.
|
|
3150
|
-
const { instance, item } = await this.loadFollowUpItem(workspaceId, executionId, itemId);
|
|
3151
|
-
if (item.kind !== 'follow_up') {
|
|
3152
|
-
throw new ConflictError('Only follow-up items can be filed as issues');
|
|
3153
|
-
}
|
|
3154
|
-
if (!this.ticketTrackerProvider) {
|
|
3155
|
-
throw new ConflictError('No issue tracker is configured for this workspace');
|
|
3156
|
-
}
|
|
3157
|
-
const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, instance.blockId)) ??
|
|
3158
|
-
instance.blockId;
|
|
3159
|
-
const body = [
|
|
3160
|
-
item.detail,
|
|
3161
|
-
item.suggestedAction ? `\n\nSuggested approach: ${item.suggestedAction}` : '',
|
|
3162
|
-
]
|
|
3163
|
-
.join('')
|
|
3164
|
-
.trim();
|
|
3165
|
-
const ticket = await this.ticketTrackerProvider.createTicket({
|
|
3166
|
-
workspaceId,
|
|
3167
|
-
frameId,
|
|
3168
|
-
title: item.title,
|
|
3169
|
-
body: body || item.title,
|
|
3170
|
-
});
|
|
3171
|
-
if (!ticket) {
|
|
3172
|
-
throw new ConflictError('No issue tracker is configured for this workspace');
|
|
3173
|
-
}
|
|
3174
|
-
return this.applyFollowUpDecision(workspaceId, executionId, itemId, (target) => {
|
|
3175
|
-
// Re-validated on the fresh snapshot inside the CAS (the ticket was already created above).
|
|
3176
|
-
if (target.kind !== 'follow_up') {
|
|
3177
|
-
throw new ConflictError('Only follow-up items can be filed as issues');
|
|
3178
|
-
}
|
|
3179
|
-
target.status = 'filed';
|
|
3180
|
-
target.ticketExternalId = ticket.externalId;
|
|
3181
|
-
target.ticketUrl = ticket.url;
|
|
3182
|
-
target.updatedAt = this.clock.now();
|
|
3183
|
-
});
|
|
2452
|
+
/** @see FollowUpGateController.fileFollowUp */
|
|
2453
|
+
fileFollowUp(workspaceId, executionId, itemId) {
|
|
2454
|
+
return this.followUpGate.fileFollowUp(workspaceId, executionId, itemId);
|
|
3184
2455
|
}
|
|
3185
|
-
/**
|
|
3186
|
-
|
|
3187
|
-
return this.
|
|
3188
|
-
if (item.kind !== 'follow_up') {
|
|
3189
|
-
throw new ConflictError('Only follow-up items can be sent back to the Coder');
|
|
3190
|
-
}
|
|
3191
|
-
item.status = 'queued';
|
|
3192
|
-
item.sentToCoder = false;
|
|
3193
|
-
item.updatedAt = this.clock.now();
|
|
3194
|
-
});
|
|
3195
|
-
}
|
|
3196
|
-
/** Answer a `question` item; the answer is folded into the Coder's next pass. */
|
|
3197
|
-
async answerFollowUp(workspaceId, executionId, itemId, answer) {
|
|
3198
|
-
return this.applyFollowUpDecision(workspaceId, executionId, itemId, (item) => {
|
|
3199
|
-
if (item.kind !== 'question') {
|
|
3200
|
-
throw new ConflictError('Only question items can be answered');
|
|
3201
|
-
}
|
|
3202
|
-
item.status = 'answered';
|
|
3203
|
-
item.answer = answer;
|
|
3204
|
-
item.sentToCoder = false;
|
|
3205
|
-
item.updatedAt = this.clock.now();
|
|
3206
|
-
});
|
|
2456
|
+
/** @see FollowUpGateController.queueFollowUp */
|
|
2457
|
+
queueFollowUp(workspaceId, executionId, itemId) {
|
|
2458
|
+
return this.followUpGate.queueFollowUp(workspaceId, executionId, itemId);
|
|
3207
2459
|
}
|
|
3208
|
-
/**
|
|
3209
|
-
|
|
3210
|
-
return this.
|
|
3211
|
-
item.status = 'dismissed';
|
|
3212
|
-
item.updatedAt = this.clock.now();
|
|
3213
|
-
});
|
|
2460
|
+
/** @see FollowUpGateController.answerFollowUp */
|
|
2461
|
+
answerFollowUp(workspaceId, executionId, itemId, answer) {
|
|
2462
|
+
return this.followUpGate.answerFollowUp(workspaceId, executionId, itemId, answer);
|
|
3214
2463
|
}
|
|
3215
|
-
/**
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
* items, hand off to a co-located approval gate, or advance past the gate — all under
|
|
3219
|
-
* OPTIMISTIC CONCURRENCY. A follow-up decision can race the driver's running-poll fold (which
|
|
3220
|
-
* appends newly-streamed items) or another decision on a sibling item, so it RE-READS +
|
|
3221
|
-
* RE-APPLIES on a lost CAS race instead of clobbering — the human-action dual of the driver's
|
|
3222
|
-
* abort-and-redrive (race-audit 2.2). The item mutation + the in-memory gate transition run
|
|
3223
|
-
* INSIDE the CAS callback (idempotent, re-runnable on reload); the non-idempotent side effects
|
|
3224
|
-
* (notifications, `signalDecision`, emit) run once AFTER, on the winning snapshot. `decide`
|
|
3225
|
-
* validates + mutates the item, throwing a `ConflictError`/`NotFoundError` that propagates
|
|
3226
|
-
* immediately (a domain error is not retried).
|
|
3227
|
-
*/
|
|
3228
|
-
async applyFollowUpDecision(workspaceId, executionId, itemId, decide) {
|
|
3229
|
-
// Captured inside the (re-runnable) callback for the last winning attempt; the
|
|
3230
|
-
// non-idempotent side effects below act on them.
|
|
3231
|
-
let outcome = 'record';
|
|
3232
|
-
let index = -1;
|
|
3233
|
-
let loopDecisionId;
|
|
3234
|
-
const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, (fresh) => {
|
|
3235
|
-
outcome = 'record';
|
|
3236
|
-
loopDecisionId = undefined;
|
|
3237
|
-
index = fresh.steps.findIndex((s) => s.followUps?.enabled && s.followUps.items.some((i) => i.id === itemId));
|
|
3238
|
-
if (index < 0)
|
|
3239
|
-
throw new NotFoundError('Follow-up item', itemId);
|
|
3240
|
-
const step = fresh.steps[index];
|
|
3241
|
-
decide(step.followUps.items.find((i) => i.id === itemId));
|
|
3242
|
-
const parkedHere = fresh.status === 'blocked' &&
|
|
3243
|
-
step.approval?.status === 'pending' &&
|
|
3244
|
-
fresh.currentStep === index;
|
|
3245
|
-
// Still collecting decisions (or the run isn't parked on this gate): only record it.
|
|
3246
|
-
if (!parkedHere || hasPendingFollowUps(step.followUps))
|
|
3247
|
-
return;
|
|
3248
|
-
// Every item decided and the run is parked here: loop the Coder for the send-back items,
|
|
3249
|
-
// hand off to a co-located approval gate, or advance past the gate.
|
|
3250
|
-
if (shouldLoopCoder(step.followUps)) {
|
|
3251
|
-
loopDecisionId = step.approval.id;
|
|
3252
|
-
this.loopCoderForFollowUps(fresh, step);
|
|
3253
|
-
outcome = 'loop';
|
|
3254
|
-
return;
|
|
3255
|
-
}
|
|
3256
|
-
const isFinalStep = index === fresh.steps.length - 1;
|
|
3257
|
-
if (step.requiresApproval && !isFinalStep && step.approval?.status === 'pending') {
|
|
3258
|
-
// The follow-up park reused `step.approval`; advancing here would silently SKIP the
|
|
3259
|
-
// approval. Refresh the proposal and hand off to the standard approval gate (the
|
|
3260
|
-
// follow-up card is cleared + the "waiting for input" card re-raised below), preserving
|
|
3261
|
-
// the follow-up-before-approval ordering recordStepResult established across the park.
|
|
3262
|
-
step.approval = { ...step.approval, proposal: step.output ?? '' };
|
|
3263
|
-
outcome = 'handoff';
|
|
3264
|
-
return;
|
|
3265
|
-
}
|
|
3266
|
-
this.runStateMachine.advanceRunPastGate(fresh, index);
|
|
3267
|
-
outcome = 'advance';
|
|
3268
|
-
});
|
|
3269
|
-
// Non-idempotent side effects on the winning snapshot (the CAS write is the source of truth,
|
|
3270
|
-
// so nothing re-persists here). The settled paths first clear the follow-up waiting card.
|
|
3271
|
-
if (outcome === 'record') {
|
|
3272
|
-
await this.runStateMachine.emitInstance(workspaceId, persisted);
|
|
3273
|
-
}
|
|
3274
|
-
else {
|
|
3275
|
-
await this.runStateMachine.clearWaitingNotification(workspaceId, persisted);
|
|
3276
|
-
if (outcome === 'loop') {
|
|
3277
|
-
await this.runStateMachine.updateBlockProgress(workspaceId, persisted, 'in_progress');
|
|
3278
|
-
await this.workRunner.signalDecision(workspaceId, persisted.id, loopDecisionId, 'approved');
|
|
3279
|
-
await this.runStateMachine.emitInstance(workspaceId, persisted);
|
|
3280
|
-
}
|
|
3281
|
-
else if (outcome === 'handoff') {
|
|
3282
|
-
await this.runStateMachine.ensureWaitingNotification(workspaceId, persisted);
|
|
3283
|
-
await this.runStateMachine.emitInstance(workspaceId, persisted);
|
|
3284
|
-
}
|
|
3285
|
-
else {
|
|
3286
|
-
// advance: block writes + `signalDecision('approved')` + emit — the approveStep template.
|
|
3287
|
-
await this.runStateMachine.settleAdvancedGate(workspaceId, persisted, index);
|
|
3288
|
-
}
|
|
3289
|
-
}
|
|
3290
|
-
return persisted.steps[index].followUps;
|
|
3291
|
-
}
|
|
3292
|
-
/** Provision inputs (`{{input.*}}`) derived from the block under deployment. */
|
|
3293
|
-
deployInputs(block) {
|
|
3294
|
-
const inputs = {
|
|
3295
|
-
blockId: block.id,
|
|
3296
|
-
title: block.title,
|
|
3297
|
-
type: block.type,
|
|
3298
|
-
description: block.description,
|
|
3299
|
-
};
|
|
3300
|
-
return inputs;
|
|
3301
|
-
}
|
|
3302
|
-
/**
|
|
3303
|
-
* Typed git/PR/repo context for the deployer, derived from the block's PR ref. A
|
|
3304
|
-
* PR-environment provider (e.g. an in-house adapter) needs the branch/repo to target
|
|
3305
|
-
* the right environment; the same values are also flattened into `{{input.*}}` for
|
|
3306
|
-
* the manifest path. `owner`/`repo` are parsed from the PR url when present.
|
|
3307
|
-
*/
|
|
3308
|
-
deployContext(block) {
|
|
3309
|
-
const context = { blockId: block.id };
|
|
3310
|
-
const pr = block.pullRequest;
|
|
3311
|
-
if (!pr)
|
|
3312
|
-
return context;
|
|
3313
|
-
if (pr.branch)
|
|
3314
|
-
context.branch = pr.branch;
|
|
3315
|
-
if (pr.number !== undefined)
|
|
3316
|
-
context.pullNumber = pr.number;
|
|
3317
|
-
if (pr.url) {
|
|
3318
|
-
context.pullUrl = pr.url;
|
|
3319
|
-
const repo = parseRepoFromPullUrl(pr.url);
|
|
3320
|
-
if (repo) {
|
|
3321
|
-
context.repoOwner = repo.owner;
|
|
3322
|
-
context.repoName = repo.repo;
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
return context;
|
|
2464
|
+
/** @see FollowUpGateController.dismissFollowUp */
|
|
2465
|
+
dismissFollowUp(workspaceId, executionId, itemId) {
|
|
2466
|
+
return this.followUpGate.dismissFollowUp(workspaceId, executionId, itemId);
|
|
3326
2467
|
}
|
|
3327
2468
|
/**
|
|
3328
2469
|
* Invoke the agent for an already-built context. Failures are swallowed into the
|