@cat-factory/orchestration 0.123.4 → 0.123.5

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 (25) hide show
  1. package/dist/modules/execution/DeployerStepController.d.ts +185 -0
  2. package/dist/modules/execution/DeployerStepController.d.ts.map +1 -0
  3. package/dist/modules/execution/DeployerStepController.js +670 -0
  4. package/dist/modules/execution/DeployerStepController.js.map +1 -0
  5. package/dist/modules/execution/ExecutionService.d.ts +8 -211
  6. package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
  7. package/dist/modules/execution/ExecutionService.js +59 -805
  8. package/dist/modules/execution/ExecutionService.js.map +1 -1
  9. package/dist/modules/execution/FollowUpGateController.d.ts +104 -0
  10. package/dist/modules/execution/FollowUpGateController.d.ts.map +1 -0
  11. package/dist/modules/execution/FollowUpGateController.js +317 -0
  12. package/dist/modules/execution/FollowUpGateController.js.map +1 -0
  13. package/dist/modules/execution/RunAdmission.d.ts +204 -0
  14. package/dist/modules/execution/RunAdmission.d.ts.map +1 -0
  15. package/dist/modules/execution/RunAdmission.js +571 -0
  16. package/dist/modules/execution/RunAdmission.js.map +1 -0
  17. package/dist/modules/execution/RunDispatcher.d.ts +24 -196
  18. package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
  19. package/dist/modules/execution/RunDispatcher.js +70 -929
  20. package/dist/modules/execution/RunDispatcher.js.map +1 -1
  21. package/dist/modules/execution/review-kinds.d.ts +41 -0
  22. package/dist/modules/execution/review-kinds.d.ts.map +1 -0
  23. package/dist/modules/execution/review-kinds.js +241 -0
  24. package/dist/modules/execution/review-kinds.js.map +1 -0
  25. package/package.json +1 -1
@@ -1,21 +1,23 @@
1
- import { allPullRequests, DEFAULT_COMPANION_MAX_ATTEMPTS, frameAllowsVisualPipeline, frameProfile, isLocalRunner, pipelineHasVisualStep, } from '@cat-factory/contracts';
2
- import { BINARY_STORAGE_TRAIT, bugInvestigation, companionFor, companionTargets, hasTrait, INTERVIEW_GATE_TRAIT, isCompanionKind, isInlineModelStep, } from '@cat-factory/agents';
3
- import { assertPipelineLaunchable, validatePipelineShape, } from '../pipelines/pipelineShape.js';
1
+ import { allPullRequests, DEFAULT_COMPANION_MAX_ATTEMPTS, pipelineHasVisualStep, } from '@cat-factory/contracts';
2
+ import { companionFor, companionTargets, hasTrait, INTERVIEW_GATE_TRAIT, isCompanionKind, } from '@cat-factory/agents';
3
+ import { assertPipelineLaunchable } from '../pipelines/pipelineShape.js';
4
4
  import { shouldRunGatedStep } from './stepGating.logic.js';
5
5
  import { resolveIndividualVendors, } from './individualVendors.logic.js';
6
- import { assertFound, ConflictError, isAllowedByFamilyPolicy, isModelUsable, isModelUsableInline, NotFoundError, resolveModelRef, RunContendedError, subscriptionOptionFor, ValidationError, } from '@cat-factory/kernel';
6
+ import { assertFound, ConflictError, NotFoundError, RunContendedError, ValidationError, } from '@cat-factory/kernel';
7
7
  import { DEFAULT_RISK_POLICY } from '@cat-factory/kernel';
8
- import { REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, BUG_INVESTIGATOR_AGENT_KIND, isTesterKind, HUMAN_TEST_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, HUMAN_REVIEW_AGENT_KIND, } from './ci.logic.js';
8
+ import { REQUIREMENTS_REVIEW_AGENT_KIND, CLARITY_REVIEW_AGENT_KIND, REQUIREMENTS_BRAINSTORM_AGENT_KIND, ARCHITECTURE_BRAINSTORM_AGENT_KIND, isTesterKind, HUMAN_TEST_AGENT_KIND, VISUAL_CONFIRM_AGENT_KIND, HUMAN_REVIEW_AGENT_KIND, } from './ci.logic.js';
9
9
  import { DEFAULT_FOLLOW_UP_MAX_LOOPS, FOLLOW_UP_PRODUCER_KIND } from './followUp.logic.js';
10
10
  import { AgentContextBuilder, } from './AgentContextBuilder.js';
11
11
  import { CompanionController } from './CompanionController.js';
12
12
  import { StepGraph } from './StepGraph.js';
13
13
  import { RunStateMachine } from './RunStateMachine.js';
14
14
  import { RunDispatcher } from './RunDispatcher.js';
15
+ import { RunAdmission } from './RunAdmission.js';
15
16
  import { inferTechnicalLabel } from './technical.logic.js';
16
17
  import { MergeResolver } from './MergeResolver.js';
17
18
  import { orderPrsForMerge } from './mergeOrder.logic.js';
18
19
  import { ReviewGateController } from './ReviewGateController.js';
20
+ import { buildBrainstormKind, buildClarityKind, buildRequirementsKind } from './review-kinds.js';
19
21
  import { ForkDecisionController } from './ForkDecisionController.js';
20
22
  import { PrReviewController } from './PrReviewController.js';
21
23
  import { BrainstormActions, ClarityReviewActions, RequirementReviewActions, } from './gate-window-facades.js';
@@ -27,14 +29,10 @@ import { VisualConfirmationController } from './VisualConfirmationController.js'
27
29
  import { InitiativeInterviewController } from './InitiativeInterviewController.js';
28
30
  import { DocInterviewController } from './DocInterviewController.js';
29
31
  import { FORK_DECISION_PRODUCER_KIND } from './forkDecision.logic.js';
30
- import { assertInitiativeShapeAllowed, } from '../initiative/initiative.logic.js';
31
32
  import { isAsyncAgentExecutor } from '@cat-factory/kernel';
32
- import { dependenciesMet, descendantIds, serviceOf, unmetDependencies, } from '../board/board.logic.js';
33
+ import { dependenciesMet, descendantIds, serviceOf } from '../board/board.logic.js';
33
34
  import { requireWorkspace } from '@cat-factory/kernel';
34
35
  import { carryForwardFailures, carryForwardOutputs, planResumedSteps, planRestartFromStep, } from './retry.logic.js';
35
- import { decideTesterInfra, ENV_CONSUMER_KINDS, needsDeployerBeforeConsumer, TESTER_INFRA_MESSAGES, } from './tester-infra.logic.js';
36
- import { decideDeployerConfig, deployerServiceConfigIssues, hasEnabledDeployerStep, } from './deployer.logic.js';
37
- import { hasLiveServiceBinding, hasServiceBinding } from './frontend-infra.logic.js';
38
36
  /**
39
37
  * The execution engine. It orchestrates a pipeline of agent-performed steps and
40
38
  * is fully deterministic: `advanceInstance` moves one run forward by exactly one
@@ -62,13 +60,15 @@ export class ExecutionService {
62
60
  events;
63
61
  board;
64
62
  spend;
65
- requirementReviewService;
66
- docInterviewService;
67
- clarityReviewService;
68
- brainstormServices;
69
- environmentProvisioning;
70
63
  /** Assembles the per-step agent context (requirements, docs, env, service frame, fragments). */
71
64
  contextBuilder;
65
+ /**
66
+ * The run-admission preflights (the `assert*` family): every config/resource precondition
67
+ * a START / RETRY / RESTART must satisfy, extracted to {@link RunAdmission} so the guard
68
+ * family can grow without re-bloating the engine. Also owns the shared
69
+ * {@link RunAdmission.modelIdIsMetered} predicate the spend gates use.
70
+ */
71
+ admission;
72
72
  /** Resolves a `merger` step's assessment into an auto-merge or a `merge_review` notification. */
73
73
  mergeResolver;
74
74
  /** Drives a companion (reviewer/spec/architect) step: grade → pass / loop producer / park. */
