diff --git a/README.md b/README.md index 1343b2c..c9106cd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ flowchart LR org --> org_evening[kvb-org-evening] org --> org_sunset[kvb-org-sunset] org --> org_sunrise[kvb-org-sunrise] + org --> org_radio[kvb-org-radio] + org --> org_queue[kvb-org-queue] agent --> agent_optimize[kvb-agent-optimize] agent --> agent_relax[kvb-agent-relax] agent --> agent_sleep[kvb-agent-sleep] @@ -175,6 +177,8 @@ flowchart LR evening[kvb-org-evening] sunset[kvb-org-sunset] sunrise[kvb-org-sunrise] + radio[kvb-org-radio] + queue[kvb-org-queue] end subgraph cat_agent [agent] agent_optimize[kvb-agent-optimize] @@ -363,7 +367,7 @@ Copy the skills into a specific project (`/.agents/skills` and `/.cl make project TARGET=/path/to/repo ``` -Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.config/agents-council/config.json` exists. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Existing settings are preserved. +Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.config/agents-council/config.json` exists. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Existing settings are preserved. Restart your agent afterwards so it loads the new skills. diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js index e735e9d..b630912 100644 --- a/scripts/enable-plugins.js +++ b/scripts/enable-plugins.js @@ -33,6 +33,7 @@ const RENAMED = { "democracy/scripts/council.py": "kvb-org-organize/scripts/council.py", "democracy/scripts/watch.py": "kvb-org-organize/scripts/watch.py", "subagent-model/scripts/log.py": "kvb-org-perform/scripts/log.py", + "kvb-org-organize/scripts/news.py": "kvb-org-radio/scripts/radio.py", }; let data = {}; @@ -109,23 +110,28 @@ if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/co } const SCRIPT_HOOKS = [ - ["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch"], - ["kvb-org-organize/scripts/news.py", "UserPromptSubmit", null, "hook"], - ["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch"], - ["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block"], - ["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn"], - ["kvb-org-perform/scripts/log.py", "PostToolUse", "Bash", "persist"], + ["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch", {}], + ["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}], + ["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}], + ["kvb-org-radio/scripts/radio.py", "Stop", null, "listen", { asyncRewake: true, timeout: 21600 }], + ["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch", {}], + ["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block", {}], + ["kvb-org-queue/scripts/queue.py", "PreToolUse", "Bash", "block", {}], + ["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn", {}], + ["kvb-org-perform/scripts/log.py", "PostToolUse", "Bash", "persist", {}], ]; -for (const [script, event, matcher, sub] of SCRIPT_HOOKS) { +for (const [script, event, matcher, sub, extra] of SCRIPT_HOOKS) { data.hooks[event] = data.hooks[event] || []; const registered = data.hooks[event].some((entry) => (entry.hooks || []).some((hook) => String(hook.command).includes(script) && String(hook.command).endsWith(` ${sub}`))); if (registered) continue; - const file = path.join(path.dirname(settingsPath), "skills", ...script.split("/")); - const entry = { hooks: [{ type: "command", command: `python3 ${JSON.stringify(file)} ${sub}` }] }; + const file = JSON.stringify(path.join(path.dirname(settingsPath), "skills", ...script.split("/"))); + // Trip-wire: python3 on a missing file exits 2, which blocks every prompt or tool call; the test makes it exit 1. + const command = `[ -f ${file} ] && python3 ${file} ${sub}`; + const entry = { hooks: [{ type: "command", command, ...extra }] }; data.hooks[event].push(matcher ? { matcher, ...entry } : entry); } fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n"); -console.log(`skills: enabled the caveman and ponytail plugins (plugin skill copies dropped from this .claude), the ${REMINDERS.join(" + ")} reminders, the kvb-org-organize claim gate, news hook and resource watch, and the kvb-org-perform log in ${settingsPath}`); +console.log(`skills: enabled the caveman and ponytail plugins (plugin skill copies dropped from this .claude), the ${REMINDERS.join(" + ")} reminders, the kvb-org-organize claim gate and resource watch, the kvb-org-radio hooks, the kvb-org-queue gate, and the kvb-org-perform log in ${settingsPath}`); diff --git a/skills/kvb-git-hotfix/SKILL.md b/skills/kvb-git-hotfix/SKILL.md index 8e03df7..1ed29b2 100644 --- a/skills/kvb-git-hotfix/SKILL.md +++ b/skills/kvb-git-hotfix/SKILL.md @@ -7,7 +7,8 @@ description: > `${CLAUDE_SKILL_DIR}/scripts/hotfix.py` keeps each hotfix as a task `hotfix/` of the `kvb-org-rasci` register, the author as R and A; -never a message or news post. +recording it merged radios a `security` item (`kvb-org-radio`), never post it +yourself. ```bash hotfix.py add --repo --branch --sha --signature '' --status staged|pr|merged [--target ...] [--pr ] @@ -39,12 +40,12 @@ hotfix.py void --repo --target [--target ...] names the blocker. Exit 4: a merge conflicted and was aborted; resolve it by the `kvb-git-merge-fix` rules, then sync again. 2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not - merged` is reported, never merged: name it in your report and go on. + merged` is reported, never merged: report it. `not on origin/` or exit 2: tell its A to correct the row. ## On any failure -Before triage, investigation or asking a peer: `match`, the line quoted +Before triage or asking a peer: `match`, the line quoted literally; `foreign` marks another repository's hotfix. A merged match: sync and run again. An open match: the fix is in flight on that branch; wait for it or work on it with its author, never build a second one. @@ -59,10 +60,8 @@ capacity rules). target; on exit 3 finished targets keep their results. - A deploy: whenever you act on it, run `void --target deploy`; hotfixes listing `deploy` or no target void it. Only on exit 3 stop it at - the next safe point, between stages and never inside a task. Never on a - timer. -- After exit 3: post the release to the news, sync, and request the chair's - grant again before rerunning. + the next safe point between stages. Never on a timer. +- After exit 3: sync, then rerun through `queue.py hold` (`kvb-org-queue`). ## Rules diff --git a/skills/kvb-git-hotfix/scripts/hotfix.py b/skills/kvb-git-hotfix/scripts/hotfix.py index c00d8f1..4643ee2 100755 --- a/skills/kvb-git-hotfix/scripts/hotfix.py +++ b/skills/kvb-git-hotfix/scripts/hotfix.py @@ -22,11 +22,13 @@ Args: --target is a make target or `deploy` the fix turns green, none listed means every target; --pr is the pull request number. update --status staged|pr|merged [--sha ] [--pr ] - [--repo ] [--default ]: move a hotfix on. --sha - resolves in the recorded git common dir or in --repo, which must be - the same repository unless the recorded one is unknown, and records - it anew. --status merged needs --sha, the commit as it sits on - origin/ after a fetch. + [--repo ] [--default ] [--session ]: move a + hotfix on. --sha resolves in the recorded git common dir or in + --repo, which must be the same repository unless the recorded one is + unknown, and records it anew. --status merged needs --sha, the commit + as it sits on origin/ after a fetch. add and update with + --status merged post a kvb-org-radio security item; a failed post + only warns. pending --repo [--target ...]: every hotfix of that repository against HEAD; with --target only those that turn one of the targets green, as void counts them. @@ -127,6 +129,7 @@ SKILLS = Path(__file__).resolve().parents[2] RASCI = SKILLS / "kvb-org-rasci" / "scripts" / "rasci.py" GATE = SKILLS / "kvb-check-gate" / "scripts" / "gate.py" WATCH = SKILLS / "kvb-org-organize" / "scripts" / "watch.py" +RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py" PREFIX = "hotfix/" STAGES = ("staged", "pr", "merged") REGISTER_STATUS = {"staged": "running", "pr": "running", "merged": "done"} @@ -584,9 +587,37 @@ def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int: fail(f"{task} is already in the register; move it on with update") tasks[task] = entry print(f"added {task}") + if args.status == "merged": + announce(task, entry, args.session) return 0 +def announce(task: str, entry: dict, session: str | None) -> None: + """Post a merged hotfix as a kvb-org-radio security item; never fails the caller. + + Args: + task: the register task, hotfix/. + entry: its register row. + session: the caller's --session, for runtimes without CLAUDE_CODE_SESSION_ID. + """ + if not RADIO.is_file(): + return + text = ( + f"HOTFIX merged: {task} {entry.get('sha', '-')[:12]} on {repository(entry)}; " + "hotfix.py sync before your next deploy, merge or gate" + ) + command = [sys.executable, str(RADIO), "send", "security", text] + if session: + command += ["--session", session] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"hotfix: radio announcement failed: {error}", file=sys.stderr) + return + if result.returncode != 0: + print(f"hotfix: radio announcement failed: {result.stderr.strip()}", file=sys.stderr) + + def located(task: str, entry: dict, given: str | None) -> tuple[str, dict]: if given is None: common = entry.get("common_dir") or "" @@ -624,6 +655,8 @@ def cmd_update(rasci: ModuleType, args: argparse.Namespace) -> int: entry["stage"] = args.status rasci.apply_status(entry, REGISTER_STATUS[args.status]) print(f"updated {task}") + if args.status == "merged": + announce(task, entry, args.session) return 0 @@ -856,6 +889,7 @@ def main() -> int: p.add_argument("--pr", type=int) p.add_argument("--repo") p.add_argument("--default") + p.add_argument("--session") p.set_defaults(func=cmd_update) p = sub.add_parser("pending") p.add_argument("--repo", required=True) diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index bc970b9..4a604bb 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -27,7 +27,7 @@ outranks them and appoints or recalls at will. 4. Elected: complete the task the claim entered in the `kvb-org-rasci` register (objective, description, priority, roles); the listed claim announces it. -5. Not elected: post your findings to the holder via the news and stop. +5. Not elected: radio your findings to the holder (`direct`) and stop. A hook (`council.py gate`) blocks worktree edits before a claim. Between machines the earliest announcement by message time wins; each host's claim @@ -41,9 +41,9 @@ ends or goes stale, or after a restart. on `grant`. - `make test`, `make test-lint`, quality gates, deploys, iteration loops, swarm, image builds, cloud, shared-data cleanup, anything with Docker or - over a few minutes: ask the chair (what, where, how long, disk), wait for - the grant, post the release to the news. In a worktree they run through - `gate.py run` or `gate.py hold` (`kvb-check-gate`). + over a few minutes: run through `queue.py hold` (`kvb-org-queue`), which + grants by rule and releases on exit; the chair only handles exceptions. In + a worktree wrap `gate.py run` or `gate.py hold` (`kvb-check-gate`). - No running job is cut short for capacity; only the operator orders one down. Exception: void by a missing merged hotfix (`kvb-git-hotfix`). `RESOURCE` hook lines address the chair; others adjust plans. @@ -55,13 +55,12 @@ ends or goes stale, or after a restart. Read `peers.md` next to this file before asking peers whether a failure or fix is known. -- Never poll: `council.py list`, the register and the news answer first. +- Never poll: `council.py list`, the register and the radio answer first. - Hotfixes: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py add` and - `update`, never a message or news post. -- Status that asks nothing (a failure, a freed slot, a result): - `${CLAUDE_SKILL_DIR}/scripts/news.py post ""` (`--to ` for one - peer). -- `SendMessage` only when the recipient must act or answer: one batched + `update`; merged ones go out as radio `security` by themselves. +- Agents are reached through `kvb-org-radio`; status that asks nothing as + `broadcast` or `direct`. +- `direct --wake` only when the recipient must act or answer: one batched message per peer per event, terse, no greeting, at most five lines unless evidence needs more, nothing already acknowledged. A message asking nothing gets no reply; end that turn in one line. diff --git a/skills/kvb-org-organize/capacity.md b/skills/kvb-org-organize/capacity.md index a18a7b4..02564be 100644 --- a/skills/kvb-org-organize/capacity.md +++ b/skills/kvb-org-organize/capacity.md @@ -5,17 +5,19 @@ - An operator-appointed allocator holds the chair: its claim says `appointed: operator`, and no election replaces it while its session runs and the operator has not ended the appointment. -- The chair keeps one queue: a hotfix gate (a branch from the default branch - that fixes it) takes the next free gate or test slot before every other - line, the rest follows the WSJF rank of the `kvb-org-wsjf` skill (it asks its - subordinates for the Fibonacci estimates); a running gate is never cut short - for a hotfix's slot, only when the hotfix voids it (`kvb-git-hotfix`). It - answers every request with its queue position through - `news.py post --to ` and sends the grant itself as a message (the - requester acts on it), enforces the limits the operator set, and checks - every grant with - `watch.py admit --need-gb ` (or `--kind `; test kinds also need CPU - and RAM below the configured limits). +- The queue of the `kvb-org-queue` skill grants heavy jobs by rule, with no + request, position post or grant message: a hotfix gate (a branch from the + default branch that fixes it, checked against its `hotfix/` register row) + before every other line, the rest by the register rank, that is the + operator's pins, then the WSJF of the `kvb-org-wsjf` skill; slot limits + from `slot_limits` in the config, disk reserved per granted job, CPU and RAM + through `watch.py`, nothing new at the stop stage or during a mayday. A + running gate is never cut short for a hotfix's slot, only when the hotfix + voids it (`kvb-git-hotfix`). +- The chair handles what the rule cannot: it collects the Fibonacci + estimates (an unestimated task ranks last), places a job the rule leaves + waiting too long, and carries out the operator's overrides. `queue.py list` + shows the queue. - A running job (deploy, gate, test, swarm run, build) is never cut short to free capacity. The chair neither asks nor orders its owner to stop, take down or kill it, not for an operator-prioritised slot and not for a diff --git a/skills/kvb-org-organize/scripts/news.py b/skills/kvb-org-organize/scripts/news.py index 0c3b721..82a9900 100755 --- a/skills/kvb-org-organize/scripts/news.py +++ b/skills/kvb-org-organize/scripts/news.py @@ -1,161 +1,31 @@ #!/usr/bin/env python3 -"""Share status news between agents without waking them: each session sees it with its next turn. - -Environment: - AGENTS_COUNCIL_ROOT: council directory; the news is /news.jsonl and - each session's read mark /news-seen/. - /tmp/agents-council unless set. - CLAUDE_CODE_SESSION_ID: the calling session. +"""Forward the old news.py calls to radio.py of the kvb-org-radio skill, which owns the same store. Args: - post "" [--to ...] [--session ]: add one item, for every - session or only the named ones. - hook: UserPromptSubmit hook; reads the hook JSON on stdin, prints the items - of the last 24 hours this session has not seen (at most 10, own posts - left out) and marks them seen. Never fails the prompt. - show [--all] [--session ]: print the unseen items, or every kept one, - without marking them. + post "" [--to ...] [--session ], hook, show [--all] + [--session ]: passed to radio.py unchanged; see its docstring. Exit codes: - 0 done, 2 usage error. + radio.py's; when radio.py is missing, 0 for hook and 2 for the rest. """ from __future__ import annotations -import argparse -import datetime -import fcntl -import importlib.util -import json import os import sys -import tempfile -import time from pathlib import Path -from types import ModuleType -ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) -NEWS = ROOT / "news.jsonl" -SEEN = ROOT / "news-seen" -LOCK = ROOT / "news.lock" -COUNCIL = Path(__file__).resolve().with_name("council.py") -WINDOW = 86400 -SHOWN = 10 -KEPT = 500 - - -def fail(message: str) -> None: - print(f"news: {message}", file=sys.stderr) - sys.exit(2) - - -def council() -> ModuleType: - spec = importlib.util.spec_from_file_location("council", COUNCIL) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def items() -> list[dict]: - try: - lines = NEWS.read_text(encoding="utf-8").splitlines() - except OSError: - return [] - rows = [] - for line in lines: - try: - row = json.loads(line) - except ValueError: - continue - if isinstance(row, dict) and isinstance(row.get("ts"), int | float): - rows.append(row) - return rows - - -def unseen(sid: str, name: str | None) -> list[dict]: - try: - mark = float((SEEN / sid).read_text(encoding="utf-8")) - except (OSError, ValueError): - mark = 0.0 - floor = max(mark, time.time() - WINDOW) - return [ - row - for row in items() - if row["ts"] > floor - and row.get("from") != name - and (not row.get("to") or name in row["to"]) - ] - - -def line(row: dict) -> str: - stamp = datetime.datetime.fromtimestamp(row["ts"]).strftime("%H:%M") - target = f" to {', '.join(row['to'])}" if row.get("to") else "" - return f"NEWS {stamp} {row.get('from', '?')}{target}: {row.get('text', '')}" - - -def cmd_post(args: argparse.Namespace, cn: ModuleType) -> int: - sid, _ = cn.caller(args.session) - text = " ".join(args.text.split()) - if not text: - fail("post needs a text") - entry = {"ts": time.time(), "from": cn.live_sessions().get(sid) or sid, "to": args.to or [], "text": text} - ROOT.mkdir(parents=True, exist_ok=True) - with LOCK.open("w") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - rows = [*items()[-(KEPT - 1):], entry] - with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle: - handle.write("".join(json.dumps(row) + "\n" for row in rows)) - os.replace(handle.name, NEWS) - print(f"posted to {', '.join(entry['to']) or 'all'}") - return 0 - - -def cmd_hook(_: argparse.Namespace, cn: ModuleType) -> int: - try: - payload = json.load(sys.stdin) - sid = payload.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID") - if not sid: - return 0 - rows = unseen(sid, cn.live_sessions().get(sid)) - if not rows: - return 0 - for row in rows[-SHOWN:]: - print(line(row)) - if len(rows) > SHOWN: - print(f"NEWS {len(rows) - SHOWN} older items: news.py show") - SEEN.mkdir(parents=True, exist_ok=True) - (SEEN / sid).write_text(str(rows[-1]["ts"]), encoding="utf-8") - except (OSError, ValueError, AttributeError): - return 0 - return 0 - - -def cmd_show(args: argparse.Namespace, cn: ModuleType) -> int: - if args.all: - rows = items() - else: - sid, _ = cn.caller(args.session) - rows = unseen(sid, cn.live_sessions().get(sid)) - for row in rows: - print(line(row)) - return 0 +RADIO = Path(__file__).resolve().parents[2] / "kvb-org-radio" / "scripts" / "radio.py" def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - sub = parser.add_subparsers(dest="command", required=True) - p = sub.add_parser("post") - p.add_argument("text") - p.add_argument("--to", action="append") - p.add_argument("--session") - p.set_defaults(func=cmd_post) - sub.add_parser("hook").set_defaults(func=cmd_hook) - p = sub.add_parser("show") - p.add_argument("--all", action="store_true") - p.add_argument("--session") - p.set_defaults(func=cmd_show) - args = parser.parse_args() - return args.func(args, council()) + if not RADIO.is_file(): + # A UserPromptSubmit hook exiting 2 blocks every prompt of the session. + if sys.argv[1:2] == ["hook"]: + return 0 + print(f"news: {RADIO} is missing; install the kvb-org-radio skill", file=sys.stderr) + return 2 + os.execv(sys.executable, [sys.executable, str(RADIO), *sys.argv[1:]]) if __name__ == "__main__": diff --git a/skills/kvb-org-organize/scripts/watch.py b/skills/kvb-org-organize/scripts/watch.py index 72a1e69..bfe8bc8 100755 --- a/skills/kvb-org-organize/scripts/watch.py +++ b/skills/kvb-org-organize/scripts/watch.py @@ -3,9 +3,13 @@ Usage: watch.py watch hook (UserPromptSubmit, PostToolUse): measure at most once - per interval machine-wide, act on the stage, show alerts + per interval machine-wide, act on the stage, show alerts, + and append the kvb-org-queue history rows of + /queue-history.jsonl whose id it lacks to + ~/.local/state/agents-council/queue-history.jsonl watch.py block hook (PreToolUse on Bash): exit 2 for a heavy job while the - stage is stop or critical + stage is stop or critical, or while a kvb-org-radio mayday + holds radio silence (/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, 3 otherwise @@ -35,6 +39,7 @@ kind listed in kinds also needs CPU busy and RAM used below the limits. from __future__ import annotations import argparse +import fcntl import json import os import re @@ -53,7 +58,10 @@ CONFIG = Path( ) STAGES = ("ok", "warn", "stop", "critical") STATE = ROOT / "watch.json" +SILENCE = ROOT / "radio-silence.json" ALERTS = ROOT / "alerts.jsonl" +HISTORY = ROOT / "queue-history.jsonl" +KEPT_HISTORY = Path.home() / ".local" / "state" / "agents-council" / "queue-history.jsonl" ALERT_TTL = 3600 LOCK_TTL = 1800 GB = 1e9 @@ -180,8 +188,43 @@ def act(cfg: dict, stage: str, previous: str, free: float) -> None: notify(cfg, f"Agents council: {head}. Heavy jobs blocked; a running stack needs the operator's OK to go.") +def history_rows(text: str) -> list[tuple[str, str]]: + rows = [] + for line in text.splitlines(): + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, dict) and "id" in row: + rows.append((row["id"], line)) + return rows + + +def keep_history() -> 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 = HISTORY.stat().st_mtime_ns + if KEPT_HISTORY.exists() and KEPT_HISTORY.stat().st_mtime_ns == source: + return + KEPT_HISTORY.parent.mkdir(parents=True, exist_ok=True) + with KEPT_HISTORY.open("a+", encoding="utf-8") as kept: + fcntl.flock(kept, fcntl.LOCK_EX) + kept.seek(0) + known = {row_id for row_id, _ in history_rows(kept.read())} + for row_id, line in history_rows(HISTORY.read_text(encoding="utf-8")): + if row_id not in known: + known.add(row_id) + kept.write(line + "\n") + kept.flush() + os.utime(KEPT_HISTORY, ns=(source, source)) + except (OSError, ValueError, TypeError): + pass + + def cmd_watch(cfg: dict) -> int: payload = json.load(sys.stdin) + keep_history() now = time.time() state = read_json(STATE) if now - state.get("checked", 0) >= cfg["interval_seconds"]: @@ -216,18 +259,29 @@ 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 cmd_block(cfg: dict) -> int: payload = json.load(sys.stdin) command = executable_text((payload.get("tool_input") or {}).get("command", "")) stage = read_json(STATE).get("stage", "ok") - if stage not in ("stop", "critical"): + mayday = silence() + if stage not in ("stop", "critical") and not mayday: return 0 if any(re.search(p, command) for p in cfg["never_block"]): return 0 if any(re.search(p, command) for p in cfg["heavy_commands"]): + reason = ( + f"a mayday by {mayday.get('by', '?')} holds radio silence: {mayday.get('text', '')}; " + "it ends with radio.py clear" + if mayday + else f"resource stage is {stage}; new heavy jobs are blocked until the disk recovers" + ) print( - f"kvb-org-organize: resource stage is {stage}; new heavy jobs are blocked until " - "the disk recovers. Teardown commands still run. Ask the resource chair.", + f"kvb-org-organize: {reason}. Teardown commands still run. Ask the resource chair.", file=sys.stderr, ) return 2 diff --git a/skills/kvb-org-queue/SKILL.md b/skills/kvb-org-queue/SKILL.md new file mode 100644 index 0000000..2059088 --- /dev/null +++ b/skills/kvb-org-queue/SKILL.md @@ -0,0 +1,91 @@ +--- +name: kvb-org-queue +description: > + Run every heavy job (gate, deploy, build, swarm) through a deterministic + queue instead of asking the chair: hotfix first, then register rank. +--- + +`${CLAUDE_SKILL_DIR}/scripts/queue.py` replaces asking the chair for heavy +slots with one queue that every waiting agent evaluates by the same rules. + +## Run a heavy job + +```bash +queue.py hold --kind --task -- +``` + +Start it in the background (`run_in_background` or equivalent) and end the +turn: hold waits, runs the command on its grant and exits with its code. + +- ``: a key of `slot_need_gb` in `~/.config/agents-council/config.json`. +- ``: your open `kvb-org-rasci` task; its rank places you. +- `--hotfix `: the job proves the register row `hotfix/` + (`kvb-git-hotfix`) and goes first. +- `--need-gb ` overrides the learned disk need. +- Start hold in the job's worktree: its git toplevel is the workdir. Other + runtimes add `--session `. +- In a worktree the command stays `gate.py run|hold` (`kvb-check-gate`): + `queue.py hold ... -- python3 run ...`. + +Light runs (no Docker, about a minute) self-admit with +`watch.py admit --kind test-nostack` (`kvb-org-organize`). + +## Order and grant + +Every 20 s each waiting hold evaluates the whole queue under one lock: + +1. Hotfix jobs first, then the register rank (`rasci.py show`), one place + earlier per whole hour waited, then arrival. +2. In that order, a job is blocked at disk stage stop or critical, at warn + when it needs disk, during a radio-silence mayday, while its kind's slots + are taken, or while its workdir runs another job. +3. A job whose need would cut below the stop threshold reserves that disk + against smaller jobs behind it. +4. An unblocked job counts as granted for the jobs behind it: none takes its + slot. +5. Kinds in `load_limits.kinds` also need CPU and RAM below the limits. + +A waiting hold prints a stderr line when its position or reason changes. + +## History and learning + +Every finished job appends a row to the council's `queue-history.jsonl`: +signature (`make `, `