@mcrescenzo/opencode-workflows 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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Host-owned autonomous drain runtime used by globalThis.drain and thin drain workflow
|
|
2
|
+
// wrappers. Domain control stays here and in trusted adapters; workflow
|
|
3
|
+
// source supplies adapter options rather than reimplementing the controller loop.
|
|
4
|
+
import { computeLaneBackoffMs } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
const CLASSIFICATIONS = new Set(["ready", "blocked", "human-gated", "external", "done"]);
|
|
7
|
+
const LANE_OUTCOMES = new Set(["implemented", "blocked", "needs-research", "failed", "no-op"]);
|
|
8
|
+
|
|
9
|
+
function assertFunction(value, name) {
|
|
10
|
+
if (typeof value !== "function") throw new Error(`drain adapter requires ${name}()`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertArray(value, name) {
|
|
14
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function itemId(item) {
|
|
18
|
+
const id = item?.id ?? item?.itemId;
|
|
19
|
+
if (typeof id !== "string" || !id) throw new Error("drain items require string id");
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeClassification(value) {
|
|
24
|
+
const classification = typeof value === "string" ? { status: value } : value;
|
|
25
|
+
if (!classification || typeof classification !== "object") throw new Error("adapter.classify() must return a classification");
|
|
26
|
+
if (!CLASSIFICATIONS.has(classification.status)) throw new Error(`Invalid drain classification: ${String(classification.status)}`);
|
|
27
|
+
return classification;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateLaneReport(report) {
|
|
31
|
+
if (!report || typeof report !== "object") throw new Error("Lane report must be an object");
|
|
32
|
+
if (typeof report.itemId !== "string" || !report.itemId) throw new Error("Lane report requires itemId");
|
|
33
|
+
if (!LANE_OUTCOMES.has(report.outcome)) throw new Error(`Invalid lane outcome: ${String(report.outcome)}`);
|
|
34
|
+
if (typeof report.summary !== "string") throw new Error("Lane report requires summary");
|
|
35
|
+
if (typeof report.readyForIntegration !== "boolean") throw new Error("Lane report requires readyForIntegration boolean");
|
|
36
|
+
assertArray(report.filesChanged, "Lane report filesChanged");
|
|
37
|
+
assertArray(report.commandsRun, "Lane report commandsRun");
|
|
38
|
+
assertArray(report.acceptanceEvidence, "Lane report acceptanceEvidence");
|
|
39
|
+
assertArray(report.residualRisks, "Lane report residualRisks");
|
|
40
|
+
assertArray(report.followups, "Lane report followups");
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateValidationReport(report) {
|
|
45
|
+
if (!report || typeof report !== "object") throw new Error("Validation report must be an object");
|
|
46
|
+
if (typeof report.itemId !== "string" || !report.itemId) throw new Error("Validation report requires itemId");
|
|
47
|
+
if (typeof report.accepted !== "boolean") throw new Error("Validation report requires accepted boolean");
|
|
48
|
+
if (typeof report.reason !== "string") throw new Error("Validation report requires reason");
|
|
49
|
+
if (typeof report.diffScopeOk !== "boolean") throw new Error("Validation report requires diffScopeOk boolean");
|
|
50
|
+
if (typeof report.followupsHandled !== "boolean") throw new Error("Validation report requires followupsHandled boolean");
|
|
51
|
+
assertArray(report.acceptanceChecklist, "Validation report acceptanceChecklist");
|
|
52
|
+
assertArray(report.validationCommands, "Validation report validationCommands");
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateAdapter(adapter) {
|
|
57
|
+
if (!adapter || typeof adapter !== "object") throw new Error("drain requires an adapter object");
|
|
58
|
+
for (const name of ["discover", "classify", "claim", "buildLanePacket", "validate", "close", "createFollowup", "proveDry"]) {
|
|
59
|
+
assertFunction(adapter[name], name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createFollowups(adapter, followups, context) {
|
|
64
|
+
const created = [];
|
|
65
|
+
for (const followup of followups ?? []) {
|
|
66
|
+
created.push(await adapter.createFollowup(followup, context));
|
|
67
|
+
}
|
|
68
|
+
return created;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function releaseClaimedItem(adapter, item, claim, reason, report, context = {}) {
|
|
72
|
+
if (typeof adapter.releaseClaim !== "function") return undefined;
|
|
73
|
+
const id = itemId(item);
|
|
74
|
+
const outcome = await adapter.releaseClaim(item, { claim, reason, outcome: "failed", ...context });
|
|
75
|
+
const record = { itemId: id, reason, outcome };
|
|
76
|
+
report.released.push(record);
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkLifecycle(ctx, context = {}) {
|
|
81
|
+
if (typeof ctx.options?.checkLifecycle === "function") ctx.options.checkLifecycle(context);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isBudgetStoppedError(error) {
|
|
85
|
+
return error?.name === "WorkflowBudgetStoppedError"
|
|
86
|
+
|| error?.code === "WORKFLOW_BUDGET_STOPPED"
|
|
87
|
+
|| error?.outcome === "budget_stopped";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isLifecycleAbortError(error) {
|
|
91
|
+
return error?.code === "WORKFLOW_CANCELLED"
|
|
92
|
+
|| error?.name === "WorkflowCancelledError"
|
|
93
|
+
|| error?.name === "WorkflowPausedError";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function errorSummary(error) {
|
|
97
|
+
return error?.message ? String(error.message) : String(error);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function canLaunchLane(options, context) {
|
|
101
|
+
if (typeof options.canLaunchLane !== "function") return true;
|
|
102
|
+
try {
|
|
103
|
+
return await options.canLaunchLane(context) !== false;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isBudgetStoppedError(error)) return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultIntegrate(laneReports) {
|
|
111
|
+
return { status: "integrated", laneReports };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Deterministic exponential backoff between drain repair attempts (opencode-workflows-jbs3.2).
|
|
115
|
+
// Opt-in: with no retryBackoffBaseMs configured the delay is 0 and ctx.sleep is never called, so
|
|
116
|
+
// the historical repair-loop timing is preserved exactly. When configured, a failed item's repair
|
|
117
|
+
// attempt waits baseMs * 2**(attempt-1) (capped) before re-running, the same backoff curve the
|
|
118
|
+
// child-lane retry uses, so a wave of failing items cannot hot-loop their repair attempts.
|
|
119
|
+
async function applyRepairBackoff(ctx, attempt) {
|
|
120
|
+
checkLifecycle(ctx, { phase: "repair-backoff", attempt, point: "before" });
|
|
121
|
+
const baseMs = ctx.retryBackoffBaseMs;
|
|
122
|
+
if (!(Number.isFinite(baseMs) && baseMs > 0)) return 0;
|
|
123
|
+
// jitter: () => 1 keeps the delay deterministic (full exponential value, no random component).
|
|
124
|
+
const delayMs = computeLaneBackoffMs(attempt, { baseMs, jitter: () => 1 });
|
|
125
|
+
if (delayMs > 0) await ctx.sleep(delayMs);
|
|
126
|
+
checkLifecycle(ctx, { phase: "repair-backoff", attempt, point: "after" });
|
|
127
|
+
return delayMs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Run one attempt for a claimed item. Returns a control signal the caller's
|
|
131
|
+
// attempt loop acts on, preserving drain()'s original break/continue semantics:
|
|
132
|
+
// - { control: "retry", priorValidation } -> caller continues (a "repair" attempt)
|
|
133
|
+
// - { control: "break" } -> caller stops the attempt loop
|
|
134
|
+
// - { control: "break", closed: true } -> caller stops; the item closed
|
|
135
|
+
// Side effects (failed/closed/salvaged/followups accumulation, releaseClaim,
|
|
136
|
+
// budgetExhausted) are applied to ctx.report exactly as the inline body did.
|
|
137
|
+
async function runLaneAttempt(ctx, { item, id, claim, attempt, context, wave, priorValidation, releaseState }) {
|
|
138
|
+
const { adapter, options, integrate, maxAttempts, report, phase } = ctx;
|
|
139
|
+
const attemptContext = { ...context, item, itemId: id, attempt, claim, priorValidation };
|
|
140
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "attempt", point: "start" });
|
|
141
|
+
if (!await canLaunchLane(options, { ...attemptContext, phase: "preattempt" })) {
|
|
142
|
+
const reason = "lane launch budget exhausted";
|
|
143
|
+
report.budgetExhausted = true;
|
|
144
|
+
report.failed.push({ itemId: id, reason });
|
|
145
|
+
wave.attempts.push({ itemId: id, attempt, stopped: "budget_exhausted", reason });
|
|
146
|
+
// Mark attempted BEFORE the await: if releaseClaim throws mid-mutation the
|
|
147
|
+
// exception unwinds into processReadyItem's catch, which must NOT re-release.
|
|
148
|
+
if (releaseState) releaseState.attempted = true;
|
|
149
|
+
await releaseClaimedItem(adapter, item, claim, reason, report);
|
|
150
|
+
return { control: "break" };
|
|
151
|
+
}
|
|
152
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "buildLanePacket", point: "before" });
|
|
153
|
+
const packet = await adapter.buildLanePacket(item, attemptContext);
|
|
154
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "buildLanePacket", point: "after" });
|
|
155
|
+
phase("spawn_lanes");
|
|
156
|
+
phase("monitor");
|
|
157
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "runLane", point: "before" });
|
|
158
|
+
const laneReport = validateLaneReport(await options.runLane(packet, attemptContext));
|
|
159
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "runLane", point: "after" });
|
|
160
|
+
if (laneReport.itemId !== id) throw new Error(`Lane report itemId mismatch: ${laneReport.itemId} !== ${id}`);
|
|
161
|
+
if (laneReport.salvage?.dirty) report.salvaged.push({ itemId: id, attempt, salvage: laneReport.salvage });
|
|
162
|
+
phase("collect_reports");
|
|
163
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "lane-followups", point: "before" });
|
|
164
|
+
const laneFollowups = await createFollowups(adapter, laneReport.followups, { ...attemptContext, laneReport });
|
|
165
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "lane-followups", point: "after" });
|
|
166
|
+
report.followups.push(...laneFollowups);
|
|
167
|
+
const attemptRecord = { itemId: id, attempt, laneReport, validationReport: undefined, followups: laneFollowups };
|
|
168
|
+
wave.attempts.push(attemptRecord);
|
|
169
|
+
|
|
170
|
+
if (!laneReport.readyForIntegration || laneReport.outcome === "failed") {
|
|
171
|
+
if (attempt < maxAttempts) {
|
|
172
|
+
phase("repair");
|
|
173
|
+
await applyRepairBackoff(ctx, attempt);
|
|
174
|
+
return { control: "retry", priorValidation };
|
|
175
|
+
}
|
|
176
|
+
report.failed.push({ itemId: id, reason: laneReport.summary, laneReport });
|
|
177
|
+
if (releaseState) releaseState.attempted = true;
|
|
178
|
+
await releaseClaimedItem(adapter, item, claim, laneReport.summary ?? "lane failed", report, { laneReport, salvage: laneReport.salvage });
|
|
179
|
+
return { control: "break" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
phase("integrate");
|
|
183
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "integrate", point: "before" });
|
|
184
|
+
const integrationState = await integrate([laneReport], attemptContext);
|
|
185
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "integrate", point: "after" });
|
|
186
|
+
phase("validate");
|
|
187
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "validate", point: "before" });
|
|
188
|
+
const validationReport = validateValidationReport(await adapter.validate(item, integrationState, { ...attemptContext, laneReport }));
|
|
189
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "validate", point: "after" });
|
|
190
|
+
if (validationReport.itemId !== id) throw new Error(`Validation report itemId mismatch: ${validationReport.itemId} !== ${id}`);
|
|
191
|
+
attemptRecord.validationReport = validationReport;
|
|
192
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "validation-followups", point: "before" });
|
|
193
|
+
const validationFollowups = await createFollowups(adapter, validationReport.followups ?? [], { ...attemptContext, laneReport, validationReport });
|
|
194
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "validation-followups", point: "after" });
|
|
195
|
+
report.followups.push(...validationFollowups);
|
|
196
|
+
|
|
197
|
+
if (validationReport.accepted && validationReport.diffScopeOk && validationReport.followupsHandled) {
|
|
198
|
+
phase("close");
|
|
199
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "close", point: "before" });
|
|
200
|
+
const closeResult = await adapter.close(item, { claim, laneReport, validationReport, integrationState }, attemptContext);
|
|
201
|
+
checkLifecycle(ctx, { ...attemptContext, phase: "close", point: "after" });
|
|
202
|
+
report.closed.push({ itemId: id, closeResult, laneReport, validationReport });
|
|
203
|
+
return { control: "break", closed: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (attempt < maxAttempts) {
|
|
207
|
+
phase("repair");
|
|
208
|
+
await applyRepairBackoff(ctx, attempt);
|
|
209
|
+
return { control: "retry", priorValidation: validationReport };
|
|
210
|
+
}
|
|
211
|
+
report.failed.push({ itemId: id, reason: validationReport.reason, laneReport, validationReport });
|
|
212
|
+
if (releaseState) releaseState.attempted = true;
|
|
213
|
+
await releaseClaimedItem(adapter, item, claim, validationReport.reason ?? "validation rejected", report, { laneReport, validationReport, salvage: laneReport.salvage });
|
|
214
|
+
return { control: "break", priorValidation: validationReport };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Claim a ready item and drive its attempt loop to a terminal outcome.
|
|
218
|
+
// Returns { breakItems } telling the wave loop whether to stop iterating ready
|
|
219
|
+
// items (true once the launch budget is exhausted). The preclaim budget check
|
|
220
|
+
// short-circuits before the "claim" phase, exactly as the inline body did.
|
|
221
|
+
async function processReadyItem(ctx, { item, classification, context, wave }) {
|
|
222
|
+
const { adapter, options, maxAttempts, report, phase } = ctx;
|
|
223
|
+
const id = itemId(item);
|
|
224
|
+
checkLifecycle(ctx, { ...context, item, itemId: id, phase: "preclaim", point: "before" });
|
|
225
|
+
if (!await canLaunchLane(options, { ...context, item, itemId: id, phase: "preclaim" })) {
|
|
226
|
+
report.budgetExhausted = true;
|
|
227
|
+
wave.budgetExhausted = { itemId: id, phase: "preclaim", reason: "lane launch budget exhausted before claim" };
|
|
228
|
+
return { breakItems: true };
|
|
229
|
+
}
|
|
230
|
+
phase("claim");
|
|
231
|
+
checkLifecycle(ctx, { ...context, item, itemId: id, phase: "claim", point: "before" });
|
|
232
|
+
const claim = await adapter.claim(item, { ...context, classification });
|
|
233
|
+
checkLifecycle(ctx, { ...context, item, itemId: id, phase: "claim", point: "after" });
|
|
234
|
+
let priorValidation;
|
|
235
|
+
let closed = false;
|
|
236
|
+
// Tracks whether any internal release site (in runLaneAttempt or the
|
|
237
|
+
// post-loop guard below) has already invoked releaseClaimedItem for this
|
|
238
|
+
// claim. The catch block must not issue a second real bd release: for the
|
|
239
|
+
// beads adapter the release mutationKey embeds the free-text reason, so a
|
|
240
|
+
// fresh "drain exception: ..." reason produces a different idempotency marker
|
|
241
|
+
// and can double-apply against the git-synced issue (opencode-workflows-5rzm).
|
|
242
|
+
const releaseState = { attempted: false };
|
|
243
|
+
try {
|
|
244
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
245
|
+
const result = await runLaneAttempt(ctx, { item, id, claim, attempt, context, wave, priorValidation, releaseState });
|
|
246
|
+
if (result.control === "retry") {
|
|
247
|
+
priorValidation = result.priorValidation;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
closed = result.closed === true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
if (!closed && !report.failed.some((failure) => failure.itemId === id)) {
|
|
254
|
+
report.failed.push({ itemId: id, reason: "attempts exhausted without close" });
|
|
255
|
+
releaseState.attempted = true;
|
|
256
|
+
await releaseClaimedItem(adapter, item, claim, "attempts exhausted without close", report);
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (isLifecycleAbortError(error)) throw error;
|
|
260
|
+
const reason = `drain exception: ${errorSummary(error)}`;
|
|
261
|
+
if (!report.failed.some((failure) => failure.itemId === id)) {
|
|
262
|
+
report.failed.push({ itemId: id, reason, error: errorSummary(error) });
|
|
263
|
+
}
|
|
264
|
+
// Only release here if no internal release was already attempted for this
|
|
265
|
+
// claim. Re-releasing with a fresh reason defeats the adapter's release
|
|
266
|
+
// dedup and can double-apply the mutation against the real issue.
|
|
267
|
+
if (!releaseState.attempted) {
|
|
268
|
+
await releaseClaimedItem(adapter, item, claim, reason, report, { error });
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
return { breakItems: report.budgetExhausted === true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Run one wave: discover, classify, then either plan (dryRun) or process every
|
|
276
|
+
// ready item. Returns the wave's signal for drain()'s wave loop:
|
|
277
|
+
// - "drained" -> no ready work (or a dryRun plan); stop, the queue is drained
|
|
278
|
+
// - "stop" -> budget exhausted or a failure occurred; stop, work may remain
|
|
279
|
+
// - "continue" -> all ready items handled cleanly; advance to the next wave
|
|
280
|
+
async function runWave(ctx, { waveNumber }) {
|
|
281
|
+
const { adapter, options, dryRun, report, phase, skippedIds } = ctx;
|
|
282
|
+
const context = { scope: options.scope, waveNumber, report };
|
|
283
|
+
phase(waveNumber === 1 ? "snapshot" : "resnapshot");
|
|
284
|
+
checkLifecycle(ctx, { ...context, phase: "discover", point: "before" });
|
|
285
|
+
const discovered = await adapter.discover(options.scope, context);
|
|
286
|
+
checkLifecycle(ctx, { ...context, phase: "discover", point: "after" });
|
|
287
|
+
assertArray(discovered, "adapter.discover result");
|
|
288
|
+
|
|
289
|
+
phase("classify");
|
|
290
|
+
const ready = [];
|
|
291
|
+
const wave = { waveNumber, discovered: discovered.length, ready: [], skipped: [], attempts: [] };
|
|
292
|
+
for (const item of discovered) {
|
|
293
|
+
const id = itemId(item);
|
|
294
|
+
checkLifecycle(ctx, { ...context, item, itemId: id, phase: "classify", point: "before" });
|
|
295
|
+
const classification = normalizeClassification(await adapter.classify(item, context));
|
|
296
|
+
checkLifecycle(ctx, { ...context, item, itemId: id, phase: "classify", point: "after" });
|
|
297
|
+
if (classification.status === "ready") {
|
|
298
|
+
ready.push({ item, classification });
|
|
299
|
+
wave.ready.push(itemId(item));
|
|
300
|
+
} else {
|
|
301
|
+
const skipped = { itemId: itemId(item), classification: classification.status, reason: classification.reason };
|
|
302
|
+
wave.skipped.push(skipped);
|
|
303
|
+
if (!skippedIds.has(skipped.itemId)) {
|
|
304
|
+
skippedIds.add(skipped.itemId);
|
|
305
|
+
report.skipped.push(skipped);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (ready.length === 0) {
|
|
311
|
+
report.waves.push(wave);
|
|
312
|
+
return { signal: "drained" };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
phase("plan_wave");
|
|
316
|
+
checkLifecycle(ctx, { ...context, phase: "plan_wave", point: "before" });
|
|
317
|
+
if (dryRun) {
|
|
318
|
+
for (const { item, classification } of ready) {
|
|
319
|
+
const id = itemId(item);
|
|
320
|
+
report.planned.push({ itemId: id, classification: classification.status, reason: classification.reason });
|
|
321
|
+
}
|
|
322
|
+
wave.planned = report.planned.map((entry) => entry.itemId);
|
|
323
|
+
report.waves.push(wave);
|
|
324
|
+
checkLifecycle(ctx, { ...context, phase: "plan_wave", point: "after" });
|
|
325
|
+
return { signal: "drained" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const { item, classification } of ready) {
|
|
329
|
+
checkLifecycle(ctx, { ...context, item, itemId: itemId(item), phase: "process", point: "before" });
|
|
330
|
+
const { breakItems } = await processReadyItem(ctx, { item, classification, context, wave });
|
|
331
|
+
checkLifecycle(ctx, { ...context, item, itemId: itemId(item), phase: "process", point: "after" });
|
|
332
|
+
if (breakItems) break;
|
|
333
|
+
}
|
|
334
|
+
report.waves.push(wave);
|
|
335
|
+
if (report.budgetExhausted) return { signal: "stop" };
|
|
336
|
+
if (report.failed.length > 0) return { signal: "stop" };
|
|
337
|
+
return { signal: "continue" };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function drain(options) {
|
|
341
|
+
const dryRun = options?.dryRun === true;
|
|
342
|
+
const adapter = options?.adapter;
|
|
343
|
+
validateAdapter(adapter);
|
|
344
|
+
if (!dryRun) assertFunction(options.runLane, "runLane");
|
|
345
|
+
const integrate = options.integrate ?? defaultIntegrate;
|
|
346
|
+
if (!dryRun) assertFunction(integrate, "integrate");
|
|
347
|
+
|
|
348
|
+
const maxWaves = Number.isInteger(options.maxWaves) && options.maxWaves > 0 ? options.maxWaves : 10;
|
|
349
|
+
const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0 ? options.maxAttempts : 2;
|
|
350
|
+
// Opt-in repair backoff (jbs3.2): default 0 preserves the historical no-delay repair loop.
|
|
351
|
+
const retryBackoffBaseMs = Number.isFinite(options.retryBackoffBaseMs) && options.retryBackoffBaseMs > 0 ? options.retryBackoffBaseMs : 0;
|
|
352
|
+
const sleep = typeof options.sleep === "function" ? options.sleep : ((ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))));
|
|
353
|
+
const report = {
|
|
354
|
+
adapter: adapter.name || "anonymous",
|
|
355
|
+
status: "running",
|
|
356
|
+
dryRun,
|
|
357
|
+
scope: options.scope ?? {},
|
|
358
|
+
gateStatus: options.gateStatus ?? options.gates,
|
|
359
|
+
phases: [],
|
|
360
|
+
waves: [],
|
|
361
|
+
planned: [],
|
|
362
|
+
closed: [],
|
|
363
|
+
failed: [],
|
|
364
|
+
released: [],
|
|
365
|
+
salvaged: [],
|
|
366
|
+
skipped: [],
|
|
367
|
+
followups: [],
|
|
368
|
+
budgetExhausted: false,
|
|
369
|
+
dryProof: undefined,
|
|
370
|
+
};
|
|
371
|
+
const ctx = { adapter, options, integrate, dryRun, maxAttempts, retryBackoffBaseMs, sleep, report, phase: undefined, skippedIds: new Set() };
|
|
372
|
+
const phase = (name) => {
|
|
373
|
+
checkLifecycle(ctx, { phase: name, point: "transition" });
|
|
374
|
+
report.phases.push(name);
|
|
375
|
+
};
|
|
376
|
+
ctx.phase = phase;
|
|
377
|
+
|
|
378
|
+
phase("preflight");
|
|
379
|
+
// exhaustedWaves stays true unless a wave drains the queue (no ready work or a
|
|
380
|
+
// dryRun plan). A budget/failure "stop" leaves it true because work may remain,
|
|
381
|
+
// which the terminal-status table reads as max_waves_exceeded vs not_dry.
|
|
382
|
+
let exhaustedWaves = true;
|
|
383
|
+
let drainError;
|
|
384
|
+
try {
|
|
385
|
+
for (let waveNumber = 1; waveNumber <= maxWaves; waveNumber += 1) {
|
|
386
|
+
const { signal } = await runWave(ctx, { waveNumber });
|
|
387
|
+
if (signal === "drained") {
|
|
388
|
+
exhaustedWaves = false;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
if (signal === "stop") break;
|
|
392
|
+
// signal === "continue": advance to the next wave.
|
|
393
|
+
}
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (isLifecycleAbortError(error)) throw error;
|
|
396
|
+
drainError = error;
|
|
397
|
+
report.error = errorSummary(error);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
phase("final_audit");
|
|
401
|
+
checkLifecycle(ctx, { phase: "proveDry", point: "before" });
|
|
402
|
+
report.dryProof = await adapter.proveDry(options.scope, { report });
|
|
403
|
+
checkLifecycle(ctx, { phase: "proveDry", point: "after" });
|
|
404
|
+
if (drainError) {
|
|
405
|
+
report.status = "failed";
|
|
406
|
+
} else if (dryRun) {
|
|
407
|
+
report.status = "dry_run_complete";
|
|
408
|
+
} else if (report.budgetExhausted) {
|
|
409
|
+
report.status = "budget_exhausted";
|
|
410
|
+
} else if (report.failed.length > 0) {
|
|
411
|
+
report.status = "failed";
|
|
412
|
+
} else if (report.dryProof?.dry === true) {
|
|
413
|
+
report.status = "complete";
|
|
414
|
+
} else if (exhaustedWaves) {
|
|
415
|
+
report.status = "max_waves_exceeded";
|
|
416
|
+
} else {
|
|
417
|
+
report.status = "not_dry";
|
|
418
|
+
}
|
|
419
|
+
phase("complete");
|
|
420
|
+
return report;
|
|
421
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
export class WorkflowCancelledError extends Error {
|
|
2
|
+
constructor(message = "Workflow run was cancelled") {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "WorkflowCancelledError";
|
|
5
|
+
this.code = "WORKFLOW_CANCELLED";
|
|
6
|
+
this.outcome = "cancelled";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WorkflowTimeoutError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "WorkflowTimeoutError";
|
|
14
|
+
this.code = "WORKFLOW_TIMEOUT";
|
|
15
|
+
this.outcome = "timeout";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class WorkflowBudgetStoppedError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "WorkflowBudgetStoppedError";
|
|
23
|
+
this.code = "WORKFLOW_BUDGET_STOPPED";
|
|
24
|
+
this.outcome = "budget_stopped";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WorkflowAuthorityError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "WorkflowAuthorityError";
|
|
32
|
+
this.code = "WORKFLOW_AUTHORITY_VIOLATION";
|
|
33
|
+
this.outcome = "failure";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Structural failure of a live capability probe (e.g. the OpenCode session API
|
|
38
|
+
// returned a success-shaped response without a child session id). This is NOT
|
|
39
|
+
// evidence that a permission was denied — it means the probe could not run — so
|
|
40
|
+
// denialProbeResult must classify it as a non-verified (blocked) gate BEFORE the
|
|
41
|
+
// denial-text regex, never as observed denial.
|
|
42
|
+
export class WorkflowProbeStructuralError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "WorkflowProbeStructuralError";
|
|
46
|
+
this.code = "WORKFLOW_PROBE_STRUCTURAL";
|
|
47
|
+
this.outcome = "failure";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- lane failure taxonomy: transient (retryable) vs terminal (fail-fast) ----------------
|
|
52
|
+
//
|
|
53
|
+
// classifyLaneError partitions a thrown lane error into "transient" (a provider
|
|
54
|
+
// rate-limit / overload / transport fault that a backed-off retry routinely clears)
|
|
55
|
+
// vs "terminal" (a misconfiguration — bad/unknown model id, auth rejection, request
|
|
56
|
+
// schema — that no retry can fix). The lane retry loop only retries the transient
|
|
57
|
+
// class, and the budget gate is re-checked before every retry, so a misconfigured
|
|
58
|
+
// or persistently-overloaded lane fails fast instead of burning the retry/cost budget
|
|
59
|
+
// (the "retry storm" hazard called out in the bead). Unknown/unmatched errors default
|
|
60
|
+
// to "terminal" so the runtime never silently turns an unclassified fault into a retry
|
|
61
|
+
// storm; this strictly preserves today's single-attempt behavior for everything except
|
|
62
|
+
// the clearly-transient signals below.
|
|
63
|
+
|
|
64
|
+
// Workflow control-flow errors are never a lane-network retry candidate: cancellation,
|
|
65
|
+
// budget stops, authority violations, lane-deadline timeouts, and probe-structural
|
|
66
|
+
// failures must propagate immediately.
|
|
67
|
+
const NON_RETRYABLE_ERROR_CODES = new Set([
|
|
68
|
+
"WORKFLOW_CANCELLED",
|
|
69
|
+
"WORKFLOW_TIMEOUT",
|
|
70
|
+
"WORKFLOW_BUDGET_STOPPED",
|
|
71
|
+
"WORKFLOW_AUTHORITY_VIOLATION",
|
|
72
|
+
"WORKFLOW_PROBE_STRUCTURAL",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// HTTP statuses that mark a transient upstream condition (rate limit / overload /
|
|
76
|
+
// temporary unavailability) vs a terminal request-shape/auth/not-found rejection.
|
|
77
|
+
const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
|
|
78
|
+
const TERMINAL_HTTP_STATUSES = new Set([400, 401, 403, 404, 405, 422]);
|
|
79
|
+
|
|
80
|
+
// Substrings that mark a transient, retryable upstream condition: provider rate
|
|
81
|
+
// limits / overload, and transport faults a fresh attempt routinely clears.
|
|
82
|
+
const TRANSIENT_ERROR_PATTERNS = [
|
|
83
|
+
/\b429\b/,
|
|
84
|
+
/\b503\b/,
|
|
85
|
+
/\b529\b/,
|
|
86
|
+
/rate.?limit/i,
|
|
87
|
+
/overloaded/i,
|
|
88
|
+
/too many requests/i,
|
|
89
|
+
/temporar(?:il)?y unavailable/i,
|
|
90
|
+
/service unavailable/i,
|
|
91
|
+
/econnreset/i,
|
|
92
|
+
/econnrefused/i,
|
|
93
|
+
/etimedout/i,
|
|
94
|
+
/\bepipe\b/i,
|
|
95
|
+
/eai_again/i,
|
|
96
|
+
/socket hang ?up/i,
|
|
97
|
+
/connection (?:reset|closed|refused|aborted)/i,
|
|
98
|
+
/network (?:error|timeout)/i,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// Substrings that mark a TERMINAL condition no retry can fix: an unknown/bad model id,
|
|
102
|
+
// authentication/authorization rejection, and request-shape/schema validation. Checked
|
|
103
|
+
// before the transient patterns so a "model not found" 404 stays terminal.
|
|
104
|
+
const TERMINAL_ERROR_PATTERNS = [
|
|
105
|
+
/\b40[0-5]\b/,
|
|
106
|
+
/\b422\b/,
|
|
107
|
+
/unauthorized/i,
|
|
108
|
+
/forbidden/i,
|
|
109
|
+
/authentication/i,
|
|
110
|
+
/invalid api[_ -]?key/i,
|
|
111
|
+
/(?:unknown|invalid|unsupported|unrecognized|bad) model/i,
|
|
112
|
+
/model[^.]*(?:not found|does not exist|is not valid|is invalid)/i,
|
|
113
|
+
/no such model/i,
|
|
114
|
+
/provider[^.]*not (?:found|configured|supported)/i,
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function laneErrorStatus(error) {
|
|
118
|
+
for (const candidate of [error?.status, error?.statusCode, error?.response?.status, error?.cause?.status]) {
|
|
119
|
+
const n = Number(candidate);
|
|
120
|
+
if (Number.isInteger(n)) return n;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function laneErrorText(error) {
|
|
126
|
+
if (error == null) return "";
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (typeof error === "string") parts.push(error);
|
|
129
|
+
if (error.message) parts.push(String(error.message));
|
|
130
|
+
if (error.error) parts.push(typeof error.error === "string" ? error.error : String(error.error?.message ?? ""));
|
|
131
|
+
if (error.cause) parts.push(typeof error.cause === "string" ? error.cause : String(error.cause?.message ?? ""));
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function classifyLaneError(error) {
|
|
136
|
+
if (error?.code && NON_RETRYABLE_ERROR_CODES.has(error.code)) return "terminal";
|
|
137
|
+
const status = laneErrorStatus(error);
|
|
138
|
+
if (status !== undefined) {
|
|
139
|
+
if (TERMINAL_HTTP_STATUSES.has(status)) return "terminal";
|
|
140
|
+
if (TRANSIENT_HTTP_STATUSES.has(status)) return "transient";
|
|
141
|
+
}
|
|
142
|
+
const text = laneErrorText(error);
|
|
143
|
+
if (TERMINAL_ERROR_PATTERNS.some((pattern) => pattern.test(text))) return "terminal";
|
|
144
|
+
if (TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(text))) return "transient";
|
|
145
|
+
return "terminal";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Honor an upstream Retry-After. `error.retryAfterMs` is authoritative (already ms);
|
|
149
|
+
// a `retry-after` header / `error.retryAfter` is the HTTP convention (seconds).
|
|
150
|
+
export function retryAfterMsFromError(error) {
|
|
151
|
+
if (Number.isFinite(error?.retryAfterMs) && error.retryAfterMs >= 0) return Math.floor(error.retryAfterMs);
|
|
152
|
+
const headerValue = error?.headers?.["retry-after"] ?? error?.response?.headers?.["retry-after"] ?? error?.retryAfter;
|
|
153
|
+
const headerSeconds = Number(
|
|
154
|
+
headerValue,
|
|
155
|
+
);
|
|
156
|
+
if (Number.isFinite(headerSeconds) && headerSeconds >= 0) return Math.floor(headerSeconds * 1000);
|
|
157
|
+
const headerDate = Date.parse(String(headerValue ?? ""));
|
|
158
|
+
if (Number.isFinite(headerDate)) return Math.max(0, Math.floor(headerDate - Date.now()));
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const DEFAULT_LANE_RETRY_BASE_MS = 250;
|
|
163
|
+
export const MAX_LANE_RETRY_DELAY_MS = 30_000;
|
|
164
|
+
|
|
165
|
+
// Exponential backoff with full jitter, capped at MAX_LANE_RETRY_DELAY_MS. When the
|
|
166
|
+
// upstream supplied a Retry-After (retryAfterMs), that delay is honored (still capped)
|
|
167
|
+
// instead of the computed exponential one. `attempt` is the 1-based index of the retry
|
|
168
|
+
// that is about to be waited out (1 = first retry).
|
|
169
|
+
export function computeLaneBackoffMs(attempt, options = {}) {
|
|
170
|
+
const {
|
|
171
|
+
baseMs = DEFAULT_LANE_RETRY_BASE_MS,
|
|
172
|
+
maxMs = MAX_LANE_RETRY_DELAY_MS,
|
|
173
|
+
retryAfterMs,
|
|
174
|
+
jitter = Math.random,
|
|
175
|
+
} = options;
|
|
176
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs >= 0) return Math.min(Math.floor(retryAfterMs), maxMs);
|
|
177
|
+
const exponential = baseMs * 2 ** Math.max(0, attempt - 1);
|
|
178
|
+
const capped = Math.min(exponential, maxMs);
|
|
179
|
+
const half = capped / 2;
|
|
180
|
+
return Math.min(maxMs, Math.round(half + jitter() * half));
|
|
181
|
+
}
|