@@ -106,8 +106,10 @@ export class ExecutionService {
106
106
  // `resolveRunRepoContext` / `runInitiatorScope` are NOT stored on the engine: their only
107
107
  // consumers (the ingest/follow-up/tracker/notification paths + the pre/post-op repo binding +
108
108
  // the initiator scope) moved to {@link RunDispatcher} (and the controllers / RunStateMachine),
109
- // so the constructor forwards the destructured params straight to those collaborators.
110
- workspaceSettingsService;
109
+ // so the constructor forwards the destructured params straight to those collaborators. The
110
+ // admission-only seams (`workspaceSettingsService` / `resolveProviderCapabilities` /
111
+ // `inlineHarnessRef` / `resolveBinaryArtifactStore` / `assertAgentBackendConfigured` /
112
+ // `environmentProvisioning`) likewise live on {@link RunAdmission} (and the controllers).
111
113
  prMerger;
112
114
  notifications;
113
115
  riskPolicyRepository;
@@ -115,18 +117,7 @@ export class ExecutionService {
115
117
  issueWriteback;
116
118
  subscriptionActivations;
117
119
  pokeInitiativeLoop;
118
- resolveProviderCapabilities;
119
- inlineHarnessRef;
120
120
  resolveWorkspaceModelDefault;
121
- /** Start-time assertion that a container-agent backend is configured (local-mode pool). */
122
- assertAgentBackendConfigured;
123
- /**
124
- * Resolves the per-account binary-artifact store, used by the start gate to refuse a
125
- * pipeline carrying a {@link BINARY_STORAGE_TRAIT} kind (e.g. the UI Tester) when the
126
- * account has no storage configured. Absent (tests/conformance with no store wired) ⇒
127
- * the gate passes through, like the other optional start guards.
128
- */
129
- resolveBinaryArtifactStore;
130
121
  /**
131
122
  * The per-step dispatch + completion spine (the four registries, the completion hub, the
132
123
  * gate machinery, the deterministic deployer/tracker steps, the pre/post-op cluster, the
@@ -167,11 +158,6 @@ export class ExecutionService {
167
158
  this.events = executionEventPublisher;
168
159
  this.board = boardService;
169
160
  this.spend = spendService;
170
- this.requirementReviewService = requirementReviewService;
171
- this.docInterviewService = docInterviewService;
172
- this.clarityReviewService = clarityReviewService;
173
- this.brainstormServices = brainstormServices;
174
- this.environmentProvisioning = environmentProvisioning;
175
161
  this.contextBuilder = new AgentContextBuilder({
176
162
  workspaceRepository,
177
163
  blockRepository,
@@ -191,6 +177,23 @@ export class ExecutionService {
191
177
  fragmentResolver,
192
178
  skillResolver,
193
179
  });
180
+ // The run-admission preflights (the shared start/retry/restart `assert*` gate family).
181
+ // The admission-only seams are forwarded here rather than stored on the engine.
182
+ this.admission = new RunAdmission({
183
+ workspaceRepository,
184
+ blockRepository,
185
+ executionRepository,
186
+ contextBuilder: this.contextBuilder,
187
+ agentKindRegistry,
188
+ spend: spendService,
189
+ environmentProvisioning,
190
+ workspaceSettingsService,
191
+ resolveBinaryArtifactStore,
192
+ resolveProviderCapabilities,
193
+ inlineHarnessRef,
194
+ resolveWorkspaceModelDefault,
195
+ assertAgentBackendConfigured,
196
+ });
194
197
  this.mergeResolver = new MergeResolver({
195
198
  blockRepository,
196
199
  notificationService,
@@ -317,10 +320,22 @@ export class ExecutionService {
317
320
  clock,
318
321
  notificationService,
319
322
  });
320
- this.requirementsKind = this.buildRequirementsKind();
321
- this.clarityKind = this.buildClarityKind();
322
- this.requirementsBrainstormKind = this.buildBrainstormKind('requirements', REQUIREMENTS_BRAINSTORM_AGENT_KIND);
323
- this.architectureBrainstormKind = this.buildBrainstormKind('architecture', ARCHITECTURE_BRAINSTORM_AGENT_KIND);
323
+ // The review-gate subjects (requirements / clarity / the two brainstorm stages), built by
324
+ // the factories in review-kinds.ts over one shared closure of collaborators — each subject's
325
+ // differentiators live there, not on the engine.
326
+ const reviewKindDeps = {
327
+ events: executionEventPublisher,
328
+ blockRepository,
329
+ executionRepository,
330
+ requirementReviewService,
331
+ clarityReviewService,
332
+ brainstormServices,
333
+ issueWriteback,
334
+ };
335
+ this.requirementsKind = buildRequirementsKind(reviewKindDeps);
336
+ this.clarityKind = buildClarityKind(reviewKindDeps);
337
+ this.requirementsBrainstormKind = buildBrainstormKind('requirements', REQUIREMENTS_BRAINSTORM_AGENT_KIND, reviewKindDeps);
338
+ this.architectureBrainstormKind = buildBrainstormKind('architecture', ARCHITECTURE_BRAINSTORM_AGENT_KIND, reviewKindDeps);
324
339
  // The interactive-planning interviewer gate — wired whenever the initiative store is
325
340
  // present (the entity is where its state lives). The interviewer LLM is optional: without
326
341
  // it (or without a model) the gate passes through, so planning still runs off the raw
@@ -399,7 +414,7 @@ export class ExecutionService {
399
414
  resolveRunRepoContext,
400
415
  resolveProviderCapabilities,
401
416
  resolveRiskPolicy: (ws, block) => this.resolveRiskPolicy(ws, block),
402
- modelIdIsMetered: (id, caps) => this.modelIdIsMetered(id, caps),
417
+ modelIdIsMetered: (id, caps) => this.admission.modelIdIsMetered(id, caps),
403
418
  });
404
419
  // Group the per-feature gate-window actions into cohesive sub-facades (exposed as
405
420
  // getters below) so they stop bloating the engine's public surface as ~30 near-identical
@@ -407,7 +422,6 @@ export class ExecutionService {
407
422
  this.requirementsReviewActions = new RequirementReviewActions(this.reviewGate, this.requirementsKind);
408
423
  this.clarityReviewActions = new ClarityReviewActions(this.reviewGate, this.clarityKind);
409
424
  this.brainstormActions = new BrainstormActions(this.reviewGate, (stage) => this.brainstormKindFor(stage));
410
- this.workspaceSettingsService = workspaceSettingsService;
411
425
  this.prMerger = pullRequestMerger;
412
426
  this.notifications = notificationService;
413
427
  this.riskPolicyRepository = riskPolicyRepository;
@@ -416,10 +430,6 @@ export class ExecutionService {
416
430
  this.subscriptionActivations = subscriptionActivationRepository;
417
431
  this.pokeInitiativeLoop = pokeInitiativeLoop;
418
432
  this.resolveWorkspaceModelDefault = resolveWorkspaceModelDefault;
419
- this.resolveProviderCapabilities = resolveProviderCapabilities;
420
- this.inlineHarnessRef = inlineHarnessRef;
421
- this.assertAgentBackendConfigured = assertAgentBackendConfigured;
422
- this.resolveBinaryArtifactStore = resolveBinaryArtifactStore;
423
433
  }
424
434
  // ---- gate-window action sub-facades -------------------------------------
425
435
  // Per-feature groupings of the dedicated review/test window actions, consumed by the
@@ -503,428 +513,6 @@ export class ExecutionService {
503
513
  const resolveDefault = this.resolveWorkspaceModelDefault;
504
514
  return resolveIndividualVendors(blockModelId, agentKinds, resolveDefault ? (kind) => resolveDefault(workspaceId, kind, modelPresetId) : undefined, hasPersonalSubscription);
505
515
  }
506
- /**
507
- * Guard a run start when the pipeline carries a VISUAL step (`tester-ui` /
508
- * `visual-confirmation`): such a step exercises a rendered UI, so it only makes sense where
509
- * there is a UI to drive — a `type: 'frontend'` frame (it owns the app under test) or a frame
510
- * a `frontend` frame links to (the linked frontend is the UI a change to that service is
511
- * validated through). On any other frame (a service with no linked frontend, a `library` /
512
- * `document` repo) a `tester-ui` step would have nothing to drive, so refuse the start with an
513
- * actionable {@link ConflictError} (`visual_pipeline_no_frontend`). The frontend surfaces the
514
- * SAME rule (via the shared `frameAllowsVisualPipeline`) so it only offers these pipelines
515
- * where they can run; this is the server-side guarantee. A non-visual pipeline passes through.
516
- * The workspace block list is read ONCE (for the frontend→service links), never per-frame.
517
- */
518
- async assertPipelineFrameTypeAllowed(workspaceId, block, agentKinds) {
519
- if (!pipelineHasVisualStep({ agentKinds: [...agentKinds] }))
520
- return;
521
- const frame = await this.contextBuilder.resolveServiceFrame(workspaceId, block.id);
522
- // A `frontend` frame is always allowed without listing the workspace; only a non-frontend
523
- // frame needs the link scan, so defer the (single) block-list read until then.
524
- if (frame?.type === 'frontend')
525
- return;
526
- const blocks = await this.blockRepository.listByWorkspace(workspaceId);
527
- if (frameAllowsVisualPipeline(frame, blocks))
528
- return;
529
- throw new ConflictError('This pipeline includes a UI-testing step, so it can only run on a frontend service (or a ' +
530
- 'backend service that has a frontend linked to it). Move the task under a frontend, link ' +
531
- 'a frontend to this service, or pick a pipeline without UI-testing steps.', 'visual_pipeline_no_frontend', { frameType: frame?.type ?? null });
532
- }
533
- /**
534
- * Guard a Tester pipeline's start on the service frame's declared provisioning being runnable.
535
- * The Tester needs SOME way to stand its system up: `infraless` (or none declared) runs with no
536
- * infra; `docker-compose`/`kubernetes`/`custom` are all provisioned by the single Deployer step
537
- * through a workspace handler, so one must resolve for the service's type. A `frontend` frame is
538
- * gated instead on having a live service under test. Throws an actionable {@link ConflictError}
539
- * (`tester_infra_unsupported` for the frontend case, `provision_type_unhandled` for a missing
540
- * handler); passes through when the provisioning seam is unwired (tests / no environment
541
- * integration), like the other optional start guards. `initiatedBy` is threaded into
542
- * `canProvision` so the run initiator's local per-user handler OVERRIDES resolve exactly as they
543
- * do at provision time (and as the Deployer-config gate does) — else a valid override-only local
544
- * setup would be falsely refused here while the deployer would actually provision it.
545
- */
546
- async assertTesterInfraConfigured(workspaceId, block, initiatedBy) {
547
- // A `frontend` frame (the self-contained UI-test flow) is gated on having a live service
548
- // under test, NOT on a provision type — resolved first and short-circuiting the backend
549
- // branch. Only enforce it when the environment seam is wired (else, like the other optional
550
- // start guards, pass through so tests / no-env deployments run unchanged).
551
- const frontend = await this.contextBuilder.resolveFrontendConfig(workspaceId, block);
552
- if (frontend) {
553
- if (!this.environmentProvisioning)
554
- return;
555
- const decision = decideTesterInfra({
556
- frontend: {
557
- hasServiceBindings: hasServiceBinding(frontend.config),
558
- hasLiveService: hasLiveServiceBinding(frontend.bindings),
559
- },
560
- provisionType: undefined,
561
- handlerResolves: true,
562
- });
563
- if (decision.ok)
564
- return;
565
- throw new ConflictError(TESTER_INFRA_MESSAGES[decision.reason], 'tester_infra_unsupported', {
566
- infraReason: decision.reason,
567
- });
568
- }
569
- const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
570
- // A `library` frame (not `liveTestable`) runs its unit/integration suite IN-CONTAINER — any
571
- // repo-local docker-compose is test infra stood up on localhost, never a Deployer-provisioned
572
- // env — so the tester never needs a workspace handler. Pass through regardless of provisioning.
573
- if (service?.type && !frameProfile(service.type).liveTestable)
574
- return;
575
- const provisioning = service?.provisioning;
576
- // `docker-compose`/`kubernetes`/`custom` are all provisioned by the Deployer via a workspace
577
- // handler, so resolve it lazily — and only when the provisioning seam is wired (else pass
578
- // through, treating it as resolvable). `infraless`/none needs no handler.
579
- const needsHandler = provisioning?.type === 'docker-compose' ||
580
- provisioning?.type === 'kubernetes' ||
581
- provisioning?.type === 'custom';
582
- const handlerResolves = needsHandler && this.environmentProvisioning
583
- ? (await this.environmentProvisioning.canProvision(workspaceId, provisioning, initiatedBy))
584
- .ok
585
- : true;
586
- const decision = decideTesterInfra({ provisionType: provisioning?.type, handlerResolves });
587
- if (decision.ok)
588
- return;
589
- // The only backend-branch refusal is a provision type with no resolvable handler.
590
- throw new ConflictError(TESTER_INFRA_MESSAGES[decision.reason], 'provision_type_unhandled', {
591
- provisionType: provisioning.type,
592
- });
593
- }
594
- /**
595
- * Fail fast when a `docker-compose`/`kubernetes`/`custom` service's chain would dead-end at an
596
- * env-consumer (tester / human-test / playwright) because no enabled `deployer` provisions the
597
- * environment before it — the exact silent dead-end this initiative fixes (the tester picks
598
- * ephemeral mode from the provision type but finds no coordinates). The pure ordering check lives
599
- * in {@link needsDeployerBeforeConsumer}; here we resolve the service's provision type (only when a
600
- * consumer is present, so consumer-less chains skip the read) and translate a positive verdict
601
- * into an actionable {@link ConflictError}. Pass-through for infraless/frontend services and for
602
- * chains with a deployer before the first consumer.
603
- */
604
- async assertDeployerBeforeConsumer(workspaceId, block, agentKinds, enabled) {
605
- const hasConsumer = agentKinds.some((kind, i) => enabled?.[i] !== false && ENV_CONSUMER_KINDS.includes(kind));
606
- if (!hasConsumer)
607
- return;
608
- const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
609
- // A `library` frame stands nothing up via the Deployer (its tester runs the suite in-container),
610
- // so a missing Deployer before the tester is never a dead-end — pass through like `infraless`.
611
- if (service?.type && !frameProfile(service.type).deployable)
612
- return;
613
- if (!needsDeployerBeforeConsumer(agentKinds, enabled, service?.provisioning?.type))
614
- return;
615
- throw new ConflictError(`This service provisions a '${service.provisioning.type}' environment, but this pipeline ` +
616
- 'has no Deployer step before its first Tester / human-test step, so the environment would ' +
617
- 'never be stood up. Reseed this pipeline to the latest built-in (which includes a Deployer) ' +
618
- 'and start a new run, or set the service to docker-compose / infraless.', 'deployer_required_before_tester', { provisionType: service.provisioning.type });
619
- }
620
- /**
621
- * Guard a pipeline that INCLUDES an enabled `deployer` step on its ephemeral-environment config
622
- * being FULL + CORRECT on BOTH sides of the "what/where ÷ how" split, so a misconfigured
623
- * environment fails LOUDLY at start with a fixable pointer instead of mid-run: a `kubernetes` /
624
- * `custom` service silently failing its async provision, or a `docker-compose` one whose deployer
625
- * NO-OPS because no handler resolves (the exact silent dead-ends this initiative closes). It
626
- * checks, in order of the fix a human would make:
627
- * 1. the SERVICE's provisioning config is complete for its declared type (manifest source /
628
- * compose path / custom-manifest id) — else `deployer_service_provisioning_incomplete`;
629
- * 2. a WORKSPACE handler resolves for the type — else `provision_type_unhandled` (the same
630
- * reason the Tester gate raises; a MISSING/ambiguous handler, not a broken one);
631
- * 3. (bonus, best-effort) the resolved deployment integration's live connection PROBES green —
632
- * else `deployer_connection_test_failed`, carrying the provider's failure detail.
633
- * Each `ConflictError` carries a machine-readable reason + details the SPA deep-links off to the
634
- * exact fix surface. Pass-through for `infraless`/undeclared services (the deployer stands nothing
635
- * up) and when the environment seam is unwired (tests / no-env deployments), like the other
636
- * optional start guards.
637
- */
638
- async assertDeployerConfigured(workspaceId, block, agentKinds, enabled, initiatedBy) {
639
- if (!this.environmentProvisioning)
640
- return;
641
- if (!hasEnabledDeployerStep(agentKinds, enabled))
642
- return;
643
- const service = await this.contextBuilder.resolveServiceConfig(workspaceId, block);
644
- // A Deployer on a `library` frame (not `deployable`) is a safe no-op regardless of any declared
645
- // provisioning — the runtime deploy loop records a library skip — so there is nothing to
646
- // validate. Checked BEFORE the provisioning branch, since a library may declare a compose path
647
- // as repo-local TEST infra (not a deployable env).
648
- if (service?.type && !frameProfile(service.type).deployable)
649
- return;
650
- const provisioning = service?.provisioning;
651
- // A Deployer on an `infraless`/undeclared service is a safe no-op (nothing to provision), so
652
- // there is nothing to validate — matching the deployer's own skip in advanceDeployerFrames.
653
- if (!provisioning || provisioning.type === 'infraless')
654
- return;
655
- const type = provisioning.type;
656
- const serviceIssues = deployerServiceConfigIssues(provisioning);
657
- // `canProvision` is a single batched handler read (no decrypt / no N+1); safe to run eagerly.
658
- // Pass the initiator so a local per-user handler override resolves exactly as provisioning does
659
- // (else a valid override-only local setup would be falsely reported as unhandled).
660
- const handlerResolution = await this.environmentProvisioning.canProvision(workspaceId, provisioning, initiatedBy);
661
- // Only probe the LIVE connection when the structural config is sound — a network probe is
662
- // wasted (and its verdict misleading) while the service config is incomplete or no handler
663
- // resolves. A probe FAULT (transient network / provider-build hiccup) is not a definitive
664
- // "connection broken" verdict, so swallow it and let the async provision surface a real fault
665
- // rather than blocking the start on a flake.
666
- let connectionTest;
667
- if (serviceIssues.length === 0 && handlerResolution.ok) {
668
- try {
669
- connectionTest =
670
- (await this.environmentProvisioning.testProvisioning(workspaceId, provisioning, initiatedBy)) ?? undefined;
671
- }
672
- catch {
673
- connectionTest = undefined;
674
- }
675
- }
676
- const decision = decideDeployerConfig({
677
- provisionType: type,
678
- serviceIssues,
679
- handlerResolution,
680
- ...(connectionTest ? { connectionTest } : {}),
681
- });
682
- if (decision.ok)
683
- return;
684
- if (decision.reason === 'service-config-incomplete') {
685
- // Deep-link target: the service FRAME's environment config (the inspector / compose wizard).
686
- const frameId = block.level === 'frame'
687
- ? block.id
688
- : ((await this.contextBuilder.resolveServiceFrameId(workspaceId, block.id)) ?? undefined);
689
- throw new ConflictError(`This service provisions a '${type}' environment via the Deployer, but its environment ` +
690
- `configuration is incomplete (missing: ${decision.missing.join(', ')}). Complete the ` +
691
- "service's environment configuration before starting.", 'deployer_service_provisioning_incomplete', { provisionType: type, missing: [...decision.missing], ...(frameId ? { frameId } : {}) });
692
- }
693
- if (decision.reason === 'workspace-unhandled') {
694
- throw new ConflictError(`This service provisions a '${type}' environment via the Deployer, but this workspace has ` +
695
- (decision.handlerReason === 'type-mismatch'
696
- ? `more than one handler matching it — pin a manifest id to disambiguate, `
697
- : `no infrastructure handler configured for that type — `) +
698
- 'configure a handler (Infrastructure → Test environments), or set the service to ' +
699
- 'infraless, before starting.', 'provision_type_unhandled', { provisionType: type });
700
- }
701
- // decision.reason === 'connection-failed'
702
- throw new ConflictError(`The '${type}' deployment integration for this service isn't working: ` +
703
- `${decision.message ?? 'the connection test failed'}. Check the handler's endpoint and ` +
704
- 'credentials (Infrastructure → Test environments) and re-test the connection, then start ' +
705
- 'again.', 'deployer_connection_test_failed', { provisionType: type, ...(decision.message ? { detail: decision.message } : {}) });
706
- }
707
- /**
708
- * Guard a pipeline's start when it carries an agent kind that RELIES on binary-artifact
709
- * storage (the {@link BINARY_STORAGE_TRAIT}, e.g. the UI Tester, which uploads its
710
- * screenshots there). Such a run would otherwise dispatch and then fail/degrade with no
711
- * place to store its artifacts, so refuse it up-front with a clear, actionable
712
- * `binary_storage_unconfigured` conflict the SPA turns into a "configure storage" prompt.
713
- * The check is trait-driven so it stays universal: a future artifact-producing kind just
714
- * carries the trait. Pass-through when no store resolver is wired (tests/conformance with
715
- * no storage) — matching the other optional start guards.
716
- */
717
- async assertBinaryStorageConfigured(workspaceId, agentKinds) {
718
- if (!agentKinds.some((kind) => hasTrait(kind, BINARY_STORAGE_TRAIT, this.agentKindRegistry)))
719
- return;
720
- const resolve = this.resolveBinaryArtifactStore;
721
- if (!resolve)
722
- return;
723
- const store = await resolve(workspaceId);
724
- if (store)
725
- return;
726
- throw new ConflictError('This pipeline includes an agent that needs binary storage (e.g. the UI Tester, which uploads its screenshots), but this account has no content storage configured. Configure content storage to run it.', 'binary_storage_unconfigured');
727
- }
728
- /**
729
- * Guard a pipeline's start on having a usable provider for every step's canonical
730
- * model. The model a step runs is resolved by the same precedence the dispatch path
731
- * uses (block pin → workspace per-kind default); each canonical id must have a usable
732
- * provider given what's configured — a direct API key for its provider, a connected
733
- * subscription vendor, or the opt-in Cloudflare lib enabled. Env-routing defaults (the
734
- * last fallback, with no catalog id) are operator-level and not gated, matching the
735
- * personal-credential gate. A throw aborts the start cleanly before any side effects.
736
- * Skipped when no capability resolver is wired (tests / unconfigured facades).
737
- */
738
- async assertProvidersConfiguredForPipeline(workspaceId, block, agentKinds, initiatedBy) {
739
- if (!this.resolveProviderCapabilities)
740
- return;
741
- const caps = await this.resolveProviderCapabilities(workspaceId, initiatedBy);
742
- const runsInline = this.inlineHarnessRef;
743
- // Two failure buckets, so the error can steer the fix precisely:
744
- // - `unconfigured`: no usable provider AT ALL (container or inline) — add a key/sub/CF.
745
- // - `inlineUnsatisfiable`: usable for a container step but NOT for an INLINE step — a
746
- // subscription-only model an inline `generateText` call can't drive (and this
747
- // deployment can't run the harness inline). The remedy is different (pin an
748
- // inline-capable model, or a preset whose inline steps resolve to one), so a subscription
749
- // model that satisfies the container steps but strands the reviewer/brainstorm/estimator
750
- // is refused up front instead of failing mid-run against an ungated env default.
751
- // - `policyBlocked`: the account-wide model-family policy blocks the model on its
752
- // effective route — a distinct, more actionable reason than "unconfigured", so it is
753
- // checked FIRST and short-circuits the other buckets for that id.
754
- const unconfigured = new Set();
755
- const inlineUnsatisfiable = new Set();
756
- const policyBlocked = new Set();
757
- const check = (id, inline) => {
758
- if (!id)
759
- return;
760
- if (caps.modelPolicy &&
761
- !isAllowedByFamilyPolicy(id, resolveModelRef(id, caps)?.provider, caps.modelPolicy)) {
762
- policyBlocked.add(id);
763
- return;
764
- }
765
- if (!isModelUsable(id, caps))
766
- unconfigured.add(id);
767
- else if (inline && !isModelUsableInline(id, caps, runsInline))
768
- inlineUnsatisfiable.add(id);
769
- };
770
- if (block.modelId) {
771
- // A block-level pin applies to every step; it must satisfy an inline step too when the
772
- // pipeline has one.
773
- check(block.modelId, agentKinds.some((kind) => isInlineModelStep(kind, this.agentKindRegistry)));
774
- }
775
- else if (this.resolveWorkspaceModelDefault) {
776
- // Independent per-kind resolutions on the start path — run them concurrently.
777
- const ids = await Promise.all(agentKinds.map((kind) => this.resolveWorkspaceModelDefault(workspaceId, kind, block.modelPresetId)));
778
- agentKinds.forEach((kind, i) => check(ids[i], isInlineModelStep(kind, this.agentKindRegistry)));
779
- }
780
- if (policyBlocked.size > 0) {
781
- throw new ConflictError(`This pipeline uses models blocked by the account's model-family policy: ` +
782
- `${[...policyBlocked].join(', ')}. Pick a model from an allowed family (or a ` +
783
- 'residency-guaranteed route), or ask an account admin to adjust the policy.', 'model_policy_blocked', { models: [...policyBlocked] });
784
- }
785
- if (unconfigured.size > 0) {
786
- throw new ConflictError(`This pipeline uses models with no configured provider: ${[...unconfigured].join(', ')}. ` +
787
- 'Add an API key for the provider, connect a subscription, or enable Cloudflare AI ' +
788
- 'before starting.', 'providers_unconfigured', { models: [...unconfigured] });
789
- }
790
- if (inlineUnsatisfiable.size > 0) {
791
- throw new ConflictError(`This pipeline has inline steps (e.g. the requirements reviewer) whose model ` +
792
- `cannot run inline: ${[...inlineUnsatisfiable].join(', ')}. A subscription-only model ` +
793
- '(Claude / GPT / GLM) runs only in the container agents, not the inline reviewers — ' +
794
- 'and this deployment has no inline harness. Pick a model preset whose inline steps ' +
795
- 'resolve to a provider-backed model (a direct API key, OpenRouter, or Cloudflare AI), ' +
796
- 'or run local mode with the ambient Claude Code / Codex CLI enabled.', 'preset_unsatisfiable', { models: [...inlineUnsatisfiable] });
797
- }
798
- }
799
- /**
800
- * Refuse to START / RETRY a run when the workspace has reached its spend budget AND the
801
- * pipeline has at least one budget-METERED step. A `0` (or exhausted) budget is a
802
- * deliberate "no paid spend" setting, but it must surface as a clear, up-front error here
803
- * rather than a silent mid-run pause. Steps that incur no metered cost — a connected
804
- * subscription model, or a keyless local-runner model — are exempt, so a workspace that
805
- * runs ONLY local/subscription models starts normally even at a `0` budget. Best-effort:
806
- * with no capability resolver wired (tests/unconfigured) it is skipped and the mid-run
807
- * gate still guards. Before any side effects, matching the other start guards.
808
- */
809
- async assertBudgetAllowsPipeline(workspaceId, block, agentKinds, initiatedBy) {
810
- const accountId = await this.workspaceRepository.accountOf(workspaceId);
811
- if (!(await this.spend.isOverBudget(workspaceId, { accountId, userId: initiatedBy })))
812
- return;
813
- if (!this.resolveProviderCapabilities)
814
- return;
815
- const caps = await this.resolveProviderCapabilities(workspaceId, initiatedBy);
816
- const ids = [];
817
- if (block.modelId) {
818
- ids.push(block.modelId);
819
- }
820
- else if (this.resolveWorkspaceModelDefault) {
821
- ids.push(...(await Promise.all(agentKinds.map((kind) => this.resolveWorkspaceModelDefault(workspaceId, kind, block.modelPresetId)))));
822
- }
823
- else {
824
- ids.push(undefined);
825
- }
826
- if (!ids.some((id) => this.modelIdIsMetered(id, caps)))
827
- return;
828
- throw new ConflictError('This run has reached a spend budget (workspace, account, or user). New runs on metered ' +
829
- 'models are paused until the budget is raised or the billing period resets. A task pinned ' +
830
- 'to a local model or a connected subscription still runs.');
831
- }
832
- /**
833
- * Whether a model id will incur metered monetary cost for THIS workspace. Non-metered:
834
- * a subscription model whose vendor is connected ("subscriptions always win"), or a
835
- * local-runner model (keyless, on the user's own endpoint). Everything else — including
836
- * env-default routing (an absent id) and Cloudflare Workers AI — is treated as metered.
837
- */
838
- modelIdIsMetered(id, caps) {
839
- const sub = subscriptionOptionFor(id);
840
- if (sub && caps.subscriptionVendors.has(sub.vendor))
841
- return false;
842
- const ref = resolveModelRef(id, caps);
843
- if (!ref)
844
- return true;
845
- if (ref.harness === 'claude-code' || ref.harness === 'codex')
846
- return false;
847
- return !isLocalRunner(ref.provider);
848
- }
849
- /**
850
- * The config/resource preconditions a run must satisfy to START, RETRY **or** RESTART:
851
- * everything that depends on the workspace environment + the steps being run, and NOT on
852
- * whether this is a fresh run or a replacement. All three entry points call this so they
853
- * can't drift — a guard added to one but silently missing from the other is exactly how a
854
- * subscription-only preset slipped past retry and failed mid-run against the routing default.
855
- * All checks are read-only and run BEFORE any side effects, each throwing an actionable
856
- * {@link ConflictError}.
857
- *
858
- * The `shape` is the effective chain that will run, NOT the current pipeline definition: a
859
- * fresh start passes the pipeline, while a retry/restart passes the STORED steps (via
860
- * {@link runnableShapeOf}) so the guard validates exactly what re-executes — a pipeline
861
- * edited out of band since the run started can't falsely refuse (or silently skip a check
862
- * for) a step that isn't actually being re-driven.
863
- *
864
- * The concurrency (task-limit) and dependency gates are deliberately NOT here — they are
865
- * start-only (a retry replaces the failed run rather than adding a new concurrent one, and a
866
- * re-drive of an already-started task isn't re-gated on its dependencies).
867
- */
868
- async assertRunnable(workspaceId, block, shape, initiatedBy) {
869
- // Reject a structurally-invalid chain (a misplaced companion or estimate-gating without a
870
- // preceding task-estimator). The builder also rejects these at save, but a pipeline can
871
- // become invalid out of band.
872
- validatePipelineShape(shape);
873
- // The Initiative Planning kinds run ONLY on an `initiative`-level block, and an
874
- // initiative block accepts ONLY such a chain — bidirectional, and here in the shared
875
- // guard so start/retry/restart can't drift on it.
876
- assertInitiativeShapeAllowed(block, shape.agentKinds);
877
- // A chain with visual steps (`tester-ui` / `visual-confirmation`) needs a UI to exercise:
878
- // it can only run on a `frontend` frame or a frame a frontend links to — else a `tester-ui`
879
- // step has no app to drive.
880
- await this.assertPipelineFrameTypeAllowed(workspaceId, block, shape.agentKinds);
881
- // A chain with a Tester needs the service's declared provisioning to be runnable
882
- // (`infraless`/none = no infra; `docker-compose`/`kubernetes`/`custom` = a workspace handler).
883
- if (shape.agentKinds.some(isTesterKind)) {
884
- await this.assertTesterInfraConfigured(workspaceId, block, initiatedBy);
885
- }
886
- // A `docker-compose`/`kubernetes`/`custom` service whose enabled chain reaches an env-consumer
887
- // (tester / human-test / playwright) with NO enabled `deployer` before it would dead-end inside
888
- // the consumer — nothing provisions the environment it reads. Fail fast with an actionable error.
889
- await this.assertDeployerBeforeConsumer(workspaceId, block, shape.agentKinds, shape.enabled);
890
- // A chain that INCLUDES an enabled Deployer needs the service's provisioning config (the
891
- // "what/where") AND the workspace's infra handler (the "how") complete + correct — and, best
892
- // effort, the deployment integration's live connection working — so a misconfigured environment
893
- // fails loudly here with a fix-it pointer instead of an async failed env (or a silent no-op).
894
- await this.assertDeployerConfigured(workspaceId, block, shape.agentKinds, shape.enabled, initiatedBy);
895
- // A chain carrying an agent that relies on binary-artifact storage (the UI Tester uploads
896
- // screenshots) needs the account to have storage configured.
897
- await this.assertBinaryStorageConfigured(workspaceId, shape.agentKinds);
898
- // A workspace that delegates container agents to a runner pool needs that pool registered
899
- // (local mode opt-in). No-op on Cloudflare/Node (fixed backend) and when delegation is off.
900
- await this.assertAgentBackendConfigured?.(workspaceId);
901
- // Every step's canonical model must have a usable provider — a container step needs any
902
- // usable flavour, an INLINE step needs an inline-usable one (a subscription-only model can't
903
- // run inline without an inline harness). This is the gate a retry used to skip.
904
- await this.assertProvidersConfiguredForPipeline(workspaceId, block, shape.agentKinds, initiatedBy);
905
- // Refuse a metered run once the spend budget is reached (a clear error rather than a silent
906
- // mid-run pause). A local/subscription-only pipeline is exempt.
907
- await this.assertBudgetAllowsPipeline(workspaceId, block, shape.agentKinds, initiatedBy);
908
- }
909
- /**
910
- * The {@link PipelineShape} a retry/restart re-drives: the stored run's steps ARE the enabled,
911
- * ordered chain that will run again, so {@link assertRunnable} validates exactly what
912
- * re-executes rather than the current pipeline definition (which may have been edited out of
913
- * band since the run started). Disabled steps were already filtered out at start, so every
914
- * stored step is enabled.
915
- */
916
- runnableShapeOf(steps) {
917
- return {
918
- agentKinds: steps.map((s) => s.agentKind),
919
- gating: steps.map((s) => s.gating ?? null),
920
- // The QC companion's live step-state carries the same `gating` config the pipeline set, so
921
- // the tester-QC gating validation re-runs on a retry against exactly what re-executes.
922
- testerQuality: steps.map((s) => s.testerQuality ?? null),
923
- // The per-step options bag (carrying a `skill` step's `skillId`) is copied onto the run
924
- // step at start, so the skill-step validation re-runs on retry against what re-executes.
925
- stepOptions: steps.map((s) => s.stepOptions ?? null),
926
- };
927
- }
928
516
  /** Start a pipeline against a block, replacing any prior run on it. */
929
517
  async start(workspaceId, blockId, pipelineId,
930
518
  /**
@@ -976,7 +564,7 @@ export class ExecutionService {
976
564
  // Shared config/resource preconditions (pipeline shape, frame type, tester infra, binary
977
565
  // storage, agent backend, provider/preset satisfiability, budget) — the SAME gate a retry
978
566
  // runs, so the two can't drift. See assertRunnable.
979
- await this.assertRunnable(workspaceId, block, pipeline, initiatedBy);
567
+ await this.admission.assertRunnable(workspaceId, block, pipeline, initiatedBy);
980
568
  // A Ralph-loop step needs a programmatic completion command (its exit condition); refuse to
981
569
  // start a misconfigured run rather than dispatch a validation-less coding pass that never
982
570
  // gates. The command is a per-task agent-config value (the SPA also requires it at creation).
@@ -990,12 +578,12 @@ export class ExecutionService {
990
578
  // re-gated on its dependencies.
991
579
  // Enforce the workspace's per-service running-task limit (off by default) — a clear,
992
580
  // actionable error before any side effects, so the human knows why the start was refused.
993
- await this.assertWithinTaskLimit(workspaceId, block);
581
+ await this.admission.assertWithinTaskLimit(workspaceId, block);
994
582
  // Hard dependency gate: a task cannot start while any block it `dependsOn` is unfinished
995
583
  // (not yet `done`/merged). Enforced server-side so it holds for manual starts, recurring
996
584
  // fires, auto-start propagation and direct API calls alike — the frontend's runnable
997
585
  // check is only a hint. Before any side effects so nothing is torn down on a refusal.
998
- await this.assertDependenciesMet(workspaceId, block);
586
+ await this.admission.assertDependenciesMet(workspaceId, block);
999
587
  // Mint the activation next: if the credential can't be unlocked, fail before
1000
588
  // tearing down the block's prior run or creating a new one.
1001
589
  const executionId = this.idGenerator.next('exec');
@@ -1150,103 +738,6 @@ export class ExecutionService {
1150
738
  await this.runStateMachine.emitInstance(workspaceId, instance);
1151
739
  return instance;
1152
740
  }
1153
- /**
1154
- * Enforce the workspace's per-service running-task limit before a task run starts.
1155
- * No-ops unless the settings module is wired, the block is a task, and a limit mode
1156
- * is active. Counts the tasks under the same service frame that already have a live
1157
- * run (running / blocked / paused) — bucketed by task type when the mode is
1158
- * `per_type`, else shared across all types — and throws a {@link ConflictError} (→ 409,
1159
- * shown as a toast) when the cap is reached. The starting block is excluded from the
1160
- * count (its prior run is about to be replaced).
1161
- */
1162
- /**
1163
- * Refuse a task start while any of its dependencies is unfinished. A task may only run
1164
- * once every block it `dependsOn` has reached `done` (its PR merged). No-ops for
1165
- * non-task blocks and for tasks with no dependencies. Throws a {@link ConflictError}
1166
- * (→ 409, shown as a toast) naming the unfinished blockers so the human knows why.
1167
- */
1168
- async assertDependenciesMet(workspaceId, block) {
1169
- if (block.level !== 'task' || block.dependsOn.length === 0)
1170
- return;
1171
- const blocks = await this.augmentWithCrossWorkspaceDeps(await this.blockRepository.listByWorkspace(workspaceId), block.dependsOn);
1172
- if (dependenciesMet(blocks, block.id))
1173
- return;
1174
- const blockers = unmetDependencies(blocks, block.id);
1175
- const names = blockers.map((b) => `"${b.title}"`).join(', ');
1176
- throw new ConflictError(`This task is blocked by ${blockers.length} unfinished dependenc${blockers.length === 1 ? 'y' : 'ies'}${names ? ` (${names})` : ''}. Finish them before starting this task.`, 'dependencies_unmet', { count: blockers.length, blockers: blockers.map((b) => b.title) });
1177
- }
1178
- /**
1179
- * Augment a workspace's block list (in place) with any dependency blocks referenced by
1180
- * `depIds` that aren't already present — a `dependsOn` edge can point at a task homed in a
1181
- * DIFFERENT workspace (a shared/mounted service). Resolved via the cross-workspace
1182
- * {@link BlockRepository.findByIds} (one batched query, not a point-read per id), so a
1183
- * shared-service blocker is evaluated by its real status instead of being silently treated
1184
- * as satisfied (missing ⇒ done). Returns the same (now-augmented) array for chaining.
1185
- */
1186
- async augmentWithCrossWorkspaceDeps(blocks, depIds) {
1187
- const have = new Set(blocks.map((b) => b.id));
1188
- const missing = [...new Set(depIds)].filter((id) => !have.has(id));
1189
- if (missing.length === 0)
1190
- return blocks;
1191
- for (const found of await this.blockRepository.findByIds(missing)) {
1192
- blocks.push(found.block);
1193
- }
1194
- return blocks;
1195
- }
1196
- async assertWithinTaskLimit(workspaceId, block) {
1197
- const settingsService = this.workspaceSettingsService;
1198
- if (!settingsService || block.level !== 'task')
1199
- return;
1200
- const settings = await settingsService.get(workspaceId);
1201
- if (settings.taskLimitMode === 'off')
1202
- return;
1203
- const all = await this.blockRepository.listByWorkspace(workspaceId);
1204
- const byId = new Map(all.map((b) => [b.id, b]));
1205
- // Walk up to the owning service frame.
1206
- let frame = block;
1207
- let guard = 0;
1208
- while (frame && frame.level !== 'frame' && guard++ < 1000) {
1209
- frame = frame.parentId ? byId.get(frame.parentId) : undefined;
1210
- }
1211
- if (!frame || frame.level !== 'frame')
1212
- return; // orphan task — nothing to scope a service limit to
1213
- const frameId = frame.id;
1214
- const underFrame = (b) => {
1215
- let cur = b;
1216
- let hops = 0;
1217
- while (cur && hops++ < 1000) {
1218
- if (cur.id === frameId)
1219
- return true;
1220
- cur = cur.parentId ? byId.get(cur.parentId) : undefined;
1221
- }
1222
- return false;
1223
- };
1224
- // Lean projection of the workspace's live runs (block + status only) — avoids loading and
1225
- // JSON-decoding every historical run's `detail` just to read the handful of live block ids.
1226
- const live = await this.executionRepository.listLive(workspaceId);
1227
- const liveBlockIds = new Set(live.map((e) => e.blockId));
1228
- const siblingTasks = all.filter((b) => b.level === 'task' && b.id !== block.id && underFrame(b));
1229
- if (settings.taskLimitMode === 'shared') {
1230
- const limit = settings.taskLimitShared ?? 0;
1231
- const running = siblingTasks.filter((b) => liveBlockIds.has(b.id)).length;
1232
- if (running >= limit) {
1233
- throw new ConflictError(`"${frame.title}" is already running ${running} of ${limit} allowed task(s). ` +
1234
- `Wait for one to finish before starting another.`, 'task_limit_reached', { frame: frame.title, limit, running });
1235
- }
1236
- return;
1237
- }
1238
- // per_type: only the configured types are capped; an unconfigured type is unbounded.
1239
- const type = block.taskType ?? 'feature';
1240
- const perType = (settings.taskLimitPerType ?? {});
1241
- const limit = perType[type];
1242
- if (limit == null)
1243
- return;
1244
- const running = siblingTasks.filter((b) => liveBlockIds.has(b.id) && (b.taskType ?? 'feature') === type).length;
1245
- if (running >= limit) {
1246
- throw new ConflictError(`"${frame.title}" is already running ${running} of ${limit} allowed ${type} task(s). ` +
1247
- `Wait for one to finish before starting another ${type} task.`, 'task_limit_reached', { frame: frame.title, limit, running, taskType: type });
1248
- }
1249
- }
1250
741
  /**
1251
742
  * Advance a single run by exactly one step and report what happened. This is
1252
743
  * the durable driver's entry point: it reloads the run from storage (so it is
@@ -1513,178 +1004,6 @@ export class ExecutionService {
1513
1004
  throw new ConflictError('Resolve the follow-up companion through its window (file / send back / answer / dismiss), not the approval gate');
1514
1005
  }
1515
1006
  }
1516
- /**
1517
- * The requirements subject for {@link reviewGate}: closures over the requirements reviewer
1518
- * service. The service-not-configured guard preserves the exact 409 the inline reviewer
1519
- * raised before this extraction.
1520
- */
1521
- buildRequirementsKind() {
1522
- const require = () => {
1523
- if (!this.requirementReviewService?.enabled) {
1524
- throw new ConflictError('The requirements reviewer is not configured');
1525
- }
1526
- return this.requirementReviewService;
1527
- };
1528
- return {
1529
- agentKind: REQUIREMENTS_REVIEW_AGENT_KIND,
1530
- entityName: 'Requirement review',
1531
- enabled: () => !!this.requirementReviewService?.enabled,
1532
- getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
1533
- review: (ws, block, preset) => require().review(ws, block.id, {
1534
- maxIterations: preset.maxRequirementIterations,
1535
- concernThreshold: preset.maxRequirementConcernAllowed,
1536
- }),
1537
- reReview: (ws, reviewId, preset) => require().reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed }),
1538
- incorporate: async (ws, _blockId, reviewId, feedback) => {
1539
- await require().incorporate(ws, reviewId, { feedback });
1540
- },
1541
- markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
1542
- markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
1543
- markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
1544
- grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
1545
- prepareRecommendations: (ws, reviewId, items) => require().prepareRecommendations(ws, reviewId, items),
1546
- markRecommendationPending: (ws, reviewId, recId, note) => require().markRecommendationPending(ws, reviewId, recId, note),
1547
- fillRecommendations: async (ws, blockId) => {
1548
- const svc = require();
1549
- const review = assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
1550
- await svc.fillPendingRecommendations(ws, review.id, {
1551
- onProgress: (r) => this.events.requirementReviewChanged?.(ws, r) ?? Promise.resolve(),
1552
- });
1553
- return assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
1554
- },
1555
- autoRecommend: async (ws, blockId) => {
1556
- const svc = require();
1557
- const review = assertFound(await svc.getForBlock(ws, blockId), 'Requirement review', blockId);
1558
- await svc.autoRecommend(ws, review.id, {
1559
- onProgress: (r) => this.events.requirementReviewChanged?.(ws, r) ?? Promise.resolve(),
1560
- });
1561
- },
1562
- emit: (ws, review) => this.events.requirementReviewChanged?.(ws, review) ?? Promise.resolve(),
1563
- };
1564
- }
1565
- /**
1566
- * The clarity (bug-report triage) subject for {@link reviewGate}: threads any upstream
1567
- * `bug-investigator` output into the reviewer/incorporation context, otherwise identical to
1568
- * the requirements kind.
1569
- */
1570
- buildClarityKind() {
1571
- const require = () => {
1572
- if (!this.clarityReviewService) {
1573
- throw new ConflictError('The clarity reviewer is not configured');
1574
- }
1575
- return this.clarityReviewService;
1576
- };
1577
- return {
1578
- agentKind: CLARITY_REVIEW_AGENT_KIND,
1579
- // Enabled whenever the clarity STORE is wired — the bug-triage seed/auto-pass path is
1580
- // deterministic (driven by the upstream investigator's structured triage) and needs no
1581
- // reviewer model, so the gate must activate even with no model configured. The LLM
1582
- // review/incorporate/re-review paths still resolve their own model (and degrade gracefully
1583
- // when unwired: no investigation + no model ⇒ the review closure auto-passes).
1584
- entityName: 'Clarity review',
1585
- enabled: () => !!this.clarityReviewService,
1586
- getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
1587
- review: async (ws, block, preset) => {
1588
- const svc = require();
1589
- const structured = await this.structuredInvestigationForBlock(ws, block.id);
1590
- let review;
1591
- if (structured) {
1592
- // An upstream structured `bug-investigator`: seed the gate from its triage — NO reviewer
1593
- // LLM. `clear` → auto-pass; `needs_clarification` → one blocking finding per question.
1594
- // The investigator explicitly asked for clarification, so those questions ALWAYS park
1595
- // for a human — the requirements-review concern tolerance (`maxRequirementConcernAllowed`,
1596
- // which governs the requirements reviewer, not bug triage) must not silently auto-pass
1597
- // them — hence a fixed `none` threshold here rather than the preset's.
1598
- review = await svc.seedReview(ws, block.id, {
1599
- clarity: structured.clarity,
1600
- questions: structured.questions,
1601
- maxIterations: preset.maxRequirementIterations,
1602
- concernThreshold: 'none',
1603
- });
1604
- }
1605
- else if (!svc.enabled) {
1606
- // No structured investigation and no reviewer model: nothing to review against, so
1607
- // auto-pass (equivalent to the old pass-through when the reviewer wasn't configured).
1608
- review = await svc.seedReview(ws, block.id, {
1609
- clarity: 'clear',
1610
- questions: [],
1611
- maxIterations: preset.maxRequirementIterations,
1612
- concernThreshold: preset.maxRequirementConcernAllowed,
1613
- });
1614
- }
1615
- else {
1616
- review = await svc.review(ws, block.id, {
1617
- maxIterations: preset.maxRequirementIterations,
1618
- concernThreshold: preset.maxRequirementConcernAllowed,
1619
- investigation: await this.investigationForBlock(ws, block.id),
1620
- });
1621
- }
1622
- // Whenever the gate parks with open questions — from the deterministic seed OR the LLM
1623
- // reviewer — best-effort echo them onto the linked tracker issue (answers still arrive
1624
- // in-app). A settled/auto-passed review echoes nothing; a tracker outage never fails the run.
1625
- await this.echoClarityQuestions(ws, block.id, review);
1626
- return review;
1627
- },
1628
- reReview: async (ws, reviewId, preset) => {
1629
- const svc = require();
1630
- // No reviewer model wired: a re-review can't run, so settle the loop (converge) instead of
1631
- // throwing — the deterministic seed path can reach a park with no model configured.
1632
- if (!svc.enabled)
1633
- return svc.markIncorporated(ws, reviewId);
1634
- return svc.reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed });
1635
- },
1636
- incorporate: async (ws, blockId, reviewId, feedback) => {
1637
- const svc = require();
1638
- // No reviewer model: can't LLM-fold the answers into a clarified report, so settle the
1639
- // review as-is (the run advances on the raw report + the recorded answers) instead of
1640
- // throwing — keeps the model-free seed path resolvable.
1641
- if (!svc.enabled) {
1642
- await svc.markIncorporated(ws, reviewId);
1643
- return;
1644
- }
1645
- const investigation = await this.investigationForBlock(ws, blockId);
1646
- await svc.incorporate(ws, reviewId, { feedback, investigation });
1647
- },
1648
- markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
1649
- markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
1650
- markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
1651
- grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
1652
- emit: (ws, review) => this.events.clarityReviewChanged?.(ws, review) ?? Promise.resolve(),
1653
- };
1654
- }
1655
- /**
1656
- * A brainstorm (structured-dialogue) subject for {@link reviewGate}, parameterised by stage.
1657
- * Otherwise identical to the requirements kind — the service handles its own upstream context
1658
- * (the architecture stage seeds from the refined requirements). The brainstorm services
1659
- * resolve their model exactly like the requirements reviewer, so the cap knobs are reused.
1660
- */
1661
- buildBrainstormKind(stage, agentKind) {
1662
- const require = () => {
1663
- const svc = this.brainstormServices?.[stage];
1664
- if (!svc?.enabled)
1665
- throw new ConflictError('The brainstorm agent is not configured');
1666
- return svc;
1667
- };
1668
- return {
1669
- agentKind,
1670
- entityName: 'Brainstorm session',
1671
- enabled: () => !!this.brainstormServices?.[stage]?.enabled,
1672
- getForBlock: (ws, blockId) => require().getForBlock(ws, blockId),
1673
- review: (ws, block, preset) => require().review(ws, block.id, {
1674
- maxIterations: preset.maxRequirementIterations,
1675
- concernThreshold: preset.maxRequirementConcernAllowed,
1676
- }),
1677
- reReview: (ws, reviewId, preset) => require().reReview(ws, reviewId, { concernThreshold: preset.maxRequirementConcernAllowed }),
1678
- incorporate: async (ws, _blockId, reviewId, feedback) => {
1679
- await require().incorporate(ws, reviewId, { feedback });
1680
- },
1681
- markIncorporated: (ws, reviewId) => require().markIncorporated(ws, reviewId),
1682
- markReReviewing: (ws, reviewId) => require().markReReviewing(ws, reviewId),
1683
- markIncorporating: (ws, reviewId) => require().markIncorporating(ws, reviewId),
1684
- grantExtraRound: (ws, reviewId) => require().grantExtraRound(ws, reviewId),
1685
- emit: (ws, session) => this.events.brainstormSessionChanged?.(ws, session) ?? Promise.resolve(),
1686
- };
1687
- }
1688
1007
  /** Pick the brainstorm kind for a stage (the dedicated window drives both via the same loop). */
