Skip to content

feat(runtime): bounded execution — resource governor, task ledger, honest cancellation - #27

Open
m-szymanska wants to merge 22 commits into
mainfrom
agent/08-bounded-execution
Open

feat(runtime): bounded execution — resource governor, task ledger, honest cancellation#27
m-szymanska wants to merge 22 commits into
mainfrom
agent/08-bounded-execution

Conversation

@m-szymanska

Copy link
Copy Markdown
Member

Summary

Closes out the "smażonko" incident (2026-08-24: bare prview in vista saturating CPU at 208–365% across the process family). The train delivers four connected mechanisms:

  • Resource governor (src/governor/): weighted semaphore over available_parallelism — Heavy checks (cores.div_ceil(2), max 2 concurrent on a 14-core machine), Light = 1. Honest progress line Running: X (Ns) · Queued: Y. Child processes registered per process group so cancellation can kill whole groups.
  • Task ledger (src/ledger/): single source of truth for what actually ran — run/cached/skipped/not-applicable per tool, with substrate identity (target_sha + tree state), cache age in seconds, and provenance (origin) for cached entries. Serialized into RUN.json; MERGE_GATE.json gains additive stale_cache_caveats (WARN-only, verdict structurally unchanged).
  • Context/gate dedup: context artifacts (tsc trace, eslint json, stylelint) adopt equivalent gate executions on the same substrate instead of re-running them; gate_coverage decisions (Covered/RuledOut/Uncovered) come from the ledger, replacing the inverted compensation booleans.
  • Cancellation contract: first Ctrl-C → governor.cancel(), controlled unwind, snapshot cleanup, exit 130 — never a verdict; second Ctrl-C → hard exit. Signal supervisor runs in its own task (App is !Sync via git2); generate() wrapped in blocking_stage. --watch and --update paths honor the same contract.

Validation

  • Gates on final HEAD: cargo fmt --check, clippy -D warnings, cargo test --lib1797 passed / 0 failed.
  • Two adversarial multi-agent review rounds (37 + 35 agents, refute-by-default skeptics) + an independent read-only verifier pass on the cancellation work; every confirmed finding fixed in-train (C13–C16), residuals named in the project backlog.
  • Live dogfood on the incident repo (vista, incident revision):
    • bare run: legal BLOCK (cargo-audit), bounded concurrency, no CPU storm;
    • real SIGINT mid-checks: clean ✗ Cancelled line, exit 130, zero orphaned processes, zero leftover snapshots;
    • --deep run: full scope (14 gates + complete 30_context/), process-family CPU ~4–7% where the incident burned 208–365%, ledger showing 10 cached (ages 839 s–51 032 s, origin == substrate → caveats correctly empty) + 7 run, dedup live.

Known residuals (tracked in backlog, all P3)

  • generate_tauri_commands still scans the local tree in-process (pre-existing PV-11 workaround).
  • Stages outside the child registry (fetch in prepare_refs, loctree in-process, snapshot creation) can run to the next seam after first Ctrl-C (verdict still protected by gates).
  • Cache freshness policy is advisory-only (7-day WARN threshold, not configurable yet).

🤖 Generated with Claude Code

vetcoders and others added 22 commits August 24, 2026 07:42
Freeze the typed API-delta vocabulary, repo-shaped base/head fixtures, historical regression mapping, provenance, confidence, and explicit unknown reasons for the 0.8 contract boundary.

Authored-By: codex <agents@vetcoders.io>
session_id: 01a03215-48a2-7891-ba82-5551633391dc
time: 2026-08-24T06:55:01+02:00
runtime: headless
Authored-By: codex <agents@vetcoders.io>
session_id: 01a03252-ef80-7461-af7d-e52034f39a8e
timestamp: 2026-08-24T06:01:53Z
runtime: Vibecrafted core runtime (headless)
Authored-By: codex <agents@vetcoders.io>
session_id: 01a0327f-3e5a-74c2-9c44-b06b5ab57001
timestamp: 2026-08-24T07:00:49Z
runtime: Vibecrafted core runtime (headless)
Authored-By: codex <agents@vetcoders.io>
Authored-By: codex <agents@vetcoders.io>
A run has no record that could tell it two surfaces asked for the same
work: the TypeScript gate and a future tsc context artifact are the same
tool reading the same tree under two different names, and nothing pairs
them. Add that record.

TaskKey is deliberately semantic — the tool name is normalised through
check_id::check_id_from_name, the existing canon for "which gate is
this", rather than introducing a second alias table free to drift from
the first. SubstrateKey reuses checks::TreeState (now deriving Hash) so
"which tree" has one enum, not two, and keeps both fields optional the
way ScanSubstrate does: an unresolved substrate stays visibly unknown
instead of being certified as anything.

TaskLedger is shared by reference across a run's concurrent tasks, so
each field sits behind its own Mutex, lookup returns an owned snapshot
rather than a borrow (a guard held across an await would deadlock the
run), and poisoning is recovered from rather than propagated.

Data model only: nothing records into it yet.
App::run now builds one TaskLedger per run and threads it through
checks::run_all / run_all_with_events, which record every check as it
resolves: a cache hit as Cached, an eligibility skip as Skipped with its
reason, an execution as Run with the duration the result already
reports.

A check that ran is keyed on the substrate its OWN provenance names —
the tree it actually read — so the ledger costs no extra git work and no
second resolution can contradict the check's own record. Checks without
provenance fall back to the run's resolved substrate, and to unknown
when that is unset, rather than being keyed on a guess.

run_all's check set and cache are now parameters of a private
run_all_checks, so the runner is testable without profile detection and
a PRVIEW_HOME-rooted cache; run_all itself is unchanged apart from
supplying them. The extraction is what lets the new test cover
run/cached/skipped with mock checks instead of spawning the real
Semgrep the fixture profile would otherwise pull in.

Write-only: nothing outside the tests reads the ledger back, and no
check, cache, progress or pack behaviour changes.
A --pr run judged two revisions at once. The gates scanned a snapshot of
the reviewed commit, but every 30_context generator ran against whatever
the operator happened to have checked out locally, so eslint.json,
cargo-tree.txt and friends described an unrelated tree while the pack
claimed one provenance (PRV-CONTEXT-SNAPSHOT-PROVENANCE).

Two independent breaks caused it. First, share_target_snapshot installed
scan_dir_override on a CLONE of the config inside run_all and returned
the WorktreeSnapshot into a local binding there, so the reviewed tree was
deleted the moment run_all's frame ended and App::run's own config never
learned about it. The snapshot handle now belongs to the ledger, which
App::run keeps alive past artifact generation; the run's resolved
substrate is recorded there in the same place. Second, even with a
snapshot on disk, generate_context_artifacts hardcoded config.repo_root
for every cwd and every filesystem probe. GenerateInput now carries the
ledger, and the context stage resolves its root as
ledger.scan_dir().unwrap_or(repo_root).

The sweep is deliberately total: cwds, the node_modules/.bin probes that
decide whether a tool is even available, the esbuild entry-point lookup,
the Tauri project detection, and the tauri_info decision recorded in
RUN.json all read the reviewed tree. Deciding availability from the local
tree while running the tool against the snapshot would be the same
mixing in a smaller form. Cargo commands resolve their directory through
checks::planned_cargo_cwd, the resolution the cargo gates already use, so
a workspace member is not collapsed to the snapshot root.

Local reviews are unchanged: with no shared snapshot the repo root IS the
reviewed tree, which is also why --watch's run_quick passes an empty
ledger.

