Skip to content

fix(codegen): reconcile the tree wherever a codegen target is in the run - #398

Open
raphaelvigee wants to merge 2 commits into
masterfrom
raphaelvigee/fix-codegen-copy-xattr
Open

fix(codegen): reconcile the tree wherever a codegen target is in the run#398
raphaelvigee wants to merge 2 commits into
masterfrom
raphaelvigee/fix-codegen-copy-xattr

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Aug 15, 2026

Copy link
Copy Markdown
Member

A codegen target's contract is about the tree: copy says "this generated file lives here, and the provenance xattr keeps glob() off it"; in_place says "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_exec never runs, so the codegen target is only ever hashed (OutputMatcher::None, via meta) and no frame touches the tree. A pre-existing unstamped file at a copy 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.

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 meta walk — single-flighted per addr per request on a new mem_codegen_tree cell, 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 a debug!: nothing to write is not a reason to fail a build that asked for something else.

The is_top gate on in_place was 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 that heph run //pkg:fmt formatted and heph run //pkg:consumer (which depends on fmt) did not.

Three gates travel together, so all three moved:

  • the write-back itself;
  • 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

--frozen became request-scoped (RequestState::frozen) because dependency frames resolve with ResultOptions::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

  • A fully-cached run now reads every 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 codegen outputs. heph query never resolves and is unaffected.
  • Ordering 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_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 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 — 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 loses is_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 — replaces in_place_dep_is_not_written_back, which pinned the old rule.
  • frozen_run_reports_a_dep_codegen_divergence_and_writes_nothing — the --frozen half.
  • codegen_write_back_runs_off_the_runtime_workers — still witnesses the blocking::run hop, now through the single pass.

Verified against the real binary: on a 2-hit / 0-miss cached run, heph run //pkg:consumer writes and stamps the copy dep's outputs and applies the in_place dep's transform (hello thereHELLO THERE); --frozen on the same run reports the divergence with its diff box and writes nothing.

Local suites: engine 549, e2e 127, plugingo-e2e --test codegen 10/10 and --test lint 4/4 (run with --test-threads=1; in parallel they exhaust this machine's disk and fail on os error 28).

🤖 Generated with Claude Code

https://claude.ai/code/session_01W9pJTeJGLJva1b2ZCJGtgz

@raphaelvigee raphaelvigee changed the title fix(codegen): land copy outputs in the tree from dependency frames too fix(codegen): reconcile the tree wherever a codegen target is in the run Aug 16, 2026
raphaelvigee and others added 2 commits August 17, 2026 09:49
A `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
raphaelvigee force-pushed the raphaelvigee/fix-codegen-copy-xattr branch from 6380e13 to 2a882b7 Compare August 17, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant