Skip to content

Report truthful push and round results, with records and metrics (#6034) - #43

Merged
inkeep-oss-sync[bot] merged 1 commit into
mainfrom
copybara/sync
Oct 9, 2026
Merged

inkeep-oss-sync[bot] merged 1 commit into
mainfrom
copybara/sync

Conversation

@inkeep-oss-sync

Copy link
Copy Markdown

Copybara-translated 1 Inkeep OSS change. Rebase-merge this PR so the prepared commit lands directly on public main.

* Add the U-records slice spec for truthful push and round results

The gate-placement unit that makes push and round results say what
happened: D31's outcome classes per step, one result marker per run,
round records, NOT-RUN for missing toolchains, the clean-tree
precondition, the metrics script, the contract's matrix reach and the
flake-admission and doctrine documents. Records the delegated decisions
with their reasons and the items that drop out.

* Record the decomposition's decisions in the U-records spec

Add DR-15: a supervisor hands INKEEP_GATE_RESULT_FILE only to a child
supervisor whose result it reads and removes it from every other child,
because supervisors pass their environment on and a leaked value would
let fixture supervisors inside a test step overwrite the outer result.

Also record that plan queries keep their exits and print no marker,
that a heuristic kill under a required CI home is reported while an
observed kill blocks (the push's exit rule is unchanged), that step
records carry the outcome reason as outcomeReason because reasonCode
already names the selection reason there, and that none of the prelude
edits adds a runner to the hook.

* [US-001] R1: Add the shared gate outcome module

Add scripts/gate-outcome.mjs, a zero-dependency module that every push and
round supervisor will share, and its paired test.

- classifyStep applies the step precedence in order: a signal Node observed,
  the supervisor's own interruption, a bare 130, 137 or 143 as a heuristic
  kill, 0 as pass, 77 as not-run only when the child's own output carries a
  column-0 NOT-RUN: line or a not-run marker (otherwise a defect with reason
  unmarked-not-run), a missing or non-executable binary as not-run with
  reason spawn-failed, and any other status as a defect that keeps its
  native status.
- runOutcome derives the run class (defect, then not-run, then killed, then
  pass), its exit (0, 1, 77, 128+N, or the bare status of a heuristic kill),
  incomplete with the killed step ids when a kill follows the first defect,
  reported reds that leave the class alone, and the summed child bytes.
  exitFor gives each class its exit, not-applicable 0 included.
- formatMarker and parseMarker write and read the INKEEP_GATE_RESULT_V1 line:
  one line of compact JSON holding only the result fields, with string
  values limited to repository-relative tokens so no absolute path or
  environment value reaches it. The reader takes the last column-0 marker
  and ignores indented ones.
- emitResult writes the result to INKEEP_GATE_RESULT_FILE when it is set and
  prints no marker; otherwise the marker is the last stderr line. A parent
  takes the recorded class only when the recorded exit equals the status it
  observed and agrees with that class. createResultFile and childEnv give a
  nested supervisor a fresh file and strip an inherited one from every
  other child.
- observeChild streams a child's stdout and stderr to the supervisor's sinks
  byte for byte, counts the bytes and collects the column-0 NOT-RUN: and
  marker lines. It waits for the child's close, so output larger than
  spawnSync's buffer arrives whole, and it keeps draining the child when a
  sink fails, so a reader that goes away neither hangs nor crashes the
  supervisor. The marker write tolerates a broken stderr for the same
  reason.

The test runs the real CLI e2e install reporter and reads its nested not-run
marker.

* [US-002] R6: Exit with a killed test batch's signal status

The paired-test runner turned every batch that died by a signal into exit 1,
so a push read a killed batch the same way as a failing test. runBatch now
returns 128 plus the signal number (143 for SIGTERM, 137 for SIGKILL) and still
names the signal on stderr, with the memory-pressure hint for SIGKILL. An
unknown signal keeps exit 1.

runBatches combines its batch and tree-integrity statuses so that a verdict
outranks a kill: the first non-zero status that is not a signal status wins,
in the existing order (tree-mutating batch, parallel batch, then the two tree
checks), and a signal status is returned only when nothing produced a
verdict. A test failure in the other batch, residue in the tree, or an
unreadable git status under CI therefore keeps its own exit when a batch was
also killed. Without a kill every combination returns what it returned before.
The comment sentence saying signal deaths stay 1 on purpose is removed because
it is no longer true.

Tests: a batch whose real child dies by SIGTERM exits 143; the SIGKILL case now
expects 137; per-order subtests for a kill beside a pass and a kill beside a
verdict; residue after either batch and an unreadable status under CI still
win over a kill. Each new case except one fails against the anchor runner for
the behavioural reason; the exception (parallel killed, tree-mutating failed)
already returned 1 there and is kept as a preservation check.

Node 24's node --test handles SIGTERM and SIGINT itself and exits 1, so in
practice only SIGKILL or a crash reaches the runner as a signal death.

* [US-003] R7: Report a missing subtree install as a NOT-RUN line

The Open Knowledge and discord-linear-sync wrappers now print
"NOT-RUN: <script> (<root> toolchain not installed; run <install>)" on
stderr and exit 0 when the subtree is not installed outside CI, in place
of the yellow "! <script> skipped" line. Under CI=true both keep their
::error:: line and exit 1, and an installed subtree delegates as before.

The three root lint-staged entries that run an Open Knowledge tool (the
two Biome writers and oxlint) print "NOT-RUN: biome check (...)" or
"NOT-RUN: oxlint (...)" on stderr and exit 0 when the tool is missing,
in place of the "skipped" lines they printed on stdout. lint-staged
shows a passing task's output only under --verbose, as it did for the
old lines.

A new test runs each of those entries the way lint-staged does (its own
argv parser, no shell), once in a tree without an Open Knowledge install
and once with stub tools: without the install the entry prints the
NOT-RUN line, runs nothing and exits 0; with it, the entry reaches the
tool and prints no NOT-RUN line. CI's script-tests job installs Open
Knowledge, so the existing conditional test's not-installed branch never
runs there; the new test exercises that arm on every host.

* [US-004] R9: Read a CI home's matrix the way GitHub expands it

The push-row validator's layer 3 decided whether a declared CI home still
runs a task by collecting the matrix axis values and every include value,
then dropping each value an exclude entry named under the exact axis key.
That kept an excluded task's home when the exclude key differed only in
letter case, could not see an include add an excluded task back, removed a
task from every combination when an exclude named it together with another
axis, and stopped with a TypeError when the exclude list was an expression.

expansionMembers now expands the matrix as GitHub's workflow parser does
(MatrixBuilder and WorkflowTemplateConverter in actions/runner): the
combinations of the list axes, less each one an exclude entry matches on
every variable it names, plus the include entries, which apply after
exclude. Variable names and string values compare without regard to case
and nested values match on the parts an entry names, as GitHub's
expression equality does. A ${{ }} is not evaluated: an exclude entry
naming a ${{ }} axis, or an exclude list written as one, removes nothing,
and an include list written as one adds nothing. Scalars of different
kinds are not coerced, and every include entry's value counts. Only the
matrix branch changed; the input, turbo and file branches are untouched.

The homes self-tests gain the four cases the spec names plus three more:
an exclude value differing in case, a partial nested exclude, and include
or exclude lists written as expressions. All seven fail against the old
reader. Both protected paths have new dated entries in
config/pre-push-contract.CHANGES.md, the tests' entry first.

* [US-005] R2: Class the scoped push helper's rows and end each run with one result

The scoped push helper (pnpm check:pre-push run directly, or as the owned
push's scoped row) now classes every row by the shared outcome rule in
scripts/gate-outcome.mjs and ends each run with the run class's exit and
exactly one INKEEP_GATE_RESULT_V1 marker as its last stderr line, or, when
INKEEP_GATE_RESULT_FILE is set, writes that result to the file and prints
no marker.

Rows run with their stdout and stderr piped and streamed through the
outcome module, which forwards the bytes unchanged, counts them and reads
the column-0 NOT-RUN and marker lines. So a row killed by a signal is
killed (exit 128+N, no remedy text), a bare 130, 137 or 143 with no signal
is a heuristic kill, a 77 is not run only with its own column-0 NOT-RUN
line, a row that cannot start is not run with a NOT-RUN line naming it,
and a defect followed by a kill in a later slot ends as a defect marked
incomplete with the killed row named. A row's byte count is now recorded
whatever the helper's own output is, and output larger than the default
child buffer is forwarded whole.

The Open Knowledge executor row keeps inherited output, because the
executor counts its own steps' bytes from its file descriptors, and gets a
fresh INKEEP_GATE_RESULT_FILE; the helper takes the executor's recorded
class only when its recorded exit equals the status the helper observed.
Every other row, and the executor's plan query, runs with the variable
removed, an inherited value included.

Availability refusals in a run are not run, exit 77, with a NOT-RUN line:
an uninstalled or stale standalone root (its refusal lines are unchanged
byte for byte, the NOT-RUN line follows them), a scope discovery failure,
and zod missing at the root. A defect before the standalone refusal keeps
the run a defect. A present but broken departure helper keeps its exit 1
and is a defect (reason departure-helper-unloadable): its remedy is to
restore a tracked file, so the change is what needs attention, not the
environment. Usage and configuration refusals keep exit 2 with no marker,
and plan queries (--select-only, --print-base and the hook's questions)
keep their exits and print no marker. Reported reds end the run at exit 0
with class pass and the count, and the last line still claims no pass; a
heuristic kill under a required CI home is reported that way while its row
stays killed.

Step records gain class, confidence and outcomeReason. reasonCode keeps
its meaning, the reason a step was selected; schema id unchanged.

Tests: the signalled changelog case now expects exit 143 with no remedy
(seen failing at 046e30e0cf first); the scope discovery runs, the
signalled covered row, the S-FR-11 refusals and the plan test's uninstalled
run pin their new exits; the piped byte-count case records the child's
bytes. 22 new tests cover the classes, the marker, the result file, the
refusals and the record fields, each run against 046e30e0cf's helper and
seen failing there, except the absence checks, which pass there and fail
there on their present twin. scripts/gate-outcome.mjs joins every fixture
list that copies the helper, and the changed test files' env helpers drop
INKEEP_GATE_RESULT_FILE.

* [US-006] R2: Class the owned push's steps and end each push with one result

The owned push (the helper under the hook's ownership input) now concludes
every step with the shared outcome rule instead of turning a signal into
128+N and propagating each step's native status:

- A step's verdict comes from what the push observed: its signal, its
  status, the column-0 NOT-RUN and result lines in its own region of the
  push log, and, for the scoped helper row, the result the helper wrote to
  a fresh INKEEP_GATE_RESULT_FILE, accepted only when its recorded exit
  equals the observed status. A kill inside the scoped helper therefore
  reaches the push as killed with its signal, and a scoped defect followed
  by a kill keeps incomplete and the killed row ids.
- Every other row, the inline warning and the conflict check included,
  runs with INKEEP_GATE_RESULT_FILE removed, so an inherited value never
  reaches a child.
- Step records carry the signal, class, confidence and outcomeReason; a
  killed step records a null exit code and its signal, not 143.
- The push stops at the first blocking step and exits by the run class:
  0, 1 for any defect whatever the step's own status (which stays on its
  record), 77 for not-run, 128+N or the bare status for a kill. Reported
  reds keep exit 0 with class pass and the count in reportedReds, so a
  heuristic kill of a step a required CI check runs is reported, while an
  observed kill blocks.
- A blocked not-run step's column-0 NOT-RUN lines are repeated at column 0
  after its indented tail.
- Each run writes one outcome record (class, reason, confidence,
  incomplete, killed steps, reported reds, output bytes and a diff class
  of ok-only, root-scripts-only, dependency, mixed or other) under the
  same records schema id.
- The push ends with one marker on stderr when no result file was handed
  to it, or writes its result to the file it was given.

finishPushRun now prints the marker only after stdout has flushed. With
stdout and stderr on one backed-up pipe, as under the hook, a direct
stderr write landed inside a partial stdout line; flushing first keeps
the marker whole, at column 0 and last. The flush absorbs a stdout write
error, because a run whose stdout reader has gone would otherwise die of
an unhandled EPIPE with exit 1 instead of its own status.

Tests: the thin-hook suite pins each class through the hook (the paired
test runner's exit 143 as a heuristic kill, killed or reported by
coverage; a direct child signal with its log lines and byte count; the
scoped helper's observed kill through its result file; exit mismatch;
incomplete carry; result-file isolation; marked, unmarked and indented
77; the inline warning's class). Pins that expected a propagated native
exit (3 and 4) now expect 1 with the native status on the step record.
The push-records suite pins the outcome record and the diff classes.
Under the anchor hook, pnpm's own exit report for the dispatch still
follows the marker on a failing push; the hook's EXIT trap takes over the
single emission in the prelude story.

* [US-007] R3: End every push through the hook with one result marker

The hook's EXIT trap is now the push's one emitter. It prints the result
the owned push wrote into a result file the hook made for it, when that
result's exit equals the push's exit, and otherwise a marker it builds in
shell from fixed tokens: the prelude's own reason when it has one,
else the class DR-1 gives the exit (0 pass, 130/137/143 a heuristic kill,
an unmarked 77 or any other status a defect that exits 1). A push that
inherits INKEEP_GATE_RESULT_FILE writes that line there and prints none.

Prelude edits (DR-7, P1 to P9 only):
- P1: gate_outcome and gate_marker, run by the trap after the core.bare
  canary and the end record; the result file is removed afterwards.
- P2: HUP, INT and TERM set killed/interrupted before exiting 129/130/143.
- P3, P4, P6: the symlinked node_modules, unreadable subject and both
  dependency preflight refusals keep their text, add a NOT-RUN line and
  exit 77 (symlinked-node-modules, subject-unreadable, toolchain-missing,
  toolchain-broken).
- P5: a not-applicable push exits 0 with its subject code.
- P7: not_run sets push-helper-missing.
- P8: only the helper dispatch gets the hook's result file, as a command
  prefix; the own-hook fallback never sees it and the outer hook stays
  silent after the branch's own hook. If mktemp fails, the trap derives
  the marker.
- P9: a mid-run core.bare flip ends the push defect/core-bare-flipped.
The run-condition block and every record_prelude_* name are unchanged,
and the push-row validator reports no finding.

push-subject.mjs prints "not-applicable <code>" (deletion, tag-only,
not-head, empty) and records the code. push-records.mjs end takes --class
and --reason-code, so the hook's terminal run record carries the class
and reason for the runs the helper does not record.

The 5582 vintage freezes the slice-1 hook and helper closure from
6bcf08770a for the two skew cells: the slice-1 helper under this hook
gets a derived marker, and this helper under the slice-1 hook prints its
own. Tests that pinned exit 1 for the prelude refusals now expect 77;
the owned-push marker check now requires the marker to be the push's
last stderr line, since pnpm's exit report no longer follows it.

* [US-008] R4: Refuse a push whose working tree is not the pushed commit

Under the hook's ownership input the push helper now checks the working
tree before its first row. Any tracked change, staged or not, anywhere in
the repository, or a non-ignored untracked file under a selected subtree
directory, scripts/, config/ or .github/, ends the push as not-run 77 with
reason dirty-tree. It names the paths and the remedy (commit, or git stash
push -u -m <tag>) and spawns no row. When the selection cannot be computed
(no usable base, or the workspace or contract unreadable) the untracked scope
narrows to scripts/, config/ and .github/, and the scoped step reports the
selection failure. A git status that cannot answer ends the push as not-run
with reason tree-state-unreadable instead of reading as clean. A direct
pnpm check:pre-push is not a push and does not apply the check.

The predicate lives in scripts/clean-tree.mjs, so the mutation probe can
open it. The four fixture lists that copy the helper's imports carry it. The
check writes a helper:clean-tree step record whose additive envNames field
lists which of CI and GITHUB_ACTIONS are set, by name only, under the same
records schema id.

The Open Knowledge executor's own push refusal of a dirty subtree now exits
77 with a NOT-RUN: line in every mode, --select-only included, instead of 2.

Two fixtures change with the rule. The Agents UI root-lockfile scratch now
sets pmOnFail: ignore as the real root does, because without it pnpm 12
rewrites the fixture's lockfile on pnpm run and dirties the tree before the
push starts. The hook-context case that plants a step into the hook commits
the planted hook, so the push it runs is clean.

* [US-009] R5: Class the root round's steps and end each round with one result

The root round now runs each step with its output streamed through the
shared outcome module, classifies it by the per-step rule, and ends with
the run class's exit: pass 0, defect 1 (the native status stays on the
step record), not-run 77, killed 128+N for an observed signal or the bare
status for a heuristic kill. A top-level round prints one
INKEEP_GATE_RESULT_V1 marker as its last stderr line, after stdout
flushes; a round handed INKEEP_GATE_RESULT_FILE writes its result there
and prints none.

A declared subtree round gets a fresh result file and a fresh round
report file (INKEEP_ROUND_REPORT_FILE; the push keeps
INKEEP_PUSH_REPORT_FILE). Its recorded class is used only when the exit
it recorded is the status the round observed, and its reported steps
become nested records under its step. Every other step runs with both
variables removed, so a value the round inherited reaches no step.

A round whose comparison cannot be established (no base can be derived,
the base shares no history with HEAD, or the changed files cannot be
listed) is not run: its explanation, a NOT-RUN line and a not-run marker,
exit 77. Usage and configuration refusals keep exit 2 and print no
marker; a present but broken departure helper stays a configuration
refusal. A step that cannot start is not run with its own NOT-RUN line.

push-records.mjs takes the boundary as a parameter, push by default,
under the same schema id. The round writes a start record, one record
per step (class, confidence and outcomeReason beside the selection
reasonCode), an outcome record with its diff class and an end record,
and keeps its own last-run file, so a round never touches the push's.
The push summary counts push records only. An unwritable records
directory changes neither the round's exit nor its marker.

* [US-010] R5: Class the Open Knowledge executor's steps and hand its result up

The Open Knowledge executor now concludes each step by its own copy of
the outcome rule (signals observed as killed, a bare 130, 137 or 143 as a
heuristic kill, a defect exits 1 with the native status kept on the
step) and ends every run with the run class's exit. The copy lives in the
executor because its test fixtures run it from standalone roots with no
monorepo above them; scripts/gate-outcome.test.mjs holds both rules to
one case table covering every arm and the run-class precedence.

A missing or stale install (node_modules/.pnpm/lock.yaml differing from
pnpm-lock.yaml, the comparison the push helper already makes) and a
comparison git cannot establish are now not-run 77 with a NOT-RUN line,
at either boundary and in every mode. Usage and configuration refusals
keep exit 2 and print no marker. A top-level run prints one
INKEEP_GATE_RESULT_V1 marker as its last stderr line; a run handed
INKEEP_GATE_RESULT_FILE writes its result there instead, and no step
inherits that file or the round report file.

Under the root round the executor appends one report entry per step to
the report file it is handed, and the root records them as nested round
records. Run directly, the round records its own start, steps, outcome
and end through the monorepo's scripts/push-records.mjs when that module
exists, and writes none when it does not; records never change the exit.

* [US-011] R8: Compute the gate metrics cells from the records and the forge

scripts/gate-metrics.mjs computes D35's cells (a) to (h) for a window
from the push and round records in the clone's common git dir and from
GitHub, read only through gh.

(a) and (b) are reported per boundary. (a) counts defect, not-run and
killed runs over started runs, leaves not-applicable runs out of every
denominator, and breaks the counts down by diff class and load band. A
run is loaded when any of its records shows a load average above its
core count.

(b) counts defect runs whose terminating step (the last blocked step)
carried an observed signal, over the runs with that evidence or a
recorded supervisor interruption. A defect marked incomplete is not a
misfile. Bare 130, 137 and 143 exits are a separate heuristic cell.

(c) gives push wall-clock p50 and p90 per common profile and band, with
selected steps and cache condition. The quiet ceiling is unknown
because FR-20's break-even needs (e), which stays unknown with its
count of candidate defect pushes.

(d) sums the seconds OK-only pushes spent in other subtrees' steps.

(f) to (h) come from the repository activity API, which reaches back to
the repository's first activity, plus each pushed head's and queue
candidate's required check results. (f) is the actor's pushed heads
with no trusted local run. (g) is the CI first-catch rate and the time
from first attempt to first green per branch. (h) is failed, no-verdict
and evicted counts per required check on each queue candidate.

--offline makes no forge call. --save-forge and --forge-file write and
read the forge data for the window it covers. A window that starts
before the retained activity, or a gh call that fails, leaves (f) to
(h) unknown with the reason.

* [US-012] R10: Document the gate result classes, records, metrics and retry bar

The CI runbook's "Pre-push NOT-RUN" entry now describes the result line every
push and round ends with (INKEEP_GATE_RESULT_V1, the five classes and their
exits, the per-step order that classes a step, how a nested supervisor hands
its result up, and the pnpm ELIFECYCLE line that can follow it), and lists each
NOT-RUN case with the reason code its result line carries: the missing push
helper, the root toolchain missing or broken, a symlinked node_modules, the
dirty-tree refusal with its paths and remedy, an unreadable push subject (now
exit 77), a missing or stale standalone install, scope discovery, the
validator's missing zod, a command that cannot start, the Open Knowledge
executor's own refusals and the round's. The scope-discovery, standalone-install,
symlink, reported-reds and core.bare entries name their result lines too.

"Push records" documents the class fields on step, outcome and end records,
round records (boundary round, last-round-<id>.json, the executor's nested
steps through INKEEP_ROUND_REPORT_FILE) and scripts/gate-metrics.mjs: its eight
cells, the unknown cells, --offline, --save-forge and --forge-file, and gh as
its only forge client.

The Open Knowledge retry entry and AGENT-GATES.md state FR-13's bar for
admitting a new retry kind: two witnesses of the failure, a quiet witness and a
stated cause, admitted only by a reviewed change to
classify-ok-validation-retry.mjs, with a kind lacking it staying an
outside-change red. AGENT-GATES.md also gains the run results the executor
prints or hands up, its availability refusals as not-run 77, the clean-tree
sentence as not-run 77, and FR-12's four doctrine items (red-proof, the
gate-job denominator, sample identity, planted positive and adjacent negative)
with their provenance in #4328's TESTING.md at 6d33c38064.

Both Discord exception reasons in config/pre-push-contract.json now say that
Discord's typecheck and tests run in no workflow and that its format check runs
in private-pr-validation.yml (Monorepo Structural Validation, job "monorepo /
format drift and actionlint").

* Fix the post-implementation review findings in docs, metrics and one test

- gate-metrics: the two unknown reasons say what they mean instead of
  naming spec rows (the break-even ceiling; attributing a refusal to the
  pushed diff). Two test titles and their expected text follow; no test
  was added or removed.
- AGENT-GATES.md and CI_RUNBOOK.md: the Open Knowledge executor's
  missing- or stale-install refusal does not apply to a push plan query,
  which the push helper reads without an install so that it makes the
  install refusal itself. Both documents said every mode.
- push-records.test.mjs: the test that kills the hook's process group
  gives the hook a test-owned TMPDIR, so the result file the killed hook
  created no longer stays behind in the shared temp directory.

* Align the U-records spec with the implementation

DR-7 now lists the hook edits as they landed, for the sibling rebase:
the result state and functions, the trap's emission and record fields,
the refusal and subject cases, the result file passed as a command
prefix, and the silent outer hook in the own-hook fallback. DR-3 states
the install refusal's exception for a push plan query. DR-6, DR-13 and
DR-15 note where the executor reads no step output, the Visimer and OK
Marketing round entries' exit 2, and the enumeration children that still
inherit the result file. Risks gain the test runner's trapped SIGTERM
and lint-staged hiding passing output.

* Reuse the records module's percentile in the gate metrics

scripts/gate-metrics.mjs carried a byte-identical copy of the nearest-rank
percentile that scripts/push-records.mjs uses for its push summary. The
metrics now import that function, so the push timing percentiles in the
summary and in cells (c) and (g) come from one definition, and a change to
the rule can no longer reach one report and miss the other.

Class: a new helper that repeats an existing one.
Verification: node --test scripts/gate-metrics.test.mjs (51/51; cells (c)
and (g) assert p50 and p90), node --test scripts/push-records.test.mjs
scripts/push-subject.test.mjs (103/103), node --check on both files,
node scripts/check-guard-pairing.mjs, and a before/after differential of
computeMetrics, render and fetchForge against the pre-change module over
600 generated record sets and 40 stubbed gh runs, all identical. The
mutation probe refuses both files because they name the killed class, so
no scratch mutation ran.

* Name the gate metrics' forge populations once

The metrics read two populations from the forge activity: the actor's
branch pushes, for cells (f) and (g), and the merge-queue candidates, for
cell (h). forgeOids, which decides whose check results to fetch, restated
both predicates. isBranchPush and isQueueCandidate now hold them, and
pushPopulation, queueOutcomes and forgeOids call them, so the fetch list
can no longer drift from the populations the cells count.

Each site keeps its own zero-oid and window filters unchanged. forgeOids
skips an entry with no after value (after ?? '0'), while the cells would
count it as an undefined head, so folding those filters together would
change behaviour; that difference is left for its owner.

Class: one policy computed twice (the fetch list and the cells).
Verification: node --test scripts/gate-metrics.test.mjs (51/51; the live
test compares the cells read through a stubbed gh with the same cells read
from a forge file and checks each population's check runs are fetched),
node --check, node scripts/check-guard-pairing.mjs, and a before/after
differential against the pre-change module over 600 generated record and
forge sets (251 activity entries without an after value among them) and
40 stubbed gh runs, all identical. The mutation probe refuses the file
because it names the killed class, so no scratch mutation ran.

* Tally heuristic kills with the gate metrics' counts helper

misfileRatio built the recordedAs tally for heuristic kills by hand: a
counting loop, then a key sort. The module's counts helper does exactly
that and already serves the cache, membership and CI tallies, so cell (b)
now calls it and the sorted-tally rule lives in one function.

Class: a helper re-implemented beside itself.
Verification: node --test scripts/gate-metrics.test.mjs (51/51; the (b)
test asserts recordedAs { defect: 1, killed: 1 }), node --check,
node scripts/check-guard-pairing.mjs, and a before/after differential
against the pre-change module over 600 generated record sets (484 cells
with heuristic kills, 183 of them with more than one recorded class) and
40 stubbed gh runs, all identical. The mutation probe refuses the file
because it names the killed class, so no scratch mutation ran.

* Correct two risk notes in the U-records spec

The scoped helper pipes every row except the Open Knowledge executor's,
which keeps inherited output because the executor counts its own steps'
bytes; and the metrics script's forge cells are bounded by how far back
the repository activity API reaches, not by the events API's limit.

* Specify interruption handling for every gate supervisor

QA found that the root round, the push helper and the Open Knowledge
executor install no SIGINT, SIGTERM or SIGHUP handler, so an interrupted
run prints no result line and leaves its records running, while DR-1's
arm 2 and the goal require one result. DR-16 states the contract, R2
and R5 gain the interruption acceptance, and the risk note no longer
claims the round already handles it.

* [US-013] R2, R5: End an interrupted round or push run killed and interrupted

QA-025 found that the root round and the push helper died of SIGINT,
SIGTERM or SIGHUP with no result line and their records left running,
because neither installed a listener. Each now handles its own
interruption (spec DR-16):

- A shared watcher in scripts/gate-outcome.mjs records the first SIGINT,
  SIGTERM or SIGHUP. The round, the direct helper run and the owned push
  check it before every step, so no step starts after the interruption.
- The step in flight is classed by DR-1 with the interruption as arm 2's
  input: a child seen to die of the signal stays arm 1, and a node --test
  step that exits 1 after the interruption is killed, not a defect.
- The run ends killed with reasonCode interrupted, the received signal
  and exit 128+N, through the existing end path: one marker (or the
  nested result file), the in-flight step, outcome and end records, and
  the removal of the result, report and reds handoff files. A run whose
  steps had already found a defect stays a defect marked incomplete, and
  a not-run run keeps its remedy, as D31 orders the classes.
- The helper's synchronous row runners (the Open Knowledge executor row,
  the owned steps and the inline row) now spawn asynchronously and wait
  on the child's close, because a listener in a process blocked in
  spawnSync runs only after the child returns.

Supervisors do not relay the signal to the step in flight: group
delivery already reaches it, and a second signal cuts short a child that
handles the first (pnpm 12.8.1 interrupt.rs, pnpm/pnpm#7374). Plan
queries keep the default disposition.

The runbook now describes the interruption; the Open Knowledge executor
still dies of the signal until its own story lands.

* [US-014] R5: End an interrupted Open Knowledge run killed and interrupted

QA-025 found that the Open Knowledge executor died of SIGINT, SIGTERM or
SIGHUP with no result line and its round records left running. Under the
root round it wrote no result file, so the root read the interrupted
round's last step from the status pnpm reported (diagnosis runs o1 to o3,
n1 and n3). The executor now handles its own interruption (spec DR-16),
with its own copy of the rule because its tests run it in roots with no
monorepo above them (DR-11):

- On run paths it watches for SIGINT, SIGTERM and SIGHUP. Plan queries
  keep the default disposition, as the push helper's do.
- Every step now spawns asynchronously and is awaited until it closes,
  because a listener in a process blocked in spawnSync runs only after the
  child returns. Output is still inherited, so the byte meter is unchanged.
- No step starts after the interruption. The step in flight is classed
  with the interruption as arm 2's input, so a step that exits after it
  with no signal of its own, as node --test does, is killed, not a defect.
- The run ends through interruptedOutcome, the same rule as the root copy:
  killed with reasonCode interrupted, the signal and exit 128+N. A run
  that already found a defect stays a defect marked incomplete, and a
  not-run run keeps its remedy. It prints one marker, or writes its result
  to the file its parent named, closes its records, and removes its
  changed-file list on exit.
- Its own step records carry the verdict's signal, as the root round's
  and the push helper's do. Report entries keep the raw exit facts.

scripts/gate-outcome.test.mjs now holds both copies of interruptedOutcome
to the shared run table, for each interruption signal and for none.

Tests: the executor's round run directly and sent each signal, a step that
handles the signal and exits 1, a defect then an interruption, a root
without the records module, and a nested run handed a result file. Also
the root round sent SIGINT while the real executor runs under it through
the pnpm stand-in, which keeps the executor's killed result, its exit and
its reported steps, and leaves no temporary file behind. Through the real
pnpm 12.8.1 in the QA clone, the diagnosis's cases ok-pnpm-group-SIGINT,
nested-root-group-SIGINT and nested-okgroup-SIGINT now end killed,
interrupted and observed, with the records closed.

AGENT-GATES.md and the CI runbook now describe the executor's interrupted
run. The runbook no longer says the executor does not handle its
interruption.

An interruption that kills a planning child, whose failure the executor
then reports as a configuration refusal, still ends with exit 2 and no
result line. The listener cannot run during synchronous planning. The
root round and the push helper behave the same way.

* Record the classifier's rollout disposition against D31's loop moves

The team-skills classifier maps killed and the other non-verdict classes
to UNCERTAIN and keeps marker-free captures as they are; D31's retry,
remedy and no-marker escalation are left for a later change. The spec
now says so, as the stand-in review of team-skills #666 asked.

* Specify that a closed output reader costs output, never the run

QA found that the root round, the push helper and the Open Knowledge
executor crash on the first write after a stdout reader closes (for
example under | head), with no result line and the round's records left
running. DR-17 states that a supervisor stops writing to a gone stream,
keeps running its steps and still ends with its result.

* Record the QA decisions on round record kinds, metrics runs and temp dirs

Round records get their own kinds so the push summary from before this
change no longer counts rounds as pushes, keeping D31's shared history.
The metrics count a run only when its group has a start record. A direct
helper run writes step records only. DR-18 makes an unusable TMPDIR an
availability refusal with one result line.

* [US-015] R2, R5: Keep a gate run going when its output reader closes early

A gate run whose stdout reader went away (pnpm check:agent | head) died on
a later write with an unhandled EPIPE, ran no further step, printed no
result line and left its records running. Node's stdio streams undo their
own destroy after a write error, so the guard console adds for one failed
write goes stale and the next failed write is thrown.

The root round, the push helper (direct run and owned push) and the Open
Knowledge executor now keep a lasting error listener on stdout and stderr
for a run. A stream whose write failed counts as lost: forwarded child
output stops going to it, and a step that would inherit it gets /dev/null
instead, so a step started after the loss is not ended by it. The run
still ends with its class's exit and, while stderr has a reader, its one
result line. The supervisor's own lines still reach write(), which fails
at once and is dropped. A step already holding an inherited stream when
its reader leaves can still fail; the round's steps and the scoped
helper's piped rows go through the supervisor and are not exposed to that.

The executor carries its own copy of the two helpers, as it does for the
outcome rule, and scripts/gate-outcome.test.mjs holds both copies to one
scenario.

* [US-016] R8: Count a metrics run only when its group has a start record

A direct pnpm check:pre-push, and a push through a main checkout whose
hook predates the records, leave step records under a run id of their
own with no start, outcome or end record. gate-metrics made a run of
every run-id group, so each such group joined the push denominators of
(a) and (b), and a window holding only such groups was described as runs
that "predate the class fields or never finished" (QA-085).

A run is now a run-id group with a start record, and every cell takes
its runs from that population. (a) reports the groups without a start
record per boundary as withoutStart, and the text report names them as
not runs. The metrics tests now include start-less groups, among them
the records the real push helper writes when run directly. The runbook
states the population and no longer says a pre-records main checkout
leaves no records at all.

* Bound the metrics window by the oldest retained record

QA-076 found gate-metrics reporting a seven-day window over about 36 hours
of retained records, and reading checked pushes whose runs had rotated out
as pushes with no local run. DR-19 treats the oldest retained record as the
records' history start, the way the forge's historyStart is treated, and
shortens the default window to it.

* [US-017] R5: Give round records their own run and step kinds

Round records shared the push records' kinds, run and step, in the one
history every worktree of a clone writes. The push summary in a checkout
from before rounds kept records, which is the main checkout's until it
fast-forwards and every worktree on an older branch, selects records by
file and kind alone, so it counted each round as a push and a killed round
as a push that never finished (QA-078). Round runs are now written as
round-run and round steps as round-step, still with boundary round, so
that reader skips them as it already skips the outcome kind. Push records
keep run and step, which those readers count.

RECORD_KINDS in push-records.mjs names each boundary's kinds, and
gate-metrics assembles runs from all of them, so its round cells read the
same as before. The new test runs the frozen summary from before round
records (scripts/__fixtures__/pre-push-vintages/5582/push-records.mjs, the
blob 9d78f3ce78:scripts/push-records.mjs also has) over a store holding a
push, a finished round and a round that never finished. The runbook's Push
records section says which kinds a round writes and why.

* [US-018] R10: Name every result-line reason code and the push-helper-missing line as printed

The runbook's Pre-push NOT-RUN section put every case under the line shape
NOT-RUN: <what did not run> (<why>; <remedy>), printed after the refusal's
own explanation (QA-065). The hook's not_run() prints differently: its
NOT-RUN line comes first and names only what is missing, and the remedy
follows on a separate indented Remedy: line, then the result line. The
section now scopes that shape to the cases that print it, and describes the
push-helper-missing line as printed: its three endings, its remedy, and the
chmod remedy a non-executable hook adds.

Two reason codes reach a result line but were documented nowhere (QA-066).
A new bullet lists every reason code by class, and covers these two:

- departure-helper-unloadable: defect, exit 1, with the lines it prints and
  their remedy. Only a direct pnpm check:pre-push --base=<ref> without --all
  reaches it. A push through the hook passes the helper no --base, so it
  never loads the departure helper. The root round refuses the same file
  with exit 2 and no result line, and --print-base and --select-only refuse
  it with exit 1.
- withheld: a backstop. A test is withheld only after its Turbo step exits
  non-zero, and that step's own verdict decides the run. Three real
  withheld rounds from the executor's end-to-end cases, run with a result
  file, each ended defect with no reason code.

The tree-state-unreadable case said it refused "the same way" as a dirty
tree. It now quotes its own lines and remedy (make git status succeed, then
push again).

Checked: the 20 outcome codes extracted from the hook, the push helper,
clean-tree, the round, gate-outcome, push-records and the executor equal the
codes the new bullet names, in both directions. Every link into the runbook
resolves except one pre-existing line anchor in an old evidence file, and
the new core.bare link matches github-slugger 2.0.0. Headings are
unchanged, and .husky/pre-push, both AGENTS.md files and AGENT-GATES.md are
untouched.

* [US-019] R2, R3, R5: End a push or round not run when TMPDIR cannot be written

With TMPDIR missing or read-only, QA-073 saw two result lines on a
dirty-tree push and a Node stack trace with a derived defect on a clean
one. The hook's mktemp failed silently, so the helper got an empty
INKEEP_GATE_RESULT_FILE, printed its own marker as a top-level run, and
the exit trap then derived a second one; on a clean tree the owned push
crashed creating its reds channel.

The hook now refuses at the dispatch (DR-7 P8) when mktemp cannot create
the push's result file: it lets mktemp's own error through, prints a
NOT-RUN line naming the temporary directory and the TMPDIR remedy, and
ends not-run 77 with reasonCode tmpdir-unusable, dispatching nothing.
Lines 1-95 of the hook and every record_prelude_* name are unchanged.

The Node supervisors make the files they hand their nested runs before
their first step and refuse the same way when they cannot: the owned push
(its reds channel and its scoped row's result file), a scoped run that
selects the Open Knowledge executor row, and a round that selects a
subtree round. Making them up front keeps a step that needs the same
directory from failing first and reading as a defect, which would outrank
not-run. gate-outcome.mjs gains makeTempDir and TempDirUnusable, which
carries the reason code and renders the NOT-RUN line. A run that hands no
nested run a file is not refused.

Observed while writing the tests: pnpm creates a missing TMPDIR whose
parent is writable, so before this change a push with a deleted but
creatable TMPDIR reached the helper and passed with two pass markers.

The runbook names the new case and reason code, and the spec's P8 line
says what happens when the file cannot be created.

* [US-020] R8: Bound the metrics window by the oldest retained run

gate-metrics now knows where its retained records begin. Rotation deletes
whole history files, oldest first, so the records hold every run whose start
record is at or after the oldest retained start record. That time is reported
as retainedFrom (in the JSON records block and on the text records line), and
a window that begins before it prints (a) to (g) unknown naming it, while (h),
which reads only the forge, is still computed. With no record, or none that
starts a run, (a) to (g) are unknown. This repairs QA-076, where a rotated
store read 60 checked pushes as pushed heads with no local run.

With no --since, --days or --until, the seven-day default starts no earlier
than the retained start, and the text report says it was shortened. The
records are now read before the forge, so the live path asks gh for the window
actually used, and a forge file's coverage is checked against that window.

The bound is the oldest retained run start, not the oldest record time. A run
in flight when the history rotates keeps its later records in the new file,
and its end record carries the run's start time, which is earlier than records
deleted with the old file; the oldest record time would then report a window
that still misses whole runs. In this host's records, 10 of 131 run ends came
while another run was in flight, so a rotation can land there.

(f) and (g) now look a pushed head up in every record group, not only in the
groups with a start record: a run whose start record rotated out keeps its end
record, with its subject and class, and its pushed head should not read as
having no local run. Groups without an end record (a direct pnpm
check:pre-push, a push through a pre-records hook) carry no subject, so they
still match nothing, and no group without a start record counts as a run.

Tests: fixtures keep an earlier run two weeks before their window, so their
windows begin after the oldest retained run, and the real-writer test that
passed --days=1 uses the default window. New cases cover a window before the
retained start (JSON and text), windows at and after it, the default window
shortened and not, the live and forge-file windows, an empty store, a run the
real rotation deleted, and a run in flight across a rotation.

* Record the refused dispatch step when TMPDIR cannot be written

US-019's refusal at the hook's dispatch (DR-7 P8) ended the push not run
with exit 77 but left no step record for the dispatch it refused, while
every refusing prelude step and the own-hook refusal record their step with
the refusing exit. The refusal now writes the hook:dispatch:check:pre-push
step record with exit 77 before it exits, inside the same P8 block; lines
1-95 of the hook are unchanged.

The TMPDIR test asserts that record for a missing and a read-only TMPDIR, on
a clean tree and on one with an unstaged edit, under sh and dash. It failed
on all twelve cases before the change (no dispatch record among the run's
prelude step records) and passes after it.

* Align DR-16, DR-19 and the P8 hook edit with what was built

DR-19 now names the bound as the oldest retained run start rather than the
oldest record: US-020 found that a run in flight across a rotation keeps an
end record whose time, the run's start, can lie before deleted runs. It also
records that (f) and (g) take a pushed head's attestation from every group
with an end record, while every count still takes only groups with a start
record. DR-16 no longer says a direct pnpm check:pre-push writes outcome and
end records; it writes step records only (DR-5). P8 says the TMPDIR refusal
records its dispatch step.

* Refuse a missing Visimer or OK Marketing install as not run

Visimer's and OK Marketing's round entries exited 2 with no NOT-RUN line
when their dependencies were missing. Since this branch classes every round
step, the root round read that 2 as a defect (DR-1 arm 8) and ended with
exit 1, where it used to pass the 2 through, so an agent was sent to fix code
that had never run. On this host 23 of 45 worktrees lack Visimer's install
and 11 lack OK Marketing's.

Each entry keeps its explanatory line, adds a last NOT-RUN line naming the
install command, and exits 77, which the root round classes not-run (D16).
Visimer's entry ships to its public mirror, and the line names no monorepo
path (the entry contract's mirrored-source test still passes).

The entry contract test for a missing install now expects 77 and the
NOT-RUN line; it failed on both entries before the change (exit 2) and
passes after it, with the other 31 entry tests unchanged. DR-13 records the
change.

* Name a round's last-run file so no worktree id can collide with it

A round's last-run file was last-round-<id>.json and a push's last-<id>.json,
so a worktree named round-foo wrote its push's last-run file under the name
worktree foo's round uses, and each overwrote the other. Patches then stopped
applying, and a push whose file a round replaced ended with an unknown
subject. The round last-run file is new on this branch, so no reader on main
depends on its name.

A round's file is now round-last-<id>.json. A push's file always starts with
last-, so the two can never take the same name, and the runbook's statement
that a round never replaces a push's last-run record now holds for every
worktree name. The pre-round summary reader, which reads only last-<id>.json,
can no longer pick up a round's file either.

The new records test writes the push of worktree round-foo and the round and
push of worktree foo into one store; it failed before the change (the
round-foo push read back the foo round) and passes after it. The round and
executor tests that read the file by name follow the new name.

* Read a nested run's step report in one place that cannot throw

The root round and the push helper each copied the same loop that turns a
nested Open Knowledge run's report file into nested step records, and both
copies dereferenced every line JSON.parse accepted. A null line crashed the
round with a TypeError before its result line (exit 1, records left running,
the report directory left behind, QA-052), an array or scalar line became a
step record with no step, and a malformed turbo object threw from the cache
state reader.

The loop now lives once in push-records.mjs as recordReportedSteps, beside
openReportFile, and both supervisors call it. readStepReport keeps only plain
objects, and cacheStateFromReport returns no cache state for Turbo facts that
are not the shape the executor writes, so a report can never change a run's
exit (DR-9). A task with no cache status, which the executor reports as null,
still counts.

Tests: readStepReport over valid, null, array, scalar, string, boolean and
torn lines keeps the two objects; malformed Turbo facts give no cache state
while a null status still counts; a subtree round that appends null, array
and scalar lines to its report ends with its own pass marker and exit. Each
failed before the change (the round crashed with QA-052's TypeError).

* Carry each nested Open Knowledge step's class into its record

The Open Knowledge executor classes every step and writes that verdict into
its own records when run directly, but the report entries it hands the root
round and the push helper carried only the exit and signal, so the same step
recorded class, confidence and outcomeReason when run directly and none when
nested (DR-9 lists them as step-record fields). Deriving them from the exit
afterwards would be lossy: an interrupted node --test step that exits 1 is
killed at the executor and would read as a defect.

Both report entries now carry the step's class, confidence and
outcomeReason from its verdict, and the shared reader records them. The
reported signal stays the observed one, which gate-metrics reads as kill
evidence. The fields are additive; a reader that ignores them is unchanged.

The round test now hands a killed step through a subtree round's report and
finds its class, confidence and reason on the nested record (null before
the change); the executor's report test expects the verdict fields.

* Share the push helper's and the round's result wording

The push helper and the root round each kept their own copy of four pieces
of result wording, all new on this branch: the text that says how a step
ended, the run-end text naming each step a kill cut short, the wait for
stdout before the result line, and the NOT-RUN line for a command that could
not start (built in three places, against the one line the runbook
documents). The copies were kept apart only so an earlier story would not
touch the push helper, and later stories had to edit both.

They now live in gate-outcome.mjs beside the marker they lead up to:
stepOutcomeText (the run or the round named where an interruption ended it,
and the round's spawn hint passed in), killedText, stdoutFlushed and
spawnFailedLine. The output is byte for byte what each copy printed. Removing
the copies also drops a few blank lines the earlier report-reader commit left
doubled.

New gate-outcome tests pin every arm of the shared wording, including the
heuristic kill text no suite asserted before; the push helper, round, hook,
records, vintage and metrics suites pass unchanged (921).

* Test an Open Knowledge install that has no install record

An install interrupted before pnpm writes node_modules/.pnpm/lock.yaml
leaves node_modules in place with no record of what was installed. The
executor reads that as stale (toolchain-stale, exit 77), and QA-072 saw it do
so after a real SIGINT, but its install-state table covered only a missing
node_modules and a mismatched record. The table now also runs that state
through the root round and the Open Knowledge round run directly, expecting
not-run with toolchain-stale from both. The push helper's own copy of the
rule already had this case (S-FR-11 (f) in check-pre-push.test.mjs).

A scratch mutation of the executor's missing-record arm, which would show
the row failing when that arm stops reading as stale, was refused by the run's
probe (the file contains signal code), so that red proof was not run.

* Correct a test title and two comments that state something false

The round test that plants a push's handoff variables before starting a
round only shows that the fixture environment drops them: its title said the
round keeps its own run under a push. It now says what it checks, as its
sibling in check-pre-push.test.mjs already does.

The lint-staged test's comment said root CI runs the root script tests
without Open Knowledge's dependencies and that the wrapper announces and
skips; CI's root script tests job installs them, and the wrapper now prints
a NOT-RUN line. The push helper's slot table said the Discord subtree appears
in no GitHub Actions workflow; its format check runs in Monorepo Structural
Validation, while its tests still run in no workflow, which is why pre-push
runs them. Both comments now give the true premise.

* Keep gate-metrics computing when the records hold a kind it does not know

The records are one history every worktree of a clone writes, so a checkout
on an older branch reads records a newer writer added. gate-metrics grouped
every record carrying a run id, so a group made only of a kind it did not
know had no first record and describeRun threw a TypeError, stopping the
whole report. No writer today produces such a group, but a later unit that
adds a kind cannot repair the copies of this reader already in other
checkouts, which is how QA-078 reached the push summary; the requirement
cannot ride with that later change, so it lands here. A record of an unknown
kind now forms no group and stays out of the cells and the load band.

The three forge filters that drop a zero object id also now share one
predicate, which takes a missing id as no pushed object. The pushed-head and
queue filters used to let a missing id through while the fetch filter dropped
it; GitHub's activity entries always carry one, so no reading changes.

The new test writes a push run and a record of an invented kind into one
store; gate-metrics threw before the change and counts the one run after it.

* Record in the spec what the implementation and the no-skimp pass found

Seven statements in the slice spec no longer matched what was built or
learned, and two requirements existed only in untracked QA notes:

- §1 now says the CI-home reader misread matrices in at least seven ways,
  not four (US-004, against GitHub's own expansion).
- DR-7's version-skew note records that the hook accepts a helper's result
  on its exit alone, which is safe only while one rule maps class to exit;
  a change to that rule or a new writer of the result file must update the
  hook in the same change (QA-040).
- DR-15 records that none of the enumeration children starts a supervisor,
  and which changes would make one do so and must then strip the result
  file.
- DR-17's reasons give the first-write detail QA-061 left out and how
  stops writing is met (US-015); DR-18's reasons say when a missing TMPDIR
  gives a stack trace and when two pass markers (US-019).
- R10 names the Discord format check's workflow by its file and current
  name, and §7 records the hook's own two-line NOT-RUN shape (US-018).

* Record what leaving Visimer's push slot non-standalone costs

DR-13 left the visimerstaleinstall row for its stated reasons. The no-skimp
pass noticed a consequence those reasons did not name: a push that selects
Visimer from a worktree without its install runs the slot's script, which
fails and reads as a defect rather than not run. That is inferred from the
slot's command form, not observed, and its remedy is the standalone change,
which touches selection data left to U-round. DR-13 now says so.

* Keep a report line that names no step out of the nested records

The shared report reader kept every plain object, so an empty object or one
whose step was not a string still became a nested record with no step, which
QA-052's re-verification noticed. Only a foreign or future writer could
produce such a line, and it changed no exit or result line, but the reader's
own test claimed a stray line can never become a record. readStepReport now
keeps only objects that name a step; the test adds both lines, failed before
the change and passes after it, and the round, push helper, records and
metrics suites pass (571).

* Make the four tests that went red on Linux CI hold on every platform

The root script tier and the Open Knowledge scripts tier failed on the
Ubuntu runners for reasons in the tests, not in the code they check:

- The drained-sink case in gate-outcome.test.mjs embedded about 350 KB
  of base64 in one -e argument, over Linux's 128 KiB per-argument cap,
  so the spawn failed with E2BIG. The child now generates its own output.
- The fake pnpm in check-agent.test.mjs was a /bin/sh script. On Linux
  that is dash, which dies at once on a group SIGINT, while real pnpm
  12.8.1 (observed) and macOS's bash wait for the script and exit with
  its status. The shim now traps INT, so the root round sees the nested
  executor's exit and takes its result, as it does with real pnpm.
- The step in run-agent-gates.private.test.mjs that handles the
  interruption could end before its handler ran: a group signal that
  landed during its ps owner probe killed ps, the loop ended, and the
  step exited 0 (about one run in four on macOS, two in sixty on Linux).
  The handled step now waits for its own handler, and only a ps exit of
  1 counts as the owner being gone.
- gate-metrics.test.mjs created a fixture repository without turning off
  auto-maintenance, which git-fixture-maintenance.test.mjs requires.

* Pin that a hook interruption keeps a defect or not-run the push helper already wrote

A signal that reaches the hook process runs its trap only after the push
helper returns, so the helper's result file is already written. Today the
trap's killed/interrupted replaces it even when the helper found a defect or
refused as not-run, contradicting DR-1's precedence and the runbook's
interruption rule. This case is red until the hook reads the file.

* Keep the helper's defect or not-run when a signal interrupts the hook

The hook's HUP, INT and TERM traps run only after the push helper returns,
so the helper's result file is already written, but gate_outcome read that
file only when no class was set and the traps always set killed. A push
whose helper found a defect, or refused as not-run, therefore ended killed
with exit 128+N, against DR-1's precedence and the runbook's interruption
rule.

gate_outcome now also reads the file after an interruption and keeps a
push-boundary defect line with exit 1 or not-run line with exit 77 as the
push's one result, with the helper's incomplete flag as written; the end
record then defers to the helper's outcome record as every accepted result
does. Any other result, a torn line or no file still ends killed as
interrupted. The deferred trap runs before the hook can read the helper's
status (observed in sh, bash and dash), so the exit-equality check cannot
apply here; the runbook now says so.

* Pin that a subtree round entry exits 128 plus the signal number when its script is killed

The Visimer and OK Marketing entries exit 1 when the pnpm run they spawn
dies by a signal, and the root round classes a bare 1 as a defect. The
entry case asserted only a non-zero status, so it passed. It now asserts
the signal's own exit status for SIGTERM and SIGKILL in both entries.

* Exit 128 plus the signal number when a subtree round entry's script is killed

The Visimer and OK Marketing round entries exited 1 when the pnpm run they
spawn died by a signal, for example tsc killed for memory. The root round
runs each entry as an ordinary step, so it saw a bare 1 and classed the
step a defect. Each entry now exits with the signal's own status, as the
root round does for a killed step, and keeps its line naming the signal.
Through pnpm 12.8.1 a SIGKILL of the script now reaches the root round as
137 and a SIGTERM as 143, both classed killed.

* Pin that an unwritable --save-forge path is reported and the report still prints

gate-metrics already reports a save it cannot make when gh returned
nothing, then prints the report. A save path under a directory that does
not exist instead threw out of saveForge after the gh fetch: Node printed
the ENOENT stack, the process exited 1, and neither the report nor the
fetched forge data reached the caller. This test runs the live path with a
stubbed gh and such a path, and expects the one stderr line, exit 0 and the
report with the cells gh's data gives. It fails until the write is guarded.

* Report a --save-forge write that fails, and print the report anyway

saveForge wrote the fetched forge data with an unguarded writeFileSync,
so a path under a missing directory, or a directory, threw out of main
after the gh fetch and before the report printed: an ENOENT or EISDIR
stack, exit 1, and the fetched data and the report both lost. The write
now fails the way a save the tool cannot make already does when gh
returned nothing: one line, "gate-metrics: the forge data was not saved
to <path>: <code>", on stderr, then the report, exit 0. Exit 2 stays the
refusal that happens before any report exists.

* Build the emitted result marker with formatMarker

emitResult wrote the INKEEP_GATE_RESULT_V1 prefix and the JSON itself,
so the template existed twice in gate-outcome.mjs and formatMarker had
no production caller. The stderr marker now comes from formatMarker; the
result-file branch still writes the bare gateResult JSON. The emitted
bytes, field order and trailing newline are unchanged.

* Keep the push report file from the executor's step children

The executor stripped INKEEP_GATE_RESULT_FILE and INKEEP_ROUND_REPORT_FILE
from every step it runs, but not INKEEP_PUSH_REPORT_FILE, so a nested
executor started by a step could append its step reports to the outer
push's report file. The executor now names that variable with a constant
beside the other two, deletes it from the step environment, and reads its
own push step reports through the same constant. The executor tests'
fixture environment drops an ambient value too, and the step-env case now
checks all three variables.

* Say why when the round records module fails to load

A direct Open Knowledge round loads the monorepo's push-records.mjs to
write its own records. When the file existed but failed to load, the
catch returned silently, so the round wrote no records and gave no
reason, while every other records failure states its cause. The catch
now prints one notice in the shape of the neighbouring beginRun line,
naming the error's code or message, and the round goes on unchanged. An
absent module stays silent. The records cases now assert the exact
notices each one prints, including a module that th…
@inkeep-oss-sync
inkeep-oss-sync Bot merged commit ae15d70 into main Oct 9, 2026
@inkeep-oss-sync
inkeep-oss-sync Bot deleted the copybara/sync branch October 9, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant