lastlight-workflow-engine 0.3.5 → 0.5.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.
@@ -39,6 +39,15 @@ export interface PhaseRunContext {
39
39
  /** Bot identity (was `getBotName()`), used in the generic-loop gate prompt. */
40
40
  botName: string;
41
41
  }
42
+ /**
43
+ * Reject shell commands containing mustache template markers to prevent
44
+ * accidental template injection into until_bash values.
45
+ *
46
+ * Exported so app-registered {@link PhaseTypeHandler}s that run their own gates
47
+ * (the `fanout` handler's per-branch `until_bash`) enforce the identical rule
48
+ * rather than a second copy of it that drifts.
49
+ */
50
+ export declare function validateShellCommand(cmd: string): void;
42
51
  /**
43
52
  * Build the agent prompt for a phase, handling both `prompt:` (template file)
44
53
  * and `skill:`/`skills:` (skill references) phase definitions.
@@ -67,14 +76,22 @@ export declare function isSoftOutcome(r: Pick<ExecutionResult, "success" | "erro
67
76
  * The seams the dedup ledger needs: the runs+executions store, the agent
68
77
  * runtime, container liveness, and telemetry. Threaded from the
69
78
  * {@link PhaseExecutor}'s injected {@link EnginePorts}.
79
+ *
80
+ * `agent` is optional because {@link runLedgeredPhase} never calls it — the
81
+ * caller's `run` callback does the work. It is required by {@link runPhase} /
82
+ * {@link runCommandPhase}, which dispatch through it.
70
83
  */
