@mcrescenzo/opencode-workflows 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// Concern (3): lane outcome and projection recording. Records lane outcomes into the
|
|
2
|
+
// append-only journal + per-lane projection files, writes the durable worktrees/waves/
|
|
3
|
+
// closeout projections, captures/reads/removes per-lane request+result checkpoints,
|
|
4
|
+
// reconstructs lane projections from disk, and computes recovery/salvage summaries.
|
|
5
|
+
// Extracted from run-store-status.js (opencode-workflows-nbp).
|
|
6
|
+
//
|
|
7
|
+
// The RunContext properties this concern reads/writes are the lane/journal/projection
|
|
8
|
+
// surface: run.id, run.dir, run.laneRecords, run.laneOutcomes, run.droppedLaneCount,
|
|
9
|
+
// run.currentPhase/agentsStarted/maxAgents/concurrency (waves index), and the
|
|
10
|
+
// editWorktrees/integrationWorktrees/integrationPlan worktree records.
|
|
11
|
+
// See {@link import("./run-context.js").RunContext}.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
ACTIVE_STATUSES,
|
|
17
|
+
MAX_STATUS_STRING_CHARS,
|
|
18
|
+
} from "./constants.js";
|
|
19
|
+
import { redactDurableValue, redactValue, truncateText } from "./text-json.js";
|
|
20
|
+
import {
|
|
21
|
+
appendEvent,
|
|
22
|
+
appendJournal,
|
|
23
|
+
durableLedgerSummary,
|
|
24
|
+
incompleteLedgerKeys,
|
|
25
|
+
ledgerFilePath,
|
|
26
|
+
loadJournal,
|
|
27
|
+
readJsonlLedger,
|
|
28
|
+
} from "./event-journal.js";
|
|
29
|
+
import { ensurePrivateDir, readJsonFile, safeProjectionName, writeJsonAtomic } from "./run-store-fs.js";
|
|
30
|
+
|
|
31
|
+
const laneProjectionWriteChains = new WeakMap();
|
|
32
|
+
const cleanLaneProjectionRecords = new WeakMap();
|
|
33
|
+
|
|
34
|
+
function durationMs(start, end) {
|
|
35
|
+
const startMs = typeof start === "string" ? Date.parse(start) : Number.NaN;
|
|
36
|
+
const endMs = typeof end === "string" ? Date.parse(end) : Number.NaN;
|
|
37
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return undefined;
|
|
38
|
+
return Math.max(0, endMs - startMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanProjectionMap(run) {
|
|
42
|
+
let records = cleanLaneProjectionRecords.get(run);
|
|
43
|
+
if (!records) {
|
|
44
|
+
records = new Map();
|
|
45
|
+
cleanLaneProjectionRecords.set(run, records);
|
|
46
|
+
}
|
|
47
|
+
return records;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function markLaneProjectionClean(run, callId, record) {
|
|
51
|
+
if (!run || typeof run !== "object" || !callId || !record) return;
|
|
52
|
+
cleanProjectionMap(run).set(String(callId), { ref: record, updatedAt: record.updatedAt });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function laneProjectionNeedsFlush(run, callId, record) {
|
|
56
|
+
if (!run || typeof run !== "object" || !callId || !record) return true;
|
|
57
|
+
const clean = cleanLaneProjectionRecords.get(run)?.get(String(callId));
|
|
58
|
+
return !clean || clean.ref !== record || clean.updatedAt !== record.updatedAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function recordLaneOutcome(run, record) {
|
|
62
|
+
const outcome = record.outcome ?? "failure";
|
|
63
|
+
const completedAt = new Date().toISOString();
|
|
64
|
+
const queueWaitMs = Number.isFinite(record.queueWaitMs)
|
|
65
|
+
? Math.max(0, record.queueWaitMs)
|
|
66
|
+
: durationMs(record.enqueuedAt, record.startedAt);
|
|
67
|
+
let firstResultForRun = false;
|
|
68
|
+
if (outcome === "success" && !run.firstResultAt) {
|
|
69
|
+
run.firstResultAt = completedAt;
|
|
70
|
+
run.timeToFirstResultMs = durationMs(run.startedAt, completedAt);
|
|
71
|
+
firstResultForRun = true;
|
|
72
|
+
}
|
|
73
|
+
if (!record.replayedOutcome && Object.hasOwn(run.laneOutcomes, outcome)) run.laneOutcomes[outcome] += 1;
|
|
74
|
+
if (record.returnedNullDueToFailure) run.droppedLaneCount += 1;
|
|
75
|
+
await appendJournal(run, {
|
|
76
|
+
type: "agent",
|
|
77
|
+
completedAt,
|
|
78
|
+
...record,
|
|
79
|
+
...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
|
|
80
|
+
});
|
|
81
|
+
await writeLaneProjection(run, record.callId, {
|
|
82
|
+
enqueuedAt: record.enqueuedAt,
|
|
83
|
+
startedAt: record.startedAt,
|
|
84
|
+
...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
|
|
85
|
+
status: outcome === "success" ? "completed" : outcome,
|
|
86
|
+
outcome,
|
|
87
|
+
completedAt,
|
|
88
|
+
childID: record.childID,
|
|
89
|
+
title: record.title,
|
|
90
|
+
taskSummary: record.taskSummary,
|
|
91
|
+
model: record.model,
|
|
92
|
+
agent: record.agent,
|
|
93
|
+
role: record.role,
|
|
94
|
+
timeoutMs: record.timeoutMs,
|
|
95
|
+
worktreePath: record.worktreePath,
|
|
96
|
+
integrationLane: record.integrationLane,
|
|
97
|
+
salvage: record.salvage,
|
|
98
|
+
recoveredFromCheckpoint: record.recoveredFromCheckpoint,
|
|
99
|
+
checkpointSignatureHash: record.checkpointSignatureHash,
|
|
100
|
+
checkpointCapturedAt: record.checkpointCapturedAt,
|
|
101
|
+
matchedViaSignatureFallback: record.matchedViaSignatureFallback,
|
|
102
|
+
originalCallId: record.originalCallId,
|
|
103
|
+
errorSummary: record.errorSummary,
|
|
104
|
+
// Failure-class taxonomy (transient/transient_exhausted/validation_exhausted/terminal) so workflow_status
|
|
105
|
+
// can show WHY a lane failed and whether a retry/resume might help, distinct from the
|
|
106
|
+
// coarse LANE_OUTCOMES bucket. Present only on failed lanes.
|
|
107
|
+
failureClass: record.failureClass,
|
|
108
|
+
retryable: record.retryable,
|
|
109
|
+
correctiveAttempts: record.correctiveAttempts,
|
|
110
|
+
tokens: record.tokens,
|
|
111
|
+
cost: record.cost,
|
|
112
|
+
});
|
|
113
|
+
if (firstResultForRun && run.firstResultEventEmitted !== true) {
|
|
114
|
+
run.firstResultEventEmitted = true;
|
|
115
|
+
await appendEvent(run, {
|
|
116
|
+
type: "run.first_result",
|
|
117
|
+
callId: record.callId,
|
|
118
|
+
firstResultAt: run.firstResultAt,
|
|
119
|
+
timeToFirstResultMs: run.timeToFirstResultMs,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Synthetic, explicit-opt-in journal entry for an orphaned lane whose result was recovered
|
|
125
|
+
// from a persisted child transcript (workflow_salvage). Mirrors recordLaneOutcome's journal +
|
|
126
|
+
// lane-projection shape but operates on a durable runDir (the owning run is interrupted, not
|
|
127
|
+
// in-memory), tags every record with salvagedFromTranscript: true, and never touches in-memory
|
|
128
|
+
// lane-outcome counters, state.json, integration ledgers, or worktrees. The result field is
|
|
129
|
+
// stored raw in journal.jsonl (consistent with normal lane results); it is intentionally NOT
|
|
130
|
+
// projected into lanes/<callId>.json (matching recordLaneOutcome, which keeps result out of the
|
|
131
|
+
// projection). salvageValidation records that the original AJV schema was unavailable, so the
|
|
132
|
+
// outcome was decided by conservative JSON-structural validation only.
|
|
133
|
+
async function writeSalvagedLaneOutcome(runDir, callId, record) {
|
|
134
|
+
if (!runDir) throw new Error("writeSalvagedLaneOutcome requires runDir");
|
|
135
|
+
if (!callId) throw new Error("writeSalvagedLaneOutcome requires callId");
|
|
136
|
+
const syntheticRun = { id: record.runId, dir: runDir, laneRecords: new Map(), journalRecords: 0 };
|
|
137
|
+
const completedAt = new Date().toISOString();
|
|
138
|
+
const outcome = record.outcome === "success" ? "success" : "failure";
|
|
139
|
+
await appendJournal(syntheticRun, {
|
|
140
|
+
type: "agent",
|
|
141
|
+
completedAt,
|
|
142
|
+
salvagedFromTranscript: true,
|
|
143
|
+
callId,
|
|
144
|
+
signatureHash: record.signatureHash,
|
|
145
|
+
outcome,
|
|
146
|
+
childID: record.childID,
|
|
147
|
+
title: record.title,
|
|
148
|
+
taskSummary: record.taskSummary,
|
|
149
|
+
startedAt: record.startedAt,
|
|
150
|
+
model: record.model,
|
|
151
|
+
agent: record.agent,
|
|
152
|
+
role: record.role,
|
|
153
|
+
timeoutMs: record.timeoutMs,
|
|
154
|
+
permissionPolicy: record.permissionPolicy,
|
|
155
|
+
result: record.result,
|
|
156
|
+
errorSummary: record.errorSummary,
|
|
157
|
+
salvageValidation: record.salvageValidation,
|
|
158
|
+
});
|
|
159
|
+
await writeLaneProjection(syntheticRun, callId, {
|
|
160
|
+
status: outcome === "success" ? "completed" : outcome,
|
|
161
|
+
outcome,
|
|
162
|
+
completedAt,
|
|
163
|
+
salvagedFromTranscript: true,
|
|
164
|
+
childID: record.childID,
|
|
165
|
+
title: record.title,
|
|
166
|
+
taskSummary: record.taskSummary,
|
|
167
|
+
model: record.model,
|
|
168
|
+
agent: record.agent,
|
|
169
|
+
role: record.role,
|
|
170
|
+
timeoutMs: record.timeoutMs,
|
|
171
|
+
permissionPolicy: record.permissionPolicy,
|
|
172
|
+
salvage: record.salvage,
|
|
173
|
+
errorSummary: record.errorSummary,
|
|
174
|
+
});
|
|
175
|
+
return { callId, outcome, completedAt };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function recoverySummary(runDir, state = {}) {
|
|
179
|
+
const domain = await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl"));
|
|
180
|
+
const validation = await readJsonlLedger(ledgerFilePath(runDir, "validation-ledger.jsonl"));
|
|
181
|
+
const apply = await readJsonlLedger(ledgerFilePath(runDir, "apply-ledger.jsonl"));
|
|
182
|
+
const applyStarted = apply.some((record) => record.phase === "started" || record.phase === "before-write");
|
|
183
|
+
const applyTerminal = apply.some((record) => record.phase === "completed" || record.phase === "failed");
|
|
184
|
+
return {
|
|
185
|
+
generatedAt: new Date().toISOString(),
|
|
186
|
+
ledgers: await durableLedgerSummary(runDir),
|
|
187
|
+
incompleteDomainMutations: incompleteLedgerKeys(domain, "mutationKey"),
|
|
188
|
+
incompleteValidationKeys: incompleteLedgerKeys(validation, "validationKey"),
|
|
189
|
+
incompleteApply: applyStarted && !applyTerminal,
|
|
190
|
+
worktreeCounts: {
|
|
191
|
+
edit: Array.isArray(state.editWorktrees) ? state.editWorktrees.length : 0,
|
|
192
|
+
integration: Array.isArray(state.integrationWorktrees) ? state.integrationWorktrees.length : 0,
|
|
193
|
+
lanes: Array.isArray(state.integrationPlan?.lanes) ? state.integrationPlan.lanes.length : 0,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function doWriteLaneProjection(run, callId, update = {}) {
|
|
199
|
+
if (!callId) return undefined;
|
|
200
|
+
const lanesDir = path.join(run.dir, "lanes");
|
|
201
|
+
await ensurePrivateDir(lanesDir);
|
|
202
|
+
const filePath = path.join(lanesDir, `${safeProjectionName(callId)}.json`);
|
|
203
|
+
const existing = run.laneRecords?.get(callId) ?? await readJsonFile(filePath, {});
|
|
204
|
+
const record = {
|
|
205
|
+
...existing,
|
|
206
|
+
runId: run.id,
|
|
207
|
+
callId,
|
|
208
|
+
...redactValue(update, { maxString: MAX_STATUS_STRING_CHARS }),
|
|
209
|
+
updatedAt: new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
await writeJsonAtomic(filePath, record);
|
|
212
|
+
if (!run.laneRecords) run.laneRecords = new Map();
|
|
213
|
+
run.laneRecords.set(callId, record);
|
|
214
|
+
markLaneProjectionClean(run, callId, record);
|
|
215
|
+
return record;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function writeLaneProjection(run, callId, update = {}) {
|
|
219
|
+
if (!callId) return undefined;
|
|
220
|
+
if (!run || typeof run !== "object") return await doWriteLaneProjection(run, callId, update);
|
|
221
|
+
let chains = laneProjectionWriteChains.get(run);
|
|
222
|
+
if (!chains) {
|
|
223
|
+
chains = new Map();
|
|
224
|
+
laneProjectionWriteChains.set(run, chains);
|
|
225
|
+
}
|
|
226
|
+
const key = String(callId);
|
|
227
|
+
const previous = chains.get(key) ?? Promise.resolve();
|
|
228
|
+
const current = previous.catch(() => {}).then(() => doWriteLaneProjection(run, callId, update));
|
|
229
|
+
chains.set(key, current.catch(() => {}));
|
|
230
|
+
return await current;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function writeDurableProjections(run, state) {
|
|
234
|
+
const worktrees = {
|
|
235
|
+
runId: run.id,
|
|
236
|
+
updatedAt: new Date().toISOString(),
|
|
237
|
+
edit: run.editWorktrees ?? [],
|
|
238
|
+
integration: run.integrationWorktrees ?? [],
|
|
239
|
+
lanes: run.integrationPlan?.lanes ?? [],
|
|
240
|
+
};
|
|
241
|
+
await writeJsonAtomic(path.join(run.dir, "worktrees.json"), worktrees);
|
|
242
|
+
await ensurePrivateDir(path.join(run.dir, "waves"));
|
|
243
|
+
await writeJsonAtomic(path.join(run.dir, "waves", "index.json"), {
|
|
244
|
+
runId: run.id,
|
|
245
|
+
updatedAt: new Date().toISOString(),
|
|
246
|
+
currentPhase: run.currentPhase,
|
|
247
|
+
agentsStarted: run.agentsStarted,
|
|
248
|
+
maxAgents: run.maxAgents,
|
|
249
|
+
concurrency: run.concurrency,
|
|
250
|
+
laneOutcomes: run.laneOutcomes,
|
|
251
|
+
droppedLaneCount: run.droppedLaneCount,
|
|
252
|
+
});
|
|
253
|
+
if (run.laneRecords instanceof Map) {
|
|
254
|
+
for (const [callId, record] of run.laneRecords) {
|
|
255
|
+
if (laneProjectionNeedsFlush(run, callId, record)) await writeLaneProjection(run, callId, record);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!ACTIVE_STATUSES.has(state.status) || state.status === "awaiting-diff-approval" || state.status === "review-required") {
|
|
259
|
+
const closeout = {
|
|
260
|
+
runId: run.id,
|
|
261
|
+
status: state.status,
|
|
262
|
+
finishedAt: state.finishedAt,
|
|
263
|
+
resultPath: state.resultPath,
|
|
264
|
+
errorSummary: state.error ? truncateText(state.error, MAX_STATUS_STRING_CHARS) : undefined,
|
|
265
|
+
operatorMetrics: state.operatorMetrics,
|
|
266
|
+
durability: state.durability,
|
|
267
|
+
};
|
|
268
|
+
await writeJsonAtomic(path.join(run.dir, "closeout.json"), closeout);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function readLaneProjections(runDir, state = {}) {
|
|
273
|
+
const fallback = Array.isArray(state?.laneRecords) ? state.laneRecords.filter((record) => record && record.callId) : [];
|
|
274
|
+
try {
|
|
275
|
+
const lanesDir = path.join(runDir, "lanes");
|
|
276
|
+
const dirents = await fs.readdir(lanesDir, { withFileTypes: true });
|
|
277
|
+
const records = [];
|
|
278
|
+
for (const dirent of dirents) {
|
|
279
|
+
if (!dirent.isFile() || !dirent.name.endsWith(".json")) continue;
|
|
280
|
+
// Skip durable per-lane checkpoint files (lanes/<callId>.request.json /
|
|
281
|
+
// lanes/<callId>.result.json, 234m.1). They share the .json suffix but are crash-window
|
|
282
|
+
// captures, not lane projections: including them would duplicate callIds and pollute the
|
|
283
|
+
// salvage-candidate scan + status lane list.
|
|
284
|
+
if (dirent.name.endsWith(".request.json") || dirent.name.endsWith(".result.json")) continue;
|
|
285
|
+
const record = await readJsonFile(path.join(lanesDir, dirent.name), null);
|
|
286
|
+
if (record && record.callId) records.push(record);
|
|
287
|
+
}
|
|
288
|
+
if (records.length === 0) return fallback;
|
|
289
|
+
const seen = new Set(records.map((record) => record.callId));
|
|
290
|
+
for (const record of fallback) if (!seen.has(record.callId)) records.push(record);
|
|
291
|
+
return records;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error.code === "ENOENT") return fallback;
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Durable per-lane checkpoint files written around session.prompt (workflow-native lane
|
|
299
|
+
// checkpointing, 234m.1). `lanes/<callId>.request.json` captures prompt-time intent
|
|
300
|
+
// (callId/signatureHash/prompt+schema hashes/childID) before the prompt is issued;
|
|
301
|
+
// `lanes/<callId>.result.json` captures the controller's validated result immediately after the
|
|
302
|
+
// prompt returns and before integration/journal append. On resume, a same-signature result
|
|
303
|
+
// checkpoint lets runChildAgent recover a completed result from its OWN store without re-running
|
|
304
|
+
// the lane or reading any transcript -- the transcript-independent recovery path that makes
|
|
305
|
+
// salvage (234m.3) rarely necessary. journal.jsonl remains authoritative; these files are an
|
|
306
|
+
// earlier, narrower durable capture that narrows the crash window between prompt-return and
|
|
307
|
+
// recordLaneOutcome, and they are removed once the journal entry supersedes them. Checkpoint
|
|
308
|
+
// writes are best-effort (callers try/catch): a checkpoint failure must never break a run.
|
|
309
|
+
//
|
|
310
|
+
// File naming mirrors writeLaneProjection: the callId is passed through safeProjectionName so a
|
|
311
|
+
// callId containing "/" or ":" (parallel/*, drain:...) becomes a safe single filename rather
|
|
312
|
+
// than a nested path. The `<kind>` suffix (request|result) keeps checkpoints distinct from the
|
|
313
|
+
// authoritative `lanes/<callId>.json` projection; readLaneProjections explicitly skips these
|
|
314
|
+
// suffixes so checkpoints never pollute the lane-projection list or salvage-candidate scan.
|
|
315
|
+
async function writeLaneCheckpoint(runDir, callId, kind, value) {
|
|
316
|
+
if (!runDir || !callId) throw new Error("writeLaneCheckpoint requires runDir and callId");
|
|
317
|
+
if (kind !== "request" && kind !== "result") {
|
|
318
|
+
throw new Error(`writeLaneCheckpoint kind must be "request" or "result", got ${String(kind)}`);
|
|
319
|
+
}
|
|
320
|
+
const lanesDir = path.join(runDir, "lanes");
|
|
321
|
+
await ensurePrivateDir(lanesDir);
|
|
322
|
+
const filePath = path.join(lanesDir, `${safeProjectionName(callId)}.${kind}.json`);
|
|
323
|
+
await writeJsonAtomic(filePath, redactDurableValue(value));
|
|
324
|
+
return filePath;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Reads the resume-relevant result checkpoint. Returns the parsed record when the file exists and
|
|
328
|
+
// carries the expected callId, otherwise null (ENOENT / unparseable / callId mismatch). Never
|
|
329
|
+
// throws: the resume branch consults this in the hot path, so any read failure is treated as
|
|
330
|
+
// "no checkpoint" and the lane falls through to the authoritative journal check / re-run. Does
|
|
331
|
+
// NOT validate the signature; the resume branch compares checkpoint.signatureHash to the lane's
|
|
332
|
+
// expected signature so a stale/mismatched checkpoint is ignored rather than trusted.
|
|
333
|
+
async function readLaneResultCheckpoint(runDir, callId) {
|
|
334
|
+
try {
|
|
335
|
+
if (!runDir || !callId) return null;
|
|
336
|
+
const filePath = path.join(runDir, "lanes", `${safeProjectionName(callId)}.result.json`);
|
|
337
|
+
const record = await readJsonFile(filePath, null);
|
|
338
|
+
return record && record.callId === callId ? record : null;
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function readLaneRequestCheckpoint(runDir, callId) {
|
|
345
|
+
try {
|
|
346
|
+
if (!runDir || !callId) return null;
|
|
347
|
+
const filePath = path.join(runDir, "lanes", `${safeProjectionName(callId)}.request.json`);
|
|
348
|
+
const record = await readJsonFile(filePath, null);
|
|
349
|
+
return record && record.callId === callId ? record : null;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Best-effort removal of a checkpoint file once the journal entry has superseded it (the journal
|
|
356
|
+
// is authoritative). Swallows ENOENT and all other errors: a lingering checkpoint is a benign
|
|
357
|
+
// degradation (on the next resume it would emit cache.checkpoint_hit instead of cache.hit for that
|
|
358
|
+
// one lane), so a removal failure must never break a run.
|
|
359
|
+
async function removeLaneCheckpoint(runDir, callId, kind) {
|
|
360
|
+
try {
|
|
361
|
+
if (!runDir || !callId) return;
|
|
362
|
+
if (kind !== "request" && kind !== "result") return;
|
|
363
|
+
const filePath = path.join(runDir, "lanes", `${safeProjectionName(callId)}.${kind}.json`);
|
|
364
|
+
await fs.rm(filePath, { force: true });
|
|
365
|
+
} catch {
|
|
366
|
+
// Best-effort: a removal failure leaves a narrow-window artifact that the resume path ignores.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Salvage candidates are mid-flight lanes (status "running" with a childID) whose owning
|
|
371
|
+
// process died before recording a success journal entry. The child transcript may still
|
|
372
|
+
// hold the lane result, so read-only status names each orphan and emits a session_read
|
|
373
|
+
// hint. Computed purely from durable projections + journal; no mutation, no resume effect.
|
|
374
|
+
async function computeSalvageCandidates(runDir, state = {}) {
|
|
375
|
+
const lanes = await readLaneProjections(runDir, state);
|
|
376
|
+
if (lanes.length === 0) return [];
|
|
377
|
+
const journal = await loadJournal(runDir);
|
|
378
|
+
const candidates = [];
|
|
379
|
+
for (const lane of lanes) {
|
|
380
|
+
const callId = lane?.callId;
|
|
381
|
+
const childID = lane?.childID;
|
|
382
|
+
if (!callId || !childID || lane.status !== "running") continue;
|
|
383
|
+
const completed = journal.get(callId);
|
|
384
|
+
if (completed && completed.outcome === "success") continue;
|
|
385
|
+
candidates.push({
|
|
386
|
+
callId,
|
|
387
|
+
childID,
|
|
388
|
+
hint: `possible transcript evidence: session_read({ sessionId: "${childID}" })`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return candidates;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Strictly read-only: attaches a best-effort salvageCandidates array to interrupted/stale-active
|
|
395
|
+
// entries only. Never mutates files or run state; never changes resume behavior.
|
|
396
|
+
async function attachSalvageCandidates(entry) {
|
|
397
|
+
if (entry?.kind !== "valid") return entry;
|
|
398
|
+
const status = entry.state?.status;
|
|
399
|
+
if (status !== "interrupted" && status !== "stale-active") return entry;
|
|
400
|
+
try {
|
|
401
|
+
entry.salvageCandidates = await computeSalvageCandidates(entry.dir, entry.state);
|
|
402
|
+
} catch {
|
|
403
|
+
entry.salvageCandidates = [];
|
|
404
|
+
}
|
|
405
|
+
return entry;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export {
|
|
409
|
+
recordLaneOutcome,
|
|
410
|
+
writeSalvagedLaneOutcome,
|
|
411
|
+
recoverySummary,
|
|
412
|
+
writeLaneProjection,
|
|
413
|
+
writeDurableProjections,
|
|
414
|
+
readLaneProjections,
|
|
415
|
+
writeLaneCheckpoint,
|
|
416
|
+
readLaneRequestCheckpoint,
|
|
417
|
+
readLaneResultCheckpoint,
|
|
418
|
+
removeLaneCheckpoint,
|
|
419
|
+
computeSalvageCandidates,
|
|
420
|
+
attachSalvageCandidates,
|
|
421
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Concern (4): run rehydration from prior on-disk state. Seeds a freshly constructed
|
|
2
|
+
// RunContext from the prior segment's persisted state.json on resume — folding prior spend
|
|
3
|
+
// into the replayed counters (never re-adding it), restoring agent/budget/cache counters,
|
|
4
|
+
// lane outcomes, and the edit/integration plan + worktree records. Extracted from
|
|
5
|
+
// run-store-status.js (opencode-workflows-nbp).
|
|
6
|
+
//
|
|
7
|
+
// This is the inverse of writeState (run-store-state.js): it reads the persisted snapshot
|
|
8
|
+
// and writes the subset of RunContext that survives across segments. See
|
|
9
|
+
// {@link import("./run-context.js").RunContext}.
|
|
10
|
+
|
|
11
|
+
import { LANE_OUTCOMES } from "./constants.js";
|
|
12
|
+
import { addTokens, copyTokenTotals, normalizeBudgetCeilings, zeroTokens } from "./budget-accounting.js";
|
|
13
|
+
import { normalizeRecentLogs } from "./run-observability.js";
|
|
14
|
+
|
|
15
|
+
function rehydrateRunFromPriorState(run, prior) {
|
|
16
|
+
if (!prior || typeof prior !== "object") return run;
|
|
17
|
+
if (Number.isInteger(prior.agentsStarted) && prior.agentsStarted >= 0) run.agentsStarted = prior.agentsStarted;
|
|
18
|
+
if (Number.isInteger(prior.maxAgents) && prior.maxAgents > 0) run.maxAgents = prior.maxAgents;
|
|
19
|
+
if (prior.budgetCeilings && typeof prior.budgetCeilings === "object") run.budgetCeilings = normalizeBudgetCeilings(prior.budgetCeilings);
|
|
20
|
+
if (prior.authority && typeof prior.authority === "object") run.authority = prior.authority;
|
|
21
|
+
if (prior.laneOutcomes && typeof prior.laneOutcomes === "object") {
|
|
22
|
+
for (const outcome of LANE_OUTCOMES) {
|
|
23
|
+
if (Number.isFinite(prior.laneOutcomes[outcome])) run.laneOutcomes[outcome] = prior.laneOutcomes[outcome];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (Number.isFinite(prior.droppedLaneCount)) run.droppedLaneCount = prior.droppedLaneCount;
|
|
27
|
+
if (typeof prior.startedAt === "string") run.startedAt = prior.startedAt;
|
|
28
|
+
if (prior.debugCapture && typeof prior.debugCapture === "object") run.debugCapture = prior.debugCapture;
|
|
29
|
+
if (typeof prior.firstResultAt === "string") run.firstResultAt = prior.firstResultAt;
|
|
30
|
+
if (Number.isFinite(prior.timeToFirstResultMs)) run.timeToFirstResultMs = prior.timeToFirstResultMs;
|
|
31
|
+
if (prior.approvalWait && typeof prior.approvalWait === "object") run.approvalWait = prior.approvalWait;
|
|
32
|
+
run.recentLogs = normalizeRecentLogs(prior.recentLogs);
|
|
33
|
+
if (typeof prior.currentPhase === "string") run.currentPhase = prior.currentPhase;
|
|
34
|
+
if (typeof prior.resultPath === "string") run.resultPath = prior.resultPath;
|
|
35
|
+
// All historical spend (prior live + prior replayed) is folded into the replayed
|
|
36
|
+
// counters and the live counters start at zero for this segment, mirroring the
|
|
37
|
+
// agentsStarted replay model (carry the aggregate forward authoritatively; do NOT
|
|
38
|
+
// let replay re-add it). Copying prior.tokens/prior.cost into run.tokens/run.cost
|
|
39
|
+
// here AND re-accumulating the same spend as cache-hit lanes replay would double-count
|
|
40
|
+
// prior spend (R9), tripping budget ceilings at a fraction of the configured limit.
|
|
41
|
+
const priorLiveTokens = zeroTokens();
|
|
42
|
+
copyTokenTotals(priorLiveTokens, prior.tokens);
|
|
43
|
+
const priorReplayedTokens = zeroTokens();
|
|
44
|
+
copyTokenTotals(priorReplayedTokens, prior.replayedTokens);
|
|
45
|
+
run.replayedTokens = addTokens(priorLiveTokens, priorReplayedTokens);
|
|
46
|
+
run.tokens = zeroTokens();
|
|
47
|
+
const priorLiveCost = Number.isFinite(prior.cost) ? prior.cost : 0;
|
|
48
|
+
const priorReplayedCost = Number.isFinite(prior.replayedCost) ? prior.replayedCost : 0;
|
|
49
|
+
run.replayedCost = priorLiveCost + priorReplayedCost;
|
|
50
|
+
run.cost = 0;
|
|
51
|
+
if (prior.cacheStats && typeof prior.cacheStats === "object") {
|
|
52
|
+
for (const key of ["hits", "misses", "invalidated"]) {
|
|
53
|
+
if (Number.isFinite(prior.cacheStats[key])) run.cacheStats[key] = prior.cacheStats[key];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(prior.editWorktrees)) run.editWorktrees = prior.editWorktrees;
|
|
57
|
+
if (Array.isArray(prior.integrationWorktrees)) run.integrationWorktrees = prior.integrationWorktrees;
|
|
58
|
+
if (prior.editPlan && typeof prior.editPlan === "object") run.editPlan = prior.editPlan;
|
|
59
|
+
if (prior.integrationPlan && typeof prior.integrationPlan === "object") run.integrationPlan = prior.integrationPlan;
|
|
60
|
+
if (prior.closeout && typeof prior.closeout === "object") run.closeout = prior.closeout;
|
|
61
|
+
if (prior.recovery && typeof prior.recovery === "object") run.recovery = prior.recovery;
|
|
62
|
+
if (Array.isArray(prior.laneRecords)) {
|
|
63
|
+
run.laneRecords = new Map(prior.laneRecords.filter((record) => record?.callId).map((record) => [record.callId, record]));
|
|
64
|
+
}
|
|
65
|
+
return run;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { rehydrateRunFromPriorState };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Concern (1): durable state writes. Serializes the live RunContext into the authoritative
|
|
2
|
+
// state.json snapshot (plus the diff/integration-plan envelope) and fans the matching
|
|
3
|
+
// durable projections out via writeDurableProjections. Extracted from run-store-status.js
|
|
4
|
+
// (opencode-workflows-nbp).
|
|
5
|
+
//
|
|
6
|
+
// writeState reads the broadest slice of RunContext of any run-store concern — effectively
|
|
7
|
+
// the whole serializable surface (identity, lifecycle, budget/tokens, lane outcomes,
|
|
8
|
+
// capabilities/diagnostics, edit/integration plans, lifecycle/notification/background) —
|
|
9
|
+
// and writes state.json. See {@link import("./run-context.js").RunContext}.
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
DURABLE_STATE_VERSION,
|
|
14
|
+
MAX_STATUS_STRING_CHARS,
|
|
15
|
+
} from "./constants.js";
|
|
16
|
+
import { truncateText } from "./text-json.js";
|
|
17
|
+
import { durableLedgerSummary } from "./event-journal.js";
|
|
18
|
+
import { computeDiffPlanHash } from "./approval-hashing.js";
|
|
19
|
+
import { selfProcessStartTime, writeJsonAtomic } from "./run-store-fs.js";
|
|
20
|
+
import { writeDurableProjections } from "./run-store-projections.js";
|
|
21
|
+
import { normalizeRecentLogs } from "./run-observability.js";
|
|
22
|
+
|
|
23
|
+
const stateWriteChains = new WeakMap();
|
|
24
|
+
let writeStateTestHook;
|
|
25
|
+
|
|
26
|
+
// lastProgressAt (opencode-workflows-jbs3.8): the most recent moment any lane demonstrably
|
|
27
|
+
// changed state. Agents read this from the compact status to detect a wedged/no-progress run
|
|
28
|
+
// (now - lastProgressAt > a documented threshold) WITHOUT pulling detail=full. Derived from the
|
|
29
|
+
// per-lane records' updatedAt/completedAt/startedAt timestamps — writeLaneProjection stamps
|
|
30
|
+
// updatedAt on every lane state change, so any lane transition advances this clock — with the
|
|
31
|
+
// run's own resume/start time as the floor so a freshly launched (or just-resumed) run that has
|
|
32
|
+
// no lane activity yet still reports a meaningful baseline instead of null. All timestamps are
|
|
33
|
+
// ISO-8601 UTC ("…Z"), which sort correctly lexicographically, so a string max is sufficient.
|
|
34
|
+
function laneRecordsArray(run) {
|
|
35
|
+
if (run.laneRecords instanceof Map) return [...run.laneRecords.values()];
|
|
36
|
+
if (Array.isArray(run.laneRecords)) return run.laneRecords;
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function computeLastProgressAt(run) {
|
|
41
|
+
let latest = null;
|
|
42
|
+
const consider = (ts) => {
|
|
43
|
+
if (typeof ts === "string" && (latest === null || ts > latest)) latest = ts;
|
|
44
|
+
};
|
|
45
|
+
consider(run.startedAt);
|
|
46
|
+
consider(run.resumedAt);
|
|
47
|
+
consider(run.lastEventAt);
|
|
48
|
+
for (const record of laneRecordsArray(run)) {
|
|
49
|
+
consider(record?.lastActivityAt ?? record?.updatedAt ?? record?.completedAt ?? record?.startedAt);
|
|
50
|
+
}
|
|
51
|
+
return latest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function doWriteState(run) {
|
|
55
|
+
const durability = {
|
|
56
|
+
stateVersion: DURABLE_STATE_VERSION,
|
|
57
|
+
ledgers: await durableLedgerSummary(run.dir),
|
|
58
|
+
projections: ["state.json", "events.jsonl", "journal.jsonl", "worktrees.json", "waves/index.json", "lanes/"],
|
|
59
|
+
recovery: run.recovery,
|
|
60
|
+
};
|
|
61
|
+
const state = {
|
|
62
|
+
stateVersion: DURABLE_STATE_VERSION,
|
|
63
|
+
id: run.id,
|
|
64
|
+
status: run.status,
|
|
65
|
+
sourcePath: run.sourcePath,
|
|
66
|
+
sourceHash: run.sourceHash,
|
|
67
|
+
meta: run.meta,
|
|
68
|
+
authority: run.authority,
|
|
69
|
+
argsPreview: run.argsPreview,
|
|
70
|
+
startedAt: run.startedAt,
|
|
71
|
+
resumedAt: run.resumedAt,
|
|
72
|
+
finishedAt: run.finishedAt,
|
|
73
|
+
lastEventAt: run.lastEventAt,
|
|
74
|
+
lastEventType: run.lastEventType,
|
|
75
|
+
lastProgressAt: computeLastProgressAt(run),
|
|
76
|
+
debugCapture: run.debugCapture,
|
|
77
|
+
firstResultAt: run.firstResultAt,
|
|
78
|
+
timeToFirstResultMs: run.timeToFirstResultMs,
|
|
79
|
+
approvalWait: run.approvalWait,
|
|
80
|
+
recentLogs: normalizeRecentLogs(run.recentLogs),
|
|
81
|
+
operatorMetrics: {
|
|
82
|
+
timeToFirstResultMs: Number.isFinite(run.timeToFirstResultMs) ? run.timeToFirstResultMs : null,
|
|
83
|
+
firstResultAt: run.firstResultAt ?? null,
|
|
84
|
+
approvalWaitMs: Number.isFinite(run.approvalWait?.durationMs) ? run.approvalWait.durationMs : null,
|
|
85
|
+
awaitingDiffApprovalAt: run.approvalWait?.startedAt ?? null,
|
|
86
|
+
appliedAt: run.approvalWait?.completedAt ?? null,
|
|
87
|
+
},
|
|
88
|
+
currentPhase: run.currentPhase,
|
|
89
|
+
agentsStarted: run.agentsStarted,
|
|
90
|
+
maxAgents: run.maxAgents,
|
|
91
|
+
concurrency: run.concurrency,
|
|
92
|
+
laneTimeoutMs: run.laneTimeoutMs,
|
|
93
|
+
maxRuntimeMs: run.maxRuntimeMs,
|
|
94
|
+
defaultChildModel: run.defaultChildModel,
|
|
95
|
+
activeAgents: run.activeAgents,
|
|
96
|
+
queuedAgents: run.waitingAgents?.length ?? 0,
|
|
97
|
+
tokens: run.tokens,
|
|
98
|
+
replayedTokens: run.replayedTokens,
|
|
99
|
+
cost: run.cost,
|
|
100
|
+
replayedCost: run.replayedCost,
|
|
101
|
+
cacheStats: run.cacheStats,
|
|
102
|
+
budgetCeilings: run.budgetCeilings,
|
|
103
|
+
autoApproved: run.autoApproved,
|
|
104
|
+
laneOutcomes: run.laneOutcomes,
|
|
105
|
+
droppedLaneCount: run.droppedLaneCount,
|
|
106
|
+
capabilities: run.capabilities,
|
|
107
|
+
diagnostics: run.diagnostics,
|
|
108
|
+
durability,
|
|
109
|
+
laneRecords: run.laneRecords instanceof Map ? [...run.laneRecords.values()] : [],
|
|
110
|
+
closeout: run.closeout,
|
|
111
|
+
worktreeCleanup: run.worktreeCleanup,
|
|
112
|
+
lifecycleRequests: run.lifecycleRequests,
|
|
113
|
+
notification: run.notification,
|
|
114
|
+
background: run.background,
|
|
115
|
+
nestedSnapshots: run.nestedSnapshots ? [...new Map([...run.nestedSnapshots.values()].map((item) => [item.sourcePath, item])).values()].map(({ source, ...item }) => item) : [],
|
|
116
|
+
resultPath: run.resultPath,
|
|
117
|
+
error: run.error ? truncateText(run.error, MAX_STATUS_STRING_CHARS) : undefined,
|
|
118
|
+
process: { pid: process.pid, startTime: await selfProcessStartTime() },
|
|
119
|
+
};
|
|
120
|
+
if (run.editPlan) {
|
|
121
|
+
state.editWorktrees = run.editWorktrees;
|
|
122
|
+
state.editPlan = { ...run.editPlan, diffPlanHash: computeDiffPlanHash(run.editPlan), patchCount: run.editPlan.patches?.length ?? 0 };
|
|
123
|
+
}
|
|
124
|
+
if (run.integrationPlan) {
|
|
125
|
+
state.integrationWorktrees = run.integrationWorktrees;
|
|
126
|
+
state.integrationPlan = {
|
|
127
|
+
...run.integrationPlan,
|
|
128
|
+
patchCount: run.integrationPlan.patches?.length ?? 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
await writeStateTestHook?.({ phase: "beforeStateWrite", run, state });
|
|
132
|
+
await writeJsonAtomic(path.join(run.dir, "state.json"), state);
|
|
133
|
+
await writeDurableProjections(run, state);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function writeState(run) {
|
|
137
|
+
const previous = stateWriteChains.get(run) ?? Promise.resolve();
|
|
138
|
+
const current = previous.catch(() => {}).then(() => doWriteState(run));
|
|
139
|
+
stateWriteChains.set(run, current.catch(() => {}));
|
|
140
|
+
return await current;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function __setWriteStateTestHook(hook) {
|
|
144
|
+
writeStateTestHook = typeof hook === "function" ? hook : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { writeState, computeLastProgressAt, __setWriteStateTestHook };
|