@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,1264 @@
|
|
|
1
|
+
// child-agent-runner.js — the child-agent lane boundary extracted from the workflow
|
|
2
|
+
// orchestrator (opencode-workflows-96b, stage 3 of the staged RunContext split).
|
|
3
|
+
//
|
|
4
|
+
// Owns runChildAgent (the full launch/cache/retry/integration/journal lifecycle of a
|
|
5
|
+
// single workflow lane) plus journalFailure (its terminal-failure recorder) and the
|
|
6
|
+
// lane-scoped helpers that exist only to serve a lane: worktree creation, edit-plan
|
|
7
|
+
// accumulation, patch normalization, lane task summaries, integration-lane lookup, and the
|
|
8
|
+
// resume cache-hit / checkpoint-hit discriminators.
|
|
9
|
+
//
|
|
10
|
+
// Coupling surface: every entry takes the shared mutable {@link RunContext} `run` object.
|
|
11
|
+
// A small `deps` bundle injects the orchestrator-resident primitives this boundary needs
|
|
12
|
+
// (concurrency slots, abort/cancel guards, durable-lifecycle polling, dirty-worktree
|
|
13
|
+
// salvage) so this module imports ONLY leaf kernel modules and never workflow-plugin.js,
|
|
14
|
+
// keeping the import graph acyclic (workflow-plugin.js -> sandbox-executor.js ->
|
|
15
|
+
// child-agent-runner.js, never back).
|
|
16
|
+
//
|
|
17
|
+
// @typedef {import("./run-context.js").RunContext} RunContext
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_RETRY_COUNT,
|
|
23
|
+
DEFAULT_CORRECTIVE_RETRY_COUNT,
|
|
24
|
+
DEFAULT_CHILD_CREATE_TIMEOUT_MS,
|
|
25
|
+
DEFAULT_CHILD_PROMPT_TIMEOUT_MS,
|
|
26
|
+
MAX_DEBUG_CAPTURE_FILE_BYTES,
|
|
27
|
+
MAX_CORRECTIVE_RETRY_COUNT,
|
|
28
|
+
MAX_CHILD_PROMPT_TIMEOUT_MS,
|
|
29
|
+
MAX_STATUS_STRING_CHARS,
|
|
30
|
+
} from "./constants.js";
|
|
31
|
+
import {
|
|
32
|
+
extractTextFromError,
|
|
33
|
+
hash,
|
|
34
|
+
redactDurableValue,
|
|
35
|
+
stableStringify,
|
|
36
|
+
textPart,
|
|
37
|
+
truncateText,
|
|
38
|
+
} from "./text-json.js";
|
|
39
|
+
import { redactFreeTextSecrets } from "./free-text-redactor.js";
|
|
40
|
+
import {
|
|
41
|
+
WorkflowAuthorityError,
|
|
42
|
+
WorkflowBudgetStoppedError,
|
|
43
|
+
WorkflowCancelledError,
|
|
44
|
+
classifyLaneError,
|
|
45
|
+
computeLaneBackoffMs,
|
|
46
|
+
retryAfterMsFromError,
|
|
47
|
+
} from "./errors.js";
|
|
48
|
+
import {
|
|
49
|
+
boundedSchemaSnapshot,
|
|
50
|
+
structuredCorrectiveInstruction,
|
|
51
|
+
structuredFormat,
|
|
52
|
+
structuredTextInstruction,
|
|
53
|
+
parseStructuredTextResult,
|
|
54
|
+
validateStructuredResult,
|
|
55
|
+
} from "./structured-output.js";
|
|
56
|
+
import { sessionApi } from "./session-access.js";
|
|
57
|
+
import { withTimeout } from "./async-util.js";
|
|
58
|
+
import { abortChild } from "./lifecycle-control.js";
|
|
59
|
+
import { unwrapClientResult } from "./capability-adapter.js";
|
|
60
|
+
import { checkBudgetBeforeLaunch, reserveLaneBudget, releaseLaneBudget } from "./budget-accounting.js";
|
|
61
|
+
import {
|
|
62
|
+
appendEvent,
|
|
63
|
+
appendIntegrationLedger,
|
|
64
|
+
claimResumeSignatureFallback,
|
|
65
|
+
laneFailureClassForError,
|
|
66
|
+
laneOutcomeForError,
|
|
67
|
+
laneSignature,
|
|
68
|
+
markResumeSignatureClaimed,
|
|
69
|
+
} from "./event-journal.js";
|
|
70
|
+
import {
|
|
71
|
+
readLaneResultCheckpoint,
|
|
72
|
+
recordLaneOutcome,
|
|
73
|
+
removeLaneCheckpoint,
|
|
74
|
+
safeProjectionName,
|
|
75
|
+
writeLaneCheckpoint,
|
|
76
|
+
writeFilePrivate,
|
|
77
|
+
writeLaneProjection,
|
|
78
|
+
writeState,
|
|
79
|
+
} from "./run-store-status.js";
|
|
80
|
+
import {
|
|
81
|
+
modelKey,
|
|
82
|
+
normalizeAgentOptions,
|
|
83
|
+
resolveLaneModel,
|
|
84
|
+
resolveLanePolicy,
|
|
85
|
+
resolveRequestedModel,
|
|
86
|
+
} from "./authority-policy.js";
|
|
87
|
+
import {
|
|
88
|
+
clearLaneEffort,
|
|
89
|
+
laneEffortPolicyForModel,
|
|
90
|
+
normalizeLaneEffort,
|
|
91
|
+
registerLaneEffort,
|
|
92
|
+
} from "./lane-effort-policy.js";
|
|
93
|
+
import { mergeRoleDefaults, resolveRole } from "./role-template-loading.js";
|
|
94
|
+
import { changedPathsSinceBase } from "./integration-mode.js";
|
|
95
|
+
import { createWorktreeAdapter } from "./worktree-adapter.js";
|
|
96
|
+
import { responseText } from "./text-json.js";
|
|
97
|
+
|
|
98
|
+
// --- lane-scoped helpers (owned here; re-exported by workflow-plugin.js for the barrel) ---
|
|
99
|
+
|
|
100
|
+
function permissionRuleKey(rule) {
|
|
101
|
+
return stableStringify({ permission: rule?.permission, pattern: rule?.pattern, action: rule?.action });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeCorrectiveRetries(value) {
|
|
105
|
+
if (Number.isInteger(value) && value >= 0) return Math.min(value, MAX_CORRECTIVE_RETRY_COUNT);
|
|
106
|
+
return DEFAULT_CORRECTIVE_RETRY_COUNT;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizePermissionRules(rules) {
|
|
110
|
+
return rules.map((rule) => ({ permission: rule?.permission, pattern: rule?.pattern, action: rule?.action }))
|
|
111
|
+
.sort((a, b) => permissionRuleKey(a).localeCompare(permissionRuleKey(b)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function extractEchoedSessionPermission(created) {
|
|
115
|
+
if (created?.data && Object.hasOwn(created.data, "permission")) return created.data.permission;
|
|
116
|
+
if (created && Object.hasOwn(created, "permission")) return created.permission;
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function sessionPermissionEchoStatus(created, expectedRules = []) {
|
|
121
|
+
const echoed = extractEchoedSessionPermission(created);
|
|
122
|
+
if (echoed === undefined) return { state: "not-echoed", expectedCount: expectedRules.length };
|
|
123
|
+
if (!Array.isArray(echoed)) {
|
|
124
|
+
return {
|
|
125
|
+
state: "mismatch",
|
|
126
|
+
expectedCount: expectedRules.length,
|
|
127
|
+
echoedCount: 0,
|
|
128
|
+
reason: "echoed permission field is not an array",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const expected = normalizePermissionRules(expectedRules);
|
|
133
|
+
const actual = normalizePermissionRules(echoed);
|
|
134
|
+
const expectedKeys = new Set(expected.map(permissionRuleKey));
|
|
135
|
+
const actualKeys = new Set(actual.map(permissionRuleKey));
|
|
136
|
+
const missing = expected.filter((rule) => !actualKeys.has(permissionRuleKey(rule)));
|
|
137
|
+
const unexpected = actual.filter((rule) => !expectedKeys.has(permissionRuleKey(rule)));
|
|
138
|
+
if (missing.length === 0 && unexpected.length === 0) {
|
|
139
|
+
return { state: "verified", expectedCount: expected.length, echoedCount: actual.length };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
state: "mismatch",
|
|
143
|
+
expectedCount: expected.length,
|
|
144
|
+
echoedCount: actual.length,
|
|
145
|
+
missing,
|
|
146
|
+
unexpected,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function laneTaskSummary(prompt, opts = {}, title) {
|
|
151
|
+
const explicit = opts.taskSummary || opts.summary || opts.label || opts.title;
|
|
152
|
+
if (explicit) return truncateText(redactFreeTextSecrets(String(explicit)), 160);
|
|
153
|
+
const firstLine = String(prompt ?? "").split(/\r?\n/).find((line) => line.trim());
|
|
154
|
+
return truncateText(redactFreeTextSecrets(firstLine || title || "workflow lane"), 160);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function durationMs(start, end) {
|
|
158
|
+
const startMs = typeof start === "string" ? Date.parse(start) : Number.NaN;
|
|
159
|
+
const endMs = typeof end === "string" ? Date.parse(end) : Number.NaN;
|
|
160
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return undefined;
|
|
161
|
+
return Math.max(0, endMs - startMs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function capUtf8Text(text, maxBytes = MAX_DEBUG_CAPTURE_FILE_BYTES) {
|
|
165
|
+
const string = String(text ?? "");
|
|
166
|
+
const bytes = Buffer.byteLength(string, "utf8");
|
|
167
|
+
if (bytes <= maxBytes) return string;
|
|
168
|
+
const marker = `\n[truncated to ${maxBytes} bytes from ${bytes} bytes]\n`;
|
|
169
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
170
|
+
const budget = Math.max(0, maxBytes - markerBytes);
|
|
171
|
+
let head = string;
|
|
172
|
+
while (Buffer.byteLength(head, "utf8") > budget && head.length > 0) {
|
|
173
|
+
const ratio = budget / Math.max(1, Buffer.byteLength(head, "utf8"));
|
|
174
|
+
head = head.slice(0, Math.max(0, Math.floor(head.length * ratio) - 1));
|
|
175
|
+
}
|
|
176
|
+
return `${head}${marker}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractMessagesArray(value) {
|
|
180
|
+
if (!value) return [];
|
|
181
|
+
if (Array.isArray(value)) return value;
|
|
182
|
+
if (Array.isArray(value.data)) return value.data;
|
|
183
|
+
if (value.data && Array.isArray(value.data.messages)) return value.data.messages;
|
|
184
|
+
if (value.data && Array.isArray(value.data.data)) return value.data.data;
|
|
185
|
+
if (Array.isArray(value.messages)) return value.messages;
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function promptDebugMarkdown({ callId, title, baseSystem, prompt, outputFormat }) {
|
|
190
|
+
return [
|
|
191
|
+
`# Workflow Lane Debug Capture`,
|
|
192
|
+
"",
|
|
193
|
+
`Call ID: ${redactFreeTextSecrets(callId)}`,
|
|
194
|
+
`Title: ${redactFreeTextSecrets(title)}`,
|
|
195
|
+
"",
|
|
196
|
+
"## Rendered System Prompt",
|
|
197
|
+
"",
|
|
198
|
+
redactFreeTextSecrets(baseSystem ?? ""),
|
|
199
|
+
"",
|
|
200
|
+
"## Task Prompt",
|
|
201
|
+
"",
|
|
202
|
+
redactFreeTextSecrets(prompt ?? ""),
|
|
203
|
+
"",
|
|
204
|
+
"## Output Format",
|
|
205
|
+
"",
|
|
206
|
+
"```json",
|
|
207
|
+
JSON.stringify(redactDurableValue(outputFormat ?? { type: "text" }), null, 2),
|
|
208
|
+
"```",
|
|
209
|
+
"",
|
|
210
|
+
].join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function transcriptJsonl(messages, maxBytes = MAX_DEBUG_CAPTURE_FILE_BYTES) {
|
|
214
|
+
let output = "";
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
let truncated = 0;
|
|
217
|
+
for (const message of messages) {
|
|
218
|
+
const line = `${JSON.stringify(redactDurableValue(message))}\n`;
|
|
219
|
+
const lineBytes = Buffer.byteLength(line, "utf8");
|
|
220
|
+
if (bytes + lineBytes > maxBytes) {
|
|
221
|
+
truncated += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
output += line;
|
|
225
|
+
bytes += lineBytes;
|
|
226
|
+
}
|
|
227
|
+
if (truncated > 0) {
|
|
228
|
+
const marker = `${JSON.stringify({ type: "debug_capture.truncated", skippedMessages: truncated, maxBytes })}\n`;
|
|
229
|
+
while (Buffer.byteLength(output + marker, "utf8") > maxBytes && output.length > 0) {
|
|
230
|
+
output = output.slice(0, Math.floor(output.length * 0.9));
|
|
231
|
+
}
|
|
232
|
+
output += marker;
|
|
233
|
+
}
|
|
234
|
+
return output;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function captureLaneDebugArtifacts(pluginContext, run, details) {
|
|
238
|
+
if (run.debugCapture?.enabled !== true || details.debugCaptured?.value === true) return;
|
|
239
|
+
details.debugCaptured.value = true;
|
|
240
|
+
const {
|
|
241
|
+
callId,
|
|
242
|
+
childID,
|
|
243
|
+
laneDirectory,
|
|
244
|
+
title,
|
|
245
|
+
prompt,
|
|
246
|
+
baseSystem,
|
|
247
|
+
schema,
|
|
248
|
+
outputFormat,
|
|
249
|
+
outcome,
|
|
250
|
+
} = details;
|
|
251
|
+
const debugDir = path.join(run.dir, "debug", safeProjectionName(callId));
|
|
252
|
+
try {
|
|
253
|
+
await writeFilePrivate(
|
|
254
|
+
path.join(debugDir, "prompt.md"),
|
|
255
|
+
capUtf8Text(promptDebugMarkdown({ callId, title, baseSystem, prompt, outputFormat })),
|
|
256
|
+
"utf8",
|
|
257
|
+
);
|
|
258
|
+
await writeFilePrivate(
|
|
259
|
+
path.join(debugDir, "schema.json"),
|
|
260
|
+
capUtf8Text(JSON.stringify(redactDurableValue(schema ?? null), null, 2)),
|
|
261
|
+
"utf8",
|
|
262
|
+
);
|
|
263
|
+
const session = sessionApi(pluginContext);
|
|
264
|
+
if (!childID || !session.has("messages")) {
|
|
265
|
+
await writeFilePrivate(
|
|
266
|
+
path.join(debugDir, "transcript.jsonl"),
|
|
267
|
+
transcriptJsonl([{ type: "debug_capture.unavailable", reason: childID ? "session.messages unavailable" : "missing childID" }]),
|
|
268
|
+
"utf8",
|
|
269
|
+
);
|
|
270
|
+
await appendEvent(run, { type: "debug_capture.transcript_unavailable", callId, childID, reason: childID ? "session.messages unavailable" : "missing childID" });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const transcript = unwrapClientResult(await session.messages({ sessionID: childID, directory: laneDirectory }), `Debug capture transcript read for ${childID}`);
|
|
274
|
+
await writeFilePrivate(
|
|
275
|
+
path.join(debugDir, "transcript.jsonl"),
|
|
276
|
+
transcriptJsonl(extractMessagesArray(transcript)),
|
|
277
|
+
"utf8",
|
|
278
|
+
);
|
|
279
|
+
await appendEvent(run, { type: "debug_capture.written", callId, childID, outcome, debugPath: `debug/${safeProjectionName(callId)}` });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
await appendEvent(run, { type: "debug_capture.failed", callId, childID, error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function findIntegrationLane(run, callId) {
|
|
286
|
+
return run.integrationPlan?.lanes?.find((lane) => lane.callId === callId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function normalizePatches(result) {
|
|
290
|
+
const patches = Array.isArray(result?.patches) ? result.patches : Array.isArray(result) ? result : [];
|
|
291
|
+
return patches.map((patch, index) => {
|
|
292
|
+
if (!patch || typeof patch !== "object") throw new Error(`Invalid edit patch at index ${index}`);
|
|
293
|
+
const relativePath = String(patch.path ?? patch.file ?? "");
|
|
294
|
+
if (!relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]+/).includes("..")) {
|
|
295
|
+
throw new Error(`Invalid edit patch path at index ${index}: ${relativePath}`);
|
|
296
|
+
}
|
|
297
|
+
if (!Object.hasOwn(patch, "content")) throw new Error(`Edit patch ${relativePath} is missing content`);
|
|
298
|
+
// Only full-file "replace" semantics are implemented at the apply sites
|
|
299
|
+
// (safeWriteFileWithinRoot does an unconditional whole-file write). Any other
|
|
300
|
+
// mode (e.g. "append") would otherwise be normalized, committed to the
|
|
301
|
+
// diff-plan hash, and then silently replace the file at apply time. Reject it
|
|
302
|
+
// here so the schema is honest rather than approving a plan we cannot honor.
|
|
303
|
+
const mode = patch.mode == null ? "replace" : String(patch.mode);
|
|
304
|
+
if (mode !== "replace") {
|
|
305
|
+
throw new Error(`Unsupported edit patch mode for ${relativePath}: ${mode} (only "replace" is supported)`);
|
|
306
|
+
}
|
|
307
|
+
// Drop the (now always-"replace") mode field rather than carrying it into the
|
|
308
|
+
// patch object: it was committed to computeDiffPlanHash but never read at any
|
|
309
|
+
// apply site, so persisting it would re-introduce a hashed-but-ignored field.
|
|
310
|
+
return { path: relativePath, content: String(patch.content) };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function addEditPlanFromResult(run, callId, result, worktreeRecord) {
|
|
315
|
+
const patches = normalizePatches(result);
|
|
316
|
+
for (const patch of patches) {
|
|
317
|
+
run.editPlan.patches.push({ ...patch, callId, worktreePath: worktreeRecord?.path });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function retagRecordsForCallId(records, originalCallId, callId) {
|
|
322
|
+
let count = 0;
|
|
323
|
+
if (!Array.isArray(records)) return count;
|
|
324
|
+
for (const record of records) {
|
|
325
|
+
if (record?.callId !== originalCallId) continue;
|
|
326
|
+
record.callId = callId;
|
|
327
|
+
count += 1;
|
|
328
|
+
}
|
|
329
|
+
return count;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function retagRehydratedLanePlan(run, originalCallId, callId, entry) {
|
|
333
|
+
const retagged = {
|
|
334
|
+
editPatches: 0,
|
|
335
|
+
editWorktrees: retagRecordsForCallId(run.editWorktrees, originalCallId, callId),
|
|
336
|
+
integrationLanes: 0,
|
|
337
|
+
integrationWorktrees: retagRecordsForCallId(run.integrationWorktrees, originalCallId, callId),
|
|
338
|
+
};
|
|
339
|
+
if (run.editPlan?.patches && entry?.worktreePath) {
|
|
340
|
+
for (const patch of run.editPlan.patches) {
|
|
341
|
+
if (patch?.callId !== originalCallId) continue;
|
|
342
|
+
patch.callId = callId;
|
|
343
|
+
retagged.editPatches += 1;
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
retagged.editPatches === 0
|
|
347
|
+
&& entry.result
|
|
348
|
+
&& typeof entry.result === "object"
|
|
349
|
+
&& !run.editPlan.patches.some((patch) => patch.callId === callId)
|
|
350
|
+
) {
|
|
351
|
+
addEditPlanFromResult(run, callId, entry.result, { path: entry.worktreePath });
|
|
352
|
+
retagged.editPatches = run.editPlan.patches.filter((patch) => patch.callId === callId).length;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (run.integrationPlan?.lanes && entry?.integrationLane) {
|
|
356
|
+
const oldLane = run.integrationPlan.lanes.find((lane) => lane?.callId === originalCallId);
|
|
357
|
+
const newLane = run.integrationPlan.lanes.find((lane) => lane?.callId === callId);
|
|
358
|
+
if (oldLane) {
|
|
359
|
+
oldLane.callId = callId;
|
|
360
|
+
retagged.integrationLanes = 1;
|
|
361
|
+
} else if (!newLane) {
|
|
362
|
+
run.integrationPlan.lanes.push({ ...entry.integrationLane, callId });
|
|
363
|
+
retagged.integrationLanes = 1;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return retagged;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function createEditWorktree(run, toolContext, callId) {
|
|
370
|
+
if (run.capabilities.worktree !== "available" || run.capabilities.directoryRooting !== "available") {
|
|
371
|
+
throw new WorkflowAuthorityError("Edit mode requires available native worktree and child directory-rooting capabilities");
|
|
372
|
+
}
|
|
373
|
+
const fallbackPath = path.join(run.dir, "worktrees", callId.replace(/[^a-z0-9_.-]+/gi, "_"));
|
|
374
|
+
await fs.mkdir(path.dirname(fallbackPath), { recursive: true });
|
|
375
|
+
const worktreeName = `workflow-${run.id}-${hash(callId).slice(0, 8)}`;
|
|
376
|
+
const branch = `workflow/${run.id}/${hash(callId).slice(0, 8)}`;
|
|
377
|
+
const created = await run.adapter.createWorktree({
|
|
378
|
+
name: worktreeName,
|
|
379
|
+
path: fallbackPath,
|
|
380
|
+
branch,
|
|
381
|
+
directory: toolContext.directory,
|
|
382
|
+
});
|
|
383
|
+
const worktreePath = created?.path || created?.directory || created?.dir || fallbackPath;
|
|
384
|
+
// Register the tracking record BEFORE the defensive mkdir below. createWorktree() has already
|
|
385
|
+
// created the real worktree dir + branch (workflow/<runId>/<hash>); cleanupWorktrees() and
|
|
386
|
+
// reclaimStrandedWorktrees() iterate ONLY run.editWorktrees / persisted state.editWorktrees, so
|
|
387
|
+
// if the trailing fs.mkdir throws (ENOSPC/EACCES/EMFILE under concurrent lanes, or an ancestor
|
|
388
|
+
// racing away) after an unregistered creation, the worktree+branch are orphaned with no code path
|
|
389
|
+
// that ever discovers them (recover() is never called). Pushing first makes a post-creation
|
|
390
|
+
// failure recoverable (opencode-workflows-ndsr).
|
|
391
|
+
const record = { callId, path: worktreePath, id: created?.id, name: created?.name ?? worktreeName, branch: created?.branch ?? branch };
|
|
392
|
+
run.editWorktrees.push(record);
|
|
393
|
+
await fs.mkdir(worktreePath, { recursive: true });
|
|
394
|
+
return record;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function createIntegrationLaneWorktree(pluginContext, run, toolContext, callId) {
|
|
398
|
+
if (!run.integrationPlan) throw new WorkflowAuthorityError("Integration lane requested without integration plan");
|
|
399
|
+
if (!run.worktreeAdapter) {
|
|
400
|
+
const primary = path.resolve(toolContext.worktree || toolContext.directory);
|
|
401
|
+
run.worktreeAdapter = await createWorktreeAdapter({
|
|
402
|
+
directory: primary,
|
|
403
|
+
worktreeRoot: path.join(path.dirname(primary), `${path.basename(primary)}.workflow-worktrees`),
|
|
404
|
+
integrationValidator: pluginContext?.__workflowIntegrationValidator,
|
|
405
|
+
signal: run.abortController.signal,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
const laneId = hash(callId).slice(0, 12);
|
|
409
|
+
const record = await run.worktreeAdapter.createLaneWorktree({
|
|
410
|
+
runId: run.id,
|
|
411
|
+
laneId,
|
|
412
|
+
baseRef: run.integrationPlan.baseCommit,
|
|
413
|
+
});
|
|
414
|
+
const normalized = { callId, laneId, ...record };
|
|
415
|
+
run.integrationWorktrees.push(normalized);
|
|
416
|
+
await appendIntegrationLedger(run, { phase: "lane-worktree-created", callId, laneId, path: normalized.path, branch: normalized.branch });
|
|
417
|
+
await writeLaneProjection(run, callId, { status: "worktree-created", laneId, worktreePath: normalized.path, branch: normalized.branch });
|
|
418
|
+
return normalized;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Resume cache-hit discriminator. The runChildAgent resume branch must reuse a prior journal
|
|
422
|
+
// entry whose signature + outcome match WITHOUT re-running the lane, but a recovered-from-
|
|
423
|
+
// transcript entry (workflow_salvage, tagged salvagedFromTranscript: true) is strictly weaker
|
|
424
|
+
// evidence than a controller-captured result, so it routes to a distinct cache.salvaged_hit
|
|
425
|
+
// event. Non-salvaged cache hits keep emitting cache.hit exactly as before. Returns null when
|
|
426
|
+
// the signature/outcome predicate is not satisfied (lane will be re-run or invalidated).
|
|
427
|
+
export function classifyResumeCacheHit(cached, sig) {
|
|
428
|
+
if (!cached || cached.signatureHash !== sig || cached.outcome !== "success") return null;
|
|
429
|
+
return cached.salvagedFromTranscript === true
|
|
430
|
+
? { kind: "salvaged-hit", eventType: "cache.salvaged_hit" }
|
|
431
|
+
: { kind: "hit", eventType: "cache.hit" };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Resume cache-hit discriminator for the durable lane checkpoint (234m.1). A result checkpoint
|
|
435
|
+
// written by THIS run's controller capture is trusted when its signatureHash matches the lane's
|
|
436
|
+
// expected signature: it is the same own-store evidence that would have reached journal.jsonl had
|
|
437
|
+
// the crash window not fallen between prompt-return and recordLaneOutcome. Returns a distinct
|
|
438
|
+
// cache.checkpoint_hit event -- separate from cache.hit (journal replay) and cache.salvaged_hit
|
|
439
|
+
// (transcript-recovered) -- so the three resume provenances stay observable: checkpoint
|
|
440
|
+
// (own-store, same signature, no journal entry yet) > journal (controller-captured, journaled) >
|
|
441
|
+
// salvage (transcript-recovered, weaker). Returns null when the checkpoint is absent or its
|
|
442
|
+
// signature does not match, so the lane falls through to the authoritative journal check / re-run.
|
|
443
|
+
export function checkpointHitForSignature(checkpoint, sig) {
|
|
444
|
+
if (!checkpoint || !sig || checkpoint.signatureHash !== sig) return null;
|
|
445
|
+
return { kind: "checkpoint-hit", eventType: "cache.checkpoint_hit", result: checkpoint.result };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Backoff sleep that bails the moment the lane (or its parent run) is aborted/cancelled,
|
|
449
|
+
// so a long Retry-After wait cannot defer a cancel/force-kill. Rejects with a
|
|
450
|
+
// WorkflowCancelledError on abort (terminal-classed: never retried) so the lane unwinds
|
|
451
|
+
// through the normal cancellation path instead of sleeping out the full delay.
|
|
452
|
+
function abortableBackoffSleep(ms, signal) {
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
if (signal?.aborted) {
|
|
455
|
+
reject(new WorkflowCancelledError("Lane aborted during retry backoff"));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
let onAbort;
|
|
459
|
+
const timer = setTimeout(() => {
|
|
460
|
+
if (onAbort) signal?.removeEventListener?.("abort", onAbort);
|
|
461
|
+
resolve();
|
|
462
|
+
}, Math.max(0, ms));
|
|
463
|
+
onAbort = () => {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
reject(new WorkflowCancelledError("Lane aborted during retry backoff"));
|
|
466
|
+
};
|
|
467
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Launch (or resume-cache-hit) a single child-agent lane.
|
|
473
|
+
*
|
|
474
|
+
* @param {object} pluginContext OpenCode plugin context.
|
|
475
|
+
* @param {object} toolContext Tool-call context (sessionID, directory, abort, ...).
|
|
476
|
+
* @param {RunContext} run Shared mutable run state.
|
|
477
|
+
* @param {object} payload Host "agent" op payload ({ callId, prompt, opts }).
|
|
478
|
+
* @param {object} deps Orchestrator-resident primitives injected to keep the import graph acyclic.
|
|
479
|
+
* @param {(run: RunContext, toolContext: object) => void} deps.throwIfAborted
|
|
480
|
+
* @param {(run: RunContext, callId: string) => void} deps.throwIfLaneCancelled
|
|
481
|
+
* @param {(run: RunContext, callId: string) => Promise<void>} deps.acquireAgentSlot
|
|
482
|
+
* @param {(run: RunContext) => void} deps.releaseAgentSlot
|
|
483
|
+
* @param {(run: RunContext) => Promise<void>} deps.checkDurableLifecycleRequest
|
|
484
|
+
* @param {(worktreePath: string|undefined, extra?: object) => Promise<object|undefined>} deps.dirtyWorktreeSalvage
|
|
485
|
+
*/
|
|
486
|
+
export async function runChildAgent(pluginContext, toolContext, run, payload, deps) {
|
|
487
|
+
const {
|
|
488
|
+
throwIfAborted,
|
|
489
|
+
throwIfLaneCancelled,
|
|
490
|
+
acquireAgentSlot,
|
|
491
|
+
releaseAgentSlot,
|
|
492
|
+
checkDurableLifecycleRequest,
|
|
493
|
+
dirtyWorktreeSalvage,
|
|
494
|
+
} = deps;
|
|
495
|
+
|
|
496
|
+
throwIfAborted(run, toolContext);
|
|
497
|
+
if (run.capabilities.childSession !== "available") {
|
|
498
|
+
throw new Error("Child sessions are unavailable in the active OpenCode client");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const prompt = String(payload.prompt ?? "");
|
|
502
|
+
let opts = payload.opts && typeof payload.opts === "object" ? payload.opts : {};
|
|
503
|
+
const callId = String(payload.callId ?? `agent:${run.agentsStarted}`);
|
|
504
|
+
const title = redactFreeTextSecrets(opts.label || `workflow ${run.id} ${callId}`);
|
|
505
|
+
const taskSummary = laneTaskSummary(prompt, opts, title);
|
|
506
|
+
let laneEnqueuedAt;
|
|
507
|
+
let laneStartedAt;
|
|
508
|
+
let model;
|
|
509
|
+
let effort;
|
|
510
|
+
let effortPolicy;
|
|
511
|
+
let agent;
|
|
512
|
+
let role;
|
|
513
|
+
let retryCount;
|
|
514
|
+
let correctiveRetries;
|
|
515
|
+
let schema;
|
|
516
|
+
let timeoutMs;
|
|
517
|
+
let baseSystem;
|
|
518
|
+
let policy;
|
|
519
|
+
let outputFormat;
|
|
520
|
+
let resolved;
|
|
521
|
+
let roleInfo;
|
|
522
|
+
let worktreeRecord;
|
|
523
|
+
let integrationLane = false;
|
|
524
|
+
let integrationLaneRecord;
|
|
525
|
+
let sig = hash(stableStringify({ sourceHash: run.sourceHash, runtimeArgs: run.runtimeArgs, prompt, opts: normalizeAgentOptions(opts), preflight: true }));
|
|
526
|
+
let childID;
|
|
527
|
+
let permissionEcho;
|
|
528
|
+
let acquired = false;
|
|
529
|
+
let failureJournaled = false;
|
|
530
|
+
const debugCaptured = { value: false };
|
|
531
|
+
const laneAbortController = new AbortController();
|
|
532
|
+
const runAbortListener = () => laneAbortController.abort();
|
|
533
|
+
run.abortController.signal.addEventListener("abort", runAbortListener, { once: true });
|
|
534
|
+
if (run.abortController.signal.aborted) laneAbortController.abort();
|
|
535
|
+
run.activeLaneAbortControllers?.set(callId, { abortController: laneAbortController, childID: undefined, directory: undefined });
|
|
536
|
+
|
|
537
|
+
async function journalFailure(error, returnedNullDueToFailure = false, outcomeDetails = {}) {
|
|
538
|
+
if (failureJournaled) return;
|
|
539
|
+
failureJournaled = true;
|
|
540
|
+
const outcome = laneOutcomeForError(error);
|
|
541
|
+
const failureClass = outcomeDetails.failureClass ?? laneFailureClassForError(error);
|
|
542
|
+
const retryable = outcomeDetails.retryable ?? (failureClass === "transient" || failureClass === "transient_exhausted" || failureClass === "validation_exhausted");
|
|
543
|
+
const salvage = await dirtyWorktreeSalvage(worktreeRecord?.path, {
|
|
544
|
+
callId,
|
|
545
|
+
laneId: worktreeRecord?.laneId,
|
|
546
|
+
branch: worktreeRecord?.branch,
|
|
547
|
+
childID,
|
|
548
|
+
outcome,
|
|
549
|
+
reason: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
|
|
550
|
+
});
|
|
551
|
+
await captureLaneDebugArtifacts(pluginContext, run, {
|
|
552
|
+
debugCaptured,
|
|
553
|
+
callId,
|
|
554
|
+
childID,
|
|
555
|
+
laneDirectory: worktreeRecord?.path || toolContext.directory,
|
|
556
|
+
title,
|
|
557
|
+
prompt,
|
|
558
|
+
baseSystem,
|
|
559
|
+
schema,
|
|
560
|
+
outputFormat,
|
|
561
|
+
outcome,
|
|
562
|
+
});
|
|
563
|
+
await recordLaneOutcome(run, {
|
|
564
|
+
callId,
|
|
565
|
+
signatureHash: sig,
|
|
566
|
+
outcome,
|
|
567
|
+
childID,
|
|
568
|
+
title,
|
|
569
|
+
taskSummary,
|
|
570
|
+
enqueuedAt: laneEnqueuedAt,
|
|
571
|
+
startedAt: laneStartedAt,
|
|
572
|
+
model: resolved?.modelKey,
|
|
573
|
+
agent,
|
|
574
|
+
role,
|
|
575
|
+
timeoutMs,
|
|
576
|
+
worktreePath: worktreeRecord?.path,
|
|
577
|
+
integrationLane: integrationLane ? findIntegrationLane(run, callId) : undefined,
|
|
578
|
+
salvage,
|
|
579
|
+
permissionPolicy: resolved?.policy,
|
|
580
|
+
permissionEcho,
|
|
581
|
+
error: extractTextFromError(error),
|
|
582
|
+
errorSummary: truncateText(redactFreeTextSecrets(extractTextFromError(error)), MAX_STATUS_STRING_CHARS),
|
|
583
|
+
// The retry decision input: only a transient-classed failure (incl. one that exhausted
|
|
584
|
+
// its backed-off retries) is worth re-attempting on a later resume; a terminal one
|
|
585
|
+
// (bad model id, auth, schema) is not.
|
|
586
|
+
failureClass,
|
|
587
|
+
retryable,
|
|
588
|
+
...outcomeDetails,
|
|
589
|
+
returnedNullDueToFailure,
|
|
590
|
+
});
|
|
591
|
+
if (salvage?.dirty) await appendEvent(run, { type: "agent.salvageable_dirty_failure", callId, childID, worktreePath: salvage.worktreePath, changedFileCount: salvage.changedFileCount, outcome });
|
|
592
|
+
await appendEvent(run, { type: `agent.${outcome}`, callId, childID, failureClass, error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
|
|
593
|
+
await writeState(run);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
role = typeof opts.role === "string" ? opts.role : undefined;
|
|
598
|
+
roleInfo = await resolveRole(role, pluginContext?.__workflowRoleDir);
|
|
599
|
+
opts = mergeRoleDefaults(roleInfo?.defaults, opts);
|
|
600
|
+
model = resolveRequestedModel(resolveLaneModel(run, opts), "child");
|
|
601
|
+
effort = normalizeLaneEffort(opts.effort);
|
|
602
|
+
effortPolicy = laneEffortPolicyForModel(effort, model);
|
|
603
|
+
agent = typeof opts.agent === "string" ? opts.agent : typeof opts.agentType === "string" ? opts.agentType : undefined;
|
|
604
|
+
retryCount = Number.isInteger(opts.retryCount) ? opts.retryCount : DEFAULT_RETRY_COUNT;
|
|
605
|
+
correctiveRetries = normalizeCorrectiveRetries(opts.correctiveRetries);
|
|
606
|
+
schema = opts.schema;
|
|
607
|
+
timeoutMs = Math.min(
|
|
608
|
+
Number.isInteger(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : (run.laneTimeoutMs ?? DEFAULT_CHILD_PROMPT_TIMEOUT_MS),
|
|
609
|
+
MAX_CHILD_PROMPT_TIMEOUT_MS,
|
|
610
|
+
);
|
|
611
|
+
const useNativeStructuredOutput = Boolean(schema && run.capabilities.structuredOutput === "available");
|
|
612
|
+
const useStructuredTextFallback = Boolean(schema && !useNativeStructuredOutput);
|
|
613
|
+
baseSystem = [
|
|
614
|
+
"You are a child worker for an OpenCode workflow.",
|
|
615
|
+
"Your final response is consumed as the workflow return value.",
|
|
616
|
+
"Be concise and return raw findings/results, not a conversational status update.",
|
|
617
|
+
roleInfo ? `Role ${roleInfo.name}:\n${roleInfo.content}` : "",
|
|
618
|
+
opts.system || "",
|
|
619
|
+
useStructuredTextFallback ? structuredTextInstruction(schema) : "",
|
|
620
|
+
]
|
|
621
|
+
.filter(Boolean)
|
|
622
|
+
.join("\n\n");
|
|
623
|
+
policy = resolveLanePolicy(run, opts);
|
|
624
|
+
outputFormat = useNativeStructuredOutput ? structuredFormat(schema) : { type: "text" };
|
|
625
|
+
resolved = {
|
|
626
|
+
opts,
|
|
627
|
+
modelKey: modelKey(model),
|
|
628
|
+
effort,
|
|
629
|
+
effortPolicy,
|
|
630
|
+
agent,
|
|
631
|
+
role,
|
|
632
|
+
rolePromptHash: roleInfo?.contentHash,
|
|
633
|
+
system: baseSystem,
|
|
634
|
+
schema,
|
|
635
|
+
outputFormat,
|
|
636
|
+
policy: {
|
|
637
|
+
mode: policy.mode,
|
|
638
|
+
policyMode: policy.policyMode,
|
|
639
|
+
authority: policy.authority,
|
|
640
|
+
shellPolicy: policy.shellPolicy,
|
|
641
|
+
mcpPolicy: policy.mcpPolicy,
|
|
642
|
+
tools: policy.tools,
|
|
643
|
+
permissionRules: policy.permissionRules,
|
|
644
|
+
secretGlobs: policy.secretGlobs,
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
sig = laneSignature(run, prompt, resolved);
|
|
648
|
+
|
|
649
|
+
const cached = run.resumeJournal.get(callId);
|
|
650
|
+
const cacheHit = classifyResumeCacheHit(cached, sig);
|
|
651
|
+
if (cacheHit) {
|
|
652
|
+
markResumeSignatureClaimed(run, callId);
|
|
653
|
+
run.cacheStats.hits += 1;
|
|
654
|
+
await appendEvent(run, {
|
|
655
|
+
type: cacheHit.eventType,
|
|
656
|
+
callId,
|
|
657
|
+
...(cacheHit.kind === "salvaged-hit" ? { salvagedFromTranscript: true } : {}),
|
|
658
|
+
});
|
|
659
|
+
// Do NOT re-accumulate this cache hit's spend: prior historical spend (live +
|
|
660
|
+
// replayed) was already folded into run.replayedTokens/run.replayedCost at resume
|
|
661
|
+
// (see rehydrateRunFromPriorState). Adding it again here would double-count prior
|
|
662
|
+
// spend and trip budget ceilings at a fraction of the configured limit (R9). This
|
|
663
|
+
// mirrors the agentsStarted replay model, where a replaced (cache-hit) lane does
|
|
664
|
+
// not re-increment the carried-forward counter. A salvaged hit reuses the recovered
|
|
665
|
+
// result the same way (no re-run, no new spend) but emits cache.salvaged_hit so the
|
|
666
|
+
// weaker transcript-recovered provenance stays distinguishable from a real capture.
|
|
667
|
+
return cached.result;
|
|
668
|
+
}
|
|
669
|
+
const signatureFallback = claimResumeSignatureFallback(run, callId, sig);
|
|
670
|
+
if (signatureFallback) {
|
|
671
|
+
run.cacheStats.hits += 1;
|
|
672
|
+
const originalCallId = String(signatureFallback.callId);
|
|
673
|
+
const retaggedPlan = retagRehydratedLanePlan(run, originalCallId, callId, signatureFallback);
|
|
674
|
+
const integrationLaneRecord = signatureFallback.integrationLane
|
|
675
|
+
? { ...signatureFallback.integrationLane, callId }
|
|
676
|
+
: undefined;
|
|
677
|
+
await recordLaneOutcome(run, {
|
|
678
|
+
callId,
|
|
679
|
+
signatureHash: sig,
|
|
680
|
+
outcome: "success",
|
|
681
|
+
childID: signatureFallback.childID,
|
|
682
|
+
title: signatureFallback.title ?? title,
|
|
683
|
+
taskSummary: signatureFallback.taskSummary ?? taskSummary,
|
|
684
|
+
enqueuedAt: signatureFallback.enqueuedAt,
|
|
685
|
+
startedAt: signatureFallback.startedAt,
|
|
686
|
+
queueWaitMs: signatureFallback.queueWaitMs,
|
|
687
|
+
model: signatureFallback.model ?? resolved.modelKey,
|
|
688
|
+
agent: signatureFallback.agent ?? agent,
|
|
689
|
+
role: signatureFallback.role ?? role,
|
|
690
|
+
rolePromptHash: signatureFallback.rolePromptHash ?? roleInfo?.contentHash,
|
|
691
|
+
timeoutMs: signatureFallback.timeoutMs ?? timeoutMs,
|
|
692
|
+
worktreePath: signatureFallback.worktreePath,
|
|
693
|
+
integrationLane: integrationLaneRecord,
|
|
694
|
+
permissionPolicy: signatureFallback.permissionPolicy ?? resolved.policy,
|
|
695
|
+
permissionEcho: signatureFallback.permissionEcho,
|
|
696
|
+
result: signatureFallback.result,
|
|
697
|
+
salvagedFromTranscript: signatureFallback.salvagedFromTranscript === true || undefined,
|
|
698
|
+
matchedViaSignatureFallback: true,
|
|
699
|
+
originalCallId,
|
|
700
|
+
replayedOutcome: true,
|
|
701
|
+
});
|
|
702
|
+
await appendEvent(run, {
|
|
703
|
+
type: "cache.signature_hit",
|
|
704
|
+
callId,
|
|
705
|
+
originalCallId,
|
|
706
|
+
signatureHash: sig,
|
|
707
|
+
...(signatureFallback.salvagedFromTranscript === true ? { salvagedFromTranscript: true } : {}),
|
|
708
|
+
retaggedPlan,
|
|
709
|
+
});
|
|
710
|
+
await writeState(run);
|
|
711
|
+
return signatureFallback.result;
|
|
712
|
+
}
|
|
713
|
+
// Workflow-native lane checkpoint (234m.1): a durable lanes/<callId>.result.json written
|
|
714
|
+
// around session.prompt can recover a completed result when the crash window fell between
|
|
715
|
+
// prompt-return and recordLaneOutcome. Journal entries are authoritative, so the checkpoint
|
|
716
|
+
// path is evaluated only after a same-signature resume-journal hit has been ruled out. A
|
|
717
|
+
// successful checkpoint recovery records the lane, then retires the checkpoint so later
|
|
718
|
+
// resumes route through the journal cache and cannot re-increment lane tallies.
|
|
719
|
+
const checkpoint = cached ? null : await readLaneResultCheckpoint(run.dir, callId);
|
|
720
|
+
const checkpointHit = checkpointHitForSignature(checkpoint, sig);
|
|
721
|
+
if (checkpointHit) {
|
|
722
|
+
run.cacheStats.hits += 1;
|
|
723
|
+
// Replay this lane's diff-plan contribution BEFORE recording the outcome. The
|
|
724
|
+
// integration-commit / addEditPlanFromResult step runs AFTER the result checkpoint is
|
|
725
|
+
// written, and its effects (run.integrationPlan.lanes / run.editPlan.patches) are only
|
|
726
|
+
// persisted by the trailing writeState. On a checkpoint-hit resume the rehydrated plan
|
|
727
|
+
// (from the pre-crash state.json) lacks this lane's changes, so restoring it here from the
|
|
728
|
+
// checkpoint's captured descriptor is what keeps recovery from journaling outcome:'success'
|
|
729
|
+
// while silently dropping the lane's patches (opencode-workflows-zi0f). Guarded so a repeated
|
|
730
|
+
// recovery cannot double-append.
|
|
731
|
+
if (checkpoint.integrationLane && run.integrationPlan && !findIntegrationLane(run, callId)) {
|
|
732
|
+
run.integrationPlan.lanes.push(checkpoint.integrationLane);
|
|
733
|
+
} else if (checkpoint.worktreePath && run.editPlan && checkpointHit.result && typeof checkpointHit.result === "object"
|
|
734
|
+
&& !run.editPlan.patches?.some((patch) => patch.callId === callId)) {
|
|
735
|
+
addEditPlanFromResult(run, callId, checkpointHit.result, { path: checkpoint.worktreePath });
|
|
736
|
+
}
|
|
737
|
+
await recordLaneOutcome(run, {
|
|
738
|
+
callId,
|
|
739
|
+
signatureHash: sig,
|
|
740
|
+
outcome: "success",
|
|
741
|
+
childID: checkpoint.childID,
|
|
742
|
+
title,
|
|
743
|
+
taskSummary,
|
|
744
|
+
enqueuedAt: checkpoint.enqueuedAt,
|
|
745
|
+
startedAt: checkpoint.startedAt,
|
|
746
|
+
queueWaitMs: checkpoint.queueWaitMs,
|
|
747
|
+
model: checkpoint.model ?? resolved.modelKey,
|
|
748
|
+
agent: checkpoint.agent ?? agent,
|
|
749
|
+
role: checkpoint.role ?? role,
|
|
750
|
+
rolePromptHash: roleInfo?.contentHash,
|
|
751
|
+
timeoutMs: checkpoint.timeoutMs ?? timeoutMs,
|
|
752
|
+
worktreePath: checkpoint.worktreePath,
|
|
753
|
+
integrationLane: checkpoint.integrationLane,
|
|
754
|
+
permissionPolicy: checkpoint.permissionPolicy ?? resolved.policy,
|
|
755
|
+
permissionEcho: checkpoint.permissionEcho,
|
|
756
|
+
result: checkpointHit.result,
|
|
757
|
+
recoveredFromCheckpoint: true,
|
|
758
|
+
checkpointSignatureHash: checkpoint.signatureHash,
|
|
759
|
+
checkpointCapturedAt: checkpoint.capturedAt,
|
|
760
|
+
});
|
|
761
|
+
await appendEvent(run, {
|
|
762
|
+
type: checkpointHit.eventType,
|
|
763
|
+
callId,
|
|
764
|
+
recoveredFromCheckpoint: true,
|
|
765
|
+
checkpointSignatureHash: checkpoint.signatureHash,
|
|
766
|
+
checkpointCapturedAt: checkpoint.capturedAt,
|
|
767
|
+
});
|
|
768
|
+
await removeLaneCheckpoint(run.dir, callId, "result");
|
|
769
|
+
await removeLaneCheckpoint(run.dir, callId, "request");
|
|
770
|
+
await writeState(run);
|
|
771
|
+
return checkpointHit.result;
|
|
772
|
+
}
|
|
773
|
+
if (cached) {
|
|
774
|
+
run.cacheStats.invalidated += 1;
|
|
775
|
+
await appendEvent(run, { type: "cache.invalidated", callId, previousOutcome: cached.outcome ?? "unknown" });
|
|
776
|
+
} else if (run.resumeJournal.size > 0) {
|
|
777
|
+
run.cacheStats.misses += 1;
|
|
778
|
+
await appendEvent(run, { type: "cache.miss", callId });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
await checkDurableLifecycleRequest(run);
|
|
782
|
+
throwIfLaneCancelled(run, callId);
|
|
783
|
+
laneEnqueuedAt = new Date().toISOString();
|
|
784
|
+
await acquireAgentSlot(run, callId);
|
|
785
|
+
acquired = true;
|
|
786
|
+
laneStartedAt = new Date().toISOString();
|
|
787
|
+
const laneQueueWaitMs = durationMs(laneEnqueuedAt, laneStartedAt);
|
|
788
|
+
throwIfAborted(run, toolContext);
|
|
789
|
+
await checkDurableLifecycleRequest(run);
|
|
790
|
+
throwIfLaneCancelled(run, callId);
|
|
791
|
+
const replacesPriorLane = Boolean(cached);
|
|
792
|
+
if (!replacesPriorLane && run.agentsStarted >= run.maxAgents) {
|
|
793
|
+
throw new WorkflowBudgetStoppedError(`Workflow exceeded maxAgents=${run.maxAgents}`);
|
|
794
|
+
}
|
|
795
|
+
checkBudgetBeforeLaunch(run);
|
|
796
|
+
if (!replacesPriorLane) run.agentsStarted += 1;
|
|
797
|
+
|
|
798
|
+
integrationLane = run.authority.integration === true && opts.worktreeEdit === true && opts.edit !== true;
|
|
799
|
+
if ((opts.edit === true || opts.worktreeEdit === true) && !(run.authority.edit || run.authority.worktreeEdit || run.authority.integration)) {
|
|
800
|
+
throw new WorkflowAuthorityError("Edit lane requested without workflow edit authority");
|
|
801
|
+
}
|
|
802
|
+
if ((opts.edit === true || opts.worktreeEdit === true) && !integrationLane && !opts.schema) {
|
|
803
|
+
// Edit lanes contribute to the diff plan only when they return structured patches
|
|
804
|
+
// (addEditPlanFromResult requires an object). A schemaless edit lane would silently
|
|
805
|
+
// discard its work and leak a worktree, so reject it up front.
|
|
806
|
+
throw new WorkflowAuthorityError("Edit lanes must declare a schema that returns { patches: [...] }");
|
|
807
|
+
}
|
|
808
|
+
if (integrationLane) {
|
|
809
|
+
worktreeRecord = await createIntegrationLaneWorktree(pluginContext, run, toolContext, callId);
|
|
810
|
+
} else if (opts.edit === true || opts.worktreeEdit === true) {
|
|
811
|
+
worktreeRecord = await createEditWorktree(run, toolContext, callId);
|
|
812
|
+
}
|
|
813
|
+
const laneDirectory = worktreeRecord?.path || toolContext.directory;
|
|
814
|
+
const activeLane = run.activeLaneAbortControllers?.get(callId);
|
|
815
|
+
if (activeLane) activeLane.directory = laneDirectory;
|
|
816
|
+
const session = sessionApi(pluginContext);
|
|
817
|
+
const createBody = { parentID: toolContext.sessionID, title, directory: laneDirectory };
|
|
818
|
+
if (policy.policyMode === "permission-ruleset") createBody.permission = policy.permissionRules;
|
|
819
|
+
const childCreateTimeoutMs = Number.isFinite(pluginContext?.__workflowChildCreateTimeoutMs) && pluginContext.__workflowChildCreateTimeoutMs > 0
|
|
820
|
+
? pluginContext.__workflowChildCreateTimeoutMs
|
|
821
|
+
: DEFAULT_CHILD_CREATE_TIMEOUT_MS;
|
|
822
|
+
const promptBody = {
|
|
823
|
+
...(agent ? { agent } : {}),
|
|
824
|
+
...(model ? { model } : {}),
|
|
825
|
+
system: baseSystem,
|
|
826
|
+
...(useNativeStructuredOutput ? { format: outputFormat } : {}),
|
|
827
|
+
parts: [textPart(prompt)],
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Bounded lane-level retry with two independent classes:
|
|
831
|
+
// - retryCount covers transient infrastructure failures and recreates a clean child session.
|
|
832
|
+
// - correctiveRetries covers malformed structured output and re-prompts the SAME child session
|
|
833
|
+
// with validation feedback, preserving its transcript. Corrective attempts are still budget-
|
|
834
|
+
// gated and spend-accounted because they run through the same prompt path below.
|
|
835
|
+
const maxAttempts = 1 + (Number.isInteger(retryCount) && retryCount >= 0 ? retryCount : DEFAULT_RETRY_COUNT);
|
|
836
|
+
let transientAttempt = 1;
|
|
837
|
+
let promptAttempt = 1;
|
|
838
|
+
let correctiveAttempts = 0;
|
|
839
|
+
let pendingCorrectiveMessage = null;
|
|
840
|
+
const correctivePromptBody = (validationMessage) => ({
|
|
841
|
+
...(agent ? { agent } : {}),
|
|
842
|
+
...(model ? { model } : {}),
|
|
843
|
+
...(useNativeStructuredOutput ? { format: outputFormat } : {}),
|
|
844
|
+
parts: [textPart(structuredCorrectiveInstruction(schema, validationMessage))],
|
|
845
|
+
});
|
|
846
|
+
const finalValidationFailureClass = () => correctiveRetries > 0 ? "validation_exhausted" : "terminal";
|
|
847
|
+
let result;
|
|
848
|
+
let tokens;
|
|
849
|
+
let cost;
|
|
850
|
+
for (;;) {
|
|
851
|
+
const attempt = promptAttempt;
|
|
852
|
+
const correctiveTurn = pendingCorrectiveMessage !== null && Boolean(childID);
|
|
853
|
+
const bodyForAttempt = correctiveTurn ? correctivePromptBody(pendingCorrectiveMessage) : promptBody;
|
|
854
|
+
pendingCorrectiveMessage = null;
|
|
855
|
+
// Re-check the shared budget ceiling before each (re)attempt so retries stay inside the same
|
|
856
|
+
// gate as the initial launch; a budget stop here is terminal-classed and never retried.
|
|
857
|
+
checkBudgetBeforeLaunch(run);
|
|
858
|
+
throwIfAborted(run, toolContext);
|
|
859
|
+
throwIfLaneCancelled(run, callId);
|
|
860
|
+
// Reserve this attempt's conservative budget slice SYNCHRONOUSLY here — no await between the
|
|
861
|
+
// ceiling check above and this reservation — so a concurrent lane's checkBudgetBeforeLaunch
|
|
862
|
+
// observes the in-flight commitment and a wave of up to `concurrency` lanes cannot each clear
|
|
863
|
+
// the gate on stale (pre-spend) counters and collectively overshoot maxCost/maxTokens by up to
|
|
864
|
+
// (concurrency-1) unreported lanes (opencode-workflows-dx1n). Reconciled in the finally below:
|
|
865
|
+
// released after the real prompt spend is folded into run.cost/run.tokens (success) or after a
|
|
866
|
+
// failed attempt that accrued no spend (retry/terminal), so a retry re-reserves cleanly and the
|
|
867
|
+
// lane's own reservation never trips its own next-attempt ceiling check.
|
|
868
|
+
const laneReservation = reserveLaneBudget(run);
|
|
869
|
+
try {
|
|
870
|
+
if (!correctiveTurn) {
|
|
871
|
+
let createDetached = false;
|
|
872
|
+
const createPromise = Promise.resolve().then(() => session.create(createBody));
|
|
873
|
+
createPromise.then(async (created) => {
|
|
874
|
+
if (!createDetached) return;
|
|
875
|
+
const lateChild = unwrapClientResult(created, `Child session creation for ${callId}`);
|
|
876
|
+
const lateChildID = lateChild?.data?.id;
|
|
877
|
+
if (lateChildID) await abortChild(pluginContext, lateChildID, laneDirectory);
|
|
878
|
+
}).catch(() => {
|
|
879
|
+
// Create already failed; the lane failure path handles the original timeout/cancel error.
|
|
880
|
+
});
|
|
881
|
+
let child;
|
|
882
|
+
try {
|
|
883
|
+
child = unwrapClientResult(await withTimeout(
|
|
884
|
+
() => createPromise,
|
|
885
|
+
{
|
|
886
|
+
timeoutMs: childCreateTimeoutMs,
|
|
887
|
+
signal: laneAbortController.signal,
|
|
888
|
+
label: `Child session creation for ${callId}`,
|
|
889
|
+
onTimeout: () => { createDetached = true; },
|
|
890
|
+
},
|
|
891
|
+
), `Child session creation for ${callId}`);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
createDetached = true;
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
createDetached = false;
|
|
897
|
+
childID = child?.data?.id;
|
|
898
|
+
if (!childID) throw new Error("OpenCode did not return a child session id");
|
|
899
|
+
run.children.set(childID, laneDirectory);
|
|
900
|
+
registerLaneEffort(childID, effortPolicy);
|
|
901
|
+
if (activeLane) activeLane.childID = childID;
|
|
902
|
+
// Reset the per-lane abort guard for this fresh child session. A prior attempt's retry
|
|
903
|
+
// teardown (or a prompt-timeout abort) set activeLane.childAbortRequested = true against the
|
|
904
|
+
// OLD childID; leaving it true would make every abortChild gate (this lane's prompt-timeout
|
|
905
|
+
// handler, sandbox-executor fanout-cancel / settlePendingHostOps, lifecycle-control
|
|
906
|
+
// abortRunChildren) silently skip aborting the brand-new attempt-2 child on cancel/kill/
|
|
907
|
+
// timeout, letting it keep running and accruing cost (opencode-workflows-dvww).
|
|
908
|
+
if (activeLane) activeLane.childAbortRequested = false;
|
|
909
|
+
throwIfLaneCancelled(run, callId);
|
|
910
|
+
|
|
911
|
+
permissionEcho = policy.policyMode === "permission-ruleset"
|
|
912
|
+
? sessionPermissionEchoStatus(child, policy.permissionRules)
|
|
913
|
+
: { state: "not-requested" };
|
|
914
|
+
if (permissionEcho.state === "mismatch") {
|
|
915
|
+
await writeLaneProjection(run, callId, {
|
|
916
|
+
status: "permission-mismatch",
|
|
917
|
+
enqueuedAt: laneEnqueuedAt,
|
|
918
|
+
startedAt: laneStartedAt,
|
|
919
|
+
queueWaitMs: laneQueueWaitMs,
|
|
920
|
+
attempt,
|
|
921
|
+
childID,
|
|
922
|
+
title,
|
|
923
|
+
taskSummary,
|
|
924
|
+
model: resolved.modelKey,
|
|
925
|
+
agent,
|
|
926
|
+
role,
|
|
927
|
+
timeoutMs,
|
|
928
|
+
policyMode: policy.policyMode,
|
|
929
|
+
worktreePath: worktreeRecord?.path,
|
|
930
|
+
integrationLane,
|
|
931
|
+
permissionPolicy: resolved.policy,
|
|
932
|
+
permissionEcho,
|
|
933
|
+
signatureHash: sig,
|
|
934
|
+
});
|
|
935
|
+
await appendEvent(run, { type: "agent.permission_mismatch", callId, childID, permissionEcho });
|
|
936
|
+
throw new WorkflowAuthorityError(`Child session permission echo mismatch for ${callId}`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
await writeLaneProjection(run, callId, {
|
|
940
|
+
status: "running",
|
|
941
|
+
enqueuedAt: laneEnqueuedAt,
|
|
942
|
+
startedAt: laneStartedAt,
|
|
943
|
+
queueWaitMs: laneQueueWaitMs,
|
|
944
|
+
attempt,
|
|
945
|
+
childID,
|
|
946
|
+
title,
|
|
947
|
+
taskSummary,
|
|
948
|
+
model: resolved.modelKey,
|
|
949
|
+
agent,
|
|
950
|
+
role,
|
|
951
|
+
timeoutMs,
|
|
952
|
+
policyMode: policy.policyMode,
|
|
953
|
+
worktreePath: worktreeRecord?.path,
|
|
954
|
+
integrationLane,
|
|
955
|
+
permissionPolicy: resolved.policy,
|
|
956
|
+
permissionEcho,
|
|
957
|
+
// Persist the lane signature so an interrupted run's transcript-salvage
|
|
958
|
+
// (workflow_salvage) can stamp the recovered journal entry with the same cache
|
|
959
|
+
// identity the resume path checks, letting a salvaged read-only result cache-hit
|
|
960
|
+
// through the normal signature branch without weakening that invariant.
|
|
961
|
+
signatureHash: sig,
|
|
962
|
+
});
|
|
963
|
+
await appendEvent(run, { type: "agent.started", callId, childID, title, model: resolved.modelKey, policyMode: policy.policyMode, permissionEcho, attempt });
|
|
964
|
+
await writeState(run);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Durable lane checkpoint (234m.1): capture prompt-time intent BEFORE issuing the prompt so a
|
|
968
|
+
// crash anywhere in the prompt->capture->journal window leaves a forensics trail and (for the
|
|
969
|
+
// result half) a recoverable own-store result. childID is known by this point (session.create
|
|
970
|
+
// returned above), so it is recorded here. Best-effort: a checkpoint write failure must never
|
|
971
|
+
// break a run (journal.jsonl remains authoritative); it is surfaced as a diagnostic event only.
|
|
972
|
+
try {
|
|
973
|
+
await writeLaneCheckpoint(run.dir, callId, "request", {
|
|
974
|
+
runId: run.id,
|
|
975
|
+
callId,
|
|
976
|
+
signatureHash: sig,
|
|
977
|
+
promptHash: hash(prompt),
|
|
978
|
+
schemaHash: schema ? hash(stableStringify(schema)) : undefined,
|
|
979
|
+
schemaSnapshot: boundedSchemaSnapshot(schema),
|
|
980
|
+
enqueuedAt: laneEnqueuedAt,
|
|
981
|
+
startedAt: laneStartedAt,
|
|
982
|
+
queueWaitMs: laneQueueWaitMs,
|
|
983
|
+
childID,
|
|
984
|
+
model: resolved.modelKey,
|
|
985
|
+
agent,
|
|
986
|
+
role,
|
|
987
|
+
attempt,
|
|
988
|
+
correctiveAttempts: correctiveAttempts || undefined,
|
|
989
|
+
writtenAt: new Date().toISOString(),
|
|
990
|
+
});
|
|
991
|
+
} catch (error) {
|
|
992
|
+
await appendEvent(run, { type: "cache.checkpoint_write_failed", callId, kind: "request", error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const lastResult = unwrapClientResult(await withTimeout(
|
|
996
|
+
() => session.prompt({ sessionID: childID, directory: laneDirectory, body: bodyForAttempt }),
|
|
997
|
+
{
|
|
998
|
+
timeoutMs,
|
|
999
|
+
signal: laneAbortController.signal,
|
|
1000
|
+
label: `Child prompt for ${callId}`,
|
|
1001
|
+
onTimeout: () => {
|
|
1002
|
+
const lane = run.activeLaneAbortControllers?.get(callId);
|
|
1003
|
+
if (lane) {
|
|
1004
|
+
if (lane.childAbortRequested === true) return undefined;
|
|
1005
|
+
lane.childAbortRequested = true;
|
|
1006
|
+
}
|
|
1007
|
+
return abortChild(pluginContext, childID, laneDirectory);
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
), `Child prompt for ${callId}`);
|
|
1011
|
+
|
|
1012
|
+
const info = lastResult?.data?.info ?? {};
|
|
1013
|
+
tokens = {
|
|
1014
|
+
input: info.tokens?.input ?? 0,
|
|
1015
|
+
output: info.tokens?.output ?? 0,
|
|
1016
|
+
reasoning: info.tokens?.reasoning ?? 0,
|
|
1017
|
+
};
|
|
1018
|
+
cost = info.cost ?? 0;
|
|
1019
|
+
run.tokens.input += tokens.input;
|
|
1020
|
+
run.tokens.output += tokens.output;
|
|
1021
|
+
run.tokens.reasoning += tokens.reasoning;
|
|
1022
|
+
run.cost += cost;
|
|
1023
|
+
|
|
1024
|
+
if (useNativeStructuredOutput) {
|
|
1025
|
+
result = run.adapter.getStructured(lastResult);
|
|
1026
|
+
if (result === undefined) throw new Error(`Native structured workflow result was missing at ${run.capabilities.structuredOutputField}`);
|
|
1027
|
+
} else if (useStructuredTextFallback) {
|
|
1028
|
+
const rawText = responseText(lastResult);
|
|
1029
|
+
try {
|
|
1030
|
+
result = parseStructuredTextResult(rawText);
|
|
1031
|
+
} catch (parseError) {
|
|
1032
|
+
const validationMessage = truncateText(extractTextFromError(parseError), MAX_STATUS_STRING_CHARS);
|
|
1033
|
+
if (correctiveAttempts < correctiveRetries) {
|
|
1034
|
+
parseError.laneRetryKind = "correctable";
|
|
1035
|
+
parseError.laneValidationMessage = validationMessage;
|
|
1036
|
+
throw parseError;
|
|
1037
|
+
}
|
|
1038
|
+
await journalFailure(parseError, false, {
|
|
1039
|
+
rawInvalidStructuredOutput: truncateText(redactFreeTextSecrets(rawText), MAX_STATUS_STRING_CHARS),
|
|
1040
|
+
failureClass: finalValidationFailureClass(),
|
|
1041
|
+
retryable: correctiveRetries > 0,
|
|
1042
|
+
correctiveAttempts: correctiveAttempts || undefined,
|
|
1043
|
+
});
|
|
1044
|
+
throw parseError;
|
|
1045
|
+
}
|
|
1046
|
+
} else {
|
|
1047
|
+
result = responseText(lastResult);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (schema) {
|
|
1051
|
+
try {
|
|
1052
|
+
validateStructuredResult(schema, result);
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
const validationMessage = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
|
|
1055
|
+
if (correctiveAttempts < correctiveRetries) {
|
|
1056
|
+
error.laneRetryKind = "correctable";
|
|
1057
|
+
error.laneValidationMessage = validationMessage;
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
await journalFailure(error, false, {
|
|
1061
|
+
rawInvalidStructuredOutput: redactFreeTextSecrets(result),
|
|
1062
|
+
failureClass: finalValidationFailureClass(),
|
|
1063
|
+
retryable: correctiveRetries > 0,
|
|
1064
|
+
correctiveAttempts: correctiveAttempts || undefined,
|
|
1065
|
+
});
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
break; // attempt succeeded — leave the retry loop with result/tokens/cost set
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
if (error?.laneRetryKind === "correctable" && correctiveAttempts < correctiveRetries && childID) {
|
|
1072
|
+
correctiveAttempts += 1;
|
|
1073
|
+
pendingCorrectiveMessage = truncateText(error.laneValidationMessage ?? extractTextFromError(error), MAX_STATUS_STRING_CHARS);
|
|
1074
|
+
await appendEvent(run, {
|
|
1075
|
+
type: "agent.corrective_retry",
|
|
1076
|
+
callId,
|
|
1077
|
+
childID,
|
|
1078
|
+
attempt,
|
|
1079
|
+
nextAttempt: promptAttempt + 1,
|
|
1080
|
+
correctiveAttempt: correctiveAttempts,
|
|
1081
|
+
maxCorrectiveRetries: correctiveRetries,
|
|
1082
|
+
failureClass: "correctable",
|
|
1083
|
+
error: pendingCorrectiveMessage,
|
|
1084
|
+
});
|
|
1085
|
+
promptAttempt += 1;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
const transient = classifyLaneError(error) === "transient";
|
|
1089
|
+
if (!transient || transientAttempt >= maxAttempts) {
|
|
1090
|
+
// Terminal failure (or a transient one that exhausted its retries): rethrow with childID
|
|
1091
|
+
// intact so journalFailure records the failing child and the outer finally performs the
|
|
1092
|
+
// SAME single teardown as before retries existed (no extra abortChild here — the timeout
|
|
1093
|
+
// path's onTimeout already aborted the child). Tag an exhausted-transient error so the
|
|
1094
|
+
// failure class stays distinguishable from a terminal one.
|
|
1095
|
+
if (transient) error.laneFailureClass = "transient_exhausted";
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
// About to retry: proactively tear down this attempt's child so the next attempt starts from
|
|
1099
|
+
// a clean childID and we never leak an orphaned session across the backoff wait.
|
|
1100
|
+
if (childID) {
|
|
1101
|
+
try {
|
|
1102
|
+
if (activeLane) activeLane.childAbortRequested = true;
|
|
1103
|
+
await abortChild(pluginContext, childID, laneDirectory);
|
|
1104
|
+
} catch { /* best-effort teardown */ }
|
|
1105
|
+
run.children.delete(childID);
|
|
1106
|
+
clearLaneEffort(childID);
|
|
1107
|
+
if (activeLane && activeLane.childID === childID) activeLane.childID = undefined;
|
|
1108
|
+
childID = undefined;
|
|
1109
|
+
}
|
|
1110
|
+
const delayMs = computeLaneBackoffMs(transientAttempt, { retryAfterMs: retryAfterMsFromError(error) });
|
|
1111
|
+
await appendEvent(run, {
|
|
1112
|
+
type: "agent.retry",
|
|
1113
|
+
callId,
|
|
1114
|
+
attempt: transientAttempt,
|
|
1115
|
+
nextAttempt: transientAttempt + 1,
|
|
1116
|
+
maxAttempts,
|
|
1117
|
+
delayMs,
|
|
1118
|
+
failureClass: "transient",
|
|
1119
|
+
error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
|
|
1120
|
+
});
|
|
1121
|
+
transientAttempt += 1;
|
|
1122
|
+
promptAttempt += 1;
|
|
1123
|
+
await abortableBackoffSleep(delayMs, laneAbortController.signal);
|
|
1124
|
+
} finally {
|
|
1125
|
+
// Reconcile this attempt's reservation regardless of outcome (success break, terminal
|
|
1126
|
+
// rethrow, or retry fall-through). Runs after the real spend was folded into run.cost/tokens
|
|
1127
|
+
// on success and before the next attempt re-reserves on retry (opencode-workflows-dx1n).
|
|
1128
|
+
releaseLaneBudget(run, laneReservation);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Register this lane's file changes into the diff plan FIRST: an integration lane commits its
|
|
1133
|
+
// worktree and appends the committed descriptor to run.integrationPlan.lanes; a plain edit lane
|
|
1134
|
+
// folds its structured patches into run.editPlan.patches. Both effects are in-memory and only
|
|
1135
|
+
// become durable at the trailing writeState, so the result checkpoint is written AFTER this
|
|
1136
|
+
// block (below) — never before it — and captures the contribution so a checkpoint-hit resume can
|
|
1137
|
+
// replay it (234m.1 / opencode-workflows-zi0f).
|
|
1138
|
+
if (integrationLane && worktreeRecord) {
|
|
1139
|
+
const committed = await run.worktreeAdapter.commit({ directory: worktreeRecord.path, message: `workflow ${run.id} ${callId}` });
|
|
1140
|
+
const paths = committed.committed ? await changedPathsSinceBase(worktreeRecord.path, run.integrationPlan.baseCommit) : [];
|
|
1141
|
+
integrationLaneRecord = {
|
|
1142
|
+
callId,
|
|
1143
|
+
laneId: worktreeRecord.laneId,
|
|
1144
|
+
path: worktreeRecord.path,
|
|
1145
|
+
branch: worktreeRecord.branch,
|
|
1146
|
+
commit: committed.commit,
|
|
1147
|
+
committed: committed.committed,
|
|
1148
|
+
paths,
|
|
1149
|
+
};
|
|
1150
|
+
run.integrationPlan.lanes.push(integrationLaneRecord);
|
|
1151
|
+
await appendIntegrationLedger(run, { phase: "lane-committed", callId, laneId: worktreeRecord.laneId, branch: worktreeRecord.branch, commit: committed.commit, committed: committed.committed, paths });
|
|
1152
|
+
await writeLaneProjection(run, callId, { status: "committed", integrationLane: integrationLaneRecord });
|
|
1153
|
+
await appendEvent(run, { type: "integration.lane_committed", callId, branch: worktreeRecord.branch, commit: committed.commit, pathCount: paths.length });
|
|
1154
|
+
} else if (worktreeRecord && result && typeof result === "object") {
|
|
1155
|
+
addEditPlanFromResult(run, callId, result, worktreeRecord);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Durable lane checkpoint (234m.1): atomically capture the controller's validated result plus
|
|
1159
|
+
// the diff-plan contribution computed just above (worktreePath + the committed integration-lane
|
|
1160
|
+
// descriptor). Written AFTER the integration-commit / addEditPlanFromResult block, and AFTER
|
|
1161
|
+
// schema validation, so the crash window is [contribution-computed, recordLaneOutcome): if the
|
|
1162
|
+
// owning process dies here, a resumed run recovers this same own-store result from
|
|
1163
|
+
// lanes/<callId>.result.json (cache.checkpoint_hit) AND replays the captured contribution back
|
|
1164
|
+
// into run.integrationPlan.lanes / run.editPlan.patches without re-running the lane, committing
|
|
1165
|
+
// git, or reading any transcript. A crash BEFORE this write leaves no checkpoint, so the lane is
|
|
1166
|
+
// re-run cleanly on resume rather than recorded as a success with a dropped contribution
|
|
1167
|
+
// (opencode-workflows-zi0f). Best-effort: a write failure must never break a run; journal.jsonl
|
|
1168
|
+
// remains authoritative.
|
|
1169
|
+
try {
|
|
1170
|
+
await writeLaneCheckpoint(run.dir, callId, "result", {
|
|
1171
|
+
runId: run.id,
|
|
1172
|
+
callId,
|
|
1173
|
+
signatureHash: sig,
|
|
1174
|
+
result,
|
|
1175
|
+
enqueuedAt: laneEnqueuedAt,
|
|
1176
|
+
startedAt: laneStartedAt,
|
|
1177
|
+
queueWaitMs: laneQueueWaitMs,
|
|
1178
|
+
childID,
|
|
1179
|
+
model: resolved.modelKey,
|
|
1180
|
+
agent,
|
|
1181
|
+
role,
|
|
1182
|
+
timeoutMs,
|
|
1183
|
+
permissionPolicy: resolved.policy,
|
|
1184
|
+
permissionEcho,
|
|
1185
|
+
worktreePath: worktreeRecord?.path,
|
|
1186
|
+
integrationLane: integrationLaneRecord,
|
|
1187
|
+
capturedAt: new Date().toISOString(),
|
|
1188
|
+
});
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
await appendEvent(run, { type: "cache.checkpoint_write_failed", callId, kind: "result", error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
await captureLaneDebugArtifacts(pluginContext, run, {
|
|
1194
|
+
debugCaptured,
|
|
1195
|
+
callId,
|
|
1196
|
+
childID,
|
|
1197
|
+
laneDirectory,
|
|
1198
|
+
title,
|
|
1199
|
+
prompt,
|
|
1200
|
+
baseSystem,
|
|
1201
|
+
schema,
|
|
1202
|
+
outputFormat,
|
|
1203
|
+
outcome: "success",
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
await recordLaneOutcome(run, {
|
|
1207
|
+
callId,
|
|
1208
|
+
signatureHash: sig,
|
|
1209
|
+
outcome: "success",
|
|
1210
|
+
childID,
|
|
1211
|
+
title,
|
|
1212
|
+
taskSummary,
|
|
1213
|
+
enqueuedAt: laneEnqueuedAt,
|
|
1214
|
+
startedAt: laneStartedAt,
|
|
1215
|
+
queueWaitMs: laneQueueWaitMs,
|
|
1216
|
+
model: resolved.modelKey,
|
|
1217
|
+
agent,
|
|
1218
|
+
role,
|
|
1219
|
+
rolePromptHash: roleInfo?.contentHash,
|
|
1220
|
+
timeoutMs,
|
|
1221
|
+
worktreePath: worktreeRecord?.path,
|
|
1222
|
+
integrationLane: integrationLaneRecord,
|
|
1223
|
+
permissionPolicy: resolved.policy,
|
|
1224
|
+
permissionEcho,
|
|
1225
|
+
result,
|
|
1226
|
+
tokens,
|
|
1227
|
+
cost,
|
|
1228
|
+
correctiveAttempts: correctiveAttempts || undefined,
|
|
1229
|
+
});
|
|
1230
|
+
// The journal entry has superseded the crash-window checkpoint (journal.jsonl is
|
|
1231
|
+
// authoritative), so retire the narrow-window artifacts. This keeps the checkpoint from
|
|
1232
|
+
// shadowing the canonical cache.hit on the next resume (a surviving result.json would route
|
|
1233
|
+
// a normally-journaled lane through cache.checkpoint_hit instead). Removed only on the
|
|
1234
|
+
// success path AFTER recordLaneOutcome, so a crash before journaling leaves result.json in
|
|
1235
|
+
// place for recovery, and a checkpoint-hit resume (which returns above before journaling)
|
|
1236
|
+
// also leaves it in place for repeated recovery. Best-effort: removal never throws.
|
|
1237
|
+
await removeLaneCheckpoint(run.dir, callId, "result");
|
|
1238
|
+
await removeLaneCheckpoint(run.dir, callId, "request");
|
|
1239
|
+
await appendEvent(run, { type: "agent.completed", callId, childID, tokens, cost });
|
|
1240
|
+
await writeState(run);
|
|
1241
|
+
return result;
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
try {
|
|
1244
|
+
await journalFailure(error, opts.onFailure === "returnNull" && laneOutcomeForError(error) !== "cancelled");
|
|
1245
|
+
} catch (journalError) {
|
|
1246
|
+
// A fallible I/O step inside journalFailure (dirtyWorktreeSalvage, recordLaneOutcome,
|
|
1247
|
+
// appendEvent, writeState) threw while recording the ORIGINAL lane failure. Never let that
|
|
1248
|
+
// secondary error replace `error`: record it as a diagnostic and fall through so the original
|
|
1249
|
+
// lane error surfaces and the returnNull contract below still holds.
|
|
1250
|
+
run.diagnostics ??= {};
|
|
1251
|
+
run.diagnostics.journalFailureError = truncateText(extractTextFromError(journalError), MAX_STATUS_STRING_CHARS);
|
|
1252
|
+
}
|
|
1253
|
+
if (opts.onFailure === "returnNull" && laneOutcomeForError(error) !== "cancelled") return null;
|
|
1254
|
+
throw error;
|
|
1255
|
+
} finally {
|
|
1256
|
+
if (acquired) releaseAgentSlot(run);
|
|
1257
|
+
run.activeLaneAbortControllers?.delete(callId);
|
|
1258
|
+
if (childID) {
|
|
1259
|
+
run.children?.delete(childID);
|
|
1260
|
+
clearLaneEffort(childID);
|
|
1261
|
+
}
|
|
1262
|
+
run.abortController.signal.removeEventListener("abort", runAbortListener);
|
|
1263
|
+
}
|
|
1264
|
+
}
|