taskflow-core 0.1.8 → 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.
- package/README.md +80 -25
- package/dist/deterministic.d.ts +8 -0
- package/dist/deterministic.d.ts.map +1 -1
- package/dist/deterministic.js +23 -1
- package/dist/deterministic.js.map +1 -1
- package/dist/exec/driver.d.ts +47 -0
- package/dist/exec/driver.d.ts.map +1 -0
- package/dist/exec/driver.js +397 -0
- package/dist/exec/driver.js.map +1 -0
- package/dist/exec/events.d.ts +76 -0
- package/dist/exec/events.d.ts.map +1 -0
- package/dist/exec/events.js +108 -0
- package/dist/exec/events.js.map +1 -0
- package/dist/exec/fold.d.ts +45 -0
- package/dist/exec/fold.d.ts.map +1 -0
- package/dist/exec/fold.js +122 -0
- package/dist/exec/fold.js.map +1 -0
- package/dist/exec/index.d.ts +17 -0
- package/dist/exec/index.d.ts.map +1 -0
- package/dist/exec/index.js +17 -0
- package/dist/exec/index.js.map +1 -0
- package/dist/exec/kernel-policy.d.ts +35 -0
- package/dist/exec/kernel-policy.d.ts.map +1 -0
- package/dist/exec/kernel-policy.js +184 -0
- package/dist/exec/kernel-policy.js.map +1 -0
- package/dist/exec/step-kinds.d.ts +41 -0
- package/dist/exec/step-kinds.d.ts.map +1 -0
- package/dist/exec/step-kinds.js +574 -0
- package/dist/exec/step-kinds.js.map +1 -0
- package/dist/exec/step.d.ts +93 -0
- package/dist/exec/step.d.ts.map +1 -0
- package/dist/exec/step.js +436 -0
- package/dist/exec/step.js.map +1 -0
- package/dist/flowir/canonical-hash.d.ts +101 -0
- package/dist/flowir/canonical-hash.d.ts.map +1 -0
- package/dist/flowir/canonical-hash.js +219 -0
- package/dist/flowir/canonical-hash.js.map +1 -0
- package/dist/flowir/compile.d.ts +49 -0
- package/dist/flowir/compile.d.ts.map +1 -0
- package/dist/flowir/compile.js +219 -0
- package/dist/flowir/compile.js.map +1 -0
- package/dist/flowir/cond.d.ts +76 -0
- package/dist/flowir/cond.d.ts.map +1 -0
- package/dist/flowir/cond.js +214 -0
- package/dist/flowir/cond.js.map +1 -0
- package/dist/flowir/index.d.ts +20 -23
- package/dist/flowir/index.d.ts.map +1 -1
- package/dist/flowir/index.js +47 -35
- package/dist/flowir/index.js.map +1 -1
- package/dist/flowir/meta.d.ts +4 -4
- package/dist/flowir/meta.d.ts.map +1 -1
- package/dist/flowir/phasefp.d.ts.map +1 -1
- package/dist/flowir/phasefp.js +13 -1
- package/dist/flowir/phasefp.js.map +1 -1
- package/dist/flowir/schema.d.ts +260 -0
- package/dist/flowir/schema.d.ts.map +1 -0
- package/dist/flowir/schema.js +234 -0
- package/dist/flowir/schema.js.map +1 -0
- package/dist/flowir/translate.d.ts.map +1 -1
- package/dist/flowir/translate.js +3 -0
- package/dist/flowir/translate.js.map +1 -1
- package/dist/host/runner-types.d.ts +3 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/rates.d.ts +121 -0
- package/dist/rates.d.ts.map +1 -0
- package/dist/rates.js +187 -0
- package/dist/rates.js.map +1 -0
- package/dist/replay.d.ts +43 -50
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +629 -19
- package/dist/replay.js.map +1 -1
- package/dist/runner-core.d.ts +24 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +225 -32
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime/phases/approval.d.ts +23 -0
- package/dist/runtime/phases/approval.d.ts.map +1 -0
- package/dist/runtime/phases/approval.js +38 -0
- package/dist/runtime/phases/approval.js.map +1 -0
- package/dist/runtime/phases/expand.d.ts +29 -0
- package/dist/runtime/phases/expand.d.ts.map +1 -0
- package/dist/runtime/phases/expand.js +126 -0
- package/dist/runtime/phases/expand.js.map +1 -0
- package/dist/runtime/phases/parallel.d.ts +26 -0
- package/dist/runtime/phases/parallel.d.ts.map +1 -0
- package/dist/runtime/phases/parallel.js +15 -0
- package/dist/runtime/phases/parallel.js.map +1 -0
- package/dist/runtime/phases/race.d.ts +31 -0
- package/dist/runtime/phases/race.d.ts.map +1 -0
- package/dist/runtime/phases/race.js +220 -0
- package/dist/runtime/phases/race.js.map +1 -0
- package/dist/runtime/phases/script.d.ts +38 -0
- package/dist/runtime/phases/script.d.ts.map +1 -0
- package/dist/runtime/phases/script.js +139 -0
- package/dist/runtime/phases/script.js.map +1 -0
- package/dist/runtime.d.ts +29 -16
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +554 -292
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +36 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +86 -10
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +6 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/trace.d.ts +4 -1
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +33 -26
- package/dist/trace.js.map +1 -1
- package/package.json +5 -4
package/dist/replay.js
CHANGED
|
@@ -1,25 +1,635 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic replay —
|
|
2
|
+
* Deterministic replay — re-fold a recorded event log under alternate **decision
|
|
3
|
+
* knobs** without calling the model (RFC §7, S3).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
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
|