lastlight-workflow-engine 0.1.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/LICENSE +21 -0
- package/dist/core/dag.d.ts +37 -0
- package/dist/core/dag.js +118 -0
- package/dist/core/dag.js.map +1 -0
- package/dist/core/loop-eval.d.ts +28 -0
- package/dist/core/loop-eval.js +74 -0
- package/dist/core/loop-eval.js.map +1 -0
- package/dist/core/phase-executor.d.ts +152 -0
- package/dist/core/phase-executor.js +922 -0
- package/dist/core/phase-executor.js.map +1 -0
- package/dist/core/phase-ref.d.ts +50 -0
- package/dist/core/phase-ref.js +91 -0
- package/dist/core/phase-ref.js.map +1 -0
- package/dist/core/scheduler.d.ts +45 -0
- package/dist/core/scheduler.js +196 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/schema.d.ts +344 -0
- package/dist/core/schema.js +460 -0
- package/dist/core/schema.js.map +1 -0
- package/dist/core/templates.d.ts +88 -0
- package/dist/core/templates.js +127 -0
- package/dist/core/templates.js.map +1 -0
- package/dist/core/types.d.ts +261 -0
- package/dist/core/types.js +15 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/verdict.d.ts +19 -0
- package/dist/core/verdict.js +30 -0
- package/dist/core/verdict.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ports/ports.d.ts +233 -0
- package/dist/ports/ports.js +11 -0
- package/dist/ports/ports.js.map +1 -0
- package/dist/test-support/fakes.d.ts +86 -0
- package/dist/test-support/fakes.js +0 -0
- package/dist/test-support/fakes.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { phaseSkillNames } from "./schema.js";
|
|
3
|
+
import { renderTemplate } from "./templates.js";
|
|
4
|
+
import { evalUntilExpression } from "./loop-eval.js";
|
|
5
|
+
import { parseReviewerVerdict } from "./verdict.js";
|
|
6
|
+
import { PhaseRef } from "./phase-ref.js";
|
|
7
|
+
// ── Module-level helpers (moved here from runner.ts) ─────────────────────────
|
|
8
|
+
/**
|
|
9
|
+
* Reject shell commands containing mustache template markers to prevent
|
|
10
|
+
* accidental template injection into until_bash values.
|
|
11
|
+
*/
|
|
12
|
+
function validateShellCommand(cmd) {
|
|
13
|
+
if (cmd.includes("{{")) {
|
|
14
|
+
throw new Error(`until_bash command rejected: contains template marker '{{'. Render templates before passing to shell.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Forward upstream phase outputs to a `bash`/`script` command as env vars
|
|
19
|
+
* (`LL_OUT_<PHASE>`), so a script can read them via `process.env` / `os.environ`
|
|
20
|
+
* without any shell-injection risk. Only simple single-line, reasonably-sized
|
|
21
|
+
* string outputs are forwarded; larger / multi-line outputs are still reachable
|
|
22
|
+
* via `{{phaseOutputs.<name>.output}}` template substitution in the command.
|
|
23
|
+
*/
|
|
24
|
+
function upstreamOutputsEnv(outputs) {
|
|
25
|
+
const env = {};
|
|
26
|
+
for (const [k, v] of Object.entries(outputs)) {
|
|
27
|
+
if (typeof v !== "string" || v.length === 0 || v.length > 4096)
|
|
28
|
+
continue;
|
|
29
|
+
if (/[\n\r]/.test(v))
|
|
30
|
+
continue;
|
|
31
|
+
const key = `LL_OUT_${k.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}`;
|
|
32
|
+
if (/^LL_OUT_[A-Z0-9_]+$/.test(key))
|
|
33
|
+
env[key] = v;
|
|
34
|
+
}
|
|
35
|
+
return env;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the agent prompt for a phase, handling both `prompt:` (template file)
|
|
39
|
+
* and `skill:`/`skills:` (skill references) phase definitions.
|
|
40
|
+
*/
|
|
41
|
+
export function buildPhasePrompt(phase, ctx, assets, extraCtx) {
|
|
42
|
+
const fullCtx = extraCtx ? { ...ctx, ...extraCtx } : ctx;
|
|
43
|
+
if (phase.prompt) {
|
|
44
|
+
const template = assets.loadPromptTemplate(phase.prompt);
|
|
45
|
+
return renderTemplate(template, fullCtx);
|
|
46
|
+
}
|
|
47
|
+
const skills = phaseSkillNames(phase);
|
|
48
|
+
if (skills.length) {
|
|
49
|
+
const [primary, ...rest] = skills;
|
|
50
|
+
const contextLines = Object.entries(fullCtx)
|
|
51
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
52
|
+
.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
|
|
53
|
+
.join("\n");
|
|
54
|
+
const others = rest.length
|
|
55
|
+
? `Other skills available if you need them: ${rest.join(", ")}.`
|
|
56
|
+
: "";
|
|
57
|
+
return [
|
|
58
|
+
`Use the **${primary}** skill to handle this request.`,
|
|
59
|
+
others,
|
|
60
|
+
"",
|
|
61
|
+
"Context:",
|
|
62
|
+
contextLines,
|
|
63
|
+
]
|
|
64
|
+
.filter((line) => line !== "")
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Phase "${phase.name}" has neither prompt: nor skills: — cannot build prompt`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Overlay per-phase executor config fields (`unrestricted_egress`,
|
|
71
|
+
* `web_search`, `sandbox_image`, resolved skill paths) onto the run-level
|
|
72
|
+
* config.
|
|
73
|
+
*/
|
|
74
|
+
export function phaseConfigFor(config, phase, assets) {
|
|
75
|
+
const skills = phaseSkillNames(phase);
|
|
76
|
+
const skillPaths = skills.length ? assets.resolveSkillPaths(skills) : undefined;
|
|
77
|
+
if (phase.unrestricted_egress === undefined &&
|
|
78
|
+
phase.web_search === undefined &&
|
|
79
|
+
phase.sandbox_image === undefined &&
|
|
80
|
+
!skillPaths) {
|
|
81
|
+
return config;
|
|
82
|
+
}
|
|
83
|
+
const next = { ...config };
|
|
84
|
+
if (phase.unrestricted_egress !== undefined) {
|
|
85
|
+
next.unrestrictedEgress = phase.unrestricted_egress;
|
|
86
|
+
}
|
|
87
|
+
if (phase.web_search !== undefined) {
|
|
88
|
+
next.webSearch = phase.web_search;
|
|
89
|
+
}
|
|
90
|
+
if (phase.sandbox_image !== undefined) {
|
|
91
|
+
next.sandboxImage = phase.sandbox_image;
|
|
92
|
+
}
|
|
93
|
+
if (skillPaths) {
|
|
94
|
+
next.skillPaths = skillPaths;
|
|
95
|
+
}
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
/** Check if an error was caused by manual termination (OOM/cancel/kill). */
|
|
99
|
+
export function isTerminated(error) {
|
|
100
|
+
if (!error)
|
|
101
|
+
return false;
|
|
102
|
+
const lower = error.toLowerCase();
|
|
103
|
+
return (lower.includes("terminated") ||
|
|
104
|
+
lower.includes("killed") ||
|
|
105
|
+
lower.includes("exit undefined") ||
|
|
106
|
+
(lower.includes("container") && lower.includes("not running")));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Classify an execution outcome as *soft* — a clean exit that produced no
|
|
110
|
+
* usable output — versus *hard* (a real crash). Generic and phase-agnostic:
|
|
111
|
+
* it reads only the result fields, with no notion of reviewer/socratic. Both
|
|
112
|
+
* the reviewer loop and the generic loop consume it so their recovery logic
|
|
113
|
+
* stays in lockstep. A soft outcome is recoverable (retry / trust an on-disk
|
|
114
|
+
* artifact / advance); a hard one — terminated, fatal, tool error, non-zero
|
|
115
|
+
* exit — is not. See `mapStopReason` in the executors for the stop-reason
|
|
116
|
+
* vocabulary this keys off (`unknown` / `error_truncated` are the soft ones).
|
|
117
|
+
*/
|
|
118
|
+
export function isSoftOutcome(r) {
|
|
119
|
+
if (r.success)
|
|
120
|
+
return true;
|
|
121
|
+
if (isTerminated(r.error))
|
|
122
|
+
return false;
|
|
123
|
+
return r.stopReason === "unknown" || r.stopReason === "error_truncated";
|
|
124
|
+
}
|
|
125
|
+
function pickResult(r) {
|
|
126
|
+
return { success: r.success, output: r.output, error: r.error };
|
|
127
|
+
}
|
|
128
|
+
function issueNumberFromTrigger(triggerId) {
|
|
129
|
+
const m = triggerId.match(/#(\d+)$/);
|
|
130
|
+
return m ? Number(m[1]) : undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Check if a sandbox container is actually running for a given taskId prefix. */
|
|
133
|
+
async function isContainerAlive(liveness, taskId) {
|
|
134
|
+
try {
|
|
135
|
+
return await liveness.isPhaseContainerAlive(taskId);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Run a single agent phase with DB-tracked deduplication. Moved verbatim from
|
|
143
|
+
* runner.ts — the dedup ledger (`shouldRunPhase`) is the single source of
|
|
144
|
+
* truth for resume, so re-running from the top skips completed phases here.
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* The DB-tracked dedup ledger shared by agent phases ({@link runPhase}) and
|
|
148
|
+
* deterministic command phases ({@link runCommandPhase}). The `run` callback
|
|
149
|
+
* does the actual work and receives a session-id sink so the executions row can
|
|
150
|
+
* be linked to its session jsonl mid-run. `shouldRunPhase` is the single source
|
|
151
|
+
* of truth for resume — re-running from the top skips completed phases here.
|
|
152
|
+
*/
|
|
153
|
+
async function runPhaseLedger(attrs, meta, deps, run) {
|
|
154
|
+
const { dedupKey, phaseName, taskId, triggerId, repo, workflowRunId } = meta;
|
|
155
|
+
const { store: db, liveness, observability } = deps;
|
|
156
|
+
return observability.withSpan("lastlight.workflow.phase", attrs, async (span) => {
|
|
157
|
+
if (db) {
|
|
158
|
+
const status = db.executions.shouldRunPhase(dedupKey, triggerId, workflowRunId);
|
|
159
|
+
if (status === "running") {
|
|
160
|
+
const alive = await isContainerAlive(liveness, taskId);
|
|
161
|
+
if (alive) {
|
|
162
|
+
console.log(`[runner] Phase ${phaseName} is already running (container alive) — skipping`);
|
|
163
|
+
span?.addEvent("lastlight.workflow.phase.skipped", { reason: "running" });
|
|
164
|
+
return { skipped: true, reason: "running" };
|
|
165
|
+
}
|
|
166
|
+
console.log(`[runner] Phase ${phaseName} was running but container is dead — cleaning up`);
|
|
167
|
+
db.executions.markStaleAsFailed(dedupKey, triggerId, workflowRunId);
|
|
168
|
+
}
|
|
169
|
+
else if (status === "done") {
|
|
170
|
+
console.log(`[runner] Phase ${phaseName} already completed successfully — skipping`);
|
|
171
|
+
span?.addEvent("lastlight.workflow.phase.skipped", { reason: "done" });
|
|
172
|
+
return { skipped: true, reason: "done" };
|
|
173
|
+
}
|
|
174
|
+
const executionId = randomUUID();
|
|
175
|
+
db.executions.recordStart({
|
|
176
|
+
id: executionId,
|
|
177
|
+
triggerType: "webhook",
|
|
178
|
+
triggerId,
|
|
179
|
+
skill: dedupKey,
|
|
180
|
+
repo,
|
|
181
|
+
issueNumber: issueNumberFromTrigger(triggerId),
|
|
182
|
+
startedAt: new Date().toISOString(),
|
|
183
|
+
workflowRunId,
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
const result = await run((sessionId) => {
|
|
187
|
+
try {
|
|
188
|
+
db.executions.recordSessionId(executionId, sessionId);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
console.warn(`[runner] Failed to persist session id mid-run for ${phaseName}:`, err);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
db.executions.recordFinish(executionId, {
|
|
195
|
+
success: result.success,
|
|
196
|
+
error: result.error,
|
|
197
|
+
turns: result.turns,
|
|
198
|
+
durationMs: result.durationMs,
|
|
199
|
+
sessionId: result.sessionId,
|
|
200
|
+
costUsd: result.costUsd,
|
|
201
|
+
inputTokens: result.inputTokens,
|
|
202
|
+
cacheCreationInputTokens: result.cacheCreationInputTokens,
|
|
203
|
+
cacheReadInputTokens: result.cacheReadInputTokens,
|
|
204
|
+
outputTokens: result.outputTokens,
|
|
205
|
+
apiDurationMs: result.apiDurationMs,
|
|
206
|
+
stopReason: result.stopReason,
|
|
207
|
+
extensionStatus: result.extensions ? JSON.stringify(result.extensions) : undefined,
|
|
208
|
+
skillsStatus: result.skills ? JSON.stringify(result.skills) : undefined,
|
|
209
|
+
});
|
|
210
|
+
span?.setAttributes({ success: result.success, stop_reason: result.stopReason ?? "unknown" });
|
|
211
|
+
observability.recordExecutionMetrics("phase", { ...attrs, success: result.success, stop_reason: result.stopReason, durationMs: result.durationMs, costUsd: result.costUsd, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
|
|
212
|
+
return { result, executionId, skipped: false };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
observability.recordError("phase", err, attrs);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const result = await run(() => { });
|
|
220
|
+
observability.recordExecutionMetrics("phase", { ...attrs, success: result.success, stop_reason: result.stopReason, durationMs: result.durationMs, costUsd: result.costUsd, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
|
|
221
|
+
return { result, skipped: false };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Run a single agent phase with DB-tracked deduplication. The dedup ledger
|
|
226
|
+
* (`shouldRunPhase`) is the single source of truth for resume, so re-running
|
|
227
|
+
* from the top skips completed phases here.
|
|
228
|
+
*/
|
|
229
|
+
export async function runPhase(workflowName, phaseName, taskId, triggerId, prompt, config, deps, modelOverride, workflowRunId, githubAccess, variantOverride) {
|
|
230
|
+
const dedupKey = `${workflowName}:${phaseName}`;
|
|
231
|
+
const attrs = {
|
|
232
|
+
"workflow.name": workflowName,
|
|
233
|
+
"phase.name": phaseName,
|
|
234
|
+
"workflow.run_id": workflowRunId,
|
|
235
|
+
"trigger.id": triggerId,
|
|
236
|
+
"task.id": taskId,
|
|
237
|
+
repo: githubAccess?.repo,
|
|
238
|
+
"issue.number": issueNumberFromTrigger(triggerId),
|
|
239
|
+
"sandbox.backend": config.sandbox,
|
|
240
|
+
model: modelOverride || config.model,
|
|
241
|
+
};
|
|
242
|
+
const baseConfig = modelOverride ? { ...config, model: modelOverride } : config;
|
|
243
|
+
const phaseConfigBase = variantOverride ? { ...baseConfig, variant: variantOverride } : baseConfig;
|
|
244
|
+
const phaseConfig = {
|
|
245
|
+
...phaseConfigBase,
|
|
246
|
+
telemetry: { workflowName, phaseName, triggerId, workflowRunId },
|
|
247
|
+
};
|
|
248
|
+
return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, workflowRunId }, deps, (onSessionId) => deps.agent.runAgent(prompt, phaseConfig, { taskId, githubAccess, onSessionId }));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Run a single deterministic `bash`/`script` phase, sharing the agent phase's
|
|
252
|
+
* dedup ledger + executions row so it appears (and dedups on resume) exactly
|
|
253
|
+
* like an agent phase — just with `turns: 0` and no model cost.
|
|
254
|
+
*/
|
|
255
|
+
export async function runCommandPhase(workflowName, phaseName, taskId, triggerId, spec, config, deps, workflowRunId, githubAccess, timeoutSeconds, sandboxEnv) {
|
|
256
|
+
const dedupKey = `${workflowName}:${phaseName}`;
|
|
257
|
+
const attrs = {
|
|
258
|
+
"workflow.name": workflowName,
|
|
259
|
+
"phase.name": phaseName,
|
|
260
|
+
"workflow.run_id": workflowRunId,
|
|
261
|
+
"trigger.id": triggerId,
|
|
262
|
+
"task.id": taskId,
|
|
263
|
+
repo: githubAccess?.repo,
|
|
264
|
+
"issue.number": issueNumberFromTrigger(triggerId),
|
|
265
|
+
"sandbox.backend": config.sandbox,
|
|
266
|
+
model: spec.kind,
|
|
267
|
+
};
|
|
268
|
+
const phaseConfig = { ...config, telemetry: { workflowName, phaseName, triggerId, workflowRunId } };
|
|
269
|
+
return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, workflowRunId }, deps, (onSessionId) => deps.agent.runCommand(spec, phaseConfig, { taskId, githubAccess, onSessionId, timeoutSeconds, sandboxEnv }));
|
|
270
|
+
}
|
|
271
|
+
// ── PhaseExecutor ────────────────────────────────────────────────────────────
|
|
272
|
+
const MAX_PREV_OUTPUT_BYTES = 10 * 1024; // cap accumulated generic-loop output at 10KB
|
|
273
|
+
export class PhaseExecutor {
|
|
274
|
+
run;
|
|
275
|
+
reporter;
|
|
276
|
+
resolver;
|
|
277
|
+
ports;
|
|
278
|
+
constructor(run, reporter, resolver, ports) {
|
|
279
|
+
this.run = run;
|
|
280
|
+
this.reporter = reporter;
|
|
281
|
+
this.resolver = resolver;
|
|
282
|
+
this.ports = ports;
|
|
283
|
+
}
|
|
284
|
+
/** The dedup-ledger seams, bundled from the injected ports + run store. */
|
|
285
|
+
get ledgerDeps() {
|
|
286
|
+
return {
|
|
287
|
+
store: this.run.store,
|
|
288
|
+
agent: this.ports.agent,
|
|
289
|
+
liveness: this.ports.liveness,
|
|
290
|
+
observability: this.ports.observability,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Execute one DAG node and return its delta. The scheduler accumulates
|
|
295
|
+
* `results` into `phases[]`, merges `outputVars` into the shared outputs
|
|
296
|
+
* map, and maps `status`/`paused`/`aborted` onto node state + run control.
|
|
297
|
+
*/
|
|
298
|
+
async execute(node, outputs) {
|
|
299
|
+
const phase = this.run.definition.phases.find((p) => p.name === node.name);
|
|
300
|
+
if (!phase) {
|
|
301
|
+
// Unknown node — shouldn't happen; treat as a no-op success.
|
|
302
|
+
return { results: [{ phase: node.name, success: true, output: "" }], status: "succeeded" };
|
|
303
|
+
}
|
|
304
|
+
const phaseType = phase.type ?? "agent";
|
|
305
|
+
if (phaseType === "context")
|
|
306
|
+
return this.runContext(phase);
|
|
307
|
+
if (phaseType === "bash" || phaseType === "script")
|
|
308
|
+
return this.runCommandBody(phase, outputs);
|
|
309
|
+
// Non-generic phase types (e.g. `post-review`) are dispatched to
|
|
310
|
+
// app-registered handlers — the engine owns only the generic kinds.
|
|
311
|
+
const handler = this.ports.handlers?.get(phaseType);
|
|
312
|
+
if (handler)
|
|
313
|
+
return handler.execute(phase, node, outputs);
|
|
314
|
+
if (!phase.prompt && phaseSkillNames(phase).length === 0) {
|
|
315
|
+
console.warn(`[runner] Phase "${phase.name}" has type=agent but neither prompt: nor skills: — skipping`);
|
|
316
|
+
return { results: [], status: "succeeded" };
|
|
317
|
+
}
|
|
318
|
+
if (phase.loop)
|
|
319
|
+
return this.runReviewerLoop(phase, outputs);
|
|
320
|
+
if (phase.generic_loop)
|
|
321
|
+
return this.runGenericLoop(phase, outputs);
|
|
322
|
+
return this.runStandard(phase, outputs);
|
|
323
|
+
}
|
|
324
|
+
// ── Per-phase bodies ───────────────────────────────────────────────────────
|
|
325
|
+
async runContext(phase) {
|
|
326
|
+
await this.reporter.onStart(phase.name);
|
|
327
|
+
const result = { phase: phase.name, success: true, output: "Context assembled" };
|
|
328
|
+
// Persist a phase_history entry so the dashboard marks context phases done.
|
|
329
|
+
this.reporter.persistPhase(phase.name, "Context assembled");
|
|
330
|
+
await this.reporter.onEnd(phase.name, result);
|
|
331
|
+
return { results: [result], status: "succeeded" };
|
|
332
|
+
}
|
|
333
|
+
/** Resolve model + variant for a phase/task, honouring YAML templates first. */
|
|
334
|
+
resolveModelVariant(template, variantTemplate, taskName, fallbackTask) {
|
|
335
|
+
const ctx = this.run.ctx;
|
|
336
|
+
const modelRaw = template ? renderTemplate(template, ctx) : undefined;
|
|
337
|
+
const model = modelRaw || this.resolver.modelFor(taskName)
|
|
338
|
+
|| (fallbackTask ? this.resolver.modelFor(fallbackTask) : undefined);
|
|
339
|
+
const variantRaw = variantTemplate ? renderTemplate(variantTemplate, ctx) : undefined;
|
|
340
|
+
const variant = variantRaw || this.resolver.variantFor(taskName)
|
|
341
|
+
|| (fallbackTask ? this.resolver.variantFor(fallbackTask) : undefined);
|
|
342
|
+
return { model, variant };
|
|
343
|
+
}
|
|
344
|
+
async runPhaseCall(label, prompt, phase, model, variant) {
|
|
345
|
+
const { definition, config, workflowId, githubAccess, taskId, triggerId } = this.run;
|
|
346
|
+
return runPhase(definition.name, label, taskId, triggerId, prompt, phaseConfigFor(config, phase, this.ports.assets), this.ledgerDeps, model, workflowId, githubAccess, variant);
|
|
347
|
+
}
|
|
348
|
+
async runStandard(phase, outputs) {
|
|
349
|
+
const phaseName = phase.name;
|
|
350
|
+
await this.reporter.onStart(phaseName);
|
|
351
|
+
await this.reporter.step(phaseName, "running", phase.messages?.on_start);
|
|
352
|
+
const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
|
|
353
|
+
const prompt = buildPhasePrompt(phase, this.run.ctx, this.ports.assets, { phaseOutputs: outputs });
|
|
354
|
+
const pr = await this.runPhaseCall(phaseName, prompt, phase, model, variant);
|
|
355
|
+
if (pr.skipped) {
|
|
356
|
+
if (pr.reason === "running") {
|
|
357
|
+
await this.reporter.message(phase.messages?.on_skipped_done);
|
|
358
|
+
return { results: [], status: "failed", aborted: true };
|
|
359
|
+
}
|
|
360
|
+
const result = { phase: phaseName, success: true, output: "Already completed" };
|
|
361
|
+
this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
|
|
362
|
+
await this.reporter.onEnd(phaseName, result);
|
|
363
|
+
await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
|
|
364
|
+
return { results: [result], status: "succeeded" };
|
|
365
|
+
}
|
|
366
|
+
const result = { phase: phaseName, ...pickResult(pr.result) };
|
|
367
|
+
await this.reporter.onEnd(phaseName, result);
|
|
368
|
+
const rawOutput = pr.result.output ?? "";
|
|
369
|
+
const outputVars = { [phaseName]: rawOutput };
|
|
370
|
+
if (phase.output_var)
|
|
371
|
+
outputVars[phase.output_var] = rawOutput;
|
|
372
|
+
if (!pr.result.success) {
|
|
373
|
+
if (!isTerminated(pr.result.error)) {
|
|
374
|
+
await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
|
|
375
|
+
}
|
|
376
|
+
this.reporter.failWorkflow(pr.result.error);
|
|
377
|
+
return { results: [result], status: "failed", outputVars };
|
|
378
|
+
}
|
|
379
|
+
// on_output rules (BLOCKED).
|
|
380
|
+
if (phase.on_output) {
|
|
381
|
+
const blocked = await this.evaluateBlocked(phase, pr.result.output ?? "");
|
|
382
|
+
if (blocked === "fail") {
|
|
383
|
+
const failResult = {
|
|
384
|
+
phase: phaseName,
|
|
385
|
+
success: false,
|
|
386
|
+
output: pr.result.output ?? "",
|
|
387
|
+
error: "BLOCKED",
|
|
388
|
+
};
|
|
389
|
+
return { results: [failResult], status: "failed", outputVars };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Approval gate.
|
|
393
|
+
if (phase.approval_gate && this.resolver.gateEnabled(phase.approval_gate) && this.run.store && this.run.workflowId) {
|
|
394
|
+
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);
|
|
395
|
+
return { results: [result], status: "succeeded", paused: true, outputVars };
|
|
396
|
+
}
|
|
397
|
+
this.reporter.persistPhase(phaseName);
|
|
398
|
+
// Make THIS phase's own output available to its on_success message. The
|
|
399
|
+
// scheduler only merges `outputVars` into the shared outputs map after
|
|
400
|
+
// execute() returns, so without this a phase referencing its own output
|
|
401
|
+
// (e.g. answer's `on_success: "{{answerResult}}"`, explore's publishResult)
|
|
402
|
+
// would render empty. Merge the just-produced vars into the render context.
|
|
403
|
+
await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
|
|
404
|
+
phaseOutputs: { ...outputs, ...outputVars },
|
|
405
|
+
});
|
|
406
|
+
return { results: [result], status: "succeeded", outputVars };
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Body for `type: bash` / `type: script` phases. Runs a deterministic command
|
|
410
|
+
* in the sandbox (no LLM), then mirrors the agent-phase lifecycle: report
|
|
411
|
+
* progress, expose stdout downstream via `output_var` →
|
|
412
|
+
* `{{phaseOutputs.<name>.output}}`, fail the workflow on a non-zero exit, and
|
|
413
|
+
* honour an optional `approval_gate`.
|
|
414
|
+
*/
|
|
415
|
+
async runCommandBody(phase, outputs) {
|
|
416
|
+
const phaseName = phase.name;
|
|
417
|
+
await this.reporter.onStart(phaseName);
|
|
418
|
+
await this.reporter.step(phaseName, "running", phase.messages?.on_start);
|
|
419
|
+
const spec = this.buildCommandSpec(phase, outputs);
|
|
420
|
+
const sandboxEnv = upstreamOutputsEnv(outputs);
|
|
421
|
+
const pr = await this.runCommandPhaseCall(phaseName, phase, spec, sandboxEnv);
|
|
422
|
+
if (pr.skipped) {
|
|
423
|
+
if (pr.reason === "running") {
|
|
424
|
+
await this.reporter.message(phase.messages?.on_skipped_done);
|
|
425
|
+
return { results: [], status: "failed", aborted: true };
|
|
426
|
+
}
|
|
427
|
+
const result = { phase: phaseName, success: true, output: "Already completed" };
|
|
428
|
+
this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
|
|
429
|
+
await this.reporter.onEnd(phaseName, result);
|
|
430
|
+
await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
|
|
431
|
+
return { results: [result], status: "succeeded" };
|
|
432
|
+
}
|
|
433
|
+
const result = { phase: phaseName, ...pickResult(pr.result) };
|
|
434
|
+
await this.reporter.onEnd(phaseName, result);
|
|
435
|
+
const rawOutput = pr.result.output ?? "";
|
|
436
|
+
const outputVars = { [phaseName]: rawOutput };
|
|
437
|
+
if (phase.output_var)
|
|
438
|
+
outputVars[phase.output_var] = rawOutput;
|
|
439
|
+
if (!pr.result.success) {
|
|
440
|
+
if (!isTerminated(pr.result.error)) {
|
|
441
|
+
await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
|
|
442
|
+
}
|
|
443
|
+
this.reporter.failWorkflow(pr.result.error);
|
|
444
|
+
return { results: [result], status: "failed", outputVars };
|
|
445
|
+
}
|
|
446
|
+
if (phase.approval_gate && this.resolver.gateEnabled(phase.approval_gate) && this.run.store && this.run.workflowId) {
|
|
447
|
+
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
|
+
return { results: [result], status: "succeeded", paused: true, outputVars };
|
|
449
|
+
}
|
|
450
|
+
this.reporter.persistPhase(phaseName);
|
|
451
|
+
await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
|
|
452
|
+
phaseOutputs: { ...outputs, ...outputVars },
|
|
453
|
+
});
|
|
454
|
+
return { results: [result], status: "succeeded", outputVars };
|
|
455
|
+
}
|
|
456
|
+
/** Render a command/script phase's template fields into a {@link CommandSpec}. */
|
|
457
|
+
buildCommandSpec(phase, outputs) {
|
|
458
|
+
const ctx = { ...this.run.ctx, phaseOutputs: outputs };
|
|
459
|
+
if ((phase.type ?? "agent") === "script") {
|
|
460
|
+
const script = renderTemplate(phase.script ?? "", ctx);
|
|
461
|
+
return { kind: "script", script, runtime: phase.runtime ?? "js", name: phase.name.replace(/[^A-Za-z0-9_-]/g, "_") };
|
|
462
|
+
}
|
|
463
|
+
const command = renderTemplate(phase.command ?? "", ctx);
|
|
464
|
+
// Guard against an unresolved template marker reaching the shell.
|
|
465
|
+
validateShellCommand(command);
|
|
466
|
+
return { kind: "bash", command };
|
|
467
|
+
}
|
|
468
|
+
async runCommandPhaseCall(label, phase, spec, sandboxEnv) {
|
|
469
|
+
const { definition, config, workflowId, githubAccess, taskId, triggerId } = this.run;
|
|
470
|
+
return runCommandPhase(definition.name, label, taskId, triggerId, spec, phaseConfigFor(config, phase, this.ports.assets), this.ledgerDeps, workflowId, githubAccess, phase.timeout_seconds, sandboxEnv);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Evaluate a `generic_loop.until_bash` condition INSIDE the sandbox (against
|
|
474
|
+
* the persisted workspace), replacing the old harness-host `execSync`. Exit 0
|
|
475
|
+
* ⇒ loop complete. The check inherits the phase's egress; no session log is
|
|
476
|
+
* written (it's an internal loop condition, not a user-facing phase).
|
|
477
|
+
*/
|
|
478
|
+
async runUntilBash(command, phase) {
|
|
479
|
+
const { config, githubAccess, taskId, triggerId, definition, workflowId } = this.run;
|
|
480
|
+
try {
|
|
481
|
+
validateShellCommand(command);
|
|
482
|
+
const res = await this.ports.agent.runCommand({ kind: "bash", command }, { ...phaseConfigFor(config, phase, this.ports.assets), telemetry: { workflowName: definition.name, phaseName: `${phase.name}_until`, triggerId, workflowRunId: workflowId } }, { taskId, githubAccess, timeoutSeconds: phase.timeout_seconds ?? 30, writeSession: false });
|
|
483
|
+
return res.success;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Evaluate the `contains_BLOCKED` rule. Returns "fail" when the workflow
|
|
491
|
+
* should fail, or "continue" when the marker is absent or bypassed.
|
|
492
|
+
*/
|
|
493
|
+
async evaluateBlocked(phase, output) {
|
|
494
|
+
const rule = phase.on_output?.contains_BLOCKED;
|
|
495
|
+
if (!rule)
|
|
496
|
+
return "continue";
|
|
497
|
+
if (!output.toUpperCase().includes("BLOCKED"))
|
|
498
|
+
return "continue";
|
|
499
|
+
const ctx = this.run.ctx;
|
|
500
|
+
const hasUnlessLabel = rule.unless_label && ctx.issueLabels.includes(rule.unless_label);
|
|
501
|
+
const titleMatches = (() => {
|
|
502
|
+
if (!rule.unless_title_matches)
|
|
503
|
+
return false;
|
|
504
|
+
if (rule.unless_title_matches.length > 200)
|
|
505
|
+
return false;
|
|
506
|
+
if (/[+*]\{0,\}.*[+*]/.test(rule.unless_title_matches) || /(\([^)]*[+*][^)]*\))[+*?]/.test(rule.unless_title_matches))
|
|
507
|
+
return false;
|
|
508
|
+
try {
|
|
509
|
+
return new RegExp(rule.unless_title_matches, "i").test(ctx.issueTitle || "");
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
})();
|
|
515
|
+
if (hasUnlessLabel || titleMatches) {
|
|
516
|
+
await this.reporter.message(rule.bypass_message || phase.messages?.on_blocked_bypassed);
|
|
517
|
+
return "continue";
|
|
518
|
+
}
|
|
519
|
+
if (rule.action === "fail") {
|
|
520
|
+
this.run.store?.executions.markLatestAsFailed(`${this.run.definition.name}:${phase.name}`, this.run.triggerId, rule.message || "BLOCKED", this.run.workflowId);
|
|
521
|
+
this.reporter.failWorkflow(rule.message || "BLOCKED");
|
|
522
|
+
const blockedTemplate = rule.message || phase.messages?.on_blocked;
|
|
523
|
+
if (blockedTemplate)
|
|
524
|
+
await this.reporter.step(phase.name, "blocked", blockedTemplate);
|
|
525
|
+
return "fail";
|
|
526
|
+
}
|
|
527
|
+
return "continue";
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Persist + pause for an approval/reply gate, atomically. The approval
|
|
531
|
+
* insert, the `waiting_approval` phase marker, the optional scratch persist
|
|
532
|
+
* (loops record their iteration/cycle state here), and the run pause all
|
|
533
|
+
* happen in one transaction via `db.runs.pauseForApproval` — a partial
|
|
534
|
+
* failure can't leave the run paused without its gate, or vice versa.
|
|
535
|
+
*/
|
|
536
|
+
async pauseForApproval(stepKey, gate, summary, kind, message, extraCtx, scratchPatch, artifact) {
|
|
537
|
+
const { store: db, workflowId, ctx } = this.run;
|
|
538
|
+
if (!db || !workflowId)
|
|
539
|
+
return;
|
|
540
|
+
const approvalId = randomUUID();
|
|
541
|
+
db.runs.pauseForApproval(workflowId, {
|
|
542
|
+
id: approvalId,
|
|
543
|
+
workflowRunId: workflowId,
|
|
544
|
+
gate,
|
|
545
|
+
summary,
|
|
546
|
+
kind,
|
|
547
|
+
artifact,
|
|
548
|
+
requestedBy: ctx.sender,
|
|
549
|
+
createdAt: new Date().toISOString(),
|
|
550
|
+
}, {
|
|
551
|
+
phase: "waiting_approval",
|
|
552
|
+
summary: `Waiting for ${kind === "reply" ? "reply" : "approval"}: ${gate} (${approvalId})`,
|
|
553
|
+
}, scratchPatch);
|
|
554
|
+
// Expose `approvalId` to the gate message template so it can deep-link to
|
|
555
|
+
// the focused approval view via `{{approvalUrl}}`. Transition the checklist
|
|
556
|
+
// step to "awaiting" (with a collapsed detail), then post the full prompt
|
|
557
|
+
// as a standalone note: approve gates get interactive Approve/Reject buttons
|
|
558
|
+
// (Slack) via `approvalNote`; reply gates stay a plain message.
|
|
559
|
+
await this.reporter.step(stepKey, "awaiting", message, { ...extraCtx, approvalId });
|
|
560
|
+
if (kind === "approve") {
|
|
561
|
+
await this.reporter.approvalNote(message, { ...extraCtx, approvalId }, { workflowRunId: workflowId });
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
await this.reporter.message(message, { ...extraCtx, approvalId });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async runReviewerLoop(phase, outputs) {
|
|
568
|
+
const loop = phase.loop;
|
|
569
|
+
const phaseName = phase.name;
|
|
570
|
+
const MAX_CYCLES = loop.max_cycles;
|
|
571
|
+
const { store: db, workflowId, triggerId, scratch } = this.run;
|
|
572
|
+
const wf = this.run.definition.name;
|
|
573
|
+
const results = [];
|
|
574
|
+
let approved = false;
|
|
575
|
+
let fixCycles = 0;
|
|
576
|
+
// Loop resume state. When the loop pauses at `loop.approval_gate` after a
|
|
577
|
+
// REQUEST_CHANGES verdict, we persist the cycle we paused at so that on
|
|
578
|
+
// resume we can tell "approved — continue with the fix cycle" apart from
|
|
579
|
+
// "review approved → done". Without this, a dedup-`done` review on resume
|
|
580
|
+
// would be misread as APPROVED and skip the required fix. (#94 follow-up)
|
|
581
|
+
const loopKey = `rloop:${phaseName}`;
|
|
582
|
+
const slot = scratch[loopKey] ?? {};
|
|
583
|
+
const pausedAtCycle = typeof slot.pausedAtCycle === "number" ? slot.pausedAtCycle : undefined;
|
|
584
|
+
while (!approved && fixCycles <= MAX_CYCLES) {
|
|
585
|
+
const reviewLabel = fixCycles === 0
|
|
586
|
+
? PhaseRef.review(phaseName).format()
|
|
587
|
+
: PhaseRef.recheck(phaseName, fixCycles).format();
|
|
588
|
+
await this.reporter.onStart(reviewLabel);
|
|
589
|
+
await this.reporter.step(reviewLabel, "running", loop.messages?.on_cycle_start, { cycle: fixCycles + 1, maxCycles: MAX_CYCLES }, fixCycles === 0
|
|
590
|
+
? undefined
|
|
591
|
+
: { insert: true, label: `${phase.label ?? phaseName} (cycle ${fixCycles + 1})` });
|
|
592
|
+
const reviewPrompt = fixCycles === 0
|
|
593
|
+
? buildPhasePrompt(phase, this.run.ctx, this.ports.assets, { phaseOutputs: outputs, fixCycle: fixCycles })
|
|
594
|
+
: this.resolver.renderPrompt(loop.on_request_changes.re_review_prompt, { phaseOutputs: outputs, fixCycle: fixCycles });
|
|
595
|
+
const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
|
|
596
|
+
const rr = await this.runPhaseCall(reviewLabel, reviewPrompt, phase, model, variant);
|
|
597
|
+
// Derive this review's verdict. A dedup-`done` review (resume) is NOT
|
|
598
|
+
// assumed approved — re-parse the verdict from its persisted output.
|
|
599
|
+
let verdict;
|
|
600
|
+
const reviewRan = !rr.skipped;
|
|
601
|
+
if (rr.skipped) {
|
|
602
|
+
if (rr.reason === "running") {
|
|
603
|
+
await this.reporter.message(phase.messages?.on_skipped_done);
|
|
604
|
+
return { results, status: "failed", aborted: true };
|
|
605
|
+
}
|
|
606
|
+
const prevOutput = db?.executions.getPhaseOutput(`${wf}:${reviewLabel}`, triggerId, workflowId) ?? "";
|
|
607
|
+
verdict = parseReviewerVerdict(prevOutput).verdict;
|
|
608
|
+
results.push({ phase: reviewLabel, success: true, output: "Already completed" });
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
const reviewerOutput = (rr.result.output || "").trim();
|
|
612
|
+
let parsed = parseReviewerVerdict(reviewerOutput);
|
|
613
|
+
let resultOverride = pickResult(rr.result);
|
|
614
|
+
// Some models (e.g. gpt-5-codex) end their final turn on the
|
|
615
|
+
// reviewer-verdict.md write with no trailing stdout, so the VERDICT:
|
|
616
|
+
// marker is absent from stdout even though the review completed cleanly
|
|
617
|
+
// — the run then mislabels a clean APPROVED as a failure AND runs a
|
|
618
|
+
// needless fix cycle. When the marker is missing from stdout, trust the
|
|
619
|
+
// verdict FILE (the reviewer's OUTPUT CONTRACT) and treat the phase as
|
|
620
|
+
// successful. Gated to soft outcomes (clean exit / "unknown" /
|
|
621
|
+
// truncated) so a hard sandbox failure can't be masked by a stale
|
|
622
|
+
// verdict doc left in the persistent per-issue store.
|
|
623
|
+
const soft = isSoftOutcome(rr.result);
|
|
624
|
+
if (parsed.viaFallback && soft) {
|
|
625
|
+
const fromFile = this.ports.verdictReader?.read({
|
|
626
|
+
config: this.run.config,
|
|
627
|
+
repo: String(this.run.ctx.repo),
|
|
628
|
+
issueDir: typeof this.run.ctx.issueDir === "string" ? this.run.ctx.issueDir : "",
|
|
629
|
+
taskId: this.run.taskId,
|
|
630
|
+
});
|
|
631
|
+
if (fromFile) {
|
|
632
|
+
parsed = fromFile;
|
|
633
|
+
resultOverride = { ...resultOverride, success: true, error: undefined };
|
|
634
|
+
console.warn(`[runner] Reviewer stdout missing VERDICT: marker — recovered ${fromFile.verdict} from reviewer-verdict.md`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
verdict = parsed.verdict;
|
|
638
|
+
if (parsed.viaFallback) {
|
|
639
|
+
console.warn(`[runner] Reviewer output missing VERDICT: marker — using fallback detection (isApproved=${verdict === "APPROVED"})`);
|
|
640
|
+
}
|
|
641
|
+
results.push({ phase: reviewLabel, ...resultOverride });
|
|
642
|
+
await this.reporter.onEnd(reviewLabel, results[results.length - 1]);
|
|
643
|
+
// Persist a marker-bearing output so a resumed run re-derives this exact
|
|
644
|
+
// verdict (stdout may have been empty when recovered from the file).
|
|
645
|
+
const persistText = /^\s*VERDICT:/im.test(reviewerOutput)
|
|
646
|
+
? reviewerOutput
|
|
647
|
+
: `VERDICT: ${verdict}\n${reviewerOutput}`;
|
|
648
|
+
const execId = "executionId" in rr ? rr.executionId : undefined;
|
|
649
|
+
if (execId && db)
|
|
650
|
+
db.executions.recordOutputText(execId, persistText);
|
|
651
|
+
}
|
|
652
|
+
const isApproved = verdict === "APPROVED";
|
|
653
|
+
if (isApproved) {
|
|
654
|
+
approved = true;
|
|
655
|
+
if (reviewRan)
|
|
656
|
+
this.reporter.persistPhase(reviewLabel, "APPROVED");
|
|
657
|
+
await this.reporter.step(reviewLabel, "done", loop.messages?.on_approved, { cycle: fixCycles + 1 });
|
|
658
|
+
}
|
|
659
|
+
else if (fixCycles < MAX_CYCLES) {
|
|
660
|
+
fixCycles++;
|
|
661
|
+
if (reviewRan)
|
|
662
|
+
this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES");
|
|
663
|
+
// Approval gate before the fix loop. Pause only on a *fresh* gate hit:
|
|
664
|
+
// not when the fix for this cycle has already run (gate was approved in
|
|
665
|
+
// a prior entry), and not when we're resuming right after approving
|
|
666
|
+
// exactly this cycle's gate.
|
|
667
|
+
if (this.resolver.gateEnabled(loop.approval_gate) && db && workflowId) {
|
|
668
|
+
const fixLabel = PhaseRef.fix(phaseName, fixCycles).format();
|
|
669
|
+
const fixAlreadyDone = db.executions.shouldRunPhase(`${wf}:${fixLabel}`, triggerId, workflowId) === "done";
|
|
670
|
+
const resumingThisGate = pausedAtCycle === fixCycles;
|
|
671
|
+
if (!fixAlreadyDone && !resumingThisGate) {
|
|
672
|
+
scratch[loopKey] = { ...slot, pausedAtCycle: fixCycles };
|
|
673
|
+
await this.pauseForApproval(reviewLabel, loop.approval_gate, `Reviewer requested changes (cycle ${fixCycles}/${MAX_CYCLES}) on phase ${phaseName}.`, "approve", loop.messages?.on_pause_for_approval, { cycle: fixCycles, maxCycles: MAX_CYCLES, gateKey: loop.approval_gate }, { [loopKey]: scratch[loopKey] }, loop.approval_artifact);
|
|
674
|
+
return { results, status: "succeeded", paused: true };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
await this.reporter.step(reviewLabel, "done", loop.messages?.on_request_changes, {
|
|
678
|
+
cycle: fixCycles,
|
|
679
|
+
maxCycles: MAX_CYCLES,
|
|
680
|
+
});
|
|
681
|
+
// Fix phase.
|
|
682
|
+
const fixLabel = PhaseRef.fix(phaseName, fixCycles).format();
|
|
683
|
+
await this.reporter.onStart(fixLabel);
|
|
684
|
+
await this.reporter.step(fixLabel, "running", loop.messages?.on_fix_start, { cycle: fixCycles, maxCycles: MAX_CYCLES }, { insert: true, label: `Fix (cycle ${fixCycles})` });
|
|
685
|
+
const { model: fixModel, variant: fixVariant } = this.resolveModelVariant(loop.on_request_changes.fix_model, loop.on_request_changes.fix_variant, `${phaseName}_fix`, phaseName);
|
|
686
|
+
const fixPromptRendered = this.resolver.renderPrompt(loop.on_request_changes.fix_prompt, {
|
|
687
|
+
phaseOutputs: outputs,
|
|
688
|
+
fixCycle: fixCycles,
|
|
689
|
+
});
|
|
690
|
+
const fr = await this.runPhaseCall(fixLabel, fixPromptRendered, phase, fixModel, fixVariant);
|
|
691
|
+
if (fr.skipped) {
|
|
692
|
+
if (fr.reason === "running") {
|
|
693
|
+
await this.reporter.message(phase.messages?.on_skipped_done);
|
|
694
|
+
return { results, status: "failed", aborted: true };
|
|
695
|
+
}
|
|
696
|
+
results.push({ phase: fixLabel, success: true, output: "Already completed" });
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
results.push({ phase: fixLabel, ...pickResult(fr.result) });
|
|
700
|
+
await this.reporter.onEnd(fixLabel, results[results.length - 1]);
|
|
701
|
+
if (!fr.result.success) {
|
|
702
|
+
if (!isTerminated(fr.result.error)) {
|
|
703
|
+
await this.reporter.step(fixLabel, "failed", loop.messages?.on_fix_failed, {
|
|
704
|
+
cycle: fixCycles,
|
|
705
|
+
maxCycles: MAX_CYCLES,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
this.reporter.persistPhase(fixLabel);
|
|
711
|
+
await this.reporter.step(fixLabel, "done");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES — max cycles reached");
|
|
716
|
+
await this.reporter.step(reviewLabel, "blocked", loop.messages?.on_max_cycles, {
|
|
717
|
+
cycle: fixCycles,
|
|
718
|
+
maxCycles: MAX_CYCLES,
|
|
719
|
+
}, { alsoNote: true });
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const outputVars = {};
|
|
724
|
+
if (phase.output_var)
|
|
725
|
+
outputVars[phase.output_var] = { approved, cycles: fixCycles };
|
|
726
|
+
// The loop node itself succeeds as a scheduling unit — even at max cycles
|
|
727
|
+
// the linear runner continued to the next phase. Individual review/fix
|
|
728
|
+
// results carry their own success flags for the run-level rollup.
|
|
729
|
+
return { results, status: "succeeded", outputVars };
|
|
730
|
+
}
|
|
731
|
+
async runGenericLoop(phase, outputs) {
|
|
732
|
+
const loop = phase.generic_loop;
|
|
733
|
+
const phaseName = phase.name;
|
|
734
|
+
const MAX_ITER = loop.max_iterations;
|
|
735
|
+
const { store: db, workflowId, scratch, config } = this.run;
|
|
736
|
+
const results = [];
|
|
737
|
+
// Reply-gate loops resume mid-flight: read the saved iteration from scratch.
|
|
738
|
+
const scratchKey = loop.scratch_key;
|
|
739
|
+
const scratchSlot = (scratchKey
|
|
740
|
+
? scratch[scratchKey] ?? {}
|
|
741
|
+
: {});
|
|
742
|
+
const resumeFromIter = loop.gate_kind === "reply" && typeof scratchSlot.iteration === "number"
|
|
743
|
+
? Math.min(scratchSlot.iteration, MAX_ITER)
|
|
744
|
+
: 0;
|
|
745
|
+
let iteration = resumeFromIter;
|
|
746
|
+
let complete = false;
|
|
747
|
+
let previousOutput = (scratchSlot.lastOutputExecutionId && db
|
|
748
|
+
? db.executions.getExecutionOutput(scratchSlot.lastOutputExecutionId) ?? ""
|
|
749
|
+
: scratchSlot.lastOutput ?? "");
|
|
750
|
+
await this.reporter.step(phaseName, "running");
|
|
751
|
+
while (!complete && iteration < MAX_ITER) {
|
|
752
|
+
iteration++;
|
|
753
|
+
const iterLabel = PhaseRef.iter(phaseName, iteration).format();
|
|
754
|
+
await this.reporter.onStart(iterLabel);
|
|
755
|
+
const iterCtx = {
|
|
756
|
+
iteration,
|
|
757
|
+
maxIterations: MAX_ITER,
|
|
758
|
+
previousOutput: loop.fresh_context ? "" : previousOutput,
|
|
759
|
+
phaseOutputs: outputs,
|
|
760
|
+
scratch,
|
|
761
|
+
};
|
|
762
|
+
const prompt = buildPhasePrompt(phase, this.run.ctx, this.ports.assets, iterCtx);
|
|
763
|
+
const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
|
|
764
|
+
let ir = await this.runPhaseCall(iterLabel, prompt, phase, model, variant);
|
|
765
|
+
if (ir.skipped) {
|
|
766
|
+
if (ir.reason === "running") {
|
|
767
|
+
await this.reporter.message(phase.messages?.on_skipped_done, { iteration });
|
|
768
|
+
return { results, status: "failed", aborted: true };
|
|
769
|
+
}
|
|
770
|
+
results.push({ phase: iterLabel, success: true, output: "Already completed" });
|
|
771
|
+
complete = true;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
// Soft-outcome retry: a clean-but-empty turn (stop reason unknown /
|
|
775
|
+
// truncated, no usable output — not a real crash) re-runs the same round
|
|
776
|
+
// up to `on_soft_failure.retries` times, under a distinct `_iter_n_retry`
|
|
777
|
+
// ledger label so resume/dedup treats it as its own step. The round
|
|
778
|
+
// number (`scratch.iteration`) is NOT advanced by a retry.
|
|
779
|
+
const softPolicy = loop.on_soft_failure ?? { retries: 0, then: "fail" };
|
|
780
|
+
let softAttempts = 0;
|
|
781
|
+
while (!ir.result.success && isSoftOutcome(ir.result) && softAttempts < softPolicy.retries) {
|
|
782
|
+
softAttempts++;
|
|
783
|
+
const retryLabel = PhaseRef.iterRetry(phaseName, iteration).format();
|
|
784
|
+
await this.reporter.onStart(retryLabel);
|
|
785
|
+
const retry = await this.runPhaseCall(retryLabel, prompt, phase, model, variant);
|
|
786
|
+
if (retry.skipped)
|
|
787
|
+
break;
|
|
788
|
+
await this.reporter.onEnd(retryLabel, { phase: retryLabel, ...pickResult(retry.result) });
|
|
789
|
+
ir = retry;
|
|
790
|
+
}
|
|
791
|
+
// A soft outcome that survived the retries: honour the loop's policy.
|
|
792
|
+
// `then: complete` treats the loop as finished (as if the `until`
|
|
793
|
+
// condition matched) and advances downstream with the work gathered so
|
|
794
|
+
// far — recording the iteration as a success so the run-level rollup
|
|
795
|
+
// (`anyFailed` in runner.ts) stays green. `then: fail` (the default)
|
|
796
|
+
// drops through to the hard-fail path below, preserving old behavior.
|
|
797
|
+
if (!ir.result.success && isSoftOutcome(ir.result) && softPolicy.then === "complete") {
|
|
798
|
+
const iterExecutionId = "executionId" in ir ? ir.executionId : undefined;
|
|
799
|
+
results.push({ phase: iterLabel, success: true, output: ir.result.output || previousOutput || "" });
|
|
800
|
+
await this.reporter.onEnd(iterLabel, results[results.length - 1]);
|
|
801
|
+
complete = true;
|
|
802
|
+
if (scratchKey && db && workflowId) {
|
|
803
|
+
const slot = { ...scratchSlot, iteration, ready: true };
|
|
804
|
+
if (iterExecutionId)
|
|
805
|
+
slot.lastOutputExecutionId = iterExecutionId;
|
|
806
|
+
delete slot.lastOutput;
|
|
807
|
+
db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
|
|
808
|
+
scratch[scratchKey] = slot;
|
|
809
|
+
}
|
|
810
|
+
this.reporter.persistPhase(iterLabel, `iteration ${iteration} — soft outcome, advancing`);
|
|
811
|
+
await this.reporter.step(phaseName, "done");
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
results.push({ phase: iterLabel, ...pickResult(ir.result) });
|
|
815
|
+
await this.reporter.onEnd(iterLabel, results[results.length - 1]);
|
|
816
|
+
if (!ir.result.success) {
|
|
817
|
+
if (!isTerminated(ir.result.error)) {
|
|
818
|
+
await this.reporter.step(phaseName, "failed", phase.messages?.on_failure, { iteration });
|
|
819
|
+
}
|
|
820
|
+
this.reporter.failWorkflow(ir.result.error);
|
|
821
|
+
const outputVars = phase.output_var
|
|
822
|
+
? { [phase.output_var]: { completed: false, iterations: iteration } }
|
|
823
|
+
: undefined;
|
|
824
|
+
return { results, status: "failed", outputVars };
|
|
825
|
+
}
|
|
826
|
+
const iterOutput = ir.result.output || "";
|
|
827
|
+
if (!loop.fresh_context) {
|
|
828
|
+
const combined = previousOutput ? `${previousOutput}\n${iterOutput}` : iterOutput;
|
|
829
|
+
previousOutput = combined.length > MAX_PREV_OUTPUT_BYTES ? combined.slice(-MAX_PREV_OUTPUT_BYTES) : combined;
|
|
830
|
+
}
|
|
831
|
+
let conditionMet = false;
|
|
832
|
+
if (loop.until) {
|
|
833
|
+
conditionMet = evalUntilExpression(loop.until, {
|
|
834
|
+
output: iterOutput,
|
|
835
|
+
scratch,
|
|
836
|
+
...Object.fromEntries(Object.entries(this.run.ctx)
|
|
837
|
+
.filter(([, v]) => typeof v === "string")
|
|
838
|
+
.map(([k, v]) => [k, v])),
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
if (!conditionMet && loop.until_bash) {
|
|
842
|
+
conditionMet = await this.runUntilBash(loop.until_bash, phase);
|
|
843
|
+
}
|
|
844
|
+
const iterExecutionId = "executionId" in ir ? ir.executionId : undefined;
|
|
845
|
+
if (iterExecutionId && db)
|
|
846
|
+
db.executions.recordOutputText(iterExecutionId, iterOutput);
|
|
847
|
+
if (conditionMet) {
|
|
848
|
+
complete = true;
|
|
849
|
+
if (scratchKey && db && workflowId) {
|
|
850
|
+
const slot = { ...scratchSlot, iteration, ready: true };
|
|
851
|
+
if (iterExecutionId)
|
|
852
|
+
slot.lastOutputExecutionId = iterExecutionId;
|
|
853
|
+
delete slot.lastOutput;
|
|
854
|
+
db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
|
|
855
|
+
scratch[scratchKey] = slot;
|
|
856
|
+
}
|
|
857
|
+
this.reporter.persistPhase(iterLabel, `iteration ${iteration} — condition met`);
|
|
858
|
+
await this.reporter.step(phaseName, "done");
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
// Interactive gate between iterations.
|
|
862
|
+
if (loop.interactive && !complete && db && workflowId) {
|
|
863
|
+
const isReply = loop.gate_kind === "reply";
|
|
864
|
+
const gateMsg = loop.gate_message
|
|
865
|
+
? renderTemplate(loop.gate_message, { ...this.run.ctx, phaseOutputs: outputs, iteration, maxIterations: MAX_ITER, scratch })
|
|
866
|
+
: `Loop iteration ${iteration}/${MAX_ITER} complete.`;
|
|
867
|
+
let gateScratchPatch;
|
|
868
|
+
if (scratchKey) {
|
|
869
|
+
const slot = {
|
|
870
|
+
...scratch[scratchKey],
|
|
871
|
+
iteration,
|
|
872
|
+
};
|
|
873
|
+
if (iterExecutionId)
|
|
874
|
+
slot.lastOutputExecutionId = iterExecutionId;
|
|
875
|
+
delete slot.lastOutput;
|
|
876
|
+
scratch[scratchKey] = slot;
|
|
877
|
+
gateScratchPatch = { [scratchKey]: slot };
|
|
878
|
+
}
|
|
879
|
+
const approvalId = randomUUID();
|
|
880
|
+
// One transaction: persist the iteration scratch, create the pending
|
|
881
|
+
// gate, append the waiting_approval marker, and pause the run.
|
|
882
|
+
db.runs.pauseForApproval(workflowId, {
|
|
883
|
+
id: approvalId,
|
|
884
|
+
workflowRunId: workflowId,
|
|
885
|
+
gate: iterLabel,
|
|
886
|
+
summary: gateMsg,
|
|
887
|
+
kind: isReply ? "reply" : "approve",
|
|
888
|
+
requestedBy: this.run.ctx.sender,
|
|
889
|
+
createdAt: new Date().toISOString(),
|
|
890
|
+
}, {
|
|
891
|
+
phase: "waiting_approval",
|
|
892
|
+
summary: `Waiting for ${isReply ? "reply" : "approval"}: ${iterLabel} (${approvalId})`,
|
|
893
|
+
}, gateScratchPatch);
|
|
894
|
+
await this.reporter.step(phaseName, "awaiting");
|
|
895
|
+
if (isReply) {
|
|
896
|
+
const parts = [iterOutput.trim(), gateMsg.trim()].filter(Boolean);
|
|
897
|
+
if (parts.length > 0)
|
|
898
|
+
await this.reporter.postNote(parts.join("\n\n---\n\n"));
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
await this.reporter.postNote(`**${phaseName} iteration ${iteration}/${MAX_ITER} complete** — approval required to continue.\n\n` +
|
|
902
|
+
`${gateMsg}\n\n` +
|
|
903
|
+
`**To continue:** comment \`@${this.run.botName} approve\`\n` +
|
|
904
|
+
`**To abort:** comment \`@${this.run.botName} reject [reason]\``);
|
|
905
|
+
}
|
|
906
|
+
return { results, status: "succeeded", paused: true };
|
|
907
|
+
}
|
|
908
|
+
this.reporter.persistPhase(iterLabel);
|
|
909
|
+
}
|
|
910
|
+
if (!complete) {
|
|
911
|
+
await this.reporter.step(phaseName, "failed", phase.messages?.on_failure, {
|
|
912
|
+
iteration,
|
|
913
|
+
maxIterations: MAX_ITER,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
const outputVars = phase.output_var
|
|
917
|
+
? { [phase.output_var]: { completed: complete, iterations: iteration } }
|
|
918
|
+
: undefined;
|
|
919
|
+
return { results, status: "succeeded", outputVars };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
//# sourceMappingURL=phase-executor.js.map
|