plan_context_cmds is split out of generate_context_artifacts so "which
command, running where" can be asserted without spawning a process. The
regression fixture is a repo whose local checkout and reviewed commit
deliberately disagree about a fact the planner reads from disk; the
snapshot case must plan every cwd inside the snapshot and see the
target's entry point, the no-snapshot case must stay on the repo root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The context stage decided whether to run eslint/stylelint/vitest itself from
the checks RESULTS list: no result for a tool meant "nobody covered this, run
it". A results list can only report what reached a result, so a gate excluded
on purpose left the same hole as a gate that was never configured — and the
compensation fired hardest exactly where it was least wanted. A fast
remote-only preset rules the lint gate out to avoid a full-tree lint; the
context stage then spent 23 s doing that lint anyway (PRV-CONTEXT-WORK-DEDUP).

The ledger holds the half the results list cannot: what was deliberately ruled
out, and why. So the decision moves there, and the parallel `checks_ran_*`
booleans go away rather than being kept alongside it.

- `TaskLedger::lookup_tool` answers "what did this run resolve for this tool on
  this tree", falling back to an unknown-substrate entry because eligibility
  skips are recorded in the checks stage's first pass, before the run resolves
  a substrate at all. It never crosses two KNOWN substrates: a different tree
  is evidence of different work.
- The context stage resolves its substrate through the same
  `resolve_scan_substrate` + `consumable_scaffolding` pair a check uses, so a
  gate and its artifact do not land on two keys over `tree_state` alone.
- A ruled-out gate records the artifact as `Skipped` when the reviewed tree
  could run the tool (this run chose not to) and `NotApplicable` when it could
  not (no switch would change it), carrying the gate's own reason either way.
  A gate that ran — including one that failed — records `Cached` naming the
  substrate it read. A tool no gate decided on is still generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`plan_tsc_trace_artifact` returned `generated: true` unconditionally outside
fast remote-only runs, so a deep run compiled the reviewed tree twice: 8 s as
the TypeScript gate, then 8 s again as `tsc --noEmit --traceResolution`, the
second compile reporting what the first already had (PRV-CONTEXT-WORK-DEDUP).

The trace now asks the ledger whether a gate already compiled this exact tool
on this exact substrate. When one did, the artifact is not generated and the
ledger records it as `Cached` naming the execution it stands on, so the pack
still accounts for the signal instead of quietly losing it.

One exception stands, deliberately: a TypeScript gate that failed with
module-resolution errors still forces the trace. There the second compile buys
something the first could not give — the candidate paths the compiler tried —
which is the whole reason the artifact exists. A resolution-related file merely
CHANGING is not enough to pay for a second full compile; that signal keeps its
existing role in fast remote-only runs, where nothing compiled at all.

`plan_tauri_info_artifact` has no gate to dedup against — no check runs
`tauri info`, so nothing in the run could have paid its 17 s — and its
behaviour is unchanged. What changes is that a deferred one records its reason
in the ledger too, so the run keeps one account of what it did not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A run kept two half-accounts of itself: the ledger knew what it had ruled
out but not what the context stage had spent time on, and the pack it
produced said nothing about either.

Three cuts make the account whole, then publish it:

- skips and cache replays are decided in the checks stage's first pass,
  which necessarily precedes the substrate resolution (the runnable set is
  what decides whether a snapshot is materialised at all), so they were
  filed under an unknown substrate. `set_substrate` now adopts them onto
  the tree the run went on to read. Only the key moves: a replay's
  `origin` names the ORIGINAL execution and is not evidence this run may
  overwrite.
- context commands that execute are recorded as `Run` with the duration
  the runtime measured, under the gate they stand in for when one exists
  (`eslint json` -> `ESLint`) and under their own slugged label when none
  does (`cargo tree` -> `cargo_tree`). The plan site hands that identity
  over, so no per-command alias table appears beside `check_id`'s.
- `RUN.json` gains an additive `ledger` object with its own `schema`
  counter. Every existing field is untouched, so a consumer that ignores
  it cannot tell the section exists — which is why `schema_version` does
  not move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cache hit resolved a gate without saying anything about the answer it
was replaying, so a pack could report a green gate whose evidence was
hours or days old and read exactly like one that had just run.

`Cache::get` now returns `age_secs` alongside the entry. It comes from
the entry file's mtime — an entry is published by a single `rename`, so
its mtime IS the moment the result became readable — which means nothing
on disk had to change format and a cache warmed by an older prview (the
legacy status-file layout included) reports its age too. An age that
cannot be established stays `None` rather than becoming a fabricated
zero.

The age travels with the replay into the ledger's `Cached` state and out
through `RUN.json`'s `ledger` view as `cache_age_secs`. It deliberately
does not enter `CheckResult`: it is a property of the entry, not of the
check's verdict, and the artifacts derived from a result must not start
reporting it as one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A verdict can rest on evidence the run never produced. In the Vista
dogfood run a Cargo audit result replayed from a cache written before a
reboot co-authored a BLOCK, and the pack said only `cached: true` —
nothing named how old that evidence was.

MERGE_GATE.json now carries `stale_cache_caveats`: one entry per gate row
with blocking influence on the verdict (policy `block`, or a raw
failed/error status that gates `quality_pass`) whose result was replayed
from a stored entry older than 7 days. The age comes from the run's task
ledger, the only place that carries it.

The field is warn-only and additive. It sits outside `decision` on
purpose: that object is closed and every field in it ranks the verdict,
so a report ABOUT the pack's evidence must not live where a reader
reconciles axes. A test diffs the whole decision, checks and
inline_findings of a stale run against a fresh one to pin that nothing
else moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Concurrency is decided per stage today: the checks stage picks its own
fan-out, the context stage picks another, and nothing holds the
machine-wide number. Two stages each behaving reasonably still
oversubscribe a laptop, and the tools are not equal — clippy and cargo
test each want the whole box while reading a manifest costs nothing.

ResourceGovernor is that number. Work declares Light or Heavy; the
governor decides what a weight costs, because that depends on the budget
it is working with (Heavy is half the budget rounded up, so eight cores
admit two heavy tasks). A permit is returned on drop, so an error path
cannot leak budget.

It also owns the other half of "bounded": the registry of live child
pids. cancel() closes the semaphore — refusing a newcomer and a task
already waiting alike — and SIGKILLs each registered process group via
the existing proc::sigkill_process_group. The registry is drained rather
than read, so a second cancel signals nothing: a pid whose process died
in between may already belong to somebody else.

Both with_budget arguments are clamped. A zero budget is a deadlock and a
heavy cost above the total parks that task forever on permits the
semaphore will never hold.

Primitive only — nothing acquires from it yet. Wiring the dispatcher is a
separate change so the budget can be reviewed before it starts shaping
when checks run. No new dependencies: tokio Semaphore and watch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ueued

The governor existed but nothing acquired from it, so a Rust+JS profile
still launched clippy, cargo test, tsc and vitest at the same instant —
four tools each sizing its own worker pool to the whole machine. The
checks stage now takes a permit before a process starts.

Checks declare their appetite through Check::resource_weight, defaulting
to Light because mis-declaring a cheap tool only wastes budget while the
reverse oversubscribes the box. Heavy is one documented list on the trait
method: the cargo family, TypeScript, Vitest, ESLint and Semgrep.

