loopagent-gate 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop a card that costs more than it is worth.
|
|
3
|
+
|
|
4
|
+
A loop without a budget is not a loop: it is a process that can spend a five-hour subscription on one
|
|
5
|
+
stuck card and still hand back nothing. The dispatcher already enforces a wall-clock cap per run
|
|
6
|
+
(`--max-runtime`); this script covers the two things a clock cannot see — money spent (tokens) and
|
|
7
|
+
progress made (new commits in the run's workspace).
|
|
8
|
+
|
|
9
|
+
python3 -m loopgate.gates.guard_card # report only, database source
|
|
10
|
+
python3 -m loopgate.gates.guard_card --json
|
|
11
|
+
python3 -m loopgate.gates.guard_card --enforce # SIGTERM + block + comment
|
|
12
|
+
python3 -m loopgate.gates.guard_card --snapshot queue.json # no database, no runtime
|
|
13
|
+
python3 -m loopgate.gates.guard_card --snapshot queue.json --json
|
|
14
|
+
python3 -m loopgate.gates.guard_card --selftest # prove every rule on fixtures
|
|
15
|
+
|
|
16
|
+
Verdict: CARD_BUDGET_STOP_0001 (exit 1) names every offending card and the rule it broke;
|
|
17
|
+
CARD_BUDGET_PASS_0001 (exit 0) means every active run is inside its budget.
|
|
18
|
+
|
|
19
|
+
Sources of truth (exactly one of them per run)
|
|
20
|
+
----------------------------------------------
|
|
21
|
+
Database source, both read-only here, and the default because existing users have it:
|
|
22
|
+
~/.hermes/kanban.db — tasks (assignee, status) and task_runs (worker_pid, started_at, metadata)
|
|
23
|
+
~/.hermes/state.db — session_model_usage keyed by the worker session recorded in run metadata
|
|
24
|
+
|
|
25
|
+
Snapshot source (`--snapshot`): a JSON document produced by whoever owns the queue, so the budget rule
|
|
26
|
+
works without one particular agent runtime and can be tested in CI. Shape, kept minimal on purpose:
|
|
27
|
+
|
|
28
|
+
{"runs": [{"task_id": "t_1", "run_id": 1, "assignee": "dev", "status": "running",
|
|
29
|
+
"started_at": 1792512986, "worker_pid": null, "workspace": "/path/to/worktree",
|
|
30
|
+
"session_tokens": {"input": 180000, "output": 12000, "cache_read": 90000,
|
|
31
|
+
"cache_write": 0, "api_calls": 40}}]}
|
|
32
|
+
|
|
33
|
+
`task_id`, `run_id`, `status`, `started_at` and `session_tokens` are required; `assignee`, `worker_pid`,
|
|
34
|
+
`workspace` and a card `title` are optional. `max_runs` is counted from the snapshot's own entries: a
|
|
35
|
+
snapshot knows the runs it was exported with, and nothing else.
|
|
36
|
+
|
|
37
|
+
Limits of the measurement, stated so nobody over-reads the number
|
|
38
|
+
-----------------------------------------------------------------
|
|
39
|
+
* Tokens are counted for the worker session only (not the reviewer's). In the database source a session
|
|
40
|
+
that is absent from the usage table is a measurement limit and reports `unknown`; in a snapshot the
|
|
41
|
+
token block is required input, so a missing key is a *data* defect and the card cannot be certified —
|
|
42
|
+
`unknown` is never rendered as `0` and never counts as a pass. A rule that cannot see the spend must
|
|
43
|
+
not certify it.
|
|
44
|
+
* A run whose workspace is not a git repository reports progress as `unknown` rather than "none", so a
|
|
45
|
+
missing workspace never manufactures a "no progress" stop.
|
|
46
|
+
* The budget values are a starting point to be re-measured from finished runs
|
|
47
|
+
(`loopgate/gates/estimate_task.py` does the same for time).
|
|
48
|
+
* `--enforce` against a snapshot can only signal a live `worker_pid`: there is no queue CLI to comment or
|
|
49
|
+
block on, so the board is deliberately left alone and the action list says so. Signaling is refused
|
|
50
|
+
(`EPERM`/`ESRCH`) rather than escalated.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from loopgate.paths import project_root, gate_file
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import contextlib
|
|
59
|
+
import io
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import signal
|
|
63
|
+
import sqlite3
|
|
64
|
+
import subprocess
|
|
65
|
+
import sys
|
|
66
|
+
import tempfile
|
|
67
|
+
import time
|
|
68
|
+
|
|
69
|
+
ROOT = project_root()
|
|
70
|
+
PASS_MARKER = "CARD_BUDGET_PASS_0001"
|
|
71
|
+
STOP_MARKER = "CARD_BUDGET_STOP_0001"
|
|
72
|
+
SELFTEST_MARKER = "CARD_BUDGET_SELFTEST_PASS_0001"
|
|
73
|
+
DEFAULT_CONFIG = os.path.join(ROOT, "configs", "card_budget.json")
|
|
74
|
+
KANBAN_DB = os.path.expanduser("~/.hermes/kanban.db")
|
|
75
|
+
STATE_DB = os.path.expanduser("~/.hermes/state.db")
|
|
76
|
+
ACTIVE_STATUSES = ("running", "claimed", "in_progress", "review")
|
|
77
|
+
SNAPSHOT_REQUIRED = ("task_id", "run_id", "status", "started_at", "session_tokens")
|
|
78
|
+
TOKEN_KEYS = (("input", "input"), ("output", "output"), ("cache_read", "cache_read"),
|
|
79
|
+
("cache_write", "cache_write"), ("api_calls", "api_calls"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SnapshotError(ValueError):
|
|
83
|
+
"""The snapshot cannot be read as a queue: named, because a silent empty queue looks like a pass."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_config(path: str) -> dict:
|
|
87
|
+
try:
|
|
88
|
+
with open(path, encoding="utf-8") as fh:
|
|
89
|
+
raw = json.load(fh)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
raw = {}
|
|
92
|
+
base = {"max_tokens": 250_000, "max_runs": 3, "max_runtime_minutes": 30, "no_progress_minutes": 20}
|
|
93
|
+
base.update(raw.get("default") or {})
|
|
94
|
+
return {"default": base, "profiles": raw.get("profiles") or {}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def budget_for(cfg: dict, assignee: str | None) -> dict:
|
|
98
|
+
merged = dict(cfg["default"])
|
|
99
|
+
merged.update(cfg["profiles"].get(assignee or "", {}) or {})
|
|
100
|
+
return merged
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def worker_session(metadata: str | None) -> str | None:
|
|
104
|
+
if not metadata:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(metadata)
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
return None
|
|
110
|
+
for key in ("worker_session_id", "session_id"):
|
|
111
|
+
value = data.get(key)
|
|
112
|
+
if value:
|
|
113
|
+
return str(value)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def session_tokens(state_db: str, session_id: str | None) -> dict | None:
|
|
118
|
+
"""Tokens billed to this worker session, or None when the session is not in the usage table."""
|
|
119
|
+
if not session_id or not os.path.exists(state_db):
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
conn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True)
|
|
123
|
+
rows = conn.execute(
|
|
124
|
+
"select coalesce(sum(input_tokens),0), coalesce(sum(output_tokens),0), "
|
|
125
|
+
"coalesce(sum(cache_read_tokens),0), coalesce(sum(cache_write_tokens),0), "
|
|
126
|
+
"coalesce(sum(api_call_count),0) from session_model_usage where session_id = ?",
|
|
127
|
+
(session_id,),
|
|
128
|
+
).fetchall()
|
|
129
|
+
conn.close()
|
|
130
|
+
except sqlite3.Error:
|
|
131
|
+
return None
|
|
132
|
+
input_t, output_t, read_t, write_t, calls = rows[0]
|
|
133
|
+
if not calls:
|
|
134
|
+
return None
|
|
135
|
+
return {"input": input_t, "output": output_t, "cache_read": read_t, "cache_write": write_t,
|
|
136
|
+
"api_calls": calls, "total": input_t + output_t + read_t + write_t}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def snapshot_tokens(block) -> dict | None:
|
|
140
|
+
"""Token block from a snapshot entry; None when it is absent or unusable (never a silent zero)."""
|
|
141
|
+
if not isinstance(block, dict):
|
|
142
|
+
return None
|
|
143
|
+
numbers = {}
|
|
144
|
+
for name, key in TOKEN_KEYS:
|
|
145
|
+
value = block.get(key)
|
|
146
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
147
|
+
return None
|
|
148
|
+
numbers[name] = value
|
|
149
|
+
numbers["total"] = numbers["input"] + numbers["output"] + numbers["cache_read"] + numbers["cache_write"]
|
|
150
|
+
return numbers
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def commits_since(workspace: str | None, started_at: float | None) -> int | None:
|
|
154
|
+
"""Commits made in the run's workspace after it started; None when that cannot be known."""
|
|
155
|
+
if not workspace or not started_at or not os.path.isdir(workspace):
|
|
156
|
+
return None
|
|
157
|
+
if not os.path.isdir(os.path.join(workspace, ".git")) and not os.path.exists(os.path.join(workspace, ".git")):
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
proc = subprocess.run(["git", "-C", workspace, "log", f"--since=@{int(started_at)}", "--oneline"],
|
|
161
|
+
capture_output=True, text=True, timeout=30)
|
|
162
|
+
except (OSError, subprocess.SubprocessError):
|
|
163
|
+
return None
|
|
164
|
+
if proc.returncode != 0:
|
|
165
|
+
return None
|
|
166
|
+
return len([line for line in proc.stdout.splitlines() if line.strip()])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def active_runs(db: str) -> list[dict]:
|
|
170
|
+
if not os.path.exists(db):
|
|
171
|
+
return []
|
|
172
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
173
|
+
conn.row_factory = sqlite3.Row
|
|
174
|
+
placeholders = ",".join("?" for _ in ACTIVE_STATUSES)
|
|
175
|
+
rows = conn.execute(
|
|
176
|
+
f"""select r.id as run_id, r.task_id, r.status, r.worker_pid, r.started_at, r.last_heartbeat_at,
|
|
177
|
+
r.metadata, r.max_runtime_seconds,
|
|
178
|
+
t.title, t.assignee, t.workspace_path, t.priority
|
|
179
|
+
from task_runs r join tasks t on t.id = r.task_id
|
|
180
|
+
where r.ended_at is null and r.status in ({placeholders})
|
|
181
|
+
order by r.started_at""",
|
|
182
|
+
ACTIVE_STATUSES,
|
|
183
|
+
).fetchall()
|
|
184
|
+
conn.close()
|
|
185
|
+
return [dict(r) for r in rows]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def snapshot_runs(path: str) -> list[dict]:
|
|
189
|
+
"""Read a queue snapshot into the same row shape `active_runs` returns.
|
|
190
|
+
|
|
191
|
+
Required keys are validated here rather than defaulted: an entry missing `started_at` or
|
|
192
|
+
`session_tokens` is reported to the caller (through `_missing`) so the card is refused, not measured
|
|
193
|
+
as zero. Shape errors abort with a named reason — "no active runs" and "I could not read the queue"
|
|
194
|
+
must never look the same.
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
with open(path, encoding="utf-8") as fh:
|
|
198
|
+
payload = json.load(fh)
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
raise SnapshotError(f"{path}: {exc.__class__.__name__}: {exc}") from exc
|
|
201
|
+
except json.JSONDecodeError as exc:
|
|
202
|
+
raise SnapshotError(f"{path}: not JSON ({exc})") from exc
|
|
203
|
+
if isinstance(payload, dict):
|
|
204
|
+
entries = payload.get("runs")
|
|
205
|
+
if not isinstance(entries, list):
|
|
206
|
+
raise SnapshotError(f"{path}: expected an object with a 'runs' list")
|
|
207
|
+
elif isinstance(payload, list):
|
|
208
|
+
entries = payload
|
|
209
|
+
else:
|
|
210
|
+
raise SnapshotError(f"{path}: expected an object with a 'runs' list or a bare list of runs")
|
|
211
|
+
runs: list[dict] = []
|
|
212
|
+
for index, entry in enumerate(entries):
|
|
213
|
+
if not isinstance(entry, dict):
|
|
214
|
+
raise SnapshotError(f"{path}: runs[{index}] is not an object")
|
|
215
|
+
missing = [key for key in SNAPSHOT_REQUIRED if entry.get(key) in (None, "")]
|
|
216
|
+
tokens = snapshot_tokens(entry.get("session_tokens"))
|
|
217
|
+
if "session_tokens" not in missing and tokens is None:
|
|
218
|
+
missing.append("session_tokens")
|
|
219
|
+
runs.append({
|
|
220
|
+
"run_id": entry.get("run_id"),
|
|
221
|
+
"task_id": entry.get("task_id"),
|
|
222
|
+
"status": entry.get("status"),
|
|
223
|
+
"worker_pid": entry.get("worker_pid"),
|
|
224
|
+
"started_at": entry.get("started_at"),
|
|
225
|
+
"last_heartbeat_at": entry.get("last_heartbeat_at"),
|
|
226
|
+
"metadata": None,
|
|
227
|
+
"max_runtime_seconds": entry.get("max_runtime_seconds"),
|
|
228
|
+
"title": entry.get("title") or entry.get("task_title") or "",
|
|
229
|
+
"assignee": entry.get("assignee"),
|
|
230
|
+
"workspace_path": entry.get("workspace") or entry.get("workspace_path"),
|
|
231
|
+
"priority": entry.get("priority"),
|
|
232
|
+
"_origin": "snapshot",
|
|
233
|
+
"_tokens": tokens,
|
|
234
|
+
"_missing": missing,
|
|
235
|
+
})
|
|
236
|
+
return runs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_count(db: str, task_id: str) -> int:
|
|
240
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
241
|
+
total = conn.execute("select count(*) from task_runs where task_id = ?", (task_id,)).fetchone()[0]
|
|
242
|
+
conn.close()
|
|
243
|
+
return total
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def evaluate(run: dict, cfg: dict, state_db: str, db: str, now: float,
|
|
247
|
+
runs_used: int | None = None) -> dict:
|
|
248
|
+
budget = budget_for(cfg, run.get("assignee"))
|
|
249
|
+
started = run.get("started_at")
|
|
250
|
+
runtime_min = (now - started) / 60.0 if started else None
|
|
251
|
+
origin = run.get("_origin") or "database"
|
|
252
|
+
if origin == "snapshot":
|
|
253
|
+
tokens = run.get("_tokens")
|
|
254
|
+
else:
|
|
255
|
+
tokens = session_tokens(state_db, worker_session(run.get("metadata")))
|
|
256
|
+
commits = commits_since(run.get("workspace_path"), started)
|
|
257
|
+
if runs_used is None:
|
|
258
|
+
runs_used = run_count(db, run["task_id"])
|
|
259
|
+
|
|
260
|
+
offences: list[str] = []
|
|
261
|
+
unverifiable: list[str] = []
|
|
262
|
+
for key in run.get("_missing") or []:
|
|
263
|
+
if key == "session_tokens":
|
|
264
|
+
# Input the exporter was required to provide: unknown is not an excuse, it is a missing fact.
|
|
265
|
+
unverifiable.append(
|
|
266
|
+
f"spent an unknown number of tokens (the snapshot entry has no usable `session_tokens`), "
|
|
267
|
+
f"so the {budget['max_tokens']:,} token cap cannot be certified")
|
|
268
|
+
elif key == "started_at":
|
|
269
|
+
unverifiable.append("no `started_at` in the snapshot entry, so wall clock and the progress "
|
|
270
|
+
"clock cannot be judged")
|
|
271
|
+
else:
|
|
272
|
+
unverifiable.append(f"the snapshot entry is missing `{key}`, so the run cannot be judged")
|
|
273
|
+
offences.extend(unverifiable)
|
|
274
|
+
if tokens and tokens["total"] > budget["max_tokens"]:
|
|
275
|
+
offences.append(f"spent {tokens['total']:,} tokens (cap {budget['max_tokens']:,})")
|
|
276
|
+
if runs_used > budget["max_runs"]:
|
|
277
|
+
offences.append(f"{runs_used} runs on one card (cap {budget['max_runs']})")
|
|
278
|
+
if runtime_min is not None and runtime_min > budget["max_runtime_minutes"]:
|
|
279
|
+
offences.append(f"{runtime_min:.0f} min of wall clock (cap {budget['max_runtime_minutes']})")
|
|
280
|
+
if (runtime_min is not None and runtime_min > budget["no_progress_minutes"]
|
|
281
|
+
and commits == 0):
|
|
282
|
+
offences.append(f"no commit in {runtime_min:.0f} min (progress clock {budget['no_progress_minutes']} min)")
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"task_id": run["task_id"],
|
|
286
|
+
"run_id": run["run_id"],
|
|
287
|
+
"assignee": run.get("assignee"),
|
|
288
|
+
"title": (run.get("title") or "")[:70],
|
|
289
|
+
"worker_pid": run.get("worker_pid"),
|
|
290
|
+
"origin": origin,
|
|
291
|
+
"runtime_minutes": None if runtime_min is None else round(runtime_min, 1),
|
|
292
|
+
"runs_used": runs_used,
|
|
293
|
+
"tokens": tokens["total"] if tokens else None,
|
|
294
|
+
"token_split": tokens,
|
|
295
|
+
"commits": commits,
|
|
296
|
+
"budget": budget,
|
|
297
|
+
"unverifiable": unverifiable,
|
|
298
|
+
"offences": offences,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def enforce(card: dict, db: str, hermes: str | None) -> list[str]:
|
|
303
|
+
"""Stop the worker, then let the board know through its own CLI (never by hand-writing the DB)."""
|
|
304
|
+
actions: list[str] = []
|
|
305
|
+
pid = card.get("worker_pid")
|
|
306
|
+
if pid:
|
|
307
|
+
try:
|
|
308
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
309
|
+
actions.append(f"SIGTERM -> pid {pid}")
|
|
310
|
+
except ProcessLookupError:
|
|
311
|
+
actions.append(f"pid {pid} already gone")
|
|
312
|
+
except OSError as exc:
|
|
313
|
+
actions.append(f"SIGTERM failed for pid {pid}: {exc}")
|
|
314
|
+
else:
|
|
315
|
+
actions.append("no worker_pid recorded (the run was never started locally)")
|
|
316
|
+
if card.get("origin") == "snapshot":
|
|
317
|
+
actions.append("snapshot source: the queue itself was not touched (no block, no comment) — "
|
|
318
|
+
"a snapshot has no board to write to; update the exporter's queue instead")
|
|
319
|
+
return actions
|
|
320
|
+
reason = "; ".join(card["offences"]) or "budget exceeded"
|
|
321
|
+
if hermes:
|
|
322
|
+
for args in (["kanban", "comment", card["task_id"], "--author", "guard_card",
|
|
323
|
+
f"Stopped by guard_card.py: {reason}"],
|
|
324
|
+
["kanban", "block", card["task_id"], "--reason", f"card budget: {reason}"]):
|
|
325
|
+
try:
|
|
326
|
+
proc = subprocess.run([hermes, *args], capture_output=True, text=True, timeout=60)
|
|
327
|
+
actions.append(("ok: " if proc.returncode == 0 else "failed: ") + " ".join(args[:3])
|
|
328
|
+
+ ("" if proc.returncode == 0 else f" — {proc.stderr.strip()[:120]}"))
|
|
329
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
330
|
+
actions.append(f"failed: {' '.join(args[:3])} — {exc}")
|
|
331
|
+
else:
|
|
332
|
+
actions.append("`hermes` not on PATH: the board was not updated, only the process was stopped")
|
|
333
|
+
return actions
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def find_hermes() -> str | None:
|
|
337
|
+
for candidate in (os.environ.get("HERMES_BIN"), os.path.expanduser("~/.local/bin/hermes"),
|
|
338
|
+
"/opt/homebrew/bin/hermes"):
|
|
339
|
+
if candidate and os.path.exists(candidate):
|
|
340
|
+
return candidate
|
|
341
|
+
from shutil import which
|
|
342
|
+
return which("hermes")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main(argv=None) -> int:
|
|
346
|
+
ap = argparse.ArgumentParser(description="Stop cards that exceed their token/run/progress budget.")
|
|
347
|
+
ap.add_argument("--db", default=KANBAN_DB)
|
|
348
|
+
ap.add_argument("--state-db", default=STATE_DB)
|
|
349
|
+
ap.add_argument("--config", default=DEFAULT_CONFIG)
|
|
350
|
+
ap.add_argument("--snapshot", help="read the queue from a JSON snapshot instead of the kanban "
|
|
351
|
+
"database (no runtime, no ~/.hermes)")
|
|
352
|
+
ap.add_argument("--enforce", action="store_true", help="SIGTERM the worker and block the card")
|
|
353
|
+
ap.add_argument("--json", action="store_true")
|
|
354
|
+
ap.add_argument("--selftest", action="store_true", help="prove every budget rule on fixtures")
|
|
355
|
+
args = ap.parse_args(argv)
|
|
356
|
+
|
|
357
|
+
if args.selftest:
|
|
358
|
+
rc = selftest()
|
|
359
|
+
print(SELFTEST_MARKER if rc == 0 else STOP_MARKER)
|
|
360
|
+
return rc
|
|
361
|
+
|
|
362
|
+
cfg = load_config(args.config)
|
|
363
|
+
if args.snapshot:
|
|
364
|
+
try:
|
|
365
|
+
runs = snapshot_runs(args.snapshot)
|
|
366
|
+
except SnapshotError as exc:
|
|
367
|
+
print(f"snapshot error: {exc}", file=sys.stderr)
|
|
368
|
+
print(STOP_MARKER)
|
|
369
|
+
return 1
|
|
370
|
+
else:
|
|
371
|
+
runs = active_runs(args.db)
|
|
372
|
+
now = time.time()
|
|
373
|
+
per_task: dict[str, int] = {}
|
|
374
|
+
for run in runs:
|
|
375
|
+
per_task[run["task_id"]] = per_task.get(run["task_id"], 0) + 1
|
|
376
|
+
cards = [evaluate(run, cfg, args.state_db, args.db, now,
|
|
377
|
+
runs_used=per_task.get(run["task_id"]) if args.snapshot else None)
|
|
378
|
+
for run in runs]
|
|
379
|
+
offenders = [c for c in cards if c["offences"]]
|
|
380
|
+
|
|
381
|
+
actions: dict[str, list[str]] = {}
|
|
382
|
+
if args.enforce:
|
|
383
|
+
hermes = None if args.snapshot else find_hermes()
|
|
384
|
+
for card in offenders:
|
|
385
|
+
actions[card["task_id"]] = enforce(card, args.db, hermes)
|
|
386
|
+
|
|
387
|
+
if args.json:
|
|
388
|
+
print(json.dumps({"marker": STOP_MARKER if offenders else PASS_MARKER,
|
|
389
|
+
"source": "snapshot" if args.snapshot else "database",
|
|
390
|
+
"budgets": cfg["default"], "cards": cards, "actions": actions},
|
|
391
|
+
ensure_ascii=False, indent=2))
|
|
392
|
+
else:
|
|
393
|
+
if not cards:
|
|
394
|
+
print("no active runs: nothing is spending")
|
|
395
|
+
if args.snapshot and cards:
|
|
396
|
+
print(f"source: snapshot {args.snapshot}")
|
|
397
|
+
for card in cards:
|
|
398
|
+
state = "OVER" if card["offences"] else "ok"
|
|
399
|
+
tokens = "unknown" if card["tokens"] is None else f"{card['tokens']:,}"
|
|
400
|
+
commits = "unknown" if card["commits"] is None else str(card["commits"])
|
|
401
|
+
print(f" {card['task_id']} [{state}] {card['assignee']} · {card['runtime_minutes']} min · "
|
|
402
|
+
f"{tokens} tokens · {commits} commit(s) · {card['title']}")
|
|
403
|
+
for offence in card["offences"]:
|
|
404
|
+
print(f" - {offence}")
|
|
405
|
+
for action in actions.get(card["task_id"], []):
|
|
406
|
+
print(f" > {action}")
|
|
407
|
+
if offenders:
|
|
408
|
+
print(f"cards over budget: {len(offenders)} of {len(cards)} active")
|
|
409
|
+
print(STOP_MARKER if offenders else PASS_MARKER)
|
|
410
|
+
return 1 if offenders else 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------- selftest
|
|
414
|
+
|
|
415
|
+
def _run(argv: list[str]) -> tuple[int, str]:
|
|
416
|
+
"""Run one verdict in-process, capturing both streams: a refusal explains itself on stderr."""
|
|
417
|
+
out, err = io.StringIO(), io.StringIO()
|
|
418
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
419
|
+
code = main(argv)
|
|
420
|
+
return code, out.getvalue() + err.getvalue()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def selftest() -> int:
|
|
424
|
+
"""Prove each rule on fixtures — the snapshot path exists so this can run in CI, so prove it there."""
|
|
425
|
+
ok = True
|
|
426
|
+
tmp = tempfile.mkdtemp(prefix="guard_card_selftest_")
|
|
427
|
+
now = int(time.time())
|
|
428
|
+
cfg_path = os.path.join(tmp, "card_budget.json")
|
|
429
|
+
with open(cfg_path, "w", encoding="utf-8") as fh:
|
|
430
|
+
json.dump({"default": {"max_tokens": 100_000, "max_runs": 2, "max_runtime_minutes": 30,
|
|
431
|
+
"no_progress_minutes": 20}}, fh)
|
|
432
|
+
tokens_ok = {"input": 1000, "output": 100, "cache_read": 0, "cache_write": 0, "api_calls": 3}
|
|
433
|
+
tokens_over = {"input": 200_000, "output": 10_000, "cache_read": 0, "cache_write": 0, "api_calls": 40}
|
|
434
|
+
|
|
435
|
+
def snapshot(name: str, runs: list[dict]) -> str:
|
|
436
|
+
path = os.path.join(tmp, name)
|
|
437
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
438
|
+
json.dump({"runs": runs}, fh)
|
|
439
|
+
return path
|
|
440
|
+
|
|
441
|
+
def check(label: str, code: int, out: str, want_code: int, want: str, forbid: str | None = None) -> None:
|
|
442
|
+
nonlocal ok
|
|
443
|
+
good = code == want_code and want in out and (forbid is None or forbid not in out)
|
|
444
|
+
print(f"[{'ok ' if good else 'FAIL'}] {label}")
|
|
445
|
+
if not good:
|
|
446
|
+
ok = False
|
|
447
|
+
print(f" exit={code} (want {want_code}); marker/needle {want!r} "
|
|
448
|
+
f"{'found' if want in out else 'MISSING'}"
|
|
449
|
+
+ (f"; forbidden {forbid!r} {'PRESENT' if forbid in out else 'absent'}" if forbid else ""))
|
|
450
|
+
print(" output: " + out.strip().replace("\n", " | ")[:400])
|
|
451
|
+
|
|
452
|
+
base = {"task_id": "t_1", "run_id": 1, "assignee": "dev", "status": "running",
|
|
453
|
+
"started_at": now - 60, "worker_pid": None, "workspace": None}
|
|
454
|
+
|
|
455
|
+
inside = snapshot("inside.json", [dict(base, session_tokens=tokens_ok)])
|
|
456
|
+
code, out = _run(["--snapshot", inside, "--config", cfg_path])
|
|
457
|
+
check("inside budget -> PASS", code, out, 0, PASS_MARKER)
|
|
458
|
+
|
|
459
|
+
over = snapshot("over.json", [dict(base, session_tokens=tokens_over)])
|
|
460
|
+
code, out = _run(["--snapshot", over, "--config", cfg_path])
|
|
461
|
+
check("tokens over cap -> STOP naming the card and the rule", code, out, 1, STOP_MARKER)
|
|
462
|
+
check("tokens over cap names the token rule", code, out, 1, "cap 100,000")
|
|
463
|
+
|
|
464
|
+
missing = {k: v for k, v in base.items()}
|
|
465
|
+
no_tokens = snapshot("no_tokens.json", [dict(missing)])
|
|
466
|
+
code, out = _run(["--snapshot", no_tokens, "--config", cfg_path])
|
|
467
|
+
check("missing session_tokens -> STOP, prints unknown", code, out, 1, "unknown tokens", "0 tokens")
|
|
468
|
+
|
|
469
|
+
thirty = now - 40 * 60
|
|
470
|
+
clock = snapshot("clock.json", [dict(base, started_at=thirty, session_tokens=tokens_ok)])
|
|
471
|
+
code, out = _run(["--snapshot", clock, "--config", cfg_path])
|
|
472
|
+
check("wall clock over cap -> STOP", code, out, 1, "min of wall clock (cap 30)")
|
|
473
|
+
|
|
474
|
+
many = snapshot("many.json", [dict(base, run_id=i, session_tokens=tokens_ok) for i in range(1, 4)])
|
|
475
|
+
code, out = _run(["--snapshot", many, "--config", cfg_path])
|
|
476
|
+
check("max_runs over cap -> STOP", code, out, 1, "runs on one card (cap 2)")
|
|
477
|
+
|
|
478
|
+
# A real repository with no commit after the run started: progress is measured, not assumed.
|
|
479
|
+
repo = os.path.join(tmp, "repo")
|
|
480
|
+
os.makedirs(repo)
|
|
481
|
+
# The commit has to be dated before the run starts, otherwise it counts as progress made by the run.
|
|
482
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(now - 3600))
|
|
483
|
+
env = {**os.environ, "GIT_AUTHOR_NAME": "selftest", "GIT_AUTHOR_EMAIL": "selftest@example.org",
|
|
484
|
+
"GIT_COMMITTER_NAME": "selftest", "GIT_COMMITTER_EMAIL": "selftest@example.org",
|
|
485
|
+
"GIT_AUTHOR_DATE": stamp, "GIT_COMMITTER_DATE": stamp}
|
|
486
|
+
git = ["git", "-C", repo]
|
|
487
|
+
made = True
|
|
488
|
+
for cmd in ([*git, "init", "-q"], [*git, "commit", "-q", "--allow-empty", "-m", "before"]):
|
|
489
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
490
|
+
made = made and proc.returncode == 0
|
|
491
|
+
if made:
|
|
492
|
+
stale = snapshot("stale.json", [dict(base, started_at=now - 25 * 60, workspace=repo,
|
|
493
|
+
session_tokens=tokens_ok)])
|
|
494
|
+
code, out = _run(["--snapshot", stale, "--config", cfg_path])
|
|
495
|
+
check("no commit past the progress clock -> STOP", code, out, 1, "no commit in")
|
|
496
|
+
else:
|
|
497
|
+
print("[skip] no-commit rule: `git` unavailable, the rule cannot be exercised here")
|
|
498
|
+
ok = False
|
|
499
|
+
|
|
500
|
+
# The enforce path must not pretend to own a board it cannot see.
|
|
501
|
+
code, out = _run(["--snapshot", over, "--config", cfg_path, "--enforce"])
|
|
502
|
+
check("--enforce on a snapshot touches no queue", code, out, 1, "queue itself was not touched")
|
|
503
|
+
|
|
504
|
+
unreadable = os.path.join(tmp, "broken.json")
|
|
505
|
+
with open(unreadable, "w", encoding="utf-8") as fh:
|
|
506
|
+
fh.write("{not json")
|
|
507
|
+
code, out = _run(["--snapshot", unreadable, "--config", cfg_path])
|
|
508
|
+
check("unreadable snapshot -> STOP naming the file", code, out, 1, "snapshot error")
|
|
509
|
+
|
|
510
|
+
empty_db = os.path.join(tmp, "missing.db")
|
|
511
|
+
code, out = _run(["--db", empty_db, "--state-db", empty_db, "--config", cfg_path])
|
|
512
|
+
check("no database present -> PASS (nothing is spending)", code, out, 0, PASS_MARKER)
|
|
513
|
+
|
|
514
|
+
print(f"selftest: {'all rules fired' if ok else 'a rule did not fire'}")
|
|
515
|
+
return 0 if ok else 1
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
sys.exit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Change detector for the loop gate (the "report only on meaningful change" piece of stage 3).
|
|
3
|
+
|
|
4
|
+
Runs ``loopgate/gates/check_loop.py`` and reduces the run to a small deterministic state fingerprint:
|
|
5
|
+
verdict marker, per-dimension statuses, the failure list, the loop metrics and the round. Compares it
|
|
6
|
+
with the previous fingerprint stored in ``artifacts/loop_watch_last.json``:
|
|
7
|
+
|
|
8
|
+
* first ever run -> prints one baseline line and stores the fingerprint;
|
|
9
|
+
* unchanged -> prints **nothing** and exits 0 (a cron `monitor` script than skips the agent run
|
|
10
|
+
entirely, so a quiet project costs no tokens);
|
|
11
|
+
* changed -> prints a compact before -> after diff and stores the new fingerprint.
|
|
12
|
+
|
|
13
|
+
The output contains no timestamps or paths that vary between runs, so identical states always produce
|
|
14
|
+
identical output (required for the cron monitor contract).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 -m loopgate.gates.loop_watch # print only when the state changed
|
|
18
|
+
python3 -m loopgate.gates.loop_watch --force # always print the current fingerprint
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from loopgate.paths import project_root, gate_file
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
ROOT = project_root()
|
|
32
|
+
REPORT = os.path.join(ROOT, "artifacts", "loop_watch_report.json")
|
|
33
|
+
LAST = os.path.join(ROOT, "artifacts", "loop_watch_last.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fingerprint() -> dict:
|
|
37
|
+
"""Run the gate and reduce it to a deterministic state fingerprint."""
|
|
38
|
+
subprocess.run(
|
|
39
|
+
[sys.executable, "-m", "loopgate.gates.check_loop", "--report", REPORT],
|
|
40
|
+
cwd=ROOT,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
)
|
|
44
|
+
report = json.load(open(REPORT, encoding="utf-8"))
|
|
45
|
+
state = json.load(open(os.path.join(ROOT, "artifacts", "loop_state.json"), encoding="utf-8"))
|
|
46
|
+
dims = {d["id"]: d["status"] for d in report.get("dimensions", [])}
|
|
47
|
+
return {
|
|
48
|
+
"marker": report.get("marker"),
|
|
49
|
+
"acceptance": "strict-complete"
|
|
50
|
+
if report.get("marker") == "LOOP_ENGINEERING_PASS_0001" and not report.get("failures")
|
|
51
|
+
else ("stage-1" if report.get("marker") == "LOOP_ENGINEERING_PASS_0001" else "not-accepted"),
|
|
52
|
+
"dims": dims,
|
|
53
|
+
"failures": report.get("failures") or [],
|
|
54
|
+
"round": state.get("round"),
|
|
55
|
+
"readiness": state.get("readiness"),
|
|
56
|
+
"evidence": state.get("evidence"),
|
|
57
|
+
"risk": state.get("risk"),
|
|
58
|
+
"blockers": len(state.get("blockers") or []),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_fp(fp: dict) -> str:
|
|
63
|
+
dims = " ".join(f"{k}={v}" for k, v in sorted(fp["dims"].items()))
|
|
64
|
+
return (
|
|
65
|
+
f"{fp['marker']} ({fp['acceptance']}) round={fp['round']} "
|
|
66
|
+
f"readiness={fp['readiness']} evidence={fp['evidence']} risk={fp['risk']} "
|
|
67
|
+
f"blockers={fp['blockers']}\n {dims}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def diff(prev: dict, cur: dict) -> list[str]:
|
|
72
|
+
lines: list[str] = []
|
|
73
|
+
for key in ("marker", "acceptance", "round", "readiness", "evidence", "risk", "blockers"):
|
|
74
|
+
if prev.get(key) != cur.get(key):
|
|
75
|
+
lines.append(f" {key}: {prev.get(key)} -> {cur.get(key)}")
|
|
76
|
+
for dim in sorted(set(prev.get("dims", {})) | set(cur.get("dims", {}))):
|
|
77
|
+
if prev.get("dims", {}).get(dim) != cur.get("dims", {}).get(dim):
|
|
78
|
+
lines.append(f" {dim}: {prev.get('dims', {}).get(dim)} -> {cur.get('dims', {}).get(dim)}")
|
|
79
|
+
if prev.get("failures") != cur.get("failures"):
|
|
80
|
+
lines.append(f" failures: {prev.get('failures')} -> {cur.get('failures')}")
|
|
81
|
+
return lines
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> int:
|
|
85
|
+
ap = argparse.ArgumentParser(description="Loop gate change detector (deterministic output).")
|
|
86
|
+
ap.add_argument("--force", action="store_true", help="print the current fingerprint even if unchanged")
|
|
87
|
+
args = ap.parse_args()
|
|
88
|
+
|
|
89
|
+
cur = fingerprint()
|
|
90
|
+
prev = json.load(open(LAST, encoding="utf-8")) if os.path.exists(LAST) else None
|
|
91
|
+
|
|
92
|
+
if args.force:
|
|
93
|
+
print("loop gate state:")
|
|
94
|
+
print(render_fp(cur))
|
|
95
|
+
json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if prev is None:
|
|
99
|
+
print("loop gate baseline established:")
|
|
100
|
+
print(render_fp(cur))
|
|
101
|
+
json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
if prev == cur:
|
|
105
|
+
json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
print("loop gate state changed:")
|
|
109
|
+
for line in diff(prev, cur):
|
|
110
|
+
print(line)
|
|
111
|
+
print("now:")
|
|
112
|
+
print(render_fp(cur))
|
|
113
|
+
json.dump(cur, open(LAST, "w", encoding="utf-8"), indent=2, sort_keys=True)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|