Files
skills/skills/kvb-org-organize/scripts/watch.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 8f942ff071 fix(kvb-org-organize): skip a missing cache root in the cache trim
cache_files walked every cache_dirs entry with os.fwalk, which raises FileNotFoundError for a folder that does not exist (~/.cache/ms-playwright on this host), so cleanup --caches crashed and trim_caches never ran at disk stage warn. A root that cannot be walked is now skipped and the other roots are still trimmed.

🎯 Fix: 95 %
💥 Break: 2 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 93
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 09:44:40 +00:00

1211 lines
46 KiB
Python
Executable File

#!/usr/bin/env python3
"""Watch disk, Docker and caches for the kvb-org-organize resource chair.
Usage:
watch.py watch hook (UserPromptSubmit, PostToolUse): measure at most once
per interval machine-wide, act on the stage, show alerts,
and append the rows of <root>/queue-history.jsonl
(kvb-org-queue) and <root>/security-accepted.jsonl
(kvb-check-security) whose id it lacks to the same name
under ~/.local/state/agents-council/
watch.py block hook (PreToolUse on Bash): exit 2 for a heavy job while the
disk stage is stop or critical, the memory stage is stop
or cannot be judged without memory_limits, or a
kvb-org-radio mayday holds radio silence
(<root>/radio-silence.json, until > now)
watch.py admit (--need-gb N | --kind KIND)
exit 0 when the grant leaves at least the stop threshold
free, CPU busy and RAM used stay below load_limits and
the memory stage is not stop, 3 otherwise
watch.py cleanup (--caches | --full)
trim caches to their budget; --full also runs the
configured low-risk cleanup commands
watch.py status print the stage computed from the live free space and the
stored samples, and the stored measurement's age in seconds
Environment:
AGENTS_COUNCIL_ROOT: shared state directory; /tmp/agents-council unless set,
because every agent on the host must agree on it. Every command creates
it with mode 700; while it is a symlink, another uid's, or group or
other writable, a hook mode prints why and exits 0 doing nothing and
any other mode exits 2. queue.py and radio.py check it the same way.
AGENTS_COUNCIL_CONFIG: config file; ~/.claude/agents-council/config.json
unless set, a path the install denies to Claude Code's file tools and
the sandbox's file rules do not let it write, because the hooks run
its commands outside the sandbox; a Docker daemon the sandbox can
reach still writes it. Without a config file every command exits 0 and does
nothing.
PATH: filtered to the directories hookpath.py trusts before any command
runs, the config's commands included.
Config keys, all required: disk, thresholds_gb {warn, stop, critical},
projection_minutes, interval_seconds, cache_interval_seconds, heavy_commands,
never_block (regexes), cleanup (shell commands), worktree_cleanup (argv lists
with {project}, run without a shell in /, so no worktree file is read),
worktrees_command (shell command printing per running stack its worktree path
and compose project, tab-separated, one per line; a project that is not a
compose project name is skipped), worktree_marker (path relative
to a worktree that must exist), busy_processes (process names; a worktree
where one of them runs is skipped), cache_dirs, cache_budget_percent,
slot_need_gb {kind: GB}, notify (argv the alert text is appended to),
load_limits {cpu_percent, mem_percent, sample_seconds}: every admit and every
queue grant needs CPU busy and RAM used below them, memory_limits {warn, stop},
each {available, swap_free, psi_full, container_swap_gb}: the memory stage is
the first of stop and warn whose MemAvailable or SwapFree percentage lies below
its bound, whose /proc/pressure/memory full avg10 reaches its bound, or where
one container (a docker-*.scope cgroup under system.slice, nested groups
counted) holds at least container_swap_gb GB of swap, else ok; a host without
swap or PSI skips that bound. heavy_commands extend the built-in rules of
heavy().
"""
from __future__ import annotations
import argparse
import contextlib
import fcntl
import importlib.util
import json
import math
import os
import re
import shlex
import stat
import subprocess
import sys
import time
from pathlib import Path
from types import ModuleType
def sibling(name: str) -> ModuleType:
spec = importlib.util.spec_from_file_location(f"kvb_watch_{name}", Path(__file__).resolve().parent / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
nofollow = sibling("nofollow")
hookpath = sibling("hookpath")
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
CONFIG = Path(
os.environ.get(
"AGENTS_COUNCIL_CONFIG",
str(Path.home() / ".claude" / "agents-council" / "config.json"),
)
)
PROJECT = re.compile(r"[a-z0-9][a-z0-9_-]*")
STAGES = ("ok", "warn", "stop", "critical")
STATE = ROOT / "watch.json"
SILENCE = ROOT / "radio-silence.json"
ALERTS = ROOT / "alerts.jsonl"
KEPT = Path.home() / ".local" / "state" / "agents-council"
HISTORY = ROOT / "queue-history.jsonl"
KEPT_HISTORY = KEPT / "queue-history.jsonl"
ACCEPTED = ROOT / "security-accepted.jsonl"
KEPT_ACCEPTED = KEPT / "security-accepted.jsonl"
MIRRORED = ((HISTORY, KEPT_HISTORY), (ACCEPTED, KEPT_ACCEPTED))
ALERT_TTL = 3600
CLIP = 1000
CLEANUP_LOCK = "cleanup.lock"
LOCK_TTL = 1800
GB = 1e9
MEMINFO = Path("/proc/meminfo")
PRESSURE = Path("/proc/pressure/memory")
CGROUPS = Path("/sys/fs/cgroup/system.slice")
SWAP_HOLDERS = 3
NEST_DEPTH = 8
MEMORY_STAGES = ("ok", "warn", "stop")
MEMORY_BOUNDS = ("available", "swap_free", "psi_full", "container_swap_gb")
MEMORY_KEYS = tuple(f"memory_limits.{stage}.{bound}" for stage in MEMORY_STAGES[1:] for bound in MEMORY_BOUNDS)
CONTAINER_ID = re.compile(r"[0-9a-f]{64}")
COMPOSE_VALUES = frozenset({
"-f", "--file", "-p", "--project-name", "--project-directory", "--env-file", "--profile", "--ansi", "--parallel",
"--progress",
})
BUILDING = frozenset({"up", "run", "create"})
SEPARATOR = re.compile(r"[();<>|&\n]+")
PUNCTUATION = "();<>|&\n"
WORD_BREAK = " \t\n;&|()<>"
ASSIGNMENT = re.compile(r"[A-Za-z_]\w*=.*", re.DOTALL)
WRAPPERS = frozenset({
"env", "sudo", "nice", "nohup", "timeout", "exec", "xargs", "stdbuf", "ionice", "uv", "uvx", "poetry", "pdm",
"pipenv", "coverage", "time", "command",
})
UV_VALUES = frozenset({
"--with", "--extra", "--group", "--python", "-p", "--project", "--directory", "--package", "--env-file", "--from",
})
WRAPPER_VALUES = {
"sudo": frozenset({
"-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T", "--user", "--group", "--host", "--chdir", "--role",
"--type", "--prompt", "--other-user", "--command-timeout", "--close-from",
}),
"timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
"env": frozenset({"-u", "-C", "--unset", "--chdir"}),
"nice": frozenset({"-n", "--adjustment"}),
"ionice": frozenset({"-c", "-n", "-p", "--class", "--classdata"}),
"xargs": frozenset({"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s"}),
"stdbuf": frozenset({"-i", "-o", "-e"}),
"time": frozenset({"-f", "-o", "--format", "--output"}),
"exec": frozenset({"-a"}),
"uv": UV_VALUES,
"uvx": UV_VALUES,
"poetry": frozenset({"-C", "-P", "--directory", "--project"}),
"pdm": frozenset({"-p", "--project"}),
"coverage": frozenset({"--source", "--include", "--omit", "--rcfile", "--context", "--data-file"}),
}
WRAPPER_WORDS = frozenset({"run", "tool"})
RESERVED = frozenset({"!", "{", "if", "then", "else", "elif", "do", "while", "until"})
WRAPPER_ARGUMENT = re.compile(r"-.*|\d+(\.\d+)?[smhd]?", re.DOTALL)
SHELLS = frozenset({"bash", "sh", "zsh", "dash"})
INLINE = re.compile(r"-[A-Za-z]*c[A-Za-z]*")
REDIRECT = re.compile(r"&?[<>]+&?")
TRAILING_REDIRECT = re.compile(r"[<>]+&?$")
PAREN = {"(": 1, ")": -1}
PYTHON = re.compile(r"python[\d.]*")
PYTHON_VALUE_OPTIONS = frozenset({"-X", "-W", "-Q"})
DRY_RUN = frozenset({"-n", "--dry-run", "--just-print", "--recon", "-q", "--question"})
SHORT_DRY = re.compile(r"-(?:(?![CfIoOWjl])[A-Za-z])*[nq][A-Za-z]*")
SHELL_VALUE = re.compile(r"[-+][A-Za-z]*[oO]|--rcfile|--init-file")
STDIN_FLAG = re.compile(r"-[A-Za-z]*s[A-Za-z]*")
SPACE = re.compile(r"\s")
ESCAPED = re.compile(r"\\[`$]")
OPENER = re.compile(r"`|\$\(")
PLACEHOLDER = "-_"
HEREDOC_MARK = "kvbheredoc"
MARK_WORD = re.compile(rf"{HEREDOC_MARK}((?:[0-9a-f]{{2}})*)")
HELP = frozenset({"-h", "--help", "--version"})
PYTEST_LIGHT = HELP | {"--collect-only", "--co"}
TEST_FILE = re.compile(r"[^*?\[\]]+\.py(::.*)?", re.DOTALL)
PYTEST_VALUE_OPTIONS = frozenset({
"-p", "-k", "-m", "-c", "-o", "-W", "-n", "--numprocesses", "-r", "--maxfail", "--tb", "--rootdir", "--basetemp", "--deselect",
"--ignore", "--ignore-glob", "--confcutdir", "--durations", "--junitxml", "--log-level", "--dist", "--cov",
"--cov-report", "--cov-config", "--timeout", "--import-mode", "--capture", "--color", "--log-cli-level",
"--log-file", "--reruns", "--tx", "--max-worker-restart",
})
UNITTEST_VALUE_OPTIONS = frozenset({"-k"})
DISCOVER_VALUE_OPTIONS = frozenset({"-s", "-p", "-t", "-k", "--start-directory", "--pattern", "--top-level-directory"})
PATTERN_OPTIONS = frozenset({"-p", "--pattern"})
MAKE_VALUE_OPTIONS = frozenset({
"-C", "-f", "-I", "-o", "-W", "--directory", "--file", "--makefile", "--include-dir", "--old-file", "--assume-old",
"--what-if", "--new-file", "--assume-new",
})
def guard_root(hook: bool) -> int | None:
try:
ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
except FileExistsError:
pass
st = ROOT.lstat()
if stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and not st.st_mode & 0o022:
return None
print(
f"agents council: {ROOT} must be a directory of uid {os.getuid()} that group and others cannot write; "
"the council hooks stay off until it is",
file=sys.stdout if hook else sys.stderr,
)
return 0 if hook else 2
def load_config() -> dict | None:
if not CONFIG.is_file():
return None
return json.loads(CONFIG.read_text(encoding="utf-8"))
def absent_part(cfg: dict, key: str) -> str | None:
node: object = cfg
parts = key.split(".")
for depth, part in enumerate(parts, 1):
if not isinstance(node, dict) or part not in node:
return ".".join(parts[:depth])
node = node[part]
return None if isinstance(node, (int, float)) and not isinstance(node, bool) and math.isfinite(node) else key
def missing(cfg: dict, keys: tuple[str, ...]) -> str | None:
"""Args:
cfg: the config.
keys: required number keys, nested ones dotted
(`memory_limits.stop.psi_full`).
Returns:
None when every key holds a number, else the error naming the
shortest missing part of each, or the key whose value is no number.
"""
absent = list(dict.fromkeys(part for key in keys if (part := absent_part(cfg, key)) is not None))
if not absent:
return None
return (
f"agents council: {CONFIG} lacks {', '.join(absent)}; add them as numbers as the watch.py and queue.py "
"docstrings document"
)
def disk(cfg: dict) -> tuple[float, float]:
st = os.statvfs(cfg["disk"])
return st.f_bavail * st.f_frsize / GB, st.f_blocks * st.f_frsize / GB
def clip(value: object) -> str:
return " ".join(str(value).split()).replace("<", "<\\")[:CLIP]
def read_json(path: Path) -> dict:
try:
data = json.loads(nofollow.read_text(ROOT, path))
except (OSError, ValueError, RecursionError):
return {}
return data if isinstance(data, dict) else {}
def write_json(path: Path, data: dict) -> None:
write_text(path, json.dumps(data))
def stage_for(free: float, samples: list[list[float]], cfg: dict, now: float) -> str:
limits = cfg["thresholds_gb"]
level = 0
for index, name in ((1, "warn"), (2, "stop"), (3, "critical")):
if free < limits[name]:
level = index
window = cfg["projection_minutes"] * 60
recent = [s for s in samples if now - s[0] <= window and now > s[0]]
if recent and level < 3:
then, before = recent[0]
rate = (before - free) / (now - then)
if rate > 0 and (free - limits["critical"]) / rate < window:
level += 1
return STAGES[level]
def alert(stage: str, text: str) -> None:
try:
ROOT.mkdir(parents=True, exist_ok=True)
nofollow.append(ROOT, ALERTS, json.dumps({"id": time.time_ns(), "stage": stage, "text": text}) + "\n", 0o600)
except OSError:
pass
def unseen_alerts(session: str) -> list[str]:
seen_path = ROOT / "seen" / session
try:
seen = int(nofollow.read_text(ROOT, seen_path))
except (OSError, ValueError):
seen = 0
try:
text = nofollow.read_text(ROOT, ALERTS)
except OSError:
return []
cutoff = time.time_ns() - ALERT_TTL * 10**9
fresh = [json.loads(line) for _, line in history_rows(text)]
fresh = [
a for a in fresh
if isinstance(a["id"], int) and isinstance(a.get("text"), str) and a["id"] > max(seen, cutoff)
]
if fresh:
write_text(seen_path, str(fresh[-1]["id"]))
return [clip(a["text"]) for a in fresh]
def write_text(path: Path, text: str) -> None:
try:
nofollow.replace(ROOT, path, text, 0o600)
except OSError:
pass
def consumers() -> str:
try:
out = subprocess.run(
["docker", "system", "df", "--format", "{{.Type}}: {{.Size}} ({{.Reclaimable}} reclaimable)"],
capture_output=True, text=True, timeout=30, check=False,
).stdout.strip()
except (OSError, subprocess.TimeoutExpired):
return "docker system df unavailable"
return "; ".join(out.splitlines()) or "docker system df printed nothing"
def notify(cfg: dict, text: str) -> None:
try:
subprocess.run([*cfg["notify"], text], capture_output=True, timeout=10, check=False)
except (OSError, subprocess.TimeoutExpired):
alert("critical", f"RESOURCE: desktop notification failed: {cfg['notify'][0]}")
def spawn_cleanup(mode: str) -> None:
subprocess.Popen(
[sys.executable, "-I", str(Path(__file__).resolve()), "cleanup", mode],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True,
)
def act(cfg: dict, stage: str, previous: str, free: float) -> None:
limits = cfg["thresholds_gb"]
head = f"/ has {free:.1f} GB free (warn < {limits['warn']}, stop < {limits['stop']}, critical < {limits['critical']} GB)"
if STAGES.index(stage) < STAGES.index(previous):
alert(stage, f"RESOURCE {stage.upper()}: recovered, {head}.")
return
if stage == "warn":
alert(stage, f"RESOURCE WARN: {head}. Biggest consumers: {consumers()}. Chair: plan a cleanup; peers: request no large slot.")
elif stage in ("stop", "critical"):
alert(stage, f"RESOURCE {stage.upper()}: {head}. New heavy jobs are blocked; low-risk cleanup is running. {consumers()}")
spawn_cleanup("--full")
if stage == "critical":
notify(cfg, f"Agents council: {head}. Heavy jobs blocked; a running stack needs the operator's OK to go.")
def act_memory(cfg: dict, stage: str, previous: str, sample: dict[str, float | None]) -> None:
head = memory_text(sample)
if MEMORY_STAGES.index(stage) < MEMORY_STAGES.index(previous):
alert(stage, f"RESOURCE MEMORY {stage.upper()}: recovered, {head}.")
return
holders = "; ".join(swap_holders(CGROUPS, container_names())) or "no container holds swap"
if stage == "warn":
alert(stage, f"RESOURCE MEMORY WARN: {head}. Top swap holders: {holders}. Chair: find the holder; peers: start nothing heavy.")
else:
alert(stage, f"RESOURCE MEMORY STOP: {head}. New heavy jobs are blocked. Top swap holders: {holders}.")
notify(cfg, f"Agents council: memory nearly exhausted ({head}). Heavy jobs blocked; top swap holders: {holders}.")
def history_rows(text: str) -> list[tuple[str, str]]:
rows = []
for line in text.splitlines():
try:
row = json.loads(line)
except (ValueError, RecursionError):
continue
if isinstance(row, dict) and "id" in row:
rows.append((row["id"], line))
return rows
def keep_rows(origin: Path, target: Path) -> None:
# The agents' sandbox cannot write ~/.local/state and /tmp is wiped on reboot, so this hook keeps the rows.
# The kept file takes the source's mtime: an unchanged source is skipped without parsing.
try:
source = origin.stat().st_mtime_ns
if target.exists() and target.stat().st_mtime_ns == source:
return
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a+", encoding="utf-8") as kept:
fcntl.flock(kept, fcntl.LOCK_EX | fcntl.LOCK_NB)
kept.seek(0)
known = {row_id for row_id, _ in history_rows(kept.read())}
for row_id, line in history_rows(nofollow.read_text(ROOT, origin)):
if row_id not in known:
known.add(row_id)
kept.write(line + "\n")
kept.flush()
os.utime(target, ns=(source, source))
except (OSError, ValueError, TypeError):
pass
def keep_history() -> None:
for origin, target in MIRRORED:
keep_rows(origin, target)
def stale(state: dict, cfg: dict, now: float) -> bool:
return now - state.get("checked", 0) >= cfg["interval_seconds"]
def measure_and_act(cfg: dict, state: dict, now: float, problem: str | None) -> None:
free, _ = disk(cfg)
samples = [s for s in state.get("samples", []) if now - s[0] <= cfg["projection_minutes"] * 60]
stage = stage_for(free, samples, cfg, now)
previous = state.get("stage", "ok")
samples.append([now, free])
state.update(checked=now, free=free, stage=stage, samples=samples)
sample = None if problem else memory()
was_short = state.get("memory_stage", "ok")
if sample is not None:
state["memory_stage"] = memory_stage(sample, cfg)
due = now - state.get("caches_checked", 0) >= cfg["cache_interval_seconds"]
if due:
state["caches_checked"] = now
write_json(STATE, state)
if stage != previous:
act(cfg, stage, previous, free)
elif due:
spawn_cleanup("--caches")
if sample is not None and state["memory_stage"] != was_short:
act_memory(cfg, state["memory_stage"], was_short, sample)
def cmd_watch(cfg: dict, problem: str | None) -> int:
"""Args:
cfg: the config.
problem: why the memory stage cannot be judged, None when it can; the
disk part still runs and every session sees the problem.
"""
payload = json.load(sys.stdin)
now = time.time()
state = read_json(STATE)
if stale(state, cfg, now):
measure_and_act(cfg, state, now, problem)
keep_history()
texts = unseen_alerts(payload["session_id"]) + ([] if problem is None else [problem])
if texts:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": payload["hook_event_name"],
"additionalContext": "\n".join(texts),
}}))
return 0
HEREDOC = re.compile(
r"<<-?\s*(?P<quote>['\"]?)(?P<escape>\\?)(?P<tag>[\w.-]+)(?P=quote)(?P<rest>[^\n]*)\n(?P<body>.*?)\n\s*(?P=tag)(?=\s|\)|$)",
re.DOTALL,
)
QUOTED = re.compile(r"'[^']*'|\"(?:\\.|[^\"\\])*\"")
def executable_text(command: str) -> str:
return QUOTED.sub("''", HEREDOC.sub("", command))
def silence() -> dict | None:
state = read_json(SILENCE)
return state if state.get("until", 0) > time.time() else None
def uncommented(command: str) -> str:
kept: list[str] = []
quote = ""
escaped = skipping = False
word_start = True
for char in command:
if skipping and char != "\n":
continue
skipping = False
if escaped:
escaped = False
elif quote == "'":
quote = "" if char == "'" else quote
elif char == "\\":
escaped = True
elif quote == '"':
quote = "" if char == '"' else quote
elif char in "'\"":
quote = char
elif char == "#" and word_start:
skipping = True
continue
kept.append(char)
word_start = not quote and not escaped and char in WORD_BREAK
return "".join(kept)
def segments(command: str) -> list[list[str]]:
"""Args:
command: a shell command line.
Returns:
The words of each simple command, split at newlines, `;`, `&`, `|`
and parentheses; a redirection, its target and a file descriptor
number in front of it are dropped and the command goes on.
Raises:
ValueError: on an unbalanced quote.
"""
lexer = shlex.shlex(uncommented(command.replace("\\\n", "")), posix=True, punctuation_chars=PUNCTUATION)
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = ""
parts: list[list[str]] = [[]]
target = False
for token in lexer:
if REDIRECT.fullmatch(token):
if parts[-1] and parts[-1][-1].isdigit():
parts[-1].pop()
target = True
elif SEPARATOR.fullmatch(token):
parts.append([])
target = TRAILING_REDIRECT.search(token) is not None
elif target:
target = False
else:
parts[-1].append(token)
return parts
def program(words: list[str]) -> list[str]:
"""Returns:
The words from the program on: shell reserved words, VAR=value,
wrappers such as env, sudo, time, timeout or `uv run`, their options,
the values of their options and their numbers dropped; the split
string of `env -S`, nothing for the lookup `command -v`.
"""
at, wrapper = 0, ""
while at < len(words):
word = words[at]
if os.path.basename(word) in WRAPPERS:
wrapper = os.path.basename(word)
elif word in WRAPPER_VALUES.get(wrapper, ()):
at += 1
elif word in WRAPPER_TURNS.get(wrapper, {}):
return WRAPPER_TURNS[wrapper][word](words[at + 1 :])
elif not (word in RESERVED or ASSIGNMENT.fullmatch(word) or (wrapper and wrapped_word(word))):
break
at += 1
return words[at:]
def lookup(rest: list[str]) -> list[str]:
return []
def split_string(rest: list[str]) -> list[str]:
return program(" ".join(rest[:1]).split() + rest[1:])
WRAPPER_TURNS = {
"command": {"-v": lookup, "-V": lookup},
"env": {"-S": split_string, "--split-string": split_string},
}
def wrapped_word(word: str) -> bool:
return word in WRAPPER_WORDS or WRAPPER_ARGUMENT.fullmatch(word) is not None
def inline_code(words: list[str]) -> str | None:
"""Args:
words: a simple command from its program on.
Returns:
The code a shell runs with -c (also clustered, such as -lc or -ic,
and after a `--`), else None.
"""
if not words or os.path.basename(words[0]) not in SHELLS:
return None
rest = words[1:]
for at, word in enumerate(rest):
if INLINE.fullmatch(word):
code = [later for later in rest[at + 1 :] if later != "--"][:1]
return code[0] if code else None
return None
def operands(args: list[str], value_options: frozenset[str]) -> list[str]:
found, skip = [], False
for word in args:
if skip:
skip = False
elif word in value_options:
skip = True
elif not word.startswith("-"):
found.append(word)
return found
def python_module(args: list[str]) -> tuple[str, list[str]]:
skip = False
for at, word in enumerate(args):
if skip:
skip = False
elif word == "-m":
return (args[at + 1], args[at + 2 :]) if at + 1 < len(args) else ("", [])
elif word.startswith("-m"):
return word[2:], args[at + 1 :]
elif word in PYTHON_VALUE_OPTIONS:
skip = True
elif not word.startswith("-"):
return os.path.basename(word), args[at + 1 :]
return "", []
def light_unittest(args: list[str]) -> bool:
if args[:1] != ["discover"]:
return len(operands(args, UNITTEST_VALUE_OPTIONS)) == 1
rest = args[1:]
patterns = [rest[at + 1] for at, word in enumerate(rest[:-1]) if word in PATTERN_OPTIONS]
patterns = patterns or operands(rest, DISCOVER_VALUE_OPTIONS)[1:2]
return bool(patterns) and TEST_FILE.fullmatch(patterns[-1]) is not None
def heavy_pytest(args: list[str]) -> bool:
files = operands(args, PYTEST_VALUE_OPTIONS)
return not (PYTEST_LIGHT.intersection(args) or (len(files) == 1 and TEST_FILE.fullmatch(files[0])))
def heavy_unittest(args: list[str]) -> bool:
return not (HELP.intersection(args) or light_unittest(args))
def make_targets(args: list[str]) -> list[str]:
return [word for word in operands(args, MAKE_VALUE_OPTIONS) if "=" not in word and not word.isdigit()]
def dry_run(args: list[str]) -> bool:
return bool(DRY_RUN.intersection(args)) or any(SHORT_DRY.fullmatch(word) for word in args)
def heavy_make(args: list[str]) -> bool:
return not dry_run(args) and any(target == "install" or target.startswith("verify-") for target in make_targets(args))
def heavy_compose(args: list[str]) -> bool:
words = operands(args, COMPOSE_VALUES)
command = words[0] if words else ""
return command == "build" or (command in BUILDING and "--build" in args)
def heavy_docker(args: list[str]) -> bool:
return args[:1] == ["compose"] and heavy_compose(args[1:])
HEAVY_TOOLS = {
"pytest": heavy_pytest, "py.test": heavy_pytest, "unittest": heavy_unittest, "make": heavy_make,
"docker": heavy_docker, "docker-compose": heavy_compose,
}
def heavy_words(run: list[str], cfg: dict, exempt: tuple[str, ...]) -> bool:
if not run:
return False
code = inline_code(run)
if code is not None:
return heavy(code, cfg, exempt)
name, args = os.path.basename(run[0]), run[1:]
if PYTHON.fullmatch(name):
name, args = python_module(args)
if name in WRAPPERS:
return heavy_words(program([name, *args]), cfg, exempt)
return name in HEAVY_TOOLS and HEAVY_TOOLS[name](args)
def segment_texts(words: list[str], run: list[str]) -> list[str]:
return list(dict.fromkeys(" ".join("''" if SPACE.search(word) else word for word in part) for part in (words, run)))
def judged(texts: list[str], run: list[str], cfg: dict, exempt: tuple[str, ...]) -> bool:
if any(re.search(pattern, text) for pattern in exempt for text in texts):
return False
if any(re.search(pattern, text) for pattern in cfg["heavy_commands"] for text in texts):
return True
return heavy_words(run, cfg, exempt)
def heavy_part(words: list[str], cfg: dict, exempt: tuple[str, ...]) -> bool:
"""Returns:
Whether one simple command is heavy; a make call is judged per target
as `make <target>`, so an exempt target never covers the next one.
"""
run = program(words)
if run and os.path.basename(run[0]) == "make" and not dry_run(run[1:]):
return any(judged([f"make {target}"], ["make", target], cfg, exempt) for target in make_targets(run[1:]))
return judged(segment_texts(words, run), run, cfg, exempt)
def marked_heredocs(command: str) -> tuple[str, list[re.Match[str]]]:
"""Returns:
The command with each heredoc replaced by the word HEREDOC_MARK
followed by its body in hex, so a nested call still reads it, the
rest of its first line kept, and the heredoc matches.
"""
found: list[re.Match[str]] = []
def mark(match: re.Match[str]) -> str:
found.append(match)
return f" {HEREDOC_MARK}{match['body'].encode(errors='surrogatepass').hex()} {match['rest']}"
return HEREDOC.sub(mark, command), found
def shell_operands(args: list[str]) -> list[str]:
found, skip = [], False
for word in args:
if skip:
skip = False
elif word.startswith(("-", "+")):
skip = SHELL_VALUE.fullmatch(word) is not None
else:
found.append(word)
return found
def reads_stdin(run: list[str]) -> bool:
"""Returns:
Whether a simple command from its program on is a shell that runs the
code on its stdin: no -c code, and -s or no operand but heredoc marks.
"""
if not run or os.path.basename(run[0]) not in SHELLS or inline_code(run) is not None:
return False
args = run[1:]
return any(STDIN_FLAG.fullmatch(word) for word in args) or all(MARK_WORD.fullmatch(word) for word in shell_operands(args))
def expanded_codes(heredocs: list[re.Match[str]]) -> list[str]:
"""Returns:
The command substitutions in the bodies of heredocs whose delimiter
is unquoted, which the shell expands.
"""
return [code for match in heredocs if not (match["quote"] or match["escape"]) for code in substituted(match["body"])[1]]
def fed_bodies(parts: list[list[str]]) -> list[str]:
marks = [MARK_WORD.fullmatch(word) for words in parts if reads_stdin(program(words)) for word in words]
return [bytes.fromhex(mark[1]).decode(errors="ignore") for mark in marks if mark]
def closing(text: str, start: int) -> int:
depth = 1
for at in range(start, len(text)):
depth += PAREN.get(text[at], 0)
if depth == 0:
return at
return len(text)
def blanked_single(quoted: re.Match[str]) -> str:
text = quoted[0]
return text if text.startswith('"') else "'" + " " * (len(text) - 2) + "'"
def substitution_end(shown: str, found: re.Match[str]) -> int:
if found[0] == "`":
end = shown.find("`", found.end())
return len(shown) if end < 0 else end
return closing(shown, found.end())
def substituted(text: str) -> tuple[str, list[str]]:
"""Returns:
The text with every `...` and $(...) outside single quotes and
escapes replaced by the option-like word PLACEHOLDER, so a wrapper
around it still parses, and the code of each, which the shell runs
first; nested ones stay inside their code.
"""
shown = QUOTED.sub(blanked_single, ESCAPED.sub(" ", text))
kept: list[str] = []
codes: list[str] = []
at = 0
found = OPENER.search(shown)
while found:
end = substitution_end(shown, found)
kept.append(text[at : found.start()] + PLACEHOLDER)
codes.append(text[found.end() : end])
at = end + 1
found = OPENER.search(shown, at)
return "".join(kept) + text[at:], codes
def cmd_block(cfg: dict, problem: str | None) -> int:
"""Args:
cfg: the config.
problem: why the memory stage cannot be judged, None when it can; a
heavy job is then refused with it.
"""
payload = json.load(sys.stdin)
raw = (payload.get("tool_input") or {}).get("command", "")
if not heavy(raw, cfg, tuple(cfg["never_block"])):
return 0
reason = block_reason(cfg, problem)
if reason is None:
return 0
print(f"kvb-org-organize: {reason}. Teardown commands still run. Ask the resource chair.", file=sys.stderr)
return 2
def disk_stage(cfg: dict) -> str:
state = read_json(STATE)
now = time.time()
if stale(state, cfg, now):
return stage_for(disk(cfg)[0], state.get("samples", []), cfg, now)
return state.get("stage", "ok")
def block_reason(cfg: dict, problem: str | None) -> str | None:
mayday = silence()
if mayday:
return (
f"a mayday by {clip(mayday.get('by', '?'))} holds radio silence: {clip(mayday.get('text', ''))}; "
"it ends with radio.py clear"
)
stage = disk_stage(cfg)
if stage in ("stop", "critical"):
return f"resource stage is {stage}; new heavy jobs are blocked until the disk recovers"
if problem is not None:
return f"{problem}; new heavy jobs are blocked until it does"
sample = memory()
if memory_stage(sample, cfg) == "stop":
return f"memory stage is stop ({memory_text(sample)}); new heavy jobs are blocked until memory recovers"
return None
def cpu_times() -> tuple[int, int]:
fields = [int(v) for v in Path("/proc/stat").read_text(encoding="utf-8").split("\n", 1)[0].split()[1:]]
return sum(fields), fields[3] + fields[4]
def cpu_busy_percent(before: tuple[int, int], after: tuple[int, int]) -> float:
total = after[0] - before[0]
idle = after[1] - before[1]
return 100.0 * (total - idle) / total
def meminfo_kb(meminfo: str) -> dict[str, int]:
values = {}
for line in meminfo.splitlines():
key, _, rest = line.partition(":")
if rest:
values[key] = int(rest.split()[0])
return values
def mem_used_percent(meminfo: str) -> float:
values = meminfo_kb(meminfo)
return 100.0 * (1 - values["MemAvailable"] / values["MemTotal"])
def load(cfg: dict) -> tuple[float, float]:
before = cpu_times()
time.sleep(cfg["load_limits"]["sample_seconds"])
cpu = cpu_busy_percent(before, cpu_times())
return cpu, mem_used_percent(MEMINFO.read_text(encoding="utf-8"))
def psi_full(pressure: str) -> float | None:
for line in pressure.splitlines():
if line.startswith("full "):
return float(dict(field.split("=", 1) for field in line.split()[1:])["avg10"])
return None
def memory_sample(meminfo: str, pressure: str | None, container_swap_gb: float) -> dict[str, float | None]:
"""Args:
meminfo: the text of /proc/meminfo.
pressure: the text of /proc/pressure/memory, None without PSI.
container_swap_gb: the swap of the container holding the most, in GB.
Returns:
{available, swap_free, psi_full, container_swap_gb}: MemAvailable and
SwapFree in percent of their totals, the full avg10 stall percentage
and container_swap_gb unchanged; swap_free is None without swap,
psi_full None without PSI.
"""
values = meminfo_kb(meminfo)
swap = values["SwapTotal"]
return {
"available": 100.0 * values["MemAvailable"] / values["MemTotal"],
"swap_free": 100.0 * values["SwapFree"] / swap if swap else None,
"psi_full": None if pressure is None else psi_full(pressure),
"container_swap_gb": container_swap_gb,
}
def memory() -> dict[str, float | None]:
try:
pressure = PRESSURE.read_text(encoding="utf-8")
except OSError:
pressure = None
largest = max((size for size, _ in container_swaps(CGROUPS)), default=0)
return memory_sample(MEMINFO.read_text(encoding="utf-8"), pressure, largest / GB)
def memory_stage(sample: dict[str, float | None], cfg: dict) -> str:
for name in ("stop", "warn"):
bound = cfg["memory_limits"][name]
if (
sample["available"] < bound["available"]
or (sample["swap_free"] is not None and sample["swap_free"] < bound["swap_free"])
or (sample["psi_full"] is not None and sample["psi_full"] >= bound["psi_full"])
or sample["container_swap_gb"] >= bound["container_swap_gb"]
):
return name
return "ok"
def memory_text(sample: dict[str, float | None]) -> str:
swap = "no swap" if sample["swap_free"] is None else f"swap free {sample['swap_free']:.0f} %"
psi = "no PSI" if sample["psi_full"] is None else f"PSI full avg10 {sample['psi_full']:.1f}"
return f"MemAvailable {sample['available']:.0f} %, {swap}, {psi}, largest container swap {sample['container_swap_gb']:.1f} GB"
def swap_bytes(cgroup: Path) -> int:
try:
return int((cgroup / "memory.swap.current").read_text(encoding="utf-8"))
except (OSError, ValueError):
return 0
def container_swaps(base: Path) -> list[tuple[int, Path]]:
try:
return [(swap_bytes(scope), scope) for scope in base.glob("docker-*.scope")]
except OSError:
return []
def cgroup_label(name: str, names: dict[str, str]) -> str:
bare = name.removeprefix("docker-").removesuffix(".scope")
if CONTAINER_ID.fullmatch(bare):
return names[bare] if bare in names else bare[:12]
return bare
def container_names() -> dict[str, str]:
try:
out = subprocess.run(
["docker", "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}"],
capture_output=True, text=True, timeout=30, check=False,
).stdout
except (OSError, subprocess.TimeoutExpired):
return {}
return dict(line.split("\t", 1) for line in out.splitlines() if "\t" in line)
def swap_chain(cgroup: Path, held: int, names: dict[str, str]) -> list[str]:
chain = [cgroup_label(cgroup.name, names)]
for _ in range(NEST_DEPTH):
try:
children = [(swap_bytes(child), child) for child in cgroup.iterdir() if child.is_dir()]
except OSError:
break
if not children:
break
size, child = max(children)
if size * 2 < held:
break
chain.append(cgroup_label(child.name, names))
cgroup, held = child, size
return chain
def swap_holders(base: Path, names: dict[str, str]) -> list[str]:
"""Args:
base: the cgroup directory holding the docker-<id>.scope groups.
names: {full container id: name} for the labels.
Returns:
Up to SWAP_HOLDERS lines, the largest swap first: the container, then
each nested group holding at least half of its parent's swap, and the
container's memory.swap.current in GB.
"""
ranked = sorted((pair for pair in container_swaps(base) if pair[0] > 0), reverse=True)[:SWAP_HOLDERS]
return [f"{' > '.join(swap_chain(scope, size, names))} {size / GB:.1f} GB" for size, scope in ranked]
def heavy(command: str, cfg: dict, exempt: tuple[str, ...]) -> bool:
"""Args:
command: the shell command line as typed, quotes included.
cfg: the config; its heavy_commands search each simple command with
quoted text blanked, and a make call also as `make <targets>`.
exempt: regexes; a simple command one of them finds is never heavy
(never_block, the queue's own hold), the others still count.
Returns:
Whether one simple command is a heavy job: an image build through
docker compose (build, or up, run or create with --build), a
heavy_commands match, make install or verify-* (not as a dry run), or
pytest or unittest on anything but exactly one test file or module,
help and version aside. Wrappers (program()), the code of `sh -c`,
command substitutions (substituted(), also in heredocs the shell
expands) and heredocs a shell reads as its code count the same. A
command with an unbalanced quote, which no shell runs, counts by the
regexes on its whole text alone. A guard against accidental heavy
runs, not a sandbox: a shell reading code from a pipe or a
here-string, a script of its own, a program named by a substitution,
quotes inside an expanding heredoc and an escaped backslash in front
of a substitution are not looked into.
"""
marked, heredocs = marked_heredocs(command)
plain, codes = substituted(uncommented(marked))
try:
parts = segments(plain)
except ValueError:
text = executable_text(command)
return not any(re.search(p, text) for p in exempt) and any(re.search(p, text) for p in cfg["heavy_commands"])
nested = codes + expanded_codes(heredocs) + fed_bodies(parts)
return any(heavy_part(words, cfg, exempt) for words in parts) or any(heavy(code, cfg, exempt) for code in nested)
def cmd_admit(cfg: dict, args: argparse.Namespace) -> int:
need = args.need_gb if args.need_gb is not None else cfg["slot_need_gb"][args.kind]
free, _ = disk(cfg)
left = free - need
floor = cfg["thresholds_gb"]["stop"]
passed = [left >= floor]
report = [f"free {free:.1f} GB, need {need} GB, leaves {left:.1f} GB (floor {floor} GB)"]
limits = cfg["load_limits"]
cpu, mem = load(cfg)
sample = memory()
stage = memory_stage(sample, cfg)
passed += [cpu < limits["cpu_percent"], mem < limits["mem_percent"], stage != "stop"]
report.append(f"cpu {cpu:.0f} % (limit {limits['cpu_percent']}), ram {mem:.0f} % (limit {limits['mem_percent']})")
report.append(f"memory stage {stage} ({memory_text(sample)})")
verdict = "grant" if all(passed) else "deny"
print(f"{verdict}: {'; '.join(report)}")
return 0 if verdict == "grant" else 3
def cache_files(dirs: list[str]) -> list[tuple[float, int, str, str]]:
files = []
seen: set[tuple[int, int]] = set()
for root in map(os.path.expanduser, dirs):
try:
for base, _, names, fd in os.fwalk(root, follow_symlinks=False):
for name in names:
try:
st = os.stat(name, dir_fd=fd, follow_symlinks=False)
except OSError:
continue
if (st.st_dev, st.st_ino) in seen:
continue
seen.add((st.st_dev, st.st_ino))
files.append((st.st_atime, st.st_blocks * 512, os.path.join(base, name), root))
except OSError:
continue
return files
def trim_caches(cfg: dict) -> float:
_, total = disk(cfg)
budget = total * GB * cfg["cache_budget_percent"] / 100
files = sorted(cache_files(cfg["cache_dirs"]))
used = sum(size for _, size, _, _ in files)
freed = 0
for _, size, path, root in files:
if used - freed <= budget:
break
try:
nofollow.unlink(Path(root), Path(path))
freed += size
except OSError:
continue
return freed / GB
def running_worktrees(cfg: dict) -> list[tuple[str, str]]:
try:
out = subprocess.run(
cfg["worktrees_command"], shell=True, capture_output=True, text=True, timeout=60, check=False
).stdout
except subprocess.TimeoutExpired:
return []
pairs = {tuple(line.split("\t")) for line in out.splitlines()}
return sorted(
(tree, project)
for tree, project in (pair for pair in pairs if len(pair) == 2)
if PROJECT.fullmatch(project) and os.path.exists(os.path.join(tree, cfg["worktree_marker"]))
)
def busy(tree: str, names: list[str]) -> bool:
root = os.path.realpath(tree)
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
if Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip() not in names:
continue
cwd = os.readlink(f"/proc/{pid}/cwd")
except OSError:
continue
if cwd == root or cwd.startswith(root + os.sep):
return True
return False
def run_cleanup(cfg: dict) -> list[str]:
commands = [(command, True) for command in cfg["cleanup"]]
for tree, project in running_worktrees(cfg):
if busy(tree, cfg["busy_processes"]):
continue
commands += [([arg.format(project=project) for arg in argv], False) for argv in cfg["worktree_cleanup"]]
results = []
for command, shell in commands:
try:
code = subprocess.run(command, shell=shell, cwd="/", capture_output=True, timeout=900, check=False).returncode
except subprocess.TimeoutExpired:
code = "timeout"
results.append(f"{'ok' if code == 0 else f'failed ({code})'}: {command if shell else shlex.join(command)}")
return results
def cmd_cleanup(cfg: dict, args: argparse.Namespace) -> int:
root = nofollow.directory(ROOT, (), True)
try:
with contextlib.suppress(FileNotFoundError, NotADirectoryError):
if time.time() - os.stat(CLEANUP_LOCK, dir_fd=root, follow_symlinks=False).st_mtime > LOCK_TTL:
os.rmdir(CLEANUP_LOCK, dir_fd=root)
try:
os.mkdir(CLEANUP_LOCK, 0o700, dir_fd=root)
except FileExistsError:
return 0
try:
before, _ = disk(cfg)
results = run_cleanup(cfg) if args.full else []
cache_gb = trim_caches(cfg)
after, _ = disk(cfg)
failed = [r for r in results if not r.startswith("ok")]
if args.full or cache_gb:
alert(
"cleanup",
f"RESOURCE CLEANUP: freed {after - before:.1f} GB ({cache_gb:.1f} GB caches), "
f"/ now {after:.1f} GB free; {len(results) - len(failed)} commands ok"
+ (f", failed: {' | '.join(failed)}" if failed else "."),
)
finally:
os.rmdir(CLEANUP_LOCK, dir_fd=root)
finally:
os.close(root)
return 0
def cmd_status(cfg: dict) -> int:
state = read_json(STATE)
free, total = disk(cfg)
now = time.time()
samples = state.get("samples", [])
age = f"{now - state['checked']:.0f}s" if "checked" in state else "unmeasured"
sample = memory()
print(
f"stage={stage_for(free, samples, cfg, now)} free={free:.1f}GB total={total:.1f}GB samples={len(samples)} age={age}"
f" memory={memory_stage(sample, cfg)} ({memory_text(sample)})"
)
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="watch")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("watch")
sub.add_parser("block")
admit = sub.add_parser("admit")
need = admit.add_mutually_exclusive_group(required=True)
need.add_argument("--need-gb", type=float)
need.add_argument("--kind")
cleanup = sub.add_parser("cleanup")
mode = cleanup.add_mutually_exclusive_group(required=True)
mode.add_argument("--caches", action="store_true")
mode.add_argument("--full", action="store_true")
sub.add_parser("status")
args = parser.parse_args()
os.environ["PATH"] = hookpath.trusted(os.environ.get("PATH", ""))
refused = guard_root(hook=args.cmd in ("watch", "block"))
if refused is not None:
return refused
cfg = load_config()
if cfg is None:
return 0
problem = missing(cfg, MEMORY_KEYS)
if args.cmd == "watch":
return cmd_watch(cfg, problem)
if args.cmd == "block":
return cmd_block(cfg, problem)
if problem is not None:
print(problem, file=sys.stderr)
return 2
if args.cmd == "admit":
return cmd_admit(cfg, args)
if args.cmd == "cleanup":
return cmd_cleanup(cfg, args)
return cmd_status(cfg)
if __name__ == "__main__":
sys.exit(main())