@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.
Files changed (28) hide show
  1. package/dist/modules/execution/AgentContextBuilder.d.ts.map +1 -1
  2. package/dist/modules/execution/AgentContextBuilder.js +14 -13
  3. package/dist/modules/execution/AgentContextBuilder.js.map +1 -1
  4. package/dist/modules/execution/DeployerStepController.d.ts +185 -0
  5. package/dist/modules/execution/DeployerStepController.d.ts.map +1 -0
  6. package/dist/modules/execution/DeployerStepController.js +670 -0
  7. package/dist/modules/execution/DeployerStepController.js.map +1 -0
  8. package/dist/modules/execution/ExecutionService.d.ts +8 -211
  9. package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
  10. package/dist/modules/execution/ExecutionService.js +59 -805
  11. package/dist/modules/execution/ExecutionService.js.map +1 -1
  12. package/dist/modules/execution/FollowUpGateController.d.ts +104 -0
  13. package/dist/modules/execution/FollowUpGateController.d.ts.map +1 -0
  14. package/dist/modules/execution/FollowUpGateController.js +317 -0
  15. package/dist/modules/execution/FollowUpGateController.js.map +1 -0
  16. package/dist/modules/execution/RunAdmission.d.ts +204 -0
  17. package/dist/modules/execution/RunAdmission.d.ts.map +1 -0
  18. package/dist/modules/execution/RunAdmission.js +571 -0
  19. package/dist/modules/execution/RunAdmission.js.map +1 -0
  20. package/dist/modules/execution/RunDispatcher.d.ts +24 -196
  21. package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
  22. package/dist/modules/execution/RunDispatcher.js +70 -929
  23. package/dist/modules/execution/RunDispatcher.js.map +1 -1
  24. package/dist/modules/execution/review-kinds.d.ts +41 -0
  25. package/dist/modules/execution/review-kinds.d.ts.map +1 -0
  26. package/dist/modules/execution/review-kinds.js +241 -0
  27. package/dist/modules/execution/review-kinds.js.map +1 -0
  28. package/package.json +8 -8