The cargo target/ lock stays — it is a correctness lock, one writer per
target/, and the budget is not that. Both are taken in ONE order, cargo
lock first: same order everywhere is what makes two locks deadlock-free,
and this direction is also the one that does not waste the machine. A
cargo check holding Heavy permits while it queues for target/ would park
half the budget on work that has not begun. Nothing acquires the cargo
lock once it holds budget, so there is no cycle in the other direction.

With admission comes an honest split. "Running: A, B, C" used to name
every runnable check from the instant the stage began; now the progress
line separates what holds budget from what waits for it ("Running: X
(12s) · Queued: Y, Z"), the ledger's started_at is the moment of
admission rather than of the first poll, and the PV-18 slow notice
measures from admission — a check parked on the budget for ten minutes
has not been slow, it has not started. TUI mode gets the same split
additively: CheckEvent::Running joins Started, which now maps to the
already-existing Pending lifecycle.

Child registration rides a task-local (governor::with_child_scope). The
governor is known at the dispatcher and the pid at the single spawn point
five frames below it, behind Check::run(&self, config) — a trait method
that cannot grow a governor argument without every check and all twenty
run_command_* call sites growing one they never read. The registration
guard unregisters on drop, so the success, timeout and error paths all
leave the registry clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The context stage spawned every planned command at once. On a JS repo
that is `tsc --traceResolution`, a project-wide `eslint`, `stylelint` and
`esbuild` starting together — the same class of tool the gates now take
Heavy permits for, running with no bound at all. Two stages each picking
their own fan-out is exactly the shape the governor exists to replace.

Commands now take a permit before they are spawned and return it the
moment they exit, so the queue drains as the machine frees up rather than
at the end of the stage. `context_cmd_weight` is the one list: tsc trace,
eslint, stylelint and esbuild are Heavy; `cargo tree`/`cargo sbom` (a
lockfile resolution, no compilation), `npm sbom` and `tauri info` are
Light. `try_acquire` is the governor's synchronous door — `generate` is a
blocking pipeline and has nothing to await on.

Children also get the rails the checks have always had: stdin detached
(already true) and their own process group (new, via proc::harden_std).
The context stage not leading its own groups was an omission rather than
a decision, and it cost twice — a timed-out `sh -c 'pnpm exec …'` left
its grandchildren behind, which the timeout path now signals properly,
and the governor had no group to reach on cancellation.

This is consistency and reach, not a fix for a measured overlap: step 5
is fully awaited before step 7, so the two stages do not run at the same
time today. That sequence is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was no signal handler anywhere in the repo. A raw SIGINT killed
prview without unwinding, which cost twice: the ledger's shared worktree
snapshot and the heuristics analysis snapshots were never dropped, so
every abandoned run left temporary trees on disk, and the checks' own
children — which lead their own process groups precisely so they do NOT
receive the terminal's SIGINT — kept compiling with no parent.

main::with_cancellation drives the run beside tokio::signal::ctrl_c. The
first interrupt CANCELS rather than aborts: governor.cancel() closes the
budget, wakes the dispatcher, and SIGKILLs each registered child's whole
process group. The run then unwinds through its ordinary `?`, which is
the point — dropping the future at the select would stop the work just as
fast and skip every destructor on the way out. A second interrupt is the
operator declining to wait, and exits at once.

checks::run_all grows a third select arm on ResourceGovernor::cancelled()
so a stage does not stay open waiting for a gate whose child has just
been killed — or for one with a long timeout that had not spawned yet.
cancelled() wraps the watch receiver because the raw one has two edges a
caller must not get wrong: changed() waits for the NEXT transition, and a
dropped sender means "never", not "now".

The context stage stops admitting after a cancel and records what it
never started as `cancelled` rather than omitting it, so the ledger does
not read as though those commands were simply never planned. A command
the SIGKILL caught is `cancelled` too, not `failed` — that is not the
tool failing.

Exit code 130 (128 + SIGINT). Deliberately outside prview's own map (0
accept, 1 reject/block, 3 gate execution error): a cancelled run produced
no verdict and must not claim one. `--tui` is not wrapped — raw mode
delivers Ctrl-C as a key event and it owns its own quit path.

Tested by calling cancel() directly. Sending a real SIGINT from a test is
a known flake class, and the signal is not what is under test; what the
loops do about it is. The end-to-end reap goes through the shared spawn
helper inside a child scope, and signals only a process the test spawned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared target snapshot was materialised only when a RUNNABLE check
was in uses_shared_scan_dir(). That reads as "no gate needs the tree, so
the run does not need the tree" — but the gates are not the only stage
that reads it. The context stage plans and produces the whole of
30_context from ledger.scan_dir().

Two ordinary --pr runs have an off-HEAD target and nothing snapshot-backed
to run. The SECOND run of the same PR replays every gate off a cache keyed
on the reviewed commit (tsc-<sha>, eslint-<sha>), so the runnable set is
empty. The fast remote-only preset skips every snapshot-backed gate and
leaves only semgrep, which owns its own worktree and is deliberately out
of uses_shared_scan_dir(). Both left the ledger with no scan dir, so
cargo tree, the SBOMs, tauri info, the tsc trace and the entry-point
probes read the operator's local checkout while the diffs and
MERGE_GATE.json described the PR's commit — PRV-CONTEXT-SNAPSHOT-PROVENANCE
again, through a quieter door, and with nothing in RUN.json to tell the
two runs apart.

The condition is now: a runnable check wants it, OR the target is off-HEAD.
The call also moves outside the dispatcher's "anything to run" guard, since
an empty runnable set is exactly the case it now has to cover. Target ==
HEAD with nothing runnable still materialises nothing: there the repo root
IS the reviewed tree, and artifacts::generate's fallback to repo_root is
the right answer — its comment said so unconditionally and now says when.

Resolving a substrate is the other half of the fix. set_substrate adopts
the first pass's skips and replays off the unknown substrate they were
necessarily recorded under, so RUN.json stops reporting a warm --pr run's
own decisions as being about no particular tree. It is resolved with an
empty consumable-scaffolding list, so it reports `snapshot`, never
`snapshot-borrowed-deps`: a snapshot carries linked node_modules whoever
runs there, but with no command to name, nothing at that point can consume
one. A command that does resolve through the link still reports that for
itself.

Cost: a warm --pr run pays for one git worktree its gates do not need.
Correctness over a saved checkout.

Tested at the dispatcher, hermetically, with staged mock gates: an
off-HEAD run where every gate replays, one where the snapshot-backed
gates skip and only a semgrep-named mock runs, and the local-run
counterpart that must still cost no worktree. The first two fail against
the old condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The interrupt was an arm of the same `select!` as the run, which works
only while the run keeps yielding. `artifacts::generate` is synchronous:
it spawns the context commands and polls them with `std::thread::sleep`,
never awaiting anything. For the whole of the longest stage of a review
the task was therefore not polled, so NEITHER interrupt arm could fire —
and `tokio::signal::ctrl_c` had by then replaced SIGINT's default
disposition, so the terminal could not end the process either. Ctrl-C
did nothing at all, which is worse than what the code did before the
signal was handled: there the kernel killed the process at once.

`with_cancellation` now spawns the supervisor instead of racing it. The
run may block its thread for minutes and the interrupt is still seen;
the supervisor is aborted whichever way the run leaves, so a task
listening for a signal never outlives the run it listened for. It moves
out of `main` and into `governor::supervisor` for two reasons: the
governor already owns what cancellation MEANS, and a binary's tests are
not covered by the repo's `cargo test --lib` gate. `blocking_stage`
wraps the `generate` call for the remaining edge — a runtime with a
single worker thread — by telling tokio the stage is about to block, so
the supervisor keeps a thread to be polled on.

