Skip to content

Commit 60e369b

Browse files
MendixMauclaude
andauthored
gate-check: half the process starts, closeout reuses the gate's verdicts (#583) (#589)
--closeout reuses the verdicts of the gate-check that just ran (keyed on stage, flags, env, day, HEAD; 30-min TTL; content signature). One-stage queries skip later-stage rows. PROJECT.md parsed once per run. Toolkit fetch at most once per 15 min. Review: MERGE-AFTER-FIX — content signature for the verdict cache re-reviewed MERGE (nit: >1 MB files mtime-only) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jQvaonym9V1wKKfAEPmRr
1 parent 39b8fa2 commit 60e369b

7 files changed

Lines changed: 511 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m
1919

2020
## Unreleased
2121

22+
- fix(gate-check, gate-cache): **the closeout verdict cache also keys on file CONTENT, so an overwrite that keeps an old mtime (`cp -p`, `rsync -t`, `tar x`, `unzip`, `touch -d`) can no longer replay a stale verdict**: a signed triage.md copied over a FAIL one still printed the cached Stage 0 FAIL (PR #589 review); the cache now stores one `find | sort | xargs cksum | cksum` signature of the project files (skipping .git, node_modules, .mxtk, mprcontents, deployment, *.mpr, files over 1 MB) and any difference, or a signature that cannot be computed, re-evaluates; new `tests/wave2/test-gate-cache.sh` (hit, edit/delete/waiver miss, cp -p miss, TTL 0, 'not evaluated' line); `MXTK_GATE_CACHE_TRACE=1` prints `gate-cache: hit|miss` on --closeout. Field run: ab-581/base copy (197 MB, 633 hashed files) - signature 28 ms, cache hit 1464 -> 1498 ms vs 2567-2786 ms full evaluation - PR #589 reviewer, OlafFede (#583) — OlafFede (#583)
23+
- perf(gate-check, closeout): **a Stage close starts about 70% fewer processes (Stage 3 on a small project: `--closeout` 820 -> 127 starts, `gate-check <dir> 3` 560 -> 275, no-stage run 626 -> 338; verdict lines unchanged)**: (1) `--closeout` replays the evaluation of the stage query that just ran (variables snapshotted in `<project>/.mxtk/gate-cache`, valid only while nothing under the project or the toolkit is newer than that run and the key matches; any doubt re-evaluates; `MXTK_GATE_CACHE_TTL_MIN=0` turns it off); (2) a stage query no longer evaluates obligation/artifact rows owed from later stages and says how many it skipped (the summary line is labelled the same way); (3) the register is parsed once per run instead of one awk per lookup; (4) the toolkit `git fetch` runs at most once per 15 min (`MXTK_FETCH_TTL_MIN`, `MXTK_NO_FETCH=1` unchanged); plus pure-bash trimming in the close-out block and the obligation reverse check. Reported by OlafFede (#583). — OlafFede (#583)
2224
- new(doctor, speed-setup): **`bin/doctor.sh --bench [<project>]`** times 50 process starts, git status, python/node start, one gate-check, an mxcli check and (on a COPY of the model) mx check against measured reference constants, prints one verdict line and writes `.claude/loop/bench-<timestamp>.tsv` for comparing machines; a **Machine health** section (normal run and `--bench`) warns on leftover mxbuild `deno` helpers and free memory under 2 GB (mxbuild is killed with exit 137 when memory runs out), `--cleanup` stops exactly those helpers after a typed y (no tty: prints the command); both runs end with ONE **Recommended fix** block (Gate mode: check, else WSL, dev container, or Claude in the cloud; antivirus-exclusion advice removed, it needed IT); new `skills/speed-setup.md`. Field run: ab-581/gk on Linux 4 cores, 46 orphan deno helpers found, bench 10.4 s vs 10.9 s reference — Maurits (mauritscgvisser)
2325
- perf(exec): **~55% fewer forks and ~60% fewer process starts per exec** (Windows: ~100 ms each; follow-up to #584/#585). Measured on a two-module Purchasing/Approval app (Mendix 11.13, `Gate mode: check`), Linux, `strace -f` counting clone+execve for one `./bin/exec.sh`: forks 733 → 335, execs 350-375 → 135, 3 runs each; verdicts, exit codes, output and the BUILD-LOG row are byte-identical. What changed: **exec.sh** resolves the model, platform, Python and toolkit root once and hands them down as exported memos (`MXTK_MPR_MEMO`, `MXTK_MODEL_DIR_MEMO`, `MXTK_PLATFORM`, `MXTK_PY_MEMO`), uses `EPOCHSECONDS` / `printf %(…)T` / `${x##*/}` instead of `date` / `basename` / `dirname`, reads PROJECT.md and CLAUDE.local.md fields in-shell, and parses lint-gate's output with ONE awk instead of ~10 grep/sed/tr/sort/paste; **_common.sh** waits for an unbounded gate with `wait` instead of a `sleep 1` process per second (10 on a warm build, 150+ on a cold Windows one), reads the model version with one Python start instead of two, and no longer starts basename/cut/sed/grep to match a Studio Pro folder to a version; **model-stamp.sh** asks git once (it asked 6 times), keeps the file list in an array instead of mktemp/paste/rm, reads the stamp once, and `clear` answers before any set-up; **snapshot-mpr.sh**, **look-ledger.sh**, **script-owners.sh**, **metrics-ledger.sh**, **row-groups.sh**, **build-plan-status.sh**, **lint-gate.sh**, **exec-approval.sh**, **mdl-scan.sh** drop their dirname/basename/date/ls|wc|tr/find|wc forks; lint-gate skips its rule-receipt Python while no rule file, receipt or disabled-list is newer than the last clean check. `MXTK_PROFILE=2` traces the scripts exec.sh starts as well (=1 counts only exec.sh's own commands, so a started script is one line there), and the profile lines now name the file. Reported by mkr87. — mkr87 (#584, #585)
2426
- new(doctor, init): **`bin/doctor.sh <project>` measures performance, not just presence**: a "Performance (measured)" section reads the last 20 `steps:` rows of the project's BUILD-LOG and reports the median exec time, the median gate and the slowest step against a normal machine (17-34 s per exec, gate 9-20 s), warning above 60 s per exec or 45 s gate with the lever to pull; on Windows with slow process starts it recommends (never requires) WSL2 or a dev container (Docker Engine in WSL2, Rancher Desktop, Podman Desktop). `Gate mode: check` is the default for every project, existing ones included after sync (`Gate mode: full` in PROJECT.md keeps a full build per exec). Windows field run (ExpenseTracker, Windows 11, Git Bash, 103 ms per process start): check-only execs took 90-103 s against 138-139 s with the full build (gate 60 s → 12-14 s); the 60-70 s of non-gate overhead is the next target. Reported by mkr87. — mkr87 (#584, #585)

‎bin/gate-check.sh‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,6 +1947,42 @@ protocol_is_relevant() {
19471947
return 1
19481948
}
19491949

1950+
# Evaluation cache (#583): `--closeout` right after a stage query replays that query's variables
1951+
# instead of re-evaluating. Everything from here to the "Regenerate index.html" block is the
1952+
# evaluation; see bin/lib/gate-cache.sh for exactly when a hit is allowed.
1953+
. "$TOOLKIT_DIR/bin/lib/gate-cache.sh"
1954+
GC_CACHE_HIT=0
1955+
[ "$CLOSEOUT" = "1" ] && gc_cache_try
1956+
[ -n "${MXTK_GATE_CACHE_TRACE:-}" ] && [ "$CLOSEOUT" = "1" ] && echo "gate-cache: $([ "$GC_CACHE_HIT" = 1 ] && echo hit || echo miss)" >&4
1957+
if [ "$GC_CACHE_HIT" = "1" ]; then
1958+
eval "$GC_CACHE_SNAPSHOT"
1959+
else
1960+
gc_cache_begin
1961+
1962+
# The fetch below runs at most once per MXTK_FETCH_TTL_MIN minutes (default 15; 0 = every run) per
1963+
# toolkit clone (#583: a git fetch is a network round trip plus four processes on every query, and a
1964+
# Stage close runs three queries in a row). The stamp lives inside the clone's git dir, written only
1965+
# after a fetch SUCCEEDED, so a failed or skipped fetch is never remembered as a good one. A fresh
1966+
# stamp reuses the remote-tracking ref the last fetch left, which is exactly what a successful
1967+
# fetch would have compared against, minutes later. A worktree or submodule checkout has a .git
1968+
# FILE ("gitdir: <path>"), read here without a process; a .git that is neither means there is
1969+
# nowhere safe to put the stamp, and every run fetches as before.
1970+
GC_FETCH_STAMP=""
1971+
if [ -d "$TOOLKIT_DIR/.git" ]; then
1972+
GC_FETCH_STAMP="$TOOLKIT_DIR/.git/mxtk-last-fetch"
1973+
elif [ -f "$TOOLKIT_DIR/.git" ]; then
1974+
_gc_gd=""; read -r _ _gc_gd < "$TOOLKIT_DIR/.git" 2>/dev/null || true
1975+
case "$_gc_gd" in /*|[A-Za-z]:*) ;; ?*) _gc_gd="$TOOLKIT_DIR/$_gc_gd" ;; esac
1976+
[ -n "$_gc_gd" ] && [ -d "$_gc_gd" ] && GC_FETCH_STAMP="$_gc_gd/mxtk-last-fetch"
1977+
fi
1978+
gc_fetch_is_fresh() {
1979+
local ttl="${MXTK_FETCH_TTL_MIN:-15}"
1980+
case "$ttl" in ''|*[!0-9]*) return 1 ;; esac
1981+
[ -n "$GC_FETCH_STAMP" ] && [ "$ttl" -gt 0 ] && [ -f "$GC_FETCH_STAMP" ] || return 1
1982+
[ -n "$(find "$GC_FETCH_STAMP" -mmin "-$ttl" 2>/dev/null)" ]
1983+
}
1984+
gc_fetch_stamp() { [ -n "$GC_FETCH_STAMP" ] && : > "$GC_FETCH_STAMP" 2>/dev/null; return 0; }
1985+
19501986
TOOLKIT_HEAD="$(git -C "$TOOLKIT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")"
19511987

19521988
# Resolve the reference tip. Fetch failure must not silently degrade to the old self-comparison:
@@ -1964,8 +2000,10 @@ if [ "${MXTK_NO_FETCH:-0}" = "1" ]; then
19642000
TOOLKIT_REF="$TOOLKIT_HEAD"
19652001
SYNC_REF_LABEL="local HEAD — UNVERIFIED, fetch skipped (MXTK_NO_FETCH=1)"
19662002
elif git -C "$TOOLKIT_DIR" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1 \
1967-
&& GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5}" \
1968-
git -C "$TOOLKIT_DIR" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=8 fetch --quiet 2>/dev/null; then
2003+
&& { gc_fetch_is_fresh \
2004+
|| { GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5}" \
2005+
git -C "$TOOLKIT_DIR" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=8 fetch --quiet 2>/dev/null \
2006+
&& gc_fetch_stamp; }; }; then
19692007
TOOLKIT_REF="$(git -C "$TOOLKIT_DIR" rev-parse --short '@{u}' 2>/dev/null || echo "unknown")"
19702008
else
19712009
TOOLKIT_REF="$TOOLKIT_HEAD"
@@ -2643,6 +2681,8 @@ printf "Open questions (raised?): %s — %s\n" "$OQ_STATUS" "$OQ_NOTE"
26432681
# INFORMATIONAL here, not blocking: the verify loop is optional on small projects, a module can
26442682
# legitimately have no pages (an integration module), and a project that joined at Stage 5 owes
26452683
# nothing below its adoption point. What must not happen is silence. Stage 6 still blocks.
2684+
# Rows for stages after the requested one are not evaluated (#583); see the two libs.
2685+
case "$REQUESTED_STAGE" in P|p|[0-7]) export MXTK_ROWS_UPTO="$REQUESTED_STAGE" ;; *) unset MXTK_ROWS_UPTO ;; esac
26462686
if [ -r "$TOOLKIT_DIR/bin/lib/obligation-check.sh" ]; then
26472687
# shellcheck source=lib/obligation-check.sh
26482688
. "$TOOLKIT_DIR/bin/lib/obligation-check.sh"
@@ -3020,6 +3060,9 @@ case "${LEDGER_OWED:-}" in
30203060
*) _roll="$_roll Source files: ${LEDGER_OWED} owed (pending/fault/missing/not inventoried)"
30213061
_owed=$(( _owed + ${LEDGER_OWED:-0} )) ;;
30223062
esac
3063+
# A stage query skipped the rows owed from later stages (#583); the counts above are for the
3064+
# requested stage and earlier, and must not read as "nothing is owed anywhere".
3065+
[ -n "${MXTK_ROWS_UPTO:-}" ] && _roll="$_roll (obligation/artifact rows for stages after ${MXTK_ROWS_UPTO} not evaluated)"
30233066
printf " %s\n" "$_roll"
30243067
if [ "$_owed" -gt 0 ]; then
30253068
echo " These do not affect the exit code and never have. They are passes and files"
@@ -3055,6 +3098,9 @@ if [ "$N_WAIVED" = "0" ] && { [ "$N_PENDING" -ge 4 ] || [ "$N_FAIL" -ge 3 ]; };
30553098
echo " $0 --waive look/<Module> --reason \"...\" $PROJECT_DIR (one PASS on one module → WAIVED)"
30563099
fi
30573100

3101+
gc_cache_end
3102+
fi # GC_CACHE_HIT
3103+
30583104
# Regenerate index.html from these exact results — subject to three conditions, all of which
30593105
# exist because this write has destroyed a real file:
30603106
# 1. Only on a full informational run, unless --html says otherwise. A stage query is a

‎bin/lib/artifact-check.sh‎

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ _art_register_lines() {
8888
_art_field() {
8989
local reg="$1" want="$2" out
9090
[ -f "$reg" ] || return 1
91+
# Loaded register (see _art_kv_load): a lookup is parameter expansion, not an awk process.
92+
if [ "$_ART_KV_REG" = "$reg" ]; then
93+
_art_kv_get "$want" || return 1
94+
printf '%s\n' "$_ART_KV_VAL"
95+
return 0
96+
fi
9197
out="$(awk -v want="$want" '
9298
/<!--/ { c = 1 } c { if ($0 ~ /-->/) c = 0; next }
9399
{
@@ -102,11 +108,84 @@ _art_field() {
102108
printf '%s\n' "$out"
103109
}
104110

111+
# The register parsed ONCE per run (#583). _art_field above is called two or three times per
112+
# manifest row, and each call was an awk process that re-read the whole register: 58 awk
113+
# starts in one Stage 3 query, each ~100 ms under Git Bash. _art_kv_load runs the very same
114+
# awk body once, emitting "key<TAB>value" lines (key lower-cased and trimmed, empty values
115+
# dropped, comments skipped, first line per key wins at lookup) into one string; _art_kv_get
116+
# answers from it with parameter expansion. Loaded only between mxtk_artifacts_report /
117+
# mxtk_closeout_report entry and exit, so a caller that edits the register between two bare
118+
# _art_field calls still gets the awk path and a fresh read.
119+
_ART_KV=""; _ART_KV_REG=""; _ART_KV_VAL=""
120+
_art_kv_load() {
121+
local reg="$1"
122+
_ART_KV_REG=""; _ART_KV=""
123+
[ -f "$reg" ] || return 0
124+
_ART_KV="$(awk '
125+
/<!--/ { c = 1 } c { if ($0 ~ /-->/) c = 0; next }
126+
{
127+
l = $0; gsub(/^[ \t>*_-]+/, "", l); gsub(/\*/, "", l)
128+
i = index(l, ":"); if (i == 0) next
129+
key = tolower(substr(l, 1, i - 1)); gsub(/^[ \t]+|[ \t]+$/, "", key)
130+
val = substr(l, i + 1); gsub(/^[ \t]+|[ \t]+$/, "", val)
131+
if (val == "") next
132+
if (index(key, "\t")) next # no lookup label contains a TAB; keep the record format unambiguous
133+
print "\n" key "\t" val
134+
}' "$reg")"
135+
# Leading newline so every record, the first included, is preceded by one.
136+
_ART_KV=$'\n'"$_ART_KV"
137+
_ART_KV_REG="$reg"
138+
}
139+
_art_kv_unload() { _ART_KV_REG=""; _ART_KV=""; }
140+
# _art_kv_get <lower-cased label> — sets _ART_KV_VAL; rc 1 when absent. Values never contain a
141+
# newline (awk works per line), so "up to the next newline" is exactly the value. A key never
142+
# contains a TAB it could be confused with: split at the first colon, trimmed.
143+
_art_kv_get() {
144+
local want="$1" rest
145+
case "$_ART_KV" in
146+
*$'\n'"$want"$'\t'*) ;;
147+
*) return 1 ;;
148+
esac
149+
rest="${_ART_KV#*$'\n'"$want"$'\t'}"
150+
_ART_KV_VAL="${rest%%$'\n'*}"
151+
[ -n "$_ART_KV_VAL" ]
152+
}
153+
154+
# Lower-case a label without a process in the common case (sets _ART_LC). Manifest ids are
155+
# lower-case ASCII; only the stage letter ("P") needs mapping, and tr stays as the fallback
156+
# for anything else with an upper-case letter.
157+
_ART_LC=""
158+
_art_lc() {
159+
case "$1" in
160+
*[A-Z]*) _ART_LC="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" ;;
161+
*) _ART_LC="$1" ;;
162+
esac
163+
}
164+
105165
# _art_waiver <register> <artifact-id> <stage> — prints the reason, exits 0 if waived.
106166
# Case-insensitive for the same reason obligation-check documents: a waiver that silently
107167
# missed on capitalisation is one the author believes is in force and the gate does not.
168+
# Loaded-register form: sets _ART_WAIVER_REASON, prints nothing, forks nothing. Same two
169+
# lookups, same order, same text as the printing form below.
170+
_ART_WAIVER_REASON=""
171+
_art_waiver_set() {
172+
local reg="$1" id="$2" st="$3" lst="$3"
173+
_ART_WAIVER_REASON=""
174+
[ "$_ART_KV_REG" = "$reg" ] || return 2
175+
case "$st" in P) lst=p ;; esac
176+
_art_lc "waived artifact $id"
177+
_art_kv_get "$_ART_LC" && { _ART_WAIVER_REASON="$_ART_KV_VAL"; return 0; }
178+
_art_lc "waived stage $lst"
179+
_art_kv_get "$_ART_LC" \
180+
&& { _ART_WAIVER_REASON="with its whole stage (Waived stage $st: $_ART_KV_VAL)"; return 0; }
181+
return 1
182+
}
108183
_art_waiver() {
109184
local reg="$1" id="$2" st="$3" r
185+
if [ "$_ART_KV_REG" = "$reg" ]; then
186+
_art_waiver_set "$reg" "$id" "$st" && { printf '%s\n' "$_ART_WAIVER_REASON"; return 0; }
187+
return 1
188+
fi
110189
r="$(_art_field "$reg" "$(printf 'waived artifact %s' "$id" | tr '[:upper:]' '[:lower:]')")" \
111190
&& { printf '%s\n' "$r"; return 0; }
112191
r="$(_art_field "$reg" "$(printf 'waived stage %s' "$st" | tr '[:upper:]' '[:lower:]')")" \
@@ -178,8 +257,10 @@ _art_register_rank() {
178257
# names it), mtime as the fallback for dateless names. Returns 0 for yes.
179258
_art_newer() {
180259
local ad bd am bm
181-
ad="$(basename "$1" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)"; ad="${ad:-0000-00-00}"
182-
bd="$(basename "$2" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)"; bd="${bd:-0000-00-00}"
260+
# Pure bash: this ran basename + grep + head per file, twice, per comparison.
261+
ad="${1##*/}"; bd="${2##*/}"
262+
if [[ "$ad" =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then ad="${BASH_REMATCH[0]}"; else ad="0000-00-00"; fi
263+
if [[ "$bd" =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then bd="${BASH_REMATCH[0]}"; else bd="0000-00-00"; fi
183264
[[ "$ad" > "$bd" ]] && return 0
184265
[[ "$ad" < "$bd" ]] && return 1
185266
# GNU first, BSD second — on GNU `stat -f` succeeds with the wrong meaning.
@@ -270,6 +351,15 @@ mxtk_artifacts_report() {
270351
return 0
271352
fi
272353

354+
# Parse the register once for every lookup below (#583); unloaded at the end.
355+
_art_kv_load "$reg"
356+
357+
# Stage limit (#583): rows for stages AFTER the one asked about are not evaluated. They can
358+
# not change a verdict for the requested stage, and on a Stage 3 query they were most of the
359+
# 120-row manifest. gate-check.sh exports MXTK_ROWS_UPTO (a stage token); unset = every row.
360+
local limit_rank=99 skipped_n=0
361+
[ -n "${MXTK_ROWS_UPTO:-}" ] && limit_rank="$(_art_rank "$MXTK_ROWS_UPTO")"
362+
273363
local adopted adopted_rank=0 mode
274364
adopted="$(_art_adopted_stage "$reg" 2>/dev/null || true)"
275365
[ -n "$adopted" ] && adopted_rank="$(_art_rank "$adopted")"
@@ -318,6 +408,8 @@ mxtk_artifacts_report() {
318408
MXTK_ART_STATUS="FAULT"; MXTK_ART_N_FAULT=$((MXTK_ART_N_FAULT+1)); continue
319409
fi
320410

411+
if [ "$r" -gt "$limit_rank" ]; then skipped_n=$((skipped_n+1)); continue; fi
412+
321413
# Entry mode: excused stages are said out loud, never skipped silently. An unknown mode
322414
# (young register, no line yet) excuses nothing.
323415
if [ -n "$mode" ]; then
@@ -352,8 +444,8 @@ mxtk_artifacts_report() {
352444
fi
353445

354446
local reason
355-
if reason="$(_art_waiver "$reg" "$id" "$stage")"; then
356-
printf 'Artifact %-24s WAIVED — %s\n' "$id" "$reason"
447+
if _art_waiver_set "$reg" "$id" "$stage"; then
448+
printf 'Artifact %-24s WAIVED — %s\n' "$id" "$_ART_WAIVER_REASON"
357449
continue
358450
fi
359451

@@ -385,12 +477,18 @@ mxtk_artifacts_report() {
385477
fi
386478
done < "$ARTIFACT_TSV"
387479

480+
if [ "$skipped_n" -gt 0 ]; then
481+
printf 'Artifacts: %s row(s) for stages after %s not evaluated — a stage query reads its own stage and earlier; run without a stage for every row\n' \
482+
"$skipped_n" "$MXTK_ROWS_UPTO"
483+
fi
484+
388485
# The one line for everything on offer and untaken — said once so it is not silent, said
389486
# once so eight optional rows do not read as eight things missing.
390487
if [ -n "$_art_offered" ]; then
391488
printf 'Artifacts optional, not opted in: %s — offered at each stage open by gate-check.sh --closeout; opt in with a register line "Opt-in artifact <id>: <why>"\n' "$_art_offered"
392489
fi
393490

491+
_art_kv_unload
394492
return 0
395493
}
396494

0 commit comments

Comments
 (0)