1689
1008
  brainstormKindFor(stage) {
1690
1009
  return stage === 'architecture'
@@ -1812,71 +1131,6 @@ export class ExecutionService {
1812
1131
  });
1813
1132
  return result;
1814
1133
  }
1815
- // ---- clarity-review context helpers (bug-report triage) ------------------
1816
- // The clarity gate triages a block's bug report — optionally enriched by an upstream
1817
- // `bug-investigator` step's prose output — through the SAME {@link ReviewGateController}
1818
- // flow as requirements; these two helpers resolve that investigator output as the triage
1819
- // subject, threaded into the clarity {@link ReviewKind}.
1820
- /** The latest `bug-investigator` step output on a run (the triage subject), or undefined. */
1821
- investigationFor(instance) {
1822
- for (let i = instance.steps.length - 1; i >= 0; i--) {
1823
- const s = instance.steps[i];
1824
- if (s.agentKind === BUG_INVESTIGATOR_AGENT_KIND && s.output)
1825
- return s.output;
1826
- }
1827
- return undefined;
1828
- }
1829
- /** Resolve a block's investigator output via its current execution (off the gate path). */
1830
- async investigationForBlock(workspaceId, blockId) {
1831
- const block = await this.blockRepository.get(workspaceId, blockId);
1832
- if (!block?.executionId)
1833
- return undefined;
1834
- const instance = await this.executionRepository.get(workspaceId, block.executionId);
1835
- return instance ? this.investigationFor(instance) : undefined;
1836
- }
1837
- /**
1838
- * The latest `bug-investigator` step's STRUCTURED triage on a run — its `clarity` verdict +
1839
- * `questions` — parsed leniently from `step.custom`. Drives the clarity gate's seed/auto-pass
1840
- * (see {@link buildClarityKind}): a structured investigator upstream means the gate seeds its
1841
- * findings from `questions` (or auto-passes on `clarity === 'clear'`) instead of running its
1842
- * own reviewer LLM. Undefined when no investigator ran or its result wasn't structured (an
1843
- * older prose investigator, or an unparseable reply) — the gate then falls back to the LLM path.
1844
- */
1845
- structuredInvestigationFor(instance) {
1846
- for (let i = instance.steps.length - 1; i >= 0; i--) {
1847
- const s = instance.steps[i];
1848
- if (s.agentKind !== BUG_INVESTIGATOR_AGENT_KIND || s.custom === undefined)
1849
- continue;
1850
- const parsed = bugInvestigation.safeParse(s.custom);
1851
- if (!parsed)
1852
- return undefined;
1853
- return { clarity: parsed.clarity, questions: parsed.questions };
1854
- }
1855
- return undefined;
1856
- }
1857
- /** Resolve a block's structured investigator triage via its current execution. */
1858
- async structuredInvestigationForBlock(workspaceId, blockId) {
1859
- const block = await this.blockRepository.get(workspaceId, blockId);
1860
- if (!block?.executionId)
1861
- return undefined;
1862
- const instance = await this.executionRepository.get(workspaceId, block.executionId);
1863
- return instance ? this.structuredInvestigationFor(instance) : undefined;
1864
- }
1865
- /**
1866
- * Best-effort echo of a parked clarity review's open questions onto the block's linked tracker
1867
- * issue (see {@link IssueWritebackProvider.postQuestions}). Fires for BOTH the deterministic
1868
- * investigator seed and the LLM reviewer, so identical human-parked states behave the same. A
1869
- * settled/auto-passed review (status `incorporated`) or one with no open items echoes nothing,
1870
- * and a tracker outage never fails the run.
1871
- */
1872
- async echoClarityQuestions(workspaceId, blockId, review) {
1873
- if (!this.issueWriteback || review.status === 'incorporated')
1874
- return;
1875
- const questions = review.items.filter((i) => i.status === 'open').map((i) => i.detail);
1876
- if (questions.length === 0)
1877
- return;
1878
- await this.issueWriteback.postQuestions(workspaceId, blockId, questions).catch(() => { });
1879
- }
1880
1134
  // The clarity / human-testing / visual-confirmation gate-window actions now live on the