The signal source is now the `Interrupts` trait rather than a direct
`ctrl_c()` call, and the second interrupt goes through
`Interrupts::abandon_run` rather than a bare `process::exit`. That is
what makes the state machine testable: a test that raised a real SIGINT
would raise it at the harness, which owns the process.

Second half: cancel now means the run NEVER reports a verdict. Only the
checks stage watched `governor.cancelled()`, and it is one stage of
several. A cancel arriving in the heuristics, inside `generate`, or in a
run whose gates all replayed off the cache — an empty runnable set never
builds that `select!` loop at all — was ignored outright. `App::run`
went on to write a pack whose context commands were every one of them
recorded `cancelled`, returned a report, and `main` computed an ACCEPT
or a BLOCK from it and exited 0 or 1. The operator asked the run to stop
and got a verdict for their trouble. `ensure_not_cancelled` guards the
seams — after the checks, either side of `generate`, and at the top of a
quick run — so the run ends in `Cancelled` and exit 130. A partial pack
may remain on disk as evidence of what got done; nothing claims a
verdict from it.

Tested in the library, with no signal raised anywhere: the first
interrupt cancels and lets the run unwind, a second abandons it without
waiting, and — the regression itself — an interrupt is observed while
the run blocks its thread in a `std::thread::sleep` poll loop. That last
one fails against the same-task `select!`. At the App level, a cancelled
quick run writes no pack at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…build

Two places where a cancel was asked for and nothing acted on it.

`--watch` builds ONE App, and therefore one governor, for the whole
session; `Semaphore::close` is one-way, so once cancelled that governor
can never grant work again. The iteration reported every failure of its
quick run as an ordinary error and carried on watching, which turned the
first Ctrl-C into a silent degradation rather than an exit: the cleanup
message printed, the watcher stayed alive, and every later edit produced
a pack with an empty 30_context under a cheerful "Regenerated
artifacts". The only way out was a second interrupt, which skips the
unwind and leaves the temporary worktrees on disk — so the run that
ASKED to be stopped cleanly was the one guaranteed not to be.

A cancellation now leaves the iteration instead of being printed by it;
other iteration errors still are, because a failed pack is not a reason
to stop watching. Both loops — the filesystem watcher and the polling
fallback — grow a biased `governor.cancelled()` arm so a cancel arriving
while the watcher is idle ends it too, and the iteration asks before it
even measures the change.

The `uv sync` pre-step ran outside any `with_child_scope`, where
`register_active_child` is a documented no-op. The governor therefore
held no pid for it and `cancel()` signalled nothing: a Ctrl-C during a
cold venv build printed "stopping running tools and cleaning up" and
then waited out the full CHECK_TIMEOUT_SECS with `uv` still going. It is
scoped now, like every other child of a run, and an already-cancelled
run does not start one at all — the gates it would be preparing for are
never going to run. The scope's own doc said the pre-step belonged
outside it; that was the drift, and it is corrected rather than kept.

The heuristics stage needs no scope: it spawns no processes at all,
running loctree in-process behind `spawn_blocking`.

Tested at the seams, no signal raised: a cancelled watch iteration ends
the watch and writes nothing, and a cancelled run's venv pre-step
refuses. Both fail against the swallowing iteration and the ungated
pre-step respectively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shared analysis snapshot is not one substrate. `resolve_scan_substrate`
answers `snapshot` or `snapshot-borrowed-deps` for the SAME directory
depending on which scaffolding the asking tool consumes from the working
tree, and `share_target_snapshot` was resolving it once, with an empty
consumable set, then filing every first-pass entry under that one key.

