fix(codegen): reconcile the tree wherever a codegen target is in the run - #398
Open
raphaelvigee wants to merge 2 commits into
Open
fix(codegen): reconcile the tree wherever a codegen target is in the run#398raphaelvigee wants to merge 2 commits into
raphaelvigee wants to merge 2 commits into
Conversation
copy outputs in the tree from dependency frames tooA `codegen = "copy"` target's output is not a tracked source: the file belongs to the target, and the provenance xattr it carries is the only thing that stops a later `glob()` from picking it up as a source *alongside* the target that produces it. That ownership is a property of the graph, not of how the user phrased the run — but both write-backs were gated on `is_top`, so a copy target reached as a dependency or inlined as a transparent-group member materialized and stamped nothing. The case that bites is the fully-cached one. When the dependent hits the cache, `inputs_result_exec` never runs, so the copy target is only ever *hashed* (`OutputMatcher::None`, via `meta`) and no frame touches the tree. A pre-existing unstamped file at that path — checked in, or generated before the target owned it — then stays visible to every glob that walks the package, and the tree quietly disagrees with the graph. Split the write-back into two passes: - `copy` runs on **every** frame that resolves the target (top-level, dependent, and the meta walk), single-flighted per addr per request on a new `mem_codegen_copy` cell so the tree is written once however many ways the target was reached. It fetches any copy group the calling frame did not itself ask for, since a dependent reads one group and the meta walk reads none — neither is a reason to leave the rest of the generated files out of the tree. Blobs that are cached but no longer obtainable skip with a `debug!`: nothing to write is not a reason to fail a build that asked for something else. - `in_place` keeps the top-level-only rule. It rewrites the user's own committed files, which stays the privilege of the target they asked for (`in_place_dep_is_not_written_back` pins it). `--frozen` becomes request-scoped (`RequestState::frozen`). Dependency frames resolve with `ResultOptions::default()`, so the flag on `ResultOptions` reaches only the top-level frame — without this, a frozen run would have started writing the tree from a dep. Frozen still writes nothing and still does its whole diff at the top-level frame; that behaviour is unchanged. Accepted costs, deliberately: - A fully-cached run now reads every copy codegen target's outputs and compares them against the tree, where before it did nothing. Writes are still skipped when the bytes match, so it is read-and-compare rather than rewrite, but it is new per-run work proportional to generated bytes. - Commands that resolve the graph rather than just query it (`heph inspect hashin/hashout`) now materialize a copy dep's files. `heph query` never resolves and is unaffected. - The write lands during `meta` for a cached build, so it can move the tree under a glob that has not been walked yet. Within one request that is stable (`cached_glob_walk` is keyed by `request_id`), and top-level write-back already had the same exposure across concurrently matched targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9pJTeJGLJva1b2ZCJGtgz
… run Follows the `copy` change in the parent commit and finishes the thought: the two codegen modes now obey one rule. What a codegen target says the tree should hold is a property of the graph, not of how the user phrased the run — so `heph run //pkg:consumer` applies the formatter it depends on, exactly as `heph run //pkg:fmt` does. The `is_top` gate it replaces was never justified in the commit that introduced it (bbee178, "fmt-like codegen"); every comment explaining it since was written after the fact. Its effect was that tree state depended on invocation phrasing, which is the same complaint the `copy` fix answered. The three gates travel together, so all three move: - The write-back itself, now one pass over both modes rather than two passes with different gates (`CodegenPass` is gone again). - `check_in_place_inputs_unchanged`, which now runs inside the write-back's single flight rather than beside it. It exists to protect that write, so it belongs with it: one write, one guard, whichever frame arrived first. - `maybe_store_fixpoint`, likewise — otherwise a dependency-reached formatter would write the tree and never record its fixpoint, and re-execute on every subsequent run. `--frozen` follows the write-back, and this is a real behaviour change: it now reports a codegen divergence reached through a *dependency*. It has to. Once dep frames materialize, a frozen check that only looked at top-level frames would pass while the real run rewrote the tree underneath it — a CI gate reporting clean on a dirty tree. Runs that were green may now fail, and where they do, the drift they name is real. `ExecuteOptions::{frozen, is_top}` are dead after this and are removed along with `inner_result_addr`'s `is_top` parameter. `is_top` stays in the `mem_result` key: the codegen write-back was its justification and no longer is, but collapsing the key changes single-flight behaviour for every target reached both ways, and that is a separate change with its own blast radius. The second variant hits the on-disk cache, so it costs a re-filter, not a re-execute. Known wart, pre-existing and now wider: a dependent's `@heph/fs` glob and the codegen target it depends on resolve concurrently in `inputs_result_meta`, and glob results are memoized per request — so whether the dependent hashes pre- or post-write-back bytes is an ordering race, self-correcting on the next run. Writing the tree during a run that is hashing the tree is the underlying issue; it wants a phase boundary, not a wider gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9pJTeJGLJva1b2ZCJGtgz
raphaelvigee
force-pushed
the
raphaelvigee/fix-codegen-copy-xattr
branch
from
August 17, 2026 07:49
6380e13 to
2a882b7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A codegen target's contract is about the tree:
copysays "this generated file lives here, and the provenance xattr keepsglob()off it";in_placesays "these committed files are the transform's output". Neither claim gets weaker because the run reached the target through a dependent or a group instead of naming it — that changes what the user typed, not what the graph says the tree should hold.Both write-backs were gated on
is_top, so it did.The case that bites
The fully-cached one. When the dependent hits the cache,
inputs_result_execnever runs, so the codegen target is only ever hashed (OutputMatcher::None, viameta) and no frame touches the tree. A pre-existing unstamped file at acopypath — checked in, or generated before the target owned it — then stays visible to every glob that walks the package, and the tree quietly disagrees with the graph.Running the target directly always worked; that path was verified against the real binary (fresh execute, cache hit, glob output, directory output) before anything was changed.
The change
One pass over both modes, on every frame that resolves the target — top-level, dependent, and the
metawalk — single-flighted per addr per request on a newmem_codegen_treecell, so the tree is written once however many frames offer it. It fetches any codegen group the calling frame did not ask for (a dependent reads one group, the meta walk reads none), so the result does not depend on which frame arrives first. Blobs that are cached but no longer obtainable skip with adebug!: nothing to write is not a reason to fail a build that asked for something else.The
is_topgate onin_placewas never justified in the commit that introduced it (bbee1786, "fmt-like codegen"); every comment explaining it since was written after the fact. Its effect was thatheph run //pkg:fmtformatted andheph run //pkg:consumer(which depends on fmt) did not.Three gates travel together, so all three moved:
check_in_place_inputs_unchanged, now inside the single flight rather than beside it — it exists to protect that write, so one write, one guard;maybe_store_fixpoint, likewise, or a dependency-reached formatter would write the tree and re-execute on every subsequent run.--frozen— a real behaviour change--frozenbecame request-scoped (RequestState::frozen) because dependency frames resolve withResultOptions::default()and would otherwise never see the flag. It now reports a codegen divergence reached through a dependency.It has to. Once dep frames materialize, a frozen check that only looked at top-level frames would pass while the real run rewrote the tree underneath it — a CI gate reporting clean on a dirty tree. Runs that were green may now fail, and where they do, the drift they name is real.
Accepted costs, deliberately
heph inspect hashin/hashout) now materialize codegen outputs.heph querynever resolves and is unaffected.@heph/fsglob and the codegen target it depends on resolve concurrently ininputs_result_meta, and glob results are memoized perrequest_id— so whether the dependent hashes pre- or post-write-back bytes is a race, self-correcting on the next run. Writing the tree during a run that is hashing the tree is the underlying issue; it wants a phase boundary, not a wider gate.Cleanup
ExecuteOptions::{frozen, is_top}are dead after this and are removed, along withinner_result_addr'sis_topparameter.is_topstays in themem_resultkey: the codegen write-back was its justification and no longer is, but collapsing the key changes single-flight behaviour for every target reached both ways — a separate change with its own blast radius. The second variant hits the on-disk cache, so it costs a re-filter, not a re-execute.Tests
copy_dep_is_written_back_and_stamped,copy_group_member_is_written_back_and_stamped— the two ways a frame losesis_top.copy_dep_stamps_a_preexisting_unstamped_file,writeback_stamps_a_preexisting_unstamped_file— the reported bug: the bytes already match, so there is nothing to write, only provenance to record.in_place_dep_is_written_back— replacesin_place_dep_is_not_written_back, which pinned the old rule.frozen_run_reports_a_dep_codegen_divergence_and_writes_nothing— the--frozenhalf.codegen_write_back_runs_off_the_runtime_workers— still witnesses theblocking::runhop, now through the single pass.Verified against the real binary: on a 2-hit / 0-miss cached run,
heph run //pkg:consumerwrites and stamps the copy dep's outputs and applies the in_place dep's transform (hello there→HELLO THERE);--frozenon the same run reports the divergence with its diff box and writes nothing.Local suites:
engine549,e2e127,plugingo-e2e --test codegen10/10 and--test lint4/4 (run with--test-threads=1; in parallel they exhaust this machine's disk and fail onos error 28).🤖 Generated with Claude Code
https://claude.ai/code/session_01W9pJTeJGLJva1b2ZCJGtgz