diff --git a/CLAUDE.md b/CLAUDE.md
index eb9860b6..8eb4d7ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,8 +33,16 @@ Work directly on `main`; commit and push straight there.
./install.sh # deps + pre-commit hooks, then verifies and fails loudly
./run.sh check # the full local gate
./run.sh status # probe every course dir, regenerate DOCS-runnability.md
+./run.sh run
# build/run one course, capped at 2G RAM and 2 cores
```
+`run` executes inside a transient systemd scope (`MemoryMax=2G`,
+`MemorySwapMax=0`, `CPUQuota=200%`, `nice -n 19`, `ionice -c 3`), so a course
+build cannot make an interactive machine stutter. This is not caution, it is a
+kernel-enforced ceiling -- PORR's suite solves 10000x10000 systems and will
+take every core it is given. Raise it deliberately for a single run with
+`RUN_MEMORY_MAX=8G RUN_CPU_QUOTA=800% ./run.sh run `.
+
Never `--no-verify`. If a gate fails, fix the cause.
## Development Workflow
diff --git a/TODO-refactor-remaining.md b/TODO-refactor-remaining.md
index f25653f1..6632e906 100644
--- a/TODO-refactor-remaining.md
+++ b/TODO-refactor-remaining.md
@@ -70,6 +70,16 @@ tests exist** -- splitting untested code is the highest-risk action here.
will touch hundreds of submitted coursework files, so they want their own
reviewed commit rather than being buried in a config change.
+### 6. Two courses still fail `run`
+
+- `Programming/PORR` -- its pytest suite solves systems up to 10000x10000 and
+ runs for many minutes at full CPU. Under the 2G/2-core cap it will be
+ OOM-killed rather than finish. It needs either a smaller default parameter
+ set for `run`, or to be marked as a deliberately heavy target.
+- `Programming/ZPOB` -- latexmk exits 12 from bibtex even though `esej.pdf` is
+ produced. Probably a missing/!stale .bib entry; needs a clean-room rebuild
+ to see the first real error rather than the cached one.
+
## Standing rule
A gate lands only once the thing it gates is clean, so
diff --git a/run.sh b/run.sh
index 1479532c..80b87699 100755
--- a/run.sh
+++ b/run.sh
@@ -22,6 +22,16 @@ readonly SCRIPT_DIR
readonly RUNNABILITY_DOC="${SCRIPT_DIR}/DOCS-runnability.md"
# Exit codes a per-directory run.sh uses to report itself.
+# Resource ceiling for `run`. A course build is background work and must never
+# be what makes an interactive machine stutter -- PORR's suite solves
+# 10000x10000 systems and happily eats every core it is given.
+#
+# Same mechanism as the pre-push hook in testsAndMisc: a transient systemd
+# scope, so the limit is enforced by the kernel rather than by good intentions.
+# MemorySwapMax=0 means it gets OOM-killed instead of thrashing swap.
+readonly RUN_MEMORY_MAX="${RUN_MEMORY_MAX:-2G}"
+readonly RUN_CPU_QUOTA="${RUN_CPU_QUOTA:-200%}"
+
readonly EXIT_BLOCKED=78
readonly EXIT_NO_CODE=79
@@ -142,8 +152,22 @@ cmd_run() {
[[ -n "$target" ]] || { err "run needs a directory"; usage 1; }
local runner="${SCRIPT_DIR}/${target}/run.sh"
[[ -x "$runner" ]] || { err "no executable run.sh in $target"; return 1; }
- log "running $target"
- "$runner"
+ log "running $target (capped at ${RUN_MEMORY_MAX} RAM, ${RUN_CPU_QUOTA} CPU)"
+ _run_capped "$runner"
+}
+
+# Run a command under a memory/CPU cap, niced so it yields to anything
+# interactive. Falls back to plain nice if systemd-run is unavailable.
+_run_capped() {
+ if command -v systemd-run > /dev/null 2>&1; then
+ systemd-run --user --scope --quiet \
+ -p "MemoryMax=${RUN_MEMORY_MAX}" \
+ -p MemorySwapMax=0 \
+ -p "CPUQuota=${RUN_CPU_QUOTA}" \
+ nice -n 19 ionice -c 3 "$@"
+ else
+ nice -n 19 ionice -c 3 "$@"
+ fi
}
cmd_sync_vendored() {