@@ -0,0 +1,670 @@
1
+ import { getErrorMessage, getErrorReason } from '@cat-factory/kernel';
2
+ import { frameProfile, frontendOriginsForService } from '@cat-factory/contracts';
3
+ import { moduleSlug } from '@cat-factory/agents';
4
+ import { deployEvictionEpoch, deployJobId, orderProvisionTargets } from './deployer.logic.js';
5
+ import { frameOf, validInvolvedServiceFrames } from './frame.logic.js';
6
+ import { TESTER_AGENT_KIND, UI_TESTER_AGENT_KIND } from './ci.logic.js';
7
+ /**
8
+ * Step kinds whose run details surface the ephemeral-environment lifecycle: the
9
+ * `deployer` provisions it and the `tester`/`playwright` exercise it. Used to gate
10
+ * the per-poll env projection so the `getByBlock` read never hits the hot path for
11
+ * the many container steps that have no env to show (see attachEnvironmentProjection).
12
+ */
13
+ const ENV_PROJECTION_KINDS = new Set([
14
+ 'deployer',
15
+ TESTER_AGENT_KIND,
16
+ UI_TESTER_AGENT_KIND,
17
+ 'playwright',
18
+ ]);
19
+ /**
20
+ * The `peerEnvUrls` provision input for the frame about to be provisioned: a comma-joined set of
21
+ * `slug=url` pairs for every target frame whose env is ALREADY ready this run — so a later
22
+ * provider (own frame, provisioned last in provider-before-consumer order) can template a
23
+ * connected service's URL into its manifest via `{{input.peerEnvUrls}}`. Empty when no peer is
24
+ * ready yet. Documented limitation: a provider needing its consumer's URL (a cyclic env
25
+ * dependency) is out of scope — there is no reconfigure pass.
26
+ */
27
+ function buildPeerEnvUrls(targets, done) {
28
+ const parts = [];
29
+ const seen = new Map();
30
+ for (const target of targets) {
31
+ const env = done[target.frameId];
32
+ if (env?.status !== 'ready' || !env.url)
33
+ continue;
34
+ // Two ready providers can slugify to the same name; suffix the collision with an ordinal so a
35
+ // second provider's URL isn't silently dropped (or its entry made ambiguous) in the joined set.
36
+ const base = moduleSlug(target.frame.title);
37
+ const count = seen.get(base) ?? 0;
38
+ seen.set(base, count + 1);
39
+ const slug = count === 0 ? base : `${base}-${count + 1}`;
40
+ parts.push(`${slug}=${env.url}`);
41
+ }
42
+ return parts.join(',');
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 deterministic `deployer` step family, extracted out of {@link RunDispatcher}: the
57
+ * multi-frame provision fan-out (own frame + involved-service peers, provider-before-consumer),
58
+ * the async container-backed deploy-job poll, the per-frame settle/failure bookkeeping on
59
+ * `step.deployEnvs`, and the environment projection every env-aware step surfaces on its run
60
+ * details. No LLM and no token usage anywhere in this family — the deployer provisions
61
+ * environments through the {@link EnvironmentProvisioningService} provider only. Pure code
62
+ * movement from the dispatcher; no behaviour changes.
63
+ */
64
+ export class DeployerStepController {
65
+ blockRepository;
66
+ contextBuilder;
67
+ runStateMachine;
68
+ environmentProvisioning;
69
+ recordStepResult;
70
+ applyContainerRunning;
71
+ applySubtaskProgress;
72
+ recoverContainerEviction;
73
+ constructor(deps) {
74
+ this.blockRepository = deps.blockRepository;
75
+ this.contextBuilder = deps.contextBuilder;
76
+ this.runStateMachine = deps.runStateMachine;
77
+ this.environmentProvisioning = deps.environmentProvisioning;
78
+ this.recordStepResult = deps.recordStepResult;
79
+ this.applyContainerRunning = deps.applyContainerRunning;
80
+ this.applySubtaskProgress = deps.applySubtaskProgress;
81
+ this.recoverContainerEviction = deps.recoverContainerEviction;
82
+ }
83
+ /**
84
+ * Stamp `step.environment` from the block's live ephemeral environment so a run's
85
+ * details show its spinning-up / running / shut-down / errored state + the exact
86
+ * error. Best-effort: a no-op when the env integration isn't wired, and never
87
+ * throws (a projection failure must not break the run). Returns whether it changed,
88
+ * so the poll path can fold it into its single emit. The `human-test` gate keeps
89
+ * its own `humanTest.environment`, so this is for the other env-consuming steps
90
+ * (tester/coder/deployer).
91
+ */
92
+ async attachEnvironmentProjection(workspaceId, blockId, step, frameId) {
93
+ if (!this.environmentProvisioning)
94
+ return false;
95
+ // Only the env-aware kinds run against an ephemeral environment (the `deployer`
96
+ // provisions it; the `tester`/`playwright` exercise it). Gating here keeps the
97
+ // per-poll `getByBlock` read off the hot path for the many container steps
98
+ // (coder/merger/ci-fixer/…) that never have an env to surface.
99
+ if (!ENV_PROJECTION_KINDS.has(step.agentKind))
100
+ return false;
101
+ try {
102
+ // Project the SPECIFIED service frame's env when given (the in-flight / failed frame of a
103
+ // multi-env deploy); otherwise the task's OWN frame (a task provisions several envs under
104
+ // one block, so an un-keyed newest-wins read could surface a peer's). Absent frame ⇒ own.
105
+ const resolvedFrameId = frameId ??
106
+ (await this.contextBuilder.resolveServiceFrameId(workspaceId, blockId)) ??
107
+ undefined;
108
+ const handle = await this.environmentProvisioning.getHandleForBlock(workspaceId, blockId, resolvedFrameId);
109
+ const next = handle
110
+ ? {
111
+ id: handle.id,
112
+ url: handle.url,
113
+ status: handle.status,
114
+ expiresAt: handle.expiresAt,
115
+ lastError: handle.lastError,
116
+ provisionType: handle.provisionType ?? null,
117
+ engine: handle.engine ?? null,
118
+ }
119
+ : null;
120
+ const prev = step.environment ?? null;
121
+ if (prev?.id === next?.id &&
122
+ prev?.status === next?.status &&
123
+ prev?.url === next?.url &&
124
+ (prev?.lastError ?? null) === (next?.lastError ?? null)) {
125
+ return false;
126
+ }
127
+ step.environment = next;
128
+ return true;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ /**
135
+ * Deterministically provision an ephemeral environment for a `deployer` step and turn the
136
+ * outcome into the step's advance result (no LLM, no token usage). On success the env
137
+ * summary is recorded as the step output. On a provisioning failure — the provider threw
138
+ * OR returned `status:'failed'` — the breakage is surfaced as a real, DISPLAYED step
139
+ * failure rather than a green step with the error buried in its prose output: `step.environment`
140
+ * is stamped with the errored env (its `lastError` renders in the step's Environment panel)
141
+ * and a structured `environment` failure is returned (the board's failure card). A deployer
142
+ * that can't provision IS failed — the downstream tester/coder steps need that environment.
143
+ *
144
+ * The failure is TERMINAL and surfaced for a human/`Retry`, NOT auto-retried by the durable
145
+ * driver — DELIBERATELY, and symmetric with `handleAgentStep`'s dispatch-failure path
146
+ * (a container that never started is likewise terminal regardless of `rethrowAgentErrors`).
147
+ * Environment provisioning is infra spin-up, not agent execution: treating it like the
148
+ * `dispatch` failure (surface the verbatim cause + one-click retry) keeps the `environment`
149
+ * classification and the provider's real error visible, where rethrowing for the driver's
150
+ * per-step retry would re-collapse it into a generic `agent` failure on exhaustion and bury
151
+ * the root cause. So do NOT reintroduce a `rethrowAgentErrors` branch here.
152
+ */
153
+ async runDeployerStep(workspaceId, instance, step, block, isFinalStep) {
154
+ // A set `jobId` means a prior (possibly replayed) dispatch already started an async deploy
155
+ // job for the IN-FLIGHT frame — re-attach by polling instead of re-provisioning (mirrors
156
+ // `handleAgentStep`). Short-circuit BEFORE resolving targets so a parked re-attach
157
+ // skips the workspace block-list read.
158
+ if (step.jobId) {
159
+ return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
160
+ }
161
+ // Fan out over every service frame this run provisions an env for — the task's OWN frame plus
162
+ // each still-valid involved-service frame (the connections initiative), ordered provider-
163
+ // before-consumer. Resolve the target set ONCE here (one workspace block-list read); the
164
+ // synchronous/infraless recursion threads it rather than re-reading per frame.
165
+ const targets = await this.resolveDeployTargets(workspaceId, block, step.deployPrimaryFrameId);
166
+ // Pin the primary (own) frame once, so every later re-entry/replay classifies it identically
167
+ // regardless of a mid-flight reparent (see {@link resolveDeployTargets}). Persisted with the
168
+ // first synchronous-settle / async-park upsert below.
169
+ step.deployPrimaryFrameId ??= targets.find((t) => t.isPrimary)?.frameId;
170
+ return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
171
+ }
172
+ /**
173
+ * Advance a `deployer` fan-out over its already-resolved `targets`: dispatch the first un-settled
174
+ * frame (parking on an async deploy job) or, once every frame has settled, complete the step. One
175
+ * deploy job per frame, dispatched SEQUENTIALLY (parking between) so a later provider can receive
176
+ * the already-ready peers' URLs. `step.deployEnvs` records each frame's TERMINAL outcome, so a
177
+ * replay resumes at the first un-settled frame. Re-entered (with the SAME targets) after each
178
+ * synchronous/infraless/failed-peer frame settles — never re-reading the block list per frame.
179
+ */
180
+ async advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets) {
181
+ const done = step.deployEnvs ?? {};
182
+ const next = targets.find((t) => !done[t.frameId]);
183
+ if (!next) {
184
+ // Every frame settled: finish the step (all ready → done; a primary failure short-circuited).
185
+ return this.completeDeployerStep(workspaceId, instance, step, isFinalStep, targets);
186
+ }
187
+ // The deployer is the SINGLE environment provisioner: it stands the frame's env up whenever
188
+ // there is genuinely one to stand up, so every downstream consumer (tester / human-test /
189
+ // playwright) can depend on a pre-provisioned env rather than standing infra up itself:
190
+ // - a DECLARED `kubernetes`/`custom` type (resolved through its per-type handler), OR
191
+ // - a DECLARED `docker-compose` type on a workspace with a compose handler configured (the
192
+ // setup wizard saves one) — the per-PR compose stack is provisioned HERE (attaching shared
193
+ // stacks / running preflights), and the tester then targets that provisioned env (see
194
+ // `testerInfraSpec`). A compose chain that reaches a tester with no resolvable handler is now
195
+ // refused at run start (`assertTesterInfraConfigured`), so this stays the sole compose path, OR
196
+ // - an UNDECLARED frame on a workspace with a legacy single-connection registered (the compat
197
+ // bridge — preserved so existing single-connection deployments keep provisioning).
198
+ // Every other frame stands nothing up HERE — `infraless`/none, an undeclared frame with NO
199
+ // connection, or a frontend frame — so the deployer records `{status:'skipped'}` and re-enters
200
+ // for the next frame. This makes the deployer a safe NO-OP prefix that can be injected before
201
+ // every tester/human-test step without failing services that never configured provisioning.
202
+ // A `library` frame (not `deployable`) is never deployed — a declared compose path is repo-local
203
+ // TEST infra, not an environment — so it stands nothing up here regardless of its provisioning.
204
+ // Gating every env branch on `deployable` forces the skip record below (mirroring `infraless`).
205
+ const deployable = frameProfile(next.frame.type).deployable;
206
+ const provisionType = next.provisioning?.type;
207
+ const declaresEnv = deployable && (provisionType === 'kubernetes' || provisionType === 'custom');
208
+ const composeEnv = deployable &&
209
+ provisionType === 'docker-compose' &&
210
+ next.provisioning !== undefined &&
211
+ // Thread the run initiator so a local per-user handler OVERRIDE resolves exactly as it does at
212
+ // provision time (and in the start-time gate) — else an override-only compose setup that
213
+ // passed `assertDeployerConfigured` would silently no-op here (the very dead-end the gate closes).
214
+ ((await this.environmentProvisioning?.canProvision(workspaceId, next.provisioning, instance.initiatedBy))?.ok ??
215
+ false);
216
+ const legacyEnv = deployable &&
217
+ provisionType === undefined &&
218
+ (await this.environmentProvisioning?.hasLegacyConnection(workspaceId));
219
+ if (!declaresEnv && !composeEnv && !legacyEnv) {
220
+ await this.environmentProvisioning?.supersedeForBlock(workspaceId, block.id, next.frameId);
221
+ step.deployEnvs = { ...done, [next.frameId]: { status: 'skipped' } };
222
+ // Persist this frame's TERMINAL outcome BEFORE processing the next frame, so a crash/replay
223
+ // mid-fan-out resumes at the first un-settled frame rather than re-doing an already-settled
224
+ // one (which, on the synchronous REST path, would re-hit the provider — no idempotency guard
225
+ // there, unlike the deterministic async job ref).
226
+ await this.runStateMachine.casPersist(workspaceId, instance);
227
+ return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
228
+ }
229
+ // Start provisioning the next frame: a raw-manifest config provisions SYNCHRONOUSLY over REST
230
+ // (a final handle); a config that needs rendering dispatches a CONTAINER-backed deploy job we
231
+ // park on and poll. The job ref is DETERMINISTIC (run id + deployer kind + FRAME + eviction
232
+ // epoch), so a Workflows replay reproduces the same id and the transport re-attaches instead
233
+ // of double-dispatching. The frame discriminator keeps each fanned-out job distinct.
234
+ const ref = {
235
+ runId: instance.id,
236
+ jobId: deployJobId(instance.id, deployEvictionEpoch(step), next.frameId),
237
+ };
238
+ const peerEnvUrls = buildPeerEnvUrls(targets, done);
239
+ let dispatch;
240
+ try {
241
+ dispatch = await this.environmentProvisioning.startProvision(await this.deployerProvisionArgs(workspaceId, instance, block, next, peerEnvUrls), ref);
242
+ }
243
+ catch (error) {
244
+ return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, next, null, getErrorMessage(error),
245
+ // Propagate the provider's machine-readable cause (e.g. `deploy_runner_unwired`) so the
246
+ // SPA can render precise, runtime-specific guidance rather than string-matching the prose.
247
+ getErrorReason(error));
248
+ }
249
+ if (dispatch.kind === 'completed') {
250
+ // Synchronous provision: record this frame's outcome, then continue to the next frame.
251
+ return this.settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, next, dispatch.handle);
252
+ }
253
+ // An async deploy job was dispatched: park on this frame. `dispatch` blocked until the job was
254
+ // accepted, so the container is up; the live phase + the provisioned outcome arrive on the
255
+ // deployer poll branch. Surface the frame's env spinning up alongside the parked step.
256
+ step.jobId = dispatch.ref.jobId;
257
+ step.deployFrameId = next.frameId;
258
+ step.container = { status: 'up' };
259
+ // Pin the provisioning config the container was built from, so the later poll/finalize maps
260
+ // the job against THIS config rather than a fresh read of the frame (which a person may edit
261
+ // mid-flight). Absent for the undeclared legacy path, which re-resolution handles harmlessly.
262
+ step.deployProvisioning = next.provisioning;
263
+ await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, next.frameId);
264
+ await this.runStateMachine.casPersist(workspaceId, instance);
265
+ await this.runStateMachine.emitInstance(workspaceId, instance);
266
+ return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
267
+ }
268
+ /**
269
+ * Resolve the ordered set of service frames a `deployer` step provisions environments for: the
270
+ * task's OWN service frame (always, `isPrimary`) plus each involved-service frame (read-time
271
+ * stale-filtered to ids that are still a connection neighbour AND resolve to a `service` frame
272
+ * WITH declared provisioning — an involved frame with none stands nothing up here). Ordered
273
+ * PROVIDER-before-CONSUMER over the connection edges among the targets (see
274
+ * {@link orderProvisionTargets}) so a later provision can receive its ready peers' URLs. One
275
+ * workspace block-list read; no per-frame point read.
276
+ *
277
+ * `pinnedPrimaryFrameId` (from {@link PipelineStep.deployPrimaryFrameId}, set on the first
278
+ * resolution) keeps the OWN/primary frame STABLE across re-entries: once the fan-out has started,
279
+ * a mid-flight reparent must not re-classify which frame is primary — that would flip an
280
+ * own-frame failure from terminal to a non-terminal peer failure. Prefer the pinned frame when it
281
+ * still resolves; fall back to a fresh `frameOf` walk otherwise.
282
+ */
283
+ async resolveDeployTargets(workspaceId, block, pinnedPrimaryFrameId) {
284
+ const blocks = await this.blockRepository.listByWorkspace(workspaceId);
285
+ const byId = new Map(blocks.map((b) => [b.id, b]));
286
+ const ownFrame = (pinnedPrimaryFrameId ? byId.get(pinnedPrimaryFrameId) : undefined) ??
287
+ frameOf(byId, block.id) ??
288
+ block;
289
+ const targets = [
290
+ {
291
+ frameId: ownFrame.id,
292
+ isPrimary: true,
293
+ provisioning: ownFrame.provisioning,
294
+ frame: ownFrame,
295
+ },
296
+ ];
297
+ // The connected involved-service frames, read-time stale-filtered by the shared helper (kept in
298
+ // sync with `AgentContextBuilder.resolveInvolvedServices`). Include each regardless of declared
299
+ // provisioning — like the OWN frame, an undeclared service falls through to the legacy
300
+ // single-connection compat bridge, and an `infraless` one is skipped by the dispatch loop. Only
301
+ // the dispatch decides what actually stands up.
302
+ for (const frame of validInvolvedServiceFrames(blocks, block, ownFrame.id)) {
303
+ if (targets.some((t) => t.frameId === frame.id))
304
+ continue;
305
+ targets.push({
306
+ frameId: frame.id,
307
+ isPrimary: false,
308
+ provisioning: frame.provisioning,
309
+ frame,
310
+ });
311
+ }
312
+ const targetIds = new Set(targets.map((t) => t.frameId));
313
+ const providersOf = new Map();
314
+ for (const target of targets) {
315
+ const providers = new Set();
316
+ for (const connection of target.frame.serviceConnections ?? []) {
317
+ if (connection.serviceBlockId !== target.frameId &&
318
+ targetIds.has(connection.serviceBlockId)) {
319
+ providers.add(connection.serviceBlockId);
320
+ }
321
+ }
322
+ providersOf.set(target.frameId, providers);
323
+ }
324
+ const order = orderProvisionTargets(targets.map((t) => ({ frameId: t.frameId, isPrimary: t.isPrimary })), providersOf);
325
+ const byFrame = new Map(targets.map((t) => [t.frameId, t]));
326
+ return order.map((id) => byFrame.get(id));
327
+ }
328
+ /**
329
+ * Record one frame's TERMINAL deploy outcome onto `step.deployEnvs`, then continue the fan-out.
330
+ * A `ready` handle records the env and re-enters {@link advanceDeployerFrames} for the next
331
+ * frame; a `failed` handle routes to {@link settleDeployerFailure} (terminal only for the own
332
+ * frame). Shared by the synchronous-provision and async-finalized paths.
333
+ */
334
+ async settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, target, handle) {
335
+ if (handle.status === 'failed') {
336
+ return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, handle.url, handle.lastError ?? 'Provisioning failed.');
337
+ }
338
+ if (handle.status !== 'ready' && !target.isPrimary) {
339
+ // A PEER env that isn't `ready` (`provisioning`, `expired`, `tearing_down`, …) is not usable
340
+ // context: `deployEnvs` can only record `ready`/`failed`/`skipped`, and recording it `ready`
341
+ // would BOTH advertise it "Provisioned involved-service environment …" AND inject its not-live
342
+ // URL into a consumer's `peerEnvUrls`. Drop it as a non-terminal peer failure instead. (The
343
+ // OWN frame keeps the historical behaviour — its env is the deploy's product; its live status
344
+ // is surfaced via the Environment projection, and the run proceeds as before.)
345
+ return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, handle.url, `Environment not ready (status: ${handle.status}).`);
346
+ }
347
+ const done = step.deployEnvs ?? {};
348
+ step.deployEnvs = { ...done, [target.frameId]: { status: 'ready', url: handle.url } };
349
+ // Persist this frame's TERMINAL outcome BEFORE provisioning the next frame (see the infraless
350
+ // branch) so a crash/replay resumes at the first un-settled frame, not re-provisioning this one.
351
+ await this.runStateMachine.casPersist(workspaceId, instance);
352
+ return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
353
+ }
354
+ /**
355
+ * Record a frame's FAILED deploy outcome and decide whether it is terminal. The task's OWN
356
+ * (primary) service frame failing fails the whole deploy step (unchanged from the single-env
357
+ * path). An involved PEER frame failing is NON-terminal — the peer's env is best-effort context
358
+ * enrichment, so the run proceeds to the remaining frames without that peer's URL rather than
359
+ * failing a task because a service it merely "involves" has a misconfigured provider. The failed
360
+ * outcome is still recorded (surfaced in {@link completeDeployerStep}).
361
+ */
362
+ async settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, url, error,
363
+ /** Machine-readable cause (e.g. `deploy_runner_unwired`) carried to the failure record. */
364
+ reason) {
365
+ const done = step.deployEnvs ?? {};
366
+ step.deployEnvs = { ...done, [target.frameId]: { status: 'failed', url: url ?? null, error } };
367
+ if (target.isPrimary) {
368
+ return this.failDeployerStep(workspaceId, instance, step, target.frameId, error, reason);
369
+ }
370
+ // A PEER failure is non-terminal — persist it BEFORE moving to the next frame so a replay
371
+ // doesn't re-attempt this failed peer (same rationale as the ready/infraless settle paths).
372
+ await this.runStateMachine.casPersist(workspaceId, instance);
373
+ return this.advanceDeployerFrames(workspaceId, instance, step, block, isFinalStep, targets);
374
+ }
375
+ /**
376
+ * Poll a `deployer` step's dispatched CONTAINER-backed deploy job (the async kustomize/helm
377
+ * path) through the environment provisioning service — NOT the agent executor. Mirrors
378
+ * `pollAgentJob`: surfaces live container/subtask progress while running, recovers a
379
+ * container eviction by re-dispatching a fresh deploy job (within the same budgets), and on a
380
+ * genuine terminal state finalizes the job into an environment record + the step result.
381
+ */
382
+ async pollDeployerJob(workspaceId, instance, step) {
383
+ const ref = { runId: instance.id, jobId: step.jobId };
384
+ // The service frame this in-flight deploy job is provisioning (a multi-env fan-out dispatches
385
+ // one job per frame). Falls back to the own frame for a single-frame deploy that predates the
386
+ // discriminator / never fanned out.
387
+ const inFlightFrameId = step.deployFrameId ?? undefined;
388
+ // Let a status-read failure THROW to the driver, exactly as `pollAgentJob` lets
389
+ // `executor.pollJob` throw: the driver counts consecutive read failures and fast-fails the
390
+ // run as `timeout` once `jobPollFailureTolerance` is hit. Swallowing it here would hide every
391
+ // read failure from that counter, so an unreachable deploy container would only stop at the
392
+ // full `jobMaxPolls` budget with a misleading "did not finish" message.
393
+ const view = await this.environmentProvisioning.pollProvisionJob(workspaceId, ref);
394
+ if (view.state === 'running') {
395
+ let changed = false;
396
+ if (this.applyContainerRunning(step, view))
397
+ changed = true;
398
+ if (this.applySubtaskProgress(step, view.progress))
399
+ changed = true;
400
+ if (await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, inFlightFrameId)) {
401
+ changed = true;
402
+ }
403
+ if (changed) {
404
+ await this.runStateMachine.casPersist(workspaceId, instance);
405
+ // Progress-only deploy-job fold: skip the LLM-metrics rollup (same reason as the
406
+ // agent running fold — a deploy job makes no LLM calls anyway).
407
+ await this.runStateMachine.emitInstance(workspaceId, instance, { rollUpMetrics: false });
408
+ }
409
+ return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
410
+ }
411
+ // The deploy container vanished (evicted/crashed). The shared recovery re-dispatches a fresh
412
+ // deploy job (the driver loops back into `runDeployerStep`, which re-provisions the same
413
+ // un-settled frame since `step.jobId` is cleared) within the same per-flavour budgets as the
414
+ // agent path, reclaiming the dead job's runner first. Null for a non-eviction failure.
415
+ if (view.state === 'failed') {
416
+ const recovered = await this.recoverContainerEviction(workspaceId, instance, step, view.error, view.evicted, () => this.environmentProvisioning.releaseProvisionJob(workspaceId, ref).catch(() => { }));
417
+ if (recovered)
418
+ return recovered;
419
+ }
420
+ // Genuine terminal (done, or a non-eviction failure): finalize the deploy job into an
421
+ // environment record and record this frame's outcome. A `failed` view maps to a failed env,
422
+ // which `settleDeployerFrame` surfaces as a displayed step failure.
423
+ const block = await this.blockRepository.get(workspaceId, instance.blockId);
424
+ if (!block)
425
+ return { kind: 'noop' };
426
+ const isFinalStep = instance.currentStep === instance.steps.length - 1;
427
+ // Resolve the full target set once (also drives the remaining-frames fan-out after this one
428
+ // settles), honouring the pinned primary frame. Derive the own/primary frame id from that set
429
+ // rather than a SECOND `resolveServiceFrameId` point-read walk — the primary target's frame id
430
+ // is the own frame (pinned, so a mid-flight reparent can't flip an own failure to a peer one).
431
+ const targets = await this.resolveDeployTargets(workspaceId, block, step.deployPrimaryFrameId);
432
+ const ownFrameId = step.deployPrimaryFrameId ?? targets.find((t) => t.isPrimary)?.frameId ?? block.id;
433
+ const frameId = inFlightFrameId ?? ownFrameId;
434
+ // Recover the in-flight frame's real service-frame block from the target set so finalize
435
+ // provisions with the FRAME's identity/inputs (a peer's env must not reuse the task block's —
436
+ // see {@link deployerProvisionArgs}); fall back to a point-read (then the task block) if a
437
+ // connection was removed mid-flight so the frame is no longer a target.
438
+ const known = targets.find((t) => t.frameId === frameId);
439
+ const frame = known?.frame ?? (await this.blockRepository.get(workspaceId, frameId)) ?? block;
440
+ // Map the job against the provisioning config the container was BUILT from (pinned at
441
+ // dispatch), not a fresh read of the frame a person may have edited mid-flight — else a
442
+ // config flip (e.g. → `infraless`) would fail a deploy whose container already succeeded. The
443
+ // pinned config is the in-flight frame's; the fallback resolution is only ever hit for the
444
+ // undeclared-own compat path (which resolves the own frame correctly).
445
+ const provisioning = step.deployProvisioning ?? (await this.resolveServiceProvisioning(workspaceId, block));
446
+ const target = { frameId, isPrimary: frameId === ownFrameId, provisioning, frame };
447
+ step.jobId = undefined;
448
+ step.deployFrameId = undefined;
449
+ step.subtasks = undefined;
450
+ // The one-shot deploy container reached a terminal state: reclaim its runner now rather than
451
+ // letting it idle out its sleepAfter window (billed-but-useless compute) / leak a self-hosted
452
+ // pool slot. The deploy job is dispatched SEPARATELY from the shared per-run container, so the
453
+ // agent path's `stopRunContainer` (final step only, run-id keyed) never reclaims it.
454
+ // Best-effort/idempotent.
455
+ await this.environmentProvisioning.releaseProvisionJob(workspaceId, ref).catch(() => { });
456
+ let handle;
457
+ try {
458
+ handle = await this.environmentProvisioning.finalizeProvision(await this.deployerProvisionArgs(workspaceId, instance, block, target, ''), view);
459
+ }
460
+ catch (error) {
461
+ // The deploy container is gone (released above) but finalize failed: stamp the container
462
+ // errored so the failed details don't keep showing it "up". A primary failure is terminal; a
463
+ // peer's is not (the fan-out proceeds), so route through `settleDeployerFailure`.
464
+ if (step.container)
465
+ step.container = { ...step.container, status: 'errored' };
466
+ step.deployProvisioning = undefined;
467
+ return this.settleDeployerFailure(workspaceId, instance, step, block, isFinalStep, targets, target, null, getErrorMessage(error));
468
+ }
469
+ step.deployProvisioning = undefined;
470
+ // Reflect the container's terminal state from the RESOLVED outcome, not the raw view: a `done`
471
+ // view the provider maps to a FAILED env (e.g. the harness exited 0 but the namespace is
472
+ // missing) must still show the container errored — keying off `view.state` alone missed that.
473
+ if (handle.status === 'failed' && step.container) {
474
+ step.container = { ...step.container, status: 'errored' };
475
+ }
476
+ return this.settleDeployerFrame(workspaceId, instance, step, block, isFinalStep, targets, target, handle);
477
+ }
478
+ /**
479
+ * The {@link ProvisionArgs} for provisioning ONE target frame's environment (synchronous or
480
+ * async). The env is keyed by the task `block.id` + the target `frameId` — so a task's own env
481
+ * and each involved-service env coexist under the same block, discriminated by frame (see the
482
+ * per-`(blockId, frameId)` supersede). The repo/clone the provider resolves is the TARGET
483
+ * FRAME's (via `frameId`), so an involved-service env clones that peer's repo at its default
484
+ * branch, while the OWN frame targets the task's PR branch (its git/PR context); a peer carries
485
+ * no PR context. The `{{input.*}}` identity (blockId/title/…) is the TARGET FRAME's for a peer
486
+ * (see {@link deployTargetInputs}) so each peer's provider namespace is distinct — the task-
487
+ * scoped inputs would collapse every peer onto one namespace. Injects `frontendOrigins` (the
488
+ * browser origins binding this service) and `peerEnvUrls` (the already-ready peers) too.
489
+ */
490
+ async deployerProvisionArgs(workspaceId, instance, block, target, peerEnvUrls) {
491
+ const frontendOrigins = await this.frontendOriginsInput(workspaceId, target.frameId);
492
+ // The OWN frame deploys the task's PR branch (its git/PR context); an involved peer carries no
493
+ // PR context, so its clone target falls back to that repo's default branch.
494
+ const context = target.isPrimary ? this.deployContext(block) : { blockId: block.id };
495
+ return {
496
+ workspaceId,
497
+ blockId: block.id,
498
+ frameId: target.frameId,
499
+ executionId: instance.id,
500
+ inputs: {
501
+ ...this.deployTargetInputs(block, target),
502
+ ...(frontendOrigins ? { frontendOrigins } : {}),
503
+ ...(peerEnvUrls ? { peerEnvUrls } : {}),
504
+ },
505
+ context,
506
+ ...(target.provisioning ? { serviceProvisioning: target.provisioning } : {}),
507
+ initiatedBy: instance.initiatedBy,
508
+ };
509
+ }
510
+ /**
511
+ * The `{{input.*}}` identity a target frame provisions with. The OWN frame keeps the historical
512
+ * task-scoped inputs (its namespace is uniquified by the task's PR repo/number). An involved PEER
513
+ * frame is scoped to the PEER FRAME's identity, with a `(task, peer)` composite `blockId` — so
514
+ * the provider namespace derived from `{{input.blockId}}` is distinct per peer AND per task,
515
+ * where the task-scoped inputs would collapse every peer of a task onto ONE namespace (each
516
+ * clobbering the previous, teardown deleting the wrong one).
517
+ */
518
+ deployTargetInputs(block, target) {
519
+ if (target.isPrimary)
520
+ return this.deployInputs(block);
521
+ return {
522
+ blockId: `${block.id}-${target.frameId}`,
523
+ title: target.frame.title,
524
+ type: target.frame.type,
525
+ description: target.frame.description,
526
+ };
527
+ }
528
+ /**
529
+ * The `frontendOrigins` provision input for a service frame: the comma-joined browser origins
530
+ * of every `frontend` frame that binds this service (see `frontendOriginsForService`), for a
531
+ * manifest to fold into the backend's CORS allow-list via `{{input.frontendOrigins}}`. Empty
532
+ * string when no frontend binds it (the key is then omitted). One workspace block-list read —
533
+ * no per-frame point read (mirrors the visual-pipeline gate).
534
+ */
535
+ async frontendOriginsInput(workspaceId, serviceFrameId) {
536
+ const blocks = await this.blockRepository.listByWorkspace(workspaceId);
537
+ return frontendOriginsForService(serviceFrameId, blocks).join(',');
538
+ }
539
+ /**
540
+ * Turn a provisioned environment handle into the `deployer` step's advance result: a `failed`
541
+ * env is surfaced as a displayed step failure (its `lastError` renders in the Environment
542
+ * panel); otherwise the env summary (status / URL / provision type / engine) is recorded as the
543
+ * step output. Shared by the synchronous and async-finalized provision paths.
544
+ */
545
+ async completeDeployerStep(workspaceId, instance, step, isFinalStep, targets) {
546
+ const byFrame = new Map(targets.map((t) => [t.frameId, t]));
547
+ const primaryFrameId = step.deployPrimaryFrameId ?? targets.find((t) => t.isPrimary)?.frameId;
548
+ // Re-project the now-final OWN environment (ready/expired + URL) so the deployer step's
549
+ // Environment panel + the downstream tester see the task's own service env, not a peer's or the
550
+ // dispatch-time `provisioning` snapshot the async poll last wrote. Pass the pinned primary frame
551
+ // so it needn't re-walk the tree to find the own frame.
552
+ await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, primaryFrameId);
553
+ // Summarise from the recorded per-frame OUTCOMES (`deployEnvs`), NOT the current `targets`: a
554
+ // mid-flight involved-services / connection edit can drop a frame from `targets` while its env
555
+ // is still recorded and live, so iterating outcomes keeps that env visible (never silently
556
+ // orphaned). Titles come from the target set when the frame is still resolvable, else the id.
557
+ const done = step.deployEnvs ?? {};
558
+ const titleOf = (frameId) => byFrame.get(frameId)?.frame.title ?? frameId;
559
+ const isPrimaryFrame = (frameId) => byFrame.get(frameId)?.isPrimary ?? frameId === primaryFrameId;
560
+ const readyEntries = Object.entries(done).filter(([, env]) => env.status === 'ready');
561
+ if (readyEntries.length === 0) {
562
+ // Every target was `infraless`/library/skipped — nothing stood up (the single-service
563
+ // infraless case plus the all-infraless fan-out). A `library` frame reports its own reason
564
+ // (it is never deployed) so the run timeline stays explainable, per the frame profile.
565
+ const primaryFrame = primaryFrameId ? byFrame.get(primaryFrameId)?.frame : undefined;
566
+ const output = primaryFrame && !frameProfile(primaryFrame.type).deployable
567
+ ? 'Library frame; no deployment or environment provisioned.'
568
+ : 'Service is infraless; no environment provisioned.';
569
+ return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
570
+ output,
571
+ model: 'environment:none',
572
+ });
573
+ }
574
+ const own = step.environment;
575
+ const lines = [];
576
+ for (const [frameId, env] of readyEntries) {
577
+ const url = env.url ?? '(pending)';
578
+ lines.push(isPrimaryFrame(frameId)
579
+ ? `Provisioned ephemeral environment for '${titleOf(frameId)}': ${url}`
580
+ : `Provisioned involved-service environment for '${titleOf(frameId)}': ${url}`);
581
+ }
582
+ // A PEER frame that failed is non-terminal (the own deploy proceeded); note it so the failure
583
+ // is visible rather than silently absent from the fan-out summary. (A primary failure never
584
+ // reaches here — it fails the step in `settleDeployerFailure`.)
585
+ for (const [frameId, env] of Object.entries(done)) {
586
+ if (env.status !== 'failed' || isPrimaryFrame(frameId))
587
+ continue;
588
+ lines.push(`Involved-service environment for '${titleOf(frameId)}' failed: ${env.error ?? 'unknown error'}`);
589
+ }
590
+ if (own?.expiresAt)
591
+ lines.push(`Expires: ${new Date(own.expiresAt).toISOString()}`);
592
+ if (own?.provisionType)
593
+ lines.push(`Provision type: ${own.provisionType}`);
594
+ if (own?.engine)
595
+ lines.push(`Engine: ${own.engine}`);
596
+ return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
597
+ output: lines.join('\n'),
598
+ model: `environment:${readyEntries.length > 1 ? 'multi' : (own?.engine ?? 'single')}`,
599
+ });
600
+ }
601
+ /**
602
+ * Resolve the SERVICE frame's declared provisioning for a run block. The run may target a
603
+ * task/module nested under the frame, so walk up to the frame (mirrors the blueprint /
604
+ * tester-gate resolution) and read its `provisioning`. Returns null when undeclared.
605
+ */
606
+ async resolveServiceProvisioning(workspaceId, block) {
607
+ const frameId = (await this.contextBuilder.resolveServiceFrameId(workspaceId, block.id)) ?? block.id;
608
+ const frame = frameId === block.id ? block : await this.blockRepository.get(workspaceId, frameId);
609
+ return frame?.provisioning;
610
+ }
611
+ /**
612
+ * Stamp the errored environment onto the deployer step (so its details show the verbatim
613
+ * `lastError`), persist + emit, then return a structured `environment` failure carrying the
614
+ * provider's message as the detail. Mirrors `handleAgentStep`'s dispatch-failure path.
615
+ */
616
+ async failDeployerStep(workspaceId, instance, step, frameId, message,
617
+ /** Machine-readable cause (e.g. `deploy_runner_unwired`) surfaced on the failure so the SPA
618
+ * renders precise guidance without string-matching the prose. */
619
+ reason) {
620
+ // Project the FAILED frame's env (so its `lastError` renders in the Environment panel) — for a
621
+ // single-frame deploy that is the own env; for a failed involved-service env it surfaces the
622
+ // peer's error rather than a sibling's healthy env.
623
+ await this.attachEnvironmentProjection(workspaceId, instance.blockId, step, frameId);
624
+ await this.runStateMachine.casPersist(workspaceId, instance);
625
+ await this.runStateMachine.emitInstance(workspaceId, instance);
626
+ return {
627
+ kind: 'job_failed',
628
+ error: 'Environment provisioning failed.',
629
+ failureKind: 'environment',
630
+ detail: message,
631
+ ...(reason ? { reason } : {}),
632
+ };
633
+ }
634
+ /** Provision inputs (`{{input.*}}`) derived from the block under deployment. */
635
+ deployInputs(block) {
636
+ const inputs = {
637
+ blockId: block.id,
638
+ title: block.title,
639
+ type: block.type,
640
+ description: block.description,
641
+ };
642
+ return inputs;
643
+ }
644
+ /**
645
+ * Typed git/PR/repo context for the deployer, derived from the block's PR ref. A
646
+ * PR-environment provider (e.g. an in-house adapter) needs the branch/repo to target
647
+ * the right environment; the same values are also flattened into `{{input.*}}` for
648
+ * the manifest path. `owner`/`repo` are parsed from the PR url when present.
649
+ */
650
+ deployContext(block) {
651
+ const context = { blockId: block.id };
652
+ const pr = block.pullRequest;
653
+ if (!pr)
654
+ return context;
655
+ if (pr.branch)
656
+ context.branch = pr.branch;
657
+ if (pr.number !== undefined)
658
+ context.pullNumber = pr.number;
659
+ if (pr.url) {
660
+ context.pullUrl = pr.url;
661
+ const repo = parseRepoFromPullUrl(pr.url);
662
+ if (repo) {
663
+ context.repoOwner = repo.owner;
664
+ context.repoName = repo.repo;
665
+ }
666
+ }
667
+ return context;
668
+ }
669
+ }
670
+ //# sourceMappingURL=DeployerStepController.js.map