On a JS repository the snapshot always carries a `node_modules` symlink,
so the run-wide answer is `snapshot` while the context stage, asking
through `context_substrate("ESLint", ...)`, computes
`snapshot-borrowed-deps`. The exact-key lookup missed. The
unknown-substrate fallback that would have caught it had just been spent
by this very adoption — it re-keys the unknown entries, so there is
nothing left for the fallback to find. `plan_context_tool` read
`Uncovered` and re-ran the gate's work in full: a whole `eslint . -f
json`, and off the fast path a whole `tsc` trace. That is the
duplication the shared snapshot exists to remove, lost on both scenarios
it was built for — a warm second `--pr N` and the fast remote-only
preset — and, when the tool was missing, explained in the pack with a
reason that was not true.

Adoption now asks per entry. `set_substrate_keyed` takes a closure from
`checks`, which alone knows what a tool consumes, and applies it to each
entry's tool id; `adopted_substrate` caches one resolution per distinct
consumable set, of which there are two, the run-wide one seeding the
first. The ledger gains no table of tools and `checks` keeps it, so the
layering holds. `consumable_scaffolding` normalises its argument through
`check_id_from_name`, because a ledger entry carries `eslint`, never
"ESLint" — without that the per-entry lookup would have answered for
nothing at all.

Tested on the shape the old tests could not see: a JS worktree whose
snapshot borrows `node_modules`, an off-HEAD target, an ESLint gate
skipped in the first pass. It fails against the run-wide adoption with
`snapshot` where `snapshot-borrowed-deps` was expected, and the id
normalisation is pinned separately — it fails too when
`consumable_scaffolding` is given back its display-name-only table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The contract says a run in which cancellation was requested never ends in
one. Two paths were still outside it.

`--update` returns before every gate there is. The first
`ensure_not_cancelled` in `App::run` sits after the checks stage, and the
update short circuit returns four steps earlier — so `prview --update`,
Ctrl-C during `prepare_refs`, and a HEAD with no new commits printed "^C
stopping running tools and cleaning up" and then handed back the
PREVIOUS run's pack, from which `main` computed an ACCEPT or a BLOCK and
exited 0 or 1. That fetch is a `git` process the governor holds no pid
for, so `cancel` cannot shorten it and the window is as wide as the
network is slow. Reusing a pack is still reporting a verdict, so the
short circuit is gated like every other seam, and `run` is asked once on
entry as well — a `--watch` App can reach it on a governor that was
closed before the run began.

The gate needs a seam to be tested at: `find_previous_run` resolves
through `PRVIEW_HOME`, and a lib test sharing a process with 1796 others
must neither read the operator's real one nor mutate the variable.
`reuse_unchanged_run` takes the previous run as an argument, so a test
fabricates one. The unregistered fetch is left as it is — registering it
belongs to the git module, not here.

Second: the TUI dispatcher is a copy of `run_all_checks` that had
drifted back into two bugs this branch had already fixed, under a
comment still claiming to mirror it. Its `uv sync` pre-step was a bare
`run_command_with_timeout` — no child scope, so `cancel` signalled
nothing during the longest command a run issues, and no gates, so an
already-cancelled run still paid for a venv its gates would never
consume. And the loop over the running gates was a plain `while let`
with no arm for the cancel at all, so a gate holding a long timeout kept
the stage open after every child had been SIGKILLed. Both now go through
what the headless path goes through. Nothing calls `cancel()` on a TUI
governor today; the point is that the copy stops re-deriving fixed bugs
the moment something does.

`run_all_with_events` gains the same checks-and-cache seam `run_all`
already delegates through, which is what lets a staged mock hang in the
loop while the governor closes underneath it.

Tested with no signal raised: a cancelled update run refuses to reuse
the pack it correctly reuses when uncancelled, and a cancelled TUI run
leaves its loop as `Cancelled`. The first fails against the ungated
short circuit with `unchanged: true` in hand; the second waits out the
mock's full 30s sleep and hits its `unreachable!`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 25, 2026 06:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements bounded execution and “honest cancellation” across the runtime (resource governor + child-process-group cancellation), adds a run-wide task ledger as the source of truth for executed/cached/skipped work, and introduces a typed enforcement_disposition axis (orthogonal to PASS/CONDITIONAL/BLOCK) that is surfaced consistently through CLI, prview gate, MCP, artifacts, and the composite GitHub Action. Additionally, Rust API/breaking signals are migrated to a repo-backed, exact-tree ApiDelta with a large fixture corpus.

Changes:

  • Add cancellation supervision + process-group child registration, and share one machine-wide resource budget across checks and context tools.
  • Add typed enforcement (clean / warnings_only / review_required / block) and update gate/CI exit adapters, MCP surfaces, tests, and docs accordingly.
  • Replace/augment Rust API/breaking analysis with exact-tree ApiDelta views, plus extensive contract fixtures; extend cache reads with cache-entry age (seconds).

Reviewed changes

Copilot reviewed 166 out of 220 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tools/githooks/pre-push Updates user-facing gate exit messaging.
tests/mcp_contract.rs MCP contract asserts enforcement_disposition is exposed and stable across readback.
tests/gate_exit_codes.rs Adds end-to-end fixture/test for warnings-only enforcement lanes.
tests/fixtures/api_surface/reexport_removed/head/src/lib.rs API-delta fixture (reexport_removed, head).
tests/fixtures/api_surface/reexport_removed/head/Cargo.toml API-delta fixture manifest (reexport_removed, head).
tests/fixtures/api_surface/reexport_removed/expected.json Expected API-delta output (reexport_removed).
tests/fixtures/api_surface/reexport_removed/base/src/lib.rs API-delta fixture (reexport_removed, base).
tests/fixtures/api_surface/reexport_removed/base/Cargo.toml API-delta fixture manifest (reexport_removed, base).
tests/fixtures/api_surface/reexport_preserved/head/src/lib.rs API-delta fixture (reexport_preserved, head).
tests/fixtures/api_surface/reexport_preserved/head/Cargo.toml API-delta fixture manifest (reexport_preserved, head).
tests/fixtures/api_surface/reexport_preserved/expected.json Expected API-delta output (reexport_preserved).
tests/fixtures/api_surface/reexport_preserved/base/src/lib.rs API-delta fixture (reexport_preserved, base).
tests/fixtures/api_surface/reexport_preserved/base/Cargo.toml API-delta fixture manifest (reexport_preserved, base).
tests/fixtures/api_surface/reexport_changed/head/src/lib.rs API-delta fixture (reexport_changed, head).
tests/fixtures/api_surface/reexport_changed/head/Cargo.toml API-delta fixture manifest (reexport_changed, head).
tests/fixtures/api_surface/reexport_changed/expected.json Expected API-delta output (reexport_changed).
tests/fixtures/api_surface/reexport_changed/base/src/lib.rs API-delta fixture (reexport_changed, base).
tests/fixtures/api_surface/reexport_changed/base/Cargo.toml API-delta fixture manifest (reexport_changed, base).
tests/fixtures/api_surface/move_same_path/head/src/lib.rs API-delta fixture (move_same_path, head).
tests/fixtures/api_surface/move_same_path/head/Cargo.toml API-delta fixture manifest (move_same_path, head).
tests/fixtures/api_surface/move_same_path/expected.json Expected API-delta output (move_same_path).
tests/fixtures/api_surface/move_same_path/base/src/lib.rs API-delta fixture (move_same_path, base).
tests/fixtures/api_surface/move_same_path/base/Cargo.toml API-delta fixture manifest (move_same_path, base).
tests/fixtures/api_surface/move_relocated/head/src/lib.rs API-delta fixture (move_relocated, head).
tests/fixtures/api_surface/move_relocated/head/Cargo.toml API-delta fixture manifest (move_relocated, head).
tests/fixtures/api_surface/move_relocated/expected.json Expected API-delta output (move_relocated).
tests/fixtures/api_surface/move_relocated/base/src/lib.rs API-delta fixture (move_relocated, base).
tests/fixtures/api_surface/move_relocated/base/Cargo.toml API-delta fixture manifest (move_relocated, base).
tests/fixtures/api_surface/module_scope_unseen_opener/head/src/lib.rs API-delta fixture (module_scope_unseen_opener, head).
tests/fixtures/api_surface/module_scope_unseen_opener/head/Cargo.toml API-delta fixture manifest (module_scope_unseen_opener, head).
tests/fixtures/api_surface/module_scope_unseen_opener/expected.json Expected API-delta output (module_scope_unseen_opener).
tests/fixtures/api_surface/module_scope_unseen_opener/base/src/lib.rs API-delta fixture (module_scope_unseen_opener, base).
tests/fixtures/api_surface/module_scope_unseen_opener/base/Cargo.toml API-delta fixture manifest (module_scope_unseen_opener, base).
tests/fixtures/api_surface/module_scope_unchanged/head/src/lib.rs API-delta fixture (module_scope_unchanged, head).
tests/fixtures/api_surface/module_scope_unchanged/head/Cargo.toml API-delta fixture manifest (module_scope_unchanged, head).
tests/fixtures/api_surface/module_scope_unchanged/expected.json Expected API-delta output (module_scope_unchanged).
tests/fixtures/api_surface/module_scope_unchanged/base/src/lib.rs API-delta fixture (module_scope_unchanged, base).
tests/fixtures/api_surface/module_scope_unchanged/base/Cargo.toml API-delta fixture manifest (module_scope_unchanged, base).
tests/fixtures/api_surface/module_scope_private_unreachable/head/src/lib.rs API-delta fixture (module_scope_private_unreachable, head).
tests/fixtures/api_surface/module_scope_private_unreachable/head/Cargo.toml API-delta fixture manifest (module_scope_private_unreachable, head).
tests/fixtures/api_surface/module_scope_private_unreachable/expected.json Expected API-delta output (module_scope_private_unreachable).
tests/fixtures/api_surface/module_scope_private_unreachable/base/src/lib.rs API-delta fixture (module_scope_private_unreachable, base).
tests/fixtures/api_surface/module_scope_private_unreachable/base/Cargo.toml API-delta fixture manifest (module_scope_private_unreachable, base).
tests/fixtures/api_surface/module_scope_changed/head/src/lib.rs API-delta fixture (module_scope_changed, head).
tests/fixtures/api_surface/module_scope_changed/head/Cargo.toml API-delta fixture manifest (module_scope_changed, head).
tests/fixtures/api_surface/module_scope_changed/expected.json Expected API-delta output (module_scope_changed).
tests/fixtures/api_surface/module_scope_changed/base/src/lib.rs API-delta fixture (module_scope_changed, base).
tests/fixtures/api_surface/module_scope_changed/base/Cargo.toml API-delta fixture manifest (module_scope_changed, base).
tests/fixtures/api_surface/long_within_cap_changed/head/src/lib.rs API-delta fixture (long_within_cap_changed, head).
tests/fixtures/api_surface/long_within_cap_changed/head/Cargo.toml API-delta fixture manifest (long_within_cap_changed, head).
tests/fixtures/api_surface/long_within_cap_changed/expected.json Expected API-delta output (long_within_cap_changed).
tests/fixtures/api_surface/long_within_cap_changed/base/src/lib.rs API-delta fixture (long_within_cap_changed, base).
tests/fixtures/api_surface/long_within_cap_changed/base/Cargo.toml API-delta fixture manifest (long_within_cap_changed, base).
tests/fixtures/api_surface/long_unchanged/head/src/lib.rs API-delta fixture (long_unchanged, head).
tests/fixtures/api_surface/long_unchanged/head/Cargo.toml API-delta fixture manifest (long_unchanged, head).
tests/fixtures/api_surface/long_unchanged/expected.json Expected API-delta output (long_unchanged).
tests/fixtures/api_surface/long_unchanged/base/src/lib.rs API-delta fixture (long_unchanged, base).
tests/fixtures/api_surface/long_unchanged/base/Cargo.toml API-delta fixture manifest (long_unchanged, base).
tests/fixtures/api_surface/long_below_32_line_cut/head/src/lib.rs API-delta fixture (long_below_32_line_cut, head).
tests/fixtures/api_surface/long_below_32_line_cut/head/Cargo.toml API-delta fixture manifest (long_below_32_line_cut, head).
tests/fixtures/api_surface/long_below_32_line_cut/expected.json Expected API-delta output (long_below_32_line_cut).
tests/fixtures/api_surface/long_below_32_line_cut/base/src/lib.rs API-delta fixture (long_below_32_line_cut, base).
tests/fixtures/api_surface/long_below_32_line_cut/base/Cargo.toml API-delta fixture manifest (long_below_32_line_cut, base).
tests/fixtures/api_surface/literal_layout_only/head/src/lib.rs API-delta fixture (literal_layout_only, head).
tests/fixtures/api_surface/literal_layout_only/head/Cargo.toml API-delta fixture manifest (literal_layout_only, head).
tests/fixtures/api_surface/literal_layout_only/expected.json Expected API-delta output (literal_layout_only).
tests/fixtures/api_surface/literal_layout_only/base/src/lib.rs API-delta fixture (literal_layout_only, base).
tests/fixtures/api_surface/literal_layout_only/base/Cargo.toml API-delta fixture manifest (literal_layout_only, base).
tests/fixtures/api_surface/literal_changed/head/src/lib.rs API-delta fixture (literal_changed, head).
tests/fixtures/api_surface/literal_changed/head/Cargo.toml API-delta fixture manifest (literal_changed, head).
tests/fixtures/api_surface/literal_changed/expected.json Expected API-delta output (literal_changed).
tests/fixtures/api_surface/literal_changed/base/src/lib.rs API-delta fixture (literal_changed, base).
tests/fixtures/api_surface/literal_changed/base/Cargo.toml API-delta fixture manifest (literal_changed, base).
tests/fixtures/api_surface/legacy_non_api_control/head/src/lib.rs API-delta fixture (legacy_non_api_control, head).
tests/fixtures/api_surface/legacy_non_api_control/head/Cargo.toml API-delta fixture manifest (legacy_non_api_control, head).
tests/fixtures/api_surface/legacy_non_api_control/expected.json Expected API-delta output (legacy_non_api_control).
tests/fixtures/api_surface/legacy_non_api_control/base/src/lib.rs API-delta fixture (legacy_non_api_control, base).
tests/fixtures/api_surface/legacy_non_api_control/base/Cargo.toml API-delta fixture manifest (legacy_non_api_control, base).
tests/fixtures/api_surface/item_opener_removed/head/src/lib.rs API-delta fixture (item_opener_removed, head).
tests/fixtures/api_surface/item_opener_removed/head/Cargo.toml API-delta fixture manifest (item_opener_removed, head).
tests/fixtures/api_surface/item_opener_removed/expected.json Expected API-delta output (item_opener_removed).
tests/fixtures/api_surface/item_opener_removed/base/src/lib.rs API-delta fixture (item_opener_removed, base).
tests/fixtures/api_surface/item_opener_removed/base/Cargo.toml API-delta fixture manifest (item_opener_removed, base).
tests/fixtures/api_surface/item_body_removed/head/src/lib.rs API-delta fixture (item_body_removed, head).
tests/fixtures/api_surface/item_body_removed/head/Cargo.toml API-delta fixture manifest (item_body_removed, head).
tests/fixtures/api_surface/item_body_removed/expected.json Expected API-delta output (item_body_removed).
tests/fixtures/api_surface/item_body_removed/base/src/lib.rs API-delta fixture (item_body_removed, base).
tests/fixtures/api_surface/item_body_removed/base/Cargo.toml API-delta fixture manifest (item_body_removed, base).
tests/fixtures/api_surface/item_body_changed/head/src/lib.rs API-delta fixture (item_body_changed, head).
tests/fixtures/api_surface/item_body_changed/head/Cargo.toml API-delta fixture manifest (item_body_changed, head).
tests/fixtures/api_surface/item_body_changed/expected.json Expected API-delta output (item_body_changed).
tests/fixtures/api_surface/item_body_changed/base/src/lib.rs API-delta fixture (item_body_changed, base).
tests/fixtures/api_surface/item_body_changed/base/Cargo.toml API-delta fixture manifest (item_body_changed, base).
tests/fixtures/api_surface/function_removed/head/src/lib.rs API-delta fixture (function_removed, head).
tests/fixtures/api_surface/function_removed/head/Cargo.toml API-delta fixture manifest (function_removed, head).
tests/fixtures/api_surface/function_removed/expected.json Expected API-delta output (function_removed).
tests/fixtures/api_surface/function_removed/base/src/lib.rs API-delta fixture (function_removed, base).
tests/fixtures/api_surface/function_removed/base/Cargo.toml API-delta fixture manifest (function_removed, base).
tests/fixtures/api_surface/function_added/head/src/lib.rs API-delta fixture (function_added, head).
tests/fixtures/api_surface/function_added/head/Cargo.toml API-delta fixture manifest (function_added, head).
tests/fixtures/api_surface/function_added/expected.json Expected API-delta output (function_added).
tests/fixtures/api_surface/function_added/base/src/lib.rs API-delta fixture (function_added, base).
tests/fixtures/api_surface/function_added/base/Cargo.toml API-delta fixture manifest (function_added, base).
tests/fixtures/api_surface/declaration_pairing_noop/head/src/lib.rs API-delta fixture (declaration_pairing_noop, head).
tests/fixtures/api_surface/declaration_pairing_noop/head/Cargo.toml API-delta fixture manifest (declaration_pairing_noop, head).
tests/fixtures/api_surface/declaration_pairing_noop/expected.json Expected API-delta output (declaration_pairing_noop).
tests/fixtures/api_surface/declaration_pairing_noop/base/src/lib.rs API-delta fixture (declaration_pairing_noop, base).
tests/fixtures/api_surface/declaration_pairing_noop/base/Cargo.toml API-delta fixture manifest (declaration_pairing_noop, base).
tests/fixtures/api_surface/declaration_pairing_changed/head/src/lib.rs API-delta fixture (declaration_pairing_changed, head).
tests/fixtures/api_surface/declaration_pairing_changed/head/Cargo.toml API-delta fixture manifest (declaration_pairing_changed, head).
tests/fixtures/api_surface/declaration_pairing_changed/expected.json Expected API-delta output (declaration_pairing_changed).
tests/fixtures/api_surface/declaration_pairing_changed/base/src/lib.rs API-delta fixture (declaration_pairing_changed, base).
tests/fixtures/api_surface/declaration_pairing_changed/base/Cargo.toml API-delta fixture manifest (declaration_pairing_changed, base).
tests/fixtures/api_surface/combining_mark_unchanged/head/src/lib.rs API-delta fixture (combining_mark_unchanged, head).
tests/fixtures/api_surface/combining_mark_unchanged/head/Cargo.toml API-delta fixture manifest (combining_mark_unchanged, head).
tests/fixtures/api_surface/combining_mark_unchanged/expected.json Expected API-delta output (combining_mark_unchanged).
tests/fixtures/api_surface/combining_mark_unchanged/base/src/lib.rs API-delta fixture (combining_mark_unchanged, base).
tests/fixtures/api_surface/combining_mark_unchanged/base/Cargo.toml API-delta fixture manifest (combining_mark_unchanged, base).
tests/fixtures/api_surface/combining_mark_distinct/head/src/lib.rs API-delta fixture (combining_mark_distinct, head).
tests/fixtures/api_surface/combining_mark_distinct/head/Cargo.toml API-delta fixture manifest (combining_mark_distinct, head).
tests/fixtures/api_surface/combining_mark_distinct/expected.json Expected API-delta output (combining_mark_distinct).
tests/fixtures/api_surface/combining_mark_distinct/base/src/lib.rs API-delta fixture (combining_mark_distinct, base).
tests/fixtures/api_surface/combining_mark_distinct/base/Cargo.toml API-delta fixture manifest (combining_mark_distinct, base).
tests/fixtures/api_surface/combining_mark_changed/head/src/lib.rs API-delta fixture (combining_mark_changed, head).
tests/fixtures/api_surface/combining_mark_changed/head/Cargo.toml API-delta fixture manifest (combining_mark_changed, head).
tests/fixtures/api_surface/combining_mark_changed/expected.json Expected API-delta output (combining_mark_changed).
tests/fixtures/api_surface/combining_mark_changed/base/src/lib.rs API-delta fixture (combining_mark_changed, base).
tests/fixtures/api_surface/combining_mark_changed/base/Cargo.toml API-delta fixture manifest (combining_mark_changed, base).
tests/fixtures/api_surface/cfg_unchanged/head/src/lib.rs API-delta fixture (cfg_unchanged, head).
tests/fixtures/api_surface/cfg_unchanged/head/Cargo.toml API-delta fixture manifest (cfg_unchanged, head).
tests/fixtures/api_surface/cfg_unchanged/expected.json Expected API-delta output (cfg_unchanged).
tests/fixtures/api_surface/cfg_unchanged/base/src/lib.rs API-delta fixture (cfg_unchanged, base).
tests/fixtures/api_surface/cfg_unchanged/base/Cargo.toml API-delta fixture manifest (cfg_unchanged, base).
tests/fixtures/api_surface/cfg_operand_reordered/head/src/lib.rs API-delta fixture (cfg_operand_reordered, head).
tests/fixtures/api_surface/cfg_operand_reordered/head/Cargo.toml API-delta fixture manifest (cfg_operand_reordered, head).
tests/fixtures/api_surface/cfg_operand_reordered/expected.json Expected API-delta output (cfg_operand_reordered).
tests/fixtures/api_surface/cfg_operand_reordered/base/src/lib.rs API-delta fixture (cfg_operand_reordered, base).
tests/fixtures/api_surface/cfg_operand_reordered/base/Cargo.toml API-delta fixture manifest (cfg_operand_reordered, base).
tests/fixtures/api_surface/cfg_guard_changed/head/src/lib.rs API-delta fixture (cfg_guard_changed, head).
tests/fixtures/api_surface/cfg_guard_changed/head/Cargo.toml API-delta fixture manifest (cfg_guard_changed, head).
tests/fixtures/api_surface/cfg_guard_changed/expected.json Expected API-delta output (cfg_guard_changed).
tests/fixtures/api_surface/cfg_guard_changed/base/src/lib.rs API-delta fixture (cfg_guard_changed, base).
tests/fixtures/api_surface/cfg_guard_changed/base/Cargo.toml API-delta fixture manifest (cfg_guard_changed, base).
tests/fixtures/api_surface/backslash_reindent/head/src/lib.rs API-delta fixture (backslash_reindent, head).
tests/fixtures/api_surface/backslash_reindent/head/Cargo.toml API-delta fixture manifest (backslash_reindent, head).
tests/fixtures/api_surface/backslash_reindent/expected.json Expected API-delta output (backslash_reindent).
tests/fixtures/api_surface/backslash_reindent/base/src/lib.rs API-delta fixture (backslash_reindent, base).
tests/fixtures/api_surface/backslash_reindent/base/Cargo.toml API-delta fixture manifest (backslash_reindent, base).
tests/fixtures/api_surface/backslash_literal_changed/head/src/lib.rs API-delta fixture (backslash_literal_changed, head).
tests/fixtures/api_surface/backslash_literal_changed/head/Cargo.toml API-delta fixture manifest (backslash_literal_changed, head).
tests/fixtures/api_surface/backslash_literal_changed/expected.json Expected API-delta output (backslash_literal_changed).
tests/fixtures/api_surface/backslash_literal_changed/base/src/lib.rs API-delta fixture (backslash_literal_changed, base).
tests/fixtures/api_surface/backslash_literal_changed/base/Cargo.toml API-delta fixture manifest (backslash_literal_changed, base).
tests/fixtures/api_surface/abi_unchanged/head/src/lib.rs API-delta fixture (abi_unchanged, head).
tests/fixtures/api_surface/abi_unchanged/head/Cargo.toml API-delta fixture manifest (abi_unchanged, head).
tests/fixtures/api_surface/abi_unchanged/expected.json Expected API-delta output (abi_unchanged).
tests/fixtures/api_surface/abi_unchanged/base/src/lib.rs API-delta fixture (abi_unchanged, base).
tests/fixtures/api_surface/abi_unchanged/base/Cargo.toml API-delta fixture manifest (abi_unchanged, base).
tests/fixtures/api_surface/abi_changed/head/src/lib.rs API-delta fixture (abi_changed, head).
tests/fixtures/api_surface/abi_changed/head/Cargo.toml API-delta fixture manifest (abi_changed, head).
tests/fixtures/api_surface/abi_changed/expected.json Expected API-delta output (abi_changed).
tests/fixtures/api_surface/abi_changed/base/src/lib.rs API-delta fixture (abi_changed, base).
tests/fixtures/api_surface/abi_changed/base/Cargo.toml API-delta fixture manifest (abi_changed, base).
src/tui/types.rs Adds queued lifecycle event for governor-admitted execution.
src/tui/mod.rs Plumbs ledger + governor into TUI run; maps Started/Running semantics.
src/proc.rs Adds harden_std and registers child pids with governor for cancellation.
src/policy/engine.rs Introduces typed EnforcementDisposition and shared enforcement table.
src/mcp/run.rs Includes enforcement_disposition in completed MCP bodies.
src/mcp/read.rs Normalizes/validates typed disposition and warning proof in decision readback.
src/mcp/mod.rs Exposes enforcement_disposition in MCP verdict.
src/main.rs Applies cancellation contract (exit 130) and uses enforcement-mode exit mapping.
src/governor/supervisor.rs Adds interrupt supervisor task + blocking_stage helper and tests.
src/git/mod.rs Adds exact-tree enumerators and worktree-change tracking helpers.
src/config/mod.rs Adds enforcement_mode to config; gate profile now sets it explicitly.
src/cli/mod.rs Updates help + adds gate --fail-on-warnings flag and semantics.
src/checks/typescript.rs Marks TS/ESLint/Vitest checks as Heavy for governor budgeting.
src/checks/semgrep.rs Marks semgrep as Heavy for governor budgeting.
src/checks/cargo.rs Exposes planned_cargo_cwd to share working-dir selection; marks cargo checks Heavy.
src/cache/mod.rs Adds cache-entry age reporting and tests; (doc comment fixes needed).
src/artifacts/verdict.rs Adds Rust API delta disposition/caveat helpers.
src/artifacts/signal/risk.rs Adds production API-aware risk scoring; keeps root helper test-only.
src/artifacts/signal/public_api.rs Splits JS/TS legacy analysis vs Rust repo-backed view; new output semantics.
src/artifacts/signal/mod.rs Exposes new signal modules (api_delta, api_surface, revision_source).
src/artifacts/report.rs Threads Rust API delta into report breaking section counts/fields.
src/artifacts/mod.rs Threads ledger + governor into artifact generation; computes Rust ApiDelta once.
src/artifacts/findings.rs Returns heuristics enforcement disposition alongside gate check materialization.
src/artifacts/dashboard/trends_tests.rs Updates dashboard test ctx for rust_api_delta field.
src/artifacts/dashboard/tests.rs Updates dashboard test ctx for rust_api_delta field.
src/artifacts/context.rs Uses API-aware risk scoring and incorporates Rust API delta outcomes/caveats.
README.md Updates gate exit contract/docs and Action inputs; references v0.8.0 tag.
docs/usage.md Updates usage + CI docs for typed enforcement disposition and fail-on-warnings.
docs/mcp.md Documents enforcement_disposition surface and schema 2.3 enforcement rules.
docs/gate-playbook.md Updates gate semantics/rollout guidance for typed disposition + cancellation exit 130.
docs/examples/artifact-pack.md Updates artifact pack example contents for new API artifacts.
docs/contracts/merge_gate.md Bumps contract to schema 2.3; documents disposition, rust_api_delta, stale cache caveats.
docs/configuration.md Updates breaking-escalation description for repo-backed Rust API facts.
CHANGELOG.md Adds detailed entries for cancellation, budgeting, typed enforcement, API delta migration.
Cargo.toml Adds dependencies for repo-backed Rust API snapshots (syn/quote/unicode-normalization).
Cargo.lock Locks new dependencies used by repo-backed Rust API snapshots.
action.yml Adds fail-on-warnings input + strict-lane semantics and summary output.
.github/workflows/gate.yml Workflow summary includes enforcement_disposition and updated messaging.
Suppressed comments (1)

src/cache/mod.rs:243

  • entry_age_secs no longer has the doc comment that explains the mtime-based age derivation (it’s currently attached to backdate). Adding a short doc comment here would keep the public-facing docs accurate.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread README.md
Comment on lines +155 to +159
- uses: vetcoders/prview-rs@v0.8.0 # fail-on-warnings requires 0.8+
id: prview
with:
strict: "true"
fail-on-warnings: "false"
Comment thread docs/usage.md
Comment on lines +143 to 148
- uses: vetcoders/prview-rs@v0.8.0 # fail-on-warnings requires 0.8+
id: prview
with:
strict: "true"
fail-on-warnings: "false"
version: "latest"
Comment thread docs/gate-playbook.md
Comment on lines +292 to 297
- uses: vetcoders/prview-rs@v0.8.0 # typed warning policy requires 0.8+
id: prview
with:
strict: "true"
fail-on-warnings: "false"
version: "latest"
Comment thread src/cache/mod.rs
Comment on lines +212 to +228
/// How old the entry file at `path` is, in whole seconds.
///
/// Read from the file's mtime rather than from anything inside it: an entry is
/// published by a single `rename`, so its mtime IS the moment the result became
/// readable, and taking the age this way costs one `stat` and keeps the on-disk
/// format untouched — every entry a previous prview wrote already carries it.
///
/// `None` rather than a guess when the age is unknowable: no metadata to read,
/// a filesystem that does not report mtime, or a timestamp in the future (a
/// clock that moved backwards, a copied tree). A replay of unknown age is a fact
/// a reviewer can act on; a fabricated zero is not.
/// Move an entry's mtime `by` into the past (test-only), so the age a replay
/// reports can be asserted without waiting for wall-clock time to pass.
///
/// Only the timestamp moves — the entry's bytes are exactly what `set` wrote,
/// which is what makes this a test of the real published-at reading rather than
/// of a fixture format.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5dadb84a03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/governor/mod.rs
Comment on lines +216 to +217
pub fn register_child(&self, key: impl Into<String>, pid: u32) {
self.lock_inflight().insert(key.into(), pid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Kill children registered after cancellation

If Ctrl-C lands between spawning a process and this registration, cancel() can set the cancelled flag and drain the registry before register_child() inserts the new PID. This race exists in both run_capture_with_timeout and the synchronous context-command runner because they register only after spawn() returns; the newly registered process group is then never signalled, so cancellation may wait for its timeout or leave grandchildren running. Make registration atomic with cancellation, for example by checking the cancelled state while holding the registry lock and immediately killing the process group when cancellation has already begun.

Useful? React with 👍 / 👎.

Comment on lines +179 to +183
unknown: snapshot_unknown_findings(base, ApiSnapshotSide::Base),
};
delta
.unknown
.extend(snapshot_unknown_findings(target, ApiSnapshotSide::Target));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deduplicate unchanged unknown regions

When the same unsupported construct exists unchanged on both revisions—for example a stable glob reexport, include!, or macro-generated public item—this unconditionally emits one base unknown and one target unknown rather than comparing their semantic proofs. Because the side and revision are part of each finding, normalize_delta() cannot deduplicate them, and apply_rust_api_delta_outcome() degrades any nonzero unknown count to review-required. Consequently, an unrelated PR in such a Rust repository becomes CONDITIONAL on every run even though the unknown source is byte-for-byte unchanged.

Useful? React with 👍 / 👎.

Comment on lines +2510 to +2512
Item::Fn(function) => {
*function.block = syn::parse_quote!({});
trim_signature_punctuation(&mut function.sig);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize non-semantic function parameter names

For a public function, this retains the complete syn::Signature, including argument patterns and generic/lifetime binder names, while stripping only the body and item name. Renaming pub fn parse(input: &str) to pub fn parse(source: &str) (or <T> to <U>) is source- and ABI-compatible for callers, but the resulting contract strings differ, so the delta reports a confirmed Changed finding and the merge gate requires review as though the API were breaking. Normalize bound identifiers before serializing the contract so only caller-observable signature changes are escalated.

Useful? React with 👍 / 👎.

Comment on lines +466 to +470
let kind = match (&before_field, &after_field) {
(Some(left), Some(right)) if left.contract == right.contract => continue,
(Some(_), Some(_)) => ApiDeltaKind::Changed,
(Some(_), None) => ApiDeltaKind::Removed,
(None, Some(_)) => ApiDeltaKind::Added,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat added public fields as breaking changes

When an existing public named struct gains a field, this classifies the field-level delta as Added. The policy layer deliberately treats added-only Rust API deltas as informational, but adding a field to a normal public struct is breaking for downstream exhaustive struct literals and patterns. For example, changing pub struct Options { pub a: u8 } to also contain pub b: u8 will leave the gate clean even though existing callers no longer compile; classify this as a breaking parent contract change unless the struct is explicitly #[non_exhaustive].

Useful? React with 👍 / 👎.

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.

2 participants