diff --git a/board/AGENTS.md b/board/AGENTS.md index dc8f08b..eacdd48 100644 --- a/board/AGENTS.md +++ b/board/AGENTS.md @@ -18,6 +18,7 @@ copy-for-Claude payload: | โŒจ Morning sync | `bin/morning.sh` (local) | the terminal command itself | | ๐ŸŒ™ Overnight | scheduled workflows (`config/policy.yaml board: overnight_jobs`); a โธ blocked gate's ::warning annotation is rendered inline | `/bug โ€ฆ โ€” ` on failures | | โค๏ธ Readiness & release | the Heart board's `badge.json` + `board.json` (structured blockers, each carrying its OWN `/bug` prompt โ€” rendered verbatim, never re-derived) and the Hands badge | `/health`, the blockers' own prompts | +| โฑ Test performance | the Heart board's published `performance` block โ€” rendered verbatim, never re-derived | each row's own prompt | | ๐Ÿท๏ธ Version consistency | the coupled-set stamps (`board: version_stamps`) | `/bug version drift: โ€ฆ` | | ๐Ÿ’ฌ Community | the Ears (`community scan`, reused wholesale) โ€” every open conversation gets a row | `/community`, `/community triage ` | | ๐Ÿ”„ Resume | the Mind's registry + generated counts; pending-release PRs | `/start_dev โ€ฆ`, `/prm ` | diff --git a/board/_board.py b/board/_board.py index e0dbd53..4cdf578 100755 --- a/board/_board.py +++ b/board/_board.py @@ -226,19 +226,25 @@ def fetch_badge(pages_base, repo): HEART_BLOCKER_CAP = 5 +PERF_FLAGGED_CAP = 5 -def fetch_heart_blockers(pages_base, repo, degraded): - """The Heart board's published machine surface (board.json, schema v2): - structured blockers, each already carrying its own /bug prompt โ€” rendered - here verbatim, never re-derived. [] when the surface is unreachable or - carries no blockers (a GREEN morning).""" +def fetch_heart_board(pages_base, repo, degraded): + """The Heart board's published machine surface (board.json, schema v2) โ€” + ONE read, two consumers (the blockers and the performance block below). + None when unreachable; the degraded row is recorded here, once.""" board = _fetch_json(f"{pages_base}/{repo}/board.json") if board is None: degraded.append("readiness: Heart board.json unreachable " "(blockers shown on the Heart board only)") - return [] - blockers = board.get("blockers") or [] + return board + + +def extract_heart_blockers(board): + """The structured blockers, each already carrying its own /bug prompt โ€” + rendered here verbatim, never re-derived. [] when the surface is + unreachable or carries no blockers (a GREEN morning).""" + blockers = (board or {}).get("blockers") or [] return [{ "text": str(b.get("text", ""))[:160], "severity": b.get("severity"), @@ -249,6 +255,115 @@ def fetch_heart_blockers(pages_base, repo, degraded): } for b in blockers[:HEART_BLOCKER_CAP]] +def fetch_heart_blockers(pages_base, repo, degraded): + """Fetch-and-extract in one call (the blockers-only door).""" + return extract_heart_blockers(fetch_heart_board(pages_base, repo, degraded)) + + +def _as_list(value): + return value if isinstance(value, list) else [] + + +def _as_dict(value): + return value if isinstance(value, dict) else {} + + +def _num(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def secs_label(seconds): + """Seconds -> a compact duration (`9m12s`, `45s`); '' when unreadable.""" + s = _num(seconds) + if s is None: + return "" + s = int(round(s)) + return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s" + + +def _perf_where(row): + """The row's identity โ€” `RepoA/Smoke Tests`, `RepoA/scripts/x.py` โ€” from + whichever of the producer's keys are present.""" + repo = str(row.get("repo") or "").strip() + what = str(row.get("workflow") or row.get("entry") or "").strip() + return "/".join(b for b in (repo, what) if b) or "?" + + +def extract_heart_performance(board, board_url=""): + """The Heart board's additive `performance` block, compacted to the + morning headline: the few worst flagged rows โ€” hang/kill events first, + then slowed gates, then SLOW no_run markers that were never measured โ€” + each carrying its OWN prompt, rendered verbatim like the blockers, plus + the counts behind them. + + None when the block is absent: an older Heart publish simply renders no + section (an unreachable board.json is already a degraded row). Measurement + lives in the Heart; this end only reads it, so every access is a .get with + a default โ€” a producer-side rename costs a field, never the render.""" + perf = _as_dict(board).get("performance") + if not isinstance(perf, dict): + return None + gates = [g for g in _as_list(perf.get("gates")) if isinstance(g, dict)] + events = [e for e in _as_list(perf.get("events")) if isinstance(e, dict)] + no_run = _as_dict(perf.get("no_run")) + rows = [r for r in _as_list(no_run.get("rows")) if isinstance(r, dict)] + + warn = [g for g in gates if str(g.get("state", "")).lower() == "warn"] + warn.sort(key=lambda g: _num(g.get("median_s")) or 0.0, reverse=True) + # A SLOW marker is not evidence of slowness: the ones with no measurement + # behind them are the worst rows here, oldest first. + unmeasured = [r for r in rows if not r.get("measured") + and str(r.get("marker", "")).upper() == "SLOW"] + unmeasured.sort(key=lambda r: str(r.get("date") or "")) + + flagged = [] + for e in events: + kind = str(e.get("kind") or "event").replace("_", " ") + took = secs_label(e.get("duration_s")) + flagged.append({ + "text": (f"{kind}: {_perf_where(e)}" + + (f" after {took}" if took else ""))[:160], + "url": e.get("run_url"), + "prompt": e.get("prompt"), + }) + for g in warn: + bits = [] + for label, key in (("median", "median_s"), ("PR", "pr_median_s"), + ("max", "max_s")): + value = secs_label(g.get(key)) + if value: + bits.append(f"{label} {value}") + if g.get("runs_counted"): + bits.append(f"{g['runs_counted']} runs") + if g.get("spark"): + bits.append(str(g["spark"])) + flagged.append({ + "text": (f"{_perf_where(g)} slowed" + + (f" โ€” {' ยท '.join(bits)}" if bits else ""))[:160], + "url": g.get("actions_url"), + "prompt": g.get("prompt"), + }) + for r in unmeasured: + since = f" since {r['date']}" if r.get("date") else "" + flagged.append({ + "text": (f"{_perf_where(r)} {str(r.get('marker') or 'SLOW')}" + f"{since}, never measured")[:160], + "url": r.get("url"), + "prompt": r.get("prompt"), + }) + return { + "flagged": flagged[:PERF_FLAGGED_CAP], + "gates_total": len(gates), + "gates_warn": len(warn), + "events": len(events), + "no_run_totals": _as_dict(no_run.get("totals")), + "board_url": board_url, + } + + def collect_versions(stamps, org, reference_repo, degraded): """Version-stamp CONSISTENCY across the coupled set (the version_drift.sh invariant: same stamp as the siblings; the release tag is context only).""" @@ -529,7 +644,12 @@ def collect(): heart = fetch_badge(pages_base, heart_repo) if heart is None: degraded.append("readiness: Heart board badge unreachable") - heart_blockers = fetch_heart_blockers(pages_base, heart_repo, degraded) + # One read of the Heart's machine surface, two consumers: the blockers + # and the test-performance block. + heart_board = fetch_heart_board(pages_base, heart_repo, degraded) + heart_blockers = extract_heart_blockers(heart_board) + performance = extract_heart_performance( + heart_board, f"{pages_base}/{heart_repo}/") hands = fetch_badge(pages_base, board_family.get("hands", "PyAutoHands")) versions = collect_versions( board_cfg.get("version_stamps", []), org, @@ -546,6 +666,7 @@ def collect(): "overnight": overnight, "heart": heart, "heart_blockers": heart_blockers, + "performance": performance, "hands": hands, "versions": versions, "community": community, @@ -584,6 +705,12 @@ def verdict(data): blocking.append(f"Heart verdict {heart_msg}") elif heart_msg and not heart_msg.startswith("GREEN"): attention.append(f"Heart verdict {heart_msg}") + # Timing rows are advisory, never gating โ€” but a run that hung or was + # killed is a morning fact, so it joins the attention tier. + events = (data.get("performance") or {}).get("events") or 0 + if events: + attention.append(f"{events} CI hang event(s) flagged on the " + "test-performance surface") if data["versions"]["drift"]: attention.append(f"{data['versions']['drift']} version stamp(s) off consensus") waiting = ((data["community"] or {}).get("counts") or {}).get("awaiting_response", 0) @@ -679,6 +806,21 @@ def render_md(data): L.append(f"- Shipped: **{data['hands'].get('message', '?')}** โ€” " f"[Hands board]({data['boards'].get('hands', '')})") L.append("") + perf = data.get("performance") + if perf is not None: + L.append("## โฑ Test performance") + if perf.get("flagged"): + for f in perf["flagged"]: + line = f"- {f['text']}" + if f.get("url"): + line += f" โ€” [run]({f['url']})" + L.append(line) + if f.get("prompt"): + L.append(f" - `{f['prompt']}`") + else: + L.append(f"- โœ“ {perf.get('gates_total', 0)} gates timed ยท nothing " + f"flagged โ€” [full timings]({perf.get('board_url', '')})") + L.append("") v = data["versions"] L.append("## ๐Ÿท๏ธ Version consistency") if v["consensus"]: @@ -909,6 +1051,27 @@ def render_html(data): f'Shipped: {esc(data["hands"].get("message", "?"))} โ€” ' f'Hands board โ†—')) + # The Heart's test-performance block, rendered as it arrived โ€” the rows + # carry their own prompts, this end never re-derives one. + perf = data.get("performance") + if perf is not None: + H.append("

โฑ Test performance

") + if perf.get("flagged"): + for f in perf["flagged"]: + link = (f' run โ†—' + if f.get("url") else "") + text = f'{esc(f["text"])}{link}' + if f.get("prompt"): + H.append(_row(text, f["prompt"])) + else: + H.append(_plain(text)) + else: + timings_url = perf.get("board_url") or heart_url + H.append(_plain( + f'โœ“ {perf.get("gates_total", 0)} gates ' + f'timed ยท nothing flagged โ€” ' + f'full timings โ†—')) + v = data["versions"] H.append("

๐Ÿท๏ธ Version consistency

") if v["consensus"] and v["drift"] == 0: diff --git a/tests/test_board.py b/tests/test_board.py index ec22783..82fc9c2 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -26,9 +26,9 @@ BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" SURFACE_KEYS = { - "generated", "org", "overnight", "heart", "heart_blockers", "hands", - "versions", "community", "resume", "open_issues", "hygiene", "devbox", - "autonomy", "doors", "boards", "degraded", "history", + "generated", "org", "overnight", "heart", "heart_blockers", "performance", + "hands", "versions", "community", "resume", "open_issues", "hygiene", + "devbox", "autonomy", "doors", "boards", "degraded", "history", } AUTONOMY_LOG = """\ @@ -40,6 +40,43 @@ | 2026-08-02 | second-task (#2) | supervised | tests pass | amended | """ +HEART_PERFORMANCE = { + "schema": 1, + "gates": [ + {"repo": "RepoA", "workflow": "Smoke Tests", "median_s": 612.0, + "pr_median_s": 640.0, "max_s": 745.0, "runs_counted": 14, + "state": "warn", "spark": "โ–โ–‚โ–„โ–…", + "actions_url": "https://example.invalid/RepoA/actions", + "prompt": "/bug smoke gate RepoA: median 9m12s over 14 runs, was 7m"}, + {"repo": "RepoB", "workflow": "Unit Tests", "median_s": 61.0, + "max_s": 74.0, "runs_counted": 12, "state": "ok", "prompt": None, + "actions_url": "https://example.invalid/RepoB/actions"}, + ], + "history": [], + # No hang events by default: the shared fixture is the all-green morning + # the verdict tests assert against. HEART_BOARD_WITH_EVENT adds one. + "events": [], + "no_run": { + "totals": {"slow": 21, "needs_fix": 4, "permanent": 46, + "unmeasured_slow": 7}, + "repos": [], + "rows": [{"repo": "RepoA", "entry": "scripts/x.py", "marker": "SLOW", + "date": "2026-07-14", "measured": False, + "prompt": "/bug no_run: RepoA scripts/x.py SLOW since " + "2026-07-14 with no measurement โ€” retime it"}], + }, +} + +HEART_PERFORMANCE_EVENT = { + "kind": "timed_out", + "repo": "RepoA", + "workflow": "Smoke Tests", + "run_url": "https://example.invalid/run/12", + "duration_s": 300, + "prompt": "/bug kill timer: RepoA Smoke Tests TIMEOUT (300s) on " + "https://example.invalid/run/12", +} + HEART_BOARD_JSON = { "schema_version": 2, "blockers": [{ @@ -50,8 +87,18 @@ "run_url": "https://example.invalid/run/9", "prompt": "/bug Heart board: RepoA nightly smoke red โ€” https://example.invalid/run/9", }], + # Additive to schema v2 โ€” an older Heart publish simply omits it. + "performance": HEART_PERFORMANCE, +} + +HEART_BOARD_WITH_EVENT = { + **HEART_BOARD_JSON, + "performance": {**HEART_PERFORMANCE, "events": [HEART_PERFORMANCE_EVENT]}, } +HEART_BOARD_NO_PERFORMANCE = { + k: v for k, v in HEART_BOARD_JSON.items() if k != "performance"} + BRAIN_PREV_BOARD_JSON = { "history": [{"date": "2026-08-20", "need_you": 3}], } @@ -118,9 +165,10 @@ def _default_fixtures(**overrides): return fx -def _fabricate(tmp_path, fixtures): +def _fabricate(tmp_path, fixtures, heart_board=None): """A PYAUTO_ROOT with a fabricated Mind, file:// sibling-board badges, and - a stub gh serving per-endpoint fixture JSON, logging every invocation.""" + a stub gh serving per-endpoint fixture JSON, logging every invocation. + `heart_board` overrides the Heart's published machine surface.""" mind = tmp_path / "PyAutoMind" (mind / "active").mkdir(parents=True) (mind / "repos.yaml").write_text(REPOS_YAML) @@ -146,7 +194,7 @@ def _fabricate(tmp_path, fixtures): # The Heart's machine surface (structured blockers) and the Brain's own # previous page (the self-carrying trend history). (pages / board_cfg["heart_board"] / "board.json").write_text( - json.dumps(HEART_BOARD_JSON)) + json.dumps(heart_board if heart_board is not None else HEART_BOARD_JSON)) brain_repo = (board_cfg.get("boards") or {}).get("brain", "PyAutoBrain") (pages / brain_repo).mkdir(parents=True, exist_ok=True) (pages / brain_repo / "board.json").write_text( @@ -387,6 +435,77 @@ def test_heart_blockers_render_with_their_own_prompts(tmp_path): assert "Shipped: **GREEN**" in md # the Hands headline joined the section +def test_test_performance_rows_carry_their_own_prompts(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(), HEART_BOARD_WITH_EVENT) + s = json.loads(_run(["--json"], tmp_path, stub).stdout) + perf = s["performance"] + assert (perf["gates_total"], perf["gates_warn"], perf["events"]) == (2, 1, 1) + assert perf["no_run_totals"]["unmeasured_slow"] == 7 + assert perf["board_url"].endswith("/PyAutoHeart/") + # Worst first: the hang event, then the slowed gate, then the SLOW marker + # nobody ever measured. A healthy gate is not a row. + assert [f["prompt"] for f in perf["flagged"]] == [ + HEART_PERFORMANCE_EVENT["prompt"], + HEART_PERFORMANCE["gates"][0]["prompt"], + HEART_PERFORMANCE["no_run"]["rows"][0]["prompt"], + ] + page = _run(["--html"], tmp_path, stub).stdout + assert "โฑ Test performance" in page + # Every row's chip is the Heart's own prompt, verbatim โ€” never re-derived. + for f in perf["flagged"]: + assert f'data-cmd="{f["prompt"]}"' in page + assert 'href="https://example.invalid/run/12"' in page + assert "Unit Tests" not in page # the ok gate carries nothing to act on + md = _run([], tmp_path, stub).stdout + assert "## โฑ Test performance" in md + for f in perf["flagged"]: + assert f"`{f['prompt']}`" in md + + +def test_a_hang_event_is_attention_not_blocking(tmp_path): + stub = _fabricate(tmp_path, _default_fixtures(), HEART_BOARD_WITH_EVENT) + badge = json.loads(_run(["--badge"], tmp_path, stub).stdout) + # Timing rows stay advisory; a run that hung is a morning fact. + assert badge["color"] == "orange" + assert badge["message"] == "1 need you" + assert "๐Ÿšจ Blocking" not in _run([], tmp_path, stub).stdout + + +def test_nothing_flagged_renders_one_quiet_row(tmp_path): + quiet = {**HEART_PERFORMANCE, + "gates": [{**g, "state": "ok"} for g in HEART_PERFORMANCE["gates"]], + "no_run": {**HEART_PERFORMANCE["no_run"], "rows": []}} + stub = _fabricate(tmp_path, _default_fixtures(), + {**HEART_BOARD_JSON, "performance": quiet}) + page = _run(["--html"], tmp_path, stub).stdout + assert "2 gates timed ยท nothing flagged" in page + assert "full timings โ†—" in page + assert "2 gates timed ยท nothing flagged" in _run([], tmp_path, stub).stdout + + +def test_heart_board_without_performance_renders_no_section(tmp_path): + """An older Heart publish: no section, and NOT a degraded row.""" + stub = _fabricate(tmp_path, _default_fixtures(), HEART_BOARD_NO_PERFORMANCE) + s = json.loads(_run(["--json"], tmp_path, stub).stdout) + assert s["performance"] is None + assert s["heart_blockers"] == HEART_BOARD_JSON["blockers"] # untouched + assert not any("performance" in d or "board.json unreachable" in d + for d in s["degraded"]) + assert "Test performance" not in _run(["--html"], tmp_path, stub).stdout + assert "Test performance" not in _run([], tmp_path, stub).stdout + + +def test_a_malformed_performance_block_never_breaks_the_render(tmp_path): + """The producer is a sibling organ โ€” field drift costs a row, not a page.""" + stub = _fabricate(tmp_path, _default_fixtures(), { + **HEART_BOARD_JSON, + "performance": {"gates": "not-a-list", "events": None, + "no_run": {"rows": [{"repo": "RepoA"}, "junk"]}}}) + r = _run(["--html"], tmp_path, stub) + assert r.returncode == 0, r.stderr + assert "0 gates timed ยท nothing flagged" in r.stdout + + def test_blocked_gate_annotation_renders_inline(tmp_path): stub = _fabricate(tmp_path, _default_fixtures(**{ "jobs.json": {"jobs": [{"id": 77, "steps": [