task-pipeline-skill 1.99.1 → 1.100.0
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.
- package/CHANGELOG.md +31 -0
- package/README.md +2 -1
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/design-tester.md +80 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +73 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +28 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/design-review.md +64 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +9 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +15 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +2 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/design_gate.py +362 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/resource_guard.py +338 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +51 -4
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +6 -4
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The machine a browser run borrows: check its load, record what the run starts, close
|
|
3
|
+
exactly that, and prove nothing is left. Stdlib only; macOS and Linux.
|
|
4
|
+
|
|
5
|
+
resource_guard.py check [--max-load-ratio 1.0] [--min-free-gb 5]
|
|
6
|
+
[--max-memory-pressure normal|warn] [--json]
|
|
7
|
+
before a run and between scenarios: load average against cores, memory pressure,
|
|
8
|
+
free disk. Exit 0 go; 4 wait (over a line: wait, then run with fewer pages).
|
|
9
|
+
resource_guard.py record <ledger.json> <pid> <what> [--cap-kind browser|page|server]
|
|
10
|
+
[--max-browsers 2] [--max-pages 4] [--max-servers 1]
|
|
11
|
+
after starting a process: append it with its command line. Exit 5 when the cap for
|
|
12
|
+
its kind is already reached; the caller closes something first.
|
|
13
|
+
resource_guard.py close <ledger.json> [--grace 5]
|
|
14
|
+
at the end: SIGTERM, then SIGKILL after the grace, every RECORDED process that is
|
|
15
|
+
still alive and still the same process (its start time is compared, which survives
|
|
16
|
+
an exec, so a reused pid is never touched), and the descendants it spawned (browser
|
|
17
|
+
helpers, a launcher's child). Nothing else is ever signalled.
|
|
18
|
+
resource_guard.py verify <ledger.json> [--json]
|
|
19
|
+
exit 0 when every recorded process is gone; 1 naming the live ones; 2 when a process
|
|
20
|
+
cannot be inspected (no `ps`): never silently clean. Prints the receipt the
|
|
21
|
+
design-review report carries (`resources`).
|
|
22
|
+
|
|
23
|
+
The operator's own browser windows are never in the ledger, so they are never touched.
|
|
24
|
+
Exit codes: 0 ok · 1 live processes · 2 usage · 4 wait · 5 cap reached.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import fcntl
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import tempfile
|
|
33
|
+
import shutil
|
|
34
|
+
import signal
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cpu_count():
|
|
41
|
+
return os.cpu_count() or 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_avg():
|
|
45
|
+
try:
|
|
46
|
+
return os.getloadavg()[0]
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def memory_pressure():
|
|
52
|
+
"""'normal' | 'warn' | 'critical' | None (unknown)."""
|
|
53
|
+
if sys.platform == "darwin":
|
|
54
|
+
try:
|
|
55
|
+
out = subprocess.run(["sysctl", "-n", "kern.memorystatus_vm_pressure_level"],
|
|
56
|
+
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
57
|
+
return {"1": "normal", "2": "warn", "4": "critical"}.get(out)
|
|
58
|
+
except (OSError, subprocess.SubprocessError):
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
with open("/proc/meminfo") as fh:
|
|
62
|
+
info = {l.split(":")[0]: int(l.split()[1]) for l in fh if ":" in l}
|
|
63
|
+
avail = info.get("MemAvailable", 0) / max(info.get("MemTotal", 1), 1)
|
|
64
|
+
return "critical" if avail < 0.05 else "warn" if avail < 0.15 else "normal"
|
|
65
|
+
except (OSError, ValueError, IndexError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
LEVELS = ("normal", "warn", "critical")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check(max_load_ratio, min_free_gb, max_memory="normal"):
|
|
73
|
+
la, cores = load_avg(), cpu_count()
|
|
74
|
+
mem = memory_pressure()
|
|
75
|
+
free_gb = shutil.disk_usage(os.path.expanduser("~")).free / 1e9
|
|
76
|
+
reasons = []
|
|
77
|
+
if la is not None and la > cores * max_load_ratio:
|
|
78
|
+
reasons.append("load %.1f over %d cores × %.1f" % (la, cores, max_load_ratio))
|
|
79
|
+
if mem in LEVELS and LEVELS.index(mem) > LEVELS.index(max_memory):
|
|
80
|
+
reasons.append("memory pressure %s over %s" % (mem, max_memory))
|
|
81
|
+
if free_gb < min_free_gb:
|
|
82
|
+
reasons.append("free disk %.1f GB under %.0f GB" % (free_gb, min_free_gb))
|
|
83
|
+
return dict(go=not reasons, load=la, cores=cores, memory=mem or "unknown",
|
|
84
|
+
free_gb=round(free_gb, 1), reasons=reasons)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Uninspectable(Exception):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def exists(pid):
|
|
92
|
+
try:
|
|
93
|
+
os.kill(int(pid), 0)
|
|
94
|
+
return True
|
|
95
|
+
except ProcessLookupError:
|
|
96
|
+
return False
|
|
97
|
+
except PermissionError:
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Every ps runs in one locale and one zone: `lstart` is a different string under each, and a
|
|
102
|
+
# start time read under another TZ would make a live process look like a stranger.
|
|
103
|
+
PS_ENV = dict(os.environ, LC_ALL="C", LANG="C", TZ="UTC")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ps_field(pid, field):
|
|
107
|
+
"""A `ps -o <field>=` value for one pid; None when the pid is gone; Uninspectable when
|
|
108
|
+
`ps` itself cannot run (the caller must not read that as gone)."""
|
|
109
|
+
try:
|
|
110
|
+
out = subprocess.run(["ps", "-o", field + "=", "-p", str(pid)], capture_output=True,
|
|
111
|
+
text=True, timeout=5, env=PS_ENV)
|
|
112
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
113
|
+
raise Uninspectable("ps unavailable (%s)" % exc)
|
|
114
|
+
if out.returncode not in (0, 1):
|
|
115
|
+
raise Uninspectable("ps exited %s" % out.returncode)
|
|
116
|
+
return out.stdout.strip() or None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def started(pid):
|
|
120
|
+
return ps_field(pid, "lstart")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def alive_same(entry):
|
|
124
|
+
"""True: the recorded process is still running (same pid, same start time). False: it is
|
|
125
|
+
gone, or the pid now belongs to another process. Uninspectable: cannot tell."""
|
|
126
|
+
if not exists(entry["pid"]):
|
|
127
|
+
return False
|
|
128
|
+
now = started(entry["pid"])
|
|
129
|
+
if now is None:
|
|
130
|
+
return False
|
|
131
|
+
return now == entry.get("started")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def descendants(pids):
|
|
135
|
+
"""Every live descendant of the given pids, from one `ps -A -o pid=,ppid=` table."""
|
|
136
|
+
try:
|
|
137
|
+
out = subprocess.run(["ps", "-A", "-o", "pid=,ppid="], capture_output=True, text=True, timeout=5,
|
|
138
|
+
env=PS_ENV).stdout
|
|
139
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
140
|
+
raise Uninspectable("ps unavailable (%s)" % exc)
|
|
141
|
+
kids = {}
|
|
142
|
+
for line in out.splitlines():
|
|
143
|
+
parts = line.split()
|
|
144
|
+
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
145
|
+
kids.setdefault(int(parts[1]), []).append(int(parts[0]))
|
|
146
|
+
seen, todo = set(), list(pids)
|
|
147
|
+
while todo:
|
|
148
|
+
p = todo.pop()
|
|
149
|
+
for c in kids.get(p, []):
|
|
150
|
+
if c not in seen:
|
|
151
|
+
seen.add(c)
|
|
152
|
+
todo.append(c)
|
|
153
|
+
return sorted(seen)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_ledger(path):
|
|
157
|
+
if not os.path.exists(path):
|
|
158
|
+
return {"schema": "resource-ledger/1", "started": []}
|
|
159
|
+
with open(path, encoding="utf-8") as fh:
|
|
160
|
+
doc = json.load(fh)
|
|
161
|
+
if doc.get("schema") != "resource-ledger/1" or not isinstance(doc.get("started"), list):
|
|
162
|
+
raise ValueError("not a resource-ledger/1 file")
|
|
163
|
+
return doc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def save_ledger(path, doc):
|
|
167
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
168
|
+
os.makedirs(d, exist_ok=True)
|
|
169
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".resource-ledger.", suffix=".tmp")
|
|
170
|
+
try:
|
|
171
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
172
|
+
json.dump(doc, fh, indent=2)
|
|
173
|
+
fh.write("\n")
|
|
174
|
+
os.replace(tmp, path)
|
|
175
|
+
finally:
|
|
176
|
+
if os.path.exists(tmp):
|
|
177
|
+
os.unlink(tmp)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class locked:
|
|
181
|
+
"""An exclusive lock on the ledger's sidecar for a whole read-modify-write: two agents
|
|
182
|
+
recording at once both land."""
|
|
183
|
+
def __init__(self, path):
|
|
184
|
+
self.lock = os.path.abspath(path) + ".lock"
|
|
185
|
+
|
|
186
|
+
def __enter__(self):
|
|
187
|
+
os.makedirs(os.path.dirname(self.lock), exist_ok=True)
|
|
188
|
+
self.fh = open(self.lock, "a")
|
|
189
|
+
fcntl.flock(self.fh, fcntl.LOCK_EX)
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *exc):
|
|
193
|
+
fcntl.flock(self.fh, fcntl.LOCK_UN)
|
|
194
|
+
self.fh.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ancestors():
|
|
198
|
+
"""This process and every ancestor up to pid 1: never recorded, never signalled."""
|
|
199
|
+
out, p = {os.getpid()}, os.getpid()
|
|
200
|
+
for _ in range(64):
|
|
201
|
+
try:
|
|
202
|
+
pp = int(ps_field(p, "ppid") or 0)
|
|
203
|
+
except ValueError:
|
|
204
|
+
break
|
|
205
|
+
if pp <= 1 or pp in out:
|
|
206
|
+
break
|
|
207
|
+
out.add(pp)
|
|
208
|
+
p = pp
|
|
209
|
+
return out | {1}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def record(path, pid, what, kind, caps):
|
|
213
|
+
if int(pid) in ancestors():
|
|
214
|
+
return 2, "pid %s is this process, one of its ancestors or pid 1: never recorded" % pid
|
|
215
|
+
with locked(path):
|
|
216
|
+
doc = load_ledger(path)
|
|
217
|
+
live = [e for e in doc["started"] if e.get("kind") == kind and alive_same(e)]
|
|
218
|
+
if len(live) >= caps[kind]:
|
|
219
|
+
return 5, "cap reached: %d live %s(s), the cap is %d; close one first" % (len(live), kind, caps[kind])
|
|
220
|
+
if not exists(pid):
|
|
221
|
+
return 2, "pid %s is not running; nothing to record" % pid
|
|
222
|
+
st = started(pid)
|
|
223
|
+
if not st:
|
|
224
|
+
return 2, "pid %s is not running; nothing to record" % pid
|
|
225
|
+
doc["started"].append(dict(pid=int(pid), what=what, kind=kind, started=st,
|
|
226
|
+
command=ps_field(pid, "command") or "", at=int(time.time())))
|
|
227
|
+
save_ledger(path, doc)
|
|
228
|
+
return 0, "recorded %s %s (%s)" % (kind, pid, what)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def close(path, grace):
|
|
232
|
+
with locked(path):
|
|
233
|
+
doc = load_ledger(path)
|
|
234
|
+
keep = ancestors()
|
|
235
|
+
alive = [e for e in doc["started"] if alive_same(e)]
|
|
236
|
+
# A protected pid (this process, an ancestor, pid 1) in the ledger is refused with its whole
|
|
237
|
+
# subtree: its descendants are the operator's session, never this run's.
|
|
238
|
+
protected = [e["pid"] for e in alive if e["pid"] in keep]
|
|
239
|
+
targets = [e for e in alive if e["pid"] not in keep]
|
|
240
|
+
# Children first are found while their parents still live; each keeps its own start time
|
|
241
|
+
# so a pid reused during the grace is not signalled.
|
|
242
|
+
kids = [dict(pid=k, started=started(k)) for k in descendants([e["pid"] for e in targets])]
|
|
243
|
+
victims = [dict(pid=e["pid"], started=e["started"]) for e in targets] + \
|
|
244
|
+
[k for k in kids if k["started"] and k["pid"] not in keep]
|
|
245
|
+
refused = list(protected)
|
|
246
|
+
for v in victims:
|
|
247
|
+
try:
|
|
248
|
+
os.kill(v["pid"], signal.SIGTERM)
|
|
249
|
+
except ProcessLookupError:
|
|
250
|
+
pass
|
|
251
|
+
except PermissionError:
|
|
252
|
+
refused.append(v["pid"])
|
|
253
|
+
deadline = time.time() + grace
|
|
254
|
+
while time.time() < deadline and any(alive_same(v) for v in victims):
|
|
255
|
+
time.sleep(0.2)
|
|
256
|
+
for v in victims:
|
|
257
|
+
if v["pid"] not in refused and alive_same(v):
|
|
258
|
+
try:
|
|
259
|
+
os.kill(v["pid"], signal.SIGKILL)
|
|
260
|
+
except ProcessLookupError:
|
|
261
|
+
pass
|
|
262
|
+
except PermissionError:
|
|
263
|
+
refused.append(v["pid"])
|
|
264
|
+
skipped = [e["pid"] for e in doc["started"] if e not in targets]
|
|
265
|
+
return [e["pid"] for e in targets], [k["pid"] for k in kids], skipped, refused
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def verify(path):
|
|
269
|
+
doc = load_ledger(path)
|
|
270
|
+
live = [e["pid"] for e in doc["started"] if alive_same(e)]
|
|
271
|
+
return dict(started=[dict(pid=e["pid"], what=e["what"], kind=e.get("kind")) for e in doc["started"]],
|
|
272
|
+
live=live, closed=not live)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv=None):
|
|
276
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
277
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
278
|
+
c = sub.add_parser("check")
|
|
279
|
+
c.add_argument("--max-load-ratio", type=float, default=1.0)
|
|
280
|
+
c.add_argument("--min-free-gb", type=float, default=5)
|
|
281
|
+
c.add_argument("--max-memory-pressure", default="normal", choices=("normal", "warn"))
|
|
282
|
+
c.add_argument("--json", action="store_true")
|
|
283
|
+
r = sub.add_parser("record")
|
|
284
|
+
r.add_argument("ledger")
|
|
285
|
+
r.add_argument("pid", type=int)
|
|
286
|
+
r.add_argument("what")
|
|
287
|
+
r.add_argument("--cap-kind", dest="kind", default="browser", choices=("browser", "page", "server"))
|
|
288
|
+
r.add_argument("--max-browsers", type=int, default=2)
|
|
289
|
+
r.add_argument("--max-pages", type=int, default=4)
|
|
290
|
+
r.add_argument("--max-servers", type=int, default=1)
|
|
291
|
+
k = sub.add_parser("close")
|
|
292
|
+
k.add_argument("ledger")
|
|
293
|
+
k.add_argument("--grace", type=float, default=5)
|
|
294
|
+
v = sub.add_parser("verify")
|
|
295
|
+
v.add_argument("ledger")
|
|
296
|
+
v.add_argument("--json", action="store_true")
|
|
297
|
+
a = ap.parse_args(argv)
|
|
298
|
+
try:
|
|
299
|
+
if a.cmd == "check":
|
|
300
|
+
res = check(a.max_load_ratio, a.min_free_gb, a.max_memory_pressure)
|
|
301
|
+
if a.json:
|
|
302
|
+
print(json.dumps(res, indent=2))
|
|
303
|
+
else:
|
|
304
|
+
print("resource_guard: %s · load %s/%d cores · memory %s · free %.1f GB%s" % (
|
|
305
|
+
"go" if res["go"] else "wait", res["load"], res["cores"], res["memory"], res["free_gb"],
|
|
306
|
+
"" if res["go"] else " · " + "; ".join(res["reasons"])))
|
|
307
|
+
return 0 if res["go"] else 4
|
|
308
|
+
if a.cmd == "record":
|
|
309
|
+
caps = dict(browser=a.max_browsers, page=a.max_pages, server=a.max_servers)
|
|
310
|
+
code, msg = record(a.ledger, a.pid, a.what, a.kind, caps)
|
|
311
|
+
print("resource_guard: %s" % msg, file=sys.stderr if code else sys.stdout)
|
|
312
|
+
return code
|
|
313
|
+
if a.cmd == "close":
|
|
314
|
+
closed, kids, skipped, refused = close(a.ledger, a.grace)
|
|
315
|
+
print("resource_guard: closed %d recorded process(es) and %d descendant(s); %d already gone"
|
|
316
|
+
% (len(closed), len(kids), len(skipped))
|
|
317
|
+
+ ("; refused (permission): %s" % ", ".join(map(str, refused)) if refused else ""))
|
|
318
|
+
return 1 if refused else 0
|
|
319
|
+
res = verify(a.ledger)
|
|
320
|
+
# The zero-byte lock sidecar stays: unlinking it while another agent holds it would let
|
|
321
|
+
# two writers in. It lives beside the ledger in .task-pipeline/, outside the tracked tree.
|
|
322
|
+
if a.json:
|
|
323
|
+
print(json.dumps(res, indent=2))
|
|
324
|
+
else:
|
|
325
|
+
print("resource_guard: %s · %d started · live: %s" % (
|
|
326
|
+
"clean" if res["closed"] else "LIVE", len(res["started"]), ", ".join(map(str, res["live"])) or "none"))
|
|
327
|
+
return 0 if res["closed"] else 1
|
|
328
|
+
except Uninspectable as exc:
|
|
329
|
+
print("resource_guard: NOT_RUN: %s; a process that cannot be inspected is never reported gone" % exc,
|
|
330
|
+
file=sys.stderr)
|
|
331
|
+
return 2
|
|
332
|
+
except (ValueError, OSError) as exc:
|
|
333
|
+
print("resource_guard: %s" % exc, file=sys.stderr)
|
|
334
|
+
return 2
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
if __name__ == "__main__":
|
|
338
|
+
sys.exit(main())
|
|
@@ -32,7 +32,7 @@ reviewed; a table and a re-run can. This script is that re-run:
|
|
|
32
32
|
(a plan that spans several repositories' graphs).
|
|
33
33
|
waves.py finish --inventory FILE --max-rounds N --max-releases M (--rounds K | --ledger RUN.md)
|
|
34
34
|
--checks K/M --artefact NAME@SHA [--releases R] [--operator-go]
|
|
35
|
-
[--clean-rounds 1|2]
|
|
35
|
+
[--clean-rounds 1|2] [--max-agents 2]
|
|
36
36
|
the finish-line verdict (`references/finish-line.md`) from the triaged items, and the
|
|
37
37
|
finish block printed. Only `triage: blocking` items count, less those closed as
|
|
38
38
|
duplicate, stale or not-reproduced; deferred and noise never change the verdict. The
|
|
@@ -47,7 +47,10 @@ reviewed; a table and a re-run can. This script is that re-run:
|
|
|
47
47
|
self-inflicted >= new, from round 2 on); 3 continue (including no --checks); 1
|
|
48
48
|
malformed (a schema error, a blocking item without `test` or `round`, B3 with
|
|
49
49
|
base_repro yes, a round past the rounds run, no round count, a ledger that disagrees,
|
|
50
|
-
--max-rounds below 2, an artefact that is not NAME@SHA).
|
|
50
|
+
--max-rounds below 2, an artefact that is not NAME@SHA). Since 1.100.0 the ledger's
|
|
51
|
+
`agents: <N> — <k>` lines (the most sub-agents running at once in round N) are read
|
|
52
|
+
too: a round over --max-agents (run.finish.maxAgents, 2 by default) is a budget
|
|
53
|
+
verdict, exit 5, unless --operator-go.
|
|
51
54
|
|
|
52
55
|
**The plan** is any JSON whose `nodes` list `{id, wave, closes}` — a work graph that carries
|
|
53
56
|
`closes` on its nodes is one, and so is a short hand-written table. A node may carry
|
|
@@ -559,6 +562,7 @@ def cmd_sync_graph(a) -> int:
|
|
|
559
562
|
# A blocking item closed as one of these was never a blocker: it counts in no round.
|
|
560
563
|
NOT_COUNTED = ("duplicate", "stale", "not-reproduced")
|
|
561
564
|
ROUND_LINE = re.compile(r"^round:\s*(\d+)\s*[—-]\s*(.*?)\s*[—-]\s*new blocking\s*(\d+)\s*$")
|
|
565
|
+
AGENTS_LINE = re.compile(r"^agents:\s*(\d+)\s*[—-]\s*(\d+)\b")
|
|
562
566
|
ARTEFACT = re.compile(r"^[^@\s]+@[0-9a-f]{7,40}$")
|
|
563
567
|
|
|
564
568
|
|
|
@@ -598,6 +602,27 @@ def ledger_rounds(path: pathlib.Path) -> dict[int, int]:
|
|
|
598
602
|
return out
|
|
599
603
|
|
|
600
604
|
|
|
605
|
+
def ledger_agents(path: pathlib.Path) -> dict[int, int]:
|
|
606
|
+
"""{round: most sub-agents at once} from the run ledger's `agents:` lines (since 1.100.0).
|
|
607
|
+
A round written twice keeps its larger count: the budget is about the peak."""
|
|
608
|
+
try:
|
|
609
|
+
text = path.read_text(encoding="utf-8")
|
|
610
|
+
except (OSError, UnicodeError) as exc:
|
|
611
|
+
raise Fail(1, [f"the run ledger {path} cannot be read ({type(exc).__name__})"])
|
|
612
|
+
out, errs = {}, []
|
|
613
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
614
|
+
if not line.startswith("agents:"):
|
|
615
|
+
continue
|
|
616
|
+
m = AGENTS_LINE.match(line.strip())
|
|
617
|
+
if not m:
|
|
618
|
+
errs.append(f"{path}:{n}: not `agents: <N> — <k>`")
|
|
619
|
+
continue
|
|
620
|
+
out[int(m[1])] = max(out.get(int(m[1]), 0), int(m[2]))
|
|
621
|
+
if errs:
|
|
622
|
+
raise Fail(1, errs)
|
|
623
|
+
return out
|
|
624
|
+
|
|
625
|
+
|
|
601
626
|
def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases: int,
|
|
602
627
|
releases: int, checks: tuple[int, int] | None,
|
|
603
628
|
operator_go: bool = False, clean_rounds: int = 1) -> tuple[int, str, dict]:
|
|
@@ -693,12 +718,31 @@ def cmd_finish(a) -> int:
|
|
|
693
718
|
raise Fail(1, [f"--artefact {a.artefact!r} is not NAME@SHA (a commit of 7-40 hex digits)"])
|
|
694
719
|
code, word, f = finish_verdict(items, rounds, a.max_rounds, a.max_releases, a.releases, checks,
|
|
695
720
|
a.operator_go, a.clean_rounds)
|
|
721
|
+
# The parallel-agent budget (run.finish.maxAgents): a round that ran more sub-agents at
|
|
722
|
+
# once than the budget allows stops the iteration like any other budget, said with the
|
|
723
|
+
# numbers; the operator's go lifts it, out loud.
|
|
724
|
+
agents = ledger_agents(pathlib.Path(a.ledger)) if a.ledger else {}
|
|
725
|
+
if a.max_agents < 1:
|
|
726
|
+
raise Fail(1, [f"--max-agents {a.max_agents} is below 1"])
|
|
727
|
+
stray = sorted(r for r in agents if r < 1 or r > rounds)
|
|
728
|
+
if stray:
|
|
729
|
+
raise Fail(1, [f"{a.ledger}: an `agents:` line names round {r}, and only rounds 1..{rounds} ran"
|
|
730
|
+
for r in stray])
|
|
731
|
+
over = sorted((r, k) for r, k in agents.items() if k > a.max_agents)
|
|
732
|
+
named = ", ".join(f"round {r}: {k}/{a.max_agents}" for r, k in over)
|
|
733
|
+
if over and not a.operator_go and code in (0, 3):
|
|
734
|
+
code, word = 5, "budget"
|
|
735
|
+
f = dict(f, why="agents over budget — " + named)
|
|
736
|
+
elif over and a.operator_go and code in (0, 3):
|
|
737
|
+
f = dict(f, why=((f.get("why") + "; ") if f.get("why") else "") + "agents over budget on the operator's go — " + named)
|
|
696
738
|
deferred = sorted((x for x in items if x.get("triage") == "deferred"), key=_priority)
|
|
697
739
|
noise = [x for x in items if x.get("triage") == "noise"]
|
|
698
740
|
risks = [x for x in deferred if x.get("blast") == 3 or x.get("rung") in ("I4", "I5")]
|
|
699
741
|
pool = sorted((x for x in items if x.get("triage") != "blocking" and _needs_node(x)
|
|
700
742
|
and x.get("triage") != "noise"), key=_priority)
|
|
701
|
-
|
|
743
|
+
peak = max(agents.values()) if agents else None
|
|
744
|
+
budget = (f"rounds {f['rounds']}/{a.max_rounds} — releases {a.releases}/{a.max_releases} — agents "
|
|
745
|
+
+ (f"{peak}/{a.max_agents}" if peak is not None else f"unrecorded/{a.max_agents}"))
|
|
702
746
|
if word == "continue":
|
|
703
747
|
print(f"next round: {f['rounds'] + 1}/{a.max_rounds} — {f['why']} — {budget}")
|
|
704
748
|
else:
|
|
@@ -756,8 +800,11 @@ def main(argv: list[str]) -> int:
|
|
|
756
800
|
f.add_argument("--rounds", type=int, help="rounds run, clean ones included")
|
|
757
801
|
f.add_argument("--ledger", help="the run ledger; its `round:` lines are the round count")
|
|
758
802
|
f.add_argument("--operator-go", action="store_true",
|
|
759
|
-
help="the operator's recorded go for rounds past --max-rounds"
|
|
803
|
+
help="the operator's recorded go for rounds past --max-rounds and sub-agents past "
|
|
804
|
+
"--max-agents; the block says which budget it lifted")
|
|
760
805
|
f.add_argument("--checks", help="K/M — exit checks green on the artefact")
|
|
806
|
+
f.add_argument("--max-agents", type=int, default=2,
|
|
807
|
+
help="run.finish.maxAgents: sub-agents running at once in one round (default 2)")
|
|
761
808
|
f.add_argument("--artefact", help="what they ran on, as NAME@SHA")
|
|
762
809
|
a = p.parse_args(argv)
|
|
763
810
|
verbs = {"merge": cmd_merge, "assign": cmd_assign, "count": cmd_count, "validate": cmd_validate,
|
|
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
|
|
|
20
20
|
|
|
21
21
|
## `read:` — which doctrine this run actually opened
|
|
22
22
|
|
|
23
|
-
The bundle is
|
|
23
|
+
The bundle is 54 reference files and nothing recorded which of them a run read, so **a
|
|
24
24
|
skipped file and a read one were indistinguishable** — the class every guard in this
|
|
25
25
|
pipeline exists to catch, left standing over the doctrine itself.
|
|
26
26
|
|
|
@@ -48,7 +48,7 @@ same claim one level down.
|
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `unmeasured — no run ledger` | there is no ledger | nothing to read from |
|
|
50
50
|
| `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
|
|
51
|
-
| `N of
|
|
51
|
+
| `N of 54 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
|
|
52
52
|
|
|
53
53
|
**It is a disclosure: no floor, no direction, never a target.** A run that needs four files
|
|
54
54
|
and reads four is not worse than one that reads thirty — and the moment the number becomes
|
|
@@ -73,7 +73,8 @@ event: <compact|session-end|subagent|memory> — <detail> — <ISO-8601>
|
|
|
73
73
|
review: <stage id> — surface <name> — rounds <N> — <returned|approved|unresolved> — <ISO-8601>
|
|
74
74
|
finding: <id> — blocking:B<n> | deferred:B-NNN | noise — round <N> — base-repro yes|no|n/a
|
|
75
75
|
round: <N> — <reading> — new blocking <k>
|
|
76
|
-
|
|
76
|
+
agents: <N> — <most sub-agents running at once in round N>
|
|
77
|
+
finish: done|budget|blocked — rounds <n>/<max> — releases <n>/<max> — agents <n>/<max>
|
|
77
78
|
read: references/<file>.md # hook-appended, deduped, UNATTESTED (no writer field)
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -161,6 +162,7 @@ review: 6 — surface landing — rounds 1 — returned — 2026-08-10T12:02Z
|
|
|
161
162
|
finding: F-021 — blocking:B3 — round 1 — base-repro no
|
|
162
163
|
finding: F-022 — deferred:B-118 — round 1 — base-repro yes
|
|
163
164
|
round: 1 — exit checks + audit axis seams — new blocking 1
|
|
165
|
+
agents: 1 — 2
|
|
164
166
|
finding: F-023 — noise — round 2 — base-repro n/a
|
|
165
167
|
round: 2 — exit checks + observer — new blocking 0
|
|
166
168
|
round: 3 — exit checks + post-release readback — new blocking 0
|
|
@@ -169,7 +171,7 @@ hand: 3 — task "add CSV export to the orders table" — done 2 — surfaced 1
|
|
|
169
171
|
scope 5f21ac3/node-24-linux/REQ-001,REQ-004 — unverified 1 (XLSX path: no fixture)
|
|
170
172
|
holds: 5 — 2 (worktree: build-csv-export, this run; container: pg-test, this run) — enumerated 8/8 classes
|
|
171
173
|
holds: 10 — none — enumerated 7/8 classes, unlooked: containers (no docker on this host)
|
|
172
|
-
finish: done — rounds 3/3 — releases 1/2
|
|
174
|
+
finish: done — rounds 3/3 — releases 1/2 — agents 2/2
|
|
173
175
|
```
|
|
174
176
|
|
|
175
177
|
The last two lines are a **trip**: the same file, two consecutive passes, the same
|