71
- interface LedgerDeps {
84
+ export interface LedgerDeps {
72
85
  store?: WorkflowStateStore;
73
- agent: AgentPort;
86
+ agent?: AgentPort;
74
87
  liveness: LivenessPort;
75
88
  observability: ObservabilityPort;
76
89
  logger?: LoggerPort;
77
90
  }
91
+ /** {@link LedgerDeps} for the two entry points that DO dispatch through the port. */
92
+ interface AgentLedgerDeps extends LedgerDeps {
93
+ agent: AgentPort;
94
+ }
78
95
  /**
79
96
  * The `owner/repo` a TELEMETRY consumer expects to filter traces by.
80
97
  *
@@ -86,7 +103,7 @@ interface LedgerDeps {
86
103
  * rule is small enough to restate, and the pair is right here on the row.
87
104
  */
88
105
  export declare function telemetryRepo(access: GitSandboxAccess | undefined): string | undefined;
89
- type RunPhaseResult = {
106
+ export type RunPhaseResult = {
90
107
  result: ExecutionResult;
91
108
  executionId: string;
92
109
  skipped: false;
@@ -97,18 +114,48 @@ type RunPhaseResult = {
97
114
  skipped: true;
98
115
  reason: "running" | "done";
99
116
  };
117
+ /**
118
+ * Run a single agent phase with DB-tracked deduplication. Moved verbatim from
119
+ * runner.ts — the dedup ledger (`shouldRunPhase`) is the single source of
120
+ * truth for resume, so re-running from the top skips completed phases here.
121
+ */
122
+ /**
123
+ * The DB-tracked dedup ledger shared by agent phases ({@link runPhase}) and
124
+ * deterministic command phases ({@link runCommandPhase}). The `run` callback
125
+ * does the actual work and receives a session-id sink so the executions row can
126
+ * be linked to its session jsonl mid-run. `shouldRunPhase` is the single source
127
+ * of truth for resume — re-running from the top skips completed phases here.
128
+ *
129
+ * **Exported** so an app-registered {@link PhaseTypeHandler} can put its own
130
+ * units of work on the same ledger. That is what a `type: fanout` phase needs:
131
+ * it bypasses {@link AgentPort} (it drives one already-provisioned sandbox
132
+ * directly) but each of its branches must still get an `executions` row, or the
133
+ * fan-out silently loses resume, dedup and per-branch cost attribution — the
134
+ * exact price WP5 warned an in-agent fan-out would pay
135
+ * (`docs/plans/deterministic-pr-levers.md` §"Parked: parallel phases (WP5)").
136
+ */
137
+ export declare function runLedgeredPhase(attrs: Record<string, unknown>, meta: {
138
+ dedupKey: string;
139
+ phaseName: string;
140
+ taskId: string;
141
+ triggerId: string;
142
+ /** BARE repo name + its account — the pair, never a qualified string. */
143
+ repo?: string;
144
+ owner?: string;
145
+ workflowRunId?: string;
146
+ }, deps: LedgerDeps, run: (onSessionId: (sessionId: string) => void) => Promise<ExecutionResult>): Promise<RunPhaseResult>;
100
147
  /**
101
148
  * Run a single agent phase with DB-tracked deduplication. The dedup ledger
102
149
  * (`shouldRunPhase`) is the single source of truth for resume, so re-running
103
150
  * from the top skips completed phases here.
104
151
  */
105
- export declare function runPhase(workflowName: string, phaseName: string, taskId: string, triggerId: string, prompt: string, config: ExecutorConfig, deps: LedgerDeps, modelOverride?: string, workflowRunId?: string, githubAccess?: GitSandboxAccess, variantOverride?: string): Promise<RunPhaseResult>;
152
+ export declare function runPhase(workflowName: string, phaseName: string, taskId: string, triggerId: string, prompt: string, config: ExecutorConfig, deps: AgentLedgerDeps, modelOverride?: string, workflowRunId?: string, githubAccess?: GitSandboxAccess, variantOverride?: string): Promise<RunPhaseResult>;
106
153
  /**
107
154
  * Run a single deterministic `bash`/`script` phase, sharing the agent phase's
108
155
  * dedup ledger + executions row so it appears (and dedups on resume) exactly
109
156
  * like an agent phase — just with `turns: 0` and no model cost.
110
157
  */
111
- export declare function runCommandPhase(workflowName: string, phaseName: string, taskId: string, triggerId: string, spec: CommandSpec, config: ExecutorConfig, deps: LedgerDeps, workflowRunId?: string, githubAccess?: GitSandboxAccess, timeoutSeconds?: number, sandboxEnv?: Record<string, string>): Promise<RunPhaseResult>;
158
+ export declare function runCommandPhase(workflowName: string, phaseName: string, taskId: string, triggerId: string, spec: CommandSpec, config: ExecutorConfig, deps: AgentLedgerDeps, workflowRunId?: string, githubAccess?: GitSandboxAccess, timeoutSeconds?: number, sandboxEnv?: Record<string, string>): Promise<RunPhaseResult>;
112
159
  export declare class PhaseExecutor {
113
160
  private readonly run;
114
161
  private readonly reporter;
@@ -163,7 +210,7 @@ export declare class PhaseExecutor {
163
210
  * and the same renderers already know how to draw it.
164
211
  *
165
212
  * Two deliberate choices in the row:
166
- * - It bypasses `runPhaseLedger` (and so `shouldRunPhase`). A condition must
213
+ * - It bypasses `runLedgeredPhase` (and so `shouldRunPhase`). A condition must
167
214
  * be re-evaluated every time it is asked; a dedup hit that replayed a
168
215
  * stale verdict against a workspace that has since changed is precisely
169
216
  * the bug this row exists to expose.
@@ -11,8 +11,12 @@ import { noopLogger } from "../ports/ports.js";
11
11
  /**
12
12
  * Reject shell commands containing mustache template markers to prevent
13
13
  * accidental template injection into until_bash values.
14
+ *
15
+ * Exported so app-registered {@link PhaseTypeHandler}s that run their own gates
16
+ * (the `fanout` handler's per-branch `until_bash`) enforce the identical rule
17
+ * rather than a second copy of it that drifts.
14
18
  */
15
- function validateShellCommand(cmd) {
19
+ export function validateShellCommand(cmd) {
16
20
  if (cmd.includes("{{")) {
17
21
  throw new Error(`until_bash command rejected: contains template marker '{{'. Render templates before passing to shell.`);
18
22
  }
@@ -167,14 +171,22 @@ export function telemetryRepo(access) {
167
171
  * does the actual work and receives a session-id sink so the executions row can
168
172
  * be linked to its session jsonl mid-run. `shouldRunPhase` is the single source
169
173
  * of truth for resume — re-running from the top skips completed phases here.
174
+ *
175
+ * **Exported** so an app-registered {@link PhaseTypeHandler} can put its own
176
+ * units of work on the same ledger. That is what a `type: fanout` phase needs:
177
+ * it bypasses {@link AgentPort} (it drives one already-provisioned sandbox
178
+ * directly) but each of its branches must still get an `executions` row, or the
179
+ * fan-out silently loses resume, dedup and per-branch cost attribution — the
180
+ * exact price WP5 warned an in-agent fan-out would pay
181
+ * (`docs/plans/deterministic-pr-levers.md` §"Parked: parallel phases (WP5)").
170
182
  */
171
- async function runPhaseLedger(attrs, meta, deps, run) {
183
+ export async function runLedgeredPhase(attrs, meta, deps, run) {
172
184
  const { dedupKey, phaseName, taskId, triggerId, repo, owner, workflowRunId } = meta;
173
185
  const { store: db, liveness, observability } = deps;
174
186
  const log = deps.logger ?? noopLogger;
175
187
  return observability.withSpan("lastlight.workflow.phase", attrs, async (span) => {
176
188
  if (db) {
177
- const status = db.executions.shouldRunPhase(dedupKey, triggerId, workflowRunId);
189
+ const status = await db.executions.shouldRunPhase(dedupKey, triggerId, workflowRunId);
178
190
  if (status === "running") {
179
191
  const alive = await isContainerAlive(liveness, taskId);
180
192
  if (alive) {
@@ -183,7 +195,7 @@ async function runPhaseLedger(attrs, meta, deps, run) {
183
195
  return { skipped: true, reason: "running" };
184
196
  }
185
197
  log.warn("phase container is dead", { phase: phaseName });
186
- db.executions.markStaleAsFailed(dedupKey, triggerId, workflowRunId);
198
+ await db.executions.markStaleAsFailed(dedupKey, triggerId, workflowRunId);
187
199
  }
188
200
  else if (status === "done") {
189
201
  log.debug("phase already completed", { phase: phaseName });
@@ -191,7 +203,7 @@ async function runPhaseLedger(attrs, meta, deps, run) {
191
203
  return { skipped: true, reason: "done" };
192
204
  }
193
205
  const executionId = randomUUID();
194
- db.executions.recordStart({
206
+ await db.executions.recordStart({
195
207
  id: executionId,
196
208
  triggerType: "webhook",
197
209
  triggerId,
@@ -204,14 +216,17 @@ async function runPhaseLedger(attrs, meta, deps, run) {
204
216
  });
205
217
  try {
206
218
  const result = await run((sessionId) => {
207
- try {
208
- db.executions.recordSessionId(executionId, sessionId);
209
- }
210
- catch (err) {
219
+ // Deliberate fire-and-forget: this callback is invoked mid-stream
220
+ // from a synchronous context that threads through the whole executor
221
+ // stack, so it cannot be awaited. Best-effort by design — the id is
222
+ // re-recorded when the phase finishes. Note the handler had to move
223
+ // from `catch` to `.catch`: the write is a promise now, so a
224
+ // surrounding try/catch would no longer see its rejection.
225
+ void db.executions.recordSessionId(executionId, sessionId).catch((err) => {
211
226
  log.warn("failed to persist session id mid-run", { phase: phaseName, err });
212
- }
227
+ });
213
228
  });
214
- db.executions.recordFinish(executionId, {
229
+ await db.executions.recordFinish(executionId, {
215
230
  success: result.success,
216
231
  error: result.error,
217
232
  turns: result.turns,
@@ -224,8 +239,8 @@ async function runPhaseLedger(attrs, meta, deps, run) {
224
239
  outputTokens: result.outputTokens,
225
240
  apiDurationMs: result.apiDurationMs,
226
241
  stopReason: result.stopReason,
227
- extensionStatus: result.extensions ? JSON.stringify(result.extensions) : undefined,
228
- skillsStatus: result.skills ? JSON.stringify(result.skills) : undefined,
242
+ extensionStatus: result.extensions,
243
+ skillsStatus: result.skills,
229
244
  });
230
245
  span?.setAttributes({ success: result.success, stop_reason: result.stopReason ?? "unknown" });
231
246
  observability.recordExecutionMetrics("phase", { ...attrs, success: result.success, stop_reason: result.stopReason, durationMs: result.durationMs, costUsd: result.costUsd, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
@@ -239,7 +254,7 @@ async function runPhaseLedger(attrs, meta, deps, run) {
239
254
  // CLI/dashboard instead of `✗`). The success path already finished the
240
255
  // row and returned, so this only fires on a genuine throw — no
241
256
  // double-finish.
242
- db.executions.recordFinish(executionId, {
257
+ await db.executions.recordFinish(executionId, {
243
258
  success: false,
244
259
  error: err instanceof Error ? err.message : String(err),
245
260
  stopReason: "error_fatal",
@@ -277,7 +292,7 @@ export async function runPhase(workflowName, phaseName, taskId, triggerId, promp
277
292
  ...phaseConfigBase,
278
293
  telemetry: { workflowName, phaseName, triggerId, workflowRunId },
279
294
  };
280
- return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, owner: githubAccess?.owner, workflowRunId }, deps, (onSessionId) => deps.agent.runAgent(prompt, phaseConfig, { taskId, githubAccess, onSessionId }));
295
+ return runLedgeredPhase(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, owner: githubAccess?.owner, workflowRunId }, deps, (onSessionId) => deps.agent.runAgent(prompt, phaseConfig, { taskId, githubAccess, onSessionId }));
281
296
  }
282
297
  /**
283
298
  * Run a single deterministic `bash`/`script` phase, sharing the agent phase's
@@ -299,7 +314,7 @@ export async function runCommandPhase(workflowName, phaseName, taskId, triggerId
299
314
  [OPENINFERENCE_SPAN_KIND]: OPENINFERENCE_CHAIN,
300
315
  };
301
316
  const phaseConfig = { ...config, telemetry: { workflowName, phaseName, triggerId, workflowRunId } };
302
- return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, owner: githubAccess?.owner, workflowRunId }, deps, (onSessionId) => deps.agent.runCommand(spec, phaseConfig, { taskId, githubAccess, onSessionId, timeoutSeconds, sandboxEnv }));
317
+ return runLedgeredPhase(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, owner: githubAccess?.owner, workflowRunId }, deps, (onSessionId) => deps.agent.runCommand(spec, phaseConfig, { taskId, githubAccess, onSessionId, timeoutSeconds, sandboxEnv }));
303
318
  }
304
319
  // ── PhaseExecutor ────────────────────────────────────────────────────────────
305
320
  const MAX_PREV_OUTPUT_BYTES = 10 * 1024; // cap accumulated generic-loop output at 10KB
@@ -362,7 +377,7 @@ export class PhaseExecutor {
362
377
  await this.reporter.onStart(phase.name);
363
378
  const result = { phase: phase.name, success: true, output: "Context assembled" };
364
379
  // Persist a phase_history entry so the dashboard marks context phases done.
365
- this.reporter.persistPhase(phase.name, "Context assembled");
380
+ await this.reporter.persistPhase(phase.name, "Context assembled");
366
381
  await this.reporter.onEnd(phase.name, result);
367
382
  return { results: [result], status: "succeeded" };
368
383
  }
@@ -394,7 +409,7 @@ export class PhaseExecutor {
394
409
  return { results: [], status: "failed", aborted: true };
395
410
  }
396
411
  const result = { phase: phaseName, success: true, output: "Already completed" };
397
- this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
412
+ await this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
398
413
  await this.reporter.onEnd(phaseName, result);
399
414
  await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
400
415
  return { results: [result], status: "succeeded" };
@@ -409,7 +424,7 @@ export class PhaseExecutor {
409
424
  if (!isTerminated(pr.result.error)) {
410
425
  await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
411
426
  }
412
- this.reporter.failWorkflow(pr.result.error);
427
+ await this.reporter.failWorkflow(pr.result.error);
413
428
  return { results: [result], status: "failed", outputVars };
414
429
  }
415
430
  // on_output rules (BLOCKED).
@@ -432,7 +447,7 @@ export class PhaseExecutor {
432
447
  if (marker && !(pr.result.output ?? "").includes(marker)) {
433
448
  const error = `phase produced no outcome — missing completion marker "${marker}"`;
434
449
  await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
435
- this.reporter.failWorkflow(error);
450
+ await this.reporter.failWorkflow(error);
436
451
  const failResult = {
437
452
  phase: phaseName,
438
453
  success: false,
@@ -447,7 +462,7 @@ export class PhaseExecutor {
447
462
  await this.pauseForApproval(phaseName, phase.approval_gate, `${phaseName} complete — awaiting ${phase.approval_gate} approval.`, "approve", phase.approval_gate_message, { gateKey: phase.approval_gate }, undefined, phase.approval_artifact);
448
463
  return { results: [result], status: "succeeded", paused: true, outputVars };
449
464
  }
450
- this.reporter.persistPhase(phaseName);
465
+ await this.reporter.persistPhase(phaseName);
451
466
  // Make THIS phase's own output available to its on_success message. The
452
467
  // scheduler only merges `outputVars` into the shared outputs map after
453
468
  // execute() returns, so without this a phase referencing its own output
@@ -478,7 +493,7 @@ export class PhaseExecutor {
478
493
  return { results: [], status: "failed", aborted: true };
479
494
  }
480
495
  const result = { phase: phaseName, success: true, output: "Already completed" };
481
- this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
496
+ await this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
482
497
  await this.reporter.onEnd(phaseName, result);
483
498
  await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
484
499
  return { results: [result], status: "succeeded" };
@@ -493,14 +508,14 @@ export class PhaseExecutor {
493
508
  if (!isTerminated(pr.result.error)) {
494
509
  await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
495
510
  }
496
- this.reporter.failWorkflow(pr.result.error);
511
+ await this.reporter.failWorkflow(pr.result.error);
497
512
  return { results: [result], status: "failed", outputVars };
498
513
  }
499
514
  if (phase.approval_gate && this.resolver.gateEnabled(phase.approval_gate) && this.run.store && this.run.workflowId) {
500
515
  await this.pauseForApproval(phaseName, phase.approval_gate, `${phaseName} complete — awaiting ${phase.approval_gate} approval.`, "approve", phase.approval_gate_message, { gateKey: phase.approval_gate }, undefined, phase.approval_artifact);
501
516
  return { results: [result], status: "succeeded", paused: true, outputVars };
502
517
  }
503
- this.reporter.persistPhase(phaseName);
518
+ await this.reporter.persistPhase(phaseName);
504
519
  await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
505
520
  phaseOutputs: { ...outputs, ...outputVars },
506
521
  });
@@ -548,7 +563,7 @@ export class PhaseExecutor {
548
563
  * and the same renderers already know how to draw it.
549
564
  *
550
565
  * Two deliberate choices in the row:
551
- * - It bypasses `runPhaseLedger` (and so `shouldRunPhase`). A condition must
566
+ * - It bypasses `runLedgeredPhase` (and so `shouldRunPhase`). A condition must
552
567
  * be re-evaluated every time it is asked; a dedup hit that replayed a
553
568
  * stale verdict against a workspace that has since changed is precisely
554
569
  * the bug this row exists to expose.
@@ -565,7 +580,7 @@ export class PhaseExecutor {
565
580
  const executionId = db ? randomUUID() : undefined;
566
581
  if (db && executionId) {
567
582
  try {
568
- db.executions.recordStart({
583
+ await db.executions.recordStart({
569
584
  id: executionId,
570
585
  triggerType: "webhook",
571
586
  triggerId,
@@ -600,7 +615,7 @@ export class PhaseExecutor {
600
615
  }
601
616
  if (db && executionId) {
602
617
  try {
603
- db.executions.recordFinish(executionId, {
618
+ await db.executions.recordFinish(executionId, {
604
619
  success: error === undefined,
605
620
  error,
606
621
  turns: 0,
@@ -613,7 +628,7 @@ export class PhaseExecutor {
613
628
  // Nothing renders `output_text` today (it backs the scratch
614
629
  // `lastOutputExecutionId` indirection); it is queryable on the row, and
615
630
  // it is the column any future surface would read.
616
- db.executions.recordOutputText(executionId, `$ ${command}\n\n${output.length > MAX_UNTIL_OUTPUT_BYTES ? output.slice(-MAX_UNTIL_OUTPUT_BYTES) : output}`);
631
+ await db.executions.recordOutputText(executionId, `$ ${command}\n\n${output.length > MAX_UNTIL_OUTPUT_BYTES ? output.slice(-MAX_UNTIL_OUTPUT_BYTES) : output}`);
617
632
  }
618
633
  catch (err) {
619
634
  log.warn("Failed to finish until_bash check row", { label, err });
@@ -652,8 +667,8 @@ export class PhaseExecutor {
652
667
  return "continue";
653
668
  }
654
669
  if (rule.action === "fail") {
655
- this.run.store?.executions.markLatestAsFailed(`${this.run.definition.name}:${phase.name}`, this.run.triggerId, rule.message || "BLOCKED", this.run.workflowId);
656
- this.reporter.failWorkflow(rule.message || "BLOCKED");
670
+ await this.run.store?.executions.markLatestAsFailed(`${this.run.definition.name}:${phase.name}`, this.run.triggerId, rule.message || "BLOCKED", this.run.workflowId);
671
+ await this.reporter.failWorkflow(rule.message || "BLOCKED");
657
672
  const blockedTemplate = rule.message || phase.messages?.on_blocked;
658
673
  if (blockedTemplate)
659
674
  await this.reporter.step(phase.name, "blocked", blockedTemplate);
@@ -673,7 +688,7 @@ export class PhaseExecutor {
673
688
  if (!db || !workflowId)
674
689
  return;
675
690
  const approvalId = randomUUID();
676
- db.runs.pauseForApproval(workflowId, {
691
+ await db.runs.pauseForApproval(workflowId, {
677
692
  id: approvalId,
678
693
  workflowRunId: workflowId,
679
694
  gate,
@@ -739,7 +754,7 @@ export class PhaseExecutor {
739
754
  await this.reporter.message(phase.messages?.on_skipped_done);
740
755
  return { results, status: "failed", aborted: true };
741
756
  }
742
- const prevOutput = db?.executions.getPhaseOutput(`${wf}:${reviewLabel}`, triggerId, workflowId) ?? "";
757
+ const prevOutput = (await db?.executions.getPhaseOutput(`${wf}:${reviewLabel}`, triggerId, workflowId)) ?? "";
743
758
  verdict = parseReviewerVerdict(prevOutput).verdict;
744
759
  results.push({ phase: reviewLabel, success: true, output: "Already completed" });
745
760
  }
@@ -789,26 +804,26 @@ export class PhaseExecutor {
789
804
  : `VERDICT: ${verdict}\n${reviewerOutput}`;
790
805
  const execId = "executionId" in rr ? rr.executionId : undefined;
791
806
  if (execId && db)
792
- db.executions.recordOutputText(execId, persistText);
807
+ await db.executions.recordOutputText(execId, persistText);
793
808
  }
794
809
  const isApproved = verdict === "APPROVED";
795
810
  if (isApproved) {
796
811
  approved = true;
797
812
  if (reviewRan)
798
- this.reporter.persistPhase(reviewLabel, "APPROVED");
813
+ await this.reporter.persistPhase(reviewLabel, "APPROVED");
799
814
  await this.reporter.step(reviewLabel, "done", loop.messages?.on_approved, { cycle: fixCycles + 1 });
800
815
  }
801
816
  else if (fixCycles < MAX_CYCLES) {
802
817
  fixCycles++;
803
818
  if (reviewRan)
804
- this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES");
819
+ await this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES");
805
820
  // Approval gate before the fix loop. Pause only on a *fresh* gate hit:
806
821
  // not when the fix for this cycle has already run (gate was approved in
807
822
  // a prior entry), and not when we're resuming right after approving
808
823
  // exactly this cycle's gate.
809
824
  if (this.resolver.gateEnabled(loop.approval_gate) && db && workflowId) {
810
825
  const fixLabel = PhaseRef.fix(phaseName, fixCycles).format();
811
- const fixAlreadyDone = db.executions.shouldRunPhase(`${wf}:${fixLabel}`, triggerId, workflowId) === "done";
826
+ const fixAlreadyDone = (await db.executions.shouldRunPhase(`${wf}:${fixLabel}`, triggerId, workflowId)) === "done";
812
827
  const resumingThisGate = pausedAtCycle === fixCycles;
813
828
  if (!fixAlreadyDone && !resumingThisGate) {
814
829
  scratch[loopKey] = { ...slot, pausedAtCycle: fixCycles };
@@ -849,12 +864,12 @@ export class PhaseExecutor {
849
864
  }
850
865
  break;
851
866
  }
852
- this.reporter.persistPhase(fixLabel);
867
+ await this.reporter.persistPhase(fixLabel);
853
868
  await this.reporter.step(fixLabel, "done");
854
869
  }
855
870
  }
856
871
  else {
857
- this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES — max cycles reached");
872
+ await this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES — max cycles reached");
858
873
  await this.reporter.step(reviewLabel, "blocked", loop.messages?.on_max_cycles, {
859
874
  cycle: fixCycles,
860
875
  maxCycles: MAX_CYCLES,
@@ -889,9 +904,9 @@ export class PhaseExecutor {
889
904
  : 0;
890
905
  let iteration = resumeFromIter;
891
906
  let complete = false;
892
- let previousOutput = (scratchSlot.lastOutputExecutionId && db
893
- ? db.executions.getExecutionOutput(scratchSlot.lastOutputExecutionId) ?? ""
894
- : scratchSlot.lastOutput ?? "");
907
+ let previousOutput = scratchSlot.lastOutputExecutionId && db
908
+ ? ((await db.executions.getExecutionOutput(scratchSlot.lastOutputExecutionId)) ?? "")
909
+ : (scratchSlot.lastOutput ?? "");
895
910
  // The LAST iteration's raw output — what the phase signs off with, and so
896
911
  // what the `on_output.requires_marker` postcondition is checked against
897
912
  // below. `previousOutput` can't stand in: it's the accumulated, truncated
@@ -964,10 +979,10 @@ export class PhaseExecutor {
964
979
  if (iterExecutionId)
965
980
  slot.lastOutputExecutionId = iterExecutionId;
966
981
  delete slot.lastOutput;
967
- db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
982
+ await db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
968
983
  scratch[scratchKey] = slot;
969
984
  }
970
- this.reporter.persistPhase(iterLabel, `iteration ${iteration} — soft outcome, advancing`);
985
+ await this.reporter.persistPhase(iterLabel, `iteration ${iteration} — soft outcome, advancing`);
971
986
  await this.reporter.step(phaseName, "done");
972
987
  break;
973
988
  }
@@ -977,7 +992,7 @@ export class PhaseExecutor {
977
992
  if (!isTerminated(ir.result.error)) {
978
993
  await this.reporter.step(phaseName, "failed", phase.messages?.on_failure, { iteration });
979
994
  }
980
- this.reporter.failWorkflow(ir.result.error);
995
+ await this.reporter.failWorkflow(ir.result.error);
981
996
  const outputVars = phase.output_var
982
997
  ? { [phase.output_var]: { completed: false, iterations: iteration } }
983
998
  : undefined;
@@ -1013,8 +1028,8 @@ export class PhaseExecutor {
1013
1028
  // summary it shows and the summary the pipeline shows agree.
1014
1029
  const iterExecutionId = "executionId" in ir ? ir.executionId : undefined;
1015
1030
  if (iterExecutionId && db)
1016
- db.executions.recordOutputText(iterExecutionId, iterOutput);
1017
- this.reporter.persistPhase(iterLabel, `iteration ${iteration} — work complete`);
1031
+ await db.executions.recordOutputText(iterExecutionId, iterOutput);
1032
+ await this.reporter.persistPhase(iterLabel, `iteration ${iteration} — work complete`);
1018
1033
  let conditionMet = false;
1019
1034
  if (loop.until) {
1020
1035
  conditionMet = evalUntilExpression(loop.until, {
@@ -1035,10 +1050,10 @@ export class PhaseExecutor {
1035
1050
  if (iterExecutionId)
1036
1051
  slot.lastOutputExecutionId = iterExecutionId;
1037
1052
  delete slot.lastOutput;
1038
- db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
1053
+ await db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
1039
1054
  scratch[scratchKey] = slot;
1040
1055
  }
1041
- this.reporter.persistPhase(iterLabel, `iteration ${iteration} — condition met`);
1056
+ await this.reporter.persistPhase(iterLabel, `iteration ${iteration} — condition met`);
1042
1057
  await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
1043
1058
  iteration,
1044
1059
  maxIterations: MAX_ITER,
@@ -1066,7 +1081,7 @@ export class PhaseExecutor {
1066
1081
  const approvalId = randomUUID();
1067
1082
  // One transaction: persist the iteration scratch, create the pending
1068
1083
  // gate, append the waiting_approval marker, and pause the run.
1069
- db.runs.pauseForApproval(workflowId, {
1084
+ await db.runs.pauseForApproval(workflowId, {
1070
1085
  id: approvalId,
1071
1086
  workflowRunId: workflowId,
1072
1087
  gate: iterLabel,
@@ -1124,7 +1139,7 @@ export class PhaseExecutor {
1124
1139
  iteration,
1125
1140
  maxIterations: MAX_ITER,
1126
1141
  });
1127
- this.reporter.failWorkflow(error);
1142
+ await this.reporter.failWorkflow(error);
1128
1143
  results.push({ phase: phaseName, success: false, output: lastIterOutput, error });
1129
1144
  return { results, status: "failed", outputVars };
1130
1145
  }