taskflow-core 0.1.7 → 0.2.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 (126) hide show
  1. package/README.md +80 -25
  2. package/dist/agents.js.map +1 -1
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/compile.js.map +1 -1
  5. package/dist/context-store.js.map +1 -1
  6. package/dist/deterministic.d.ts +8 -0
  7. package/dist/deterministic.d.ts.map +1 -1
  8. package/dist/deterministic.js +23 -1
  9. package/dist/deterministic.js.map +1 -1
  10. package/dist/exec/driver.d.ts +47 -0
  11. package/dist/exec/driver.d.ts.map +1 -0
  12. package/dist/exec/driver.js +397 -0
  13. package/dist/exec/driver.js.map +1 -0
  14. package/dist/exec/events.d.ts +76 -0
  15. package/dist/exec/events.d.ts.map +1 -0
  16. package/dist/exec/events.js +108 -0
  17. package/dist/exec/events.js.map +1 -0
  18. package/dist/exec/fold.d.ts +45 -0
  19. package/dist/exec/fold.d.ts.map +1 -0
  20. package/dist/exec/fold.js +122 -0
  21. package/dist/exec/fold.js.map +1 -0
  22. package/dist/exec/index.d.ts +17 -0
  23. package/dist/exec/index.d.ts.map +1 -0
  24. package/dist/exec/index.js +17 -0
  25. package/dist/exec/index.js.map +1 -0
  26. package/dist/exec/kernel-policy.d.ts +35 -0
  27. package/dist/exec/kernel-policy.d.ts.map +1 -0
  28. package/dist/exec/kernel-policy.js +184 -0
  29. package/dist/exec/kernel-policy.js.map +1 -0
  30. package/dist/exec/step-kinds.d.ts +41 -0
  31. package/dist/exec/step-kinds.d.ts.map +1 -0
  32. package/dist/exec/step-kinds.js +574 -0
  33. package/dist/exec/step-kinds.js.map +1 -0
  34. package/dist/exec/step.d.ts +93 -0
  35. package/dist/exec/step.d.ts.map +1 -0
  36. package/dist/exec/step.js +436 -0
  37. package/dist/exec/step.js.map +1 -0
  38. package/dist/flowir/canonical-hash.d.ts +101 -0
  39. package/dist/flowir/canonical-hash.d.ts.map +1 -0
  40. package/dist/flowir/canonical-hash.js +219 -0
  41. package/dist/flowir/canonical-hash.js.map +1 -0
  42. package/dist/flowir/compile.d.ts +49 -0
  43. package/dist/flowir/compile.d.ts.map +1 -0
  44. package/dist/flowir/compile.js +219 -0
  45. package/dist/flowir/compile.js.map +1 -0
  46. package/dist/flowir/cond.d.ts +76 -0
  47. package/dist/flowir/cond.d.ts.map +1 -0
  48. package/dist/flowir/cond.js +214 -0
  49. package/dist/flowir/cond.js.map +1 -0
  50. package/dist/flowir/index.d.ts +20 -23
  51. package/dist/flowir/index.d.ts.map +1 -1
  52. package/dist/flowir/index.js +47 -35
  53. package/dist/flowir/index.js.map +1 -1
  54. package/dist/flowir/meta.d.ts +4 -4
  55. package/dist/flowir/meta.d.ts.map +1 -1
  56. package/dist/flowir/phasefp.d.ts.map +1 -1
  57. package/dist/flowir/phasefp.js +13 -1
  58. package/dist/flowir/phasefp.js.map +1 -1
  59. package/dist/flowir/schema.d.ts +260 -0
  60. package/dist/flowir/schema.d.ts.map +1 -0
  61. package/dist/flowir/schema.js +234 -0
  62. package/dist/flowir/schema.js.map +1 -0
  63. package/dist/flowir/translate.d.ts.map +1 -1
  64. package/dist/flowir/translate.js +3 -0
  65. package/dist/flowir/translate.js.map +1 -1
  66. package/dist/frontmatter.d.ts.map +1 -1
  67. package/dist/host/runner-types.d.ts +3 -0
  68. package/dist/host/runner-types.d.ts.map +1 -1
  69. package/dist/index.d.ts +2 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/interpolate.js.map +1 -1
  74. package/dist/peek.js.map +1 -1
  75. package/dist/rates.d.ts +121 -0
  76. package/dist/rates.d.ts.map +1 -0
  77. package/dist/rates.js +187 -0
  78. package/dist/rates.js.map +1 -0
  79. package/dist/reflexion.js.map +1 -1
  80. package/dist/replay.d.ts +43 -50
  81. package/dist/replay.d.ts.map +1 -1
  82. package/dist/replay.js +629 -19
  83. package/dist/replay.js.map +1 -1
  84. package/dist/runner-core.d.ts +24 -0
  85. package/dist/runner-core.d.ts.map +1 -1
  86. package/dist/runner-core.js +225 -32
  87. package/dist/runner-core.js.map +1 -1
  88. package/dist/runtime/phases/approval.d.ts +23 -0
  89. package/dist/runtime/phases/approval.d.ts.map +1 -0
  90. package/dist/runtime/phases/approval.js +38 -0
  91. package/dist/runtime/phases/approval.js.map +1 -0
  92. package/dist/runtime/phases/expand.d.ts +29 -0
  93. package/dist/runtime/phases/expand.d.ts.map +1 -0
  94. package/dist/runtime/phases/expand.js +126 -0
  95. package/dist/runtime/phases/expand.js.map +1 -0
  96. package/dist/runtime/phases/parallel.d.ts +26 -0
  97. package/dist/runtime/phases/parallel.d.ts.map +1 -0
  98. package/dist/runtime/phases/parallel.js +15 -0
  99. package/dist/runtime/phases/parallel.js.map +1 -0
  100. package/dist/runtime/phases/race.d.ts +31 -0
  101. package/dist/runtime/phases/race.d.ts.map +1 -0
  102. package/dist/runtime/phases/race.js +220 -0
  103. package/dist/runtime/phases/race.js.map +1 -0
  104. package/dist/runtime/phases/script.d.ts +38 -0
  105. package/dist/runtime/phases/script.d.ts.map +1 -0
  106. package/dist/runtime/phases/script.js +139 -0
  107. package/dist/runtime/phases/script.js.map +1 -0
  108. package/dist/runtime.d.ts +29 -16
  109. package/dist/runtime.d.ts.map +1 -1
  110. package/dist/runtime.js +554 -292
  111. package/dist/runtime.js.map +1 -1
  112. package/dist/schema.d.ts +44 -12
  113. package/dist/schema.d.ts.map +1 -1
  114. package/dist/schema.js +91 -10
  115. package/dist/schema.js.map +1 -1
  116. package/dist/scorers.d.ts.map +1 -1
  117. package/dist/scorers.js.map +1 -1
  118. package/dist/store.d.ts +6 -0
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js.map +1 -1
  121. package/dist/trace.d.ts +4 -1
  122. package/dist/trace.d.ts.map +1 -1
  123. package/dist/trace.js +33 -26
  124. package/dist/trace.js.map +1 -1
  125. package/dist/workspace.d.ts.map +1 -1
  126. package/package.json +5 -4