1881
1135
  // per-feature sub-facades (`clarityReview` / `humanTest` / `visualConfirm`); see the getters
1882
1136
  // above and {@link gate-window-facades}.
@@ -2050,7 +1304,7 @@ export class ExecutionService {
2050
1304
  return;
2051
1305
  // A dependent's OTHER blockers may live in another workspace (a shared service); resolve
2052
1306
  // them so `dependenciesMet` doesn't treat a cross-workspace blocker as missing-⇒-satisfied.
2053
- await this.augmentWithCrossWorkspaceDeps(blocks, dependents.flatMap((d) => d.dependsOn));
1307
+ await this.admission.augmentWithCrossWorkspaceDeps(blocks, dependents.flatMap((d) => d.dependsOn));
2054
1308
  // Resolve every dependent's pipeline from ONE workspace list, not a per-dependent
2055
1309
  // point-read in the loop (banned N+1): index the catalog by id, and take the board's
2056
1310
  // "Run" default (the first pipeline) for any dependent with no pinned pipeline.
@@ -2394,7 +1648,7 @@ export class ExecutionService {
2394
1648
  // STORED steps (what the retry actually re-drives), not the current pipeline definition, so
2395
1649
  // an out-of-band pipeline edit can't skew the gate and a deleted pipeline needs no special
2396
1650
  // case. Before any side effects.
2397
- await this.assertRunnable(workspaceId, block, this.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
1651
+ await this.admission.assertRunnable(workspaceId, block, this.admission.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
2398
1652
  const { steps, currentStep } = planResumedSteps(previous);
2399
1653
  // Mint the activation before replacing the failed run, so a bad password aborts
2400
1654
  // the retry without losing the retryable terminal run.
@@ -2479,7 +1733,7 @@ export class ExecutionService {
2479
1733
  // so it must be gated identically — otherwise a run whose preset can't run every step (e.g. a
2480
1734
  // subscription-only model an inline reviewer can't drive) strands mid-run instead of being
2481
1735
  // refused up front. Before any teardown/side effects.
2482
- await this.assertRunnable(workspaceId, block, this.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
1736
+ await this.admission.assertRunnable(workspaceId, block, this.admission.runnableShapeOf(previous.steps), initiatedBy ?? previous.initiatedBy);
2483
1737
  // Tear down whatever was driving the run we're about to replace — its per-run
2484
1738
  // container AND its durable driver — before minting the restart. A `done`/`failed`
2485
1739
  // run is already terminal (a no-op teardown), but a still-`running` run would