@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. 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 };