package/dist/replay.js CHANGED
@@ -1,25 +1,635 @@
1
1
  /**
2
- * Deterministic replay — **type stub + design contract (0.1.7)**.
2
+ * Deterministic replay — re-fold a recorded event log under alternate **decision
3
+ * knobs** without calling the model (RFC §7, S3).
3
4
  *
4
- * Replay re-evaluates a recorded run against changed **decision knobs** (gate
5
- * thresholds, budget caps, model route) by reading the event trace
6
- * (`trace.ts`) and re-applying deterministic decision logic — **never calling
7
- * `runTask`**. Zero tokens, offline. Because the DAG and its verdicts are data,
8
- * you can re-adjudicate them against recorded evidence.
5
+ * **Import graph guard (structural):** this module imports only pure packages
6
+ * (`exec/events`, `exec/fold`, `deterministic`, `scorers`). It must NEVER import
7
+ * `runtime.ts` or `exec/driver` / `exec/step` (those drag process-spawning).
9
8
  *
10
- * **0.1.7 ships the trace foundation + this type only.** The pure `replayRun()`
11
- * function and the `replay` action land in 0.2.0. The `ReplayDecision` type is
12
- * defined now so the trace schema and the diff-report contract are fixed before
13
- * any events are emitted — avoiding a breaking migration later.
9
+ * 0.1.7 shipped types only. 0.2.0 implements {@link replayRun}.
10
+ */
11
+ import { overBudget } from "./deterministic.js";
12
+ import { foldEvents } from "./exec/fold.js";
13
+ import { emptyUsage } from "./usage.js";
14
+ function sumUsage(run) {
15
+ const u = emptyUsage();
16
+ for (const p of Object.values(run.phases)) {
17
+ u.input += p.usage.input;
18
+ u.output += p.usage.output;
19
+ u.cacheRead += p.usage.cacheRead;
20
+ u.cacheWrite += p.usage.cacheWrite;
21
+ u.cost += p.usage.cost;
22
+ u.turns += p.usage.turns;
23
+ }
24
+ return u;
25
+ }
26
+ function gateScoreDecision(d) {
27
+ return d?.type === "gate-score" ? d : undefined;
28
+ }
29
+ /** Select the outer run from a legacy mixed-run trace without trusting append
30
+ * order. Nested buffers may flush before their parent's phase batch, so the
31
+ * first JSONL record can belong to a child. Prefer a unique temporal envelope,
32
+ * then explicit inner-flow markers and earliest start time. Lifecycle
33
+ * completeness is a validity gate, never evidence that a run is outer: a
34
+ * child can be complete while its parent trace is truncated. If no signal is
35
+ * decisive, return a deterministic candidate with `ambiguous:true`; replay
36
+ * will fail safe to needs-live-rerun. */
37
+ function selectOuterRun(events) {
38
+ const byRun = new Map();
39
+ for (const event of events) {
40
+ if (!event.runId)
41
+ continue;
42
+ byRun.set(event.runId, [...(byRun.get(event.runId) ?? []), event]);
43
+ }
44
+ if (byRun.size === 0)
45
+ return { ambiguous: false };
46
+ const groups = [...byRun].map(([runId, groupEvents]) => {
47
+ const starts = groupEvents.filter((e) => e.kind === "phase-start");
48
+ const ends = groupEvents.filter((e) => e.kind === "phase-end");
49
+ const openStarts = new Map();
50
+ let lifecycleValid = starts.length > 0;
51
+ for (const event of groupEvents) {
52
+ if (event.kind === "phase-start") {
53
+ openStarts.set(event.phaseId, [...(openStarts.get(event.phaseId) ?? []), event.ts]);
54
+ }
55
+ else if (event.kind === "phase-end") {
56
+ const queue = openStarts.get(event.phaseId) ?? [];
57
+ const startedAt = queue.shift();
58
+ if (startedAt === undefined || event.ts < startedAt)
59
+ lifecycleValid = false;
60
+ openStarts.set(event.phaseId, queue);
61
+ }
62
+ }
63
+ if ([...openStarts.values()].some((queue) => queue.length > 0))
64
+ lifecycleValid = false;
65
+ return {
66
+ runId,
67
+ minStart: Math.min(...(starts.length ? starts : groupEvents).map((e) => e.ts)),
68
+ maxEnd: Math.max(...(ends.length ? ends : groupEvents).map((e) => e.ts)),
69
+ completeLifecycle: lifecycleValid,
70
+ innerFlowMarkers: groupEvents.filter((e) => e.kind === "decision" && e.decision?.type === "unreplayable" && e.decision.reason === "inner-flow").length,
71
+ };
72
+ });
73
+ const selected = (group) => ({
74
+ runId: group.runId,
75
+ // A truncated selected run cannot support reuse even when its identity is
76
+ // otherwise obvious: missing phase-end means output/status may be partial.
77
+ ambiguous: !group.completeLifecycle,
78
+ });
79
+ if (groups.length === 1)
80
+ return selected(groups[0]);
81
+ const envelopes = groups.filter((g) => groups.every((other) => g.minStart <= other.minStart && g.maxEnd >= other.maxEnd));
82
+ if (envelopes.length === 1)
83
+ return selected(envelopes[0]);
84
+ const markerMax = Math.max(...groups.map((g) => g.innerFlowMarkers));
85
+ if (markerMax > 0) {
86
+ const marked = groups.filter((g) => g.innerFlowMarkers === markerMax);
87
+ if (marked.length === 1)
88
+ return selected(marked[0]);
89
+ }
90
+ const earliest = Math.min(...groups.map((g) => g.minStart));
91
+ const earliestGroups = groups.filter((g) => g.minStart === earliest);
92
+ if (earliestGroups.length === 1)
93
+ return selected(earliestGroups[0]);
94
+ return {
95
+ runId: [...groups].sort((a, b) => a.runId.localeCompare(b.runId))[0].runId,
96
+ ambiguous: true,
97
+ };
98
+ }
99
+ /**
100
+ * Re-evaluate a recorded event log under optional decision overrides.
101
+ *
102
+ * - **No overrides** → every completed phase is `"reused"` (consistency oracle).
103
+ * - **thresholds[phaseId]** → re-compare recorded `gate-score.combined` to the
104
+ * new threshold; emit `verdict-flipped` / `would-block` / `threshold-changed`.
105
+ * - **budgetMax*** → re-tally recorded usage; phases that would not have run
106
+ * under a tighter cap → `would-exceed-budget`.
107
+ * - **models / args** → currently `needs-live-rerun` for any phase (cannot
108
+ * re-judge quality offline without more instrumentation).
14
109
  *
15
- * The 0.2.0 `replayRun()` will import ONLY from pure modules:
16
- * `trace.ts` (read), `schema.ts` (topo sort/desugar),
17
- * `interpolate.ts` (condition re-eval), `deterministic.ts` (parseGateVerdict,
18
- * overBudget), `scorers.ts` (pure scorer re-run).
19
- * It must NOT import `runtime.ts` (which drags in the process-spawning runner)
20
- * — the "replay never spends a token" invariant is enforced structurally by the
21
- * import graph.
110
+ * Never calls a model. Never throws.
22
111
  */
