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,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Visual checks — what an operator *sees*, not only what the JSON says.
|
|
3
|
+
|
|
4
|
+
Why a separate script: the product and technical gates read machine reports. A person reads a page. The
|
|
5
|
+
difference matters — a page can be technically correct and visually wrong (a FAIL row rendered green, a
|
|
6
|
+
verdict buried below the fold, an empty queue table that looks like a broken page). Those are user-facing
|
|
7
|
+
defects that no JSON assertion catches, and they are exactly what the `qa` role is for.
|
|
8
|
+
|
|
9
|
+
Mechanism: render the operator status page, serve it locally, capture a screenshot and the DOM with headless
|
|
10
|
+
Chrome, then judge the DOM against the scenario's required/forbidden text. The screenshot is kept as an
|
|
11
|
+
artefact for the vision review step (a human or the agent looks at it; a text assertion cannot see layout).
|
|
12
|
+
|
|
13
|
+
Requires Google Chrome (headless); fail-closed when it is absent — a visual check that silently skips is
|
|
14
|
+
worse than no visual check.
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
-----
|
|
18
|
+
python3 -m loopgate.gates.check_visual --list
|
|
19
|
+
python3 -m loopgate.gates.check_visual # capture + judge every scenario
|
|
20
|
+
python3 -m loopgate.gates.check_visual --strict # SKIP becomes FAIL
|
|
21
|
+
python3 -m loopgate.gates.check_visual --scenario V01 --verbose
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from loopgate.paths import project_root, gate_file
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import datetime as dt
|
|
30
|
+
import functools
|
|
31
|
+
import http.server
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import socket
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import threading
|
|
39
|
+
|
|
40
|
+
ROOT = project_root()
|
|
41
|
+
|
|
42
|
+
from loopgate.gates import render_status_page # noqa: E402 (sibling script: the page under test)
|
|
43
|
+
|
|
44
|
+
PASS_MARKER = "VISUAL_CHECK_PASS_0001"
|
|
45
|
+
STOP_MARKER = "VISUAL_CHECK_STOP_0001"
|
|
46
|
+
SCENARIOS_REL = "configs/visual_scenarios.json"
|
|
47
|
+
SHOT_DIR_REL = "artifacts/visual_checks"
|
|
48
|
+
CHROME_CANDIDATES = [
|
|
49
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
50
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
51
|
+
"/usr/bin/google-chrome",
|
|
52
|
+
"/usr/bin/chromium",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_scenarios(root: str = ROOT) -> dict:
|
|
57
|
+
with open(os.path.join(root, SCENARIOS_REL), encoding="utf-8") as fh:
|
|
58
|
+
return json.load(fh)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def chrome_binary() -> str | None:
|
|
62
|
+
for candidate in CHROME_CANDIDATES:
|
|
63
|
+
if os.path.exists(candidate):
|
|
64
|
+
return candidate
|
|
65
|
+
return shutil.which("google-chrome") or shutil.which("chromium") or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------- evaluation (pure)
|
|
69
|
+
|
|
70
|
+
def evaluate(scenario: dict, observation: dict) -> list[str]:
|
|
71
|
+
"""Reasons the observed page violates the scenario; empty list = the page passes.
|
|
72
|
+
|
|
73
|
+
Pure so the technical gate can attack it (D13) with falsified DOM snapshots.
|
|
74
|
+
"""
|
|
75
|
+
exp = scenario.get("expect") or {}
|
|
76
|
+
dom = observation.get("dom") or ""
|
|
77
|
+
reasons: list[str] = []
|
|
78
|
+
for needle in exp.get("require_text") or []:
|
|
79
|
+
if needle not in dom:
|
|
80
|
+
reasons.append(f"a user would not see {needle!r} on the page")
|
|
81
|
+
for needle in exp.get("forbid_text") or []:
|
|
82
|
+
if needle in dom:
|
|
83
|
+
reasons.append(f"the page shows {needle!r}, which must not be visible to a user")
|
|
84
|
+
if exp.get("visible_cell_truncation"):
|
|
85
|
+
import re as _re
|
|
86
|
+
width = int(exp.get("cell_width", 70))
|
|
87
|
+
for cell in _re.findall(r"<td[^>]*>([^<>]+)</td>", dom):
|
|
88
|
+
text = cell.strip()
|
|
89
|
+
# The renderer clips long cells to `width` characters, ending with an ellipsis. A cell at or over
|
|
90
|
+
# that width without the ellipsis is text cut mid-word — visible to a user, invisible to a JSON check.
|
|
91
|
+
if len(text) >= width and not text.endswith("…"):
|
|
92
|
+
reasons.append(f"a table cell is cut mid-word without an ellipsis: {text[-28:]!r}")
|
|
93
|
+
shot = observation.get("screenshot")
|
|
94
|
+
if exp.get("screenshot_required") and not (shot and os.path.exists(shot)):
|
|
95
|
+
reasons.append("no screenshot was captured, so nobody can look at the page")
|
|
96
|
+
if exp.get("min_bytes") and observation.get("screenshot_bytes", 0) < exp["min_bytes"]:
|
|
97
|
+
reasons.append(f"the screenshot is {observation.get('screenshot_bytes', 0)} bytes "
|
|
98
|
+
f"(< {exp['min_bytes']}), so the page most likely rendered blank")
|
|
99
|
+
return reasons
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- capture
|
|
103
|
+
|
|
104
|
+
class _Handler(http.server.SimpleHTTPRequestHandler):
|
|
105
|
+
def log_message(self, *args): # keep the check output readable
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def serve(directory: str) -> tuple[http.server.ThreadingHTTPServer, int]:
|
|
110
|
+
with socket.socket() as sock:
|
|
111
|
+
sock.bind(("127.0.0.1", 0))
|
|
112
|
+
port = int(sock.getsockname()[1])
|
|
113
|
+
handler = functools.partial(_Handler, directory=directory)
|
|
114
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
115
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
116
|
+
return server, port
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def capture(chrome: str, url: str, shot_path: str, width: int = 1280, height: int = 1400) -> tuple[str, int]:
|
|
120
|
+
"""Return (dom, screenshot bytes). Both come from one headless Chrome run each."""
|
|
121
|
+
os.makedirs(os.path.dirname(shot_path), exist_ok=True)
|
|
122
|
+
shot = subprocess.run([chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
|
123
|
+
f"--window-size={width},{height}", f"--screenshot={shot_path}", url],
|
|
124
|
+
capture_output=True, text=True, timeout=60)
|
|
125
|
+
if shot.returncode != 0 or not os.path.exists(shot_path):
|
|
126
|
+
return "", 0
|
|
127
|
+
dom_proc = subprocess.run([chrome, "--headless=new", "--disable-gpu", "--dump-dom", url],
|
|
128
|
+
capture_output=True, text=True, timeout=60)
|
|
129
|
+
dom = dom_proc.stdout if dom_proc.returncode == 0 else ""
|
|
130
|
+
return dom, os.path.getsize(shot_path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv=None) -> int:
|
|
134
|
+
ap = argparse.ArgumentParser(description="Visual checks over the operator status page.")
|
|
135
|
+
ap.add_argument("--root", default=ROOT)
|
|
136
|
+
ap.add_argument("--list", action="store_true")
|
|
137
|
+
ap.add_argument("--scenario", action="append")
|
|
138
|
+
ap.add_argument("--strict", action="store_true")
|
|
139
|
+
ap.add_argument("--verbose", action="store_true")
|
|
140
|
+
ap.add_argument("--json", action="store_true")
|
|
141
|
+
args = ap.parse_args(argv)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
config = load_scenarios(args.root)
|
|
145
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
146
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
147
|
+
print(STOP_MARKER)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
scenarios = config["scenarios"]
|
|
151
|
+
if args.list:
|
|
152
|
+
for sc in scenarios:
|
|
153
|
+
flag = "optional" if sc.get("optional") else "mandatory"
|
|
154
|
+
print(f" {sc['id']} [{flag}] {sc['page']:<22} {sc['title']}")
|
|
155
|
+
return 0
|
|
156
|
+
if args.scenario:
|
|
157
|
+
wanted = set(args.scenario)
|
|
158
|
+
scenarios = [s for s in scenarios if s["id"] in wanted] or []
|
|
159
|
+
|
|
160
|
+
chrome = chrome_binary()
|
|
161
|
+
print("VISUAL CHECK — loopgate/gates/check_visual.py")
|
|
162
|
+
print(f"page: {config['page']} chrome: {chrome or '(not found)'}")
|
|
163
|
+
print()
|
|
164
|
+
if chrome is None:
|
|
165
|
+
print("[FAIL] no headless Chrome/Chromium on this machine: a visual check that silently skips is "
|
|
166
|
+
"worse than none")
|
|
167
|
+
print(STOP_MARKER)
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
render_status_page.main(["--root", args.root])
|
|
171
|
+
server, port = serve(os.path.join(args.root, "artifacts"))
|
|
172
|
+
results, failures, skips = [], [], []
|
|
173
|
+
try:
|
|
174
|
+
for sc in scenarios:
|
|
175
|
+
shot = os.path.join(args.root, SHOT_DIR_REL, f"{sc['id']}_{sc['page'].replace('/', '_')}.png")
|
|
176
|
+
dom, size = capture(chrome, f"http://127.0.0.1:{port}/{sc['page']}", shot)
|
|
177
|
+
observation = {"dom": dom, "screenshot": shot, "screenshot_bytes": size}
|
|
178
|
+
if not dom:
|
|
179
|
+
observation["skip"] = "the page did not render (Chrome returned no DOM)"
|
|
180
|
+
reasons = evaluate(sc, observation)
|
|
181
|
+
status = "SKIP" if observation.get("skip") else ("OK" if not reasons else "FAIL")
|
|
182
|
+
if status == "SKIP" and args.strict:
|
|
183
|
+
status, reasons = "FAIL", [f"prerequisite missing: {observation['skip']}"]
|
|
184
|
+
results.append({"id": sc["id"], "title": sc["title"], "page": sc["page"], "status": status,
|
|
185
|
+
"reasons": reasons, "screenshot": os.path.relpath(shot, args.root),
|
|
186
|
+
"screenshot_bytes": size})
|
|
187
|
+
print(f"{sc['id']} [{status}] {sc['title']}")
|
|
188
|
+
if observation.get("skip"):
|
|
189
|
+
print(f" - SKIP: {observation['skip']}")
|
|
190
|
+
for reason in reasons:
|
|
191
|
+
print(f" - {reason}")
|
|
192
|
+
if args.verbose and size:
|
|
193
|
+
print(f" - screenshot {os.path.relpath(shot, args.root)} ({size} bytes)")
|
|
194
|
+
if status == "FAIL":
|
|
195
|
+
failures.append(sc["id"])
|
|
196
|
+
if status == "SKIP":
|
|
197
|
+
skips.append(sc["id"])
|
|
198
|
+
finally:
|
|
199
|
+
server.shutdown()
|
|
200
|
+
|
|
201
|
+
ok = not failures
|
|
202
|
+
print()
|
|
203
|
+
print(f"visual scenarios: {len(results) - len(failures) - len(skips)} ok, {len(failures)} fail, "
|
|
204
|
+
f"{len(skips)} skip")
|
|
205
|
+
report = {"marker": PASS_MARKER if ok else STOP_MARKER, "page": config["page"],
|
|
206
|
+
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
207
|
+
"scenarios": results, "failures": failures, "skips": skips}
|
|
208
|
+
report_path = os.path.join(args.root, config.get("report", "artifacts/visual_check_report.json"))
|
|
209
|
+
os.makedirs(os.path.dirname(report_path), exist_ok=True)
|
|
210
|
+
with open(report_path, "w", encoding="utf-8") as fh:
|
|
211
|
+
json.dump(report, fh, ensure_ascii=False, indent=2)
|
|
212
|
+
fh.write("\n")
|
|
213
|
+
print(f"report: {os.path.relpath(report_path, args.root)}")
|
|
214
|
+
if args.json:
|
|
215
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
216
|
+
if ok:
|
|
217
|
+
print(PASS_MARKER)
|
|
218
|
+
return 0
|
|
219
|
+
print(STOP_MARKER)
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
sys.exit(main())
|
loopgate/gates/doctor.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report what this machine can and cannot run, before a failure is mistaken for a code defect.
|
|
3
|
+
|
|
4
|
+
Why: the gates silently degrade when an input is missing — a product scenario SKIPs without jsonschema, a
|
|
5
|
+
visual check fails without Chrome, the queue needs git and (optionally) `gh`. Every one of those is a
|
|
6
|
+
one-line answer to "why is this yellow?", and without this script the answer is an afternoon of reading.
|
|
7
|
+
It also pins the interpreter question: `#!/usr/bin/env python3` resolves to whatever the PATH holds, so
|
|
8
|
+
two interpreters on one machine can give two verdicts for the same tree.
|
|
9
|
+
|
|
10
|
+
python3 -m loopgate.gates.doctor
|
|
11
|
+
python3 -m loopgate.gates.doctor --json
|
|
12
|
+
|
|
13
|
+
Exit 0 always: this is a report, not a gate. The gates decide.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from loopgate.paths import project_root, gate_file
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
ROOT = project_root()
|
|
28
|
+
MARKER = "DOCTOR_REPORT_0001"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def probe_jsonschema() -> tuple[str, str]:
|
|
32
|
+
"""('ok' | 'missing' | 'damaged', detail) — the distinction the product gate makes."""
|
|
33
|
+
try:
|
|
34
|
+
import jsonschema # noqa: F401
|
|
35
|
+
import referencing # noqa: F401
|
|
36
|
+
import referencing.jsonschema # noqa: F401
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
detail = str(exc)
|
|
39
|
+
if "rpds" in detail or "referencing" in detail:
|
|
40
|
+
return "damaged", detail
|
|
41
|
+
return "missing", detail
|
|
42
|
+
return "ok", ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def which(binary: str) -> str | None:
|
|
46
|
+
return shutil.which(binary)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git_status() -> str:
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run(["git", "-C", ROOT, "status", "--porcelain"], capture_output=True, text=True)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
return f"unavailable ({exc})"
|
|
54
|
+
if proc.returncode != 0:
|
|
55
|
+
return f"error: {proc.stderr.strip()}"
|
|
56
|
+
changed = len([line for line in proc.stdout.splitlines() if line.strip()])
|
|
57
|
+
return "clean" if changed == 0 else f"{changed} changed path(s)"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def gh_auth() -> str:
|
|
61
|
+
if not which("gh"):
|
|
62
|
+
return "gh not installed (issue bridge unavailable; --issues-file and --target backlog still work)"
|
|
63
|
+
try:
|
|
64
|
+
proc = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
return f"unavailable ({exc})"
|
|
67
|
+
return "authenticated" if proc.returncode == 0 else "not authenticated (run `gh auth login`)"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def chrome() -> str:
|
|
71
|
+
candidates = [
|
|
72
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
73
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
74
|
+
]
|
|
75
|
+
for path in candidates:
|
|
76
|
+
if os.path.exists(path):
|
|
77
|
+
return path
|
|
78
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
|
79
|
+
found = which(name)
|
|
80
|
+
if found:
|
|
81
|
+
return found
|
|
82
|
+
return "not found (visual checks will fail closed)"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv=None) -> int:
|
|
86
|
+
ap = argparse.ArgumentParser(description="Report interpreter and optional inputs.")
|
|
87
|
+
ap.add_argument("--json", action="store_true")
|
|
88
|
+
args = ap.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
jsonschema_state, jsonschema_detail = probe_jsonschema()
|
|
91
|
+
report = {
|
|
92
|
+
"marker": MARKER,
|
|
93
|
+
"python": {"version": sys.version.split()[0], "executable": sys.executable,
|
|
94
|
+
"meets_floor_3_9": sys.version_info >= (3, 9)},
|
|
95
|
+
"optional": {
|
|
96
|
+
"jsonschema": {"state": jsonschema_state, "detail": jsonschema_detail,
|
|
97
|
+
"repair": ("python3 -m pip install --force-reinstall rpds-py referencing"
|
|
98
|
+
if jsonschema_state == "damaged"
|
|
99
|
+
else "python3 -m pip install jsonschema")},
|
|
100
|
+
"chrome": chrome(),
|
|
101
|
+
"gh": gh_auth(),
|
|
102
|
+
},
|
|
103
|
+
"repo": {"root": ROOT, "git": which("git") or "missing", "working_tree": git_status()},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if args.json:
|
|
107
|
+
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
108
|
+
else:
|
|
109
|
+
print("doctor — what this machine can run")
|
|
110
|
+
print()
|
|
111
|
+
print(f" python {report['python']['version']} ({report['python']['executable']})")
|
|
112
|
+
if not report["python"]["meets_floor_3_9"]:
|
|
113
|
+
print(" WARNING: below the supported floor (3.9)")
|
|
114
|
+
state = jsonschema_state
|
|
115
|
+
detail = f" — {jsonschema_detail}" if jsonschema_detail else ""
|
|
116
|
+
print(f" jsonschema {state}{detail}")
|
|
117
|
+
if state != "ok":
|
|
118
|
+
print(f" repair: {report['optional']['jsonschema']['repair']}")
|
|
119
|
+
print(" effect: product scenario P08 (published contracts) reports SKIP")
|
|
120
|
+
print(f" chrome {report['optional']['chrome']}")
|
|
121
|
+
print(f" gh {report['optional']['gh']}")
|
|
122
|
+
print(f" git {report['repo']['git']}; working tree {report['repo']['working_tree']}")
|
|
123
|
+
print()
|
|
124
|
+
print("Interpreters disagree: `python3` on PATH is what `make gate` uses, so if this report is "
|
|
125
|
+
"not the verdict you expected, run with `make gate PYTHON=<path>`.")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task timeboxes from measured history (the answer to "how long will this take, and who decides?").
|
|
3
|
+
|
|
4
|
+
Why this file exists
|
|
5
|
+
--------------------
|
|
6
|
+
Nobody should guess a task's duration, and the supervisor is the worst possible estimator: it is the party
|
|
7
|
+
whose optimism is being tested. The queue already produces the data — every kanban run records when it
|
|
8
|
+
started and when it finished — so the estimate is *measured*, not promised:
|
|
9
|
+
|
|
10
|
+
p50 = what a task of this kind usually costs
|
|
11
|
+
p90 = what it costs when it goes badly
|
|
12
|
+
cap = 2 x p90, rounded up, passed to the dispatcher as ``--max-runtime``
|
|
13
|
+
|
|
14
|
+
The cap is not a prediction: it is the point where the dispatcher SIGTERMs the worker and re-queues the
|
|
15
|
+
task, which turns "it ran forever" into a visible failure. Per-assignee, because a reviewer's p90 and an
|
|
16
|
+
implementer's p90 have nothing to do with each other.
|
|
17
|
+
|
|
18
|
+
Who estimates: the queue, from its own history (this script), and the operator approves the proposal by
|
|
19
|
+
committing ``configs/task_estimates.json``. The bridge then applies the cap to every queued task.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
python3 -m loopgate.gates.estimate_task # show the measured distribution and the proposal
|
|
24
|
+
python3 -m loopgate.gates.estimate_task --write # write configs/task_estimates.json (review, then commit)
|
|
25
|
+
python3 -m loopgate.gates.estimate_task --json
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from loopgate.paths import project_root, gate_file
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import datetime as dt
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
ROOT = project_root()
|
|
40
|
+
ESTIMATES_REL = "configs/task_estimates.json"
|
|
41
|
+
MIN_CAP_MINUTES = 15
|
|
42
|
+
ROUNDING_MINUTES = 5
|
|
43
|
+
# Below this many finished runs a percentile is noise, so the proposal stays at the floor and says so.
|
|
44
|
+
MIN_SAMPLES = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def kanban_tasks() -> list[dict]:
|
|
48
|
+
proc = subprocess.run(["hermes", "kanban", "list", "--json", "--archived"],
|
|
49
|
+
capture_output=True, text=True)
|
|
50
|
+
if proc.returncode != 0:
|
|
51
|
+
raise RuntimeError(f"hermes kanban list failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
52
|
+
return json.loads(proc.stdout or "[]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def durations_minutes(tasks: list[dict]) -> dict[str, list[float]]:
|
|
56
|
+
out: dict[str, list[float]] = {}
|
|
57
|
+
for task in tasks:
|
|
58
|
+
started, completed = task.get("started_at"), task.get("completed_at")
|
|
59
|
+
assignee = task.get("assignee") or "(unassigned)"
|
|
60
|
+
if not started or not completed or completed < started:
|
|
61
|
+
continue
|
|
62
|
+
out.setdefault(assignee, []).append((completed - started) / 60.0)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def percentile(values: list[float], fraction: float) -> float:
|
|
67
|
+
if not values:
|
|
68
|
+
return 0.0
|
|
69
|
+
ordered = sorted(values)
|
|
70
|
+
index = min(len(ordered) - 1, max(0, int(round(fraction * (len(ordered) - 1)))))
|
|
71
|
+
return ordered[index]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def round_up(value: float, step: int = ROUNDING_MINUTES) -> int:
|
|
75
|
+
return int(step * -(-value // step))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def propose(measured: dict[str, list[float]]) -> dict:
|
|
79
|
+
proposal = {"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
80
|
+
"rule": f"cap = max({MIN_CAP_MINUTES} min, round_up(2 x p90, {ROUNDING_MINUTES} min)); "
|
|
81
|
+
f"needs >= {MIN_SAMPLES} finished runs before the percentiles mean anything",
|
|
82
|
+
"assignees": {}}
|
|
83
|
+
for assignee, values in sorted(measured.items()):
|
|
84
|
+
p50, p90 = percentile(values, 0.5), percentile(values, 0.9)
|
|
85
|
+
if len(values) < MIN_SAMPLES:
|
|
86
|
+
cap = MIN_CAP_MINUTES
|
|
87
|
+
basis = f"floor: only {len(values)} finished run(s)"
|
|
88
|
+
else:
|
|
89
|
+
cap = max(MIN_CAP_MINUTES, round_up(2 * p90))
|
|
90
|
+
basis = f"2 x p90({p90:.1f} min) over {len(values)} run(s)"
|
|
91
|
+
proposal["assignees"][assignee] = {"samples": len(values), "p50_minutes": round(p50, 1),
|
|
92
|
+
"p90_minutes": round(p90, 1), "max_runtime": f"{cap}m",
|
|
93
|
+
"basis": basis}
|
|
94
|
+
return proposal
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv=None) -> int:
|
|
98
|
+
ap = argparse.ArgumentParser(description="Derive per-assignee task timeboxes from kanban history.")
|
|
99
|
+
ap.add_argument("--root", default=ROOT)
|
|
100
|
+
ap.add_argument("--write", action="store_true", help=f"write {ESTIMATES_REL}")
|
|
101
|
+
ap.add_argument("--json", action="store_true")
|
|
102
|
+
args = ap.parse_args(argv)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
measured = durations_minutes(kanban_tasks())
|
|
106
|
+
except (OSError, RuntimeError, json.JSONDecodeError) as exc:
|
|
107
|
+
print(f"cannot read the queue history: {exc}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
if not measured:
|
|
110
|
+
print("no finished runs yet: nothing to measure, every cap stays at the floor")
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
proposal = propose(measured)
|
|
114
|
+
if args.json:
|
|
115
|
+
print(json.dumps(proposal, ensure_ascii=False, indent=2))
|
|
116
|
+
else:
|
|
117
|
+
print("measured task durations (finished kanban runs)")
|
|
118
|
+
print(f"{'assignee':<14}{'runs':>6}{'p50':>9}{'p90':>9}{'cap':>8} basis")
|
|
119
|
+
for assignee, row in proposal["assignees"].items():
|
|
120
|
+
print(f"{assignee:<14}{row['samples']:>6}{row['p50_minutes']:>9.1f}{row['p90_minutes']:>9.1f}"
|
|
121
|
+
f"{row['max_runtime']:>8} {row['basis']}")
|
|
122
|
+
print()
|
|
123
|
+
print("a cap is not a promise: at the cap the dispatcher SIGTERMs the worker and re-queues the task,")
|
|
124
|
+
print("so 'it ran forever' becomes a visible failure instead of a silent one.")
|
|
125
|
+
|
|
126
|
+
if args.write:
|
|
127
|
+
path = os.path.join(args.root, ESTIMATES_REL)
|
|
128
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
129
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
130
|
+
json.dump(proposal, fh, ensure_ascii=False, indent=2)
|
|
131
|
+
fh.write("\n")
|
|
132
|
+
print(f"wrote {ESTIMATES_REL} — review it and commit; the bridge applies it to queued tasks")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|