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.
- package/dist/core/phase-executor.d.ts +53 -6
- package/dist/core/phase-executor.js +65 -50
- package/dist/core/phase-executor.js.map +1 -1
- package/dist/core/phase-ref.d.ts +26 -2
- package/dist/core/phase-ref.js +51 -2
- package/dist/core/phase-ref.js.map +1 -1
- package/dist/core/scheduler.js +6 -6
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/schema.d.ts +78 -0
- package/dist/core/schema.js +171 -2
- package/dist/core/schema.js.map +1 -1
- package/dist/core/types.d.ts +18 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/ports/ports.d.ts +37 -21
- package/dist/ports/ports.js +2 -1
- package/dist/ports/ports.js.map +1 -1
- package/dist/test-support/fakes.d.ts +3 -3
- package/dist/test-support/fakes.js +18 -18
- package/dist/test-support/fakes.js.map +1 -1
- package/package.json +3 -4
|
@@ -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
|
|
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:
|
|
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:
|
|
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 `
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
228
|
-
skillsStatus: result.skills
|
|
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
|
|
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
|
|
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 `
|
|
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 =
|
|
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
|
}
|