From 55fceef1748f8dd20409c5e822ef496532ef374e Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 22 Jul 2026 20:53:49 -0700 Subject: [PATCH 1/4] feat(adapters): post-mortem transport-failure classification (part 1/3 of #194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coding CLI that loses its API connection idles out the session clock and is stamped `timeout` (or `stalled`/`crashed`), indistinguishable from a real wall-clock timeout — charging a dev attempt for a session that never reached the API. This adds the plumbing to classify those post-mortem, without yet consuming the signal. - SessionResult gains `env_fault` / `env_fault_evidence` (appended LAST so positional constructions stay valid) plus a base-class identity `_classify_env_fault` template hook, chained in `run()` AFTER `_post_kill_reconcile` so a rescue-to-completed is never re-classified. - CLIProfile gains `env_fault_patterns` (Python `re`), compiled+validated at parse time; seeded only in claude.toml (API Error + connection cause on the same line, so prose "API Error" test output does not trip it). - GenericAdapter overrides the hook: for a timeout/stalled/crashed verdict with result_json None, it reads the last 64 KiB of the tee'd pane log, strips ANSI, matches line-by-line (last match wins, evidence truncated to 240 chars), and stamps the result + an `env-fault-classified` breadcrumb. Any OSError -> no classification (best-effort doctrine). Downstream consumption (escalation/engine/sweep pause-not-charge) lands in parts 2-3. refs #194 — do not close. --- CHANGELOG.md | 11 ++ docs/adapter-authoring-guide.md | 56 +++++--- src/bmad_loop/adapters/base.py | 27 +++- src/bmad_loop/adapters/generic.py | 89 +++++++++++- src/bmad_loop/adapters/profile.py | 16 +++ src/bmad_loop/data/profiles/claude.toml | 12 ++ tests/test_generic_tmux.py | 172 ++++++++++++++++++++++++ tests/test_profile.py | 37 +++++ 8 files changed, 396 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3090f9bb..ad1cc161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ breaking changes may land in a minor release. ### Added +- **Post-mortem transport-failure classification — plumbing (part 1/3 of #194).** Adapters can now + classify a non-completed (`timeout`/`stalled`/`crashed`) session as an _environment fault_ by + matching per-profile `env_fault_patterns` regexes against the ANSI-stripped tail of the session's + pane log — the signature of a coding CLI that lost its API connection and idled out the session + clock without doing real work. `SessionResult` gains `env_fault` / `env_fault_evidence`, the + generic tmux adapter reads the log tail once after the verdict and reconcile settle (last match + wins, `over_budget`/`completed` excluded), and the `claude` profile ships a seed pattern that + requires an `API Error` line _and_ a connection-level cause on the same line. This part is + plumbing only — nothing consumes the flag yet; the story pipeline, workflow, and sweep sessions + pause on it (without burning attempts) in later parts. + - **Documented the `BMAD_LOOP_*` environment variables (#246).** The three runtime override vars — `BMAD_LOOP_MUX_BACKEND`, `BMAD_LOOP_PROCESS_HOST`, `BMAD_LOOP_SESSION_TIMEOUT_S` — now have a reference table in the README. Behavior is unchanged; they are read through a single diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 5f6ff04f..9ce6bf84 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -357,23 +357,24 @@ resolves to `claude`. ### `CLIProfile` -| Field | Required | Default | Meaning | -| ---------------------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | ✅ | — | Profile id, also the `--cli` value and override key. | -| `binary` | ✅ | — | Executable to launch (resolved on `PATH`). | -| `[hooks]` | ✅ | — | The `HookSpec` table (see below). | -| `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | -| `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | -| `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). | -| `bypass_args` | | `()` | Flags that bypass permission/approval prompts for unattended runs (e.g. `--allow-all-tools`). | -| `model_flag` | | `--model` | Flag used to pass the model name when one is configured. | -| `env` | | `{}` | Extra environment variables for the session. | -| `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | -| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | -| `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | -| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. | -| `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | -| `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | +| Field | Required | Default | Meaning | +| ---------------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | ✅ | — | Profile id, also the `--cli` value and override key. | +| `binary` | ✅ | — | Executable to launch (resolved on `PATH`). | +| `[hooks]` | ✅ | — | The `HookSpec` table (see below). | +| `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | +| `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | +| `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). | +| `bypass_args` | | `()` | Flags that bypass permission/approval prompts for unattended runs (e.g. `--allow-all-tools`). | +| `model_flag` | | `--model` | Flag used to pass the model name when one is configured. | +| `env` | | `{}` | Extra environment variables for the session. | +| `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | +| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | +| `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | +| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. | +| `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | +| `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | +| `env_fault_patterns` | | `()` | Python `re` patterns matched line-by-line against the ANSI-stripped tail of a **non-completed** (`timeout`/`stalled`/`crashed`) session's pane log to classify a transport/API **environment fault** (#194); a match stamps `env_fault` / `env_fault_evidence` on the `SessionResult`. Compiled and validated at parse time (an invalid regex is a profile error). Seeded only for `claude`; empty = inert. | ### `HookSpec` (the `[hooks]` table) @@ -445,8 +446,11 @@ Three frozen dataclasses cross the seam: window id, HTTP session id, …), `launched_ns` (wall-clock ns just before launch; the floor for hook events). - **`SessionResult`** (returned by `wait_for_completion`) — `status` (one of - `completed`, `stalled`, `timeout`, `crashed`), `result_json`, `session_id`, - `transcript_path`. + `completed`, `stalled`, `timeout`, `crashed`, `over_budget`), `result_json`, + `session_id`, `transcript_path`, and the optional post-mortem forensics + `env_fault` / `env_fault_evidence` (set by `_classify_env_fault` when a + non-completed session is matched as a transport/API **environment fault** — + see the `env_fault_patterns` profile key). ### Methods @@ -460,6 +464,20 @@ The base class provides `run(spec)`, the template that chains `start_session` → `wait_for_completion` → `kill` (the kill runs in a `finally`). You normally don't override it. +`run(spec)` then chains two post-processing template hooks over the returned +`SessionResult` (both identity no-ops on the base class, so an adapter that needs +neither leaves them alone): + +- `_post_kill_reconcile(handle, spec, result)` — runs right after the + `finally`-kill. A session that finished but lost its final `Stop` can be + rescued to `completed` here, once window death has settled the liveness the + live-window verdict had to leave open (see `GenericDevAdapter`). +- `_classify_env_fault(handle, spec, result)` — runs **last**, and only for a + non-`completed` result with `result_json is None`. An adapter may re-inspect + its post-mortem log tail and stamp `env_fault` / `env_fault_evidence` on the + result (see the `env_fault_patterns` profile key). Because it runs after the + reconcile, a rescue-to-`completed` is never re-classified. + Optional capabilities (default to "unsupported" / no-op): - `send_text(handle, text)` — nudge a running session. Raises `NotImplementedError` diff --git a/src/bmad_loop/adapters/base.py b/src/bmad_loop/adapters/base.py index 92c39fd9..76b2d9ba 100644 --- a/src/bmad_loop/adapters/base.py +++ b/src/bmad_loop/adapters/base.py @@ -78,6 +78,14 @@ class SessionResult: # unless the guard tripped. Set on every post-trip exit — warn-mode sessions # that run to completion carry it too — so the engine can journal it. budget_weighted: int | None = None + # transport-failure classification (#194): True when a non-completed session + # was post-mortem-matched as an *environment fault* (the coding CLI lost its + # API connection and idled out the session clock instead of doing real work). + # Set by the _classify_env_fault hook; env_fault_evidence carries the matched, + # ANSI-stripped log line. These two MUST stay the LAST fields so every + # positional SessionResult construction in the codebase stays valid. + env_fault: bool = False + env_fault_evidence: str | None = None class CodingCLIAdapter(ABC): @@ -118,7 +126,8 @@ def run(self, spec: SessionSpec) -> SessionResult: result = self.wait_for_completion(handle, spec) finally: self.kill(handle) - return self._post_kill_reconcile(handle, spec, result) + result = self._post_kill_reconcile(handle, spec, result) + return self._classify_env_fault(handle, spec, result) def _post_kill_reconcile( self, handle: SessionHandle, spec: SessionSpec, result: SessionResult @@ -132,3 +141,19 @@ def _post_kill_reconcile( now that the kill has settled the liveness question a live-window verdict had to leave open.""" return result + + def _classify_env_fault( + self, handle: SessionHandle, spec: SessionSpec, result: SessionResult + ) -> SessionResult: + """Last-chance post-mortem: label a non-completed session an environment + fault (#194) when the CLI lost its API connection and idled out the + session clock rather than doing real work. + + Runs LAST in ``run()`` — after ``_post_kill_reconcile`` — so a reconcile + upgrade to ``completed`` is never re-classified, and only a genuinely + non-completed verdict (``result_json is None``) is ever inspected. Base + behavior: identity, like ``_post_kill_reconcile``, so adapters with no + post-mortem signal (HTTP/mock) stay inert. Adapters that tee the pane + (see GenericAdapter) may match profile patterns against the log tail here + and stamp ``env_fault`` / ``env_fault_evidence`` onto the result.""" + return result diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index e8fe8428..05b62e9b 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -3,10 +3,13 @@ Each pipeline step gets a fresh tmux window running the full interactive CLI with the skill invocation as the initial prompt. Completion is detected exclusively through hook-written event files (Stop/SessionEnd) plus the -presence of the skill-written result.json — the pane log's *contents* are -never parsed for control flow (only tee'd for human debugging), though its -*growth* (mtime/size, never the bytes — see ``_log_activity_key``) is read as -a liveness signal to re-arm the dev-stall grace window. +presence of the skill-written result.json — the pane log's *contents* never +drive the wait loop (only tee'd for human debugging), though its *growth* +(mtime/size, never the bytes — see ``_log_activity_key``) is read as a liveness +signal to re-arm the dev-stall grace window. The one exception is post-mortem: +after the verdict and reconcile have settled, a single tail read of the log +classifies a transport-failure environment fault (#194, see +``_classify_env_fault``) — it labels the result, it never drives the wait loop. Everything CLI-specific (binary, prompt rendering, bypass flags, usage parser) comes from a declarative CLIProfile; each CLI's hook config registers @@ -18,7 +21,9 @@ from __future__ import annotations +import dataclasses import json +import re import shlex import time from pathlib import Path @@ -45,6 +50,26 @@ RESULT_GRACE_S = 15.0 RESULT_POLL_S = 0.5 KILL_POLL_S = 0.5 +# Post-mortem transport-failure classification (#194): how much of the tee'd +# pane log's tail to scan, how long an evidence excerpt to keep, and which +# non-completed statuses are eligible. over_budget is excluded — a budget +# crossing proves real API traffic — and completed never reaches the scan. +ENV_FAULT_TAIL_BYTES = 64 * 1024 +ENV_FAULT_EVIDENCE_MAX = 240 +ENV_FAULT_STATUSES = frozenset({"timeout", "stalled", "crashed"}) +# Self-contained ANSI/terminal-control stripper for the log tail: CSI, OSC (BEL- +# or ST-terminated), other two-char ESC sequences, and raw C1 bytes. Deliberately +# NOT the TUI/pyte machinery — the classifier reads raw pane bytes best-effort and +# must not pull a terminal emulator into the adapter. +_ANSI_RE = re.compile( + r""" + \x1b\[ [0-?]* [ -/]* [@-~] # CSI ... final byte + | \x1b\] .*? (?: \x07 | \x1b\\ ) # OSC ... BEL or ST + | \x1b [@-Z\\-_] # 2-char ESC sequences (incl. C1 via ESC) + | [\x80-\x9f] # raw C1 control bytes + """, + re.VERBOSE, +) # min spacing between heartbeat.json overwrites in wait_for_completion; the # heartbeat's staleness is what makes a frozen orchestrator (#157) diagnosable. HEARTBEAT_INTERVAL_S = 30.0 @@ -221,6 +246,9 @@ def __init__( self.run_dir = run_dir self.policy = policy self.profile = profile + # Precompiled once per adapter (the profile validated each at parse time, + # so re.compile cannot raise here); empty tuple = classification inert. + self._env_fault_patterns = tuple(re.compile(p) for p in profile.env_fault_patterns) self.mux = mux or get_multiplexer() # None = use the profile's default bypass flags; a tuple replaces them self.extra_args = extra_args @@ -691,6 +719,59 @@ def _log_activity_key(self, task_id: str) -> tuple[int, int] | None: return None return (st.st_mtime_ns, st.st_size) + def _classify_env_fault( + self, handle: SessionHandle, spec: SessionSpec, result: SessionResult + ) -> SessionResult: + """Post-mortem transport-failure classification (#194). + + Runs last in ``run()`` (after ``_post_kill_reconcile``): only a + non-completed verdict (``result.status`` in ``ENV_FAULT_STATUSES``, + ``result_json is None``) with configured patterns is inspected, so a + reconcile upgrade to ``completed`` is never re-classified and adapters + without patterns stay inert. On a matching log-tail line, stamp + ``env_fault`` / ``env_fault_evidence`` and drop an ``env-fault-classified`` + lifecycle breadcrumb. No match, no patterns, or an unreadable log leaves + the verdict untouched — best-effort, like ``_write_heartbeat``.""" + if ( + not self._env_fault_patterns + or result.status not in ENV_FAULT_STATUSES + or result.result_json is not None + ): + return result + evidence = self._env_fault_evidence(handle.task_id) + if evidence is None: + return result + self._note_lifecycle( + handle.task_id, "env-fault-classified", status=result.status, evidence=evidence + ) + return dataclasses.replace(result, env_fault=True, env_fault_evidence=evidence) + + def _env_fault_evidence(self, task_id: str) -> str | None: + """Scan the tail of the tee'd pane log for a transport-failure pattern. + + Reads the last ``ENV_FAULT_TAIL_BYTES`` (binary, decoded with + ``errors="replace"``, ``\\r``→``\\n``), strips ANSI, and matches each + line against the precompiled patterns. Returns the ANSI-stripped matching + line (last match winning, truncated to ``ENV_FAULT_EVIDENCE_MAX``), or + None when nothing matches or the log can't be read (any ``OSError`` → no + classification, the best-effort doctrine).""" + try: + with (self.logs_dir / f"{task_id}.log").open("rb") as fh: + fh.seek(0, 2) # SEEK_END + size = fh.tell() + fh.seek(max(0, size - ENV_FAULT_TAIL_BYTES)) + raw = fh.read() + except OSError: + return None + text = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace").replace("\r", "\n")) + match_line: str | None = None + for line in text.split("\n"): + if any(pat.search(line) for pat in self._env_fault_patterns): + match_line = line # last match wins + if match_line is None: + return None + return match_line.strip()[:ENV_FAULT_EVIDENCE_MAX] + def _window_alive(self, handle: SessionHandle) -> bool: return handle.native_id in self.mux.list_window_ids(self.session_name) diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index e7d242d0..61f24988 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -13,6 +13,7 @@ from __future__ import annotations +import re import tomllib from dataclasses import dataclass, field from importlib import resources @@ -87,6 +88,13 @@ class CLIProfile: # that a `git worktree add` checkout omits; provision_worktree copies them in # from the main repo so isolated dev/review sessions can reach the MCP server. seed_files: tuple[str, ...] = () + # Python `re` patterns matched line-by-line against the ANSI-stripped tail of + # a non-completed session's pane log to classify a transport/API *environment + # fault* (#194) — e.g. an "API Error … Connection refused" the CLI printed + # while idling out the session clock. Compiled and validated at parse time + # (an invalid regex is a profile error). Seeded only for `claude`; empty = + # inert. Override/extend via a project profile in .bmad-loop/profiles/. + env_fault_patterns: tuple[str, ...] = () @property def hookless(self) -> bool: @@ -166,6 +174,13 @@ def fail(msg: str) -> ProfileError: if not seed or is_absolute_path(seed) or has_parent_ref(seed): raise fail(f"seed_files entries must be project-relative paths: got {seed!r}") + env_fault_patterns = tuple(str(p) for p in doc.get("env_fault_patterns", ())) + for pattern in env_fault_patterns: + try: + re.compile(pattern) + except re.error as e: + raise fail(f"env_fault_patterns entry is not a valid regex: {pattern!r} ({e})") from e + return CLIProfile( name=name, binary=binary, @@ -182,6 +197,7 @@ def fail(msg: str) -> ProfileError: subagent_stop_without_transcript=bool(doc.get("subagent_stop_without_transcript", False)), first_run_note=str(doc.get("first_run_note", "")), seed_files=seed_files, + env_fault_patterns=env_fault_patterns, ) diff --git a/src/bmad_loop/data/profiles/claude.toml b/src/bmad_loop/data/profiles/claude.toml index be7d8921..83b0e93d 100644 --- a/src/bmad_loop/data/profiles/claude.toml +++ b/src/bmad_loop/data/profiles/claude.toml @@ -16,6 +16,18 @@ seed_files = [ ".claude/settings.local.json", ] +# Transport-failure classification (#194): regexes matched line-by-line against +# the ANSI-stripped tail of a non-completed (timeout/stalled/crashed) session's +# pane log. A match reclassifies the session as an environment fault so the +# orchestrator can PAUSE (and re-arm with a fresh budget) instead of charging a +# dev attempt for a session that never reached the API. The pattern requires an +# "API Error" line AND a connection-level cause on the SAME line, so ordinary +# story test output that merely mentions "API Error" in prose does not trip it. +# To disable or extend, copy this profile into .bmad-loop/profiles/ and edit. +env_fault_patterns = [ + "API Error.*(Unable to connect|Connection ?(error|refused|reset|timed ?out)|ConnectionRefused|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN)", +] + # Force Claude Code's classic (inline/scrollback) renderer. The fullscreen TUI # (research preview, alt-screen ?1049h like vim/htop) repaints in place, so the # tmux pane capture only ever holds the final frame and the Log tab shows a single diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 0cc889d0..e0f56aac 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -2388,6 +2388,178 @@ def raising_wait(handle, spec): assert calls == ["kill"] +# ----------------------------------- env-fault classification (#194, part 1/3) +# +# A coding CLI that loses its API connection idles out the session clock and is +# stamped `timeout` (or `stalled`/`crashed`), indistinguishable from a real +# wall-clock timeout. _classify_env_fault reads the tee'd pane log's tail ONCE +# after the verdict and reconcile settle, matches the profile's env_fault_patterns +# against the ANSI-stripped lines, and stamps env_fault/env_fault_evidence so a +# later phase can PAUSE instead of charging a dev attempt. These pin the classifier +# in isolation plus its ordering through run(). Downstream consumption is phase 2+. + +_ENV_FAULT_TASK = "1-1-a-dev-1" + + +def _write_task_log(adapter, data: bytes, task_id=_ENV_FAULT_TASK) -> None: + (adapter.logs_dir / f"{task_id}.log").write_bytes(data) + + +def _classify(adapter, status, *, result_json=None, task_id=_ENV_FAULT_TASK) -> SessionResult: + handle = SessionHandle(task_id=task_id, native_id="@1") + result = SessionResult(status=status, result_json=result_json) + spec = make_spec(adapter.run_dir, task_id=task_id) + return adapter._classify_env_fault(handle, spec, result) + + +def test_classify_env_fault_flags_timeout_from_ansi_log(tmp_path): + """The headline case: a timeout whose pane log holds an ANSI-colored + `API Error … (ConnectionRefused)` line is stamped env_fault, the evidence is + the ANSI-stripped line, and an `env-fault-classified` breadcrumb is written.""" + adapter = make_adapter(tmp_path) # claude profile ships the seed pattern + _write_task_log( + adapter, + b"building the diff...\n" + b"\x1b[31mAPI Error: Unable to connect (ConnectionRefused)\x1b[0m\n" + b"idle...\n", + ) + result = _classify(adapter, "timeout") + assert result.env_fault is True + assert result.status == "timeout" # the status string is unchanged + assert result.env_fault_evidence == "API Error: Unable to connect (ConnectionRefused)" + assert "\x1b" not in result.env_fault_evidence # ANSI stripped + events = _lifecycle_lines(adapter, _ENV_FAULT_TASK) + assert [e["event"] for e in events] == ["env-fault-classified"] + assert events[0]["status"] == "timeout" + assert "ConnectionRefused" in events[0]["evidence"] + + +@pytest.mark.parametrize("status", ["stalled", "crashed"]) +def test_classify_env_fault_flags_stalled_and_crashed(tmp_path, status): + """stalled and crashed join timeout in the eligible set — all three can be a + lost-connection session dressed up as a non-completed verdict.""" + adapter = make_adapter(tmp_path) + _write_task_log(adapter, b"API Error: Connection refused\n") + result = _classify(adapter, status) + assert result.env_fault is True + assert result.env_fault_evidence == "API Error: Connection refused" + + +def test_classify_env_fault_ignores_completed_and_over_budget(tmp_path): + """completed never reaches the scan (it carries result_json), and over_budget is + excluded outright — a budget crossing proves real API traffic. Both pass through + unchanged (same object), with no breadcrumb.""" + adapter = make_adapter(tmp_path) + _write_task_log(adapter, b"API Error: Connection refused\n") + completed = _classify(adapter, "completed", result_json={"ok": True}) + assert completed.env_fault is False and completed.env_fault_evidence is None + over = _classify(adapter, "over_budget") + assert over.env_fault is False and over.env_fault_evidence is None + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + +def test_classify_env_fault_ignores_result_json_present(tmp_path): + """The guard is `result_json is None`: an eligible status that somehow carries a + result dict is trusted work, never re-classified.""" + adapter = make_adapter(tmp_path) + _write_task_log(adapter, b"API Error: Connection refused\n") + result = _classify(adapter, "timeout", result_json={"salvaged": True}) + assert result.env_fault is False + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + +def test_classify_env_fault_inert_without_patterns(tmp_path): + """A profile with no env_fault_patterns (codex) never classifies, even with a + matching line in the log.""" + adapter = make_adapter(tmp_path, profile_name="codex") + assert adapter._env_fault_patterns == () + _write_task_log(adapter, b"API Error: Connection refused\n") + result = _classify(adapter, "timeout") + assert result.env_fault is False + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + +def test_classify_env_fault_no_match_leaves_verdict(tmp_path): + """A log with no transport-failure line (only benign output that mentions + `API Error` in prose, the false-positive control) leaves the verdict alone.""" + adapter = make_adapter(tmp_path) + _write_task_log(adapter, b"the story tests how we surface an API Error to users\n") + result = _classify(adapter, "timeout") + assert result.env_fault is False + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + +def test_classify_env_fault_missing_log_degrades_silently(tmp_path): + """No pane log at all (an OSError on read) → no classification, no crash, + no breadcrumb — the best-effort doctrine.""" + adapter = make_adapter(tmp_path) # no log file written + result = _classify(adapter, "timeout") + assert result.env_fault is False + assert result.env_fault_evidence is None + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + +def test_classify_env_fault_last_match_wins_and_truncates(tmp_path): + """Multiple matching lines → the LAST one is the evidence (the most recent + failure), and a long line is truncated to ENV_FAULT_EVIDENCE_MAX.""" + adapter = make_adapter(tmp_path) + filler = "x" * 400 + _write_task_log( + adapter, + ( + f"API Error: Connection refused FIRST {filler}\n" + f"unrelated line\n" + f"API Error: Connection refused LAST {filler}\n" + ).encode(), + ) + result = _classify(adapter, "crashed") + assert result.env_fault is True + assert len(result.env_fault_evidence) == generic.ENV_FAULT_EVIDENCE_MAX + assert "LAST" in result.env_fault_evidence # last match won + assert "FIRST" not in result.env_fault_evidence + + +def test_run_classifies_env_fault_after_reconcile(tmp_path): + """Through run(): a non-rescued timeout (window still alive at the post-kill + probe) whose log tail matches is stamped env_fault — classification runs on the + reconcile-settled result.""" + adapter, _impl = make_dev_adapter(tmp_path) + _write_task_log( + adapter, + b"\x1b[31mAPI Error: Unable to connect (ECONNREFUSED)\x1b[0m\n", + task_id="3-1-dev-1", + ) + adapter.start_session = lambda spec: _dev_handle() + adapter.wait_for_completion = lambda handle, spec: _unvouched("timeout") + adapter.kill = lambda handle: None + adapter._window_alive = lambda handle: True # alive → reconcile keeps the timeout + result = adapter.run(_dev_spec(tmp_path)) + assert result.status == "timeout" + assert result.env_fault is True + assert "ECONNREFUSED" in result.env_fault_evidence + + +def test_run_reconcile_upgrade_is_not_reclassified(tmp_path): + """The ordering invariant: a session reconcile upgrades to completed is NOT + re-classified, even though the same log tail would match — env_fault runs after + the reconcile and only ever inspects a non-completed, result-less verdict.""" + adapter, impl = make_dev_adapter(tmp_path) + (impl / "spec-3-1-foo.md").write_text(_DONE_SPEC) # a done artifact to rescue + _write_task_log( + adapter, + b"API Error: Unable to connect (ECONNREFUSED)\n", # would match if scanned + task_id="3-1-dev-1", + ) + adapter.start_session = lambda spec: _dev_handle() + adapter.wait_for_completion = lambda handle, spec: _unvouched("timeout") + adapter.kill = lambda handle: None + adapter._window_alive = lambda handle: False # dead → reconcile rescues to completed + result = adapter.run(_dev_spec(tmp_path)) + assert result.status == "completed" + assert result.env_fault is False + assert result.env_fault_evidence is None + + def test_wait_for_completion_tolerates_transient_liveness_probe_failure(tmp_path, monkeypatch): """A transient transport hang (the liveness probe raising MultiplexerError, e.g. a 30s tmux hang) must never be read as a dead window -> crash. The tick is diff --git a/tests/test_profile.py b/tests/test_profile.py index bbccd63f..c2187c56 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -75,6 +75,12 @@ def test_builtin_profiles_load(): for name in sorted(set(profiles) - {"claude"}): assert "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN" not in profiles[name].env assert "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS" not in profiles[name].env + # transport-failure classification (#194): only claude seeds env_fault_patterns + # (the "API Error … connection cause" signature); every other built-in ships + # none, so classification stays inert until a project overlay adds patterns + assert profiles["claude"].env_fault_patterns # non-empty + for name in ("codex", "gemini", "copilot", "antigravity", "opencode-http"): + assert profiles[name].env_fault_patterns == () # opencode-http is hookless (HTTP/SSE transport): no hook dialect surfaces, # skills read from the claude tree, usage comes over HTTP (no transcript parser) opencode = profiles["opencode-http"] @@ -109,6 +115,29 @@ def test_seed_files_default_empty_when_unset(tmp_path): assert load_profiles(tmp_path)["mycli"].seed_files == () +def test_env_fault_patterns_default_empty_when_unset(tmp_path): + # MINIMAL_PROFILE omits env_fault_patterns -> defaults to () (classification inert) + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "mycli.toml").write_text(MINIMAL_PROFILE) + assert load_profiles(tmp_path)["mycli"].env_fault_patterns == () + + +def test_env_fault_patterns_parse_from_overlay(tmp_path): + # a project overlay may add/extend the transport-failure patterns; they parse + # into a tuple verbatim (compilation validated, see the invalid-regex case) + profiles_dir = tmp_path / ".bmad-loop" / "profiles" + profiles_dir.mkdir(parents=True) + (profiles_dir / "mycli.toml").write_text( + MINIMAL_PROFILE.replace( + "[hooks]", + 'env_fault_patterns = ["API Error.*refused", "socket hang up"]\n[hooks]', + ) + ) + prof = load_profiles(tmp_path)["mycli"] + assert prof.env_fault_patterns == ("API Error.*refused", "socket hang up") + + def test_skill_tree_defaults_when_unset(): # MINIMAL_PROFILE omits skill_tree -> defaults to .claude/skills assert get_profile("claude").skill_tree == ".claude/skills" @@ -202,6 +231,14 @@ def test_user_profile_overlay(tmp_path): ), "seed_files", ), + # an env_fault_patterns entry that is not a valid regex fails fast at parse + ( + MINIMAL_PROFILE.replace( + "[hooks]", + 'env_fault_patterns = ["API Error(unbalanced"]\n[hooks]', + ), + "env_fault_patterns", + ), ( MINIMAL_PROFILE.replace("[hooks]", "usage_grace_s = -1\n[hooks]"), "usage_grace_s", From c6909e644a9adf0c51b64131defa5737992d6b57 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 22 Jul 2026 21:29:08 -0700 Subject: [PATCH 2/4] fix(engine,escalation,sweep): env-fault sessions pause instead of burning attempts (parts 2-3 of #194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the transport-failure classification from part 1 across the whole session pipeline: a session whose coding CLI lost its API connection (result env_fault=True) now PAUSEs for a human — re-arm restores the budget — instead of charging the attempt/cycle and deferring the story as if its code broke. This mirrors how verify environment faults (rc 126/127) already behave. escalation.py: - shared `env_fault_detail(result)` so the pause reason wording stays uniform. - decide_dev / decide_review_session: PAUSE first thing in the non-completed block (CRITICAL crits check still runs first — env-fault results carry result_json=None, so no conflict). engine.py: - dev-decision / fix-decision journal env_fault now ORs the session flag with the verify-path outcome flag. - _session_end_extras stamps env_fault/env_fault_evidence on both session-end emit sites. - _fix_phase escalates a non-completed env-fault fix session (worktree preserved via the existing PAUSE fall-through). - _run_workflows: a blocking workflow's env-fault session escalates instead of deferring the story; workflow-end carries the flag. Non-blocking workflows keep continuing (journaled only). sweep.py: - migration + triage loops escalate a non-completed env-fault session after the decision journal append, before any retry/attempt-cap charge; both journal events gain env_fault. Budget restoration is the sweep's existing ESCALATED-resume attempt reset (unchanged, documented). Tests: escalation dev/review PAUSE + guard pins; engine dev pause + rearm restores attempt, fix-phase escalate, two-plain-timeouts-still-defer guard; blocking-workflow escalate; sweep migration + triage escalate-without-consuming -attempts + plain-timeout guard. Docs: FEATURES.md typed-escalations bullet + env-fault-classified breadcrumb; CHANGELOG consolidated to the full feature. Closes #194. --- CHANGELOG.md | 30 ++++++---- docs/FEATURES.md | 3 +- src/bmad_loop/engine.py | 39 +++++++++++- src/bmad_loop/escalation.py | 26 ++++++++ src/bmad_loop/sweep.py | 24 +++++++- tests/test_engine.py | 106 +++++++++++++++++++++++++++++++++ tests/test_escalation.py | 53 +++++++++++++++++ tests/test_plugin_workflows.py | 25 ++++++++ tests/test_sweep.py | 76 +++++++++++++++++++++++ 9 files changed, 368 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1cc161..329db179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,26 @@ breaking changes may land in a minor release. ### Added -- **Post-mortem transport-failure classification — plumbing (part 1/3 of #194).** Adapters can now - classify a non-completed (`timeout`/`stalled`/`crashed`) session as an _environment fault_ by - matching per-profile `env_fault_patterns` regexes against the ANSI-stripped tail of the session's - pane log — the signature of a coding CLI that lost its API connection and idled out the session - clock without doing real work. `SessionResult` gains `env_fault` / `env_fault_evidence`, the - generic tmux adapter reads the log tail once after the verdict and reconcile settle (last match - wins, `over_budget`/`completed` excluded), and the `claude` profile ships a seed pattern that - requires an `API Error` line _and_ a connection-level cause on the same line. This part is - plumbing only — nothing consumes the flag yet; the story pipeline, workflow, and sweep sessions - pause on it (without burning attempts) in later parts. +- **Transport failures pause instead of burning attempts (#194).** A session whose coding CLI + lost its API connection stays alive but idle, printing `API Error: Unable to connect …` while it + idles out the session clock — indistinguishable from a real wall-clock timeout, so two such + outages exhausted `max_dev_attempts` and deferred the story with zero real work attempted. + bmad-loop now classifies these post-mortem and pauses for a human (re-arm restores the budget), + exactly as verify environment faults (`rc 126/127`) already do: + - Adapters classify a non-completed (`timeout`/`stalled`/`crashed`) session as an _environment + fault_ by matching per-profile `env_fault_patterns` regexes against the ANSI-stripped tail of + the pane log. `SessionResult` gains `env_fault` / `env_fault_evidence`; the generic tmux + adapter reads the log tail once after the verdict and reconcile settle (last match wins, + `over_budget`/`completed` excluded), drops an `env-fault-classified` breadcrumb, and the + `claude` profile ships a seed pattern requiring an `API Error` line _and_ a connection-level + cause on the same line (so prose "API Error" test output does not trip it). + - The dev, review, and fix phases PAUSE on a classified session (evidence in the reason, worktree + preserved) instead of charging the attempt/cycle; the `dev-decision` / `fix-decision` / + `session-end` journal entries carry the flag and evidence. + - Blocking plugin workflows escalate rather than defer the story on a classified session + (non-blocking workflows keep continuing, journaled only); the sweep migration and triage loops + escalate before charging a retry, and the sweep's existing escalation-resume restores their + budget. - **Documented the `BMAD_LOOP_*` environment variables (#246).** The three runtime override vars — `BMAD_LOOP_MUX_BACKEND`, `BMAD_LOOP_PROCESS_HOST`, `BMAD_LOOP_SESSION_TIMEOUT_S` — now diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 46416058..d4da1289 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -60,6 +60,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Bounded dev retries (default 2): verify-failures keep the tree and feed the failing output to the next session via `--feedback`; other failures roll back to baseline. - Plateau-defer: when review won't converge, the story is skipped, the spec stashed into the run dir, deferred-work preserved, the run continues. - Typed escalations: `CRITICAL` pauses the run + notifies (desktop + `ATTENTION` file); `PREFERENCE` is journaled and continues. +- Environment faults pause without burning budget (#194): a session whose coding CLI never reached the API — a deterministic verify command dying `rc 126/127`, **or** a dev/review/fix/workflow/sweep session whose pane log matches the profile's `env_fault_patterns` (an `API Error … Connection refused`-class transport failure that idled out the session clock) — pauses the run with the matched evidence instead of charging the attempt/cycle and deferring the story as if its code were broken. Re-arm (or a sweep's escalation-resume) restores the budget, so a resume after the outage clears re-drives from a clean slate. Patterns are per-profile (seeded only for `claude`; extend/disable via a project profile overlay). - CRITICAL resolution: `bmad-loop resolve ` opens an interactive resolve agent seeded with the escalation + frozen spec; you disambiguate, it re-arms the story (`escalated → pending`, spec reset to `ready-for-dev`) and resumes. `--no-interactive` skips to re-arm if you fixed the spec yourself. - Intent-gap patch-restore (BMAD-METHOD#2564): when review halts on an `intent gap`, `bmad-dev-auto` saves the attempted change as a patch file (in the implementation-artifacts folder, referenced from the halt output) before reverting the tree. If the attempted reading turns out to be correct, the resolve agent adds `"restore_patch": ""` to its `resolution.json`; the orchestrator then re-arms the spec to `in-review` (not `ready-for-dev`) and re-applies the patch onto the baseline after every reset, so the re-driven session resumes _review_ on the restored diff instead of re-implementing from scratch. A hand-driven `bmad-loop resolve --no-interactive --restore-patch ` does the same. A patch that fails to apply escalates rather than dispatching a session onto a half-restored tree. Deferred-work sweep bundles (below) get the same recovery — an escalated bundle re-arms to `in-review` and the re-driven bundle session resumes review on the re-applied patch. @@ -92,7 +93,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - Every run is a resumable on-disk state machine: `bmad-loop resume ` continues from a gate, escalation, or interruption. - A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item. -- All run state in `.bmad-loop/runs//` (gitignored): `state.json`, `journal.jsonl` (every decision), `events/` (hook signals), `tasks//` (per-session prompt + `result.json` + diagnostic breadcrumbs — `session-lifecycle.jsonl` records timeout-fire and budget-guard trips/terminations (`budget-tripped` / `over-budget-fired`), `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops), `logs/`, `deferred/`, `resolve/`, `ATTENTION`. +- All run state in `.bmad-loop/runs//` (gitignored): `state.json`, `journal.jsonl` (every decision), `events/` (hook signals), `tasks//` (per-session prompt + `result.json` + diagnostic breadcrumbs — `session-lifecycle.jsonl` records timeout-fire, budget-guard trips/terminations (`budget-tripped` / `over-budget-fired`), and transport-failure classification (`env-fault-classified`, #194), `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops), `logs/`, `deferred/`, `resolve/`, `ATTENTION`. - `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time. ### Hook-based transport (no pane-scraping) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 705e042d..eaf24a07 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -27,6 +27,7 @@ critical_escalations, decide_dev, decide_review_session, + env_fault_detail, preference_escalations, ) from .journal import Journal, save_state @@ -1027,14 +1028,29 @@ def _run_workflows(self, stage: str, task: StoryTask, seq: int) -> bool: session_stage="pre_workflow_session", label=f"{lp.name}.{wf.name}", ) + wf_extras: dict = {"env_fault": result.env_fault} + if result.env_fault_evidence: + wf_extras["env_fault_evidence"] = result.env_fault_evidence self.journal.append( "workflow-end", plugin=lp.name, workflow=wf.name, status=result.status, story_key=task.story_key, + **wf_extras, ) if wf.blocking and result.status != "completed": + if result.env_fault: + # A blocking workflow session that lost its API connection (#194) + # never ran — escalate (re-arm restores the budget) instead of + # deferring the story on a transport failure. Non-blocking + # workflows keep continuing (journaled only): the next story + # session will classify and pause if the outage persists. + self._escalate( + task, + f"environment fault: blocking workflow {wf.name!r} ({lp.name}) " + f"session {result.status} ({env_fault_detail(result)})", + ) self._defer( task, f"blocking workflow {wf.name!r} ({lp.name}) did not complete: {result.status}", @@ -1165,7 +1181,10 @@ def _dev_phase(self, task: StoryTask, resume_result: SessionResult | None = None session_status=result.status, action=str(decision.action), reason=decision.reason, - env_fault=bool(outcome is not None and outcome.env_fault), + # env_fault from EITHER the verify path (rc 126/127) or the + # session-transport classification (#194); decide_dev PAUSEs on + # the latter, so the fall-through below preserves the worktree. + env_fault=bool((outcome is not None and outcome.env_fault) or result.env_fault), ) self._save() if decision.action == Action.PROCEED: @@ -1866,6 +1885,12 @@ def _session_end_extras(self, result: SessionResult) -> dict: budget=self.policy.limits.max_tokens_per_session, budget_mode=self.policy.limits.session_budget_mode, ) + # transport-failure classification (#194): rides both session-end emit + # sites so the evidence line is on the record wherever the session ended. + if result.env_fault: + extras["env_fault"] = True + if result.env_fault_evidence: + extras["env_fault_evidence"] = result.env_fault_evidence return extras @staticmethod @@ -2211,8 +2236,18 @@ def _fix_phase(self, task: StoryTask, reason: str) -> bool: attempt=task.attempt, session_status=result.status, ok=ok, - env_fault=bool(outcome is not None and outcome.env_fault), + env_fault=bool((outcome is not None and outcome.env_fault) or result.env_fault), ) + if result.status != "completed" and result.env_fault: + # A fix session whose CLI lost its API connection (#194) did no + # repair work — another attempt cannot fix the run environment, so + # pause (re-arm restores the budget) instead of burning the dev + # budget. A completed-but-env-fault-grade verify failure is handled + # by the retryable check just below (its own escalate path). + self._escalate( + task, + f"environment fault: fix session {result.status} ({env_fault_detail(result)})", + ) if outcome is not None and not outcome.ok and not outcome.retryable: # escalate-grade failure (environment fault): another repair # session cannot fix the run environment — stop spending the diff --git a/src/bmad_loop/escalation.py b/src/bmad_loop/escalation.py index 8622ac54..b10a4633 100644 --- a/src/bmad_loop/escalation.py +++ b/src/bmad_loop/escalation.py @@ -52,6 +52,15 @@ def preference_escalations(result_json: dict[str, Any] | None) -> list[dict[str, ] +def env_fault_detail(result: SessionResult) -> str: + """The evidence excerpt for an environment-fault pause reason, or a generic + fallback when the adapter classified a fault but kept no line (#194). Shared + by every site that pauses a transport-failed session — dev/review here, plus + fix/workflow/sweep in the engine — so the reason wording stays uniform: + ``environment fault: session ()``.""" + return result.env_fault_evidence or "transport-failure pattern in session log" + + def decide_dev( task: StoryTask, result: SessionResult, @@ -68,6 +77,16 @@ def decide_dev( exhausted = _exhausted_action(task) if result.status != "completed": + if result.env_fault: + # A transport/API failure (the CLI never reached the API, #194): the + # attempt did no real work, so pause for a human instead of charging + # it — re-arm resets the budget, exactly like a verify env-fault (rc + # 126/127). The crits check above already ran (env-fault results carry + # result_json=None, so it found nothing). + return Decision( + Action.PAUSE, + f"environment fault: dev session {result.status} ({env_fault_detail(result)})", + ) reason = f"dev session {result.status}" if budget_left: return Decision(Action.RETRY, reason) @@ -92,6 +111,13 @@ def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy budget_left = task.review_cycle < policy.limits.max_review_cycles if result.status != "completed": + if result.env_fault: + # transport/API failure (#194): pause rather than charge a review + # cycle for a session that never reached the API (see decide_dev). + return Decision( + Action.PAUSE, + f"environment fault: review session {result.status} ({env_fault_detail(result)})", + ) reason = f"review session {result.status}" if budget_left: return Decision(Action.RETRY, reason) diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index fc4c0380..987735a2 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -20,7 +20,7 @@ from . import deferredwork, gates, verify from .engine import Engine -from .escalation import critical_escalations +from .escalation import critical_escalations, env_fault_detail from .model import Phase, StoryTask from .platform_util import atomic_replace from .statemachine import advance @@ -755,7 +755,19 @@ def _ensure_migration(self, text: str) -> None: session_status=result.status, ok=not errors, errors=errors, + env_fault=result.env_fault, ) + if result.status != "completed" and result.env_fault: + # The migration session's CLI lost its API connection (#194): it did + # no rewrite work, so pause (the ESCALATED-resume above resets + # task.attempt to 0 — fresh budget) instead of charging a migration + # attempt. Escalate BEFORE the _safe_reset/attempt-cap path; that + # resume also restores the ledger if the worktree is dirty. + self._escalate( + task, + f"environment fault: migration session {result.status} " + f"({env_fault_detail(result)})", + ) if not errors: advance(task, Phase.DONE) self._save() @@ -858,7 +870,17 @@ def _ensure_triage(self, open_now: set[str], cycle: int = 1) -> TriagePlan: session_status=result.status, ok=plan is not None, errors=errors, + env_fault=result.env_fault, ) + if result.status != "completed" and result.env_fault: + # transport/API failure (#194): pause rather than charge a triage + # attempt for a session that never reached the API. The + # ESCALATED-resume above resets task.attempt to 0 (fresh budget). + self._escalate( + task, + f"environment fault: triage session {result.status} " + f"({env_fault_detail(result)})", + ) if plan is not None: advance(task, Phase.DONE) self._save() diff --git a/tests/test_engine.py b/tests/test_engine.py index 155a7c12..eebaf387 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -4498,6 +4498,112 @@ def env_breaking_fix(spec): assert fix["env_fault"] is True +# ---------------------------- session-transport environment faults (#194) ---- +# +# A dev/review/fix session whose coding CLI lost its API connection is classified +# env_fault by the adapter (part 1) and stamped on the SessionResult. These pin +# the story-pipeline consumption (part 2): the run PAUSEs — evidence journaled, +# worktree preserved — instead of charging the attempt, and re-arm restores the +# budget. Guard pins confirm plain (non-classified) failures still retry/defer. + + +def test_session_env_fault_pauses_dev_without_burning_budget(project): + """A dev session classified an environment fault (#194) pauses the run at the + first story rather than charging the attempt; the decision + session-end carry + the evidence, and re-arm restores the budget (attempt -> 0).""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + evidence = "API Error: Unable to connect (ECONNREFUSED)" + engine, adapter = make_engine( + project, + [SessionResult(status="timeout", env_fault=True, env_fault_evidence=evidence)], + ) + summary = engine.run() + + assert summary.paused and summary.escalated == 1 and summary.deferred == 0 + assert [s.role for s in adapter.sessions] == ["dev"] # no retry session burned + task = engine.state.tasks["1-1-a"] + assert task.phase == Phase.ESCALATED + assert task.attempt == 1 # the one real session, not a spent budget + assert engine.state.paused_stage == PAUSE_ESCALATION + assert "environment fault: dev session timeout" in engine.state.paused_reason + assert evidence in engine.state.paused_reason + + dec = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"][-1] + assert dec["action"] == "pause" + assert dec["env_fault"] is True + end = [e for e in engine.journal.entries() if e["kind"] == "session-end"][-1] + assert end["env_fault"] is True + assert end["env_fault_evidence"] == evidence + + # the resolve workflow's re-arm step restores the attempt budget + rearm_escalation(engine.run_dir) + assert load_state(engine.run_dir).tasks["1-1-a"].attempt == 0 + + +def test_two_plain_timeouts_still_defer(project): + """Guard: NON-env-fault timeouts keep today's flow — two of them exhaust the + dev budget and defer the story (the env-fault pause must not intercept them).""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + limits=LimitsPolicy(max_dev_attempts=2), + scm=ScmPolicy(rollback_on_failure=True), + ) + engine, adapter = make_engine( + project, + [SessionResult(status="timeout"), SessionResult(status="timeout")], + policy=policy, + ) + summary = engine.run() + + assert summary.deferred == 1 and summary.escalated == 0 and not summary.paused + assert [s.role for s in adapter.sessions] == ["dev", "dev"] # both attempts spent + assert engine.state.tasks["1-1-a"].phase == Phase.DEFERRED + dec = [e for e in engine.journal.entries() if e["kind"] == "dev-decision"] + assert all(d["env_fault"] is False for d in dec) + + +def test_fix_phase_session_env_fault_escalates(project): + """A fix session whose CLI lost its API connection (#194) escalates instead of + burning the remaining dev budget on repair sessions that never ran.""" + write_sprint(project, {"1-1-a": "ready-for-dev"}) + marker = project.project / "fixed.marker" + script = project.project / "check.sh" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + + def dev_with_marker(spec): + marker.write_text("ok\n") + return dev_effect(project, "1-1-a")(spec) + + def breaking_review(spec): + marker.unlink() # ordinary fixable failure -> routes to a fix session + return review_effect(project, "1-1-a", clean=True)(spec) + + evidence = "API Error: Connection reset by peer" + env_fault_fix = SessionResult(status="timeout", env_fault=True, env_fault_evidence=evidence) + + policy = Policy( + gates=GatesPolicy(mode="none"), + notify=QUIET, + verify=VerifyPolicy(commands=(_file_exists_cmd(marker), f'"{script}"')), + limits=LimitsPolicy(max_dev_attempts=3), # budget left -> must not be spent + ) + engine, adapter = make_engine( + project, [dev_with_marker, breaking_review, env_fault_fix], policy=policy + ) + summary = engine.run() + + assert summary.paused and summary.escalated == 1 and summary.deferred == 0 + assert [s.role for s in adapter.sessions] == ["dev", "review", "dev"] # one fix, then stop + assert engine.state.tasks["1-1-a"].phase == Phase.ESCALATED + assert "environment fault: fix session timeout" in engine.state.paused_reason + assert evidence in engine.state.paused_reason + fix = [e for e in engine.journal.entries() if e["kind"] == "fix-decision"][-1] + assert fix["env_fault"] is True + + def test_max_stories_limit(project): write_sprint(project, {"1-1-a": "ready-for-dev", "1-2-b": "ready-for-dev"}) engine, _ = make_engine( diff --git a/tests/test_escalation.py b/tests/test_escalation.py index 0bf55c5d..0cbc64b3 100644 --- a/tests/test_escalation.py +++ b/tests/test_escalation.py @@ -56,6 +56,59 @@ def test_env_fault_outcome_pauses_even_with_budget_left(): assert "rc=127" in decision.reason +def test_dev_env_fault_session_pauses_even_with_budget_left(): + """A dev session classified as a transport/API environment fault (#194) PAUSEs + immediately — like the verify env-fault above, the attempt budget is never + consulted, and the reason carries the evidence line.""" + task = _task(attempt=1) # 1 < 2 -> budget remains + env_fault = SessionResult( + status="timeout", env_fault=True, env_fault_evidence="API Error: Connection refused" + ) + decision = decide_dev(task, env_fault, None, POLICY) + assert decision.action == Action.PAUSE + assert "environment fault: dev session timeout" in decision.reason + assert "API Error: Connection refused" in decision.reason + + +def test_dev_env_fault_session_pauses_even_when_budget_exhausted(): + """The env-fault pause outranks budget exhaustion too — a spent budget must not + downgrade it to a defer (that would file a transport failure as deferred work).""" + task = _task(attempt=2) # 2 == max_dev_attempts -> budget spent + env_fault = SessionResult(status="crashed", env_fault=True) + decision = decide_dev(task, env_fault, None, POLICY) + assert decision.action == Action.PAUSE + assert "environment fault" in decision.reason + + +def test_dev_plain_noncompleted_still_retries_with_budget(): + """Guard pin: a NON-env-fault timeout with budget left still RETRYs — the + env-fault branch must not swallow ordinary transient failures.""" + task = _task(attempt=1) + plain = SessionResult(status="timeout") # env_fault defaults False + decision = decide_dev(task, plain, None, POLICY) + assert decision.action == Action.RETRY + assert "environment fault" not in decision.reason + + +def test_review_env_fault_session_pauses(): + """The same classification pauses a review session (evidence in the reason), + where a plain non-completed review would RETRY/DEFER.""" + task = _task(review_cycle=1) # budget remains + env_fault = SessionResult( + status="stalled", env_fault=True, env_fault_evidence="API Error: ETIMEDOUT" + ) + decision = decide_review_session(task, env_fault, POLICY) + assert decision.action == Action.PAUSE + assert "environment fault: review session stalled" in decision.reason + assert "ETIMEDOUT" in decision.reason + + +def test_review_plain_noncompleted_still_retries_with_budget(): + task = _task(review_cycle=1) + plain = SessionResult(status="crashed") + assert decide_review_session(task, plain, POLICY).action == Action.RETRY + + def test_review_exhausted_defers_normal_story(): task = _task(review_cycle=2) # 2 == max_review_cycles crashed = SessionResult(status="crashed") diff --git a/tests/test_plugin_workflows.py b/tests/test_plugin_workflows.py index 7bfdf736..041cea7a 100644 --- a/tests/test_plugin_workflows.py +++ b/tests/test_plugin_workflows.py @@ -268,6 +268,31 @@ def test_blocking_workflow_failure_defers_the_unit(project): assert "story-deferred" in kinds +def test_blocking_workflow_env_fault_escalates_instead_of_deferring(project): + """A blocking workflow session classified an environment fault (#194) escalates + the run (re-arm restores the budget) instead of deferring the story as if its + code were broken; the workflow-end entry carries env_fault + evidence.""" + setup_story(project) + reg = PluginRegistry([LoadedPlugin(manifest=wf_manifest("wf", blocking=True))]) + evidence = "API Error: Unable to connect (ECONNREFUSED)" + script = [ + dev_effect(project, "1-1-a"), + SessionResult(status="timeout", env_fault=True, env_fault_evidence=evidence), + ] + engine, _ = make_engine(project, script, reg) + summary = engine.run() + + assert summary.paused and summary.escalated == 1 and summary.deferred == 0 + assert engine.state.tasks["1-1-a"].phase == Phase.ESCALATED + assert "environment fault: blocking workflow" in engine.state.paused_reason + assert evidence in engine.state.paused_reason + end = [e for e in engine.journal.entries() if e["kind"] == "workflow-end"][-1] + assert end["env_fault"] is True + assert end["env_fault_evidence"] == evidence + kinds = [e["kind"] for e in engine.journal.entries()] + assert "story-deferred" not in kinds # escalated, not deferred + + def test_nonblocking_workflow_failure_is_advisory(project): captured: list = [] setup_story(project) diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 35519485..bb045a31 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -650,6 +650,82 @@ def test_triage_validation_failure_retries_with_feedback_then_escalates(project) assert "not triaged: DW-1" in open(feedback_path).read() +def test_triage_session_env_fault_escalates_then_resume_restores_budget(project): + """A triage session whose CLI lost its API connection (#194) escalates on the + first attempt instead of retrying up to max_triage_attempts; the decision + carries env_fault, and the ESCALATED-resume restores the budget (attempt -> 0) + so a resume after the outage re-drives triage cleanly.""" + write_ledger(project, {"DW-1": "open"}) + evidence = "API Error: Unable to connect (ECONNREFUSED)" + engine, adapter = make_sweep( + project, + [SessionResult(status="timeout", env_fault=True, env_fault_evidence=evidence)], + ) + summary = engine.run() + + assert summary.paused + task = engine.state.tasks["sweep-triage"] + assert task.phase == Phase.ESCALATED + assert task.attempt == 1 # only the one session — no retry budget spent + assert "environment fault: triage session timeout" in engine.state.paused_reason + assert evidence in engine.state.paused_reason + assert len(adapter.sessions) == 1 # no feedback-retry session + dec = [e for e in engine.journal.entries() if e["kind"] == "triage-decision"][-1] + assert dec["env_fault"] is True + + # resume once the outage clears: the ESCALATED-resume resets attempt to 0 + # (fresh budget) and re-drives triage to completion + good = triage_result(["DW-1"], skip=[{"id": "DW-1", "reason": "moot"}]) + resumed, radapter = resume_sweep(project, engine, [triage_effect(good)]) + assert not resumed.run().paused + assert resumed.state.tasks["sweep-triage"].phase == Phase.DONE + assert len(radapter.sessions) == 1 + + +def test_triage_plain_timeout_still_retries_to_cap(project): + """Guard: a NON-env-fault triage timeout keeps today's behavior — retries up to + max_triage_attempts (2) then escalates. The env-fault branch must not swallow + ordinary transient failures on the first attempt.""" + write_ledger(project, {"DW-1": "open"}) + engine, adapter = make_sweep( + project, + [SessionResult(status="timeout"), SessionResult(status="timeout")], + ) + summary = engine.run() + + assert summary.paused + assert engine.state.tasks["sweep-triage"].phase == Phase.ESCALATED + assert len(adapter.sessions) == 2 # both attempts spent, not escalated on the first + dec = [e for e in engine.journal.entries() if e["kind"] == "triage-decision"] + assert all(d["env_fault"] is False for d in dec) + + +def test_migration_session_env_fault_escalates_without_consuming_attempts(project): + """A migration session whose CLI lost its API connection (#194) escalates on the + first attempt instead of charging a migration retry; migrate-decision carries + env_fault, and the legacy ledger is left untouched (no half-rewrite lands).""" + write_legacy_ledger(project, LEGACY_LEDGER) + evidence = "API Error: Unable to connect (ECONNREFUSED)" + engine, adapter = make_sweep( + project, + [SessionResult(status="timeout", env_fault=True, env_fault_evidence=evidence)], + ) + summary = engine.run() + + assert summary.paused + task = engine.state.tasks["sweep-migrate"] + assert task.phase == Phase.ESCALATED + assert task.attempt == 1 # only the one session — no migration retry spent + assert "environment fault: migration session timeout" in engine.state.paused_reason + assert evidence in engine.state.paused_reason + assert len(adapter.sessions) == 1 + dec = [e for e in engine.journal.entries() if e["kind"] == "migrate-decision"][-1] + assert dec["env_fault"] is True + # the legacy ledger is intact and the tree clean: no half-rewrite escaped + assert project.deferred_work.read_text(encoding="utf-8") == LEGACY_LEDGER + assert worktree_clean(project.project) + + def test_triage_escalation_resume_retries_triage(project): write_ledger(project, {"DW-1": "open"}) bad = triage_result(["DW-1"]) From b7b5bd39d1fae0532698f58d57fa5ff938e302e3 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 22 Jul 2026 22:13:03 -0700 Subject: [PATCH 3/4] fix(adapters): harden #194 env-fault classifier per PR #272 review Address three CodeRabbit findings on the transport-failure classifier: - Reset the reused pane log in start_session. Task-ids are reused across a re-armed run and both mux backends append, so a prior cycle's transport error lingered in the 64 KiB tail and mis-flagged a later unrelated timeout as env_fault. Unlink logs/.log before re-piping, mirroring the existing result.json unlink (and matching journal.py's "next session replaces it" assumption). - Bound operator-supplied pattern matching. Match env_fault_patterns with the `regex` module under a per-pattern timeout (ENV_FAULT_MATCH_TIMEOUT_S=2.0s) so a pathological profile regex can't catastrophically backtrack and hang a session teardown; a TimeoutError declines to classify (best-effort, like an unreadable log). Adds `regex` as a core dependency; validation compiles with the same engine. - Validate list-valued profile fields. env_fault_patterns / seed_files / launch_args / bypass_args must be a TOML array of strings: a bare string silently became per-character entries and a scalar leaked a raw TypeError. Both now raise a friendly ProfileError via a shared str_list() guard. Tests: task-id reuse regression, pathological-pattern timeout bound, and scalar/non-string-item rejection across all four list fields. refs #194 --- CHANGELOG.md | 5 +- docs/adapter-authoring-guide.md | 36 +++++----- pyproject.toml | 6 ++ src/bmad_loop/adapters/generic.py | 41 ++++++++---- src/bmad_loop/adapters/profile.py | 23 +++++-- tests/test_generic_tmux.py | 52 +++++++++++++++ tests/test_profile.py | 27 ++++++++ uv.lock | 106 ++++++++++++++++++++++++++++++ 8 files changed, 259 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 329db179..23e333ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,10 @@ breaking changes may land in a minor release. adapter reads the log tail once after the verdict and reconcile settle (last match wins, `over_budget`/`completed` excluded), drops an `env-fault-classified` breadcrumb, and the `claude` profile ships a seed pattern requiring an `API Error` line _and_ a connection-level - cause on the same line (so prose "API Error" test output does not trip it). + cause on the same line (so prose "API Error" test output does not trip it). Patterns must be a + list of strings and are matched with the `regex` module under a per-pattern timeout (a + pathological pattern can't hang a teardown), and the pane log is reset when a re-armed run reuses + a task id so a prior cycle's line can't misclassify a later session. - The dev, review, and fix phases PAUSE on a classified session (evidence in the reason, worktree preserved) instead of charging the attempt/cycle; the `dev-decision` / `fix-decision` / `session-end` journal entries carry the flag and evidence. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 9ce6bf84..24ee9f04 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -357,24 +357,24 @@ resolves to `claude`. ### `CLIProfile` -| Field | Required | Default | Meaning | -| ---------------------------------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | ✅ | — | Profile id, also the `--cli` value and override key. | -| `binary` | ✅ | — | Executable to launch (resolved on `PATH`). | -| `[hooks]` | ✅ | — | The `HookSpec` table (see below). | -| `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | -| `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | -| `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). | -| `bypass_args` | | `()` | Flags that bypass permission/approval prompts for unattended runs (e.g. `--allow-all-tools`). | -| `model_flag` | | `--model` | Flag used to pass the model name when one is configured. | -| `env` | | `{}` | Extra environment variables for the session. | -| `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | -| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | -| `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | -| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. | -| `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | -| `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | -| `env_fault_patterns` | | `()` | Python `re` patterns matched line-by-line against the ANSI-stripped tail of a **non-completed** (`timeout`/`stalled`/`crashed`) session's pane log to classify a transport/API **environment fault** (#194); a match stamps `env_fault` / `env_fault_evidence` on the `SessionResult`. Compiled and validated at parse time (an invalid regex is a profile error). Seeded only for `claude`; empty = inert. | +| Field | Required | Default | Meaning | +| ---------------------------------- | -------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | ✅ | — | Profile id, also the `--cli` value and override key. | +| `binary` | ✅ | — | Executable to launch (resolved on `PATH`). | +| `[hooks]` | ✅ | — | The `HookSpec` table (see below). | +| `skill_tree` | | `.claude/skills` | Project-relative tree this CLI reads skills from (`.agents/skills` for codex/gemini); `bmad-loop init` installs the `bmad-loop-*` skills here. Must be relative. | +| `prompt_template` | | `{prompt}` | How the canonical `/skill args` prompt is rendered. Placeholders: `{prompt}` (whole string), `{skill}` (leading slash-command name, no `/`), `{args}` (the remainder). | +| `launch_args` | | `()` | Extra argv passed at launch, e.g. `["-i"]` to stay interactive (gemini/copilot). | +| `bypass_args` | | `()` | Flags that bypass permission/approval prompts for unattended runs (e.g. `--allow-all-tools`). | +| `model_flag` | | `--model` | Flag used to pass the model name when one is configured. | +| `env` | | `{}` | Extra environment variables for the session. | +| `usage_parser` | | `none` | Which transcript token parser to use — one of `claude-jsonl`, `codex-rollout`, `gemini-chat`, `copilot-events`, `none`. | +| `usage_grace_s` | | `0.0` | Seconds to keep polling the transcript for token totals after the session ends. `0` = read once. Raise it for CLIs that flush totals only on shutdown (copilot writes `modelMetrics` ~1s after the turn-end hook). Must be ≥ 0. | +| `stop_without_result_nudges` | | unset (use global) | Per-adapter floor for Stop-without-result nudges. Leave unset to inherit `limits.stop_without_result_nudges`. Raise it for CLIs that fire a turn-end hook _per response turn_ (copilot's `agentStop`), where the global default of 1 declares them stalled too early. Must be ≥ 0 if set. | +| `subagent_stop_without_transcript` | | `false` | Set `true` for CLIs that fire the turn-end hook for _subagent_ turns too, with an empty `transcriptPath` and a tool-use session id (copilot's `agentStop`). A `Stop` carrying no transcript is then treated as a subagent stop and ignored, so the main session's real turn-end drives completion. Leave `false` and every `Stop` is the main turn-end. | +| `first_run_note` | | `""` | Human note printed by `init` about a manual first-run/auth step this CLI needs. | +| `seed_files` | | `()` | Project-relative gitignored configs (MCP/CLI settings) a `git worktree add` checkout omits; `provision_worktree` copies them into isolated dev/review worktrees. Must be relative. | +| `env_fault_patterns` | | `()` | Regex patterns matched line-by-line against the ANSI-stripped tail of a **non-completed** (`timeout`/`stalled`/`crashed`) session's pane log to classify a transport/API **environment fault** (#194); a match stamps `env_fault` / `env_fault_evidence` on the `SessionResult`. Must be a **list of strings**; compiled and validated at parse time (an invalid regex is a profile error). Matched with the `regex` module under a per-pattern timeout (`ENV_FAULT_MATCH_TIMEOUT_S`), so a pathological pattern can't hang a session teardown. Seeded only for `claude`; empty = inert. | ### `HookSpec` (the `[hooks]` table) diff --git a/pyproject.toml b/pyproject.toml index a8e66768..1d925aa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,12 @@ authors = [{ name = "BMad Code, LLC" }] requires-python = ">=3.11" dependencies = [ "pyyaml>=6.0", + # stdlib `re` has no match timeout; the #194 env-fault classifier runs + # operator-supplied profile regexes over pane logs, so it matches with the + # `regex` module's per-call timeout= to keep a pathological pattern from + # hanging a session teardown. Core (all-platform) — classification is on the + # main pipeline path. + "regex>=2024.11.6", # win32 liveness needs psutil (no /proc; os.kill(pid,0) is destructive there); # without it the TUI shows every run as `?`. Hard dep so no install path misses it. "psutil>=7.2.2; sys_platform == 'win32'", diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 05b62e9b..ae58c6f1 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -29,6 +29,8 @@ from pathlib import Path from typing import TYPE_CHECKING +import regex + from .. import devcontract, gates, runs from ..bmadconfig import ProjectPaths from ..journal import LOGS_DIR @@ -57,6 +59,11 @@ ENV_FAULT_TAIL_BYTES = 64 * 1024 ENV_FAULT_EVIDENCE_MAX = 240 ENV_FAULT_STATUSES = frozenset({"timeout", "stalled", "crashed"}) +# Wall-clock bound on each operator-supplied pattern match (run via the `regex` +# module, not stdlib `re`, whose `search` has no timeout): a pathological profile +# regex can't hang run() teardown. Huge headroom — a sane pattern over the ≤64 KiB +# tail matches in microseconds; a runaway is capped at ~this and declines to classify. +ENV_FAULT_MATCH_TIMEOUT_S = 2.0 # Self-contained ANSI/terminal-control stripper for the log tail: CSI, OSC (BEL- # or ST-terminated), other two-char ESC sequences, and raw C1 bytes. Deliberately # NOT the TUI/pyte machinery — the classifier reads raw pane bytes best-effort and @@ -246,9 +253,10 @@ def __init__( self.run_dir = run_dir self.policy = policy self.profile = profile - # Precompiled once per adapter (the profile validated each at parse time, - # so re.compile cannot raise here); empty tuple = classification inert. - self._env_fault_patterns = tuple(re.compile(p) for p in profile.env_fault_patterns) + # Precompiled once per adapter (the profile validated each at parse time with + # the same regex engine, so regex.compile cannot raise here); matched under a + # per-pattern timeout in _env_fault_evidence. Empty tuple = classification inert. + self._env_fault_patterns = tuple(regex.compile(p) for p in profile.env_fault_patterns) self.mux = mux or get_multiplexer() # None = use the profile's default bypass flags; a tuple replaces them self.extra_args = extra_args @@ -336,6 +344,10 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: self.build_command(spec), ) log_file = self.logs_dir / f"{spec.task_id}.log" + # A re-armed run reuses task_ids and both mux backends append; drop the prior + # cycle's tee so the #194 tail scan can't match a stale transport error (mirrors + # the result.json unlink above; journal.py already assumes "next session replaces it"). + log_file.unlink(missing_ok=True) # pipe_pane tolerates the window having already died (a CLI that crashes on # launch can take it down before the tee attaches); the dead window is then # reported as a crash in wait_for_completion. @@ -750,11 +762,12 @@ def _env_fault_evidence(self, task_id: str) -> str | None: """Scan the tail of the tee'd pane log for a transport-failure pattern. Reads the last ``ENV_FAULT_TAIL_BYTES`` (binary, decoded with - ``errors="replace"``, ``\\r``→``\\n``), strips ANSI, and matches each - line against the precompiled patterns. Returns the ANSI-stripped matching - line (last match winning, truncated to ``ENV_FAULT_EVIDENCE_MAX``), or - None when nothing matches or the log can't be read (any ``OSError`` → no - classification, the best-effort doctrine).""" + ``errors="replace"``, ``\\r``→``\\n``), strips ANSI, and matches each line + against the precompiled patterns under a per-match ``ENV_FAULT_MATCH_TIMEOUT_S`` + bound. Returns the ANSI-stripped matching line (last match winning, truncated + to ``ENV_FAULT_EVIDENCE_MAX``), or None when nothing matches, the log can't be + read (any ``OSError``), or a pattern exceeds the match timeout + (``TimeoutError``) — no classification, the best-effort doctrine.""" try: with (self.logs_dir / f"{task_id}.log").open("rb") as fh: fh.seek(0, 2) # SEEK_END @@ -765,9 +778,15 @@ def _env_fault_evidence(self, task_id: str) -> str | None: return None text = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace").replace("\r", "\n")) match_line: str | None = None - for line in text.split("\n"): - if any(pat.search(line) for pat in self._env_fault_patterns): - match_line = line # last match wins + try: + for line in text.split("\n"): + if any( + pat.search(line, timeout=ENV_FAULT_MATCH_TIMEOUT_S) + for pat in self._env_fault_patterns + ): + match_line = line # last match wins + except TimeoutError: + return None # runaway pattern → decline to classify (best-effort, like OSError) if match_line is None: return None return match_line.strip()[:ENV_FAULT_EVIDENCE_MAX] diff --git a/src/bmad_loop/adapters/profile.py b/src/bmad_loop/adapters/profile.py index 61f24988..ec9b1a73 100644 --- a/src/bmad_loop/adapters/profile.py +++ b/src/bmad_loop/adapters/profile.py @@ -13,12 +13,13 @@ from __future__ import annotations -import re import tomllib from dataclasses import dataclass, field from importlib import resources from pathlib import Path +import regex + from ..platform_util import has_parent_ref, is_absolute_path USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"} @@ -120,6 +121,14 @@ def _parse_profile(doc: dict, source: str) -> CLIProfile: def fail(msg: str) -> ProfileError: return ProfileError(f"profile {source}: {msg}") + def str_list(key: str) -> tuple[str, ...]: + # TOML arrays parse as list; reject a bare string (which would iterate to + # per-character entries) or a scalar (a raw TypeError) with a friendly error. + raw = doc.get(key, []) + if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw): + raise fail(f"{key} must be a list of strings") + return tuple(raw) + name = str(doc.get("name", "")).strip() binary = str(doc.get("binary", "")).strip() if not name or not binary: @@ -169,16 +178,16 @@ def fail(msg: str) -> ProfileError: if not skill_tree or is_absolute_path(skill_tree) or has_parent_ref(skill_tree): raise fail("skill_tree must be a project-relative path") - seed_files = tuple(str(s) for s in doc.get("seed_files", ())) + seed_files = str_list("seed_files") for seed in seed_files: if not seed or is_absolute_path(seed) or has_parent_ref(seed): raise fail(f"seed_files entries must be project-relative paths: got {seed!r}") - env_fault_patterns = tuple(str(p) for p in doc.get("env_fault_patterns", ())) + env_fault_patterns = str_list("env_fault_patterns") for pattern in env_fault_patterns: try: - re.compile(pattern) - except re.error as e: + regex.compile(pattern) # same engine the adapter matches with (timeout-guarded) + except regex.error as e: raise fail(f"env_fault_patterns entry is not a valid regex: {pattern!r} ({e})") from e return CLIProfile( @@ -187,8 +196,8 @@ def fail(msg: str) -> ProfileError: hooks=HookSpec(dialect=dialect, config_path=config_path, events=events), skill_tree=skill_tree, prompt_template=str(doc.get("prompt_template", "{prompt}")), - launch_args=tuple(str(a) for a in doc.get("launch_args", ())), - bypass_args=tuple(str(a) for a in doc.get("bypass_args", ())), + launch_args=str_list("launch_args"), + bypass_args=str_list("bypass_args"), model_flag=str(doc.get("model_flag", "--model")), env={str(k): str(v) for k, v in doc.get("env", {}).items()}, usage_parser=usage_parser, diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index e0f56aac..5f93defd 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -17,6 +17,7 @@ from pathlib import Path import pytest +import regex from bmad_loop.adapters import generic, tmux_base from bmad_loop.adapters.base import SessionHandle, SessionResult, SessionSpec @@ -2560,6 +2561,57 @@ def test_run_reconcile_upgrade_is_not_reclassified(tmp_path): assert result.env_fault_evidence is None +class _StartSessionMux: + """Minimal mux to drive the real start_session: new_window returns a stable id + and pipe_pane records the log path it was handed (it writes nothing itself).""" + + def __init__(self): + self.piped: list[tuple[str, Path]] = [] + + def new_window(self, session_name, window_name, cwd, env, cmd): + return "@1" + + def pipe_pane(self, window_id, log_file): + self.piped.append((window_id, Path(log_file))) + + +def test_start_session_resets_reused_task_log(tmp_path): + """A re-armed run reuses task_ids and both mux backends APPEND to + logs/.log, so a prior cycle's transport-failure line would linger in the + 64 KiB tail and mis-flag a later unrelated timeout. start_session drops the stale + tee before re-piping (mirroring the result.json unlink), so the reused path holds + only the current session's output.""" + mux = _StartSessionMux() + adapter = make_adapter(tmp_path, mux=mux) + adapter._ensure_session = lambda cwd: None # skip the tmux server plumbing + task_id = _ENV_FAULT_TASK + _write_task_log(adapter, b"API Error: Unable to connect (ECONNREFUSED)\n", task_id=task_id) + log_path = adapter.logs_dir / f"{task_id}.log" + assert log_path.exists() # the prior cycle's tee is present... + + adapter.start_session(make_spec(tmp_path, task_id=task_id)) + + assert not log_path.exists() # ...and start_session unlinked it before re-piping + assert mux.piped == [("@1", log_path)] # the fresh tee attaches to the same path + # a re-driven session that times out with no NEW matching output is not misclassified + assert _classify(adapter, "timeout", task_id=task_id).env_fault is False + + +def test_classify_env_fault_bounds_pathological_pattern(tmp_path, monkeypatch): + """A pathological operator regex can't hang run() teardown: each match is bounded + by ENV_FAULT_MATCH_TIMEOUT_S, and exceeding it declines to classify (best-effort, + like an unreadable log) rather than backtracking forever on a long tail line.""" + adapter = make_adapter(tmp_path) + adapter._env_fault_patterns = (regex.compile(r"(a+)+$"),) # catastrophic backtracker + monkeypatch.setattr(generic, "ENV_FAULT_MATCH_TIMEOUT_S", 0.1) + _write_task_log(adapter, b"a" * 1000 + b"!\n") # long non-matching line -> deep backtrack + start = time.monotonic() + result = _classify(adapter, "timeout") + assert time.monotonic() - start < 5 # bounded; did not hang on the runaway match + assert result.env_fault is False and result.env_fault_evidence is None + assert _lifecycle_lines(adapter, _ENV_FAULT_TASK) == [] + + def test_wait_for_completion_tolerates_transient_liveness_probe_failure(tmp_path, monkeypatch): """A transient transport hang (the liveness probe raising MultiplexerError, e.g. a 30s tmux hang) must never be read as a dead window -> crash. The tick is diff --git a/tests/test_profile.py b/tests/test_profile.py index c2187c56..f3deeb80 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -239,6 +239,33 @@ def test_user_profile_overlay(tmp_path): ), "env_fault_patterns", ), + # list fields must be a TOML array of strings: a bare string would iterate to + # per-character entries (regexes, in env_fault_patterns' case) and a scalar + # would leak a raw TypeError — both are rejected with a friendly ProfileError. + ( + MINIMAL_PROFILE.replace("[hooks]", 'env_fault_patterns = "API"\n[hooks]'), + "env_fault_patterns must be a list of strings", + ), + ( + MINIMAL_PROFILE.replace("[hooks]", "env_fault_patterns = 5\n[hooks]"), + "env_fault_patterns must be a list of strings", + ), + ( + MINIMAL_PROFILE.replace("[hooks]", "env_fault_patterns = [1]\n[hooks]"), + "env_fault_patterns must be a list of strings", + ), + ( + MINIMAL_PROFILE.replace("[hooks]", "launch_args = [1]\n[hooks]"), + "launch_args must be a list of strings", + ), + ( + MINIMAL_PROFILE.replace("[hooks]", "seed_files = 3\n[hooks]"), + "seed_files must be a list of strings", + ), + ( + MINIMAL_PROFILE.replace('bypass_args = ["--yes"]', 'bypass_args = "x"'), + "bypass_args must be a list of strings", + ), ( MINIMAL_PROFILE.replace("[hooks]", "usage_grace_s = -1\n[hooks]"), "usage_grace_s", diff --git a/uv.lock b/uv.lock index 6c4f5d05..b1375c50 100644 --- a/uv.lock +++ b/uv.lock @@ -22,6 +22,7 @@ source = { editable = "." } dependencies = [ { name = "psutil", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, + { name = "regex" }, ] [package.optional-dependencies] @@ -54,6 +55,7 @@ requires-dist = [ { name = "psutil", marker = "extra == 'non-linux'", specifier = ">=7.2.2" }, { name = "pyte", marker = "extra == 'tui'", specifier = ">=0.8.2" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "regex", specifier = ">=2024.11.6" }, { name = "textual", marker = "extra == 'tui'", specifier = ">=8.0,<9" }, { name = "tomlkit", marker = "extra == 'tui'", specifier = ">=0.13" }, ] @@ -387,6 +389,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "rich" version = "15.0.0" From c66428cc08580156ed8d0ee4d8a01d709ff01583 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 22 Jul 2026 22:30:46 -0700 Subject: [PATCH 4/4] refactor(escalation): share env-fault pause-reason formatter (#194) CodeRabbit nitpick (PR #272): the pause/escalation reason template 'environment fault: {role} session {status} ({detail})' was hand-duplicated at six sites across escalation/engine/sweep. Extract env_fault_pause_reason() next to env_fault_detail and route all six through it; output strings stay byte-identical (existing assertions unchanged). engine/sweep now import the formatter instead of env_fault_detail (their only prior caller). --- src/bmad_loop/engine.py | 9 +++++---- src/bmad_loop/escalation.py | 12 ++++++++++-- src/bmad_loop/sweep.py | 8 +++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index eaf24a07..134af02e 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -27,7 +27,7 @@ critical_escalations, decide_dev, decide_review_session, - env_fault_detail, + env_fault_pause_reason, preference_escalations, ) from .journal import Journal, save_state @@ -1048,8 +1048,9 @@ def _run_workflows(self, stage: str, task: StoryTask, seq: int) -> bool: # session will classify and pause if the outage persists. self._escalate( task, - f"environment fault: blocking workflow {wf.name!r} ({lp.name}) " - f"session {result.status} ({env_fault_detail(result)})", + env_fault_pause_reason( + f"blocking workflow {wf.name!r} ({lp.name})", result + ), ) self._defer( task, @@ -2246,7 +2247,7 @@ def _fix_phase(self, task: StoryTask, reason: str) -> bool: # by the retryable check just below (its own escalate path). self._escalate( task, - f"environment fault: fix session {result.status} ({env_fault_detail(result)})", + env_fault_pause_reason("fix", result), ) if outcome is not None and not outcome.ok and not outcome.retryable: # escalate-grade failure (environment fault): another repair diff --git a/src/bmad_loop/escalation.py b/src/bmad_loop/escalation.py index b10a4633..998e06f1 100644 --- a/src/bmad_loop/escalation.py +++ b/src/bmad_loop/escalation.py @@ -61,6 +61,14 @@ def env_fault_detail(result: SessionResult) -> str: return result.env_fault_evidence or "transport-failure pattern in session log" +def env_fault_pause_reason(role: str, result: SessionResult) -> str: + """The uniform pause/escalation reason for a transport-failed session (#194). + `role` is the descriptor placed before "session" — e.g. "dev", "review", + "fix", "migration", "triage", or a richer "blocking workflow 'x' (y)". Keeps + the wording identical across escalation/engine/sweep (see env_fault_detail).""" + return f"environment fault: {role} session {result.status} ({env_fault_detail(result)})" + + def decide_dev( task: StoryTask, result: SessionResult, @@ -85,7 +93,7 @@ def decide_dev( # result_json=None, so it found nothing). return Decision( Action.PAUSE, - f"environment fault: dev session {result.status} ({env_fault_detail(result)})", + env_fault_pause_reason("dev", result), ) reason = f"dev session {result.status}" if budget_left: @@ -116,7 +124,7 @@ def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy # cycle for a session that never reached the API (see decide_dev). return Decision( Action.PAUSE, - f"environment fault: review session {result.status} ({env_fault_detail(result)})", + env_fault_pause_reason("review", result), ) reason = f"review session {result.status}" if budget_left: diff --git a/src/bmad_loop/sweep.py b/src/bmad_loop/sweep.py index 987735a2..4a25d76d 100644 --- a/src/bmad_loop/sweep.py +++ b/src/bmad_loop/sweep.py @@ -20,7 +20,7 @@ from . import deferredwork, gates, verify from .engine import Engine -from .escalation import critical_escalations, env_fault_detail +from .escalation import critical_escalations, env_fault_pause_reason from .model import Phase, StoryTask from .platform_util import atomic_replace from .statemachine import advance @@ -765,8 +765,7 @@ def _ensure_migration(self, text: str) -> None: # resume also restores the ledger if the worktree is dirty. self._escalate( task, - f"environment fault: migration session {result.status} " - f"({env_fault_detail(result)})", + env_fault_pause_reason("migration", result), ) if not errors: advance(task, Phase.DONE) @@ -878,8 +877,7 @@ def _ensure_triage(self, open_now: set[str], cycle: int = 1) -> TriagePlan: # ESCALATED-resume above resets task.attempt to 0 (fresh budget). self._escalate( task, - f"environment fault: triage session {result.status} " - f"({env_fault_detail(result)})", + env_fault_pause_reason("triage", result), ) if plan is not None: advance(task, Phase.DONE)