Under R1, a T0b push (test-infrastructure/, lint config, graph-ui, memwaste
tooling) selected lint and no test leg, so the static contract steps that
police exactly those files -- the VM scripts, the compose and run.sh wiring,
the venue-parity rules -- ran nowhere. Replayed over September, 13 of the 18
T0b pushes ran no contract step at all.
The R1 table now gives T0b lint + contracts, so those PRs get the
contracts job (scripts/test.sh --contracts-only) as T0a PRs do. The
fixture table's six T0b cases expect it, and the history replay asserts
that every push runs the contract steps (the contracts lane or any test
leg). Comments that described the job as docs-only are updated.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The pulls/files API lists at most 3000 files and says nothing when it stops,
and the lane decision is only as complete as that list: a cut list can drop
exactly the file that needed a lane.
select-lanes.sh takes --expect-files N (the PR's changed_files; batch rows
take "expect_files") and decides full with a file_list_truncated reason when
the list has fewer distinct paths than N (a complete list never does: renames
only add their old path), when N is at or over the 3000-file cap, or when the
list itself reaches the cap. The tier still classifies what was listed. An
empty or non-numeric count is a usage error, so a workflow that loses the
count fails loudly instead of skipping the check.
pr.yml passes github.event.pull_request.changed_files. The selector contract
covers short, complete, rename-inflated, at-cap and just-under-cap lists and
the github-output and batch channels; the wiring contract requires the count
to be passed.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
_test.yml takes a shard_profile input: "standard" (the default; dry run and
release pass nothing and keep ubuntu x3 / windows x2, byte-identical
matrices) or "pr", which pr.yml now uses: ubuntu x86 and Windows run as one
job each and only arm64, the slowest leg, splits in two. A PR runs few legs
and every shard re-pays its setup, so fewer, longer jobs cost less. An
unknown profile is an error. The wiring contract pins both the default and
PR's use of the profile.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A new pr.yml job, test-impact-shadow, predicts from the code graph which C
test suites a change can affect and uploads the prediction, so graph-based
selection can be judged against what actually failed before anything gates
on it. It is continue-on-error and not in ci-ok's needs: it can never block
or pass a merge.
scripts/ci/test-impact-shadow.sh --fetch-release downloads the latest
published release archive for the host plus checksums.txt with the job's
read-only token and verifies the SHA-256 (seconds instead of a build, and a
PR that breaks the indexer cannot corrupt its own prediction); pr.yml builds
the checkout only if that fails. --binary/--out then indexes the checkout
into a throwaway HOME/cache with a private CBM_RUNTIME_DIR (short, under
/tmp, so the daemon socket fits sun_path), runs detect_changes against the
merge commit's base at depth 15 and hands it to predict.py. The graph is
only consulted when C sources changed.
scripts/test-impact/predict.py ports the replay's mapping: impacted test
functions -> registered suites (perf excluded; nested RUN_SUITE children
resolve to the registered suite that runs them), changed test files ->
whole suite, fixtures -> suites that name them, grammars -> grammar suites,
and run-all on the selector's full, a test header, test infrastructure, or
a saturated/truncated impact set. It writes prediction.json and a short
step-summary table.
tests/test_test_impact_predict.sh (Step 0z5) pins the mapping on a
synthetic tree; the wiring contract requires the job to stay
continue-on-error; the script joins the venue-parity interface probes.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
pr.yml's `changes` job now lists the changed files (renames with their old
path too), runs scripts/ci/select-lanes.sh in a credential-less step and
outputs tier, full, lanes and the pr-smoke matrix. security, lint and test
pass the lanes to their reusable workflows, which take a `lanes` input
defaulting to "all": dry-run.yml and release.yml pass none and run exactly
as before (setup-matrix output for every broad/shard combination is
byte-identical; the TSan matrix moved into setup-matrix with the same legs).
Under a selection, setup-matrix keeps only the tagged legs of the selected
lanes (an untagged leg is an error, never a silent drop) and each lane job
has an `if:` on its lane. pr-smoke and memwaste run on their lanes instead of
the old product regex. Docs-only PRs (T0a) get a `contracts` job running the
new `scripts/test.sh --contracts-only` (Step 0 contracts, no build). ci-ok
needs contracts, lists the run's jobs (actions: read) and passes LANES/JOBS
to require-all-green.sh; shard-completeness passes --lanes.
tests/test_lane_wiring_contract.sh (Step 0z4) pins it: dry run and release
pass no lanes and the inputs default to all, pr.yml wires the selection
through, and every lane is served by a tagged leg or an `if:` naming it.
The smoke-fixture contract now checks the selector's decisions for the
smoke-harness paths instead of the removed regex; select-lanes.sh joins the
venue-parity interface probes.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Under lane selection a PR runs a subset of its jobs, and "skipped" stops
meaning "not needed": a selected lane that was skipped, cancelled or never
created is a silent gate loss that the needs-level ci-ok waves through.
require-all-green.sh keeps the needs-level check and, when given the
selector's LANES and the run's JOBS (Actions API job list), requires every
selected lane to have at least one job and all of them to have succeeded.
Unselected lanes may be skipped or absent but may not fail; memwaste stays
report-only. A selection it cannot trust (not a JSON list, unknown lanes, or
neither lint nor contracts) is red. Matrix jobs are mapped by the lane value
in their name, single jobs by their job id.
verify-shard-union.sh takes an optional --lanes: no manifests at all is
green only when the selection holds no test leg; without --lanes (dry run,
release) or with "all" it fails exactly as before.
tests/test_lane_gate_contract.sh drives both with synthetic inputs
(selected-skipped/cancelled/missing -> red, unselected-skipped -> green,
no legs -> green only for an empty leg selection); Step 0z3.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
scripts/ci/select-lanes.sh maps a PR's changed-file list to a tier, a lane
set, a full flag and the reasons: each path gets a category, the categories
give a tier (T0a docs .. T5 core), the tier gives the R1 lanes, and a few
categories add lanes of their own (pkg -> wrappers + 3-OS smoke, graph-ui ->
Windows guards, memory core -> memwaste, ...). It fails safe: tier T5, an
unmatched path, an empty list, the selector itself, any workflow, the test
harness, Makefile.cbm and vendored code select every lane. The output is a
pure function of the file set.
tests/test_select_lanes.sh pins the decision table (43 cases), determinism
(order, duplicates, CRLF), the input channels and the lane universe.
scripts/test-impact/replay-selector.sh replays all 767 September 2026 PR
pushes and the 22 real failures from a compact fixture: every push gets its
expected tier (729 equal to the analysis, 38 documented deviations where the
analysis split Makefile.cbm diffs by content and the selector, seeing names
only, is conservatively full), and every real failure's lane is selected.
Both run as Step 0z/0z2 of scripts/test.sh.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Why: the activation rendezvous is per OS account, so an install or update
run under another HOME / CBM_CACHE_DIR (a sandbox, a second profile) meets
the account's live daemon at the same endpoint. The daemon's
activation-shutdown handler verifies only the requester's build, never its
cache, so the CLI's scope decision is the only thing that tells "my daemon"
from "someone else's". That decision drained the cohort unless the active
cache was positively DIFFERENT: a busy session group (another activation or
a session start held it, so the active identity was never read) and an
unreadable active cache both fell through to "ours", printed "Stopping
active CBM sessions" and shut the daemon and every session behind it down.
Fix (user decision 2026-09-28, "unknown = foreign, keep it"): only a
positive match may drain. cli_activation_resolve_scope returns
CLI_ACTIVATION_SCOPE_ACTIVE only for an OK admission (exact identity, cache
included, or an empty group) or for a conflict whose cache fingerprint
equals this activation's. BUSY and a blank active cache now return the new
CLI_ACTIVATION_SCOPE_UNCONFIRMED, handled like a foreign cohort: no lock is
taken, no drain request is sent, the mutation proceeds, and the audit log
records "quiesce_skipped: active cohort ownership unconfirmed (...)". A
read-only status probe names the daemon that was left (pid, version) and
the notice says how to stop it: close its sessions, then run
`codebase-memory-mcp daemon stop`. The confirmed paths are unchanged: a
normal self-upgrade still drains its own daemon, a clearly foreign cache is
still left alone.
Proof (product binaries; private CBM_RUNTIME_DIR; an own permanent test
daemon started from the same build under a host HOME/cache; a sandbox
HOME/cache on the same runtime dir; the cohort maintenance gate held by a
second process, i.e. a busy session group):
- origin/main: "Stopping active CBM sessions and operations for
install...", the test daemon was stopped (daemon status: not running),
and the install still failed (rc=1 after 23 s, "could not be stopped
safely").
- this change: "Leaving the running CBM daemon untouched (pid 76713,
version dev): this install could not confirm that it serves the cache
this install targets (the session group was busy, so its cache identity
could not be read)." The test daemon kept running with the same pid and
the install completed (rc=0, 10 s).
A lifetime record whose cache field is zeroed is refused (UNSAFE) by both
builds with the daemon kept; that path never drained and is unchanged.
Tests (cli suite; fixture daemon in a private runtime dir; sandbox HOME):
- cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon
(the new seam cbm_cli_set_activation_scope_cache_unreadable_for_test
blanks the observed active cache fingerprint)
- cli_install_foreign_home_busy_cohort_keeps_host_daemon (the maintenance
gate is held through the product's own lock-directory handle)
Both were RED 3x on origin/main for the right reason (the host daemon was
drained: ASSERT(host_serving)), are green with the fix, and turn RED again
when the fix is reverted. Controls stay green:
cli_install_into_host_namespace_still_drains_host_cohort (confirmed same
cache: drained, as before) and
cli_install_{skip_binary,binary}_into_foreign_home_never_drains_host_cohort
(clearly different cache: kept, as before).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The parallel-scheduler contract failed twice on test-windows CLANG64 while
running fake suites only:
- PR #2394 (run 36364479687): "Windows timeout race refused without a
surviving descendant to refuse over". The refusal came 38s after the
forced leader exit (a cold powershell/CIM descendant probe, once in the
wave loop and again in the cleanup pass), while the fixture's descendant
was a `time.sleep(30)` that had already exited on its own.
- PR #2345 (run 36199749518): "suite 'hang_after_summary' taskkill could
not prove process-tree cleanup". The scheduler bounded taskkill.exe with
--kill-grace, which the contract passes as 1s, so a slow cold start of
the tool was reported as a failed cleanup.
Both verdicts were a race between a clock and runner latency. An audit of
the fixture found more of the same shape:
- Every hanging fixture ended on its own after 30s. A starved scheduler
could therefore see a "hung" leader exit 0, and a descendant the
scheduler failed to kill could vanish before the leak check looked.
- The 1s --timeout could kill a slow leader before it had printed its
summary (asserted pass=1), spawned its descendant or written the
descendant pid. That pid was then read back with int() from a file
written create-then-write, so a reader could see no file or an empty one.
The empty-file read recorded earlier was the scheduler's ready file,
which 3b9052c9 already publishes atomically; the fixture's own writer
had the same shape.
- The descendant installed its SIGTERM-ignore during its own start-up. A
SIGTERM that won that race skipped the SIGKILL escalation the POSIX leg
exists to cover, silently and with the verdict unchanged.
- On Windows, the stubborn-tree leak check was a single look right after
taskkill, but TerminateProcess returns before its target has exited.
Fix it by construction, not by budget:
- A gate. Every process that must outlive the scheduler's decision blocks
reading stdin. That is a pipe whose only write end the contract holds,
handed down through the scheduler, and the read returns only at EOF. The
contract closes the pipe once the verdict is recorded. If the contract
dies, EOF releases everything, so nothing is orphaned and no fixture
ends on a clock.
- The three hanging scenarios run under the scheduler's existing
pre-terminate barrier. They are released only once the fixture has
published `<suite>.established`, which happens after the summary is
flushed, the descendant has confirmed it is armed, and its pid is known.
The marker is written to a temp file and moved into place with
os.replace, so a reader sees either no file or the whole file.
- The Windows leak check waits on the descendant's process handle. That
wait is exact, not a race: the descendant is held by the gate, so the
scheduler's kill is the only way it can end.
- run-test-wave.py runs taskkill through windows_taskkill_tree() with its
own stable-state budget (WINDOWS_TASKKILL_SECONDS = 15), as 132e8fc3 did
for the descendant probe. --kill-grace keeps bounding what its name
says. Real runs already pass --kill-grace 15, so they are unchanged.
The contract pins this structurally, the same way it pins the probe:
exactly one taskkill site, no kill-grace there, and a helper bounded by
the constant that fails closed on a timeout, a start failure or a
non-zero exit.
Every assertion keeps its contract:
- A leader that hangs after a green summary is recorded as rc=124 pass=1.
- A zero-test child is recorded as rc=97.
- The wave continues after a bounded failure.
- A SIGTERM-resistant tree is killed in full.
- A leader that exits between the timeout decision and termination is
still cleaned up on POSIX. On Windows the scheduler refuses (rc=2,
naming the cleanup failure) over a descendant that is really alive.
A new self-check fails loudly if a held leader or descendant is not live
while the barrier holds, that is, if the fixture no longer exercises what
those assertions claim.
Proof, on macOS arm64 and a Linux arm64 container. The Windows branch was
reasoned through; the VM is down.
- Torture hook (proof only) that delays each hanging leader 2s, past the
1s timeout. The old fixture is RED 3/3 on every scenario (pass=0,
FileNotFoundError on descendant.pid, "lost its bounded result"). The new
one is green, including at 8s, and 10/10 under the hook plus CPU load.
- The #2394 mechanism: after an injected 31s refusal latency the old
descendant is gone and the new one is live. It exits 0.02s after the
gate closes.
- The taskkill pin is RED on the old scheduler and green on the new one.
- Stress, 20 runs each under CPU load. The old full contract passed 20/20
on macOS and 20/20 on Linux, so these hosts do not reproduce the CI
timing. The rebuilt held scenarios passed 20/20 on macOS and 20/20 on
Linux. The full contract after the change passes a single run on macOS
and on Linux, and 20/20 on each leg under CPU load (4 load workers;
the Linux container is capped at 4 CPUs).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>