@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,3017 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { integrateLaneCommits } from "./integration-mode.js";
|
|
8
|
+
import { createExtensionRegistry } from "./extension-registry.js";
|
|
9
|
+
import { assertWritableWorkflowPath, safeWriteFileWithinRoot } from "./path-policy.js";
|
|
10
|
+
import {
|
|
11
|
+
BUNDLED_COMMAND_DIR,
|
|
12
|
+
BUNDLED_SKILL_DIR,
|
|
13
|
+
BUNDLED_WORKFLOW_DIR,
|
|
14
|
+
DEFAULT_CHILD_PROMPT_TIMEOUT_MS,
|
|
15
|
+
DEFAULT_CONCURRENCY,
|
|
16
|
+
DEFAULT_GUEST_DEADLINE_MS,
|
|
17
|
+
DEFAULT_MAX_AGENTS,
|
|
18
|
+
DEFAULT_SUBPROCESS_MAX_BUFFER,
|
|
19
|
+
DEFAULT_SUBPROCESS_TIMEOUT_MS,
|
|
20
|
+
HARD_CONCURRENCY_LIMIT,
|
|
21
|
+
LANE_OUTCOMES,
|
|
22
|
+
MAX_CHILD_PROMPT_TIMEOUT_MS,
|
|
23
|
+
OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV,
|
|
24
|
+
MAX_STATUS_STRING_CHARS,
|
|
25
|
+
SECRET_GLOBS,
|
|
26
|
+
normalizeHardConcurrencyLimit,
|
|
27
|
+
resolveOpencodeConfigDir,
|
|
28
|
+
} from "./constants.js";
|
|
29
|
+
import {
|
|
30
|
+
extractTextFromError,
|
|
31
|
+
hash,
|
|
32
|
+
hashStable,
|
|
33
|
+
jsonPreview,
|
|
34
|
+
redactDurableValue,
|
|
35
|
+
stableStringify,
|
|
36
|
+
truncateText,
|
|
37
|
+
} from "./text-json.js";
|
|
38
|
+
import {
|
|
39
|
+
WorkflowCancelledError,
|
|
40
|
+
WorkflowBudgetStoppedError,
|
|
41
|
+
} from "./errors.js";
|
|
42
|
+
import { sessionApi } from "./session-access.js";
|
|
43
|
+
import { redactFreeTextSecrets } from "./free-text-redactor.js";
|
|
44
|
+
import {
|
|
45
|
+
cancelRun,
|
|
46
|
+
clearNotificationRuntimeState,
|
|
47
|
+
deliverWorkflowNotifications,
|
|
48
|
+
idleNotificationSessions,
|
|
49
|
+
killRun,
|
|
50
|
+
maybeDeliverCompletionNotification,
|
|
51
|
+
pauseRun,
|
|
52
|
+
abortRunChildren,
|
|
53
|
+
rejectWaitingAgents,
|
|
54
|
+
updateNotificationIdleState,
|
|
55
|
+
writeCompletionNotification,
|
|
56
|
+
} from "./lifecycle-control.js";
|
|
57
|
+
import {
|
|
58
|
+
assertLiveGateProbeAllowed,
|
|
59
|
+
collectTextParts,
|
|
60
|
+
createCapabilityAdapter,
|
|
61
|
+
liveGateReport,
|
|
62
|
+
promoteCapabilities,
|
|
63
|
+
promoteVerifiedGateCapabilities,
|
|
64
|
+
unwrapClientResult,
|
|
65
|
+
verifyRequiredAuthorityGates,
|
|
66
|
+
verifyNetworkMcpAuthorityGates,
|
|
67
|
+
VERIFIED_PROBE_TTL_MS,
|
|
68
|
+
} from "./capability-adapter.js";
|
|
69
|
+
import {
|
|
70
|
+
normalizeBudgetCeilings,
|
|
71
|
+
} from "./budget-accounting.js";
|
|
72
|
+
import {
|
|
73
|
+
approvalSnapshotList,
|
|
74
|
+
approvalHash,
|
|
75
|
+
computeDiffPlanHash,
|
|
76
|
+
} from "./approval-hashing.js";
|
|
77
|
+
import {
|
|
78
|
+
appendApplyLedger,
|
|
79
|
+
appendEvent,
|
|
80
|
+
appendIntegrationLedger,
|
|
81
|
+
appendValidationLedger,
|
|
82
|
+
buildResumeSignatureIndex,
|
|
83
|
+
compactJournal,
|
|
84
|
+
applyLedgerHasCompleted,
|
|
85
|
+
computeDomainMutationHash,
|
|
86
|
+
countNonEmptyLines,
|
|
87
|
+
domainMutationApprovalManifestForRun,
|
|
88
|
+
domainMutationManifest,
|
|
89
|
+
finalizeStagedDomainMutations,
|
|
90
|
+
loadJournal,
|
|
91
|
+
stagedDomainMutationManifest,
|
|
92
|
+
} from "./event-journal.js";
|
|
93
|
+
import {
|
|
94
|
+
acquireWorkflowLock,
|
|
95
|
+
assertSafeRunId,
|
|
96
|
+
cleanupRuns,
|
|
97
|
+
computeSalvageCandidates,
|
|
98
|
+
ensurePrivateDir,
|
|
99
|
+
ensureRunRoot,
|
|
100
|
+
isPathInside,
|
|
101
|
+
lifecycleRequestPath,
|
|
102
|
+
lockPathForRun,
|
|
103
|
+
readLock,
|
|
104
|
+
readLaneProjections,
|
|
105
|
+
readLaneRequestCheckpoint,
|
|
106
|
+
readLifecycleRequests,
|
|
107
|
+
readRunById,
|
|
108
|
+
reconcileRuns,
|
|
109
|
+
rehydrateRunFromPriorState,
|
|
110
|
+
runDirForRoot,
|
|
111
|
+
runRoots,
|
|
112
|
+
runs,
|
|
113
|
+
statusText,
|
|
114
|
+
eventsText,
|
|
115
|
+
timeoutResumeEligibilityForState,
|
|
116
|
+
writeFilePrivate,
|
|
117
|
+
writeJsonAtomic,
|
|
118
|
+
writeSalvagedLaneOutcome,
|
|
119
|
+
writeState,
|
|
120
|
+
} from "./run-store-status.js";
|
|
121
|
+
import {
|
|
122
|
+
createWorkflowToastEventSink,
|
|
123
|
+
showToast,
|
|
124
|
+
startWorkflowProgressToasts,
|
|
125
|
+
workflowApplyToastCard,
|
|
126
|
+
workflowHeartbeatToastCard,
|
|
127
|
+
workflowTerminalToastCard,
|
|
128
|
+
} from "./notification-toast.js";
|
|
129
|
+
import { applyLaneEffortParams } from "./lane-effort-policy.js";
|
|
130
|
+
import {
|
|
131
|
+
AD_HOC_AUTHORITY_PROFILE,
|
|
132
|
+
WORKFLOW_AUTHORITY_PROFILES,
|
|
133
|
+
assertWriteWorkflowAllowed,
|
|
134
|
+
authorityAutoApproveTier,
|
|
135
|
+
authorityArgsForWorkflow,
|
|
136
|
+
authoritySummary,
|
|
137
|
+
autoApproveCovers,
|
|
138
|
+
configureWorkflowPermissions,
|
|
139
|
+
effectiveAutoApproveCeiling,
|
|
140
|
+
modelKey,
|
|
141
|
+
normalizeAutoApproveTier,
|
|
142
|
+
resolveDrainMode,
|
|
143
|
+
resolveLaneModel,
|
|
144
|
+
resolveRequestedModel,
|
|
145
|
+
resolveRunAuthority,
|
|
146
|
+
} from "./authority-policy.js";
|
|
147
|
+
import {
|
|
148
|
+
buildNestedSnapshots,
|
|
149
|
+
hasExplicitWorkflowSource,
|
|
150
|
+
isTrustedWorkflowPath,
|
|
151
|
+
parseWorkflowSource,
|
|
152
|
+
resolveWorkflowSourceForStart,
|
|
153
|
+
} from "./workflow-source.js";
|
|
154
|
+
import {
|
|
155
|
+
listRoles,
|
|
156
|
+
listTemplates,
|
|
157
|
+
listWorkflows,
|
|
158
|
+
saveTemplate,
|
|
159
|
+
saveWorkflow,
|
|
160
|
+
} from "./role-template-loading.js";
|
|
161
|
+
import { readJsonFile } from "./run-store-status.js";
|
|
162
|
+
import { ajv, compileSchemaWithIdentity, validateStructuredResult } from "./structured-output.js";
|
|
163
|
+
// Re-exported below (and surfaced through index.js + the kernel barrel) so the public
|
|
164
|
+
// workflow-plugin export contract and the __test surface are preserved after the
|
|
165
|
+
// child-agent-runner / sandbox-executor extraction.
|
|
166
|
+
import {
|
|
167
|
+
addEditPlanFromResult,
|
|
168
|
+
checkpointHitForSignature,
|
|
169
|
+
classifyResumeCacheHit,
|
|
170
|
+
createEditWorktree,
|
|
171
|
+
normalizePatches,
|
|
172
|
+
runChildAgent,
|
|
173
|
+
} from "./child-agent-runner.js";
|
|
174
|
+
import { executeSandbox, runNestedWorkflow } from "./sandbox-executor.js";
|
|
175
|
+
import { inlineResultProjection } from "./result-readback.js";
|
|
176
|
+
|
|
177
|
+
const execFileAsync = promisify(execFile);
|
|
178
|
+
|
|
179
|
+
// Coupling-surface contract for the shared mutable run object threaded through this
|
|
180
|
+
// orchestrator and the extracted boundaries (sandbox-executor.js, child-agent-runner.js).
|
|
181
|
+
// @typedef {import("./run-context.js").RunContext} RunContext
|
|
182
|
+
|
|
183
|
+
// Terminal states a run may be resumed from (jbs3.1). "paused"/"interrupted" are
|
|
184
|
+
// cooperative/force-kill stops. "failed" (a lane threw a non-cancellation error) and
|
|
185
|
+
// "budget_stopped" (a cost/token/agent ceiling was hit) are ALSO resumable: their
|
|
186
|
+
// completed lanes are journaled and replay from cache at zero re-spend, so resuming
|
|
187
|
+
// re-runs only the failed/remaining lanes instead of stranding finished work. An
|
|
188
|
+
// explicit operator "cancelled" stays NON-resumable by design (cancel == abandon).
|
|
189
|
+
const RESUMABLE_STATUSES = new Set(["paused", "interrupted", "failed", "budget_stopped"]);
|
|
190
|
+
|
|
191
|
+
// Background heuristic (jbs3.7, mfv9.6). A foreground run blocks the calling agent inside
|
|
192
|
+
// workflow_run for the whole execution, so the agent cannot workflow_pause / workflow_cancel its
|
|
193
|
+
// own run mid-flight -- only the human ESC-to-interrupt path can. For a large fan-out that is a real
|
|
194
|
+
// loss of control. Wide, deep, or explicitly long runs default to background when background is
|
|
195
|
+
// omitted; explicit args.background and resume-pinned priorState.background still win.
|
|
196
|
+
const BACKGROUND_RECOMMEND_MAX_AGENTS = 8;
|
|
197
|
+
const BACKGROUND_RECOMMEND_WAVES = 3;
|
|
198
|
+
const BACKGROUND_RECOMMEND_DURATION_MS = 10 * 60 * 1000;
|
|
199
|
+
async function gitOutput(directory, args) {
|
|
200
|
+
const { stdout } = await execFileAsync("git", ["-C", directory, ...args], { encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER });
|
|
201
|
+
return stdout.trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function gitHead(directory) {
|
|
205
|
+
try {
|
|
206
|
+
return await gitOutput(directory, ["rev-parse", "HEAD"]);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new Error(`Workflow edit/apply requires a Git worktree with an initial commit: ${extractTextFromError(error)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function assertGitCleanAtBase(directory, baseCommit) {
|
|
213
|
+
const head = await gitHead(directory);
|
|
214
|
+
if (head !== baseCommit) throw new Error(`baseCommit mismatch: primary HEAD is ${head}, expected ${baseCommit}`);
|
|
215
|
+
const status = await gitOutput(directory, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
216
|
+
const dirty = status.split(/\r?\n/).filter((line) => {
|
|
217
|
+
if (!line.trim()) return false;
|
|
218
|
+
const rel = line.slice(3).replace(/\\/g, "/");
|
|
219
|
+
return !rel.startsWith(".opencode/workflows/runs/");
|
|
220
|
+
});
|
|
221
|
+
if (dirty.length > 0) {
|
|
222
|
+
throw new Error(`Primary Git worktree is dirty; refusing apply: ${dirty.slice(0, 5).join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function gitPathTracked(directory, relativePath) {
|
|
227
|
+
try {
|
|
228
|
+
await execFileAsync("git", ["-C", directory, "ls-files", "--error-unmatch", "--", relativePath], { encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER });
|
|
229
|
+
return true;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseGitStatusFiles(status) {
|
|
236
|
+
return status.split(/\r?\n/).filter(Boolean).map((line) => {
|
|
237
|
+
const statusCode = line.slice(0, 2).trim() || "modified";
|
|
238
|
+
const rawPath = line.slice(3).trim();
|
|
239
|
+
const file = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() : rawPath;
|
|
240
|
+
return { status: statusCode, path: file.replace(/\\/g, "/") };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function dirtyWorktreeSalvage(worktreePath, extra = {}) {
|
|
245
|
+
if (!worktreePath) return undefined;
|
|
246
|
+
let status;
|
|
247
|
+
try {
|
|
248
|
+
status = await gitOutput(worktreePath, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return { ...extra, dirty: undefined, worktreePath, error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) };
|
|
251
|
+
}
|
|
252
|
+
const changedFiles = parseGitStatusFiles(status);
|
|
253
|
+
if (changedFiles.length === 0) return undefined;
|
|
254
|
+
let diffStat = "";
|
|
255
|
+
try {
|
|
256
|
+
diffStat = await gitOutput(worktreePath, ["diff", "--stat", "HEAD", "--"]);
|
|
257
|
+
} catch {
|
|
258
|
+
// Diff stat is best-effort salvage metadata; status remains authoritative.
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
...extra,
|
|
262
|
+
dirty: true,
|
|
263
|
+
worktreePath,
|
|
264
|
+
changedFiles,
|
|
265
|
+
changedFileCount: changedFiles.length,
|
|
266
|
+
diffStat,
|
|
267
|
+
statusLines: status.split(/\r?\n/).filter(Boolean),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseCommandMarkdown(source, fallbackDescription) {
|
|
272
|
+
const normalized = String(source ?? "").replace(/\r\n/g, "\n");
|
|
273
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
274
|
+
if (!match) return { description: fallbackDescription, template: normalized.trim() };
|
|
275
|
+
|
|
276
|
+
const description = match[1].match(/^description:\s*(.+?)\s*$/m)?.[1]?.replace(/^['"]|['"]$/g, "") ?? fallbackDescription;
|
|
277
|
+
return { description, template: match[2].trimStart() };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function registerBundledCommand(cfg, name, commandPath, fallbackDescription) {
|
|
281
|
+
if (cfg.command[name]) return;
|
|
282
|
+
try {
|
|
283
|
+
const source = await fs.readFile(commandPath, "utf8");
|
|
284
|
+
cfg.command[name] = parseCommandMarkdown(source, fallbackDescription);
|
|
285
|
+
} catch {
|
|
286
|
+
/* bundled command markdown absent (e.g. trimmed tarball) — degrade, do not break plugin load */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function registerCommandsFromDir(cfg, commandDir) {
|
|
291
|
+
let commandFiles = [];
|
|
292
|
+
try {
|
|
293
|
+
commandFiles = (await fs.readdir(commandDir)).filter((file) => file.endsWith(".md"));
|
|
294
|
+
} catch {
|
|
295
|
+
/* command dir absent (e.g. trimmed tarball or an extension without commands) — degrade */
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const file of commandFiles.sort()) {
|
|
299
|
+
const name = file.slice(0, -3);
|
|
300
|
+
await registerBundledCommand(cfg, name, path.join(commandDir, file), `Run the ${name} workflow`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function configureWorkflowEntrypoints(cfg, extensionAssetDirs = { workflows: [], commands: [], skills: [] }) {
|
|
305
|
+
cfg.skills = cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills) ? cfg.skills : {};
|
|
306
|
+
cfg.skills.paths = Array.isArray(cfg.skills.paths) ? cfg.skills.paths : [];
|
|
307
|
+
if (!cfg.skills.paths.includes(BUNDLED_SKILL_DIR)) cfg.skills.paths.push(BUNDLED_SKILL_DIR);
|
|
308
|
+
// Extension skill dirs merge in (explicitly-configured trusted asset dirs).
|
|
309
|
+
for (const skillDir of extensionAssetDirs?.skills ?? []) {
|
|
310
|
+
if (!cfg.skills.paths.includes(skillDir)) cfg.skills.paths.push(skillDir);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
cfg.command = cfg.command && typeof cfg.command === "object" && !Array.isArray(cfg.command) ? cfg.command : {};
|
|
314
|
+
// Register every bundled command markdown generically (no per-command hardcoding). The command
|
|
315
|
+
// name is the file basename; the description is parsed from the markdown (fallback otherwise).
|
|
316
|
+
// Bundled FIRST, then extensions: registerBundledCommand's `if (cfg.command[name]) return` guard
|
|
317
|
+
// gives bundled > extension precedence — an extension can only add NET-NEW command names.
|
|
318
|
+
await registerCommandsFromDir(cfg, BUNDLED_COMMAND_DIR);
|
|
319
|
+
for (const cmdDir of extensionAssetDirs?.commands ?? []) {
|
|
320
|
+
await registerCommandsFromDir(cfg, cmdDir);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function coerceLaneTimeoutMs(value, label) {
|
|
325
|
+
if (value === undefined || value === null) return undefined;
|
|
326
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
327
|
+
if (value > MAX_CHILD_PROMPT_TIMEOUT_MS) throw new Error(`${label} must be <= ${MAX_CHILD_PROMPT_TIMEOUT_MS}ms`);
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function laneTimeoutAliasValue(source = {}, label = "lane timeout") {
|
|
332
|
+
const laneTimeoutMs = coerceLaneTimeoutMs(source.laneTimeoutMs, `${label}.laneTimeoutMs`);
|
|
333
|
+
const childPromptTimeoutMs = coerceLaneTimeoutMs(source.childPromptTimeoutMs, `${label}.childPromptTimeoutMs`);
|
|
334
|
+
if (laneTimeoutMs !== undefined && childPromptTimeoutMs !== undefined && laneTimeoutMs !== childPromptTimeoutMs) {
|
|
335
|
+
throw new Error(`${label} laneTimeoutMs and childPromptTimeoutMs must match when both are provided`);
|
|
336
|
+
}
|
|
337
|
+
return laneTimeoutMs ?? childPromptTimeoutMs;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function checkDurableLifecycleRequest(pluginContextOrRun, maybeToolContext, maybeRun) {
|
|
341
|
+
const run = maybeRun ?? pluginContextOrRun;
|
|
342
|
+
const pluginContext = maybeRun ? pluginContextOrRun : {};
|
|
343
|
+
const toolContext = maybeRun ? maybeToolContext : {};
|
|
344
|
+
const requests = await readLifecycleRequests(run.dir);
|
|
345
|
+
if (requests.kill) {
|
|
346
|
+
// A force-terminate request observed by a still-live owner: abandon the run to a resumable
|
|
347
|
+
// terminal state (interrupted) rather than the cooperative cancel/pause path. run.killed
|
|
348
|
+
// makes runWorkflowExecution's catch keep the interrupted status.
|
|
349
|
+
run.lifecycleRequests = requests;
|
|
350
|
+
run.killed = true;
|
|
351
|
+
run.status = "interrupted";
|
|
352
|
+
run.abortController.abort();
|
|
353
|
+
rejectWaitingAgents(run, new WorkflowCancelledError(requests.kill.reason || "Workflow run was force-terminated by durable request"));
|
|
354
|
+
await abortRunChildren(pluginContext, run, toolContext?.directory);
|
|
355
|
+
await appendEvent(run, { type: "run.killed", requestedAt: requests.kill.requestedAt, reason: requests.kill.reason });
|
|
356
|
+
await writeState(run);
|
|
357
|
+
throw new WorkflowCancelledError(requests.kill.reason || "Workflow run was force-terminated by durable request");
|
|
358
|
+
}
|
|
359
|
+
if (requests.cancel) {
|
|
360
|
+
run.lifecycleRequests = requests;
|
|
361
|
+
run.status = "cancelling";
|
|
362
|
+
run.abortController.abort();
|
|
363
|
+
rejectWaitingAgents(run, new WorkflowCancelledError(requests.cancel.reason || "Workflow run was cancelled by durable request"));
|
|
364
|
+
await abortRunChildren(pluginContext, run, toolContext?.directory);
|
|
365
|
+
await appendEvent(run, { type: "run.cancel_requested", requestedAt: requests.cancel.requestedAt, reason: requests.cancel.reason });
|
|
366
|
+
await writeState(run);
|
|
367
|
+
throw new WorkflowCancelledError(requests.cancel.reason || "Workflow run was cancelled by durable request");
|
|
368
|
+
}
|
|
369
|
+
if (requests.pause) {
|
|
370
|
+
run.lifecycleRequests = requests;
|
|
371
|
+
run.pauseRequested = true;
|
|
372
|
+
run.status = "pausing";
|
|
373
|
+
run.abortController.abort();
|
|
374
|
+
rejectWaitingAgents(run, new WorkflowCancelledError(requests.pause.reason || "Workflow run was paused by durable request"));
|
|
375
|
+
await abortRunChildren(pluginContext, run, toolContext?.directory);
|
|
376
|
+
await appendEvent(run, { type: "run.pause_requested", requestedAt: requests.pause.requestedAt, reason: requests.pause.reason });
|
|
377
|
+
await writeState(run);
|
|
378
|
+
throw new WorkflowCancelledError(requests.pause.reason || "Workflow run was paused by durable request");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function readResumeRunEntry(context, runId) {
|
|
383
|
+
for (const root of runRoots(context)) {
|
|
384
|
+
const dir = runDirForRoot(root, runId);
|
|
385
|
+
const state = await readJsonFile(path.join(dir, "state.json"), undefined);
|
|
386
|
+
if (state !== undefined) return { root, dir, state };
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Workflow run is not resumable: ${runId} was not found`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Transitional lifecycle statuses: a cooperative pause/cancel has been requested but the run has
|
|
392
|
+
// not finished unwinding yet, so it is neither still cleanly running nor settled into a
|
|
393
|
+
// resumable/terminal status. A resume attempt in this window previously hit the generic "not
|
|
394
|
+
// resumable from status pausing" error, which reads like a hard rejection. Return actionable
|
|
395
|
+
// settle guidance instead so an agent that pauses-then-immediately-resumes knows to poll for the
|
|
396
|
+
// settled status rather than treating the transitional state as a dead end.
|
|
397
|
+
const SETTLING_STATUSES = new Set(["pausing", "cancelling"]);
|
|
398
|
+
|
|
399
|
+
async function assertResumableState(entry, runId, args = {}) {
|
|
400
|
+
const status = String(entry?.state?.status ?? "unknown");
|
|
401
|
+
if (RESUMABLE_STATUSES.has(status)) {
|
|
402
|
+
if (Object.hasOwn(args, "resumePolicy")) {
|
|
403
|
+
throw new Error("resumePolicy is only valid when resuming an eligible timed-out run");
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (status === "timed-out") {
|
|
408
|
+
const runLock = await readLock(lockPathForRun(entry.dir, "run"));
|
|
409
|
+
const state = runLock ? { ...entry.state, locks: { ...(entry.state.locks ?? {}), run: runLock } } : entry.state;
|
|
410
|
+
const timeoutRecovery = timeoutResumeEligibilityForState(state);
|
|
411
|
+
if (!timeoutRecovery.eligible) {
|
|
412
|
+
throw new Error(`Workflow run ${runId} timed-out resume is blocked: ${timeoutRecovery.blockedReasons.join("; ")}`);
|
|
413
|
+
}
|
|
414
|
+
if (args.resumePolicy !== "extend-deadline") {
|
|
415
|
+
throw new Error(`Workflow run ${runId} timed-out resume requires resumePolicy:"extend-deadline"`);
|
|
416
|
+
}
|
|
417
|
+
const priorMaxRuntimeMs = entry.state.maxRuntimeMs;
|
|
418
|
+
if (!Number.isInteger(args.maxRuntimeMs) || args.maxRuntimeMs <= priorMaxRuntimeMs) {
|
|
419
|
+
throw new Error(`Workflow run ${runId} timed-out resume requires maxRuntimeMs greater than prior maxRuntimeMs ${priorMaxRuntimeMs}`);
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (SETTLING_STATUSES.has(status)) {
|
|
424
|
+
if (status === "pausing") {
|
|
425
|
+
throw new Error(`Workflow run ${runId} is still settling (status pausing); poll workflow_status({ runId: "${runId}" }) until status is paused, then resume with workflow_run({ resumeRunId: "${runId}" }).`);
|
|
426
|
+
}
|
|
427
|
+
throw new Error(`Workflow run ${runId} is still settling (status cancelling); poll workflow_status({ runId: "${runId}" }) until it reaches a terminal status. A cancelled run is terminal and not resumable.`);
|
|
428
|
+
}
|
|
429
|
+
throw new Error(`Workflow run ${runId} is not resumable from status ${status}; only ${[...RESUMABLE_STATUSES].join(", ")} runs can be resumed`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function backgroundHeuristic(approval = {}) {
|
|
433
|
+
const maxAgents = Number.isInteger(approval?.maxAgents) ? approval.maxAgents : 0;
|
|
434
|
+
const concurrency = Math.max(1, Number.isInteger(approval?.concurrency) ? approval.concurrency : 1);
|
|
435
|
+
const waves = Math.max(1, Math.ceil(maxAgents / concurrency));
|
|
436
|
+
const maxAgentsSignal = approval?.maxAgentsSignal !== false;
|
|
437
|
+
const maxRuntimeSignal = approval?.maxRuntimeSignal !== false;
|
|
438
|
+
const wide = maxAgentsSignal && maxAgents >= BACKGROUND_RECOMMEND_MAX_AGENTS;
|
|
439
|
+
const deep = maxAgentsSignal && waves >= BACKGROUND_RECOMMEND_WAVES;
|
|
440
|
+
const long = maxRuntimeSignal && Number.isInteger(approval?.maxRuntimeMs) && approval.maxRuntimeMs >= BACKGROUND_RECOMMEND_DURATION_MS;
|
|
441
|
+
const signals = [
|
|
442
|
+
wide ? `maxAgents=${maxAgents}` : undefined,
|
|
443
|
+
deep ? `~${waves} concurrency waves (concurrency=${concurrency})` : undefined,
|
|
444
|
+
long ? `maxRuntimeMs=${approval.maxRuntimeMs}ms` : undefined,
|
|
445
|
+
].filter(Boolean);
|
|
446
|
+
return { recommended: wide || deep || long, maxAgents, concurrency, waves, wide, deep, long, signals };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function backgroundSignalsText(heuristic) {
|
|
450
|
+
return (heuristic?.signals ?? []).join(", ");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function workflowBackgroundDecision(meta = {}, sourcePath = "", args = {}, priorState = null, sizing = {}) {
|
|
454
|
+
if (priorState && typeof priorState.background === "boolean") {
|
|
455
|
+
return { enabled: priorState.background, source: "resume" };
|
|
456
|
+
}
|
|
457
|
+
if (typeof args.background === "boolean") {
|
|
458
|
+
return { enabled: args.background, source: "explicit" };
|
|
459
|
+
}
|
|
460
|
+
if (meta.harness === "drain") {
|
|
461
|
+
const runtimeArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args) ? args.args : {};
|
|
462
|
+
if (resolveDrainMode(runtimeArgs) === "autonomous-local") {
|
|
463
|
+
return { enabled: true, source: "drain-autonomous-local" };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const heuristic = backgroundHeuristic(sizing);
|
|
467
|
+
if (heuristic.recommended) return { enabled: true, source: "heuristic", heuristic };
|
|
468
|
+
return { enabled: false, source: "foreground-default", heuristic };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function effectiveWorkflowBackground(meta = {}, sourcePath = "", args = {}, priorState = null, sizing = {}) {
|
|
472
|
+
return workflowBackgroundDecision(meta, sourcePath, args, priorState, sizing).enabled;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Returns an advisory background-recommendation line for the approval preview, or undefined when
|
|
476
|
+
// background is already enabled or the run is small enough that blocking the agent foreground is
|
|
477
|
+
// cheap. Gates on maxAgents (wide), concurrency via wave count (deep/serialized), and an explicit
|
|
478
|
+
// maxRuntimeMs (long-running) so all three size signals from the bead are considered.
|
|
479
|
+
function backgroundRecommendation(approval) {
|
|
480
|
+
if (approval?.background === true) return undefined;
|
|
481
|
+
if (approval?.backgroundDecision?.source === "resume") return undefined;
|
|
482
|
+
const heuristic = backgroundHeuristic(approval);
|
|
483
|
+
if (!heuristic.recommended) return undefined;
|
|
484
|
+
const signals = backgroundSignalsText(heuristic);
|
|
485
|
+
return [
|
|
486
|
+
`Background recommended (heuristic): this is a large/long run (${signals}).`,
|
|
487
|
+
"Running it foreground blocks the calling agent for the whole run, so the agent cannot workflow_pause/workflow_cancel it mid-run (only the human ESC-to-interrupt path can).",
|
|
488
|
+
"Pass background: true to retain a control channel: poll workflow_status, then workflow_pause/workflow_cancel as needed. The human ESC path is unchanged either way.",
|
|
489
|
+
].join(" ");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function backgroundDefaultLine(approval) {
|
|
493
|
+
if (approval?.backgroundDecision?.source !== "heuristic") return undefined;
|
|
494
|
+
const heuristic = approval.backgroundDecision.heuristic ?? backgroundHeuristic(approval);
|
|
495
|
+
return [
|
|
496
|
+
`Background defaulted (heuristic): this is a large/long run (${backgroundSignalsText(heuristic)}).`,
|
|
497
|
+
"The run starts in background so the calling agent keeps a control channel for workflow_status, workflow_pause, and workflow_cancel.",
|
|
498
|
+
].join(" ");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function backgroundNotificationWarning(approval, runId) {
|
|
502
|
+
if (approval?.background !== true) return undefined;
|
|
503
|
+
if (approval?.notificationDelivery?.promptAsyncAvailable !== false) return undefined;
|
|
504
|
+
const statusHint = runId
|
|
505
|
+
? `Poll workflow_status({ runId: "${runId}" }) for completion and use detail:"result" for final output.`
|
|
506
|
+
: "After launch, poll workflow_status with the returned run id and use detail:\"result\" for final output.";
|
|
507
|
+
return `Background notification warning: session.promptAsync is unavailable, so no completion prompt can be delivered to the invoking session. ${statusHint}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function authorityIsolationSummary(authority) {
|
|
511
|
+
if (authority.integration) return "local integration worktrees; primary-tree writes require workflow_apply";
|
|
512
|
+
if (authority.worktreeEdit) return "native edit worktrees; primary-tree writes require workflow_apply";
|
|
513
|
+
if (authority.edit) return "primary-tree write authority gated by workflow_apply";
|
|
514
|
+
return "no workflow-managed write isolation requested";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function mutationDomainSummary(run) {
|
|
518
|
+
if (run.meta?.harness === "drain") {
|
|
519
|
+
const runtimeArgs = run.runtimeArgs && typeof run.runtimeArgs === "object" ? run.runtimeArgs : {};
|
|
520
|
+
const mode = resolveDrainMode(runtimeArgs);
|
|
521
|
+
return mode === "autonomous-local"
|
|
522
|
+
? "domain state via the trusted drain adapter; primary tree changes require workflow_apply"
|
|
523
|
+
: "none (drain dry-run)";
|
|
524
|
+
}
|
|
525
|
+
if (run.authority?.integration) return "integration worktrees and workflow_apply-gated primary tree";
|
|
526
|
+
if (run.authority?.edit || run.authority?.worktreeEdit) return "workflow_apply-gated primary tree";
|
|
527
|
+
return "none declared";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isRunAborted(run, toolContext) {
|
|
531
|
+
return run.abortController.signal.aborted || (!run.ignoreToolAbort && toolContext.abort?.aborted);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function throwIfAborted(run, toolContext) {
|
|
535
|
+
if (isRunAborted(run, toolContext)) throw new WorkflowCancelledError();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function laneCancelledByFanout(run, callId) {
|
|
539
|
+
for (const scope of run.cancelledFanoutScopes ?? []) {
|
|
540
|
+
if (callId === scope || callId.startsWith(`${scope}/`)) return true;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function throwIfLaneCancelled(run, callId) {
|
|
546
|
+
if (laneCancelledByFanout(run, callId)) throw new WorkflowCancelledError("Workflow lane was cancelled by failFast fanout");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function acquireAgentSlot(run, callId) {
|
|
550
|
+
if (run.abortController.signal.aborted) throw new WorkflowCancelledError();
|
|
551
|
+
throwIfLaneCancelled(run, callId);
|
|
552
|
+
if (run.activeAgents < run.concurrency) {
|
|
553
|
+
run.activeAgents += 1;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
let resolve;
|
|
557
|
+
let reject;
|
|
558
|
+
const promise = new Promise((res, rej) => {
|
|
559
|
+
resolve = res;
|
|
560
|
+
reject = rej;
|
|
561
|
+
});
|
|
562
|
+
run.waitingAgents.push({ resolve, reject, callId });
|
|
563
|
+
await promise;
|
|
564
|
+
if (run.abortController.signal.aborted) {
|
|
565
|
+
releaseAgentSlot(run);
|
|
566
|
+
throw new WorkflowCancelledError();
|
|
567
|
+
}
|
|
568
|
+
// A waiter is resolved by releaseAgentSlot HANDING OFF the slot (it does not
|
|
569
|
+
// decrement activeAgents), so this just-resolved lane now owns the slot. If the
|
|
570
|
+
// lane is fanout-cancelled in the microtask window after the hand-off, throwing
|
|
571
|
+
// here leaves runChildAgent with acquired=false, so its finally skips
|
|
572
|
+
// releaseAgentSlot and the handed-off slot leaks -> activeAgents permanently
|
|
573
|
+
// inflates and the concurrency gate eventually deadlocks (leak survives resume).
|
|
574
|
+
// Release the slot before throwing, mirroring the abort branch above (R5).
|
|
575
|
+
if (laneCancelledByFanout(run, callId)) {
|
|
576
|
+
releaseAgentSlot(run);
|
|
577
|
+
throw new WorkflowCancelledError("Workflow lane was cancelled by failFast fanout");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function releaseAgentSlot(run) {
|
|
582
|
+
const next = run.waitingAgents.shift();
|
|
583
|
+
if (next) {
|
|
584
|
+
next.resolve();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
run.activeAgents = Math.max(0, run.activeAgents - 1);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Nested workflow() lanes execute inside the parent run and draw from the same maxAgents
|
|
591
|
+
// budget; a nested workflow's own declared maxAgents is ignored at runtime. Surface this so
|
|
592
|
+
// authors do not underbudget a parent that delegates to a larger nested cold path, and warn
|
|
593
|
+
// (heuristically) when a snapshotted nested workflow statically declares more agents than the
|
|
594
|
+
// parent budget. The warning is advisory only — dynamic workflow code may launch fewer lanes —
|
|
595
|
+
// so it never blocks an otherwise-valid approval.
|
|
596
|
+
function nestedBudgetNotes(run) {
|
|
597
|
+
if (!run.nestedSnapshots?.size) return [];
|
|
598
|
+
const uniqueByPath = [...new Map([...run.nestedSnapshots.values()].map((item) => [item.sourcePath, item])).values()];
|
|
599
|
+
const notes = ["Nested budget: nested workflow() lanes run inside this run and share its Max agents budget; a nested workflow's own declared maxAgents is ignored at runtime."];
|
|
600
|
+
for (const item of uniqueByPath) {
|
|
601
|
+
let declared;
|
|
602
|
+
try {
|
|
603
|
+
declared = parseWorkflowSource(item.source).meta?.maxAgents;
|
|
604
|
+
} catch {
|
|
605
|
+
declared = undefined;
|
|
606
|
+
}
|
|
607
|
+
if (typeof declared === "number" && declared > run.maxAgents) {
|
|
608
|
+
notes.push(`Nested budget warning (heuristic): nested workflow ${item.sourcePath} declares maxAgents=${declared}, higher than this run's Max agents=${run.maxAgents}. If it cold-runs its full fan-out it may budget-stop; raise this workflow's maxAgents or pass a precomputed result to skip the nested run.`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return notes;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function finiteOrNull(value) {
|
|
615
|
+
return Number.isFinite(value) ? value : null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function intOrNull(value) {
|
|
619
|
+
return Number.isInteger(value) ? value : null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function truthyEnvFlag(value) {
|
|
623
|
+
if (typeof value !== "string") return false;
|
|
624
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function sourceLineCount(source) {
|
|
628
|
+
if (source.length === 0) return 0;
|
|
629
|
+
return source.split(/\r\n|\r|\n/).length;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sourcePreviewMetadata(source, sourcePath, args = {}) {
|
|
633
|
+
const metadata = {
|
|
634
|
+
path: sourcePath,
|
|
635
|
+
byteLength: Buffer.byteLength(source, "utf8"),
|
|
636
|
+
lineCount: sourceLineCount(source),
|
|
637
|
+
inline: sourcePath === "<inline>",
|
|
638
|
+
};
|
|
639
|
+
if (args.includeSourceSnippet === true) {
|
|
640
|
+
const maxChars = Number.isInteger(args.sourceSnippetMaxChars) ? Math.min(Math.max(args.sourceSnippetMaxChars, 1), 2000) : 800;
|
|
641
|
+
metadata.snippet = truncateText(source, maxChars);
|
|
642
|
+
metadata.snippetChars = maxChars;
|
|
643
|
+
}
|
|
644
|
+
return metadata;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function approvalPreviewEnvelope(run) {
|
|
648
|
+
const phases = Array.isArray(run.meta.phases) ? run.meta.phases.join(", ") : "not declared";
|
|
649
|
+
const phaseList = Array.isArray(run.meta.phases) ? [...run.meta.phases] : [];
|
|
650
|
+
const nestedSnapshots = approvalSnapshotList(run.nestedSnapshots);
|
|
651
|
+
const nested = nestedSnapshots.length ? nestedSnapshots.map((item) => `${item.sourcePath} ${item.sourceHash}`).join("\n") : "none";
|
|
652
|
+
const approvedHash = approvalHash(run);
|
|
653
|
+
const backgroundHint = backgroundRecommendation(run) || null;
|
|
654
|
+
const backgroundDefault = backgroundDefaultLine(run) || null;
|
|
655
|
+
const notificationWarning = backgroundNotificationWarning(run) || null;
|
|
656
|
+
const requiredGates = Array.isArray(run.authority?.requiredGates) ? [...run.authority.requiredGates] : [];
|
|
657
|
+
return {
|
|
658
|
+
type: "workflow_preview",
|
|
659
|
+
status: "approval_required",
|
|
660
|
+
executed: false,
|
|
661
|
+
workflow: {
|
|
662
|
+
name: run.meta.name || "unnamed workflow",
|
|
663
|
+
description: run.meta.description || "not declared",
|
|
664
|
+
phases: phaseList,
|
|
665
|
+
phasesText: phases,
|
|
666
|
+
},
|
|
667
|
+
source: {
|
|
668
|
+
path: run.sourcePath,
|
|
669
|
+
sourceHash: run.sourceHash,
|
|
670
|
+
external: run.externalSource === true,
|
|
671
|
+
...run.sourceMetadata,
|
|
672
|
+
},
|
|
673
|
+
approvalHash: approvedHash,
|
|
674
|
+
runtimeArgsPreview: run.argsPreview,
|
|
675
|
+
laneBudget: {
|
|
676
|
+
maxAgents: run.maxAgents,
|
|
677
|
+
concurrency: run.concurrency,
|
|
678
|
+
laneTimeoutMs: run.laneTimeoutMs,
|
|
679
|
+
guestDeadlineMs: run.guestDeadlineMs,
|
|
680
|
+
maxRuntimeMs: intOrNull(run.maxRuntimeMs),
|
|
681
|
+
},
|
|
682
|
+
modelPlan: {
|
|
683
|
+
defaultChildModel: run.defaultChildModel,
|
|
684
|
+
fast: run.modelTiers?.fast ?? run.defaultChildModel,
|
|
685
|
+
deep: run.modelTiers?.deep ?? run.defaultChildModel,
|
|
686
|
+
},
|
|
687
|
+
budgetCeilings: {
|
|
688
|
+
maxCost: finiteOrNull(run.budgetCeilings?.maxCost),
|
|
689
|
+
maxTokens: intOrNull(run.budgetCeilings?.maxTokens),
|
|
690
|
+
},
|
|
691
|
+
autoApprove: run.autoApprove ?? null,
|
|
692
|
+
background: {
|
|
693
|
+
enabled: run.background === true,
|
|
694
|
+
source: run.backgroundDecision?.source ?? null,
|
|
695
|
+
defaultReason: backgroundDefault,
|
|
696
|
+
recommendation: backgroundHint,
|
|
697
|
+
notificationWarning,
|
|
698
|
+
},
|
|
699
|
+
notificationDelivery: {
|
|
700
|
+
promptAsyncAvailable: run.notificationDelivery?.promptAsyncAvailable === true,
|
|
701
|
+
completionPrompt: run.background === true
|
|
702
|
+
? (run.notificationDelivery?.promptAsyncAvailable === true ? "available" : "unavailable")
|
|
703
|
+
: "not_applicable",
|
|
704
|
+
warning: notificationWarning,
|
|
705
|
+
},
|
|
706
|
+
debugCapture: {
|
|
707
|
+
enabled: run.debugCapture === true,
|
|
708
|
+
source: run.debugCaptureSource ?? (run.debugCapture === true ? "unknown" : "off"),
|
|
709
|
+
artifacts: run.debugCapture === true ? "debug/<lane>/prompt.md, schema.json, transcript.jsonl" : "none",
|
|
710
|
+
},
|
|
711
|
+
authority: {
|
|
712
|
+
profile: run.authority.profile || AD_HOC_AUTHORITY_PROFILE,
|
|
713
|
+
requiredGates,
|
|
714
|
+
isolation: authorityIsolationSummary(run.authority),
|
|
715
|
+
summary: authoritySummary(run.authority),
|
|
716
|
+
details: run.authority,
|
|
717
|
+
},
|
|
718
|
+
mutationDomains: {
|
|
719
|
+
summary: mutationDomainSummary(run),
|
|
720
|
+
},
|
|
721
|
+
resume: run.resumePreview ? {
|
|
722
|
+
policy: run.resumePolicy ?? null,
|
|
723
|
+
...run.resumePreview,
|
|
724
|
+
summary: resumeReplayLine(run.resumePreview),
|
|
725
|
+
} : null,
|
|
726
|
+
nestedSnapshots,
|
|
727
|
+
nestedSnapshotText: nested,
|
|
728
|
+
nestedBudgetNotes: nestedBudgetNotes(run),
|
|
729
|
+
capabilities: {
|
|
730
|
+
childSession: run.capabilities.childSession,
|
|
731
|
+
permissions: run.capabilities.permissions,
|
|
732
|
+
structuredOutput: run.capabilities.structuredOutput,
|
|
733
|
+
worktree: run.capabilities.worktree,
|
|
734
|
+
directoryRooting: run.capabilities.directoryRooting,
|
|
735
|
+
worktreeEditIsolation: run.capabilities.worktreeEditIsolation,
|
|
736
|
+
},
|
|
737
|
+
consent: [
|
|
738
|
+
...(run.meta?.harness === "drain" && run.authority?.profile === "drain-autonomous-local"
|
|
739
|
+
? ["for an autonomous-local drain, this launch approval authorizes in-run apply of a verified successful diff plan to the local primary tree (accepted changes land; staged domain mutations finalize) instead of stopping at awaiting-diff-approval. Failed drains keep failed-with-diff-plan for review via workflow_apply."]
|
|
740
|
+
: []),
|
|
741
|
+
...(requiredGates.length
|
|
742
|
+
? ["after approval, elevated launch may run required live-gate preflight probes before mutation/lane launch; probes spawn short-lived child sessions (model token use) and, for worktree/directory-rooting gates, create and remove scratch worktrees. The preview never probes; these side effects begin only after approval."]
|
|
743
|
+
: []),
|
|
744
|
+
...(run.debugCapture === true
|
|
745
|
+
? ["debug capture is enabled; lane prompts, schemas, and child transcripts are persisted under the private run directory after secrets redaction and size caps. This increases local sensitive evidence and disk usage."]
|
|
746
|
+
: []),
|
|
747
|
+
],
|
|
748
|
+
approvalHashCovers: "workflow source, runtime args, authority, model, budgets, concurrency, debug capture, resume policy, capabilities, and nested snapshots",
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function autoApprovePreviewLine(autoApprove) {
|
|
753
|
+
if (!autoApprove) return null;
|
|
754
|
+
if (autoApprove.eligible) {
|
|
755
|
+
return `Auto-approve: eligible (tier=${autoApprove.tier}, ceiling=${autoApprove.effectiveCeiling})`;
|
|
756
|
+
}
|
|
757
|
+
if (!autoApprove.configuredCeiling) {
|
|
758
|
+
return `Auto-approve: off (resolved tier=${autoApprove.tier}; configure options.autoApprove to enable single-call launch)`;
|
|
759
|
+
}
|
|
760
|
+
if (!autoApprove.effectiveCeiling) {
|
|
761
|
+
return `Auto-approve: off for this call (configured=${autoApprove.configuredCeiling}, requested=${autoApprove.requestedCeiling ?? "none"})`;
|
|
762
|
+
}
|
|
763
|
+
return `Auto-approve: not eligible (tier=${autoApprove.tier}, ceiling=${autoApprove.effectiveCeiling})`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function workflowPreviewJson(run) {
|
|
767
|
+
return JSON.stringify(approvalPreviewEnvelope(run), null, 2);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function approvalSummary(run) {
|
|
771
|
+
const preview = approvalPreviewEnvelope(run);
|
|
772
|
+
// Human-first layout: the plan a user needs to reason about (what/models/lanes/authority/cost)
|
|
773
|
+
// leads; hashes, capabilities, and consent demote into a "Technical envelope" footer. Every line
|
|
774
|
+
// label is preserved verbatim so order-independent regex assertions over the preview still match.
|
|
775
|
+
return [
|
|
776
|
+
`Workflow approval required for ${preview.workflow.name}.`,
|
|
777
|
+
`Description: ${preview.workflow.description}`,
|
|
778
|
+
`Phases: ${preview.workflow.phasesText}`,
|
|
779
|
+
`Runtime args preview: ${preview.runtimeArgsPreview}`,
|
|
780
|
+
`Max agents: ${preview.laneBudget.maxAgents}`,
|
|
781
|
+
`Concurrency: ${preview.laneBudget.concurrency}`,
|
|
782
|
+
`Default child model: ${preview.modelPlan.defaultChildModel}`,
|
|
783
|
+
`Model plan: fast=${preview.modelPlan.fast} deep=${preview.modelPlan.deep}`,
|
|
784
|
+
`Lane timeout: ${preview.laneBudget.laneTimeoutMs}ms`,
|
|
785
|
+
...(preview.laneBudget.maxRuntimeMs !== null ? [`Run deadline (maxRuntimeMs, operability limit, not approval-bound): ${preview.laneBudget.maxRuntimeMs}ms`] : []),
|
|
786
|
+
`Budget ceilings: maxCost=${preview.budgetCeilings.maxCost ?? "none"}, maxTokens=${preview.budgetCeilings.maxTokens ?? "none"}`,
|
|
787
|
+
`Debug capture: ${preview.debugCapture.enabled ? `enabled (${preview.debugCapture.source})` : "off"}`,
|
|
788
|
+
`Background: ${preview.background.enabled}`,
|
|
789
|
+
...(preview.background.defaultReason ? [preview.background.defaultReason] : []),
|
|
790
|
+
...(preview.background.recommendation ? [preview.background.recommendation] : []),
|
|
791
|
+
...(preview.background.notificationWarning ? [preview.background.notificationWarning] : []),
|
|
792
|
+
`Authority profile: ${preview.authority.profile}`,
|
|
793
|
+
`Required gates: ${preview.authority.requiredGates.length ? preview.authority.requiredGates.join(", ") : "none"}`,
|
|
794
|
+
`Isolation: ${preview.authority.isolation}`,
|
|
795
|
+
`Authority: ${preview.authority.summary}`,
|
|
796
|
+
...(preview.autoApprove ? [autoApprovePreviewLine(preview.autoApprove)] : []),
|
|
797
|
+
`Mutation domains: ${preview.mutationDomains.summary}`,
|
|
798
|
+
...(preview.resume?.policy ? [`Resume policy: ${preview.resume.policy}`] : []),
|
|
799
|
+
...(preview.resume ? [preview.resume.summary] : []),
|
|
800
|
+
`Nested workflow snapshots:\n${preview.nestedSnapshotText}`,
|
|
801
|
+
...preview.nestedBudgetNotes,
|
|
802
|
+
"--- Technical envelope (hashes, capabilities, consent) ---",
|
|
803
|
+
`Source: ${preview.source.path}`,
|
|
804
|
+
`sourceHash: ${preview.source.sourceHash}`,
|
|
805
|
+
...(preview.source.external ? [`External source (allowExternalScriptPath opt-in): true`] : []),
|
|
806
|
+
`approvalHash: ${preview.approvalHash}`,
|
|
807
|
+
`Capability summary: childSession=${preview.capabilities.childSession}, permissions=${preview.capabilities.permissions}, structuredOutput=${preview.capabilities.structuredOutput}, worktree=${preview.capabilities.worktree}, directoryRooting=${preview.capabilities.directoryRooting}, worktreeEditIsolation=${preview.capabilities.worktreeEditIsolation}`,
|
|
808
|
+
"Capability note: available-unverified is API shape only, not behavioral proof; elevated authority fails closed unless required capabilities are available/verified.",
|
|
809
|
+
...preview.consent.map((line) => `Consent: ${line}`),
|
|
810
|
+
`approvalHash covers ${preview.approvalHashCovers}.`,
|
|
811
|
+
"Re-run with approve: true and approvalHash set to this approvalHash to execute this exact workflow envelope.",
|
|
812
|
+
].join("\n");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function approvalPreviewResponse(run, args) {
|
|
816
|
+
return args.format === "json" ? workflowPreviewJson(run) : approvalSummary(run);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function approvalMismatchResponse(run, args) {
|
|
820
|
+
const freshPreview = approvalPreviewEnvelope(run);
|
|
821
|
+
return JSON.stringify({
|
|
822
|
+
type: "workflow_approval_mismatch",
|
|
823
|
+
status: "approval_mismatch",
|
|
824
|
+
executed: false,
|
|
825
|
+
reason: typeof args.approvalHash === "string" && args.approvalHash.length > 0 ? "approval_hash_mismatch" : "missing_approval_hash",
|
|
826
|
+
message: "Workflow approval required: nothing executed because approve:true did not include the current approvalHash for this workflow envelope. Review the freshPreview and re-run with its approvalHash if the plan is acceptable.",
|
|
827
|
+
suppliedApprovalHash: typeof args.approvalHash === "string" ? args.approvalHash : null,
|
|
828
|
+
freshApprovalHash: freshPreview.approvalHash,
|
|
829
|
+
freshPreview,
|
|
830
|
+
}, null, 2);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function workflowAutoApprovePreview(pluginContext, args, authority) {
|
|
834
|
+
const configuredCeiling = normalizeAutoApproveTier(pluginContext?.workflowAutoApproveCeiling);
|
|
835
|
+
const hasRequestedCeiling = args.autoApprove !== undefined && args.autoApprove !== null;
|
|
836
|
+
const requestedCeiling = hasRequestedCeiling ? normalizeAutoApproveTier(args.autoApprove) : null;
|
|
837
|
+
if (!configuredCeiling && !hasRequestedCeiling) return null;
|
|
838
|
+
const tier = authorityAutoApproveTier(authority);
|
|
839
|
+
const effectiveCeiling = effectiveAutoApproveCeiling(configuredCeiling, args.autoApprove);
|
|
840
|
+
return {
|
|
841
|
+
tier,
|
|
842
|
+
configuredCeiling: configuredCeiling || null,
|
|
843
|
+
requestedCeiling: requestedCeiling || null,
|
|
844
|
+
effectiveCeiling: effectiveCeiling || null,
|
|
845
|
+
eligible: Boolean(effectiveCeiling && autoApproveCovers(effectiveCeiling, tier)),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function workflowAutoApproval(pluginContext, args, approval) {
|
|
850
|
+
const autoApprove = approval.autoApprove ?? workflowAutoApprovePreview(pluginContext, args, approval.authority);
|
|
851
|
+
if (!autoApprove?.eligible) return null;
|
|
852
|
+
return { tier: autoApprove.tier, ceiling: autoApprove.effectiveCeiling };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function applyApprovalMismatchError({ runId, reason, field, supplied, expected, state, plan, actualPlanHash, currentDomainHash }) {
|
|
856
|
+
const mismatchSummary = reason === "staged_domain_mutations_changed"
|
|
857
|
+
? "staged domain mutations changed after diff approval"
|
|
858
|
+
: `${field} mismatch`;
|
|
859
|
+
const freshApplyApproval = {
|
|
860
|
+
runId,
|
|
861
|
+
status: state?.status ?? null,
|
|
862
|
+
approvedSourceHash: state?.sourceHash ?? null,
|
|
863
|
+
baseCommit: plan?.baseCommit ?? null,
|
|
864
|
+
diffPlanHash: actualPlanHash ?? plan?.diffPlanHash ?? null,
|
|
865
|
+
domainMutationHash: currentDomainHash ?? plan?.domainMutationHash ?? null,
|
|
866
|
+
};
|
|
867
|
+
return new Error(JSON.stringify({
|
|
868
|
+
type: "workflow_apply_approval_mismatch",
|
|
869
|
+
status: "approval_mismatch",
|
|
870
|
+
executed: false,
|
|
871
|
+
reason,
|
|
872
|
+
field,
|
|
873
|
+
message: `${mismatchSummary}. Re-read workflow_status with detail:"full" for the run and retry with the fresh hash fields if the diff plan is still acceptable.`,
|
|
874
|
+
supplied,
|
|
875
|
+
expected,
|
|
876
|
+
freshStatusCommand: `workflow_status({ runId: "${runId}", format: "json", detail: "full" })`,
|
|
877
|
+
freshApplyApproval,
|
|
878
|
+
}, null, 2));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function workflowResultReadbackCommand(runId) {
|
|
882
|
+
return `workflow_status({ runId: "${runId}", format: "json", detail: "result" })`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function workflowResultReadbackLines(run) {
|
|
886
|
+
return [
|
|
887
|
+
`Read redacted result: ${workflowResultReadbackCommand(run.id)}`,
|
|
888
|
+
"JSON result payload: status.result.output",
|
|
889
|
+
];
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function workflowInlineResultLines(run, output) {
|
|
893
|
+
const projection = inlineResultProjection(output);
|
|
894
|
+
if (projection.inline) {
|
|
895
|
+
return [
|
|
896
|
+
`Result (redacted JSON, ${projection.bytes} bytes):`,
|
|
897
|
+
projection.text,
|
|
898
|
+
];
|
|
899
|
+
}
|
|
900
|
+
return [
|
|
901
|
+
`Result omitted from workflow_run: redacted JSON is ${projection.bytes} bytes, above inline cap ${projection.maxBytes}.`,
|
|
902
|
+
`Read full/partial result: ${workflowResultReadbackCommand(run.id)}`,
|
|
903
|
+
];
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function workflowToastOptions(pluginContext) {
|
|
907
|
+
return { ascii: pluginContext?.__workflowToastAscii === true };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function normalizeWorkflowToastAscii(value) {
|
|
911
|
+
return value === true || value === "ascii" || value === "plain-ascii";
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function showWorkflowRunToast(pluginContext, run, kind) {
|
|
915
|
+
const options = workflowToastOptions(pluginContext);
|
|
916
|
+
const card = kind === "heartbeat" ? workflowHeartbeatToastCard(run, options) : workflowTerminalToastCard(run, options);
|
|
917
|
+
await showToast(pluginContext, card.variant, card.title, card.message);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function showWorkflowApplyToast(pluginContext, state) {
|
|
921
|
+
if (!["apply-running", "applied", "review-required", "apply-failed"].includes(state.status)) return;
|
|
922
|
+
const card = workflowApplyToastCard(state, workflowToastOptions(pluginContext));
|
|
923
|
+
await showToast(pluginContext, card.variant, card.title, card.message);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function msBetween(start, end) {
|
|
927
|
+
const startMs = typeof start === "string" ? Date.parse(start) : Number.NaN;
|
|
928
|
+
const endMs = typeof end === "string" ? Date.parse(end) : Number.NaN;
|
|
929
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return undefined;
|
|
930
|
+
return Math.max(0, endMs - startMs);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function appendPersistedRunEvent(runDir, state, event) {
|
|
934
|
+
const eventRun = {
|
|
935
|
+
id: state.id,
|
|
936
|
+
dir: runDir,
|
|
937
|
+
eventCount: await countNonEmptyLines(path.join(runDir, "events.jsonl")),
|
|
938
|
+
};
|
|
939
|
+
await appendEvent(eventRun, event);
|
|
940
|
+
state.lastEventAt = eventRun.lastEventAt;
|
|
941
|
+
state.lastEventType = eventRun.lastEventType;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function completeApprovalWaitMetric(runDir, state, diffPlanHash) {
|
|
945
|
+
if (!state.approvalWait?.startedAt) return;
|
|
946
|
+
const completedAt = new Date().toISOString();
|
|
947
|
+
const durationMs = msBetween(state.approvalWait.startedAt, completedAt);
|
|
948
|
+
state.approvalWait = {
|
|
949
|
+
...state.approvalWait,
|
|
950
|
+
completedAt,
|
|
951
|
+
durationMs,
|
|
952
|
+
diffPlanHash: state.approvalWait.diffPlanHash ?? diffPlanHash,
|
|
953
|
+
};
|
|
954
|
+
state.operatorMetrics = {
|
|
955
|
+
...(state.operatorMetrics ?? {}),
|
|
956
|
+
approvalWaitMs: durationMs ?? null,
|
|
957
|
+
awaitingDiffApprovalAt: state.approvalWait.startedAt,
|
|
958
|
+
appliedAt: completedAt,
|
|
959
|
+
};
|
|
960
|
+
await appendPersistedRunEvent(runDir, state, {
|
|
961
|
+
type: "run.approval_wait_completed",
|
|
962
|
+
diffPlanHash,
|
|
963
|
+
awaitingDiffApprovalAt: state.approvalWait.startedAt,
|
|
964
|
+
appliedAt: completedAt,
|
|
965
|
+
approvalWaitMs: durationMs,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// createEditWorktree and createIntegrationLaneWorktree moved to child-agent-runner.js
|
|
970
|
+
// (lane-owned worktree creation); imported above and re-exported below for the barrel.
|
|
971
|
+
|
|
972
|
+
async function cleanupWorktrees(run) {
|
|
973
|
+
// Best-effort removal of every worktree created for this run. The native (v2) removeWorktree
|
|
974
|
+
// also deletes the worktree's branch. The filesystem fallback only removes throwaway
|
|
975
|
+
// worktree directories created under the run dir, never anything outside it.
|
|
976
|
+
for (const record of run.editWorktrees ?? []) {
|
|
977
|
+
try {
|
|
978
|
+
await run.adapter.removeWorktree({ directory: record.path, id: record.id });
|
|
979
|
+
} catch {
|
|
980
|
+
// Native worktree removal is best effort.
|
|
981
|
+
}
|
|
982
|
+
if (record.path && record.path.startsWith(`${run.dir}${path.sep}`)) {
|
|
983
|
+
try {
|
|
984
|
+
await fs.rm(record.path, { recursive: true, force: true });
|
|
985
|
+
} catch {
|
|
986
|
+
// Filesystem fallback cleanup is best effort.
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const integrationRecords = run.integrationWorktrees ?? [];
|
|
991
|
+
if (integrationRecords.length === 0) return false;
|
|
992
|
+
run.worktreeCleanup = run.worktreeCleanup ?? { integration: [] };
|
|
993
|
+
let changed = false;
|
|
994
|
+
for (const record of integrationRecords) {
|
|
995
|
+
const summary = {
|
|
996
|
+
role: record.role,
|
|
997
|
+
callId: record.callId,
|
|
998
|
+
laneId: record.laneId,
|
|
999
|
+
path: record.path,
|
|
1000
|
+
branch: record.branch,
|
|
1001
|
+
};
|
|
1002
|
+
try {
|
|
1003
|
+
if (!run.worktreeAdapter?.remove) {
|
|
1004
|
+
Object.assign(summary, { removed: false, preserved: true, reason: "missing-worktree-adapter" });
|
|
1005
|
+
} else {
|
|
1006
|
+
const result = await run.worktreeAdapter.remove(record);
|
|
1007
|
+
Object.assign(summary, {
|
|
1008
|
+
path: result.path ?? summary.path,
|
|
1009
|
+
removed: result.removed === true,
|
|
1010
|
+
preserved: result.preserved === true,
|
|
1011
|
+
reason: result.reason,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
Object.assign(summary, { removed: false, preserved: true, reason: "remove-failed", error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS) });
|
|
1016
|
+
}
|
|
1017
|
+
changed = true;
|
|
1018
|
+
run.worktreeCleanup.integration.push(summary);
|
|
1019
|
+
try { await appendIntegrationLedger(run, { phase: "worktree-cleanup", ...summary }); } catch {}
|
|
1020
|
+
if (!summary.removed) {
|
|
1021
|
+
try { await appendEvent(run, { type: "integration.worktree_preserved", ...summary }); } catch {}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return changed;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Hard-enforced read-only-vs-edit asymmetry for integration. A salvaged lane is read-only by
|
|
1028
|
+
// construction: workflow_salvage skips edit/integration lanes entirely and never writes a
|
|
1029
|
+
// worktree commit, so a salvaged entry cannot merge or auto-apply. This predicate encodes that
|
|
1030
|
+
// invariant in code, not just docs: even a salvaged entry that somehow carried committed: true
|
|
1031
|
+
// is rejected here, keeping it out of integrateLaneCommits() and the path to runAutoApply().
|
|
1032
|
+
function isLaneIntegrable(lane) {
|
|
1033
|
+
return Boolean(lane)
|
|
1034
|
+
&& lane.committed === true
|
|
1035
|
+
&& lane.salvagedFromTranscript !== true
|
|
1036
|
+
&& lane.acceptedForIntegration !== false;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const DRAIN_FAILURE_STATUSES = new Set(["failed", "not_dry", "max_waves_exceeded", "budget_exhausted"]);
|
|
1040
|
+
function drainOutputFailed(output) {
|
|
1041
|
+
return output !== null && typeof output === "object" && (DRAIN_FAILURE_STATUSES.has(output.status) || output.drainBlocking === true);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function applyErrorWithRollbackFailures(error, rollbackFailures) {
|
|
1045
|
+
const primary = extractTextFromError(error);
|
|
1046
|
+
if (!Array.isArray(rollbackFailures) || rollbackFailures.length === 0) return primary;
|
|
1047
|
+
const paths = rollbackFailures.map((failure) => failure.path).filter(Boolean).join(", ");
|
|
1048
|
+
return `${primary}; Rollback incomplete${paths ? `: ${paths}` : ""}`;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Only host/extension-trusted workflow sources may auto-apply (never a project/global shadow), so an
|
|
1052
|
+
// approved-but-untrusted script cannot escalate a launch approval into an unattended primary-tree write
|
|
1053
|
+
// + domain mutation. Trusted = the core bundled dir + any explicitly-configured extension workflow dir
|
|
1054
|
+
// (read from the SAME registry that confers drain-adapter trust). A project/global shadow that WINS
|
|
1055
|
+
// resolution still lands on a project/global path, so it is denied here regardless of resolution order.
|
|
1056
|
+
function isTrustedAutoApplySource(sourcePath, pluginContext) {
|
|
1057
|
+
const resolved = path.resolve(String(sourcePath ?? ""));
|
|
1058
|
+
const isUnder = (root) => resolved === root || resolved.startsWith(root + path.sep);
|
|
1059
|
+
if (isUnder(BUNDLED_WORKFLOW_DIR)) return true;
|
|
1060
|
+
const extWfDirs = pluginContext?.workflowExtensionRegistry?.assetDirs()?.workflows ?? [];
|
|
1061
|
+
return extWfDirs.some((dir) => isUnder(path.resolve(String(dir))));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Generic autonomous-local auto-apply gate. Keyed on the PERSISTED authority (resume-safe), the drain
|
|
1065
|
+
// harness, a trusted source, and the registered adapter opting in (supportsAutoApply). Domain-neutral.
|
|
1066
|
+
function shouldAutoApplyDrain(run, pluginContext) {
|
|
1067
|
+
if (run.meta?.harness !== "drain") return false;
|
|
1068
|
+
if (run.authority?.profile !== "drain-autonomous-local") return false;
|
|
1069
|
+
if (!isTrustedAutoApplySource(run.sourcePath, pluginContext)) return false;
|
|
1070
|
+
const registration = pluginContext.workflowExtensionRegistry?.drainAdapter(run.meta?.adapter);
|
|
1071
|
+
if (registration && registration.supportsAutoApply !== true) return false;
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Autonomous-local auto-apply (.5): for an autonomous-local drain in a trusted source, a successful
|
|
1076
|
+
// drain with a verified diff plan is applied to the primary tree IN-RUN (still hash-gated, still using
|
|
1077
|
+
// the same patch/rollback/domain-finalization path as workflow_apply) instead of stopping at
|
|
1078
|
+
// awaiting-diff-approval. Staged domain mutations are finalized and read back. Apply failures
|
|
1079
|
+
// enter the retryable apply-failed state. Returns true on success.
|
|
1080
|
+
async function runAutoApply(pluginContext, toolContext, run) {
|
|
1081
|
+
const plan = run.editPlan;
|
|
1082
|
+
if (!plan || !Array.isArray(plan.patches) || plan.patches.length === 0) return false;
|
|
1083
|
+
const root = path.resolve(toolContext.worktree || toolContext.directory);
|
|
1084
|
+
let planned;
|
|
1085
|
+
try {
|
|
1086
|
+
await assertGitCleanAtBase(root, plan.baseCommit);
|
|
1087
|
+
planned = await validatePatchTargets(root, plan.patches);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
run.status = "apply-failed";
|
|
1090
|
+
run.error = extractTextFromError(error);
|
|
1091
|
+
await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, auto: true });
|
|
1092
|
+
await writeState(run);
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
run.status = "apply-running";
|
|
1096
|
+
await writeState(run);
|
|
1097
|
+
await appendApplyLedger(run.dir, { phase: "started", diffPlanHash: plan.diffPlanHash, patchCount: plan.patches.length, auto: true });
|
|
1098
|
+
try {
|
|
1099
|
+
for (const { patch } of planned) {
|
|
1100
|
+
await appendApplyLedger(run.dir, { phase: "before-write", diffPlanHash: plan.diffPlanHash, path: patch.path, contentHash: hash(patch.content), auto: true });
|
|
1101
|
+
// TOCTOU-safe (R18): re-validate ancestors + open the final component with
|
|
1102
|
+
// O_NOFOLLOW immediately at write time, so a symlink swapped in after
|
|
1103
|
+
// validatePatchTargets cannot redirect the write outside root.
|
|
1104
|
+
await safeWriteFileWithinRoot(root, patch.path, patch.content);
|
|
1105
|
+
await appendApplyLedger(run.dir, { phase: "after-write", diffPlanHash: plan.diffPlanHash, path: patch.path, contentHash: hash(patch.content), auto: true });
|
|
1106
|
+
}
|
|
1107
|
+
await appendApplyLedger(run.dir, { phase: "completed", diffPlanHash: plan.diffPlanHash, auto: true });
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
const rollbackFailures = await rollbackPatches(planned, root);
|
|
1110
|
+
run.status = "apply-failed";
|
|
1111
|
+
run.error = applyErrorWithRollbackFailures(error, rollbackFailures);
|
|
1112
|
+
await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, rollbackFailures, auto: true });
|
|
1113
|
+
await writeState(run);
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
const domainFinalization = await finalizeStagedDomainMutations(run.dir, run, (op) => pluginContext.__workflowDomainMutationHandlers?.[op] ?? pluginContext.workflowExtensionRegistry?.mutationHandler(op));
|
|
1118
|
+
run.domainFinalization = domainFinalization;
|
|
1119
|
+
run.error = undefined;
|
|
1120
|
+
await appendApplyLedger(run.dir, { phase: "domain-finalized", finalized: domainFinalization?.finalized ?? 0, auto: true });
|
|
1121
|
+
return true;
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
run.status = "apply-failed";
|
|
1124
|
+
run.error = `Domain finalization failed after auto-apply: ${extractTextFromError(error)}`;
|
|
1125
|
+
await appendApplyLedger(run.dir, { phase: "failed", diffPlanHash: plan.diffPlanHash, error: run.error, auto: true });
|
|
1126
|
+
await writeState(run);
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Orchestrator-resident primitives the extracted sandbox/child-agent boundaries depend on.
|
|
1132
|
+
// Injected (rather than imported by those modules) so the import graph stays acyclic:
|
|
1133
|
+
// workflow-plugin.js -> sandbox-executor.js -> child-agent-runner.js, never back. These all
|
|
1134
|
+
// read/write the shared mutable run object and remain owned here because other orchestrator
|
|
1135
|
+
// code (and the test surface) also uses them.
|
|
1136
|
+
const SANDBOX_DEPS = {
|
|
1137
|
+
throwIfAborted,
|
|
1138
|
+
throwIfLaneCancelled,
|
|
1139
|
+
acquireAgentSlot,
|
|
1140
|
+
releaseAgentSlot,
|
|
1141
|
+
checkDurableLifecycleRequest,
|
|
1142
|
+
dirtyWorktreeSalvage,
|
|
1143
|
+
laneTimeoutAliasValue,
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
async function runWorkflowExecution(pluginContext, toolContext, run, body, args) {
|
|
1147
|
+
let stopProgressToasts = () => {};
|
|
1148
|
+
// Run-level wall-clock deadline (jbs3.6): when maxRuntimeMs is set, arm a single timer that
|
|
1149
|
+
// aborts the run controller so a wedged lane/host call cannot outlive the deadline. The abort
|
|
1150
|
+
// surfaces as a WorkflowCancelledError through the cooperative checks; run.deadlineExceeded
|
|
1151
|
+
// makes the catch below record a terminal "timed-out" status with a partial result.json.
|
|
1152
|
+
let deadlineTimer;
|
|
1153
|
+
if (Number.isInteger(run.maxRuntimeMs) && run.maxRuntimeMs > 0) {
|
|
1154
|
+
deadlineTimer = setTimeout(() => {
|
|
1155
|
+
if (run.abortController.signal.aborted) return;
|
|
1156
|
+
run.deadlineExceeded = true;
|
|
1157
|
+
run.abortController.abort();
|
|
1158
|
+
rejectWaitingAgents(run, new WorkflowCancelledError(`Workflow run exceeded maxRuntimeMs deadline of ${run.maxRuntimeMs}ms`));
|
|
1159
|
+
abortRunChildren(pluginContext, run, toolContext.directory).catch(() => {});
|
|
1160
|
+
appendEvent(run, { type: "run.deadline_exceeded", maxRuntimeMs: run.maxRuntimeMs }).catch(() => {});
|
|
1161
|
+
}, run.maxRuntimeMs);
|
|
1162
|
+
if (typeof deadlineTimer.unref === "function") deadlineTimer.unref();
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
await showWorkflowRunToast(pluginContext, run, "heartbeat");
|
|
1166
|
+
stopProgressToasts = startWorkflowProgressToasts(pluginContext, run);
|
|
1167
|
+
const output = await executeSandbox(pluginContext, toolContext, run, body, args, {
|
|
1168
|
+
...SANDBOX_DEPS,
|
|
1169
|
+
checkDurableLifecycleRequest: (checkedRun) => checkDurableLifecycleRequest(pluginContext, toolContext, checkedRun),
|
|
1170
|
+
});
|
|
1171
|
+
throwIfAborted(run, toolContext);
|
|
1172
|
+
const drainFailed = drainOutputFailed(output);
|
|
1173
|
+
const integrationLanes = run.integrationPlan?.lanes?.filter(isLaneIntegrable) ?? [];
|
|
1174
|
+
if (integrationLanes.length > 0) {
|
|
1175
|
+
await appendIntegrationLedger(run, { phase: "integration-started", laneCount: integrationLanes.length });
|
|
1176
|
+
const result = await integrateLaneCommits({
|
|
1177
|
+
adapter: run.worktreeAdapter,
|
|
1178
|
+
runId: run.id,
|
|
1179
|
+
baseCommit: run.integrationPlan.baseCommit,
|
|
1180
|
+
lanes: integrationLanes,
|
|
1181
|
+
secretGlobs: SECRET_GLOBS,
|
|
1182
|
+
signal: run.abortController.signal,
|
|
1183
|
+
});
|
|
1184
|
+
await appendIntegrationLedger(run, { phase: "integration-completed", status: result.status, mergedLaneCount: result.mergedLanes?.length ?? 0, patchCount: result.patches?.length ?? 0 });
|
|
1185
|
+
if (result.validation) {
|
|
1186
|
+
const validationRecord = {
|
|
1187
|
+
phase: "integration-validation",
|
|
1188
|
+
validationKey: `integration:${run.id}`,
|
|
1189
|
+
status: result.validation.accepted === true ? "passed" : "failed",
|
|
1190
|
+
reason: result.validation.reason,
|
|
1191
|
+
validationCommands: result.validation.validationCommands,
|
|
1192
|
+
evidence: result.validation.evidence,
|
|
1193
|
+
};
|
|
1194
|
+
await appendValidationLedger(run, validationRecord);
|
|
1195
|
+
await appendEvent(run, { type: "integration.validation", status: validationRecord.status, reason: validationRecord.reason });
|
|
1196
|
+
}
|
|
1197
|
+
run.integrationPlan.integrationResult = result;
|
|
1198
|
+
run.integrationPlan.patches = result.patches ?? [];
|
|
1199
|
+
if (result.integrationWorktree) run.integrationWorktrees.push({ role: "integration", ...result.integrationWorktree });
|
|
1200
|
+
if (result.status === "review-required") {
|
|
1201
|
+
run.status = "review-required";
|
|
1202
|
+
run.error = result.reason || result.error || "Integration requires review";
|
|
1203
|
+
run.finishedAt = new Date().toISOString();
|
|
1204
|
+
run.resultPath = path.join(run.dir, "result.json");
|
|
1205
|
+
await writeJsonAtomic(run.resultPath, redactDurableValue({ output, integration: result }));
|
|
1206
|
+
await appendIntegrationLedger(run, { phase: "review-required", reason: result.reason, culpritLane: result.culpritLane, conflictCount: result.conflicts?.length ?? 0 });
|
|
1207
|
+
await appendEvent(run, { type: "integration.review_required", reason: result.reason, culpritLane: result.culpritLane, conflictCount: result.conflicts?.length ?? 0 });
|
|
1208
|
+
const notification = await writeCompletionNotification(run);
|
|
1209
|
+
await writeState(run);
|
|
1210
|
+
await maybeDeliverCompletionNotification(pluginContext, notification);
|
|
1211
|
+
await showWorkflowRunToast(pluginContext, run, "terminal");
|
|
1212
|
+
return [
|
|
1213
|
+
`Workflow ${run.id} review-required.`,
|
|
1214
|
+
...workflowInlineResultLines(run, output),
|
|
1215
|
+
`Result file: ${run.resultPath}`,
|
|
1216
|
+
...workflowResultReadbackLines(run),
|
|
1217
|
+
result.reason ? `Reason: ${result.reason}` : undefined,
|
|
1218
|
+
result.culpritLane ? `Culprit lane: ${result.culpritLane}` : undefined,
|
|
1219
|
+
].filter((line) => line !== undefined).join("\n");
|
|
1220
|
+
}
|
|
1221
|
+
if (result.patches?.length > 0) {
|
|
1222
|
+
run.editPlan = {
|
|
1223
|
+
sourceHash: run.sourceHash,
|
|
1224
|
+
baseCommit: run.integrationPlan.baseCommit,
|
|
1225
|
+
patches: result.patches,
|
|
1226
|
+
worktrees: run.integrationWorktrees,
|
|
1227
|
+
integration: true,
|
|
1228
|
+
lanes: result.lanes,
|
|
1229
|
+
mergedLanes: result.mergedLanes,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (run.editPlan?.patches?.length > 0) {
|
|
1234
|
+
run.editPlan.worktrees = run.editPlan.integration ? run.integrationWorktrees : run.editWorktrees;
|
|
1235
|
+
run.editPlan.domainMutationManifest = await stagedDomainMutationManifest(run.dir);
|
|
1236
|
+
run.editPlan.domainMutationHash = computeDomainMutationHash(run.editPlan.domainMutationManifest);
|
|
1237
|
+
run.editPlan.diffPlanHash = computeDiffPlanHash(run.editPlan);
|
|
1238
|
+
await writeJsonAtomic(path.join(run.dir, "diff-plan.json"), run.editPlan);
|
|
1239
|
+
if (run.editPlan.integration) await appendIntegrationLedger(run, { phase: "diff-plan-created", diffPlanHash: run.editPlan.diffPlanHash, patchCount: run.editPlan.patches.length });
|
|
1240
|
+
// A failed/partial drain must not be masked as a clean apply-ready success.
|
|
1241
|
+
// Preserve the diff plan for review but surface the failure in run status, event, toast, and summary.
|
|
1242
|
+
run.status = drainFailed ? "failed-with-diff-plan" : "awaiting-diff-approval";
|
|
1243
|
+
if (run.status === "awaiting-diff-approval" && !run.approvalWait?.startedAt) {
|
|
1244
|
+
run.approvalWait = { startedAt: new Date().toISOString(), diffPlanHash: run.editPlan.diffPlanHash };
|
|
1245
|
+
}
|
|
1246
|
+
} else {
|
|
1247
|
+
run.editPlan = undefined;
|
|
1248
|
+
run.status = "completed";
|
|
1249
|
+
}
|
|
1250
|
+
// Autonomous-local auto-apply (.5): a trusted autonomous-local drain applies a verified, successful
|
|
1251
|
+
// diff plan in-run instead of stopping at awaiting-diff-approval. Failed drains keep failed-with-diff-plan.
|
|
1252
|
+
if (run.status === "awaiting-diff-approval" && shouldAutoApplyDrain(run, pluginContext)) {
|
|
1253
|
+
const applied = await runAutoApply(pluginContext, toolContext, run);
|
|
1254
|
+
if (applied) run.status = "completed";
|
|
1255
|
+
}
|
|
1256
|
+
run.finishedAt = new Date().toISOString();
|
|
1257
|
+
run.resultPath = path.join(run.dir, "result.json");
|
|
1258
|
+
await writeJsonAtomic(run.resultPath, redactDurableValue({ output }));
|
|
1259
|
+
await appendEvent(run, {
|
|
1260
|
+
type: run.status === "awaiting-diff-approval" ? "run.awaiting_diff_approval" : run.status === "failed-with-diff-plan" ? "run.failed_with_diff_plan" : run.status === "apply-failed" ? "run.apply_failed" : "run.completed",
|
|
1261
|
+
diffPlanHash: run.editPlan?.diffPlanHash,
|
|
1262
|
+
awaitingDiffApprovalAt: run.approvalWait?.startedAt,
|
|
1263
|
+
drainStatus: typeof output === "object" && output ? output.status : undefined,
|
|
1264
|
+
});
|
|
1265
|
+
const notification = await writeCompletionNotification(run);
|
|
1266
|
+
await writeState(run);
|
|
1267
|
+
await maybeDeliverCompletionNotification(pluginContext, notification);
|
|
1268
|
+
await showWorkflowRunToast(pluginContext, run, "terminal");
|
|
1269
|
+
return [
|
|
1270
|
+
`Workflow ${run.id} ${run.status === "awaiting-diff-approval" ? "awaiting diff approval" : run.status === "failed-with-diff-plan" ? "failed with diff plan for review" : run.status === "apply-failed" ? "auto-apply failed" : "completed"}.`,
|
|
1271
|
+
...workflowInlineResultLines(run, output),
|
|
1272
|
+
`Result file: ${run.resultPath}`,
|
|
1273
|
+
...workflowResultReadbackLines(run),
|
|
1274
|
+
run.editPlan?.diffPlanHash ? `Diff plan hash: ${run.editPlan.diffPlanHash}` : undefined,
|
|
1275
|
+
drainFailed && typeof output === "object" && output ? `Drain status: ${output.status}` : undefined,
|
|
1276
|
+
].filter((line) => line !== undefined).join("\n");
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
rejectWaitingAgents(run, error);
|
|
1279
|
+
// Precedence: an explicit force-kill (resumable "interrupted") and a wall-clock deadline
|
|
1280
|
+
// (terminal "timed-out") both abort the controller, so they must be classified before the
|
|
1281
|
+
// generic aborted->"cancelled" fallback.
|
|
1282
|
+
// Precedence: a budget stop does NOT abort the controller, so it is classified before the
|
|
1283
|
+
// generic aborted->"cancelled"/"failed" fallback. The thrown WorkflowBudgetStoppedError loses
|
|
1284
|
+
// its prototype/code crossing the QuickJS guest boundary (sandbox-executor re-wraps lane
|
|
1285
|
+
// errors as plain VM errors), so detect it the same way the rest of the run state is tracked:
|
|
1286
|
+
// off the durable run object. journalFailure records a per-lane "budget_stopped" outcome via
|
|
1287
|
+
// laneOutcomeForError BEFORE the error crosses that boundary, so run.laneOutcomes is the
|
|
1288
|
+
// reliable run-level signal. Keep the direct error checks for any controller-side throw that
|
|
1289
|
+
// never crossed the VM. budget_stopped and failed are both resumable (RESUMABLE_STATUSES).
|
|
1290
|
+
const budgetStopped = error instanceof WorkflowBudgetStoppedError
|
|
1291
|
+
|| error?.code === "WORKFLOW_BUDGET_STOPPED"
|
|
1292
|
+
|| (run.laneOutcomes?.budget_stopped ?? 0) > 0;
|
|
1293
|
+
run.status = run.killed ? "interrupted"
|
|
1294
|
+
: run.deadlineExceeded ? "timed-out"
|
|
1295
|
+
: run.pauseRequested ? "paused"
|
|
1296
|
+
: budgetStopped ? "budget_stopped"
|
|
1297
|
+
: run.abortController.signal.aborted ? "cancelled"
|
|
1298
|
+
: "failed";
|
|
1299
|
+
run.error = extractTextFromError(error);
|
|
1300
|
+
run.finishedAt = new Date().toISOString();
|
|
1301
|
+
if (run.status === "timed-out" || run.status === "budget_stopped" || run.status === "failed") {
|
|
1302
|
+
// Persist a partial result so an operator/agent can see how the run terminated and what
|
|
1303
|
+
// completed-lane work survived (durable lane projections/journal remain on disk
|
|
1304
|
+
// independently). For the resumable terminal states (failed/budget_stopped) this makes the
|
|
1305
|
+
// recoverable work observable so a resume reuses it instead of re-spending.
|
|
1306
|
+
run.resultPath = path.join(run.dir, "result.json");
|
|
1307
|
+
await writeJsonAtomic(run.resultPath, redactDurableValue({
|
|
1308
|
+
status: run.status,
|
|
1309
|
+
partial: true,
|
|
1310
|
+
resumable: RESUMABLE_STATUSES.has(run.status),
|
|
1311
|
+
maxRuntimeMs: run.maxRuntimeMs,
|
|
1312
|
+
budgetCeilings: run.budgetCeilings,
|
|
1313
|
+
laneOutcomes: run.laneOutcomes,
|
|
1314
|
+
error: truncateText(run.error, MAX_STATUS_STRING_CHARS),
|
|
1315
|
+
}));
|
|
1316
|
+
}
|
|
1317
|
+
const eventType = run.status === "paused" ? "run.paused"
|
|
1318
|
+
: run.status === "interrupted" ? "run.killed"
|
|
1319
|
+
: run.status === "timed-out" ? "run.timed_out"
|
|
1320
|
+
: run.status === "budget_stopped" ? "run.budget_stopped"
|
|
1321
|
+
: run.status === "cancelled" ? "run.cancelled"
|
|
1322
|
+
: "run.failed";
|
|
1323
|
+
await appendEvent(run, { type: eventType, error: truncateText(run.error, MAX_STATUS_STRING_CHARS) });
|
|
1324
|
+
const notification = await writeCompletionNotification(run);
|
|
1325
|
+
await writeState(run);
|
|
1326
|
+
await maybeDeliverCompletionNotification(pluginContext, notification);
|
|
1327
|
+
await showWorkflowRunToast(pluginContext, run, "terminal");
|
|
1328
|
+
throw error;
|
|
1329
|
+
} finally {
|
|
1330
|
+
clearTimeout(deadlineTimer);
|
|
1331
|
+
stopProgressToasts();
|
|
1332
|
+
run.eventSink = undefined;
|
|
1333
|
+
try {
|
|
1334
|
+
// The diff plan is self-contained (patches carry their content), so worktrees are
|
|
1335
|
+
// vestigial once the sandbox finishes; remove them for every terminal outcome to
|
|
1336
|
+
// avoid leaking git worktrees/branches and throwaway directories.
|
|
1337
|
+
const cleaned = await cleanupWorktrees(run);
|
|
1338
|
+
if (cleaned && run.status !== "running") await writeState(run);
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
run.diagnostics ??= {};
|
|
1341
|
+
run.diagnostics.finalizationCleanupError = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
|
|
1342
|
+
try { await appendEvent(run, { type: "run.finalization_cleanup_failed", error: run.diagnostics.finalizationCleanupError }); } catch {}
|
|
1343
|
+
} finally {
|
|
1344
|
+
if (!run.background || run.status !== "running") runs.delete(run.id);
|
|
1345
|
+
if (run.status !== "running") await run.releaseRunLock?.();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function sameStableValue(a, b) {
|
|
1351
|
+
return stableStringify(a) === stableStringify(b);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// jbs3.4: resume replay preview for the approval gate. A resumed run re-executes the body and
|
|
1355
|
+
// serves completed lanes from the journal cache when each lane's signature still matches. The
|
|
1356
|
+
// signature (event-journal.laneSignature) is keyed on sourceHash + runtimeArgs + the lane's resolved
|
|
1357
|
+
// model + body/capability-derived fields. On an allowed resume the source and model envelope are
|
|
1358
|
+
// PINNED (a change is rejected by assertResumeEnvelopeUnchanged before approval), so the only
|
|
1359
|
+
// operator-facing lever that still invalidates the cache is runtimeArgs -- and changing any of these
|
|
1360
|
+
// shared, top-level signature inputs invalidates EVERY completed lane uniformly, forcing it to
|
|
1361
|
+
// re-run and re-pay its prior spend (which still counts toward the budget ceiling). This computes
|
|
1362
|
+
// how many completed lanes would re-run and the approximate dollar re-spend so the operator sees it
|
|
1363
|
+
// before approving. Returns null for a cold start (no prior run to replay).
|
|
1364
|
+
async function computeResumeReplayPreview(resumeEntry, priorState, proposed) {
|
|
1365
|
+
if (!resumeEntry || !priorState) return null;
|
|
1366
|
+
const journal = await loadJournal(resumeEntry.dir);
|
|
1367
|
+
const completedLanes = [...journal.values()].filter((entry) => entry.outcome === "success");
|
|
1368
|
+
// jbs3.3 edit-and-resume: an edited body changes sourceHash, but lane reuse is now content-addressed
|
|
1369
|
+
// PER LANE (event-journal.laneSignature no longer mixes in the whole-file hash), so a source edit no
|
|
1370
|
+
// longer uniformly invalidates every completed lane — only the lanes whose own resolved inputs
|
|
1371
|
+
// changed re-run. Which lanes those are is not known until the new body executes (each lane's prompt
|
|
1372
|
+
// is resolved during replay), so the preview reports an honest worst-case bound rather than the false
|
|
1373
|
+
// "everything re-runs" the whole-run hash compare would give. The other levers (runtimeArgs, model)
|
|
1374
|
+
// are top-level signature inputs whose change DOES invalidate every lane uniformly, so they are still
|
|
1375
|
+
// reported as a hard count.
|
|
1376
|
+
const editedBody = proposed.editAndResume === true && proposed.sourceHash !== priorState.sourceHash;
|
|
1377
|
+
const reasons = [];
|
|
1378
|
+
if (!editedBody && proposed.sourceHash !== priorState.sourceHash) reasons.push("source");
|
|
1379
|
+
if (stableStringify(proposed.runtimeArgs ?? null) !== stableStringify(priorState.runtimeArgs ?? null)) reasons.push("runtime args");
|
|
1380
|
+
if (typeof priorState.defaultChildModel === "string" && proposed.defaultChildModel !== priorState.defaultChildModel) reasons.push("model");
|
|
1381
|
+
const invalidated = reasons.length > 0;
|
|
1382
|
+
const willReRun = invalidated ? completedLanes : [];
|
|
1383
|
+
const reSpend = willReRun.reduce((sum, entry) => sum + (Number.isFinite(entry.cost) ? entry.cost : 0), 0);
|
|
1384
|
+
const maxReSpend = completedLanes.reduce((sum, entry) => sum + (Number.isFinite(entry.cost) ? entry.cost : 0), 0);
|
|
1385
|
+
return { completed: completedLanes.length, willReRun: willReRun.length, reSpend, reasons, editedBody, maxReSpend };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function resumeReplayLine(preview) {
|
|
1389
|
+
if (preview.editedBody) {
|
|
1390
|
+
return `Resume replay: edited body — unchanged lanes replay from cache at no new spend; only edited/dependent lanes re-run (up to ${preview.completed} cached lanes, ~$${preview.maxReSpend.toFixed(4)} worst-case re-spend; replayed spend already counts toward the budget ceiling).`;
|
|
1391
|
+
}
|
|
1392
|
+
if (preview.willReRun > 0) {
|
|
1393
|
+
return `Resume replay: ${preview.willReRun} lanes will re-run, ~$${preview.reSpend.toFixed(4)} re-spend (changed ${preview.reasons.join(", ")} invalidates ${preview.willReRun} of ${preview.completed} cached lanes; replayed spend already counts toward the budget ceiling).`;
|
|
1394
|
+
}
|
|
1395
|
+
return `Resume replay: 0 lanes will re-run, ~$0 re-spend (${preview.completed} completed lanes replay from cache at no new spend).`;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function assertResumeEnvelopeUnchanged(args, prior, requested, opts = {}) {
|
|
1399
|
+
if (!prior) return;
|
|
1400
|
+
if (Object.hasOwn(args, "maxAgents") && args.maxAgents !== prior.maxAgents) throw new Error(`resumeRunId cannot change maxAgents from ${prior.maxAgents} to ${args.maxAgents}`);
|
|
1401
|
+
// jbs3.4: the model envelope is pinned to the prior segment (defaultChildModel/modelTiers above).
|
|
1402
|
+
// Reject -- rather than silently ignore -- an operator who passes a DIFFERENT childModel/modelTiers
|
|
1403
|
+
// on resume: changing the model would invalidate every completed lane's cached signature and force
|
|
1404
|
+
// a full re-run/re-spend, so it must start a new workflow instead. Read the requested arg directly
|
|
1405
|
+
// (normalized the same way as the resolved envelope) so the comparison is apples-to-apples.
|
|
1406
|
+
if (Object.hasOwn(args, "childModel") && args.childModel != null && typeof prior.defaultChildModel === "string") {
|
|
1407
|
+
const requestedModel = modelKey(resolveRequestedModel(args.childModel, "default child"));
|
|
1408
|
+
if (requestedModel !== prior.defaultChildModel) throw new Error(`resumeRunId cannot change the child model from ${prior.defaultChildModel} to ${requestedModel} without starting a new workflow`);
|
|
1409
|
+
}
|
|
1410
|
+
if (Object.hasOwn(args, "modelTiers") && args.modelTiers && typeof args.modelTiers === "object" && !Array.isArray(args.modelTiers)) {
|
|
1411
|
+
for (const tier of ["fast", "deep"]) {
|
|
1412
|
+
if (!Object.hasOwn(args.modelTiers, tier) || args.modelTiers[tier] == null) continue;
|
|
1413
|
+
const requestedTier = modelKey(resolveRequestedModel(args.modelTiers[tier], `${tier} tier`));
|
|
1414
|
+
const priorTier = prior.modelTiers && typeof prior.modelTiers[tier] === "string" ? prior.modelTiers[tier] : prior.defaultChildModel;
|
|
1415
|
+
if (typeof priorTier === "string" && requestedTier !== priorTier) throw new Error(`resumeRunId cannot change the ${tier}-tier model from ${priorTier} to ${requestedTier} without starting a new workflow`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
const requestedLaneTimeoutMs = laneTimeoutAliasValue(args, "workflow_run");
|
|
1419
|
+
if (requestedLaneTimeoutMs !== undefined && requestedLaneTimeoutMs !== (prior.laneTimeoutMs ?? DEFAULT_CHILD_PROMPT_TIMEOUT_MS)) throw new Error("resumeRunId cannot change laneTimeoutMs without starting a new workflow");
|
|
1420
|
+
const priorBudget = normalizeBudgetCeilings(prior.budgetCeilings);
|
|
1421
|
+
// jbs3.1: resuming a budget-stopped run may RAISE the cost/token ceiling (re-approved) so the
|
|
1422
|
+
// remaining/failed lanes get headroom; the raise (never a lower) is validated by
|
|
1423
|
+
// resolveResumeBudgetCeilings, so the equality pin is relaxed only on that path.
|
|
1424
|
+
if (!opts.allowBudgetRaise) {
|
|
1425
|
+
if (Object.hasOwn(args, "maxCost") && args.maxCost !== priorBudget.maxCost) throw new Error("resumeRunId cannot change maxCost without starting a new workflow");
|
|
1426
|
+
if (Object.hasOwn(args, "maxTokens") && args.maxTokens !== priorBudget.maxTokens) throw new Error("resumeRunId cannot change maxTokens without starting a new workflow");
|
|
1427
|
+
}
|
|
1428
|
+
if (Object.hasOwn(args, "authority") && !sameStableValue(args.authority, prior.authority)) throw new Error("resumeRunId cannot change authority without starting a new workflow");
|
|
1429
|
+
if (Object.hasOwn(args, "profile") && args.profile !== prior.authority?.profile) throw new Error("resumeRunId cannot change profile without starting a new workflow");
|
|
1430
|
+
if (Object.hasOwn(args, "background") && args.background !== (prior.background === true)) throw new Error("resumeRunId cannot change background mode without starting a new workflow");
|
|
1431
|
+
if (Object.hasOwn(args, "debugCapture") && args.debugCapture !== (prior.debugCapture?.enabled === true)) throw new Error("resumeRunId cannot change debugCapture without starting a new workflow");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// jbs3.1: on a budget-stopped resume the operator may RAISE the cost/token ceiling so the
|
|
1435
|
+
// remaining/failed lanes have headroom; completed lanes still replay from cache at zero new
|
|
1436
|
+
// spend. A raise is the ONLY permitted change — lowering a ceiling below the prior value (which
|
|
1437
|
+
// historical replayed spend may already exceed) is rejected; omitting a ceiling keeps the prior.
|
|
1438
|
+
function resolveResumeBudgetCeilings(args, priorBudget) {
|
|
1439
|
+
const next = { maxCost: priorBudget.maxCost, maxTokens: priorBudget.maxTokens };
|
|
1440
|
+
if (Object.hasOwn(args, "maxCost") && Number.isFinite(args.maxCost)) {
|
|
1441
|
+
if (Number.isFinite(priorBudget.maxCost) && args.maxCost < priorBudget.maxCost) {
|
|
1442
|
+
throw new Error(`resumeRunId cannot lower maxCost on a budget-stopped resume (prior ${priorBudget.maxCost}, requested ${args.maxCost}); raise it or start a new workflow`);
|
|
1443
|
+
}
|
|
1444
|
+
next.maxCost = args.maxCost;
|
|
1445
|
+
}
|
|
1446
|
+
if (Object.hasOwn(args, "maxTokens") && Number.isInteger(args.maxTokens)) {
|
|
1447
|
+
if (Number.isInteger(priorBudget.maxTokens) && args.maxTokens < priorBudget.maxTokens) {
|
|
1448
|
+
throw new Error(`resumeRunId cannot lower maxTokens on a budget-stopped resume (prior ${priorBudget.maxTokens}, requested ${args.maxTokens}); raise it or start a new workflow`);
|
|
1449
|
+
}
|
|
1450
|
+
next.maxTokens = args.maxTokens;
|
|
1451
|
+
}
|
|
1452
|
+
return normalizeBudgetCeilings(next);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Best-effort read of the invoking session's active model so the default child
|
|
1456
|
+
// model can inherit it. Tries the live session first (when the SDK exposes a
|
|
1457
|
+
// model on the session), then the global config default. Never throws; returns
|
|
1458
|
+
// { model: null } when nothing is readable, in which case callers must supply an
|
|
1459
|
+
// explicit child model (there is no hard-coded model fallback).
|
|
1460
|
+
async function readActiveSessionModel(pluginContext, toolContext) {
|
|
1461
|
+
const client = pluginContext?.client;
|
|
1462
|
+
if (!client) return { model: null, source: "none" };
|
|
1463
|
+
try {
|
|
1464
|
+
if (toolContext?.sessionID && client.session?.get) {
|
|
1465
|
+
const got = await client.session.get({ path: { id: toolContext.sessionID } });
|
|
1466
|
+
const m = got?.data?.model;
|
|
1467
|
+
if (typeof m === "string" && m.includes("/")) return { model: m, source: "active" };
|
|
1468
|
+
}
|
|
1469
|
+
} catch { /* fall through to config default */ }
|
|
1470
|
+
try {
|
|
1471
|
+
const cfg = await client.config?.get?.();
|
|
1472
|
+
const m = cfg?.data?.model;
|
|
1473
|
+
if (typeof m === "string" && m.includes("/")) return { model: m, source: "config-default" };
|
|
1474
|
+
} catch { /* fall through */ }
|
|
1475
|
+
return { model: null, source: "none" };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const WORKFLOW_PROVIDER_LIST_TTL_MS = VERIFIED_PROBE_TTL_MS;
|
|
1479
|
+
const workflowProviderListCache = new Map();
|
|
1480
|
+
const workflowProviderListClientKeys = new WeakMap();
|
|
1481
|
+
let workflowProviderListClientKeySeq = 0;
|
|
1482
|
+
|
|
1483
|
+
function workflowProviderListCacheKey(pluginContext) {
|
|
1484
|
+
if (pluginContext?.serverUrl !== undefined && pluginContext?.serverUrl !== null && pluginContext?.serverUrl !== false) {
|
|
1485
|
+
return `server:${String(pluginContext.serverUrl)}`;
|
|
1486
|
+
}
|
|
1487
|
+
const client = pluginContext?.client;
|
|
1488
|
+
if (client && (typeof client === "object" || typeof client === "function")) {
|
|
1489
|
+
let key = workflowProviderListClientKeys.get(client);
|
|
1490
|
+
if (!key) {
|
|
1491
|
+
workflowProviderListClientKeySeq += 1;
|
|
1492
|
+
key = `client:${workflowProviderListClientKeySeq}`;
|
|
1493
|
+
workflowProviderListClientKeys.set(client, key);
|
|
1494
|
+
}
|
|
1495
|
+
return key;
|
|
1496
|
+
}
|
|
1497
|
+
return "default";
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function emptyProviderListReadback() {
|
|
1501
|
+
return { providersRaw: [], providerDefault: {} };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
async function readWorkflowProviderList(pluginContext) {
|
|
1505
|
+
const client = pluginContext?.client;
|
|
1506
|
+
if (!client?.config?.providers) return emptyProviderListReadback();
|
|
1507
|
+
|
|
1508
|
+
const key = workflowProviderListCacheKey(pluginContext);
|
|
1509
|
+
const existing = workflowProviderListCache.get(key);
|
|
1510
|
+
const now = Date.now();
|
|
1511
|
+
if (existing) {
|
|
1512
|
+
if (now - existing.ts < WORKFLOW_PROVIDER_LIST_TTL_MS) return await existing.promise;
|
|
1513
|
+
workflowProviderListCache.delete(key);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const entry = { ts: now, promise: undefined };
|
|
1517
|
+
entry.promise = (async () => {
|
|
1518
|
+
try {
|
|
1519
|
+
const res = await client.config.providers();
|
|
1520
|
+
return {
|
|
1521
|
+
providersRaw: Array.isArray(res?.data?.providers) ? res.data.providers : [],
|
|
1522
|
+
providerDefault: res?.data?.default ?? {},
|
|
1523
|
+
};
|
|
1524
|
+
} catch {
|
|
1525
|
+
if (workflowProviderListCache.get(key) === entry) workflowProviderListCache.delete(key);
|
|
1526
|
+
return emptyProviderListReadback();
|
|
1527
|
+
}
|
|
1528
|
+
})();
|
|
1529
|
+
workflowProviderListCache.set(key, entry);
|
|
1530
|
+
return await entry.promise;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function invalidateWorkflowProviderListCache(pluginContextOrScope) {
|
|
1534
|
+
if (pluginContextOrScope === undefined || pluginContextOrScope === "all") {
|
|
1535
|
+
const count = workflowProviderListCache.size;
|
|
1536
|
+
workflowProviderListCache.clear();
|
|
1537
|
+
return count;
|
|
1538
|
+
}
|
|
1539
|
+
const key = typeof pluginContextOrScope === "string"
|
|
1540
|
+
? pluginContextOrScope
|
|
1541
|
+
: workflowProviderListCacheKey(pluginContextOrScope);
|
|
1542
|
+
return workflowProviderListCache.delete(key) ? 1 : 0;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function hardConcurrencyLimitForContext(pluginContext) {
|
|
1546
|
+
return normalizeHardConcurrencyLimit(pluginContext?.__workflowHardConcurrencyLimit, HARD_CONCURRENCY_LIMIT);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Read-only model discovery for the planning agent: the invoking session's model
|
|
1550
|
+
// plus every available/authenticated provider and its models, and a no-deviation
|
|
1551
|
+
// fast/deep suggestion (both default to the session model — stay in family).
|
|
1552
|
+
// Never throws; missing providers degrade to an empty list.
|
|
1553
|
+
async function buildWorkflowModels(pluginContext, toolContext) {
|
|
1554
|
+
const session = await readActiveSessionModel(pluginContext, toolContext);
|
|
1555
|
+
const { providersRaw, providerDefault } = await readWorkflowProviderList(pluginContext);
|
|
1556
|
+
const providers = providersRaw.map((p) => ({
|
|
1557
|
+
id: p.id,
|
|
1558
|
+
name: p.name,
|
|
1559
|
+
source: p.source,
|
|
1560
|
+
default: providerDefault[p.id] ?? null,
|
|
1561
|
+
models: Object.values(p.models ?? {}).map((m) => ({
|
|
1562
|
+
id: m.id,
|
|
1563
|
+
name: m.name,
|
|
1564
|
+
...(m.variants ? { variants: Object.keys(m.variants) } : {}),
|
|
1565
|
+
})),
|
|
1566
|
+
}));
|
|
1567
|
+
const sessionModel = session.model;
|
|
1568
|
+
const slash = typeof sessionModel === "string" ? sessionModel.indexOf("/") : -1;
|
|
1569
|
+
const providerID = slash > 0 ? sessionModel.slice(0, slash) : null;
|
|
1570
|
+
const modelID = slash > 0 ? sessionModel.slice(slash + 1) : null;
|
|
1571
|
+
// Default suggestion = stay in the session family at the same model (no deviation).
|
|
1572
|
+
const suggested = { fast: sessionModel ?? null, deep: sessionModel ?? null };
|
|
1573
|
+
return {
|
|
1574
|
+
session: { model: sessionModel, providerID, modelID, family: providerID, source: session.source },
|
|
1575
|
+
providers,
|
|
1576
|
+
suggested,
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Pre-flight model validation (jbs3.5): cross-reference every resolved run model
|
|
1581
|
+
// (defaultChildModel + modelTiers.fast/deep) against the live provider/model list
|
|
1582
|
+
// at plan time and reject an unknown model BEFORE approval, any capability probe,
|
|
1583
|
+
// or any lane launch — with the available-models list in the message.
|
|
1584
|
+
//
|
|
1585
|
+
// Degrades gracefully for transient provider-list gaps: an empty/unreadable
|
|
1586
|
+
// provider list (buildWorkflowModels never throws and returns [] when the read
|
|
1587
|
+
// fails) skips validation rather than rejecting every model. Only a non-empty,
|
|
1588
|
+
// successfully-read list can reject, so a real list that genuinely omits the
|
|
1589
|
+
// requested model is the only way to fail closed.
|
|
1590
|
+
function assertModelsAvailable(providers, models) {
|
|
1591
|
+
if (!Array.isArray(providers) || providers.length === 0) return;
|
|
1592
|
+
const index = new Map();
|
|
1593
|
+
for (const p of providers) {
|
|
1594
|
+
index.set(p.id, new Set((p.models ?? []).map((m) => m.id)));
|
|
1595
|
+
}
|
|
1596
|
+
const available = providers
|
|
1597
|
+
.map((p) => `${p.id}: ${(p.models ?? []).map((m) => m.id).join(", ") || "(none)"}`)
|
|
1598
|
+
.join("; ");
|
|
1599
|
+
for (const { label, model } of models) {
|
|
1600
|
+
if (!model || typeof model !== "string") continue;
|
|
1601
|
+
const slash = model.indexOf("/");
|
|
1602
|
+
const providerID = slash > 0 ? model.slice(0, slash) : null;
|
|
1603
|
+
const modelID = slash > 0 ? model.slice(slash + 1) : null;
|
|
1604
|
+
const providerModels = providerID ? index.get(providerID) : undefined;
|
|
1605
|
+
if (!providerModels) {
|
|
1606
|
+
throw new Error(`Model "${model}" (${label}) not available: provider "${providerID ?? model}" is not in the available provider list. Available: ${available}`);
|
|
1607
|
+
}
|
|
1608
|
+
if (!providerModels.has(modelID)) {
|
|
1609
|
+
throw new Error(`Model "${model}" (${label}) not available from provider "${providerID}"; available: ${available}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// jbs3.10: a workflow may declare meta.argsSchema (a JSON Schema). When present, the runtime args
|
|
1615
|
+
// payload (args.args) is validated against it at plan time — before the approval envelope is built
|
|
1616
|
+
// and before any lane launches — so a malformed payload fails loudly here instead of surfacing as a
|
|
1617
|
+
// confusing mid-run failure (or silently running with missing/extra fields). Reuses the existing
|
|
1618
|
+
// shared AJV instance (structured-output.js) rather than adding a second validator.
|
|
1619
|
+
function assertWorkflowArgsMatchSchema(meta, runtimeArgs) {
|
|
1620
|
+
const schema = meta?.argsSchema;
|
|
1621
|
+
if (schema === undefined || schema === null) return;
|
|
1622
|
+
if (typeof schema !== "object" || Array.isArray(schema)) {
|
|
1623
|
+
throw new Error("Workflow meta.argsSchema must be a JSON Schema object");
|
|
1624
|
+
}
|
|
1625
|
+
let validate;
|
|
1626
|
+
try {
|
|
1627
|
+
// A workflow is typically planned twice (preview, then approve) in one process; if the author
|
|
1628
|
+
// gave the schema an $id, reuse the already-compiled validator instead of recompiling (which
|
|
1629
|
+
// AJV rejects as a duplicate id) — but only when the schema CONTENT still matches what was
|
|
1630
|
+
// registered under that $id. A reused $id carrying a differently-shaped schema (edit-and-resume,
|
|
1631
|
+
// or an unrelated workflow picking the same id) must recompile, not silently validate against the
|
|
1632
|
+
// stale rules. compileSchemaWithIdentity enforces that content check; see structured-output.js.
|
|
1633
|
+
validate = compileSchemaWithIdentity(schema);
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
throw new Error(`Invalid workflow meta.argsSchema: ${extractTextFromError(error)}`);
|
|
1636
|
+
}
|
|
1637
|
+
if (validate(runtimeArgs ?? null)) return;
|
|
1638
|
+
throw new Error(
|
|
1639
|
+
`Workflow args do not match meta.argsSchema:\n${ajv.errorsText(validate.errors, { separator: "\n", dataVar: "args" })}`,
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async function planWorkflowEnvelope(pluginContext, toolContext, args) {
|
|
1644
|
+
const resumeRunId = args.resumeRunId ? assertSafeRunId(args.resumeRunId, "resumeRunId") : undefined;
|
|
1645
|
+
// jbs3.3 edit-and-resume: the opt-in only makes sense when resuming AND supplying the edited body.
|
|
1646
|
+
// Reject up front (before any source read) so a misuse can never silently fall back to running the
|
|
1647
|
+
// unedited persisted script or starting a brand-new run.
|
|
1648
|
+
if (args.editAndResume === true) {
|
|
1649
|
+
if (!resumeRunId) throw new Error("editAndResume requires resumeRunId (it resumes a prior run with an edited body)");
|
|
1650
|
+
if (!hasExplicitWorkflowSource(args)) throw new Error("editAndResume requires the edited body via source/scriptPath/name");
|
|
1651
|
+
}
|
|
1652
|
+
const resumeEntry = resumeRunId ? await readResumeRunEntry(toolContext, resumeRunId) : undefined;
|
|
1653
|
+
if (resumeEntry) await assertResumableState(resumeEntry, resumeRunId, args);
|
|
1654
|
+
const priorState = resumeEntry ? await readJsonFile(path.join(resumeEntry.dir, "state.json"), null) : null;
|
|
1655
|
+
// Trusted extension workflow dirs (read uniformly from pluginContext — the single source of truth
|
|
1656
|
+
// under double-instantiation) merge into source resolution + scriptPath admission + nested snapshots.
|
|
1657
|
+
const extWfDirs = pluginContext.workflowExtensionRegistry?.assetDirs()?.workflows ?? [];
|
|
1658
|
+
const { source, sourcePath } = await resolveWorkflowSourceForStart(toolContext, args, resumeEntry, extWfDirs);
|
|
1659
|
+
const sourceHash = hash(source);
|
|
1660
|
+
const { meta, body } = parseWorkflowSource(source);
|
|
1661
|
+
const sourceMetadata = sourcePreviewMetadata(source, sourcePath, args);
|
|
1662
|
+
assertWorkflowArgsMatchSchema(meta, args.args);
|
|
1663
|
+
// Drain workflows accept a runtime-args laneTimeoutMs alias; arg-shape validation is handled by
|
|
1664
|
+
// the canonical drain normalization (authorityArgsForWorkflow) for harness==="drain" workflows.
|
|
1665
|
+
const isDrainHarness = meta.harness === "drain";
|
|
1666
|
+
const nestedSnapshots = await buildNestedSnapshots(toolContext, source, extWfDirs);
|
|
1667
|
+
const adapter = await createCapabilityAdapter(pluginContext);
|
|
1668
|
+
const maxAgents = resumeEntry && Number.isInteger(priorState?.maxAgents)
|
|
1669
|
+
? priorState.maxAgents
|
|
1670
|
+
: Number.isInteger(args.maxAgents)
|
|
1671
|
+
? args.maxAgents
|
|
1672
|
+
: Number.isInteger(meta.maxAgents)
|
|
1673
|
+
? meta.maxAgents
|
|
1674
|
+
: DEFAULT_MAX_AGENTS;
|
|
1675
|
+
const sessionModel = await readActiveSessionModel(pluginContext, toolContext);
|
|
1676
|
+
// jbs3.4: pin the model envelope on resume exactly like maxAgents/authority/budget/concurrency.
|
|
1677
|
+
// A resumed run reuses the prior segment's resolved defaultChildModel/modelTiers so completed
|
|
1678
|
+
// lanes keep matching their cached signatures (event-journal.laneSignature keys on the resolved
|
|
1679
|
+
// model) even when the invoking session's model has since changed -- otherwise every completed
|
|
1680
|
+
// lane would silently invalidate and re-spend. A different childModel/modelTiers passed on resume
|
|
1681
|
+
// is rejected by assertResumeEnvelopeUnchanged below rather than being silently honored.
|
|
1682
|
+
const defaultChildModel = resumeEntry && typeof priorState?.defaultChildModel === "string"
|
|
1683
|
+
? priorState.defaultChildModel
|
|
1684
|
+
: modelKey(resolveRequestedModel(args.childModel || meta.childModel || meta.defaultChildModel || sessionModel.model, "default child"));
|
|
1685
|
+
// No hard-coded model fallback (AGENTS.md: "Model IDs from config, never literals"). The default
|
|
1686
|
+
// child model is resolved, in order, from an explicit childModel arg, the workflow meta, then the
|
|
1687
|
+
// invoking session's model. When none is readable there is no model to launch lanes with, so fail
|
|
1688
|
+
// explicitly and prompt the caller for one rather than silently selecting a provider/model.
|
|
1689
|
+
if (!defaultChildModel) {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
"No child model could be resolved: the active session exposes no model and no childModel was supplied. " +
|
|
1692
|
+
"Pass childModel (\"provider/model\") to workflow_run, set meta.childModel/meta.defaultChildModel in the workflow, " +
|
|
1693
|
+
"or run from a session that has a configured model.",
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
// A lane that declares tier: "fast"|"deep" resolves to modelTiers[tier]; both tiers always
|
|
1697
|
+
// populate (defaulting to defaultChildModel) so the value is deterministic and hash-stable,
|
|
1698
|
+
// and legacy lanes (no tier) stay on defaultChildModel via resolveLaneModel's fallback.
|
|
1699
|
+
const tierSource = (args.modelTiers && typeof args.modelTiers === "object" && !Array.isArray(args.modelTiers))
|
|
1700
|
+
? args.modelTiers
|
|
1701
|
+
: (meta.modelTiers && typeof meta.modelTiers === "object" && !Array.isArray(meta.modelTiers) ? meta.modelTiers : {});
|
|
1702
|
+
const modelTiers = resumeEntry && priorState?.modelTiers && typeof priorState.modelTiers === "object" && !Array.isArray(priorState.modelTiers)
|
|
1703
|
+
? { fast: priorState.modelTiers.fast ?? defaultChildModel, deep: priorState.modelTiers.deep ?? defaultChildModel }
|
|
1704
|
+
: {
|
|
1705
|
+
fast: modelKey(resolveRequestedModel(tierSource.fast || defaultChildModel, "fast tier")),
|
|
1706
|
+
deep: modelKey(resolveRequestedModel(tierSource.deep || defaultChildModel, "deep tier")),
|
|
1707
|
+
};
|
|
1708
|
+
// Pre-flight model validation against the live provider list (jbs3.5). Reuse
|
|
1709
|
+
// buildWorkflowModels' provider read and reject an unknown model here — before
|
|
1710
|
+
// the approval envelope is built, before capability probes, and before any lane
|
|
1711
|
+
// launches. Skip on resume: the resumed models are pinned by priorState and were
|
|
1712
|
+
// validated at original plan time, and re-checking against a possibly-changed
|
|
1713
|
+
// provider list could spuriously block a previously-approved run.
|
|
1714
|
+
if (!resumeEntry) {
|
|
1715
|
+
const { providers: availableProviders } = await buildWorkflowModels(pluginContext, toolContext);
|
|
1716
|
+
assertModelsAvailable(availableProviders, [
|
|
1717
|
+
{ label: "default child model", model: defaultChildModel },
|
|
1718
|
+
{ label: "fast tier", model: modelTiers.fast },
|
|
1719
|
+
{ label: "deep tier", model: modelTiers.deep },
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1722
|
+
// Canonicalize drain invocations (profile<->mode reconciliation) BEFORE authority/background/hash
|
|
1723
|
+
// so all of them — and the workflow body — see one consistent args object. No-op for non-drain.
|
|
1724
|
+
args = authorityArgsForWorkflow(meta, args);
|
|
1725
|
+
const authority = resumeEntry && priorState?.authority && typeof priorState.authority === "object" ? priorState.authority : resolveRunAuthority(meta, args);
|
|
1726
|
+
const argsPreview = jsonPreview(args.args ?? null);
|
|
1727
|
+
const requestedBudgetCeilings = {
|
|
1728
|
+
maxCost: Number.isFinite(args.maxCost) ? args.maxCost : Number.isFinite(meta.maxCost) ? meta.maxCost : undefined,
|
|
1729
|
+
maxTokens: Number.isInteger(args.maxTokens) ? args.maxTokens : Number.isInteger(meta.maxTokens) ? meta.maxTokens : undefined,
|
|
1730
|
+
};
|
|
1731
|
+
// jbs3.1: a budget-stopped resume may RAISE the ceiling (re-approved); any other resume pins
|
|
1732
|
+
// the prior ceiling exactly. A cold start uses the requested ceiling.
|
|
1733
|
+
const resumingBudgetStopped = resumeEntry && String(priorState?.status ?? "") === "budget_stopped";
|
|
1734
|
+
const priorBudgetCeilings = normalizeBudgetCeilings(priorState?.budgetCeilings);
|
|
1735
|
+
const budgetCeilings = !resumeEntry
|
|
1736
|
+
? requestedBudgetCeilings
|
|
1737
|
+
: resumingBudgetStopped
|
|
1738
|
+
? resolveResumeBudgetCeilings(args, priorBudgetCeilings)
|
|
1739
|
+
: priorBudgetCeilings;
|
|
1740
|
+
const hardConcurrencyLimit = hardConcurrencyLimitForContext(pluginContext);
|
|
1741
|
+
const concurrency = resumeEntry && Number.isInteger(priorState?.concurrency) ? priorState.concurrency : Math.max(1, Math.min(Number.isInteger(args.concurrency) ? args.concurrency : meta.concurrency || DEFAULT_CONCURRENCY, hardConcurrencyLimit));
|
|
1742
|
+
const workflowRunLaneTimeoutMs = laneTimeoutAliasValue(args, "workflow_run");
|
|
1743
|
+
const bundledRuntimeLaneTimeoutMs = isDrainHarness && args.args && typeof args.args === "object" && !Array.isArray(args.args) ? laneTimeoutAliasValue(args.args, "drain args") : undefined;
|
|
1744
|
+
if (workflowRunLaneTimeoutMs !== undefined && bundledRuntimeLaneTimeoutMs !== undefined && workflowRunLaneTimeoutMs !== bundledRuntimeLaneTimeoutMs) {
|
|
1745
|
+
throw new Error("workflow_run laneTimeoutMs and drain args laneTimeoutMs must match when both are provided");
|
|
1746
|
+
}
|
|
1747
|
+
const requestedLaneTimeoutMs = workflowRunLaneTimeoutMs ?? bundledRuntimeLaneTimeoutMs ?? laneTimeoutAliasValue(meta, "workflow meta");
|
|
1748
|
+
const laneTimeoutMs = resumeEntry && Number.isInteger(priorState?.laneTimeoutMs) ? priorState.laneTimeoutMs : requestedLaneTimeoutMs ?? DEFAULT_CHILD_PROMPT_TIMEOUT_MS;
|
|
1749
|
+
const guestDeadlineMs = resumeEntry && Number.isInteger(priorState?.guestDeadlineMs) ? priorState.guestDeadlineMs : Number.isInteger(args.guestDeadlineMs) ? args.guestDeadlineMs : Number.isInteger(meta.guestDeadlineMs) ? meta.guestDeadlineMs : DEFAULT_GUEST_DEADLINE_MS;
|
|
1750
|
+
// Optional run-level wall-clock deadline (jbs3.6). Distinct from the per-lane laneTimeoutMs
|
|
1751
|
+
// and the synchronous guest-burst guestDeadlineMs: when set, runWorkflowExecution hard-stops
|
|
1752
|
+
// the whole run (terminal status "timed-out" + partial result) after this many ms regardless
|
|
1753
|
+
// of where work is wedged. undefined => no run-level deadline.
|
|
1754
|
+
const resumingTimedOutWithExtendedDeadline = resumeEntry && priorState?.status === "timed-out" && args.resumePolicy === "extend-deadline";
|
|
1755
|
+
const maxRuntimeMs = resumeEntry
|
|
1756
|
+
? resumingTimedOutWithExtendedDeadline && Number.isInteger(args.maxRuntimeMs)
|
|
1757
|
+
? args.maxRuntimeMs
|
|
1758
|
+
: Number.isInteger(priorState?.maxRuntimeMs)
|
|
1759
|
+
? priorState.maxRuntimeMs
|
|
1760
|
+
: undefined
|
|
1761
|
+
: Number.isInteger(args.maxRuntimeMs)
|
|
1762
|
+
? args.maxRuntimeMs
|
|
1763
|
+
: Number.isInteger(meta.maxRuntimeMs)
|
|
1764
|
+
? meta.maxRuntimeMs
|
|
1765
|
+
: undefined;
|
|
1766
|
+
const baseCommit = authority.edit || authority.worktreeEdit || authority.integration ? await gitHead(toolContext.worktree || toolContext.directory) : undefined;
|
|
1767
|
+
const backgroundSizingSignals = {
|
|
1768
|
+
// Workflow meta.maxAgents is a ceiling, and many bundled review workflows intentionally
|
|
1769
|
+
// over-provision it. Treat only per-call maxAgents as an expected fan-out signal.
|
|
1770
|
+
maxAgentsSignal: Number.isInteger(args.maxAgents),
|
|
1771
|
+
maxRuntimeSignal: Number.isInteger(args.maxRuntimeMs) || Number.isInteger(meta.maxRuntimeMs),
|
|
1772
|
+
};
|
|
1773
|
+
const backgroundDecision = workflowBackgroundDecision(meta, sourcePath, args, priorState, { maxAgents, concurrency, maxRuntimeMs, ...backgroundSizingSignals });
|
|
1774
|
+
const background = backgroundDecision.enabled;
|
|
1775
|
+
const notificationDelivery = { promptAsyncAvailable: sessionApi(pluginContext).has("promptAsync") };
|
|
1776
|
+
const requestedDebugCapture = args.debugCapture === true || truthyEnvFlag(process.env[OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV]);
|
|
1777
|
+
const debugCapture = resumeEntry
|
|
1778
|
+
? priorState?.debugCapture?.enabled === true
|
|
1779
|
+
: requestedDebugCapture;
|
|
1780
|
+
const debugCaptureSource = debugCapture
|
|
1781
|
+
? (args.debugCapture === true ? "workflow_run" : truthyEnvFlag(process.env[OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV]) ? OPENCODE_WORKFLOWS_DEBUG_CAPTURE_ENV : "resume")
|
|
1782
|
+
: "off";
|
|
1783
|
+
assertResumeEnvelopeUnchanged(args, priorState, { defaultChildModel, authority, budgetCeilings, maxAgents }, { allowBudgetRaise: resumingBudgetStopped });
|
|
1784
|
+
const resumePreview = await computeResumeReplayPreview(resumeEntry, priorState, { sourceHash, runtimeArgs: args.args ?? null, defaultChildModel, modelTiers, editAndResume: args.editAndResume === true });
|
|
1785
|
+
const externalSource = sourcePath !== "<inline>" && !isTrustedWorkflowPath(sourcePath, toolContext, extWfDirs);
|
|
1786
|
+
const autoApprove = workflowAutoApprovePreview(pluginContext, args, authority);
|
|
1787
|
+
|
|
1788
|
+
return {
|
|
1789
|
+
resumeRunId,
|
|
1790
|
+
resumeEntry,
|
|
1791
|
+
priorState,
|
|
1792
|
+
source,
|
|
1793
|
+
body,
|
|
1794
|
+
adapter,
|
|
1795
|
+
meta,
|
|
1796
|
+
approval: {
|
|
1797
|
+
meta,
|
|
1798
|
+
sourcePath,
|
|
1799
|
+
sourceHash,
|
|
1800
|
+
sourceMetadata,
|
|
1801
|
+
externalSource,
|
|
1802
|
+
runtimeArgs: args.args ?? null,
|
|
1803
|
+
maxAgents,
|
|
1804
|
+
concurrency,
|
|
1805
|
+
laneTimeoutMs,
|
|
1806
|
+
defaultChildModel,
|
|
1807
|
+
modelTiers,
|
|
1808
|
+
authority,
|
|
1809
|
+
argsPreview,
|
|
1810
|
+
budgetCeilings,
|
|
1811
|
+
baseCommit,
|
|
1812
|
+
guestDeadlineMs,
|
|
1813
|
+
maxRuntimeMs,
|
|
1814
|
+
debugCapture,
|
|
1815
|
+
debugCaptureSource,
|
|
1816
|
+
...backgroundSizingSignals,
|
|
1817
|
+
background,
|
|
1818
|
+
backgroundDecision,
|
|
1819
|
+
notificationDelivery,
|
|
1820
|
+
resumeRunId: args.resumeRunId,
|
|
1821
|
+
resumePolicy: args.resumePolicy ?? null,
|
|
1822
|
+
resumePreview,
|
|
1823
|
+
capabilities: adapter.capabilities,
|
|
1824
|
+
nestedSnapshots,
|
|
1825
|
+
autoApprove,
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async function startWorkflow(pluginContext, toolContext, args) {
|
|
1831
|
+
assertWriteWorkflowAllowed(toolContext, "workflow_run");
|
|
1832
|
+
const { resumeRunId, resumeEntry, priorState, source, body, adapter, meta, approval } = await planWorkflowEnvelope(pluginContext, toolContext, args);
|
|
1833
|
+
const autoApproved = args.approve === true ? null : workflowAutoApproval(pluginContext, args, approval);
|
|
1834
|
+
if (args.approve !== true && !autoApproved) return approvalPreviewResponse(approval, args);
|
|
1835
|
+
if (args.approve === true && args.approvalHash !== approvalHash(approval)) return approvalMismatchResponse(approval, args);
|
|
1836
|
+
const {
|
|
1837
|
+
sourcePath,
|
|
1838
|
+
sourceHash,
|
|
1839
|
+
sourceMetadata,
|
|
1840
|
+
nestedSnapshots,
|
|
1841
|
+
argsPreview,
|
|
1842
|
+
maxAgents,
|
|
1843
|
+
concurrency,
|
|
1844
|
+
laneTimeoutMs,
|
|
1845
|
+
defaultChildModel,
|
|
1846
|
+
modelTiers,
|
|
1847
|
+
authority,
|
|
1848
|
+
budgetCeilings,
|
|
1849
|
+
baseCommit,
|
|
1850
|
+
guestDeadlineMs,
|
|
1851
|
+
maxRuntimeMs,
|
|
1852
|
+
background,
|
|
1853
|
+
backgroundDecision,
|
|
1854
|
+
notificationDelivery,
|
|
1855
|
+
debugCapture,
|
|
1856
|
+
debugCaptureSource,
|
|
1857
|
+
} = approval;
|
|
1858
|
+
// Verify shape-derived capabilities with live probes before the run starts (after the
|
|
1859
|
+
// approval check, so the approval envelope is hashed over pre-probe state and a preview
|
|
1860
|
+
// never probes). This settles run.capabilities before any lane executes.
|
|
1861
|
+
await promoteCapabilities(pluginContext, toolContext, adapter, authority, { childLanesAllowed: maxAgents > 0 });
|
|
1862
|
+
// A non-dry drain manages its own integration-worktree isolation internally (the drain runtime),
|
|
1863
|
+
// so it delegates those capability checks rather than requiring them pre-launch here.
|
|
1864
|
+
const drainDelegatesIntegrationGates = meta.harness === "drain" && args.args?.dryRun !== true;
|
|
1865
|
+
const requiredGateStatus = await verifyRequiredAuthorityGates(pluginContext, toolContext, adapter, authority);
|
|
1866
|
+
// Keep the historical network/MCP verifier hook for diagnostics compatibility.
|
|
1867
|
+
// webfetch/websearch/mcp authority is enforced by permission rules plus the
|
|
1868
|
+
// permissionEnforcement gate; networkAccess remains informational/reserved,
|
|
1869
|
+
// while mcpAccess can be probed explicitly through workflow_live_gates.
|
|
1870
|
+
await verifyNetworkMcpAuthorityGates(pluginContext, toolContext, authority);
|
|
1871
|
+
promoteVerifiedGateCapabilities(adapter, requiredGateStatus);
|
|
1872
|
+
if (authority.profile === AD_HOC_AUTHORITY_PROFILE) {
|
|
1873
|
+
const needsPermissions = authority.shell || authority.network || authority.mcp || authority.edit || authority.worktreeEdit || authority.integration || maxAgents > 0;
|
|
1874
|
+
const needsWorktreeIsolation = authority.edit || authority.worktreeEdit || authority.integration;
|
|
1875
|
+
function requireCapability(name, reason) {
|
|
1876
|
+
if (adapter.capabilities[name] !== "available") {
|
|
1877
|
+
throw new Error(`${reason}; ${name}=${adapter.capabilities[name]} (available-unverified is not behavioral proof)`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (needsPermissions) requireCapability("permissions", "Child-lane or elevated workflow authority requires verified permission enforcement");
|
|
1881
|
+
if (needsWorktreeIsolation && !drainDelegatesIntegrationGates) {
|
|
1882
|
+
requireCapability("worktree", "Edit/worktreeEdit/integration workflows require verified worktree API behavior");
|
|
1883
|
+
requireCapability("directoryRooting", "Edit/worktreeEdit/integration workflows require verified child directory rooting");
|
|
1884
|
+
requireCapability("worktreeEditIsolation", "Edit/worktreeEdit/integration workflows require verified worktree edit isolation");
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const root = resumeEntry?.root ?? await ensureRunRoot(toolContext);
|
|
1889
|
+
const runId = resumeRunId ?? crypto.randomUUID();
|
|
1890
|
+
const dir = runDirForRoot(root, runId);
|
|
1891
|
+
if (runs.has(runId)) throw new Error(`Workflow run is already active: ${runId}`);
|
|
1892
|
+
await ensurePrivateDir(dir);
|
|
1893
|
+
let releaseRunLock = await acquireWorkflowLock(lockPathForRun(dir, "run"), { operation: "run", runId });
|
|
1894
|
+
let run;
|
|
1895
|
+
try {
|
|
1896
|
+
if (resumeRunId) {
|
|
1897
|
+
await fs.rm(lifecycleRequestPath(dir, "pause"), { force: true });
|
|
1898
|
+
// A force-killed run is resumable; its durable kill-request.json must be cleared on resume
|
|
1899
|
+
// or checkDurableLifecycleRequest would immediately re-abandon the freshly resumed run.
|
|
1900
|
+
await fs.rm(lifecycleRequestPath(dir, "kill"), { force: true });
|
|
1901
|
+
}
|
|
1902
|
+
await writeFilePrivate(path.join(dir, "script.js"), source, "utf8");
|
|
1903
|
+
const resumeJournal = args.resumeRunId ? await loadJournal(dir) : new Map();
|
|
1904
|
+
if (args.resumeRunId) await compactJournal(dir, resumeJournal);
|
|
1905
|
+
const resumeSignatureIndex = buildResumeSignatureIndex(resumeJournal);
|
|
1906
|
+
|
|
1907
|
+
run = {
|
|
1908
|
+
id: runId,
|
|
1909
|
+
dir,
|
|
1910
|
+
projectDirectory: toolContext.directory,
|
|
1911
|
+
projectWorktree: toolContext.worktree,
|
|
1912
|
+
sourcePath,
|
|
1913
|
+
sourceHash,
|
|
1914
|
+
sourceMetadata,
|
|
1915
|
+
meta,
|
|
1916
|
+
authority,
|
|
1917
|
+
runtimeArgs: args.args ?? null,
|
|
1918
|
+
nestedSnapshots,
|
|
1919
|
+
argsPreview,
|
|
1920
|
+
status: "running",
|
|
1921
|
+
startedAt: new Date().toISOString(),
|
|
1922
|
+
resumedAt: undefined,
|
|
1923
|
+
finishedAt: undefined,
|
|
1924
|
+
currentPhase: undefined,
|
|
1925
|
+
agentsStarted: 0,
|
|
1926
|
+
maxAgents,
|
|
1927
|
+
concurrency,
|
|
1928
|
+
laneTimeoutMs,
|
|
1929
|
+
defaultChildModel,
|
|
1930
|
+
modelTiers,
|
|
1931
|
+
activeAgents: 0,
|
|
1932
|
+
waitingAgents: [],
|
|
1933
|
+
tokens: { input: 0, output: 0, reasoning: 0 },
|
|
1934
|
+
replayedTokens: { input: 0, output: 0, reasoning: 0 },
|
|
1935
|
+
cost: 0,
|
|
1936
|
+
replayedCost: 0,
|
|
1937
|
+
cacheStats: { hits: 0, misses: 0, invalidated: 0 },
|
|
1938
|
+
budgetCeilings,
|
|
1939
|
+
laneOutcomes: Object.fromEntries(LANE_OUTCOMES.map((outcome) => [outcome, 0])),
|
|
1940
|
+
droppedLaneCount: 0,
|
|
1941
|
+
error: undefined,
|
|
1942
|
+
resultPath: undefined,
|
|
1943
|
+
closeout: undefined,
|
|
1944
|
+
lifecycleRequests: undefined,
|
|
1945
|
+
notification: undefined,
|
|
1946
|
+
notificationTarget: background ? {
|
|
1947
|
+
sessionID: toolContext.sessionID,
|
|
1948
|
+
messageID: toolContext.messageID,
|
|
1949
|
+
directory: toolContext.directory,
|
|
1950
|
+
agent: toolContext.agent,
|
|
1951
|
+
} : undefined,
|
|
1952
|
+
recovery: undefined,
|
|
1953
|
+
capabilities: adapter.capabilities,
|
|
1954
|
+
diagnostics: adapter.diagnostics,
|
|
1955
|
+
adapter,
|
|
1956
|
+
editWorktrees: [],
|
|
1957
|
+
editPlan: authority.edit || authority.worktreeEdit ? { sourceHash, baseCommit, patches: [], worktrees: [] } : undefined,
|
|
1958
|
+
integrationPlan: authority.integration ? { sourceHash, baseCommit, lanes: [], worktrees: [], integrationResult: undefined } : undefined,
|
|
1959
|
+
integrationWorktrees: [],
|
|
1960
|
+
worktreeAdapter: undefined,
|
|
1961
|
+
laneRecords: new Map(),
|
|
1962
|
+
children: new Map(),
|
|
1963
|
+
activeLaneAbortControllers: new Map(),
|
|
1964
|
+
cancelledFanoutScopes: new Set(),
|
|
1965
|
+
resumeJournal,
|
|
1966
|
+
resumeSignatureIndex,
|
|
1967
|
+
resumeSignatureClaims: new Set(),
|
|
1968
|
+
abortController: new AbortController(),
|
|
1969
|
+
pauseRequested: false,
|
|
1970
|
+
background,
|
|
1971
|
+
backgroundDecision,
|
|
1972
|
+
notificationDelivery,
|
|
1973
|
+
debugCapture: { enabled: debugCapture === true, source: debugCaptureSource },
|
|
1974
|
+
autoApproved,
|
|
1975
|
+
recentLogs: [],
|
|
1976
|
+
ignoreToolAbort: background,
|
|
1977
|
+
hostCalls: 0,
|
|
1978
|
+
eventCount: 0,
|
|
1979
|
+
journalRecords: 0,
|
|
1980
|
+
nestingDepth: 0,
|
|
1981
|
+
guestDeadlineMs,
|
|
1982
|
+
maxRuntimeMs,
|
|
1983
|
+
deadlineExceeded: false,
|
|
1984
|
+
killed: false,
|
|
1985
|
+
releaseRunLock,
|
|
1986
|
+
};
|
|
1987
|
+
if (run.notificationTarget?.sessionID) idleNotificationSessions.delete(run.notificationTarget.sessionID);
|
|
1988
|
+
if (args.resumeRunId) {
|
|
1989
|
+
// Rehydrate run-level state persisted by the prior segment so a resumed run does not
|
|
1990
|
+
// reset observability/limit counters, worktree ledgers, or the original start time.
|
|
1991
|
+
rehydrateRunFromPriorState(run, priorState);
|
|
1992
|
+
// jbs3.1: rehydrate restores the prior (possibly breached) ceiling from state.json; re-apply
|
|
1993
|
+
// the approved envelope ceiling so a budget-stopped resume's RAISED ceiling takes effect.
|
|
1994
|
+
// For every non-raise resume this is the same value rehydrate just set (a safe no-op).
|
|
1995
|
+
run.budgetCeilings = budgetCeilings;
|
|
1996
|
+
run.resumedAt = new Date().toISOString();
|
|
1997
|
+
// Seed the in-memory caps from the true on-disk totals so the MAX_* limits enforce
|
|
1998
|
+
// against the whole append-only file, not just this session's appends.
|
|
1999
|
+
run.journalRecords = await countNonEmptyLines(path.join(dir, "journal.jsonl"));
|
|
2000
|
+
run.eventCount = await countNonEmptyLines(path.join(dir, "events.jsonl"));
|
|
2001
|
+
}
|
|
2002
|
+
runs.set(run.id, run);
|
|
2003
|
+
run.eventSink = createWorkflowToastEventSink(pluginContext, run);
|
|
2004
|
+
if (run.autoApproved) {
|
|
2005
|
+
await appendEvent(run, { type: "run.auto_approved", tier: run.autoApproved.tier, ceiling: run.autoApproved.ceiling });
|
|
2006
|
+
}
|
|
2007
|
+
await appendEvent(run, { type: "run.started", capabilities: run.capabilities, diagnostics: run.diagnostics });
|
|
2008
|
+
await writeState(run);
|
|
2009
|
+
|
|
2010
|
+
if (run.background) {
|
|
2011
|
+
run.done = runWorkflowExecution(pluginContext, toolContext, run, body, args.args).catch((_error) => {
|
|
2012
|
+
// Background run error after state-write attempt: best effort.
|
|
2013
|
+
});
|
|
2014
|
+
void run.done;
|
|
2015
|
+
const notificationWarning = backgroundNotificationWarning(run, run.id);
|
|
2016
|
+
return [
|
|
2017
|
+
`Workflow ${run.id} started in background.`,
|
|
2018
|
+
`Status: workflow_status({ runId: "${run.id}" })`,
|
|
2019
|
+
...(notificationWarning ? [notificationWarning] : []),
|
|
2020
|
+
"Background runs continue only while the current OpenCode process stays alive.",
|
|
2021
|
+
].join("\n");
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
return await runWorkflowExecution(pluginContext, toolContext, run, body, args.args);
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
if (!run || run.status === "running") {
|
|
2027
|
+
// A throw after runs.set (e.g. appendEvent/writeState failing) leaves a phantom
|
|
2028
|
+
// 'running' entry that would block resume-id retry for the process lifetime. Drop it,
|
|
2029
|
+
// but only if this invocation still owns the slot (guard against a concurrent owner).
|
|
2030
|
+
if (run && runs.get(run.id) === run) runs.delete(run.id);
|
|
2031
|
+
await releaseRunLock?.();
|
|
2032
|
+
}
|
|
2033
|
+
throw error;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
async function validatePatchAncestors(root, patchPath) {
|
|
2038
|
+
const rootReal = await fs.realpath(root);
|
|
2039
|
+
const parts = patchPath.split(/[\\/]+/).filter(Boolean);
|
|
2040
|
+
let current = root;
|
|
2041
|
+
for (const part of parts.slice(0, -1)) {
|
|
2042
|
+
current = path.join(current, part);
|
|
2043
|
+
let stat;
|
|
2044
|
+
try {
|
|
2045
|
+
stat = await fs.lstat(current);
|
|
2046
|
+
} catch (error) {
|
|
2047
|
+
if (error.code === "ENOENT") return;
|
|
2048
|
+
throw error;
|
|
2049
|
+
}
|
|
2050
|
+
if (stat.isSymbolicLink()) throw new Error(`Patch ancestor is a symlink: ${patchPath}`);
|
|
2051
|
+
if (!stat.isDirectory()) throw new Error(`Patch ancestor is not a directory: ${patchPath}`);
|
|
2052
|
+
const real = await fs.realpath(current);
|
|
2053
|
+
if (!isPathInside(rootReal, real)) throw new Error(`Patch ancestor escapes primary root: ${patchPath}`);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
async function validatePatchTargets(root, patches, options = {}) {
|
|
2058
|
+
const requireTracked = options.requireTracked !== false;
|
|
2059
|
+
const seen = new Set();
|
|
2060
|
+
const planned = [];
|
|
2061
|
+
for (const patch of patches) {
|
|
2062
|
+
if (!patch || typeof patch !== "object") throw new Error("Invalid edit patch entry");
|
|
2063
|
+
if (typeof patch.path !== "string" || typeof patch.content !== "string") throw new Error("Edit patches must include string path and content");
|
|
2064
|
+
if (seen.has(patch.path)) {
|
|
2065
|
+
const overlappingError = new Error(`Overlapping edit patch path: ${patch.path}`);
|
|
2066
|
+
overlappingError.code = "OVERLAPPING_PATCH";
|
|
2067
|
+
throw overlappingError;
|
|
2068
|
+
}
|
|
2069
|
+
seen.add(patch.path);
|
|
2070
|
+
if (path.isAbsolute(patch.path) || patch.path.split(/[\\/]+/).includes("..")) throw new Error(`Patch escapes primary root: ${patch.path}`);
|
|
2071
|
+
assertWritableWorkflowPath(patch.path);
|
|
2072
|
+
const target = path.resolve(root, patch.path);
|
|
2073
|
+
if (!target.startsWith(`${root}${path.sep}`) && target !== root) throw new Error(`Patch escapes primary root: ${patch.path}`);
|
|
2074
|
+
await validatePatchAncestors(root, patch.path);
|
|
2075
|
+
let existed = false;
|
|
2076
|
+
let previousContent;
|
|
2077
|
+
try {
|
|
2078
|
+
const stat = await fs.lstat(target);
|
|
2079
|
+
if (stat.isSymbolicLink()) throw new Error(`Patch target is a symlink: ${patch.path}`);
|
|
2080
|
+
if (stat.isDirectory()) throw new Error(`Patch target is a directory: ${patch.path}`);
|
|
2081
|
+
previousContent = await fs.readFile(target, "utf8");
|
|
2082
|
+
existed = true;
|
|
2083
|
+
if (requireTracked) {
|
|
2084
|
+
const tracked = await gitPathTracked(root, patch.path);
|
|
2085
|
+
if (!tracked) throw new Error(`Patch target exists but is untracked: ${patch.path}`);
|
|
2086
|
+
}
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
if (error.code !== "ENOENT") throw error;
|
|
2089
|
+
}
|
|
2090
|
+
planned.push({ patch, target, existed, previousContent });
|
|
2091
|
+
}
|
|
2092
|
+
return planned;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function verifyAppliedPatchContents(planned) {
|
|
2096
|
+
for (const item of planned) {
|
|
2097
|
+
if (!item.existed || item.previousContent !== item.patch.content) {
|
|
2098
|
+
throw new Error(`Applied patch content no longer matches approved diff plan: ${item.patch.path}`);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function rollbackPatches(planned, root) {
|
|
2104
|
+
const failures = [];
|
|
2105
|
+
for (const item of [...planned].reverse()) {
|
|
2106
|
+
try {
|
|
2107
|
+
if (item.existed) {
|
|
2108
|
+
// Restore prior content TOCTOU-safely (R18): when a root is supplied, the
|
|
2109
|
+
// O_NOFOLLOW write refuses to follow a symlink swapped in for the target so
|
|
2110
|
+
// rollback cannot be redirected outside root either.
|
|
2111
|
+
if (root) await safeWriteFileWithinRoot(root, item.patch.path, item.previousContent);
|
|
2112
|
+
else await fs.writeFile(item.target, item.previousContent, "utf8");
|
|
2113
|
+
} else {
|
|
2114
|
+
await fs.rm(item.target, { force: true });
|
|
2115
|
+
}
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
failures.push({
|
|
2118
|
+
path: item.patch?.path,
|
|
2119
|
+
error: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
return failures;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Read a raw, unredacted child transcript via the SDK session.messages API. Salvage must
|
|
2127
|
+
// read transcripts directly (not through the redacting session_read wrapper some setups may
|
|
2128
|
+
// layer on top of session history, e.g. a separate opencode-sessions plugin — not a dependency
|
|
2129
|
+
// of this package, named only as a point of comparison):
|
|
2130
|
+
// the journal already stores unredacted lane results, and salvage recovers that same class of
|
|
2131
|
+
// content. Returns { readable, messages, reason }.
|
|
2132
|
+
async function readRawTranscript(pluginContext, childID) {
|
|
2133
|
+
const session = sessionApi(pluginContext);
|
|
2134
|
+
if (!session.has("messages")) return { readable: false, messages: null, reason: "session.messages unavailable" };
|
|
2135
|
+
try {
|
|
2136
|
+
const result = await session.messages({ sessionID: childID });
|
|
2137
|
+
const unwrapped = unwrapClientResult(result, `Salvage transcript read for ${childID}`);
|
|
2138
|
+
return { readable: true, messages: unwrapped, reason: undefined };
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
return { readable: false, messages: null, reason: extractTextFromError(error) };
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function extractMessagesArray(value) {
|
|
2145
|
+
if (!value) return [];
|
|
2146
|
+
if (Array.isArray(value)) return value;
|
|
2147
|
+
if (Array.isArray(value.data)) return value.data;
|
|
2148
|
+
if (value.data && Array.isArray(value.data.messages)) return value.data.messages;
|
|
2149
|
+
if (value.data && Array.isArray(value.data.data)) return value.data.data;
|
|
2150
|
+
if (Array.isArray(value.messages)) return value.messages;
|
|
2151
|
+
return [];
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function isAssistantMessage(message) {
|
|
2155
|
+
if (!message || typeof message !== "object") return false;
|
|
2156
|
+
const role = String(message.role ?? message.type ?? "").toLowerCase();
|
|
2157
|
+
return role === "assistant";
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// Find the final assistant message in a transcript and return its concatenated text. Structured
|
|
2161
|
+
// (schema) lanes normally surface their JSON as the model's final text reply, so this is the
|
|
2162
|
+
// best-effort recovery target for salvage.
|
|
2163
|
+
function extractFinalAssistantText(messagesValue) {
|
|
2164
|
+
const messages = extractMessagesArray(messagesValue);
|
|
2165
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
2166
|
+
const message = messages[i];
|
|
2167
|
+
if (isAssistantMessage(message)) {
|
|
2168
|
+
const parts = [];
|
|
2169
|
+
collectTextParts(message, parts);
|
|
2170
|
+
const text = parts.join("\n").trim();
|
|
2171
|
+
if (text) return { found: true, text };
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return { found: false, text: "" };
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function tryParseJson(text) {
|
|
2178
|
+
try {
|
|
2179
|
+
return { ok: true, value: JSON.parse(text) };
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
return { ok: false, error: extractTextFromError(error) };
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// The approval hash covers the recomputed preview facts (per-candidate callId/childID, skip
|
|
2186
|
+
// reason, final-message presence/length, final-message hash, parse verdict, signature
|
|
2187
|
+
// availability). Re-reading transcripts and re-verifying this hash on approve guarantees the
|
|
2188
|
+
// transcript state has not drifted between preview and approve.
|
|
2189
|
+
function normalizeSalvageSchemaSnapshot(requestCheckpoint) {
|
|
2190
|
+
const snapshot = requestCheckpoint?.schemaSnapshot;
|
|
2191
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
2192
|
+
return {
|
|
2193
|
+
status: requestCheckpoint?.schemaHash ? "unavailable" : "absent",
|
|
2194
|
+
hash: requestCheckpoint?.schemaHash,
|
|
2195
|
+
schema: undefined,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
const status = ["present", "absent", "oversized"].includes(snapshot.status) ? snapshot.status : "unavailable";
|
|
2199
|
+
return {
|
|
2200
|
+
status,
|
|
2201
|
+
hash: typeof snapshot.hash === "string" ? snapshot.hash : requestCheckpoint?.schemaHash,
|
|
2202
|
+
bytes: Number.isInteger(snapshot.bytes) ? snapshot.bytes : undefined,
|
|
2203
|
+
schema: status === "present" && snapshot.schema && typeof snapshot.schema === "object" ? snapshot.schema : undefined,
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function salvageValidationForParsedValue(parsed, schemaSnapshot) {
|
|
2208
|
+
const base = {
|
|
2209
|
+
kind: schemaSnapshot.schema ? "json-schema" : "json-parse",
|
|
2210
|
+
originalSchemaAvailable: Boolean(schemaSnapshot.schema),
|
|
2211
|
+
schemaSnapshotStatus: schemaSnapshot.status,
|
|
2212
|
+
schemaHash: schemaSnapshot.hash,
|
|
2213
|
+
};
|
|
2214
|
+
if (!parsed.ok) {
|
|
2215
|
+
return { ...base, schemaVerdict: "not-checked", recoveredResultValid: false, validationError: parsed.error };
|
|
2216
|
+
}
|
|
2217
|
+
if (!schemaSnapshot.schema) {
|
|
2218
|
+
return { ...base, schemaVerdict: "not-checked", recoveredResultValid: true };
|
|
2219
|
+
}
|
|
2220
|
+
try {
|
|
2221
|
+
validateStructuredResult(schemaSnapshot.schema, parsed.value);
|
|
2222
|
+
return { ...base, schemaVerdict: "valid", recoveredResultValid: true };
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
return {
|
|
2225
|
+
...base,
|
|
2226
|
+
schemaVerdict: "invalid",
|
|
2227
|
+
recoveredResultValid: false,
|
|
2228
|
+
validationError: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
function salvageApprovalPayload(enriched) {
|
|
2234
|
+
return enriched.map((entry) => ({
|
|
2235
|
+
callId: entry.callId,
|
|
2236
|
+
childID: entry.childID,
|
|
2237
|
+
skipped: entry.skipped ?? null,
|
|
2238
|
+
finalMessageFound: entry.finalMessageFound,
|
|
2239
|
+
finalMessageLength: entry.finalMessageLength,
|
|
2240
|
+
parseVerdict: entry.parseVerdict,
|
|
2241
|
+
finalMessageHash: entry.finalMessageHash,
|
|
2242
|
+
resumeSignatureAvailable: entry.resumeSignatureAvailable,
|
|
2243
|
+
validationKind: entry.salvageValidation?.kind,
|
|
2244
|
+
originalSchemaAvailable: entry.salvageValidation?.originalSchemaAvailable,
|
|
2245
|
+
schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
|
|
2246
|
+
schemaHash: entry.salvageValidation?.schemaHash,
|
|
2247
|
+
schemaVerdict: entry.salvageValidation?.schemaVerdict,
|
|
2248
|
+
validationErrorHash: entry.salvageValidation?.validationError ? hash(entry.salvageValidation.validationError) : undefined,
|
|
2249
|
+
}));
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function computeSalvageApprovalHash(runId, payload) {
|
|
2253
|
+
return hashStable({ scope: "workflow_salvage.v1", runId, candidates: payload });
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Build the enriched per-candidate preview by reading each orphan's lane projection + raw
|
|
2257
|
+
// transcript. Edit/integration lanes are reported but never salvaged (no worktree commit
|
|
2258
|
+
// exists; integrate() requires lane.committed). Read-only lanes are JSON-structurally
|
|
2259
|
+
// validated against their final assistant message.
|
|
2260
|
+
async function computeSalvagePreview(pluginContext, runDir, state, runId) {
|
|
2261
|
+
const candidates = await computeSalvageCandidates(runDir, state);
|
|
2262
|
+
const lanes = await readLaneProjections(runDir, state);
|
|
2263
|
+
const laneByCallId = new Map(lanes.map((lane) => [lane.callId, lane]).filter(([, lane]) => lane));
|
|
2264
|
+
const enriched = [];
|
|
2265
|
+
for (const candidate of candidates) {
|
|
2266
|
+
const lane = laneByCallId.get(candidate.callId) ?? {};
|
|
2267
|
+
const resumeSignatureAvailable = Boolean(lane.signatureHash);
|
|
2268
|
+
const base = {
|
|
2269
|
+
callId: candidate.callId,
|
|
2270
|
+
childID: candidate.childID,
|
|
2271
|
+
title: lane.title,
|
|
2272
|
+
taskSummary: lane.taskSummary,
|
|
2273
|
+
model: lane.model,
|
|
2274
|
+
agent: lane.agent,
|
|
2275
|
+
role: lane.role,
|
|
2276
|
+
timeoutMs: lane.timeoutMs,
|
|
2277
|
+
permissionPolicy: lane.permissionPolicy,
|
|
2278
|
+
signatureHash: lane.signatureHash,
|
|
2279
|
+
startedAt: lane.startedAt,
|
|
2280
|
+
};
|
|
2281
|
+
// Edit/integration lanes carry a worktree (and integration lanes carry an integrationLane
|
|
2282
|
+
// record). Salvage is read-only-only: there is no worktree commit to recover and integrate()
|
|
2283
|
+
// already requires lane.committed, so report-and-skip rather than ever salvaging.
|
|
2284
|
+
if (lane.worktreePath || lane.integrationLane) {
|
|
2285
|
+
enriched.push({
|
|
2286
|
+
...base,
|
|
2287
|
+
skipped: "edit-lane-without-commit",
|
|
2288
|
+
finalMessageFound: false,
|
|
2289
|
+
finalMessageLength: 0,
|
|
2290
|
+
parseVerdict: "skipped",
|
|
2291
|
+
finalMessageHash: null,
|
|
2292
|
+
resumeSignatureAvailable,
|
|
2293
|
+
});
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
const transcript = await readRawTranscript(pluginContext, candidate.childID);
|
|
2297
|
+
if (!transcript.readable) {
|
|
2298
|
+
enriched.push({
|
|
2299
|
+
...base,
|
|
2300
|
+
skipped: `transcript-unreadable:${truncateText(transcript.reason, 120)}`,
|
|
2301
|
+
finalMessageFound: false,
|
|
2302
|
+
finalMessageLength: 0,
|
|
2303
|
+
parseVerdict: "unreadable",
|
|
2304
|
+
finalMessageHash: null,
|
|
2305
|
+
resumeSignatureAvailable,
|
|
2306
|
+
});
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const final = extractFinalAssistantText(transcript.messages);
|
|
2310
|
+
const parsed = tryParseJson(final.text);
|
|
2311
|
+
const requestCheckpoint = await readLaneRequestCheckpoint(runDir, candidate.callId);
|
|
2312
|
+
const schemaSnapshot = normalizeSalvageSchemaSnapshot(requestCheckpoint);
|
|
2313
|
+
const salvageValidation = salvageValidationForParsedValue(parsed, schemaSnapshot);
|
|
2314
|
+
enriched.push({
|
|
2315
|
+
...base,
|
|
2316
|
+
skipped: undefined,
|
|
2317
|
+
finalMessageFound: final.found,
|
|
2318
|
+
finalMessageLength: final.text.length,
|
|
2319
|
+
finalText: final.text,
|
|
2320
|
+
parsedValue: parsed.ok ? parsed.value : undefined,
|
|
2321
|
+
parseVerdict: parsed.ok ? "valid" : "invalid",
|
|
2322
|
+
parseError: parsed.ok ? undefined : parsed.error,
|
|
2323
|
+
salvageValidation,
|
|
2324
|
+
finalMessageHash: hash(final.text),
|
|
2325
|
+
resumeSignatureAvailable,
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
return enriched;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Explicit, hash-gated recovery of orphaned read-only lane results from persisted child
|
|
2332
|
+
// transcripts. Gated like workflow_reconcile (assertWriteWorkflowAllowed). Preview lists
|
|
2333
|
+
// recoverable lanes without writing; approve (matching hash) writes synthetic journal entries
|
|
2334
|
+
// tagged salvagedFromTranscript: true. Salvage never calls integrate()/runAutoApply and never
|
|
2335
|
+
// touches state.json, worktrees, or integration ledgers.
|
|
2336
|
+
async function salvageRun(pluginContext, context, args) {
|
|
2337
|
+
assertWriteWorkflowAllowed(context, "workflow_salvage");
|
|
2338
|
+
if (typeof args.runId !== "string" || !args.runId.trim()) throw new Error("workflow_salvage requires runId");
|
|
2339
|
+
const runId = args.runId.trim();
|
|
2340
|
+
const entry = await readRunById(context, runId);
|
|
2341
|
+
if (entry.kind !== "valid") throw new Error(`Cannot salvage invalid run ${runId}: ${entry.status}`);
|
|
2342
|
+
const runDir = entry.dir;
|
|
2343
|
+
const state = entry.state;
|
|
2344
|
+
const runLock = await readLock(lockPathForRun(runDir, "run"));
|
|
2345
|
+
if (runLock?.active) {
|
|
2346
|
+
throw new Error(
|
|
2347
|
+
`Workflow ${runId} still holds an active run lock (pid ${runLock.process?.pid}); ` +
|
|
2348
|
+
"wait for the owning run to settle (or run workflow_reconcile) before retrying workflow_salvage."
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
const enriched = await computeSalvagePreview(pluginContext, runDir, state, runId);
|
|
2353
|
+
const computedHash = computeSalvageApprovalHash(runId, salvageApprovalPayload(enriched));
|
|
2354
|
+
|
|
2355
|
+
const selectedCallIds = Array.isArray(args.callIds) && args.callIds.length > 0
|
|
2356
|
+
? new Set(args.callIds.filter((callId) => typeof callId === "string"))
|
|
2357
|
+
: null;
|
|
2358
|
+
|
|
2359
|
+
if (args.approve !== true || args.approvalHash !== computedHash) {
|
|
2360
|
+
const candidates = enriched.map((entry) => {
|
|
2361
|
+
const view = {
|
|
2362
|
+
callId: entry.callId,
|
|
2363
|
+
childID: entry.childID,
|
|
2364
|
+
parseVerdict: entry.parseVerdict,
|
|
2365
|
+
validationKind: entry.salvageValidation?.kind ?? "not-checked",
|
|
2366
|
+
originalSchemaAvailable: entry.salvageValidation?.originalSchemaAvailable === true,
|
|
2367
|
+
schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
|
|
2368
|
+
schemaHash: entry.salvageValidation?.schemaHash,
|
|
2369
|
+
schemaVerdict: entry.salvageValidation?.schemaVerdict,
|
|
2370
|
+
finalMessageFound: entry.finalMessageFound,
|
|
2371
|
+
finalMessageLength: entry.finalMessageLength,
|
|
2372
|
+
resumeSignatureAvailable: entry.resumeSignatureAvailable,
|
|
2373
|
+
};
|
|
2374
|
+
if (entry.skipped) view.skipped = entry.skipped;
|
|
2375
|
+
if (entry.salvageValidation?.validationError) view.validationError = truncateText(entry.salvageValidation.validationError, MAX_STATUS_STRING_CHARS);
|
|
2376
|
+
if (entry.finalMessageFound) view.redactedSnippet = truncateText(redactFreeTextSecrets(entry.finalText ?? ""), 200);
|
|
2377
|
+
return view;
|
|
2378
|
+
});
|
|
2379
|
+
return JSON.stringify({
|
|
2380
|
+
mode: "preview",
|
|
2381
|
+
runId,
|
|
2382
|
+
approvalHash: computedHash,
|
|
2383
|
+
candidates,
|
|
2384
|
+
note: "Preview only; nothing written. Re-run with approve: true and this approvalHash (optionally callIds to narrow) to write tagged synthetic journal entries for schema-validated or JSON-parse-validated read-only lanes. Salvage never calls integrate/runAutoApply.",
|
|
2385
|
+
}, null, 2);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// Approve: the recomputed preview hash already matched args.approvalHash, so the transcript
|
|
2389
|
+
// state is exactly what the operator approved. Write synthetic entries only for non-skipped
|
|
2390
|
+
// read-only lanes; outcome is success only when the final message parsed as JSON.
|
|
2391
|
+
const salvaged = [];
|
|
2392
|
+
const skipped = [];
|
|
2393
|
+
for (const entry of enriched) {
|
|
2394
|
+
if (selectedCallIds && !selectedCallIds.has(entry.callId)) continue;
|
|
2395
|
+
if (entry.skipped) {
|
|
2396
|
+
skipped.push({ callId: entry.callId, childID: entry.childID, reason: entry.skipped });
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
const outcome = entry.salvageValidation?.recoveredResultValid === true ? "success" : "failure";
|
|
2400
|
+
await writeSalvagedLaneOutcome(runDir, entry.callId, {
|
|
2401
|
+
runId,
|
|
2402
|
+
signatureHash: entry.signatureHash,
|
|
2403
|
+
outcome,
|
|
2404
|
+
childID: entry.childID,
|
|
2405
|
+
title: entry.title,
|
|
2406
|
+
taskSummary: entry.taskSummary,
|
|
2407
|
+
model: entry.model,
|
|
2408
|
+
agent: entry.agent,
|
|
2409
|
+
role: entry.role,
|
|
2410
|
+
timeoutMs: entry.timeoutMs,
|
|
2411
|
+
permissionPolicy: entry.permissionPolicy,
|
|
2412
|
+
startedAt: entry.startedAt,
|
|
2413
|
+
result: outcome === "success" ? entry.parsedValue : undefined,
|
|
2414
|
+
errorSummary: outcome === "success"
|
|
2415
|
+
? undefined
|
|
2416
|
+
: entry.salvageValidation?.kind === "json-schema"
|
|
2417
|
+
? `Salvaged transcript final message did not match stored JSON schema: ${truncateText(entry.salvageValidation?.validationError ?? "", MAX_STATUS_STRING_CHARS)}`
|
|
2418
|
+
: `Salvaged transcript final message did not parse as JSON: ${truncateText(entry.parseError ?? "", MAX_STATUS_STRING_CHARS)}`,
|
|
2419
|
+
salvageValidation: entry.salvageValidation,
|
|
2420
|
+
});
|
|
2421
|
+
salvaged.push({
|
|
2422
|
+
callId: entry.callId,
|
|
2423
|
+
childID: entry.childID,
|
|
2424
|
+
outcome,
|
|
2425
|
+
parseVerdict: entry.parseVerdict,
|
|
2426
|
+
validationKind: entry.salvageValidation?.kind,
|
|
2427
|
+
schemaSnapshotStatus: entry.salvageValidation?.schemaSnapshotStatus,
|
|
2428
|
+
schemaVerdict: entry.salvageValidation?.schemaVerdict,
|
|
2429
|
+
resumeSignatureAvailable: entry.resumeSignatureAvailable,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
return JSON.stringify({ mode: "approve", runId, approvalHash: computedHash, salvaged, skipped }, null, 2);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
async function applyWorkflow(pluginContext, context, args) {
|
|
2436
|
+
assertWriteWorkflowAllowed(context, "workflow_apply");
|
|
2437
|
+
if (args.approvalIntent !== "apply") throw new Error('workflow_apply requires approvalIntent: "apply"');
|
|
2438
|
+
const entry = await readRunById(context, args.runId);
|
|
2439
|
+
if (entry.kind !== "valid") throw new Error(`Cannot apply invalid run ${args.runId}: ${entry.status}`);
|
|
2440
|
+
const releaseApplyLock = await acquireWorkflowLock(lockPathForRun(entry.dir, "apply"), { operation: "apply", runId: args.runId });
|
|
2441
|
+
try {
|
|
2442
|
+
// Fail closed while the owning run still holds an ACTIVE run.lock. apply.lock serializes two
|
|
2443
|
+
// concurrent applies, but it does NOT serialize apply-vs-active-run: a backgrounded run writes
|
|
2444
|
+
// awaiting-diff-approval/apply-failed to state.json and only releases run.lock afterward in its
|
|
2445
|
+
// finally block (runWorkflowExecution line ~1085). Applying in that window races the owner's own
|
|
2446
|
+
// writeState() in its finally, which would clobber apply-running/applied back to the pre-apply
|
|
2447
|
+
// status (a lost update on state.json). The on-disk status alone is not proof of settlement.
|
|
2448
|
+
// Stale/dead run.locks (no live owner) are admitted so interrupted/stale-active recovery and the
|
|
2449
|
+
// normal foreground path (run.lock already released before the caller can call apply) still work.
|
|
2450
|
+
const runLock = await readLock(lockPathForRun(entry.dir, "run"));
|
|
2451
|
+
if (runLock?.active) {
|
|
2452
|
+
throw new Error(
|
|
2453
|
+
`Workflow ${args.runId} still holds an active run lock (pid ${runLock.process?.pid}); ` +
|
|
2454
|
+
`wait for the owning run to settle (or run workflow_reconcile) before retrying workflow_apply.`
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
const state = entry.state;
|
|
2458
|
+
// A crash between the completed-ledger append and the state.json=applied write leaves
|
|
2459
|
+
// on-disk status apply-running, which reconcile (workflow_reconcile / stale detection)
|
|
2460
|
+
// rewrites to interrupted or stale-active. Those statuses are admitted here only so the
|
|
2461
|
+
// matching-completed-ledger idempotency path below can finalize the staged mutations; if
|
|
2462
|
+
// no completed record matches, the gate still rejects them (verified after actualPlanHash).
|
|
2463
|
+
const isInterruptedRecovery = state.status === "interrupted" || state.status === "stale-active";
|
|
2464
|
+
if (state.status !== "applied" && state.status !== "awaiting-diff-approval" && state.status !== "apply-failed" && state.status !== "failed-with-diff-plan" && !isInterruptedRecovery) {
|
|
2465
|
+
throw new Error(`Workflow ${args.runId} is not awaiting diff approval; status=${state.status}`);
|
|
2466
|
+
}
|
|
2467
|
+
if (state.sourceHash !== args.approvedSourceHash) {
|
|
2468
|
+
throw applyApprovalMismatchError({
|
|
2469
|
+
runId: args.runId,
|
|
2470
|
+
reason: "approved_source_hash_mismatch",
|
|
2471
|
+
field: "approvedSourceHash",
|
|
2472
|
+
supplied: args.approvedSourceHash,
|
|
2473
|
+
expected: state.sourceHash ?? null,
|
|
2474
|
+
state,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
const plan = JSON.parse(await fs.readFile(path.join(entry.dir, "diff-plan.json"), "utf8"));
|
|
2478
|
+
const actualPlanHash = computeDiffPlanHash(plan);
|
|
2479
|
+
if (actualPlanHash !== args.diffPlanHash) {
|
|
2480
|
+
throw applyApprovalMismatchError({
|
|
2481
|
+
runId: args.runId,
|
|
2482
|
+
reason: "diff_plan_hash_mismatch",
|
|
2483
|
+
field: "diffPlanHash",
|
|
2484
|
+
supplied: args.diffPlanHash,
|
|
2485
|
+
expected: actualPlanHash,
|
|
2486
|
+
state,
|
|
2487
|
+
plan,
|
|
2488
|
+
actualPlanHash,
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
if (plan.baseCommit !== args.baseCommit) {
|
|
2492
|
+
throw applyApprovalMismatchError({
|
|
2493
|
+
runId: args.runId,
|
|
2494
|
+
reason: "base_commit_mismatch",
|
|
2495
|
+
field: "baseCommit",
|
|
2496
|
+
supplied: args.baseCommit,
|
|
2497
|
+
expected: plan.baseCommit ?? null,
|
|
2498
|
+
state,
|
|
2499
|
+
plan,
|
|
2500
|
+
actualPlanHash,
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
if (plan.domainMutationHash !== args.domainMutationHash) {
|
|
2504
|
+
throw applyApprovalMismatchError({
|
|
2505
|
+
runId: args.runId,
|
|
2506
|
+
reason: "domain_mutation_hash_mismatch",
|
|
2507
|
+
field: "domainMutationHash",
|
|
2508
|
+
supplied: args.domainMutationHash,
|
|
2509
|
+
expected: plan.domainMutationHash ?? null,
|
|
2510
|
+
state,
|
|
2511
|
+
plan,
|
|
2512
|
+
actualPlanHash,
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
const currentDomainManifest = await domainMutationApprovalManifestForRun(entry.dir);
|
|
2516
|
+
const currentDomainHash = computeDomainMutationHash(currentDomainManifest);
|
|
2517
|
+
if (currentDomainHash !== plan.domainMutationHash) {
|
|
2518
|
+
throw applyApprovalMismatchError({
|
|
2519
|
+
runId: args.runId,
|
|
2520
|
+
reason: "staged_domain_mutations_changed",
|
|
2521
|
+
field: "domainMutationHash",
|
|
2522
|
+
supplied: args.domainMutationHash,
|
|
2523
|
+
expected: currentDomainHash,
|
|
2524
|
+
state,
|
|
2525
|
+
plan,
|
|
2526
|
+
actualPlanHash,
|
|
2527
|
+
currentDomainHash,
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
// Primary-tree cleanliness is enforced by assertGitCleanAtBase(root, plan.baseCommit) on the
|
|
2531
|
+
// fresh-apply path below; there is no caller-supplied dirty-state proof to validate here.
|
|
2532
|
+
const root = path.resolve(context.worktree || context.directory);
|
|
2533
|
+
async function finalizeAfterApply() {
|
|
2534
|
+
try {
|
|
2535
|
+
const domainFinalization = await finalizeStagedDomainMutations(entry.dir, state, (op) => pluginContext.__workflowDomainMutationHandlers?.[op] ?? pluginContext.workflowExtensionRegistry?.mutationHandler(op));
|
|
2536
|
+
state.domainFinalization = domainFinalization;
|
|
2537
|
+
state.status = "applied";
|
|
2538
|
+
state.finishedAt = new Date().toISOString();
|
|
2539
|
+
state.error = undefined;
|
|
2540
|
+
await completeApprovalWaitMetric(entry.dir, state, actualPlanHash);
|
|
2541
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2542
|
+
await writeJsonAtomic(path.join(entry.dir, "closeout.json"), {
|
|
2543
|
+
runId: state.id,
|
|
2544
|
+
status: state.status,
|
|
2545
|
+
finishedAt: state.finishedAt,
|
|
2546
|
+
resultPath: state.resultPath,
|
|
2547
|
+
operatorMetrics: state.operatorMetrics,
|
|
2548
|
+
domainFinalization: state.domainFinalization,
|
|
2549
|
+
durability: state.durability,
|
|
2550
|
+
});
|
|
2551
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2552
|
+
return domainFinalization;
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
state.status = "apply-failed";
|
|
2555
|
+
state.error = `Domain finalization failed after apply: ${extractTextFromError(error)}`;
|
|
2556
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2557
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2558
|
+
throw error;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
async function verifyRecoveryAppliedContents() {
|
|
2562
|
+
try {
|
|
2563
|
+
const planned = await validatePatchTargets(root, plan.patches, { requireTracked: false });
|
|
2564
|
+
verifyAppliedPatchContents(planned);
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
state.status = "apply-failed";
|
|
2567
|
+
state.error = extractTextFromError(error);
|
|
2568
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2569
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2570
|
+
throw error;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (state.status === "applied") {
|
|
2574
|
+
await verifyRecoveryAppliedContents();
|
|
2575
|
+
const domainFinalization = await finalizeAfterApply();
|
|
2576
|
+
return domainFinalization.finalized > 0
|
|
2577
|
+
? `Workflow ${args.runId} already applied; finalized ${domainFinalization.finalized} domain mutations.`
|
|
2578
|
+
: `Workflow ${args.runId} already applied.`;
|
|
2579
|
+
}
|
|
2580
|
+
const alreadyCompleted = await applyLedgerHasCompleted(entry.dir, actualPlanHash);
|
|
2581
|
+
if (isInterruptedRecovery && !alreadyCompleted) {
|
|
2582
|
+
// Admitted into the gate above, but no completed ledger record matches: the crash (if
|
|
2583
|
+
// any) happened before the patch writes finished, so there is nothing to finalize
|
|
2584
|
+
// idempotently and a fresh apply from this status is unsafe. Reject like the original gate.
|
|
2585
|
+
throw new Error(`Workflow ${args.runId} is not awaiting diff approval; status=${state.status}`);
|
|
2586
|
+
}
|
|
2587
|
+
if (alreadyCompleted) {
|
|
2588
|
+
await verifyRecoveryAppliedContents();
|
|
2589
|
+
const domainFinalization = await finalizeAfterApply();
|
|
2590
|
+
return domainFinalization.finalized > 0
|
|
2591
|
+
? `Workflow ${args.runId} already applied; finalized ${domainFinalization.finalized} domain mutations.`
|
|
2592
|
+
: `Workflow ${args.runId} already applied.`;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
let planned;
|
|
2596
|
+
try {
|
|
2597
|
+
await assertGitCleanAtBase(root, plan.baseCommit);
|
|
2598
|
+
planned = await validatePatchTargets(root, plan.patches);
|
|
2599
|
+
} catch (error) {
|
|
2600
|
+
state.status = error?.code === "OVERLAPPING_PATCH" ? "review-required" : "apply-failed";
|
|
2601
|
+
state.error = extractTextFromError(error);
|
|
2602
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2603
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2604
|
+
throw error;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
state.status = "apply-running";
|
|
2608
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2609
|
+
await appendApplyLedger(entry.dir, { phase: "started", diffPlanHash: actualPlanHash, patchCount: plan.patches.length });
|
|
2610
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2611
|
+
try {
|
|
2612
|
+
for (const { patch } of planned) {
|
|
2613
|
+
await appendApplyLedger(entry.dir, { phase: "before-write", path: patch.path, contentHash: hash(patch.content) });
|
|
2614
|
+
// TOCTOU-safe (R18): re-validate ancestors + open the final component with
|
|
2615
|
+
// O_NOFOLLOW immediately at write time, so a symlink swapped in after
|
|
2616
|
+
// validatePatchTargets cannot redirect the write outside root.
|
|
2617
|
+
await safeWriteFileWithinRoot(root, patch.path, patch.content);
|
|
2618
|
+
await appendApplyLedger(entry.dir, { phase: "after-write", path: patch.path, contentHash: hash(patch.content) });
|
|
2619
|
+
}
|
|
2620
|
+
await appendApplyLedger(entry.dir, { phase: "completed", diffPlanHash: actualPlanHash });
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
const rollbackFailures = await rollbackPatches(planned, root);
|
|
2623
|
+
state.status = error?.code === "OVERLAPPING_PATCH" ? "review-required" : "apply-failed";
|
|
2624
|
+
state.error = applyErrorWithRollbackFailures(error, rollbackFailures);
|
|
2625
|
+
await appendApplyLedger(entry.dir, { phase: "failed", diffPlanHash: actualPlanHash, error: state.error, rollbackFailures });
|
|
2626
|
+
await writeJsonAtomic(path.join(entry.dir, "state.json"), state);
|
|
2627
|
+
await showWorkflowApplyToast(pluginContext, state);
|
|
2628
|
+
throw error;
|
|
2629
|
+
}
|
|
2630
|
+
const domainFinalization = await finalizeAfterApply();
|
|
2631
|
+
return domainFinalization.finalized > 0
|
|
2632
|
+
? `Workflow ${args.runId} applied ${plan.patches.length} patches and finalized ${domainFinalization.finalized} domain mutations.`
|
|
2633
|
+
: `Workflow ${args.runId} applied ${plan.patches.length} patches.`;
|
|
2634
|
+
} finally {
|
|
2635
|
+
await releaseApplyLock();
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
async function WorkflowPlugin(pluginContext, options) {
|
|
2640
|
+
pluginContext.__workflowHardConcurrencyLimit = normalizeHardConcurrencyLimit(
|
|
2641
|
+
options?.hardConcurrencyLimit ?? pluginContext?.hardConcurrencyLimit ?? pluginContext?.__workflowHardConcurrencyLimit,
|
|
2642
|
+
HARD_CONCURRENCY_LIMIT,
|
|
2643
|
+
);
|
|
2644
|
+
pluginContext.__workflowToastAscii = normalizeWorkflowToastAscii(
|
|
2645
|
+
options?.toastCards?.ascii ?? options?.workflowToastAscii ?? pluginContext?.__workflowToastAscii,
|
|
2646
|
+
);
|
|
2647
|
+
pluginContext.workflowAutoApproveCeiling = normalizeAutoApproveTier(
|
|
2648
|
+
options?.autoApprove ?? pluginContext?.workflowAutoApproveCeiling,
|
|
2649
|
+
) || false;
|
|
2650
|
+
const hardConcurrencyLimit = hardConcurrencyLimitForContext(pluginContext);
|
|
2651
|
+
// Trusted extension seam: opencode delivers per-plugin `options` as the factory's second arg
|
|
2652
|
+
// (the [path, {options}] tuple form in opencode.json). One registry per pluginContext — factories
|
|
2653
|
+
// are double-instantiated, so a per-instance registry avoids duplicate-name throws. Extension
|
|
2654
|
+
// modules are TRUSTED host code, loaded only from explicit config (never auto-discovered).
|
|
2655
|
+
const extensionRegistry = createExtensionRegistry();
|
|
2656
|
+
pluginContext.workflowExtensionRegistry = extensionRegistry;
|
|
2657
|
+
const extensionPaths = Array.isArray(options?.extensions) ? options.extensions : [];
|
|
2658
|
+
// Load extensions in the factory body (the factory is async and opencode awaits it) so the static
|
|
2659
|
+
// `tool` map returned below can include extension-contributed tools. Fail loud: a configured-but-
|
|
2660
|
+
// unloadable extension rejects the factory promise rather than silently disabling itself.
|
|
2661
|
+
if (extensionPaths.length) {
|
|
2662
|
+
await extensionRegistry.loadExtensions(extensionPaths, { configDir: resolveOpencodeConfigDir() });
|
|
2663
|
+
}
|
|
2664
|
+
const hooks = {
|
|
2665
|
+
config: async (cfg) => {
|
|
2666
|
+
configureWorkflowPermissions(cfg);
|
|
2667
|
+
await configureWorkflowEntrypoints(cfg, pluginContext.workflowExtensionRegistry?.assetDirs());
|
|
2668
|
+
},
|
|
2669
|
+
event: async ({ event }) => {
|
|
2670
|
+
// The event hook is fire-and-forget: opencode does not await it, and a throw (or rejected
|
|
2671
|
+
// promise) can destabilize the session or silence later events (AGENTS.md non-negotiable
|
|
2672
|
+
// invariant). deliverWorkflowNotifications touches the filesystem (readdir/readFile), which
|
|
2673
|
+
// can reject with EPERM/EACCES/EIO, so guard the whole body and swallow any failure here.
|
|
2674
|
+
try {
|
|
2675
|
+
updateNotificationIdleState(event);
|
|
2676
|
+
await deliverWorkflowNotifications(pluginContext, event);
|
|
2677
|
+
} catch {
|
|
2678
|
+
// Notification delivery is best effort; persisted state on disk remains authoritative.
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
dispose: async () => {
|
|
2682
|
+
clearNotificationRuntimeState();
|
|
2683
|
+
},
|
|
2684
|
+
"chat.params": async (input, output) => {
|
|
2685
|
+
applyLaneEffortParams(input, output);
|
|
2686
|
+
},
|
|
2687
|
+
tool: {
|
|
2688
|
+
workflow_run: tool({
|
|
2689
|
+
description:
|
|
2690
|
+
"Run a sandboxed OpenCode workflow. By default this is two-phase: call WITHOUT approve=true to get the plan summary (use format=json for a structured workflow_preview with approvalHash, models, lane budget, authority, and cost), then call WITH approve=true and the matching approvalHash to execute; missing/stale hashes return approval_mismatch with executed=false. If the plugin is configured with options.autoApprove, an eligible call may launch immediately without approvalHash when the resolved authority tier is covered by the configured ceiling; args.autoApprove can only narrow that configured ceiling for one call. workflow_apply remains independently hash-gated.",
|
|
2691
|
+
args: {
|
|
2692
|
+
name: tool.schema.string().optional(),
|
|
2693
|
+
scriptPath: tool.schema.string().optional(),
|
|
2694
|
+
allowExternalScriptPath: tool.schema.boolean().optional(),
|
|
2695
|
+
source: tool.schema.string().optional(),
|
|
2696
|
+
includeSourceSnippet: tool.schema.boolean().optional(),
|
|
2697
|
+
sourceSnippetMaxChars: tool.schema.number().int().positive().max(2000).optional(),
|
|
2698
|
+
args: tool.schema.any().optional(),
|
|
2699
|
+
approve: tool.schema.boolean().optional(),
|
|
2700
|
+
approvalHash: tool.schema.string().optional(),
|
|
2701
|
+
autoApprove: tool.schema.enum(["readOnly", "worktree", "all"]).optional(),
|
|
2702
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2703
|
+
resumeRunId: tool.schema.string().optional(),
|
|
2704
|
+
resumePolicy: tool.schema.enum(["extend-deadline"]).optional(),
|
|
2705
|
+
editAndResume: tool.schema.boolean().optional(),
|
|
2706
|
+
maxAgents: tool.schema.number().int().positive().max(100000).optional(),
|
|
2707
|
+
concurrency: tool.schema.number().int().positive().max(hardConcurrencyLimit).optional(),
|
|
2708
|
+
laneTimeoutMs: tool.schema.number().int().positive().max(MAX_CHILD_PROMPT_TIMEOUT_MS).optional(),
|
|
2709
|
+
childPromptTimeoutMs: tool.schema.number().int().positive().max(MAX_CHILD_PROMPT_TIMEOUT_MS).optional(),
|
|
2710
|
+
childModel: tool.schema.string().optional(),
|
|
2711
|
+
modelTiers: tool.schema.object({
|
|
2712
|
+
fast: tool.schema.string().optional(),
|
|
2713
|
+
deep: tool.schema.string().optional(),
|
|
2714
|
+
}).optional(),
|
|
2715
|
+
profile: tool.schema.enum(Object.keys(WORKFLOW_AUTHORITY_PROFILES)).optional(),
|
|
2716
|
+
background: tool.schema.boolean().optional(),
|
|
2717
|
+
debugCapture: tool.schema.boolean().optional(),
|
|
2718
|
+
authority: tool.schema.any().optional(),
|
|
2719
|
+
baseCommit: tool.schema.string().optional(),
|
|
2720
|
+
maxCost: tool.schema.number().nonnegative().optional(),
|
|
2721
|
+
maxTokens: tool.schema.number().int().nonnegative().optional(),
|
|
2722
|
+
guestDeadlineMs: tool.schema.number().int().positive().max(60_000).optional(),
|
|
2723
|
+
maxRuntimeMs: tool.schema.number().int().positive().max(24 * 60 * 60 * 1000).optional(),
|
|
2724
|
+
},
|
|
2725
|
+
async execute(args, context) {
|
|
2726
|
+
return await startWorkflow(pluginContext, context, args);
|
|
2727
|
+
},
|
|
2728
|
+
}),
|
|
2729
|
+
workflow_status: tool({
|
|
2730
|
+
description: "Show recent workflow runs or one workflow run state.",
|
|
2731
|
+
args: {
|
|
2732
|
+
runId: tool.schema.string().optional(),
|
|
2733
|
+
limit: tool.schema.number().int().positive().max(100).optional(),
|
|
2734
|
+
includePendingApproval: tool.schema.boolean().optional(),
|
|
2735
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2736
|
+
detail: tool.schema.enum(["compact", "full", "result"]).optional(),
|
|
2737
|
+
reconcile: tool.schema.boolean().optional(),
|
|
2738
|
+
},
|
|
2739
|
+
async execute(args, context) {
|
|
2740
|
+
return await statusText(context, args);
|
|
2741
|
+
},
|
|
2742
|
+
}),
|
|
2743
|
+
workflow_events: tool({
|
|
2744
|
+
description: "Read a workflow run's redacted lifecycle events with type-prefix filtering and offset pagination.",
|
|
2745
|
+
args: {
|
|
2746
|
+
runId: tool.schema.string(),
|
|
2747
|
+
typePrefix: tool.schema.string().optional(),
|
|
2748
|
+
sinceTimestamp: tool.schema.string().optional(),
|
|
2749
|
+
beforeTimestamp: tool.schema.string().optional(),
|
|
2750
|
+
offset: tool.schema.number().int().nonnegative().optional(),
|
|
2751
|
+
limit: tool.schema.number().int().positive().max(500).optional(),
|
|
2752
|
+
order: tool.schema.enum(["newest", "oldest"]).optional(),
|
|
2753
|
+
format: tool.schema.enum(["json", "summary"]).optional(),
|
|
2754
|
+
},
|
|
2755
|
+
async execute(args, context) {
|
|
2756
|
+
return await eventsText(context, args);
|
|
2757
|
+
},
|
|
2758
|
+
}),
|
|
2759
|
+
workflow_reconcile: tool({
|
|
2760
|
+
description: "Persist recovery state for stale workflow runs and clear stale workflow locks.",
|
|
2761
|
+
args: {
|
|
2762
|
+
runId: tool.schema.string().optional(),
|
|
2763
|
+
limit: tool.schema.number().int().positive().max(100).optional(),
|
|
2764
|
+
includePendingApproval: tool.schema.boolean().optional(),
|
|
2765
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2766
|
+
detail: tool.schema.enum(["compact", "full", "result"]).optional(),
|
|
2767
|
+
},
|
|
2768
|
+
async execute(args, context) {
|
|
2769
|
+
return await reconcileRuns(context, args);
|
|
2770
|
+
},
|
|
2771
|
+
}),
|
|
2772
|
+
workflow_cancel: tool({
|
|
2773
|
+
description: "Cancel an active workflow run and abort its child OpenCode sessions best-effort.",
|
|
2774
|
+
args: {
|
|
2775
|
+
runId: tool.schema.string(),
|
|
2776
|
+
},
|
|
2777
|
+
async execute(args, context) {
|
|
2778
|
+
return await cancelRun(pluginContext, context, args);
|
|
2779
|
+
},
|
|
2780
|
+
}),
|
|
2781
|
+
workflow_pause: tool({
|
|
2782
|
+
description: "Pause an active workflow run, abort active child sessions best-effort, and preserve durable state for resume.",
|
|
2783
|
+
args: {
|
|
2784
|
+
runId: tool.schema.string(),
|
|
2785
|
+
},
|
|
2786
|
+
async execute(args, context) {
|
|
2787
|
+
return await pauseRun(pluginContext, context, args);
|
|
2788
|
+
},
|
|
2789
|
+
}),
|
|
2790
|
+
workflow_kill: tool({
|
|
2791
|
+
description: "Force-terminate a wedged workflow run immediately: abort it, release its locks without waiting on cooperative settle, and leave it in a resumable (interrupted) state. Use when workflow_cancel/workflow_pause do not return because a lane is stuck.",
|
|
2792
|
+
args: {
|
|
2793
|
+
runId: tool.schema.string(),
|
|
2794
|
+
},
|
|
2795
|
+
async execute(args, context) {
|
|
2796
|
+
return await killRun(pluginContext, context, args);
|
|
2797
|
+
},
|
|
2798
|
+
}),
|
|
2799
|
+
workflow_save: tool({
|
|
2800
|
+
description: "Save a reusable workflow script to the project workflow directory by default, or globally with an explicit globalScopeIntent opt-in.",
|
|
2801
|
+
args: {
|
|
2802
|
+
name: tool.schema.string(),
|
|
2803
|
+
source: tool.schema.string(),
|
|
2804
|
+
scope: tool.schema.enum(["global", "project"]).optional(),
|
|
2805
|
+
globalScopeIntent: tool.schema.literal("save-global-workflow").optional(),
|
|
2806
|
+
overwrite: tool.schema.boolean().optional(),
|
|
2807
|
+
},
|
|
2808
|
+
async execute(args, context) {
|
|
2809
|
+
return await saveWorkflow(context, args);
|
|
2810
|
+
},
|
|
2811
|
+
}),
|
|
2812
|
+
workflow_list: tool({
|
|
2813
|
+
description: "List saved project and global workflows without running them.",
|
|
2814
|
+
args: {
|
|
2815
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2816
|
+
},
|
|
2817
|
+
async execute(args, context) {
|
|
2818
|
+
// Surface the invoking session's model as the display default for workflows that declare no
|
|
2819
|
+
// model (the child default is the session model at run time). Read-only and best-effort:
|
|
2820
|
+
// readActiveSessionModel never throws and never prompts a model.
|
|
2821
|
+
const sessionModel = await readActiveSessionModel(pluginContext, context);
|
|
2822
|
+
return await listWorkflows(
|
|
2823
|
+
context,
|
|
2824
|
+
args,
|
|
2825
|
+
sessionModel.model,
|
|
2826
|
+
pluginContext.workflowExtensionRegistry?.assetDirs()?.workflows ?? [],
|
|
2827
|
+
);
|
|
2828
|
+
},
|
|
2829
|
+
}),
|
|
2830
|
+
workflow_cleanup: tool({
|
|
2831
|
+
description: "Dry-run or apply workflow run retention cleanup while preserving active, corrupt, and ambiguous edit runs.",
|
|
2832
|
+
args: {
|
|
2833
|
+
dryRun: tool.schema.boolean().optional().describe("When true or omitted, only previews which completed run dirs would be removed. Set false to delete eligible old runs."),
|
|
2834
|
+
keep: tool.schema.number().int().min(0).max(1000).optional().describe("Number of newest completed terminal runs to preserve; active, corrupt, ambiguous edit, and pinned runs are always preserved."),
|
|
2835
|
+
},
|
|
2836
|
+
async execute(args, context) {
|
|
2837
|
+
return await cleanupRuns(context, args);
|
|
2838
|
+
},
|
|
2839
|
+
}),
|
|
2840
|
+
workflow_apply: tool({
|
|
2841
|
+
description: "Apply an awaiting workflow diff plan to the primary tree after explicit hash-gated approval. Use hash fields copied from a prior workflow_status({detail:\"full\"}) or workflow_run apply-preview for the same run; stale or missing hashes fail closed with a structured workflow_apply_approval_mismatch payload.",
|
|
2842
|
+
args: {
|
|
2843
|
+
runId: tool.schema.string().describe("Run id currently in awaiting-diff-approval, apply-failed, failed-with-diff-plan, applied, or an interrupted recovery state."),
|
|
2844
|
+
approvedSourceHash: tool.schema.string().describe("Exact sourceHash copied from workflow_status detail:\"full\" for this run; proves the workflow source that produced the diff plan."),
|
|
2845
|
+
baseCommit: tool.schema.string().describe("Exact editPlan.baseCommit from workflow_status detail:\"full\"; primary HEAD must still match before patch writes."),
|
|
2846
|
+
diffPlanHash: tool.schema.string().describe("Exact editPlan.diffPlanHash from workflow_status detail:\"full\"; covers the normalized patch plan and staged domain manifest."),
|
|
2847
|
+
domainMutationHash: tool.schema.string().describe("Exact editPlan.domainMutationHash from workflow_status detail:\"full\"; fails if staged domain mutations changed after diff approval."),
|
|
2848
|
+
approvalIntent: tool.schema.literal("apply").describe("Must be the literal string \"apply\" to show this call is an explicit apply approval."),
|
|
2849
|
+
},
|
|
2850
|
+
async execute(args, context) {
|
|
2851
|
+
return await applyWorkflow(pluginContext, context, args);
|
|
2852
|
+
},
|
|
2853
|
+
}),
|
|
2854
|
+
workflow_salvage: tool({
|
|
2855
|
+
description: "Recover orphaned read-only lane results from persisted child transcripts into tagged synthetic journal entries, behind an explicit preview/approve gate.",
|
|
2856
|
+
args: {
|
|
2857
|
+
runId: tool.schema.string().describe("Interrupted or stale-active run id whose lane checkpoints should be inspected for salvage candidates."),
|
|
2858
|
+
callIds: tool.schema.array(tool.schema.string()).optional().describe("Optional subset of candidate lane callIds to salvage; omitted means preview or approve all eligible candidates."),
|
|
2859
|
+
approve: tool.schema.boolean().optional().describe("Omit or false to preview candidates and approvalHash; true writes tagged synthetic journal entries when the hash matches."),
|
|
2860
|
+
approvalHash: tool.schema.string().optional().describe("Approval hash returned by a prior salvage preview for the same run and candidate set. Required when approve is true."),
|
|
2861
|
+
},
|
|
2862
|
+
async execute(args, context) {
|
|
2863
|
+
return await salvageRun(pluginContext, context, args);
|
|
2864
|
+
},
|
|
2865
|
+
}),
|
|
2866
|
+
workflow_roles: tool({
|
|
2867
|
+
description: "List workflow specialist role prompts, hash provenance, and typed roles.json defaults.",
|
|
2868
|
+
args: {
|
|
2869
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2870
|
+
},
|
|
2871
|
+
async execute(args) {
|
|
2872
|
+
return await listRoles(args, { roleDir: pluginContext?.__workflowRoleDir });
|
|
2873
|
+
},
|
|
2874
|
+
}),
|
|
2875
|
+
workflow_models: tool({
|
|
2876
|
+
description: "List the invoking session's model and all available/authenticated providers and models, with a no-deviation fast/deep suggestion. Read-only; no run is started.",
|
|
2877
|
+
args: {
|
|
2878
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2879
|
+
},
|
|
2880
|
+
async execute(args, context) {
|
|
2881
|
+
const models = await buildWorkflowModels(pluginContext, context);
|
|
2882
|
+
if (args.format === "json") return JSON.stringify(models);
|
|
2883
|
+
const lines = [
|
|
2884
|
+
`Session model: ${models.session.model ?? "unknown"} (source: ${models.session.source})`,
|
|
2885
|
+
`Suggested: fast=${models.suggested.fast ?? "?"} deep=${models.suggested.deep ?? "?"}`,
|
|
2886
|
+
"Providers:",
|
|
2887
|
+
...models.providers.map((p) => ` ${p.id} [${p.source}] -> ${p.models.map((m) => m.id).join(", ")}`),
|
|
2888
|
+
];
|
|
2889
|
+
return lines.join("\n");
|
|
2890
|
+
},
|
|
2891
|
+
}),
|
|
2892
|
+
workflow_templates: tool({
|
|
2893
|
+
description: "List shipped v2 workflow templates without writing files; pass includeSource=true to retrieve source bodies explicitly.",
|
|
2894
|
+
args: {
|
|
2895
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2896
|
+
template: tool.schema.string().optional(),
|
|
2897
|
+
includeSource: tool.schema.boolean().optional(),
|
|
2898
|
+
},
|
|
2899
|
+
async execute(args, context) {
|
|
2900
|
+
return await listTemplates(args);
|
|
2901
|
+
},
|
|
2902
|
+
}),
|
|
2903
|
+
workflow_template_save: tool({
|
|
2904
|
+
description: "Save a shipped v2 workflow template to project or global workflows.",
|
|
2905
|
+
args: {
|
|
2906
|
+
template: tool.schema.string().describe("Bundled template name from workflow_templates, for example first-run-slice or scoped-parallel."),
|
|
2907
|
+
name: tool.schema.string().optional().describe("Saved workflow slug; omitted uses the template name."),
|
|
2908
|
+
scope: tool.schema.enum(["global", "project"]).optional().describe("Destination workflow directory; omitted defaults to project for template saves."),
|
|
2909
|
+
globalScopeIntent: tool.schema.literal("save-global-workflow").optional().describe("Required only with scope:\"global\" to confirm this writes into the shared global workflow directory."),
|
|
2910
|
+
overwrite: tool.schema.boolean().optional().describe("Set true to replace an existing saved workflow with the same name."),
|
|
2911
|
+
},
|
|
2912
|
+
async execute(args, context) {
|
|
2913
|
+
return await saveTemplate(context, args);
|
|
2914
|
+
},
|
|
2915
|
+
}),
|
|
2916
|
+
workflow_live_gates: tool({
|
|
2917
|
+
description: "Report workflow v2 live-gate capability status for the active runtime.",
|
|
2918
|
+
args: {
|
|
2919
|
+
format: tool.schema.enum(["summary", "json"]).optional(),
|
|
2920
|
+
probePermissionEnforcement: tool.schema.boolean().optional(),
|
|
2921
|
+
probeDeniedBash: tool.schema.boolean().optional(),
|
|
2922
|
+
probeCommandScopedBash: tool.schema.boolean().optional(),
|
|
2923
|
+
probeSecretReadDeny: tool.schema.boolean().optional(),
|
|
2924
|
+
probeStructuredOutput: tool.schema.boolean().optional(),
|
|
2925
|
+
probeWorktreeApi: tool.schema.boolean().optional(),
|
|
2926
|
+
probeDirectoryRooting: tool.schema.boolean().optional(),
|
|
2927
|
+
probeWorktreeEditIsolation: tool.schema.boolean().optional(),
|
|
2928
|
+
probeIntegrationWorktreeIsolation: tool.schema.boolean().optional(),
|
|
2929
|
+
probeBackgroundContinuation: tool.schema.boolean().optional(),
|
|
2930
|
+
probeConcurrencyCapacity: tool.schema.boolean().optional(),
|
|
2931
|
+
concurrencyProbeLimit: tool.schema.number().int().positive().max(hardConcurrencyLimit).optional(),
|
|
2932
|
+
probeCancellation: tool.schema.boolean().optional(),
|
|
2933
|
+
probeWorkflowNotification: tool.schema.boolean().optional(),
|
|
2934
|
+
probeNetworkAccess: tool.schema.boolean().optional(),
|
|
2935
|
+
probeMcpAccess: tool.schema.boolean().optional(),
|
|
2936
|
+
resetProbeCache: tool.schema.boolean().optional(),
|
|
2937
|
+
resetProbeCacheScope: tool.schema.enum(["runtime", "all"]).optional(),
|
|
2938
|
+
approvalIntent: tool.schema.literal("probe").optional(),
|
|
2939
|
+
},
|
|
2940
|
+
async execute(args, context) {
|
|
2941
|
+
assertLiveGateProbeAllowed(context, args);
|
|
2942
|
+
if (args.resetProbeCache === true) {
|
|
2943
|
+
invalidateWorkflowProviderListCache(args.resetProbeCacheScope === "all" ? "all" : pluginContext);
|
|
2944
|
+
}
|
|
2945
|
+
return await liveGateReport(pluginContext, context, args);
|
|
2946
|
+
},
|
|
2947
|
+
}),
|
|
2948
|
+
// review_materialize is contributed by the beads extension (workflow-domains/beads/beads-extension.js
|
|
2949
|
+
// `tools` factory); the core kernel ships no beads-specific tool.
|
|
2950
|
+
},
|
|
2951
|
+
};
|
|
2952
|
+
// Merge extension-contributed tools into the static tool map (Stage 4). toolKit injects the kernel's
|
|
2953
|
+
// single @opencode-ai/plugin `tool`/`schema` (one zod instance) plus pluginContext + the write guard,
|
|
2954
|
+
// so an extension needs no @opencode-ai/plugin dependency of its own. Core tool names are reserved
|
|
2955
|
+
// (fail-closed): an extension may only contribute NET-NEW tool names.
|
|
2956
|
+
const toolKit = { tool, schema: tool.schema, assertWriteWorkflowAllowed, pluginContext };
|
|
2957
|
+
Object.assign(hooks.tool, extensionRegistry.tools(toolKit, Object.keys(hooks.tool)));
|
|
2958
|
+
return hooks;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
WorkflowPlugin.__test = {
|
|
2962
|
+
acquireAgentSlot,
|
|
2963
|
+
releaseAgentSlot,
|
|
2964
|
+
configureWorkflowEntrypoints,
|
|
2965
|
+
isTrustedAutoApplySource,
|
|
2966
|
+
shouldAutoApplyDrain,
|
|
2967
|
+
normalizePatches,
|
|
2968
|
+
planWorkflowEnvelope,
|
|
2969
|
+
assertWorkflowArgsMatchSchema,
|
|
2970
|
+
readActiveSessionModel,
|
|
2971
|
+
buildWorkflowModels,
|
|
2972
|
+
hardConcurrencyLimitForContext,
|
|
2973
|
+
normalizeWorkflowToastAscii,
|
|
2974
|
+
workflowProviderListCache,
|
|
2975
|
+
workflowProviderListCacheKey,
|
|
2976
|
+
WORKFLOW_PROVIDER_LIST_TTL_MS,
|
|
2977
|
+
invalidateWorkflowProviderListCache,
|
|
2978
|
+
salvageRun,
|
|
2979
|
+
extractFinalAssistantText,
|
|
2980
|
+
tryParseJson,
|
|
2981
|
+
computeSalvageApprovalHash,
|
|
2982
|
+
salvageApprovalPayload,
|
|
2983
|
+
classifyResumeCacheHit,
|
|
2984
|
+
checkpointHitForSignature,
|
|
2985
|
+
isLaneIntegrable,
|
|
2986
|
+
backgroundRecommendation,
|
|
2987
|
+
assertResumableState,
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
export {
|
|
2991
|
+
acquireAgentSlot,
|
|
2992
|
+
addEditPlanFromResult,
|
|
2993
|
+
applyWorkflow,
|
|
2994
|
+
approvalSummary,
|
|
2995
|
+
assertGitCleanAtBase,
|
|
2996
|
+
cleanupWorktrees,
|
|
2997
|
+
configureWorkflowEntrypoints,
|
|
2998
|
+
createEditWorktree,
|
|
2999
|
+
executeSandbox,
|
|
3000
|
+
gitHead,
|
|
3001
|
+
gitOutput,
|
|
3002
|
+
gitPathTracked,
|
|
3003
|
+
isRunAborted,
|
|
3004
|
+
normalizePatches,
|
|
3005
|
+
planWorkflowEnvelope,
|
|
3006
|
+
releaseAgentSlot,
|
|
3007
|
+
rollbackPatches,
|
|
3008
|
+
runChildAgent,
|
|
3009
|
+
runNestedWorkflow,
|
|
3010
|
+
runWorkflowExecution,
|
|
3011
|
+
salvageRun,
|
|
3012
|
+
startWorkflow,
|
|
3013
|
+
throwIfAborted,
|
|
3014
|
+
validatePatchTargets,
|
|
3015
|
+
};
|
|
3016
|
+
|
|
3017
|
+
export default WorkflowPlugin;
|