@cat-factory/orchestration 0.13.0 → 0.14.0

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.
@@ -0,0 +1,147 @@
1
+ import type { AgentExecutor, Block, BlockRepository, BranchUpdater, EnvironmentHandle, ExecutionInstance, ExecutionRepository, PipelineStep, WorkRunner } from '@cat-factory/kernel';
2
+ import type { NotificationService } from '../notifications/NotificationService.js';
3
+ import type { AdvanceResult } from './advance.js';
4
+ import type { AgentContextBuilder } from './AgentContextBuilder.js';
5
+ /**
6
+ * The engine collaborators the human-testing gate drives (kept on the engine, injected here).
7
+ * The environment + branch-update seams are optional — absent ones put the gate into its
8
+ * degraded "manual" mode rather than failing.
9
+ */
10
+ export interface HumanTestControllerDeps {
11
+ blockRepository: BlockRepository;
12
+ executionRepository: ExecutionRepository;
13
+ workRunner: WorkRunner;
14
+ agentExecutor: AgentExecutor;
15
+ contextBuilder: AgentContextBuilder;
16
+ notificationService?: NotificationService;
17
+ /** Provision a fresh ephemeral env for the block (wraps the env provisioning service). */
18
+ provisionEnvironment?: (workspaceId: string, block: Block, executionId: string) => Promise<EnvironmentHandle>;
19
+ /** Re-poll an env's status (wraps the env provisioning service). */
20
+ refreshEnvironment?: (workspaceId: string, environmentId: string) => Promise<EnvironmentHandle>;
21
+ /** Tear an env down (wraps the env teardown service). Best-effort. */
22
+ teardownEnvironment?: (workspaceId: string, environmentId: string) => Promise<void>;
23
+ /** Merge the repo default branch into the block's PR branch (server-side). */
24
+ branchUpdater?: BranchUpdater;
25
+ /** The task's helper attempt budget (from the resolved merge preset). */
26
+ resolveMergePreset: (workspaceId: string, block: Block) => Promise<{
27
+ ciMaxAttempts: number;
28
+ }>;
29
+ parkStepOnDecision: (workspaceId: string, instance: ExecutionInstance, step: PipelineStep, proposal?: string) => Promise<AdvanceResult>;
30
+ finishStep: (step: PipelineStep) => void;
31
+ startStep: (step: PipelineStep) => void;
32
+ updateBlockProgress: (workspaceId: string, instance: ExecutionInstance, status: 'in_progress' | 'blocked') => Promise<void>;
33
+ finalizeBlock: (workspaceId: string, instance: ExecutionInstance, confidence: number | undefined) => Promise<void>;
34
+ stopRunContainer: (workspaceId: string, instance: ExecutionInstance) => Promise<void>;
35
+ persistInstance: (workspaceId: string, instance: ExecutionInstance) => Promise<void>;
36
+ emitInstance: (workspaceId: string, instance: ExecutionInstance) => Promise<void>;
37
+ clockNow: () => number;
38
+ }
39
+ /** The settle outcome of a helper (fixer / conflict-resolver) job, as seen by the gate. */
40
+ type HelperUpdate = {
41
+ state: 'done';
42
+ } | {
43
+ state: 'failed';
44
+ };
45
+ /**
46
+ * Drives the `human-test` gate: a non-LLM engine step where a HUMAN is the verdict. When the
47
+ * step is reached it spins up an ephemeral environment and PARKS, surfacing the live URL; a
48
+ * person validates the change and then drives one of a handful of actions — confirm (tear the
49
+ * env down + advance), request a fix from findings (dispatch the Tester's `fixer`, rebuild the
50
+ * env, re-park), pull main into the branch + redeploy (a clean merge rebuilds the env; a
51
+ * conflict dispatches the `conflict-resolver`), recreate, or destroy the env. Modelled like the
52
+ * iterative review gates: the slow/awaiting work runs in the durable driver (the human actions
53
+ * just record intent + signal), so the HTTP request the user is no longer waiting on never
54
+ * blocks. Extracted out of `ExecutionService`; the shared step-graph primitives stay on the
55
+ * engine and are injected via {@link HumanTestControllerDeps}.
56
+ */
57
+ export declare class HumanTestController {
58
+ private readonly deps;
59
+ constructor(deps: HumanTestControllerDeps);
60
+ /**
61
+ * Run the gate from `stepInstance`. On FRESH entry (no state yet) it provisions an
62
+ * environment and parks (or degrades to manual mode when no provider is wired). On RE-ENTRY
63
+ * after a human action (a `pendingAction` is set on the parked step) it consumes that action.
64
+ * Otherwise (a replay with no pending action) it re-derives from the current phase.
65
+ */
66
+ evaluate(workspaceId: string, instance: ExecutionInstance, step: PipelineStep, block: Block, isFinalStep: boolean): Promise<AdvanceResult>;
67
+ /**
68
+ * Re-poll the in-flight environment provisioning from the durable driver's `awaiting_gate`
69
+ * loop (delegated from `pollGate` when the current step is a human-test gate still
70
+ * provisioning). Ready → park for the human; still provisioning → keep polling; failed →
71
+ * degrade to manual mode and park so the human can recreate or test by hand.
72
+ */
73
+ pollEnvironment(workspaceId: string, instance: ExecutionInstance): Promise<AdvanceResult>;
74
+ /**
75
+ * The provisioning poll budget was spent while still provisioning (delegated from
76
+ * `resolveGatePollExhaustion`). Don't fail the run — park in degraded mode so the human can
77
+ * wait, recreate, or test by hand. The env record keeps provisioning in the background.
78
+ */
79
+ onProvisionTimeout(workspaceId: string, instance: ExecutionInstance): Promise<AdvanceResult>;
80
+ /**
81
+ * A helper job (fixer / conflict-resolver) the gate dispatched has settled (delegated from
82
+ * `pollAgentJob`). Record the round's outcome and rebuild the environment against the
83
+ * (now-updated) branch, then re-park the human. We never fail the whole run here — the human
84
+ * is in control and can request another fix.
85
+ */
86
+ onHelperComplete(workspaceId: string, instance: ExecutionInstance, step: PipelineStep, update: HelperUpdate): Promise<AdvanceResult>;
87
+ /** The human confirmed the change works: tear the env down and advance the run. */
88
+ confirm(workspaceId: string, blockId: string): Promise<ExecutionInstance>;
89
+ /** The human wrote findings and asked for a fix: dispatch the Tester's `fixer`. */
90
+ requestFix(workspaceId: string, blockId: string, findings: string): Promise<ExecutionInstance>;
91
+ /** Pull the repo default branch into the PR branch + redeploy (conflict → conflict-resolver). */
92
+ pullMain(workspaceId: string, blockId: string): Promise<ExecutionInstance>;
93
+ /** Rebuild the ephemeral environment on demand. */
94
+ recreateEnvironment(workspaceId: string, blockId: string): Promise<ExecutionInstance>;
95
+ /**
96
+ * Destroy the ephemeral environment on demand WITHOUT advancing — the run stays parked so the
97
+ * human can recreate it (or confirm/test manually) later. Synchronous: no durable driver
98
+ * involvement, since nothing about the run's position changes.
99
+ */
100
+ destroyEnvironment(workspaceId: string, blockId: string): Promise<ExecutionInstance>;
101
+ /** Fresh entry: stand up an environment (or degrade) and park for the human. */
102
+ private begin;
103
+ /** Consume a human-requested action on re-entry. */
104
+ private handleAction;
105
+ /** Pull main into the PR branch; clean → rebuild env; conflict → conflict-resolver. */
106
+ private pullMainInDriver;
107
+ /**
108
+ * Dispatch a helper container — the Tester's `fixer` (from findings) or the
109
+ * `conflict-resolver` (after a conflicting pull-main) — and park on its job. The gate's
110
+ * phase tracks which helper is in flight; `onHelperComplete` rebuilds the env on its settle.
111
+ */
112
+ private dispatchHelper;
113
+ /** Tear down the current env (best-effort) and provision a fresh one, then re-park. */
114
+ private recreateAndContinue;
115
+ /** Park in degraded (manual) mode: no live env, but the human can still test + confirm. */
116
+ private degrade;
117
+ /** Flip to awaiting-human, summon the human (idempotent notification), and park. */
118
+ private toAwaitingHuman;
119
+ /** Finish the gate step and advance to the next step (or finish the run). No re-signal. */
120
+ private completeStep;
121
+ /**
122
+ * Record the human's action on the parked gate step and wake the durable driver, which
123
+ * re-enters {@link evaluate} and acts on it (the analogue of `incorporateRequirements`).
124
+ * Re-arms the run to `running` first so the woken driver advances instead of no-oping.
125
+ */
126
+ private signalAction;
127
+ /** Locate the run + gate step a block's human-test gate is parked on (or null). */
128
+ private findParked;
129
+ /**
130
+ * Locate the run + gate step for a block's ACTIVE human-test gate — parked for the human OR
131
+ * still provisioning an env (the two phases a human can destroy from). Unlike {@link
132
+ * findParked} it does not require a pending approval, so a provisioning env can be cancelled.
133
+ */
134
+ private findActive;
135
+ private requireParked;
136
+ /** Tear down the env tracked on the step (best-effort) and forget it locally. */
137
+ private teardownCurrent;
138
+ /** Project an environment handle onto the compact view carried on the step. */
139
+ private toEnvView;
140
+ private proposal;
141
+ /** Summon the human to test (idempotent per block+type). Best-effort. */
142
+ private raiseReadyNotification;
143
+ /** Dismiss the "ready for testing" card once the gate passes. Best-effort. */
144
+ private clearReadyNotification;
145
+ }
146
+ export {};
147
+ //# sourceMappingURL=HumanTestController.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HumanTestController.d.ts","sourceRoot":"","sources":["../../../src/modules/execution/HumanTestController.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,493 @@
1
+ import { ConflictError, getErrorMessage, isAsyncAgentExecutor } from '@cat-factory/kernel';
2
+ import { CONFLICT_RESOLVER_AGENT_KIND, FIXER_AGENT_KIND, HUMAN_TEST_AGENT_KIND, } from './ci.logic.js';
3
+ /** Render the human's findings as the resolved-context block handed to the fixer. */
4
+ function renderFindingsForFixer(findings) {
5
+ return [
6
+ 'A human tested the change in a live environment and found the issues below.',
7
+ 'Fix them and push to the PR branch; the environment will be rebuilt for re-testing.',
8
+ '',
9
+ findings.trim(),
10
+ ]
11
+ .join('\n')
12
+ .trim();
13
+ }
14
+ /**
15
+ * Drives the `human-test` gate: a non-LLM engine step where a HUMAN is the verdict. When the
16
+ * step is reached it spins up an ephemeral environment and PARKS, surfacing the live URL; a
17
+ * person validates the change and then drives one of a handful of actions — confirm (tear the
18
+ * env down + advance), request a fix from findings (dispatch the Tester's `fixer`, rebuild the
19
+ * env, re-park), pull main into the branch + redeploy (a clean merge rebuilds the env; a
20
+ * conflict dispatches the `conflict-resolver`), recreate, or destroy the env. Modelled like the
21
+ * iterative review gates: the slow/awaiting work runs in the durable driver (the human actions
22
+ * just record intent + signal), so the HTTP request the user is no longer waiting on never
23
+ * blocks. Extracted out of `ExecutionService`; the shared step-graph primitives stay on the
24
+ * engine and are injected via {@link HumanTestControllerDeps}.
25
+ */
26
+ export class HumanTestController {
27
+ deps;
28
+ constructor(deps) {
29
+ this.deps = deps;
30
+ }
31
+ // ---- driver-entry paths --------------------------------------------------
32
+ /**
33
+ * Run the gate from `stepInstance`. On FRESH entry (no state yet) it provisions an
34
+ * environment and parks (or degrades to manual mode when no provider is wired). On RE-ENTRY
35
+ * after a human action (a `pendingAction` is set on the parked step) it consumes that action.
36
+ * Otherwise (a replay with no pending action) it re-derives from the current phase.
37
+ */
38
+ async evaluate(workspaceId, instance, step, block, isFinalStep) {
39
+ const ht = step.humanTest;
40
+ if (ht?.pendingAction) {
41
+ const action = ht.pendingAction;
42
+ ht.pendingAction = null;
43
+ // Checkpoint the consumed action BEFORE doing any slow/side-effecting work (a helper
44
+ // dispatch is a real container). The driver runs `advance` inside a retriable durable
45
+ // step: if the slow work succeeds but its later persist throws, the closure retries —
46
+ // and unless the cleared `pendingAction` is already in storage it would re-consume the
47
+ // action and dispatch a SECOND helper. Persisting now makes the dispatch at-most-once
48
+ // (a crash between here and the dispatch merely drops the action; the human re-requests).
49
+ await this.deps.persistInstance(workspaceId, instance);
50
+ return this.handleAction(workspaceId, instance, step, block, isFinalStep, action);
51
+ }
52
+ if (!ht)
53
+ return this.begin(workspaceId, instance, step, block);
54
+ // Replay / re-entry with no pending action: re-derive from the phase.
55
+ if (ht.phase === 'provisioning') {
56
+ return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
57
+ }
58
+ // A helper (fixer / conflict-resolver) is in flight: the step is `working` with a live
59
+ // job, NOT parked. Re-attach to its job instead of re-parking, so a re-drive through
60
+ // `advance` (the stale-run sweeper, or a durable replay that lost the `awaiting_job`
61
+ // position) keeps polling the job rather than abandoning it.
62
+ if ((ht.phase === 'fixing' || ht.phase === 'resolving_conflicts') && step.jobId) {
63
+ return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
64
+ }
65
+ return this.deps.parkStepOnDecision(workspaceId, instance, step, this.proposal(ht));
66
+ }
67
+ /**
68
+ * Re-poll the in-flight environment provisioning from the durable driver's `awaiting_gate`
69
+ * loop (delegated from `pollGate` when the current step is a human-test gate still
70
+ * provisioning). Ready → park for the human; still provisioning → keep polling; failed →
71
+ * degrade to manual mode and park so the human can recreate or test by hand.
72
+ */
73
+ async pollEnvironment(workspaceId, instance) {
74
+ const step = instance.steps[instance.currentStep];
75
+ if (!step || step.agentKind !== HUMAN_TEST_AGENT_KIND || !step.humanTest) {
76
+ return { kind: 'continue' };
77
+ }
78
+ const ht = step.humanTest;
79
+ if (ht.phase !== 'provisioning')
80
+ return { kind: 'continue' };
81
+ const block = await this.deps.blockRepository.get(workspaceId, instance.blockId);
82
+ if (!block)
83
+ return { kind: 'noop' };
84
+ if (!this.deps.refreshEnvironment || !ht.environment) {
85
+ return this.degrade(workspaceId, instance, step, block, 'Environment is no longer tracked.');
86
+ }
87
+ let handle;
88
+ try {
89
+ handle = await this.deps.refreshEnvironment(workspaceId, ht.environment.id);
90
+ }
91
+ catch (error) {
92
+ return this.degrade(workspaceId, instance, step, block, `Could not read the environment status (${getErrorMessage(error)}).`);
93
+ }
94
+ ht.environment = this.toEnvView(handle);
95
+ if (handle.status === 'ready') {
96
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
97
+ }
98
+ if (handle.status === 'failed' ||
99
+ handle.status === 'expired' ||
100
+ handle.status === 'torn_down') {
101
+ return this.degrade(workspaceId, instance, step, block, 'Environment provisioning failed; recreate it or test against the PR branch.');
102
+ }
103
+ await this.deps.persistInstance(workspaceId, instance);
104
+ await this.deps.emitInstance(workspaceId, instance);
105
+ return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
106
+ }
107
+ /**
108
+ * The provisioning poll budget was spent while still provisioning (delegated from
109
+ * `resolveGatePollExhaustion`). Don't fail the run — park in degraded mode so the human can
110
+ * wait, recreate, or test by hand. The env record keeps provisioning in the background.
111
+ */
112
+ async onProvisionTimeout(workspaceId, instance) {
113
+ const step = instance.steps[instance.currentStep];
114
+ if (!step || step.agentKind !== HUMAN_TEST_AGENT_KIND || !step.humanTest) {
115
+ return { kind: 'continue' };
116
+ }
117
+ const block = await this.deps.blockRepository.get(workspaceId, instance.blockId);
118
+ if (!block)
119
+ return { kind: 'noop' };
120
+ return this.degrade(workspaceId, instance, step, block, 'Environment is taking longer than expected to provision; recreate it or test against the PR branch.');
121
+ }
122
+ /**
123
+ * A helper job (fixer / conflict-resolver) the gate dispatched has settled (delegated from
124
+ * `pollAgentJob`). Record the round's outcome and rebuild the environment against the
125
+ * (now-updated) branch, then re-park the human. We never fail the whole run here — the human
126
+ * is in control and can request another fix.
127
+ */
128
+ async onHelperComplete(workspaceId, instance, step, update) {
129
+ const ht = step.humanTest;
130
+ if (!ht)
131
+ return { kind: 'continue' };
132
+ const rounds = ht.rounds ?? [];
133
+ const last = rounds[rounds.length - 1];
134
+ if (last && !last.outcome)
135
+ last.outcome = update.state === 'failed' ? 'failed' : 'completed';
136
+ step.jobId = undefined;
137
+ step.subtasks = undefined;
138
+ // Reclaim the finished helper container before reprovisioning so a fresh env build
139
+ // doesn't re-attach to the completed job by run id.
140
+ await this.deps.stopRunContainer(workspaceId, instance);
141
+ const block = await this.deps.blockRepository.get(workspaceId, instance.blockId);
142
+ if (!block)
143
+ return { kind: 'noop' };
144
+ return this.recreateAndContinue(workspaceId, instance, step, block);
145
+ }
146
+ // ---- human actions (called from ExecutionService, driven server-side) ----
147
+ /** The human confirmed the change works: tear the env down and advance the run. */
148
+ async confirm(workspaceId, blockId) {
149
+ return this.signalAction(workspaceId, blockId, { type: 'confirm' });
150
+ }
151
+ /** The human wrote findings and asked for a fix: dispatch the Tester's `fixer`. */
152
+ async requestFix(workspaceId, blockId, findings) {
153
+ return this.signalAction(workspaceId, blockId, { type: 'request-fix', findings });
154
+ }
155
+ /** Pull the repo default branch into the PR branch + redeploy (conflict → conflict-resolver). */
156
+ async pullMain(workspaceId, blockId) {
157
+ return this.signalAction(workspaceId, blockId, { type: 'pull-main' });
158
+ }
159
+ /** Rebuild the ephemeral environment on demand. */
160
+ async recreateEnvironment(workspaceId, blockId) {
161
+ return this.signalAction(workspaceId, blockId, { type: 'recreate' });
162
+ }
163
+ /**
164
+ * Destroy the ephemeral environment on demand WITHOUT advancing — the run stays parked so the
165
+ * human can recreate it (or confirm/test manually) later. Synchronous: no durable driver
166
+ * involvement, since nothing about the run's position changes.
167
+ */
168
+ async destroyEnvironment(workspaceId, blockId) {
169
+ // Destroy is allowed both while parked (awaiting_human) AND while an env is still
170
+ // provisioning — a human must be able to cancel a slow/stuck provision without waiting
171
+ // for the poll budget to exhaust.
172
+ const { instance, step } = this.requireParked(await this.findActive(workspaceId, blockId));
173
+ const ht = step.humanTest;
174
+ await this.teardownCurrent(workspaceId, ht);
175
+ if (ht.phase === 'provisioning') {
176
+ // Cancelled mid-provision: drop the env so the driver's next `pollEnvironment` (which
177
+ // owns the phase transitions during provisioning) hits its `!ht.environment` guard and
178
+ // degrades to manual mode, parking the human. We don't flip the phase here ourselves —
179
+ // the durable poll loop is the single owner of that transition.
180
+ ht.environment = null;
181
+ }
182
+ else if (ht.environment) {
183
+ ht.environment = { ...ht.environment, status: 'torn_down' };
184
+ }
185
+ await this.deps.persistInstance(workspaceId, instance);
186
+ await this.deps.emitInstance(workspaceId, instance);
187
+ return instance;
188
+ }
189
+ // ---- internals -----------------------------------------------------------
190
+ /** Fresh entry: stand up an environment (or degrade) and park for the human. */
191
+ async begin(workspaceId, instance, step, block) {
192
+ const maxAttempts = (await this.deps.resolveMergePreset(workspaceId, block)).ciMaxAttempts;
193
+ step.humanTest = {
194
+ phase: 'provisioning',
195
+ environment: null,
196
+ attempts: 0,
197
+ maxAttempts,
198
+ rounds: [],
199
+ ...(block.pullRequest?.branch ? { headSha: null } : {}),
200
+ };
201
+ if (!this.deps.provisionEnvironment) {
202
+ return this.degrade(workspaceId, instance, step, block, 'No ephemeral-environment provider is configured; test against the PR branch and confirm here.');
203
+ }
204
+ try {
205
+ const handle = await this.deps.provisionEnvironment(workspaceId, block, instance.id);
206
+ step.humanTest.environment = this.toEnvView(handle);
207
+ if (handle.status === 'ready') {
208
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
209
+ }
210
+ await this.deps.persistInstance(workspaceId, instance);
211
+ await this.deps.emitInstance(workspaceId, instance);
212
+ return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
213
+ }
214
+ catch (error) {
215
+ return this.degrade(workspaceId, instance, step, block, `Could not provision an environment (${getErrorMessage(error)}); test against the PR branch and confirm here.`);
216
+ }
217
+ }
218
+ /** Consume a human-requested action on re-entry. */
219
+ async handleAction(workspaceId, instance, step, block, isFinalStep, action) {
220
+ const ht = step.humanTest;
221
+ switch (action.type) {
222
+ case 'confirm': {
223
+ await this.teardownCurrent(workspaceId, ht);
224
+ ht.phase = 'passed';
225
+ if (ht.environment)
226
+ ht.environment = { ...ht.environment, status: 'torn_down' };
227
+ await this.clearReadyNotification(workspaceId, instance.blockId);
228
+ return this.completeStep(workspaceId, instance, step, isFinalStep);
229
+ }
230
+ case 'request-fix':
231
+ return this.dispatchHelper(workspaceId, instance, step, block, 'fix', action.findings ?? '');
232
+ case 'pull-main':
233
+ return this.pullMainInDriver(workspaceId, instance, step, block);
234
+ case 'recreate':
235
+ return this.recreateAndContinue(workspaceId, instance, step, block);
236
+ }
237
+ }
238
+ /** Pull main into the PR branch; clean → rebuild env; conflict → conflict-resolver. */
239
+ async pullMainInDriver(workspaceId, instance, step, block) {
240
+ if (!this.deps.branchUpdater) {
241
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
242
+ }
243
+ let outcome;
244
+ try {
245
+ outcome = await this.deps.branchUpdater.updateFromBase(workspaceId, block.id);
246
+ }
247
+ catch {
248
+ // The branch update failed (e.g. no PR): leave the human parked to retry/confirm.
249
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
250
+ }
251
+ if (outcome === 'conflict') {
252
+ return this.dispatchHelper(workspaceId, instance, step, block, 'pull-main', '');
253
+ }
254
+ // merged / noop → rebuild the env against the updated branch.
255
+ return this.recreateAndContinue(workspaceId, instance, step, block);
256
+ }
257
+ /**
258
+ * Dispatch a helper container — the Tester's `fixer` (from findings) or the
259
+ * `conflict-resolver` (after a conflicting pull-main) — and park on its job. The gate's
260
+ * phase tracks which helper is in flight; `onHelperComplete` rebuilds the env on its settle.
261
+ */
262
+ async dispatchHelper(workspaceId, instance, step, block, roundKind, findings) {
263
+ const ht = step.humanTest;
264
+ const executor = this.deps.agentExecutor;
265
+ if (!isAsyncAgentExecutor(executor)) {
266
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
267
+ }
268
+ // Both helpers push onto the implementation PR branch, so they need one to exist.
269
+ if (!block.pullRequest?.branch) {
270
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
271
+ }
272
+ const helperKind = roundKind === 'fix' ? FIXER_AGENT_KIND : CONFLICT_RESOLVER_AGENT_KIND;
273
+ const isFinalStep = instance.currentStep === instance.steps.length - 1;
274
+ const base = await this.deps.contextBuilder.buildContext(workspaceId, instance, step, isFinalStep, block);
275
+ const context = roundKind === 'fix'
276
+ ? {
277
+ ...base,
278
+ agentKind: helperKind,
279
+ priorOutputs: [
280
+ ...base.priorOutputs,
281
+ { agentKind: HUMAN_TEST_AGENT_KIND, output: renderFindingsForFixer(findings) },
282
+ ],
283
+ }
284
+ : { ...base, agentKind: helperKind };
285
+ const handle = await executor.startJob(context);
286
+ step.jobId = handle.jobId;
287
+ if (handle.model)
288
+ step.model = handle.model;
289
+ step.startingContainer = true;
290
+ step.subtasks = undefined;
291
+ // Leave the parked decision state: while the helper runs the step is `working` with a
292
+ // live job (like the Tester→Fixer loop), NOT `waiting_decision` on a stale approval. If
293
+ // it stayed parked, a re-drive through `advance` (sweeper / replay) would re-park on the
294
+ // old approval id and silently abandon the in-flight helper. The human re-parks on a
295
+ // fresh approval once the helper settles (`onHelperComplete` → `toAwaitingHuman`).
296
+ this.deps.startStep(step);
297
+ step.approval = null;
298
+ ht.phase = roundKind === 'fix' ? 'fixing' : 'resolving_conflicts';
299
+ ht.attempts += 1;
300
+ ht.rounds = [
301
+ ...(ht.rounds ?? []),
302
+ {
303
+ kind: roundKind,
304
+ findings: roundKind === 'fix' ? findings : 'Pulled latest main into the branch (conflicts).',
305
+ helperKind,
306
+ jobId: handle.jobId,
307
+ outcome: null,
308
+ at: this.deps.clockNow(),
309
+ },
310
+ ];
311
+ await this.deps.persistInstance(workspaceId, instance);
312
+ await this.deps.emitInstance(workspaceId, instance);
313
+ return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
314
+ }
315
+ /** Tear down the current env (best-effort) and provision a fresh one, then re-park. */
316
+ async recreateAndContinue(workspaceId, instance, step, block) {
317
+ const ht = step.humanTest;
318
+ await this.teardownCurrent(workspaceId, ht);
319
+ // The old env is gone — drop it immediately so that if the re-provision below fails (or
320
+ // no provider is wired) the gate degrades to a clean manual mode instead of surfacing a
321
+ // stale "ready" env + live URL pointing at the just-destroyed environment. The success
322
+ // path overwrites this with the fresh handle.
323
+ ht.environment = null;
324
+ if (!this.deps.provisionEnvironment) {
325
+ return this.degrade(workspaceId, instance, step, block, 'No ephemeral-environment provider is configured; test against the PR branch and confirm here.');
326
+ }
327
+ try {
328
+ const handle = await this.deps.provisionEnvironment(workspaceId, block, instance.id);
329
+ ht.environment = this.toEnvView(handle);
330
+ ht.degradedReason = null;
331
+ if (handle.status === 'ready') {
332
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
333
+ }
334
+ ht.phase = 'provisioning';
335
+ await this.deps.persistInstance(workspaceId, instance);
336
+ await this.deps.emitInstance(workspaceId, instance);
337
+ return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
338
+ }
339
+ catch (error) {
340
+ return this.degrade(workspaceId, instance, step, block, `Could not provision an environment (${getErrorMessage(error)}); test against the PR branch and confirm here.`);
341
+ }
342
+ }
343
+ /** Park in degraded (manual) mode: no live env, but the human can still test + confirm. */
344
+ async degrade(workspaceId, instance, step, block, reason) {
345
+ const ht = step.humanTest;
346
+ ht.degradedReason = reason;
347
+ return this.toAwaitingHuman(workspaceId, instance, step, block);
348
+ }
349
+ /** Flip to awaiting-human, summon the human (idempotent notification), and park. */
350
+ async toAwaitingHuman(workspaceId, instance, step, block) {
351
+ const ht = step.humanTest;
352
+ ht.phase = 'awaiting_human';
353
+ await this.raiseReadyNotification(workspaceId, instance, block, ht);
354
+ return this.deps.parkStepOnDecision(workspaceId, instance, step, this.proposal(ht));
355
+ }
356
+ /** Finish the gate step and advance to the next step (or finish the run). No re-signal. */
357
+ async completeStep(workspaceId, instance, step, isFinalStep) {
358
+ this.deps.finishStep(step);
359
+ step.progress = 1;
360
+ step.subtasks = undefined;
361
+ step.approval = null;
362
+ if (isFinalStep) {
363
+ instance.status = 'done';
364
+ await this.deps.finalizeBlock(workspaceId, instance, undefined);
365
+ await this.deps.persistInstance(workspaceId, instance);
366
+ await this.deps.emitInstance(workspaceId, instance);
367
+ await this.deps.stopRunContainer(workspaceId, instance);
368
+ return { kind: 'done' };
369
+ }
370
+ instance.currentStep += 1;
371
+ const next = instance.steps[instance.currentStep];
372
+ if (next)
373
+ this.deps.startStep(next);
374
+ await this.deps.updateBlockProgress(workspaceId, instance, 'in_progress');
375
+ await this.deps.persistInstance(workspaceId, instance);
376
+ await this.deps.emitInstance(workspaceId, instance);
377
+ return { kind: 'continue' };
378
+ }
379
+ /**
380
+ * Record the human's action on the parked gate step and wake the durable driver, which
381
+ * re-enters {@link evaluate} and acts on it (the analogue of `incorporateRequirements`).
382
+ * Re-arms the run to `running` first so the woken driver advances instead of no-oping.
383
+ */
384
+ async signalAction(workspaceId, blockId, action) {
385
+ const { instance, step } = this.requireParked(await this.findParked(workspaceId, blockId));
386
+ const ht = step.humanTest;
387
+ // Honour the resolved fix-attempt ceiling (the sibling Tester gate enforces the same
388
+ // `ciMaxAttempts`). The human stays in control of the other actions (confirm / pull main /
389
+ // recreate); only the findings-driven fix loop is capped, so it can't run away.
390
+ if (action.type === 'request-fix' && ht.attempts >= ht.maxAttempts) {
391
+ throw new ConflictError(`This task has reached its fix-attempt limit (${ht.maxAttempts}); confirm the change, pull main, or recreate the environment instead.`);
392
+ }
393
+ ht.pendingAction = action;
394
+ if (instance.status === 'blocked')
395
+ instance.status = 'running';
396
+ await this.deps.persistInstance(workspaceId, instance);
397
+ await this.deps.emitInstance(workspaceId, instance);
398
+ await this.deps.workRunner.signalDecision(workspaceId, instance.id, step.approval.id, 'human-test');
399
+ return instance;
400
+ }
401
+ /** Locate the run + gate step a block's human-test gate is parked on (or null). */
402
+ async findParked(workspaceId, blockId) {
403
+ const block = await this.deps.blockRepository.get(workspaceId, blockId);
404
+ if (!block?.executionId)
405
+ return null;
406
+ const instance = await this.deps.executionRepository.get(workspaceId, block.executionId);
407
+ if (!instance)
408
+ return null;
409
+ const step = instance.steps.find((s) => s.agentKind === HUMAN_TEST_AGENT_KIND &&
410
+ s.state === 'waiting_decision' &&
411
+ s.approval?.status === 'pending');
412
+ return step ? { instance, step } : null;
413
+ }
414
+ /**
415
+ * Locate the run + gate step for a block's ACTIVE human-test gate — parked for the human OR
416
+ * still provisioning an env (the two phases a human can destroy from). Unlike {@link
417
+ * findParked} it does not require a pending approval, so a provisioning env can be cancelled.
418
+ */
419
+ async findActive(workspaceId, blockId) {
420
+ const block = await this.deps.blockRepository.get(workspaceId, blockId);
421
+ if (!block?.executionId)
422
+ return null;
423
+ const instance = await this.deps.executionRepository.get(workspaceId, block.executionId);
424
+ if (!instance)
425
+ return null;
426
+ const step = instance.steps.find((s) => s.agentKind === HUMAN_TEST_AGENT_KIND &&
427
+ (s.humanTest?.phase === 'awaiting_human' || s.humanTest?.phase === 'provisioning'));
428
+ return step ? { instance, step } : null;
429
+ }
430
+ requireParked(found) {
431
+ if (!found)
432
+ throw new ConflictError('No human-test gate is currently awaiting input');
433
+ return found;
434
+ }
435
+ /** Tear down the env tracked on the step (best-effort) and forget it locally. */
436
+ async teardownCurrent(workspaceId, ht) {
437
+ const id = ht.environment?.id;
438
+ if (!id || !this.deps.teardownEnvironment)
439
+ return;
440
+ try {
441
+ await this.deps.teardownEnvironment(workspaceId, id);
442
+ }
443
+ catch {
444
+ // Best-effort: a failing provider must not wedge the gate. The TTL sweep reclaims it.
445
+ }
446
+ }
447
+ /** Project an environment handle onto the compact view carried on the step. */
448
+ toEnvView(handle) {
449
+ return {
450
+ id: handle.id,
451
+ url: handle.url,
452
+ status: handle.status,
453
+ ...(handle.expiresAt != null ? { expiresAt: handle.expiresAt } : {}),
454
+ };
455
+ }
456
+ proposal(ht) {
457
+ if (ht.environment?.url)
458
+ return `Test the change at ${ht.environment.url}, then confirm or request a fix.`;
459
+ return 'Test the change, then confirm or request a fix.';
460
+ }
461
+ /** Summon the human to test (idempotent per block+type). Best-effort. */
462
+ async raiseReadyNotification(workspaceId, instance, block, ht) {
463
+ if (!this.deps.notificationService)
464
+ return;
465
+ const where = ht.environment?.url
466
+ ? `Test it at ${ht.environment.url}.`
467
+ : 'Test it against the PR branch.';
468
+ await this.deps.notificationService.raise(workspaceId, {
469
+ type: 'human_test_ready',
470
+ blockId: block.id,
471
+ executionId: instance.id,
472
+ title: `"${block.title}" is ready for human testing`,
473
+ body: `${where} Confirm it works to continue the pipeline, or request a fix with your findings.`,
474
+ payload: {
475
+ ...(block.pullRequest?.url ? { prUrl: block.pullRequest.url } : {}),
476
+ pipelineName: instance.pipelineName,
477
+ },
478
+ });
479
+ }
480
+ /** Dismiss the "ready for testing" card once the gate passes. Best-effort. */
481
+ async clearReadyNotification(workspaceId, blockId) {
482
+ const svc = this.deps.notificationService;
483
+ if (!svc)
484
+ return;
485
+ const open = await svc.listOpen(workspaceId);
486
+ for (const n of open) {
487
+ if (n.type === 'human_test_ready' && n.blockId === blockId) {
488
+ await svc.resolve(workspaceId, n.id, 'act');
489
+ }
490
+ }
491
+ }
492
+ }
493
+ //# sourceMappingURL=HumanTestController.js.map