Repository navigation
feat: invalidate stale downstream outputs after fork changes #33
Description
Problem
When a fork changes an upstream tool or model output, the downstream recorded responses in the retained portion of the forked trace may no longer correspond to the modified execution path. Reusing those responses without clearly identifying their validity can make a fork misleading.
Concretely, in the current implementation:
ForkEnginekeeps every event withstep <= fork_pointand can override atool_resultorllm_responseanywhere in that prefix (seesrc/stepfork/fork/engine.pyandsrc/stepfork/fork/overrides.py). Events recorded after the overridden step but at or before the fork point are copied unchanged into the forked trace, even though their outputs were produced by the original upstream state.ReplaySessionbuilds its call sequence from whatever trace it is given and substitutes a recorded output whenever the next call matches by order, identity, and input fingerprint (seesrc/stepfork/replay/session.py). It has no concept of a parent trace, a fork point, or an applied override, and it never checks whether an upstream event was modified before reusing a downstream recorded output.- The only classification StepFork tracks today is
substitutedvsexecutedper call (MatchedCall.action/ExecutedCall.action). There is nostaleorinvalidstate, and no marker on events or in the manifest records that a recorded output was produced under an upstream state that a fork has since changed. docs/fork.mdstates this explicitly: fork currently "does not detect divergence" and "divergence detection requires replay integration (planned)".
In practice this means a fork that changes an early output can still silently reuse downstream recorded outputs (LLM responses, tool results, and the final output comparison) that were captured under the original upstream values. The replay may even pass, giving the false impression that the modified execution path was actually observed. When inputs do happen to change, StepFork raises a replay mismatch, but that is a symptom of the agent behaving differently, not a deliberate classification of which recorded outputs are still trustworthy.
Scope
- Investigate how StepFork currently identifies divergence between an original trace and a fork. Establish what signals already exist (fork provenance in
ForkInfo, override application, replay matching) and what is missing. - Define how downstream recorded outputs should be classified when an upstream change may invalidate them. At minimum the distinction between these three cases must become explicit wherever the implementation supports surfacing it:
- Reused recorded output - substituted from the recording, produced under the same upstream state that the current trace reflects.
- Potentially stale output - substituted from the recording, but an upstream tool or LLM output was changed (e.g. by a fork override) after it was recorded, so it is not evidence for the modified execution path.
- Freshly executed output - actually run during replay (live mode / live LLM in hybrid mode).
- Document the expected behavior when fresh execution is unavailable or disabled (frozen replay, no
allow_live_tools), so users know what a substituted stale output does and does not prove. - Preserve existing trace compatibility where practical. Any new metadata should be additive and optional; existing traces must continue to load, validate, and replay unchanged.
- Treat automatic re-execution and dependency-aware invalidation (computing exactly which downstream events an upstream change affects) as open design questions to investigate, not as requirements. Do not assume or claim that StepFork already performs either.
Keep this issue focused on replay correctness after divergence. It is separate from instrumentation coverage reporting (#30) and from retrieval evidence capture (#31).
Acceptance criteria
- Regression tests cover a fork that changes an early recorded output and then encounters downstream recorded calls during replay, exercising both the case where downstream inputs still match (stale output would be substituted silently today) and the case where they diverge.
- Potentially stale outputs cannot be mistaken for verified results from the modified execution path. Reuse of a recorded output after an upstream change is either flagged, classified, or refused, rather than indistinguishable from a normal substitution.
- The distinction between reused, potentially stale, and freshly executed outputs is clearly communicated in the user-facing surface that the design chooses (replay summary, CLI report, or documented API), where the implementation supports it.
- Behavior and limitations are documented, including what happens when fresh execution is unavailable.
- Existing traces and the existing
substituted/executedsemantics keep working; any format extension is additive and justified. - Existing tests are not weakened. New tests are offline and deterministic.
Testing
Add deterministic, offline regression tests for the fork-then-replay scenario described above, plus unit coverage for whatever classification or marker the design introduces. Run the focused tests and the full suite (uv run pytest), and confirm ruff check, ruff format --check, and mypy stay clean.
This needs a design decision before implementation. Please comment with a proposed approach (classification at fork time vs. at replay time, and how it is surfaced) before opening a PR.
Contributions are welcome.
Activity
how2how2how2-arch commentedon Oct 28, 2026
Read the code paths this issue names, on
mainafter #37. One correction to the framing first, then an answer to the question you asked.The blocker is upstream of replay.
ForkEngine.fork()builds a completeForkInfo, and then it is dropped:fork_trace()keeps onlyfork_point_step,fork_point_event_idandoverrides_appliedinForkResult, andTraceManifesthas no field to receive the rest (trace/manifest.py:extra="forbid",schema_version: Literal["0.1"];storage.build_manifest()fills the manifest from trace fields only). So a saved fork is not merely "unlabelled by replay" — it is indistinguishable from a plain recording. The parent run id, the fork point and the override set are discarded at the moment the bundle is written, and no replay-side change can recover them.Two details from that path:
overrides_appliedis a count, and the classification needs the set. "The output at step 7 was recorded under an upstream state that changed at step 2" requires knowing which steps were overridden; no arithmetic on a count produces that.ForkInfo.parent_trace_pathis a filesystem path, which is not portable inside a bundle. What a bundle can carry is a digest of the parent.Also worth noting:
OverrideRegistry.apply_override()preservesidandstepand replaces onlyoutput, so an overridden event is identical in identity to the event it replaced. Diffing parent against fork shows the value and nothing about why it differs.Proposal: persist at fork time, classify at replay time, as a pure function of the persisted data.
fork:in the manifest —parent_run_id,parent_trace_sha256,fork_point_step,fork_point_event_id,fork_timestamp, andoverrides: [{step, event_id, target, replaced_output_sha256}]. Digest rather than value: the overridden value is already in the event; what the bundle cannot otherwise name is which outputs were replaced, and the digest of the parent's original is what makes the record checkable rather than asserted.ForkResultkeepsoverrides_applied: int— nothing that reads it breaks — and gains the tuple.fork is Nonegivesrecorded, so every bundle written today replays exactly as it does now.Two axes, not three states.
MatchedCall.actionanswers how the value was obtained; evidence answers whether the value is evidence for this path. A call can beexecutedwhile its inputs were derived from a stale value — those two facts do not fit in one enum, and collapsing them means one of the two can never be reported. Keepactiona two-valuedLiteraland add a defaulted field beside it. (I have been bitten the other way round: one status cell holding two facts makes an unrelated timeout look like a refutation.)The error is one-sided, deliberately. With no dataflow, "after the earliest override" over-approximates dependency. Name the field so it says that: a value may depend on a changed upstream value, not was affected by one. The direction is the point — it can over-report stale and never under-report, which is what makes the label shippable before dependency analysis exists rather than a guess that occasionally blesses a stale output.
The forward-compat question you have to decide, not the PR.
extra="forbid"plusLiteral["0.1"]means "additive and optional" is still a versioned change: an older StepFork reading a new fork bundle will refuse it instead of ignoring the new key. Either bump to0.2with a loader that accepts both, or accept the break explicitly. I would rather the issue state which than have a PR imply one of the two.Where to surface it. Silent substitution is survivable on its own. What creates the false impression is the receipt saying
substitutedand the diff passing — that reads as "the modified path was observed". So evidence belongs on the receipt and diff verdict: if any compared output ispotentially-stale, the verdict says so and names the override steps responsible. Refusal does not have to be the default; keep the tool usable and make the claim narrow.Tests. I did not run the suite — git transport to github.com is failing from this machine right now, so treat the list below as a proposal, not a green run.
recordedandpotentially-stalerespectively. A fixture with only post-override calls cannot separate those two units, and a guard whose fixtures cannot separate the units it confuses will pass while measuring the wrong scope.forkkey loads and replays with everythingrecorded, so backward compatibility is a test rather than a claim.[]in a saved fork and assert the downstream classification flips. That proves the classifier reads the provenance instead of recomputing the answer from the events.ReplayMismatchError; the new axis must not absorb it.Not claiming: no re-execution, no dependency-aware invalidation, no "fork detects divergence".
docs/fork.mdkeeps its limitation line as written — the label describes what a value is evidence for, and says nothing about what ran.