feat(runtime): bounded execution — resource governor, task ledger, honest cancellation - #27
feat(runtime): bounded execution — resource governor, task ledger, honest cancellation#27m-szymanska wants to merge 22 commits into
Conversation
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>
There was a problem hiding this comment.
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
ApiDeltaviews, 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_secsno longer has the doc comment that explains the mtime-based age derivation (it’s currently attached tobackdate). 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.
| - uses: vetcoders/prview-rs@v0.8.0 # fail-on-warnings requires 0.8+ | ||
| id: prview | ||
| with: | ||
| strict: "true" | ||
| fail-on-warnings: "false" |
| - uses: vetcoders/prview-rs@v0.8.0 # fail-on-warnings requires 0.8+ | ||
| id: prview | ||
| with: | ||
| strict: "true" | ||
| fail-on-warnings: "false" | ||
| version: "latest" |
| - uses: vetcoders/prview-rs@v0.8.0 # typed warning policy requires 0.8+ | ||
| id: prview | ||
| with: | ||
| strict: "true" | ||
| fail-on-warnings: "false" | ||
| version: "latest" |
| /// 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. |
There was a problem hiding this comment.
💡 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".
| pub fn register_child(&self, key: impl Into<String>, pid: u32) { | ||
| self.lock_inflight().insert(key.into(), pid); |
There was a problem hiding this comment.
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 👍 / 👎.
| unknown: snapshot_unknown_findings(base, ApiSnapshotSide::Base), | ||
| }; | ||
| delta | ||
| .unknown | ||
| .extend(snapshot_unknown_findings(target, ApiSnapshotSide::Target)); |
There was a problem hiding this comment.
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 👍 / 👎.
| Item::Fn(function) => { | ||
| *function.block = syn::parse_quote!({}); | ||
| trim_signature_punctuation(&mut function.sig); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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, |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
Closes out the "smażonko" incident (2026-08-24: bare
prviewin vista saturating CPU at 208–365% across the process family). The train delivers four connected mechanisms:src/governor/): weighted semaphore overavailable_parallelism— Heavy checks (cores.div_ceil(2), max 2 concurrent on a 14-core machine), Light = 1. Honest progress lineRunning: X (Ns) · Queued: Y. Child processes registered per process group so cancellation can kill whole groups.src/ledger/): single source of truth for what actually ran —run/cached/skipped/not-applicableper tool, with substrate identity (target_sha+ tree state), cache age in seconds, and provenance (origin) for cached entries. Serialized intoRUN.json;MERGE_GATE.jsongains additivestale_cache_caveats(WARN-only, verdict structurally unchanged).gate_coveragedecisions (Covered/RuledOut/Uncovered) come from the ledger, replacing the inverted compensation booleans.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 inblocking_stage.--watchand--updatepaths honor the same contract.Validation
cargo fmt --check,clippy -D warnings,cargo test --lib— 1797 passed / 0 failed.✗ Cancelledline, exit 130, zero orphaned processes, zero leftover snapshots;--deeprun: full scope (14 gates + complete30_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_commandsstill scans the local tree in-process (pre-existing PV-11 workaround).prepare_refs, loctree in-process, snapshot creation) can run to the next seam after first Ctrl-C (verdict still protected by gates).🤖 Generated with Claude Code