Buckets:
| """Measure-loop circuit breaker state. | |
| ``measure`` is the driver of the Step 4 edit->measure loop. A runaway | |
| loop (an agent oscillating on the same column for dozens of rounds) | |
| burns a Chromium render per iteration and, worse, keeps an autonomous | |
| session grinding long after a human should have been called. The | |
| breaker counts **consecutive failed measurements** in a small state | |
| file next to the poster HTML; a PASS (or ``--reset-budget``) clears it. | |
| When the count reaches the cap, ``measure`` refuses to keep iterating | |
| and exits ``EXIT_BUDGET_EXHAUSTED`` (3) with a loud banner. | |
| Design notes (mechanism inspired by ResearchStudio's paper2poster | |
| ``.fill_budget.json``; implementation is posterly's own -- see | |
| NOTICE.md): | |
| - The counter lives ON DISK so it survives a compacted / restarted | |
| agent context -- the exact failure mode that lets an in-prompt round | |
| count silently reset. | |
| - Only *completed* measurements count: a run that never produced | |
| geometry (Playwright missing, nav timeout, MathJax settle failure) | |
| is an environment problem, not a loop iteration. | |
| - Consecutive-failure semantics: a poster that reaches PASS and is | |
| later re-edited starts from a clean budget instead of inheriting | |
| stale debt. | |
| - A stale state file (older than ``STALE_AFTER_HOURS``) is treated as a | |
| new working session and reset -- posterly is used interactively | |
| across days on the same file, unlike the batch pipeline this borrows | |
| from. | |
| - State I/O must never break the geometry gate: every failure path | |
| degrades to "budget disabled + warning", never to a crash or a | |
| spurious gate verdict. | |
| """ | |
| from __future__ import annotations | |
| import datetime as _dt | |
| import json | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| #: Default cap on CONSECUTIVE failed measurements before the breaker | |
| #: trips. A normal posterly session passes through PASS states often | |
| #: (which reset the count); 30 straight failures on one poster is a | |
| #: pathological loop, not diligence. | |
| DEFAULT_MEASURE_BUDGET = 30 | |
| #: A state file untouched for this many hours is a previous working | |
| #: session; its count no longer describes the current loop. | |
| STALE_AFTER_HOURS = 12.0 | |
| #: Exit code for "budget exhausted" -- distinct from 1 (gate FAIL) and | |
| #: 2 (usage/environment error) so callers can tell "stop iterating" | |
| #: from "fix the poster". | |
| EXIT_BUDGET_EXHAUSTED = 3 | |
| _SCHEMA_VERSION = 1 | |
| def budget_path(html_path: Path) -> Path: | |
| """State-file path for one poster: | |
| ``.<name>.posterly_budget.json``. | |
| Keyed by the HTML file's FULL name (not stem) so two posters in the | |
| same directory never share a budget -- including the | |
| ``poster.html`` / ``poster.htm`` pair a stem key would collide. | |
| """ | |
| return html_path.parent / f".{html_path.name}.posterly_budget.json" | |
| def _now() -> _dt.datetime: | |
| return _dt.datetime.now(_dt.timezone.utc) | |
| def load_count( | |
| path: Path, | |
| poster_name: str, | |
| now: _dt.datetime | None = None, | |
| ) -> tuple[int, str | None]: | |
| """Read the consecutive-failure count. Returns ``(count, warning)``. | |
| Any unreadable / implausible state degrades to ``(0, warning)``: | |
| missing file (no warning -- the normal fresh state), corrupt JSON, | |
| wrong schema, a timestamp in the future (clock skew / copied file), | |
| or a stale timestamp (previous session; silently resets). | |
| """ | |
| now = now or _now() | |
| try: | |
| raw = path.read_text(encoding="utf-8") | |
| except FileNotFoundError: | |
| return 0, None | |
| except OSError as exc: | |
| return 0, f"could not read {path.name}: {exc}" | |
| try: | |
| data = json.loads(raw) | |
| if not isinstance(data, dict): | |
| raise ValueError("not a JSON object") | |
| count = int(data["count"]) | |
| updated = _dt.datetime.fromisoformat(str(data["updated"])) | |
| if data.get("schema_version") != _SCHEMA_VERSION: | |
| raise ValueError("unknown schema_version") | |
| if count < 0: | |
| raise ValueError("negative count") | |
| except (KeyError, ValueError, TypeError) as exc: | |
| return 0, f"corrupt budget state {path.name} ({exc}); reset to 0" | |
| stored_poster = data.get("poster") | |
| if stored_poster is not None and stored_poster != poster_name: | |
| return 0, ( | |
| f"budget state {path.name} belongs to another poster " | |
| f"({stored_poster!r}); reset to 0" | |
| ) | |
| if updated.tzinfo is None: | |
| updated = updated.replace(tzinfo=_dt.timezone.utc) | |
| if updated > now + _dt.timedelta(minutes=5): | |
| return 0, ( | |
| f"budget state {path.name} has a future timestamp; reset to 0" | |
| ) | |
| if (now - updated) > _dt.timedelta(hours=STALE_AFTER_HOURS): | |
| return 0, None # previous session -- silent, expected reset | |
| return count, None | |
| def record_failure( | |
| path: Path, | |
| poster_name: str, | |
| count: int, | |
| now: _dt.datetime | None = None, | |
| ) -> str | None: | |
| """Atomically persist ``count``. Returns a warning string on I/O | |
| failure (the caller prints it; the gate verdict is never affected). | |
| """ | |
| now = now or _now() | |
| payload = json.dumps({ | |
| "schema_version": _SCHEMA_VERSION, | |
| "poster": poster_name, | |
| "count": count, | |
| "updated": now.replace(microsecond=0).isoformat(), | |
| }) | |
| try: | |
| fd, tmp = tempfile.mkstemp( | |
| prefix=path.name + ".", dir=str(path.parent) | |
| ) | |
| try: | |
| with os.fdopen(fd, "w", encoding="utf-8") as fh: | |
| fh.write(payload) | |
| os.replace(tmp, path) | |
| except BaseException: | |
| try: | |
| os.unlink(tmp) | |
| except OSError: | |
| pass | |
| raise | |
| except OSError as exc: | |
| return ( | |
| f"could not write budget state {path.name} ({exc}); " | |
| "circuit breaker will not persist across runs" | |
| ) | |
| return None | |
| def clear( | |
| path: Path, | |
| poster_name: str | None = None, | |
| now: _dt.datetime | None = None, | |
| ) -> str | None: | |
| """Remove the state file (PASS or ``--reset-budget``). | |
| When the file exists but cannot be removed, falls back to | |
| atomically overwriting it with ``count = 0`` (needs | |
| ``poster_name``) -- a stale count surviving a PASS would break the | |
| CONSECUTIVE-failures contract on the next run. Returns a warning | |
| string only when the state could be neither removed nor zeroed -- | |
| callers must surface it and must NOT let the stale count produce a | |
| phantom breaker (e.g. by disabling the budget for the run). A | |
| missing file is the normal case and returns ``None``. | |
| """ | |
| try: | |
| path.unlink() | |
| return None | |
| except FileNotFoundError: | |
| return None | |
| except OSError as exc: | |
| zero_warn = None | |
| if poster_name is not None: | |
| zero_warn = record_failure(path, poster_name, 0, now) | |
| if zero_warn is None: | |
| return None # zeroed in place -- semantically cleared | |
| detail = ( | |
| f"; zero-write fallback also failed ({zero_warn})" | |
| if zero_warn else "" | |
| ) | |
| return ( | |
| f"could not remove budget state {path.name} ({exc})" | |
| f"{detail}; the stale failure count may persist" | |
| ) | |
| return None | |
| def breaker_banner(count: int, cap: int, *, pre_render: bool = False) -> str: | |
| """The loud stop banner. ``pre_render=True`` = refused before even | |
| opening the browser (the cap was already reached last run).""" | |
| when = ( | |
| "Budget was already exhausted -- refusing to render again." | |
| if pre_render else | |
| "This measurement exhausted the budget." | |
| ) | |
| return ( | |
| "=" * 68 + "\n" | |
| f" CIRCUIT BREAKER -- {count}/{cap} consecutive failed " | |
| "measurements.\n" | |
| f" {when}\n" | |
| " Stop iterating. The loop is not converging: re-think the " | |
| "layout\n" | |
| " (re-pack cards across columns -- see `poster_check.py pack`, " | |
| "or\n" | |
| " reselect the template/canvas) or escalate to the human with " | |
| "the\n" | |
| " current best state rendered.\n" | |
| " The counter resets on the first PASS, after " | |
| f"{STALE_AFTER_HOURS:.0f}h idle, or via\n" | |
| " --reset-budget (use it only for a deliberate fresh start, " | |
| "not to\n" | |
| " keep grinding the same edits).\n" | |
| + "=" * 68 | |
| ) | |
Xet Storage Details
- Size:
- 8.28 kB
- Xet hash:
- 6ff84d80a66d617105ad7cc1c4ece72ce150951d35315eefbc6b6e2ed8f67bce
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.