23
- /** Sentinel: this module exports types only in 0.1.7. Implemented in 0.2.0. */
24
- export const REPLAY_NOT_YET_IMPLEMENTED = "replayRun() lands in 0.2.0; 0.1.7 ships the trace foundation + this type contract.";
112
+ export function replayRun(events, overrides = {}) {
113
+ // A trace file is scoped to one run. Older traces could contain nested-flow
114
+ // events with a different runId; identify the outer run defensively so phase
115
+ // ids/usages from children cannot corrupt the replay snapshot.
116
+ const selectedRun = selectOuterRun(events);
117
+ const rootRunId = selectedRun.runId;
118
+ const rootEvents = rootRunId ? events.filter((e) => e.runId === rootRunId) : [...events];
119
+ const baseline = foldEvents(rootEvents);
120
+ const baselineIncomplete = Object.values(baseline.phases).some((phase) => phase.status === "pending" ||
121
+ phase.status === "running" ||
122
+ (phase.startedAt !== undefined && phase.endedAt !== undefined && phase.endedAt < phase.startedAt));
123
+ const totalUsage = sumUsage(baseline);
124
+ const replayed = {
125
+ ...baseline,
126
+ phases: Object.fromEntries(Object.entries(baseline.phases).map(([id, p]) => [
127
+ id,
128
+ { ...p, usage: { ...p.usage }, decision: p.decision ? { ...p.decision } : undefined },
129
+ ])),
130
+ };
131
+ const byDecision = new Map();
132
+ let needsLiveRerun = false;
133
+ const hasModelOverride = overrides.models && Object.keys(overrides.models).length > 0;
134
+ const hasArgsOverride = overrides.args && Object.keys(overrides.args).length > 0;
135
+ const hasThresholdOverride = overrides.thresholds !== undefined && Object.keys(overrides.thresholds).length > 0;
136
+ const hasBudgetOverride = overrides.budgetMaxUSD !== undefined || overrides.budgetMaxTokens !== undefined;
137
+ const order = [];
138
+ const seen = new Set();
139
+ const deps = new Map();
140
+ const dependencyRecorded = new Set();
141
+ const phaseStart = new Map();
142
+ const decisionHistory = new Map();
143
+ const unreplayable = new Map();
144
+ for (const [eventIndex, e] of rootEvents.entries()) {
145
+ if (!seen.has(e.phaseId)) {
146
+ seen.add(e.phaseId);
147
+ order.push(e.phaseId);
148
+ }
149
+ if (e.kind === "phase-start") {
150
+ if (!phaseStart.has(e.phaseId))
151
+ phaseStart.set(e.phaseId, { ts: e.ts, index: eventIndex });
152
+ if (e.dependencies !== undefined) {
153
+ deps.set(e.phaseId, [...e.dependencies]);
154
+ dependencyRecorded.add(e.phaseId);
155
+ }
156
+ }
157
+ if (e.kind === "decision" && e.decision?.type === "unreplayable") {
158
+ unreplayable.set(e.phaseId, e.decision.reason);
159
+ }
160
+ if (e.kind === "decision" && e.decision) {
161
+ decisionHistory.set(e.phaseId, [...(decisionHistory.get(e.phaseId) ?? []), e.decision]);
162
+ }
163
+ }
164
+ for (const id of Object.keys(baseline.phases))
165
+ if (!seen.has(id))
166
+ order.push(id);
167
+ const lastGateScore = (phaseId) => {
168
+ const history = decisionHistory.get(phaseId) ?? [];
169
+ for (let i = history.length - 1; i >= 0; i--) {
170
+ const decision = history[i];
171
+ if (decision.type === "gate-score")
172
+ return decision;
173
+ }
174
+ return undefined;
175
+ };
176
+ const children = new Map();
177
+ for (const [id, parents] of deps) {
178
+ for (const parent of parents)
179
+ children.set(parent, [...(children.get(parent) ?? []), id]);
180
+ }
181
+ const descendants = (root) => {
182
+ const out = [];
183
+ const queue = [...(children.get(root) ?? [])];
184
+ const visited = new Set();
185
+ while (queue.length) {
186
+ const id = queue.shift();
187
+ if (visited.has(id))
188
+ continue;
189
+ visited.add(id);
190
+ out.push(id);
191
+ queue.push(...(children.get(id) ?? []));
192
+ }
193
+ return out;
194
+ };
195
+ const graphIds = Object.keys(baseline.phases);
196
+ const graphIdSet = new Set(graphIds);
197
+ const missingDependencyMetadata = graphIds.length > 1 && graphIds.some((id) => !dependencyRecorded.has(id));
198
+ let graphTopologyAmbiguous = missingDependencyMetadata;
199
+ const layerMemo = new Map();
200
+ const layerOf = (id, visiting = new Set()) => {
201
+ const memo = layerMemo.get(id);
202
+ if (memo !== undefined)
203
+ return memo;
204
+ if (!dependencyRecorded.has(id) || visiting.has(id)) {
205
+ graphTopologyAmbiguous = true;
206
+ return undefined;
207
+ }
208
+ const nextVisiting = new Set(visiting).add(id);
209
+ let layer = 0;
210
+ for (const parent of deps.get(id) ?? []) {
211
+ if (!graphIdSet.has(parent)) {
212
+ graphTopologyAmbiguous = true;
213
+ return undefined;
214
+ }
215
+ const parentLayer = layerOf(parent, nextVisiting);
216
+ if (parentLayer === undefined)
217
+ return undefined;
218
+ layer = Math.max(layer, parentLayer + 1);
219
+ }
220
+ layerMemo.set(id, layer);
221
+ return layer;
222
+ };
223
+ for (const id of graphIds)
224
+ layerOf(id);
225
+ const blockRoots = [];
226
+ const unblockRoots = [];
227
+ const liveRoots = [];
228
+ const markNeedsLive = (phaseId, reason, causedBy) => {
229
+ const phase = baseline.phases[phaseId];
230
+ if (!phase)
231
+ return;
232
+ const current = byDecision.get(phaseId)?.outcome;
233
+ // A phase deterministically prevented from running needs no model call, and
234
+ // a recorded failure remains a recorded failure unless another decision
235
+ // proves it would not have run.
236
+ if (current === "needs-live-rerun" || current === "would-skip" || current === "would-block" || current === "failed")
237
+ return;
238
+ needsLiveRerun = true;
239
+ byDecision.set(phaseId, {
240
+ phaseId,
241
+ outcome: "needs-live-rerun",
242
+ reason,
243
+ priorOutcome: phase.status,
244
+ replayedOutcome: "pending",
245
+ ...(causedBy?.length ? { causedBy } : {}),
246
+ });
247
+ replayed.phases[phaseId].status = "pending";
248
+ };
249
+ const forceNeedsLive = (phaseId, reason) => {
250
+ const phase = baseline.phases[phaseId];
251
+ if (!phase)
252
+ return;
253
+ needsLiveRerun = true;
254
+ byDecision.set(phaseId, {
255
+ phaseId,
256
+ outcome: "needs-live-rerun",
257
+ reason,
258
+ priorOutcome: phase.status,
259
+ replayedOutcome: "pending",
260
+ });
261
+ replayed.phases[phaseId].status = "pending";
262
+ };
263
+ const markWouldSkip = (phaseId, reason, causedBy) => {
264
+ const phase = baseline.phases[phaseId];
265
+ if (!phase)
266
+ return;
267
+ byDecision.set(phaseId, {
268
+ phaseId,
269
+ outcome: "would-skip",
270
+ reason,
271
+ priorOutcome: phase.status,
272
+ replayedOutcome: "skipped",
273
+ causedBy,
274
+ });
275
+ replayed.phases[phaseId].status = "skipped";
276
+ };
277
+ for (const phaseId of order) {
278
+ const phase = baseline.phases[phaseId];
279
+ if (!phase)
280
+ continue;
281
+ const prior = phase.status;
282
+ if (prior === "failed") {
283
+ byDecision.set(phaseId, {
284
+ phaseId,
285
+ outcome: "failed",
286
+ reason: phase.error ?? "recorded failure",
287
+ priorOutcome: prior,
288
+ replayedOutcome: prior,
289
+ });
290
+ continue;
291
+ }
292
+ const unrep = unreplayable.get(phaseId);
293
+ if (unrep) {
294
+ needsLiveRerun = true;
295
+ liveRoots.push(phaseId);
296
+ byDecision.set(phaseId, {
297
+ phaseId,
298
+ outcome: "needs-live-rerun",
299
+ reason: `recorded phase is unreplayable offline: ${unrep}`,
300
+ priorOutcome: prior,
301
+ replayedOutcome: "pending",
302
+ });
303
+ replayed.phases[phaseId].status = "pending";
304
+ continue;
305
+ }
306
+ if (hasModelOverride && overrides.models?.[phaseId]) {
307
+ needsLiveRerun = true;
308
+ liveRoots.push(phaseId);
309
+ byDecision.set(phaseId, {
310
+ phaseId,
311
+ outcome: "needs-live-rerun",
312
+ reason: `model override ${overrides.models[phaseId]} cannot be quality-replayed offline`,
313
+ priorOutcome: prior,
314
+ replayedOutcome: "pending",
315
+ });
316
+ replayed.phases[phaseId].status = "pending";
317
+ continue;
318
+ }
319
+ if (hasArgsOverride) {
320
+ // Args may change interpolated task text for any phase — conservative.
321
+ needsLiveRerun = true;
322
+ liveRoots.push(phaseId);
323
+ byDecision.set(phaseId, {
324
+ phaseId,
325
+ outcome: "needs-live-rerun",
326
+ reason: "args override may change interpolated task text",
327
+ priorOutcome: prior,
328
+ replayedOutcome: "pending",
329
+ });
330
+ replayed.phases[phaseId].status = "pending";
331
+ continue;
332
+ }
333
+ // FoldedPhase retains only the last decision. A later budget/cache marker
334
+ // must not erase the recorded gate score needed by threshold replay.
335
+ const score = lastGateScore(phaseId) ?? gateScoreDecision(phase.decision);
336
+ const newThreshold = overrides.thresholds?.[phaseId];
337
+ if (score && newThreshold !== undefined) {
338
+ const oldThreshold = score.threshold ?? 0.7;
339
+ const oldVerdict = score.verdict;
340
+ const newVerdict = score.combined >= newThreshold ? "pass" : "block";
341
+ replayed.phases[phaseId].decision = { ...score, threshold: newThreshold, verdict: newVerdict };
342
+ if (oldVerdict !== newVerdict) {
343
+ byDecision.set(phaseId, {
344
+ phaseId,
345
+ outcome: newVerdict === "block" ? "would-block" : "verdict-flipped",
346
+ reason: `threshold ${oldThreshold}→${newThreshold}; combined=${score.combined} → ${newVerdict}`,
347
+ priorOutcome: oldVerdict,
348
+ replayedOutcome: newVerdict,
349
+ });
350
+ replayed.phases[phaseId].status = newVerdict === "block" ? "blocked" : "done";
351
+ if (newVerdict === "block") {
352
+ blockRoots.push(phaseId);
353
+ }
354
+ else if (oldVerdict === "block") {
355
+ // Recorded descendants were skipped and therefore have no output to
356
+ // reuse. A BLOCK→PASS flip opens a previously unexecuted graph branch;
357
+ // every transitive consumer must run live.
358
+ unblockRoots.push(phaseId);
359
+ }
360
+ }
361
+ else if (oldThreshold !== newThreshold) {
362
+ byDecision.set(phaseId, {
363
+ phaseId,
364
+ outcome: "threshold-changed",
365
+ reason: `threshold ${oldThreshold}→${newThreshold}; verdict still ${oldVerdict}`,
366
+ priorOutcome: oldVerdict,
367
+ replayedOutcome: newVerdict,
368
+ });
369
+ }
370
+ else {
371
+ byDecision.set(phaseId, {
372
+ phaseId,
373
+ outcome: "reused",
374
+ reason: "gate-score unchanged under same threshold",
375
+ priorOutcome: prior,
376
+ replayedOutcome: prior,
377
+ });
378
+ }
379
+ continue;
380
+ }
381
+ byDecision.set(phaseId, {
382
+ phaseId,
383
+ outcome: "reused",
384
+ reason: "no applicable overrides; recorded outcome kept",
385
+ priorOutcome: prior,
386
+ replayedOutcome: prior,
387
+ });
388
+ }
389
+ // Runtime gate admission is global, not descendant-local. A PASS→BLOCK flip
390
+ // keeps siblings that had already started in the gate's layer, but prevents
391
+ // later admissions in that layer and every subsequent layer — including
392
+ // independent branches. If recorded timing/topology cannot prove admission,
393
+ // fail safe live rather than claiming a skip.
394
+ for (const root of blockRoots) {
395
+ const rootLayer = layerOf(root);
396
+ // Runtime sets gateBlocked only after executePhase returns. phase-end is the
397
+ // recorded scheduler-completion boundary; the earlier gate-score/verdict
398
+ // decision is not an admission cutoff.
399
+ const admissionCutoff = baseline.phases[root]?.endedAt;
400
+ for (const id of graphIds) {
401
+ if (id === root)
402
+ continue;
403
+ const candidateLayer = layerOf(id);
404
+ if (graphTopologyAmbiguous || rootLayer === undefined || candidateLayer === undefined || admissionCutoff === undefined) {
405
+ markNeedsLive(id, `gate '${root}' would block but recorded admission timing is incomplete`, [root]);
406
+ continue;
407
+ }
408
+ if (candidateLayer > rootLayer) {
409
+ markWouldSkip(id, `gate '${root}' would globally stop subsequent layers`, [root]);
410
+ continue;
411
+ }
412
+ if (candidateLayer === rootLayer) {
413
+ const startedAt = baseline.phases[id]?.startedAt;
414
+ if (startedAt === undefined) {
415
+ markNeedsLive(id, `gate '${root}' would block but sibling admission timing is missing`, [root]);
416
+ }
417
+ else if (startedAt >= admissionCutoff) {
418
+ // phase-end is the closest legacy trace boundary, but gateBlocked is
419
+ // assigned immediately after executePhase returns. Another worker may
420
+ // claim a same-layer slot in that microtask gap, so a start at or after
421
+ // phase-end is ambiguous without an explicit scheduler-admission event.
422
+ markNeedsLive(id, `gate '${root}' and sibling admission order cannot be proven`, [root]);
423
+ }
424
+ }
425
+ }
426
+ }
427
+ // The inverse gate flip revives every phase the runtime actually labelled as
428
+ // globally gate-blocked, including independent branches. For older traces
429
+ // without the error reason, a skipped graph descendant is the conservative
430
+ // fallback unless another explicit skip decision explains it.
431
+ for (const root of unblockRoots) {
432
+ const revived = [];
433
+ for (const id of graphIds) {
434
+ if (id === root)
435
+ continue;
436
+ const phase = baseline.phases[id];
437
+ const recordedGateSkip = phase?.status === "skipped" && /^gate blocked/i.test(phase.error ?? "");
438
+ if (!recordedGateSkip)
439
+ continue;
440
+ markNeedsLive(id, `gate '${root}' would now pass and make this recorded gate-skip reachable`, [root]);
441
+ revived.push(id);
442
+ }
443
+ for (const revivedRoot of revived) {
444
+ for (const id of descendants(revivedRoot)) {
445
+ markNeedsLive(id, `newly reachable phase '${revivedRoot}' requires a live result`, [revivedRoot]);
446
+ }
447
+ }
448
+ }
449
+ // A live-rerun root makes every transitive consumer non-replayable too: its
450
+ // interpolated input may change even when the consumer itself had no marker.
451
+ for (const root of liveRoots) {
452
+ for (const id of descendants(root)) {
453
+ markNeedsLive(id, `upstream phase '${root}' requires a live rerun`, [root]);
454
+ }
455
+ }
456
+ // Re-tally by recorded DAG layers, not flat append order. Runtime starts a
457
+ // whole dependency layer concurrently; a sibling already started when one
458
+ // completion crosses the cap cannot retrospectively become a budget skip.
459
+ // Within each layer, phase-start time (then append index) gives a stable cause
460
+ // for reporting. Missing/cyclic dependency metadata fails safe to live rerun
461
+ // instead of inventing a sequential schedule.
462
+ if (hasBudgetOverride) {
463
+ const ids = Object.keys(baseline.phases);
464
+ const idSet = new Set(ids);
465
+ let topologyAmbiguous = ids.length > 1 && ids.some((id) => !dependencyRecorded.has(id));
466
+ const indegree = new Map(ids.map((id) => [id, 0]));
467
+ const topoChildren = new Map();
468
+ for (const id of ids) {
469
+ for (const parent of deps.get(id) ?? []) {
470
+ if (!idSet.has(parent) || parent === id) {
471
+ topologyAmbiguous = true;
472
+ continue;
473
+ }
474
+ indegree.set(id, (indegree.get(id) ?? 0) + 1);
475
+ topoChildren.set(parent, [...(topoChildren.get(parent) ?? []), id]);
476
+ }
477
+ }
478
+ const startCompare = (a, b) => {
479
+ const sa = phaseStart.get(a);
480
+ const sb = phaseStart.get(b);
481
+ return (sa?.ts ?? Infinity) - (sb?.ts ?? Infinity) || (sa?.index ?? Infinity) - (sb?.index ?? Infinity) || a.localeCompare(b);
482
+ };
483
+ const layers = [];
484
+ let ready = ids.filter((id) => indegree.get(id) === 0).sort(startCompare);
485
+ let visited = 0;
486
+ while (ready.length > 0) {
487
+ const layer = ready;
488
+ layers.push(layer);
489
+ visited += layer.length;
490
+ const next = [];
491
+ for (const id of layer) {
492
+ for (const child of topoChildren.get(id) ?? []) {
493
+ const n = (indegree.get(child) ?? 0) - 1;
494
+ indegree.set(child, n);
495
+ if (n === 0)
496
+ next.push(child);
497
+ }
498
+ }
499
+ ready = next.sort(startCompare);
500
+ }
501
+ if (visited !== ids.length)
502
+ topologyAmbiguous = true;
503
+ if (topologyAmbiguous) {
504
+ for (const id of ids) {
505
+ markNeedsLive(id, "recorded dependency layers are incomplete; budget replay requires a live rerun");
506
+ }
507
+ }
508
+ else {
509
+ const knownUsage = [];
510
+ let budgetCause;
511
+ let uncertainRoots = [];
512
+ for (const layer of layers) {
513
+ if (budgetCause) {
514
+ for (const id of layer) {
515
+ markWouldSkip(id, `budget was exceeded after '${budgetCause}'`, [budgetCause]);
516
+ }
517
+ continue;
518
+ }
519
+ if (uncertainRoots.length > 0) {
520
+ for (const id of layer) {
521
+ if (replayed.phases[id]?.status !== "skipped") {
522
+ markNeedsLive(id, "earlier live-rerun spend makes the replay budget boundary uncertain", uncertainRoots);
523
+ }
524
+ }
525
+ continue;
526
+ }
527
+ const layerUncertain = [];
528
+ for (const phaseId of layer) {
529
+ const phase = baseline.phases[phaseId];
530
+ if (!phase || replayed.phases[phaseId]?.status === "skipped")
531
+ continue;
532
+ if (byDecision.get(phaseId)?.outcome === "needs-live-rerun") {
533
+ layerUncertain.push(phaseId);
534
+ continue;
535
+ }
536
+ knownUsage.push(phase.usage);
537
+ const input = {
538
+ usages: knownUsage,
539
+ maxUSD: overrides.budgetMaxUSD,
540
+ maxTokens: overrides.budgetMaxTokens,
541
+ };
542
+ if (!budgetCause && overBudget(input).over) {
543
+ budgetCause = phaseId;
544
+ const current = byDecision.get(phaseId)?.outcome;
545
+ if (current !== "would-block" && current !== "failed") {
546
+ byDecision.set(phaseId, {
547
+ phaseId,
548
+ outcome: "would-exceed-budget",
549
+ reason: "this phase's recorded spend crosses the replay budget cap",
550
+ priorOutcome: phase.status,
551
+ replayedOutcome: phase.status,
552
+ });
553
+ }
554
+ }
555
+ }
556
+ // All members of this layer were already admitted, so none is skipped
557
+ // even when one crossed the cap. Unknown model/arg spend only makes
558
+ // subsequent layers uncertain when no known lower bound already crossed.
559
+ if (!budgetCause && layerUncertain.length > 0)
560
+ uncertainRoots = layerUncertain;
561
+ }
562
+ }
563
+ }
564
+ // A looser replay budget can remove a recorded budget stop. Those skipped
565
+ // phases have no output to reuse: if the new budget analysis did not still
566
+ // deterministically skip them, they (and their consumers) require live work.
567
+ // Apply this after budget composition so it cannot overwrite a valid new
568
+ // would-skip from a tighter cap or an earlier gate.
569
+ if (hasBudgetOverride) {
570
+ const revivedBudgetSkips = [];
571
+ for (const id of graphIds) {
572
+ const phase = baseline.phases[id];
573
+ const recordedBudgetSkip = phase?.status === "skipped" &&
574
+ ((decisionHistory.get(id) ?? []).some((decision) => decision.type === "budget-hit") ||
575
+ /budget exceeded/i.test(phase.error ?? ""));
576
+ if (!recordedBudgetSkip || byDecision.get(id)?.outcome === "would-skip")
577
+ continue;
578
+ markNeedsLive(id, "replay budget makes a recorded budget-skipped phase reachable", [id]);
579
+ if (byDecision.get(id)?.outcome === "needs-live-rerun")
580
+ revivedBudgetSkips.push(id);
581
+ }
582
+ for (const root of revivedBudgetSkips) {
583
+ for (const id of descendants(root)) {
584
+ markNeedsLive(id, `newly reachable budget-skipped phase '${root}' requires a live result`, [root]);
585
+ }
586
+ }
587
+ }
588
+ // Legacy multi-phase traces omitted phase-start.dependencies. Any local knob
589
+ // whose effect can propagate through the graph is unsafe to localize against
590
+ // an invented empty graph. Preserve a threshold root's directly computable
591
+ // verdict only when it is the sole override; every other potentially related
592
+ // phase (or every phase for model/args/budget combinations) fails safe live.
593
+ const phaseIds = Object.keys(baseline.phases);
594
+ const hasGraphSensitiveOverride = hasThresholdOverride || hasModelOverride || hasArgsOverride || hasBudgetOverride;
595
+ if (missingDependencyMetadata && hasGraphSensitiveOverride) {
596
+ const thresholdRoots = new Set(Object.keys(overrides.thresholds ?? {}));
597
+ const singleThresholdOnly = hasThresholdOverride && thresholdRoots.size === 1 && !hasModelOverride && !hasArgsOverride && !hasBudgetOverride;
598
+ for (const id of phaseIds) {
599
+ const directlyReplayableThresholdRoot = singleThresholdOnly && thresholdRoots.has(id) && lastGateScore(id) !== undefined;
600
+ if (directlyReplayableThresholdRoot)
601
+ continue;
602
+ forceNeedsLive(id, "legacy trace lacks dependency metadata; refusing local override propagation against an empty graph");
603
+ }
604
+ }
605
+ if (selectedRun.ambiguous || baselineIncomplete) {
606
+ needsLiveRerun = true;
607
+ for (const id of order) {
608
+ const phase = baseline.phases[id];
609
+ if (!phase)
610
+ continue;
611
+ byDecision.set(id, {
612
+ phaseId: id,
613
+ outcome: "needs-live-rerun",
614
+ reason: "mixed trace run identity is ambiguous; refusing offline reuse",
615
+ priorOutcome: phase.status,
616
+ replayedOutcome: "pending",
617
+ });
618
+ replayed.phases[id].status = "pending";
619
+ }
620
+ }
621
+ // Derive the flag from the final composed decisions. A model override that is
622
+ // deterministically skipped by an earlier gate/budget no longer requires a
623
+ // live call after precedence has been resolved.
624
+ needsLiveRerun = [...byDecision.values()].some((d) => d.outcome === "needs-live-rerun");
625
+ return {
626
+ decisions: order.flatMap((id) => (byDecision.has(id) ? [byDecision.get(id)] : [])),
627
+ baseline,
628
+ replayed,
629
+ needsLiveRerun,
630
+ totalUsage,
631
+ };
632
+ }
633
+ /** @deprecated kept for 0.1.7 callers that checked the sentinel string */
634
+ export const REPLAY_NOT_YET_IMPLEMENTED = "replayRun() is implemented in 0.2.0; this sentinel remains for back-compat string checks.";
25
635
  //# sourceMappingURL=replay.js.map