From efc185745cf4d835f053131f4a2323b1a340d270 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 23:34:02 -0700 Subject: [PATCH 01/11] fix(tui): preview the chain pick a run makes after closing drained children The start-run preview of a parent initiative passed drained children over and never simulated the close `run` takes before every pick, so with A drained (all tickets closed, initiative open) and B after A it showed "no child can start (waiting)" while the run closes A and builds B. Which case held: readiness is exactly the prerequisites. The vendored v7 `tickets.py` `parent_view` sets `waiting_on` to the `after` children whose status is not `done`, and `next` lists a child `ready` when `waiting_on` is empty and its own status is not done or dropped; nothing else holds it. So the pick can be re-judged purely, without writing. - initiativechain: add `next_child_after_close(view, passes_over, closes)`, which reads each drained child (filtered by `closes`, when given) as `done`, drops those slugs from every `waiting_on`, re-derives `ready` for the changed children and returns `next_child` on that view; `ChildPick.closed` names the children it treated as closed. - tui: `_parent_board` picks through it and tags those children `closes first` (replacing `drained`); the dry-run hint stays. - cli: `run --dry-run` had the same false negative (it printed the chain's `waiting` end plus "which this preview does not simulate"). It now picks through the same helper (`_chain_pick(after_close=True)`), closing only a drained child whose fresh pick finds nothing, as `_close_drained_children` does (`work-left`); the predicted line names the closes. The real run's pick is unchanged: it reads the tree after its writes. - tests: helper unit rows, a drained-first preview and a genuinely blocked preview, the dry-run's drained and work-left rows; ablations performed. - tests/test_cli.py: reword ablation docstrings whose call moved into `_resolve_config_refusal` / `_resolve_pre_session_gates` (7107d132). Text only. - docs: tui-guide, tickets-guide, cli reference; CHANGELOG DW-574 entry. --- CHANGELOG.md | 3 +- docs/reference/cli.md | 2 +- docs/tickets-guide.md | 4 +- docs/tui-guide.md | 10 +-- src/bmad_loop/cli.py | 53 ++++++++++------ src/bmad_loop/initiativechain.py | 47 +++++++++++++- src/bmad_loop/tui/data.py | 22 +++---- src/bmad_loop/tui/screens/modals.py | 2 +- tests/test_cli.py | 95 ++++++++++++++++++----------- tests/test_initiativechain.py | 91 +++++++++++++++++++++++++-- tests/test_tui_data.py | 56 ++++++++++++++--- 11 files changed, 292 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f63057a..9175eb9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -224,7 +224,8 @@ breaking changes may land in a minor release. exit 1). Require worktree isolation without the auto retrospective; pause each child at its epic boundaries; close drained children (`status: done`) before every pick; spend one `--max-stories` budget across the chain. Show the chain in `run --dry-run` - and the TUI start-run preview, and a chained run's child on its tickets board (DW-574). + and the TUI start-run preview, both picking as if the drained children were closed, + and a chained run's child on its tickets board (DW-574). ### Removed diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0facf19c0..25f7bf19a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -166,7 +166,7 @@ bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] - Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. - `[stories] source = "tickets"` runs the active initiative's ticket tree. Before anything is created, `run` executes the `tickets.*` preflight (capability probe included) and refuses with exit 1 on a problem, including a ticket store it cannot classify or drive (`tickets.store-topology` and the refusals below). The classified topology is recorded on the run, and `resume`, `resolve` and the TUI refuse a store that changed since; `branch_per = "run"` prints the `tickets.branch-per-run` warning. Each picked ticket is dispatched as `ticket ` and, once verified at `built`, marked `done` under `[tickets] mark_done = "auto"` (left at `built` under `"never"`): inside its unit's commit for a store tracked in the code checkout (T1), after the code lands for a shared store, with a plan-only commit in a store that is its own repository (T3/T4). Under worktree isolation a unit's branch is `bmad-loop///`. - On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes. Before each pick, its first included, it marks every drained child's initiative `done` and prints `... closed (drained)`, or `... leaves open (: ...)` while a run holds the child, its first pick still finds work, or its store is refused. It prints `initiative chain : -> ` per switch, or `... ends ()` with each waiting child's unmet prerequisites and their status, and stops at a run that does not finish, including each child's pause at `epic-boundary` after every epic it finishes; `resume` or `resolve` driving that run to finished continues the chain. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run; `--max-stories` is one budget for the whole chain, and the chain ends `max-stories` once it is spent. -- `--dry-run` in tickets mode reads the active initiative through its `tickets.py` and marks nothing. It prints the startable tickets in build order (initiative epic table, then entry order), each with its badges, its `ticket ` dispatch (`thorough` when `[tickets] thorough_review` selects it, `Halt after planning.` on an unrefined plan-checkpoint ticket) and its env, then the first pause a run would reach (`predicted:`), the `next` group counts and the tree's problems. A `tickets.py` failure exits 1. On a parent initiative it lists the children in build order (status, closed and total tickets, unmet prerequisites, and a tag: `next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`), prints the `next` child's block, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the chain's end line when no child can start. It closes no drained child; the predicted line names those the run would close first. `--max-stories` caps the first child's block only, `--epic` and `--story` exit 1, and the banner warns when the preflight would refuse the parent. +- `--dry-run` in tickets mode reads the active initiative through its `tickets.py` and marks nothing. It prints the startable tickets in build order (initiative epic table, then entry order), each with its badges, its `ticket ` dispatch (`thorough` when `[tickets] thorough_review` selects it, `Halt after planning.` on an unrefined plan-checkpoint ticket) and its env, then the first pause a run would reach (`predicted:`), the `next` group counts and the tree's problems. A `tickets.py` failure exits 1. On a parent initiative it lists the children in build order (status, closed and total tickets, unmet prerequisites, and a tag: `next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`), prints the `next` child's block, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the chain's end line when no child can start. It closes no drained child but picks as if the run had, and the predicted line names them. `--max-stories` caps the first child's block only, `--epic` and `--story` exit 1, and the banner warns when the preflight would refuse the parent. - Prints `run starting (attach: bmad-loop attach)`, then the run summary; exits 0 once the engine has run. ### `sweep` diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index 5bdd4d84e..bb3e8e4eb 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -57,9 +57,9 @@ Each child's run pauses at `epic-boundary` after every epic it finishes, as a on `--epic` and `--story` select within one initiative, so `run` on a parent, and its dry run, refuse them with exit 1. `--run-id` names the first child's run; each later child's run gets its own id. `--max-stories` caps the whole chain: each child's run starts with what the runs before it left, counting every ticket they dispatched. -`bmad-loop run --dry-run` on a parent writes nothing. It lists the children in build order, each with its status, closed and total tickets, the prerequisites it waits on and a tag (`next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`). It then prints the `next` child's dry run, read through that child's folder, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the end line `run` prints when no child can start. It closes no drained child; the predicted line names those the run closes before its first pick. Its `--max-stories` caps the first child's listing only, and its banner warns when the preflight would refuse the parent. +`bmad-loop run --dry-run` on a parent writes nothing. It lists the children in build order, each with its status, closed and total tickets, the prerequisites it waits on and a tag (`next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`). It then prints the `next` child's dry run, read through that child's folder, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the end line `run` prints when no child can start. It closes no drained child, but picks as the tree would read once the run has closed them (a drained child with an epic still to close or break down stays open, as `run` leaves it); the predicted line names those the run closes before its first pick. Its `--max-stories` caps the first child's listing only, and its banner warns when the preflight would refuse the parent. -The TUI's start-run preview lists the same children and shows the board of the first ready child that has a folder and is not drained; `run --dry-run` names the exact first pick. A chained child run's [tickets board](tui-guide.md#tickets-board-middle-tickets-mode) shows that child's tickets. +The TUI's start-run preview lists the same children, tags each drained child `closes first`, and shows the board of the first ready child once those are closed; `run --dry-run` names the exact first pick. A chained child run's [tickets board](tui-guide.md#tickets-board-middle-tickets-mode) shows that child's tickets. ## Store layouts diff --git a/docs/tui-guide.md b/docs/tui-guide.md index cbc6590e0..915000dfc 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -401,11 +401,11 @@ To return to the default proportions, delete the `[tui]` size keys from ticket counts and the refs that pause on hitl or a checkpoint. On a parent initiative it lists the children in build order, each with its status, closed and total tickets, what it waits on and a tag (`first ready`, `ready`, - `waiting`, `no folder`, `drained`, `done` or `dropped`), then the counts and - checkpoint refs of the `first ready` child (the first ready child that has a - folder and is not drained), or why no child can start. The run's pick also - passes over a ready child with nothing to dispatch, so `run --dry-run` names - the child a run builds first. A + `waiting`, `no folder`, `closes first`, `done` or `dropped`), then the counts + and checkpoint refs of the `first ready` child (the first ready child with a + folder once a run closes the drained ones), or why no child can start. A run + passes over a ready child with nothing to dispatch and keeps open a drained + one with an epic to close, so `run --dry-run` names the child it builds first. A tickets pick launches only while `[stories] source = "tickets"`. Under that policy a stories pick still launches (its `--spec` overrides the policy source); a sprint pick does not. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index b07ccdb52..75ab0d5a3 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4462,25 +4462,38 @@ def _chain_pick( paths: bmadconfig.ProjectPaths, parent: str, view: ticketmodel.ParentView | None = None, + *, + after_close: bool = False, ) -> initiativechain.ChildPick: """The next child of the parent initiative ``parent`` (``paths`` are the parent's): :func:`initiativechain.next_child` over ``next `` (``view`` when the caller already read it), passing over as ``drained`` a ready child whose fresh pick (:func:`_fresh_pick`) finds nothing to dispatch. Reads only. - Raises :class:`tickettree.TicketsError` on a tree fault and ValueError on a - child slug that is no folder name.""" + ``after_close`` (the dry run) re-judges the pick as if + :func:`_close_drained_children` had closed each drained child whose fresh pick + finds nothing (:func:`initiativechain.next_child_after_close`). Raises + :class:`tickettree.TicketsError` on a tree fault and ValueError on a child + slug that is no folder name.""" folder = paths.initiative_folder assert folder is not None # _chain_parent if view is None: view = _chain_ticket_tree(project, parent).parent_next(folder) + empty: dict[str, bool] = {} + + def nothing_left(child: ticketmodel.ChildInitiative) -> bool: + if child.slug not in empty: + tree = _chain_ticket_tree( + project, parent, paths.with_initiative(child.slug).initiative_folder + ) + decision = _fresh_pick(tree.next(), tree.status()) + empty[child.slug] = isinstance(decision, tickets_source.Skip) + return empty[child.slug] def drained(child: ticketmodel.ChildInitiative) -> str | None: - tree = _chain_ticket_tree( - project, parent, paths.with_initiative(child.slug).initiative_folder - ) - decision = _fresh_pick(tree.next(), tree.status()) - return initiativechain.DRAINED if isinstance(decision, tickets_source.Skip) else None + return initiativechain.DRAINED if nothing_left(child) else None + if after_close: + return initiativechain.next_child_after_close(view, drained, nothing_left) return initiativechain.next_child(view, drained) @@ -5224,13 +5237,13 @@ def _dry_run_tickets( def _chain_child_tag(child: ticketmodel.ChildInitiative, pick: initiativechain.ChildPick) -> str: """The dry run's tag for one child of a parent initiative: ``next`` (the child the run builds first), ``done``/``dropped``, ``drained (the run closes it - first)`` (``initiativechain.drained``), ``waiting``, ``no folder``, or - ``ready`` (with why the pick passed it over, when it did).""" + first)`` (in ``pick.closed``), ``waiting``, ``no folder``, or ``ready`` + (with why the pick passed it over, when it did).""" if pick.child is not None and child.slug == pick.child.slug: return "next" if child.status in ("done", "dropped"): return child.status - if initiativechain.drained(child): + if child.slug in pick.closed: return "drained (the run closes it first)" if not child.ready: return "waiting" @@ -5251,9 +5264,11 @@ def _dry_run_chain( chain with stands in for the block. Never writes: the run's first step, closing drained children - (:func:`_close_drained_children`), is not taken or simulated, so the pick is - the one the tree reads now, and the predicted line names the children the run - closes before its first pick. ``--max-stories`` caps the first child's + (:func:`_close_drained_children`), is not taken but re-judged — the pick is + the one the tree would read once each drained child whose fresh pick finds + nothing is closed (``_chain_pick(after_close=True)``), and the predicted line + names those children. A close the run skips for a live or unfinished run or + a refused store is not predicted. ``--max-stories`` caps the first child's listing; the cap's spend across later children is not predicted.""" if args.epic is not None or args.story: print(_chain_selector_refusal(parent), file=sys.stderr) @@ -5263,7 +5278,7 @@ def _dry_run_chain( assert folder is not None # _chain_parent try: view = _chain_ticket_tree(project, parent).parent_next(folder) - pick = _chain_pick(project, paths, parent, view) + pick = _chain_pick(project, paths, parent, view, after_close=True) child_paths = None if pick.child is None else paths.with_initiative(pick.child.slug) except tickettree.TicketsError as e: print(f"tickets mode: tickets.py {e.kind}: {e}", file=sys.stderr) @@ -5281,15 +5296,15 @@ def _dry_run_chain( closed, total = initiativechain.closed_tickets(child), child.counts.get("total", 0) status += f"; {closed} of {total} tickets closed" waits = ", ".join( - f"{p} ({initiativechain.prerequisite_status(view, p)})" for p in child.waiting_on + f"{p} ({initiativechain.prerequisite_status(view, p)})" + for p in child.waiting_on + if p not in pick.closed ) line = f" {position}. {child.slug} ({status}) [{_chain_child_tag(child, pick)}]" print(line + (f" waits on: {waits}" if waits else "")) - closing = [c.slug for c in view.initiatives if initiativechain.drained(c)] closes = ( - f"; the run closes drained {', '.join(closing)} before its first pick, " - "which this preview does not simulate" - if closing + f"; the run closes drained {', '.join(pick.closed)} before its first pick" + if pick.closed else "" ) if pick.child is None or child_paths is None: diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py index b39814edc..02b966b52 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -22,6 +22,8 @@ :func:`drained` says whether a child is open with every ticket closed — the child whose initiative a chain marks ``done`` before it picks again. +:func:`next_child_after_close` re-judges :func:`next_child` as the view reads once +those closes are written, for a prediction that does not write them. When no child is taken the pick says why the chain ends: ``waiting`` when some child still waits on a prerequisite (a drained child whose initiative is not @@ -33,7 +35,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Final, Literal from .statemachine import TICKET_CLOSED_STATES @@ -81,13 +83,15 @@ class ChildPick: walked past before the pick (in build order); ``waiting`` the slugs of the children still waiting on a prerequisite, whether or not one was taken, and ``waiting_on`` one row per prerequisite each of them waits on, in the same - order.""" + order. ``closed`` names the drained children :func:`next_child_after_close` + judged as closed, in build order (empty from :func:`next_child`).""" child: ChildInitiative | None passed_over: tuple[PassedOver, ...] waiting: tuple[str, ...] end: ChainEnd | None waiting_on: tuple[WaitingOn, ...] = () + closed: tuple[str, ...] = () def closed_tickets(child: ChildInitiative) -> int: @@ -142,3 +146,42 @@ def next_child( passed.append(PassedOver(child.slug, why)) end: ChainEnd = "waiting" if waiting else "passed-over" if passed else "all-done" return ChildPick(None, tuple(passed), waiting, end, waiting_on) + + +def next_child_after_close( + view: ParentView, + passes_over: Callable[[ChildInitiative], str | None] | None = None, + closes: Callable[[ChildInitiative], bool] | None = None, +) -> ChildPick: + """:func:`next_child` on ``view`` as it reads once a chain has marked its + drained children ``done``, as it does before every pick — for a prediction + that does not write them. ``closes``, when given, is asked about each + drained child and says whether the chain closes it (every drained child, + when not given). + + ``tickets.py next`` judges a child ready from its prerequisites alone: no + ``after`` child short of ``done``, and not done or dropped itself. So each + closed child reads ``done`` and not ready, every ``waiting_on`` loses the + closed children, and a child whose ``waiting_on`` changed is ready when it + empties and the child is not done or dropped; every other child keeps the + answer ``next`` gave. The pick's ``closed`` names the closed children.""" + closing = tuple( + c.slug for c in view.initiatives if drained(c) and (closes is None or closes(c)) + ) + if not closing: + return next_child(view, passes_over) + shut = frozenset(closing) + children: list[ChildInitiative] = [] + for child in view.initiatives: + if child.slug in shut: + child = replace(child, status="done", ready=False) + elif shut.intersection(child.waiting_on): + waits = tuple(p for p in child.waiting_on if p not in shut) + child = replace( + child, waiting_on=waits, ready=not waits and child.status not in _CLOSED + ) + children.append(child) + rejudged = replace( + view, initiatives=tuple(children), ready=tuple(c.slug for c in children if c.ready) + ) + return replace(next_child(rejudged, passes_over), closed=closing) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index e8db9266b..d60f3926c 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -1342,14 +1342,14 @@ def project_tickets_overview(project: Path) -> TicketsBoard: def _chain_tag(child: ticketmodel.ChildInitiative, pick: initiativechain.ChildPick) -> str: """The start-run preview's tag for one child of a parent initiative: ``first ready`` (the child :func:`_parent_board` shows the board of), - ``done``/``dropped``, ``drained`` (:func:`~bmad_loop.initiativechain.drained`), - ``waiting``, ``no folder``, or ``ready``.""" + ``done``/``dropped``, ``closes first`` (drained, so a run marks it done + before its first pick), ``waiting``, ``no folder``, or ``ready``.""" if pick.child is not None and child.slug == pick.child.slug: return "first ready" if child.status in ("done", "dropped"): return child.status - if initiativechain.drained(child): - return "drained" + if child.slug in pick.closed: + return "closes first" if not child.ready: return "waiting" if child.folder is None: @@ -1360,13 +1360,13 @@ def _chain_tag(child: ticketmodel.ChildInitiative, pick: initiativechain.ChildPi def _parent_board(project: Path, paths: bmadconfig.ProjectPaths) -> TicketsBoard: """The start-run preview of the parent active initiative ``paths`` name: its children (``tickets.py next ``, pinned to the parent) and the board of - the first ready child that has a folder and is not drained - (:func:`~bmad_loop.initiativechain.next_child` with - :func:`~bmad_loop.initiativechain.drained` as the probe), read through that - child's folder with the tree pinned to the parent. + the first ready child once a run has closed every drained child, as it does + before its first pick (:func:`~bmad_loop.initiativechain.next_child_after_close`), + read through that child's folder with the tree pinned to the parent. An approximation of the chain ``bmad-loop run`` builds: its pick also passes - over a ready child whose fresh pick dispatches nothing, which takes a + over a ready child whose fresh pick dispatches nothing, and it leaves open a + drained child whose fresh pick still finds work, which takes a ``next``/``status`` read per child; ``run --dry-run`` prints that prediction.""" parent = paths.active_initiative folder = paths.initiative_folder @@ -1375,9 +1375,7 @@ def _parent_board(project: Path, paths: bmadconfig.ProjectPaths) -> TicketsBoard view = _parent_view(project, parent, folder) except tickettree.TicketsError as e: return TicketsBoard((), f"tickets.py {e.kind}: {e}", "", parent=parent) - pick = initiativechain.next_child( - view, lambda c: initiativechain.DRAINED if initiativechain.drained(c) else None - ) + pick = initiativechain.next_child_after_close(view) children = tuple( ChainChild( slug=c.slug, diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index be7c23be5..6959dbd50 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -228,7 +228,7 @@ def _chain_preview(board: data.TicketsBoard) -> Text: text.append(f"\nfirst ready: {board.initiative} · {len(board.rows)} tickets", style="bold") _append_board(text, board) text.append( - "\n first ready skips drained children only — " + "\n first ready assumes a run closes every drained child — " "run --dry-run prints the exact chain prediction", style="dim", ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 886c74f69..a79e6b7d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6225,8 +6225,9 @@ def test_resolve_early_dispatch_refuses_a_changed_ticket_store_first( """A tickets run's `--adopt-branch` / `--reverify` each persist a transition before the resume that refuses a changed ticket store, so the store is judged ahead of both: rc 1 with the store's refusal, the state untouched. Ablation - (performed): drop the early `_reject_store_topology` gate in `cmd_resolve` and - both rows FAIL (adopt persists COMMITTING; reverify refuses for another reason).""" + (performed): drop the early `_reject_store_topology` gate in + `_resolve_pre_session_gates` and both rows FAIL (adopt persists COMMITTING; reverify refuses for another reason). + """ from bmad_loop import ticketstore from bmad_loop.journal import load_state, save_state @@ -6559,9 +6560,9 @@ def test_resolve_refuses_worktree_isolation_before_it_mutates_anything( the fix — writing nothing is. `_resolve_run_with_a_moved_code_root` stubs `_resume_paused_run` to 0, so the rc can only come from the hoisted check. - Ablation: move the `_reject_isolation_conflict(...)` call below the - `runs.restamp_code_root(...)` line and the persisted-root assertion reddens; delete - it outright and the rc assertion reddens too. + Ablation: move the post-confirm `_resolve_config_refusal(...)` call in `cmd_resolve` + below the `runs.restamp_code_root(...)` line and the persisted-root assertion + reddens; delete it outright and the rc assertion reddens too. """ from bmad_loop import runs from bmad_loop.journal import load_state @@ -6593,8 +6594,9 @@ def test_resolve_refuses_an_untrusted_plugin_before_it_rearms(project, monkeypat was already spent. A re-arm-only resolve launches nothing and is not judged. `_resume_paused_run` is stubbed to 0, so the rc can only come from the hoist. - Ablation: delete the `_reject_plugin_trust` call in `cmd_resolve` and the - `--resume` leg re-arms.""" + Ablation: delete both `_reject_plugin_trust` calls — the pre-session one in + `_resolve_pre_session_gates` and the post-confirm one in `cmd_resolve` — and the + `--resume` leg re-arms; delete either alone and the other refuses first.""" from bmad_loop.journal import load_state from bmad_loop.model import Phase @@ -6669,8 +6671,8 @@ def test_resolve_refuses_an_untrusted_plugin_before_any_resolve_action( only then meeting the resume's refusal. One preflight now precedes all three. Each action is failed rather than stubbed, so the rc can only come from the - early refusal. Ablation: delete the `_reject_plugin_trust` call ahead of the - reverify dispatch in `cmd_resolve` and every row reddens on its action.""" + early refusal. Ablation: delete the `_reject_plugin_trust` call in + `_resolve_pre_session_gates` and every row reddens on its action.""" from bmad_loop import resolve from bmad_loop.journal import load_state from bmad_loop.model import Phase @@ -6732,9 +6734,9 @@ def test_resolve_refuses_worktree_isolation_before_the_interactive_session( re-reads the config after a conversation of unbounded length, and it is the only one the `--no-interactive` path reaches. - Ablation: delete the `_reject_isolation_conflict` call from the `else:` branch of - `pre_session_paths` and this reddens on `_make_adapters` while the sibling row above - stays green. + Ablation: delete the pre-session `_resolve_config_refusal(...)` call (the `else:` + branch of the `pre_session_paths` read in `cmd_resolve`) and this reddens on + `_make_adapters` while the sibling row above stays green. """ from bmad_loop import resolve, runs from bmad_loop.journal import load_state @@ -11686,8 +11688,8 @@ def _assert_escalation_intact(run_dir, before): @pytest.mark.parametrize("interactive", [True, False], ids=["interactive", "noninteractive"]) def test_resolve_refuses_sweep_run_on_undecodable_ledger(project, monkeypatch, capsys, interactive): - """The DW-229 row. Ablation: delete the probe block from `cmd_resolve` and the - re-arm recorder fills instead of returning the ledger refusal. The interactive + """The DW-229 row. Ablation: delete the `unreadable_sweep_ledger` probe block from + `_resolve_pre_session_gates` and the re-arm recorder fills instead of returning the ledger refusal. The interactive case also records a session. Persisted-state assertions complement the call recorders; the re-arm stub itself does not mutate state.""" from bmad_loop.journal import load_state @@ -11768,8 +11770,8 @@ def test_resolve_refuses_a_sweep_on_a_switched_initiative_before_any_write( resume's own refusal. The judgment itself is pinned in tests/test_runsetup.py; here it is stubbed to a refusal. - ABLATION (performed): delete the entry gate in `cmd_resolve` and the - interactive, adopt and reverify rows reach their side-effect recorders (the + ABLATION (performed): delete the `sweep_initiative_conflict` entry gate in + `_resolve_pre_session_gates` and the interactive, adopt and reverify rows reach their side-effect recorders (the non-interactive re-arm rows fall to the post-confirm check instead).""" from bmad_loop import resolve as resolve_mod from bmad_loop import runsetup @@ -15962,15 +15964,16 @@ def test_dry_run_tickets_parent_previews_the_chain(project, monkeypatch, capsys) ) -def test_dry_run_tickets_parent_tags_a_drained_child_and_closes_nothing( +def test_dry_run_tickets_parent_predicts_the_pick_after_closing_a_drained_child( project, monkeypatch, capsys ): - """AUTH drained but its initiative not yet `done`: the real run would close it - and then build BILLING. The dry run never writes, so it tags AUTH `drained (the - run closes it first)`, picks as the unclosed tree reads — BILLING still waits, - so the chain ends `waiting` with no tickets block — and says the run closes AUTH - before its first pick. Ablation (performed): drop the `drained` tag and AUTH - reads `ready, passed over (drained)` — this FAILS.""" + """AUTH drained but its initiative not yet `done`: the real run closes it and + then builds BILLING. The dry run writes nothing, but re-judges the pick as the + tree reads once AUTH is closed: AUTH is tagged `drained (the run closes it + first)`, BILLING is `next` with no wait left, its tickets block is printed, and + the predicted line says the run closes AUTH first. Ablation (performed): pick + with `after_close=False` in `_dry_run_chain` and BILLING still waits, the chain + ends `waiting` — this FAILS.""" _chain_project(project, monkeypatch, capsys, []) _drain_child(project.project, CHAIN_AUTH) @@ -15978,23 +15981,43 @@ def test_dry_run_tickets_parent_tags_a_drained_child_and_closes_nothing( assert rc == 0, err assert written == "" - assert ( - f" 1. {CHAIN_AUTH} (open; 4 of 4 tickets closed) [drained (the run closes it first)]\n" - in out - ) - assert f" 2. {CHAIN_BILLING} (open; 0 of 4 tickets closed) [waiting]" in out - assert "build order (" not in out - end = ( - f"initiative chain {CHAIN_PARENT}: ends (waiting) — waiting: {CHAIN_BILLING} on " - f"{CHAIN_AUTH} (open) (passed over: {CHAIN_AUTH} (drained))" - ) - assert out.splitlines()[-1] == ( - f"predicted: {end}; the run closes drained {CHAIN_AUTH} before its first pick, " - "which this preview does not simulate" + lines = out.splitlines() + assert lines[1:3] == [ + f" 1. {CHAIN_AUTH} (open; 4 of 4 tickets closed) [drained (the run closes it first)]", + f" 2. {CHAIN_BILLING} (open; 0 of 4 tickets closed) [next]", + ] + assert f"tickets mode: initiative {CHAIN_BILLING} — 1 ready of 4 tickets" in out + assert lines[-1] == ( + f"predicted: chain starts with {CHAIN_BILLING}; later children are chosen as each " + f"finishes; the run closes drained {CHAIN_AUTH} before its first pick" ) assert not _initiative_status(project, CHAIN_AUTH) +def test_dry_run_tickets_parent_keeps_a_drained_child_with_work_left_open( + project, monkeypatch, capsys +): + """AUTH's tickets are all closed but its epics are not: the run leaves it open + (`work-left`) and builds it to close them, so the dry run counts no close — + AUTH is `next` and BILLING still waits on it.""" + _chain_project(project, monkeypatch, capsys, []) + _drain_child(project.project, CHAIN_AUTH, epics=False) + + rc, out, err, written = _chain_dry_run(project, monkeypatch, capsys) + + assert rc == 0, err + assert written == "" + lines = out.splitlines() + assert lines[1:3] == [ + f" 1. {CHAIN_AUTH} (open; 4 of 4 tickets closed) [next]", + f" 2. {CHAIN_BILLING} (open; 0 of 4 tickets closed) [waiting] " + f"waits on: {CHAIN_AUTH} (open)", + ] + assert lines[-1] == ( + f"predicted: chain starts with {CHAIN_AUTH}; later children are chosen as each finishes" + ) + + @pytest.mark.parametrize("selector", [["--epic", "epic-cart"], ["--story", "1.1"]]) def test_dry_run_tickets_parent_refuses_epic_and_story(project, monkeypatch, capsys, selector): """`--epic`/`--story` on a parent: the dry run refuses with `run`'s own text and diff --git a/tests/test_initiativechain.py b/tests/test_initiativechain.py index e5140f3cc..15231ac0a 100644 --- a/tests/test_initiativechain.py +++ b/tests/test_initiativechain.py @@ -1,9 +1,18 @@ """initiativechain.next_child: which ready child of a parent initiative a chain -builds next, and why the chain ends when none is taken.""" +builds next, and why the chain ends when none is taken; next_child_after_close: +the same pick once the chain has closed its drained children.""" from __future__ import annotations -from bmad_loop.initiativechain import NO_FOLDER, PassedOver, WaitingOn, next_child +from bmad_loop.initiativechain import ( + DRAINED, + NO_FOLDER, + PassedOver, + WaitingOn, + drained, + next_child, + next_child_after_close, +) from bmad_loop.ticketmodel import ChildInitiative, InitiativeEdge, ParentView @@ -15,9 +24,11 @@ def _child( status: str = "in-progress", after: tuple[str, ...] = (), waiting_on: tuple[str, ...] = (), + closed: int | None = None, ) -> ChildInitiative: """A child as `parse_parent_next` builds it; ``folder=""`` means "its slug", - and a folder-less child carries no status, as upstream prints it.""" + and a folder-less child carries no status, as upstream prints it. ``closed`` + gives it four tickets, that many of them done.""" return ChildInitiative( id=slug, slug=slug, @@ -25,7 +36,7 @@ def _child( after=tuple(InitiativeEdge(a, "") for a in after), folder=(slug if folder == "" else folder), status=(status if folder is not None else ""), - counts={}, + counts={} if closed is None else {"total": 4, "done": closed}, waiting_on=waiting_on, ready=ready, ) @@ -159,3 +170,75 @@ def test_waiting_children_name_each_prerequisite_with_its_status(): WaitingOn("reports", "billing", "in-progress"), WaitingOn("reports", "gone", "?"), ) + + +def _passes_drained(child: ChildInitiative) -> str | None: + return DRAINED if drained(child) else None + + +def test_after_close_takes_the_dependent_of_a_drained_child(): + """AUTH drained and open, BILLING waiting on it: the chain closes AUTH before + it picks, so the re-judged pick takes BILLING and names AUTH closed. + + Ablation (performed): return `next_child(view, passes_over)` without the + re-judge and BILLING still waits — this FAILS.""" + auth = _child("auth", ready=True, status="", closed=4) + billing = _child("billing", after=("auth",), waiting_on=("auth",), closed=0) + + pick = next_child_after_close(_view(auth, billing), _passes_drained) + + assert pick.child is not None and pick.child.slug == "billing" + assert pick.child.ready and pick.child.waiting_on == () + assert pick.closed == ("auth",) + assert (pick.passed_over, pick.waiting, pick.waiting_on, pick.end) == ((), (), (), None) + + +def test_after_close_keeps_a_child_waiting_on_an_undrained_prerequisite(): + """A genuinely blocked chain: BILLING waits on AUTH, open with tickets left + and itself waiting on a folder-less IDENTITY. Nothing is drained, nothing + closes, and no child can start.""" + identity = _child("identity", ready=True, folder=None) + auth = _child("auth", status="", after=("identity",), waiting_on=("identity",), closed=1) + billing = _child("billing", after=("auth",), waiting_on=("auth",), closed=0) + + pick = next_child_after_close(_view(identity, auth, billing), _passes_drained) + + assert pick.child is None and pick.end == "waiting" + assert pick.closed == () + assert pick.passed_over == (PassedOver("identity", NO_FOLDER),) + assert pick.waiting_on == ( + WaitingOn("auth", "identity", NO_FOLDER), + WaitingOn("billing", "auth", "open"), + ) + + +def test_after_close_drops_only_the_closed_prerequisites(): + """REPORTS waits on drained AUTH and on in-progress BILLING: closing AUTH + leaves it waiting on BILLING alone.""" + auth = _child("auth", ready=True, status="", closed=4) + billing = _child("billing", ready=True, closed=0) + reports = _child("reports", after=("auth", "billing"), waiting_on=("auth", "billing")) + + pick = next_child_after_close(_view(auth, billing, reports), _passes_drained) + + assert pick.child is billing + assert pick.waiting == ("reports",) + assert pick.waiting_on == (WaitingOn("reports", "billing", "in-progress"),) + + +def test_after_close_asks_closes_about_drained_children_only(): + """``closes`` is asked about each drained child; one it keeps open is judged + as ``next`` read it — here passed over as drained, its dependent waiting.""" + auth = _child("auth", ready=True, status="", closed=4) + billing = _child("billing", after=("auth",), waiting_on=("auth",), closed=0) + asked: list[str] = [] + + def closes(child: ChildInitiative) -> bool: + asked.append(child.slug) + return False + + pick = next_child_after_close(_view(auth, billing), _passes_drained, closes) + + assert asked == ["auth"] + assert pick.child is None and pick.end == "waiting" and pick.closed == () + assert pick.passed_over == (PassedOver("auth", DRAINED),) diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 3b24a2bab..a3d12ffc5 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -3,6 +3,7 @@ from __future__ import annotations import builtins +import dataclasses import importlib import json import os @@ -1736,8 +1737,9 @@ def board(project: Path, initiative: str, folder: Path) -> data.TicketsBoard: # ------------------------------------------------------------- initiative chains # A v7 parent initiative over the sliced children AUTH and BILLING (after AUTH), # each holding `DEFAULT_TREE`, read by the real vendored `tickets.py`. Real-spawn -# budget: 4 (`next ` + the first child's `status` for the preview, one -# `next ` for the drained preview, one `status` for the chained run's board). +# budget: 6 (`next ` + the first child's `status` for the preview and for +# the drained preview, one `next ` for the blocked preview, one `status` for +# the chained run's board). CHAIN_PARENT = "initiative-platform" CHAIN_AUTH, CHAIN_BILLING = "initiative-auth", "initiative-billing" @@ -1839,26 +1841,60 @@ def test_project_tickets_overview_previews_a_parent_initiative(tmp_path, monkeyp assert sorted(r.ref for r in board.rows) == ["1.1", "1.2", "2.1", "2.2"] -def test_project_tickets_overview_tags_a_drained_child(tmp_path, monkeypatch): - """A child with every ticket closed and its initiative file still open is - `drained` — passed over, never previewed as the first ready child — and the - child after it still waits on it, so no child can start. +def test_project_tickets_overview_closes_a_drained_child_first(tmp_path, monkeypatch): + """A child with every ticket and epic closed and its initiative file still + open is drained: a run marks it done before its first pick, so the preview + tags it `closes first` and previews the child after it — BILLING, no longer + waiting — never `no child can start`. - Ablation (performed): pass `next_child` no probe and AUTH is the first ready - child — this FAILS.""" + Ablation (performed): pick with `next_child` and `drained` as the probe in + `_parent_board` (no re-judge) and BILLING still waits, the chain ends + `waiting` — this FAILS.""" root = tmp_path / "proj" _chain_project(root, monkeypatch) auth = v7_layout(root, initiative=CHAIN_AUTH) + write_ticket_tree(auth, tuple(dataclasses.replace(e, status="done") for e in DEFAULT_TREE)) for epic, stem, ticket in CHAIN_PLANS: write_plan(auth, epic, stem, ticket, "done") board = data.project_tickets_overview(root) assert board.fault is None, board.fault assert [(c.slug, c.tag) for c in board.children] == [ - (CHAIN_AUTH, "drained"), - (CHAIN_BILLING, "waiting"), + (CHAIN_AUTH, "closes first"), + (CHAIN_BILLING, "first ready"), ] assert (board.children[0].closed, board.children[0].total) == (4, 4) + assert board.children[1].waiting_on == () + assert board.chain_end is None + assert board.initiative == CHAIN_BILLING + assert sorted(r.ref for r in board.rows) == ["1.1", "1.2", "2.1", "2.2"] + + +def test_project_tickets_overview_keeps_a_blocked_chain_blocked(tmp_path, monkeypatch): + """BILLING waits on AUTH, open with every ticket left and itself waiting on + IDENTITY, declared but not sliced: nothing is drained, nothing closes, and no + child can start.""" + root = tmp_path / "proj" + parent = _chain_project(root, monkeypatch) + (parent / "tickets" / "tickets.toml").write_text( + '[[initiative]]\nid = 3\nslug = "initiative-identity"\ntitle = "Identity"\n\n' + f'[[initiative]]\nid = 1\nslug = "{CHAIN_AUTH}"\ntitle = "Auth"\n' + 'after = [{ initiative = 3, needs = "accounts" }]\n\n' + f'[[initiative]]\nid = 2\nslug = "{CHAIN_BILLING}"\ntitle = "Billing"\n' + 'after = [{ initiative = 1, needs = "auth live" }]\n', + encoding="utf-8", + ) + + board = data.project_tickets_overview(root) + assert board.fault is None, board.fault + assert [(c.slug, c.tag) for c in board.children] == [ + ("initiative-identity", "no folder"), + (CHAIN_AUTH, "waiting"), + (CHAIN_BILLING, "waiting"), + ] + assert [(w.prerequisite, w.status) for w in board.children[2].waiting_on] == [ + (CHAIN_AUTH, "open") + ] assert board.rows == () assert board.initiative == "" assert board.chain_end == "waiting" From faf3cff2e9ffba034511ef7fa38059d811f66fee Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 23:42:32 -0700 Subject: [PATCH 02/11] fix(tui): tag the chain preview children from the re-judged view da8b8bac re-judged the chain pick as the tree reads once drained children are closed, but both previews still tagged and printed each child from the parent view `next` returned. With A drained and B and C both after A, the pick was B with nothing waiting, yet C was tagged `waiting` with an empty waits-on list; a folder-less C read `waiting` instead of `no folder`. - initiativechain: `ChildPick.children` carries the children of the view the pick was judged on: the parent's as `next` read them from `next_child`, the re-judged ones (closed children `done`, dependents' `waiting_on`/`ready` re-derived) from `next_child_after_close`. - cli `_dry_run_chain` and tui `_parent_board` iterate `pick.children` for tags and waits; `_chain_child_tag`/`_chain_tag` check `pick.closed` ahead of `done`/`dropped`, since a closed child now reads `done`, so it keeps `drained (the run closes it first)` / `closes first`. - Status and counts columns show the pre-close truth (the tree as it reads now) in both previews: status is `prerequisite_status` over the original view, so a closed child reads `open`, and the counts are unchanged by the close. The tag is where the simulated close shows. - tests: unit rows for the re-judged and pass-through children, a folder-less dependent; dry-run and TUI rows for two dependents of one drained child, sliced (`ready`) and folder-less (`no folder`). Ablations performed: tagging from `view.initiatives` in `_dry_run_chain` and `_parent_board` fails the dry-run and TUI rows; carrying `view.initiatives` in `next_child_after_close` fails the unit row. --- src/bmad_loop/cli.py | 23 +++++++------ src/bmad_loop/initiativechain.py | 16 ++++++--- src/bmad_loop/tui/data.py | 19 ++++++----- tests/test_cli.py | 57 ++++++++++++++++++++++++++++++++ tests/test_initiativechain.py | 48 +++++++++++++++++++++++++++ tests/test_tui_data.py | 44 ++++++++++++++++++++++-- 6 files changed, 181 insertions(+), 26 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 75ab0d5a3..4cfb7c5bd 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -5235,16 +5235,17 @@ def _dry_run_tickets( def _chain_child_tag(child: ticketmodel.ChildInitiative, pick: initiativechain.ChildPick) -> str: - """The dry run's tag for one child of a parent initiative: ``next`` (the child - the run builds first), ``done``/``dropped``, ``drained (the run closes it - first)`` (in ``pick.closed``), ``waiting``, ``no folder``, or ``ready`` - (with why the pick passed it over, when it did).""" + """The dry run's tag for one child of a parent initiative, read from the + re-judged child (``pick.children``): ``next`` (the child the run builds + first), ``drained (the run closes it first)`` (in ``pick.closed``), + ``done``/``dropped``, ``waiting``, ``no folder``, or ``ready`` (with why the + pick passed it over, when it did).""" if pick.child is not None and child.slug == pick.child.slug: return "next" - if child.status in ("done", "dropped"): - return child.status if child.slug in pick.closed: return "drained (the run closes it first)" + if child.status in ("done", "dropped"): + return child.status if not child.ready: return "waiting" if child.folder is None: @@ -5267,7 +5268,9 @@ def _dry_run_chain( (:func:`_close_drained_children`), is not taken but re-judged — the pick is the one the tree would read once each drained child whose fresh pick finds nothing is closed (``_chain_pick(after_close=True)``), and the predicted line - names those children. A close the run skips for a live or unfinished run or + names those children. Each child's tag and waits come from that re-judged + view (``pick.children``); its status and counts are the tree as it reads + now. A close the run skips for a live or unfinished run or a refused store is not predicted. ``--max-stories`` caps the first child's listing; the cap's spend across later children is not predicted.""" if args.epic is not None or args.story: @@ -5290,15 +5293,13 @@ def _dry_run_chain( f"tickets mode: parent initiative {parent} — {len(view.initiatives)} children " "in build order:" ) - for position, child in enumerate(view.initiatives, 1): + for position, child in enumerate(pick.children, 1): status = initiativechain.prerequisite_status(view, child.slug) if child.folder is not None: closed, total = initiativechain.closed_tickets(child), child.counts.get("total", 0) status += f"; {closed} of {total} tickets closed" waits = ", ".join( - f"{p} ({initiativechain.prerequisite_status(view, p)})" - for p in child.waiting_on - if p not in pick.closed + f"{p} ({initiativechain.prerequisite_status(view, p)})" for p in child.waiting_on ) line = f" {position}. {child.slug} ({status}) [{_chain_child_tag(child, pick)}]" print(line + (f" waits on: {waits}" if waits else "")) diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py index 02b966b52..9a175a29e 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -84,7 +84,11 @@ class ChildPick: children still waiting on a prerequisite, whether or not one was taken, and ``waiting_on`` one row per prerequisite each of them waits on, in the same order. ``closed`` names the drained children :func:`next_child_after_close` - judged as closed, in build order (empty from :func:`next_child`).""" + judged as closed, in build order (empty from :func:`next_child`), and + ``children`` holds every child of the view the pick was judged on, in build + order: the parent's as ``next`` read them from :func:`next_child`, re-judged + (each closed child ``done``, its dependents' ``waiting_on`` and ``ready`` + re-derived) from :func:`next_child_after_close`.""" child: ChildInitiative | None passed_over: tuple[PassedOver, ...] @@ -92,6 +96,7 @@ class ChildPick: end: ChainEnd | None waiting_on: tuple[WaitingOn, ...] = () closed: tuple[str, ...] = () + children: tuple[ChildInitiative, ...] = () def closed_tickets(child: ChildInitiative) -> int: @@ -142,10 +147,12 @@ def next_child( if why is None and passes_over is not None: why = passes_over(child) if why is None: - return ChildPick(child, tuple(passed), waiting, None, waiting_on) + return ChildPick( + child, tuple(passed), waiting, None, waiting_on, children=view.initiatives + ) passed.append(PassedOver(child.slug, why)) end: ChainEnd = "waiting" if waiting else "passed-over" if passed else "all-done" - return ChildPick(None, tuple(passed), waiting, end, waiting_on) + return ChildPick(None, tuple(passed), waiting, end, waiting_on, children=view.initiatives) def next_child_after_close( @@ -164,7 +171,8 @@ def next_child_after_close( closed child reads ``done`` and not ready, every ``waiting_on`` loses the closed children, and a child whose ``waiting_on`` changed is ready when it empties and the child is not done or dropped; every other child keeps the - answer ``next`` gave. The pick's ``closed`` names the closed children.""" + answer ``next`` gave. The pick's ``closed`` names the closed children and its + ``children`` are the re-judged ones, so a caller tags each child from them.""" closing = tuple( c.slug for c in view.initiatives if drained(c) and (closes is None or closes(c)) ) diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index d60f3926c..51570915a 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -1340,16 +1340,17 @@ def project_tickets_overview(project: Path) -> TicketsBoard: def _chain_tag(child: ticketmodel.ChildInitiative, pick: initiativechain.ChildPick) -> str: - """The start-run preview's tag for one child of a parent initiative: - ``first ready`` (the child :func:`_parent_board` shows the board of), - ``done``/``dropped``, ``closes first`` (drained, so a run marks it done - before its first pick), ``waiting``, ``no folder``, or ``ready``.""" + """The start-run preview's tag for one child of a parent initiative, read from + the re-judged child (``pick.children``): ``first ready`` (the child + :func:`_parent_board` shows the board of), ``closes first`` (drained, so a run + marks it done before its first pick), ``done``/``dropped``, ``waiting``, + ``no folder``, or ``ready``.""" if pick.child is not None and child.slug == pick.child.slug: return "first ready" - if child.status in ("done", "dropped"): - return child.status if child.slug in pick.closed: return "closes first" + if child.status in ("done", "dropped"): + return child.status if not child.ready: return "waiting" if child.folder is None: @@ -1362,7 +1363,9 @@ def _parent_board(project: Path, paths: bmadconfig.ProjectPaths) -> TicketsBoard children (``tickets.py next ``, pinned to the parent) and the board of the first ready child once a run has closed every drained child, as it does before its first pick (:func:`~bmad_loop.initiativechain.next_child_after_close`), - read through that child's folder with the tree pinned to the parent. + read through that child's folder with the tree pinned to the parent. Each + child's tag and waits come from the re-judged view; its status and counts + are the tree as it reads now. An approximation of the chain ``bmad-loop run`` builds: its pick also passes over a ready child whose fresh pick dispatches nothing, and it leaves open a @@ -1385,7 +1388,7 @@ def _parent_board(project: Path, paths: bmadconfig.ProjectPaths) -> TicketsBoard tag=_chain_tag(c, pick), waiting_on=tuple(w for w in pick.waiting_on if w.slug == c.slug), ) - for c in view.initiatives + for c in pick.children ) if pick.child is None: return TicketsBoard((), None, "", parent=parent, children=children, chain_end=pick.end) diff --git a/tests/test_cli.py b/tests/test_cli.py index a79e6b7d0..8e06eb204 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16018,6 +16018,63 @@ def test_dry_run_tickets_parent_keeps_a_drained_child_with_work_left_open( ) +CHAIN_REPORTS = "initiative-reports" + + +def _chain_second_dependent(root, *, folder=True): + """Add REPORTS after AUTH beside BILLING — sliced holding `DEFAULT_TREE`, or + declared with no folder — drain AUTH, and commit it.""" + parent = v7_layout(root, initiative=CHAIN_PARENT).initiative_folder + toml = parent / "tickets" / "tickets.toml" + toml.write_text( + toml.read_text(encoding="utf-8") + + f'\n[[initiative]]\nid = 3\nslug = "{CHAIN_REPORTS}"\ntitle = "Reports"\n' + 'after = [{ initiative = 1, needs = "auth live" }]\n', + encoding="utf-8", + ) + if folder: + write_ticket_tree(v7_layout(root, initiative=CHAIN_REPORTS), DEFAULT_TREE) + _drain_child(root, CHAIN_AUTH) + + +@pytest.mark.parametrize( + ("folder", "reports"), + [ + (True, f" 3. {CHAIN_REPORTS} (open; 0 of 4 tickets closed) [ready]"), + (False, f" 3. {CHAIN_REPORTS} (no-folder) [no folder]"), + ], + ids=["sliced", "no-folder"], +) +def test_dry_run_tickets_parent_tags_each_dependent_of_a_closed_child( + project, monkeypatch, capsys, folder, reports +): + """AUTH drained, BILLING and REPORTS both after it: the run closes AUTH and + builds BILLING, and REPORTS — no longer waiting — is tagged as the tree reads + once AUTH is closed: `ready` with no waits, or `no folder` when it is declared + but not sliced. AUTH's status column stays the tree as it reads now (`open`). + + Ablation (performed): tag and print from `view.initiatives` (the unclosed + tree) in `_dry_run_chain` and REPORTS reads `waiting` — both rows FAIL.""" + _chain_project(project, monkeypatch, capsys, []) + _chain_second_dependent(project.project, folder=folder) + + rc, out, err, written = _chain_dry_run(project, monkeypatch, capsys) + + assert rc == 0, err + assert written == "" + lines = out.splitlines() + assert lines[:4] == [ + f"tickets mode: parent initiative {CHAIN_PARENT} — 3 children in build order:", + f" 1. {CHAIN_AUTH} (open; 4 of 4 tickets closed) [drained (the run closes it first)]", + f" 2. {CHAIN_BILLING} (open; 0 of 4 tickets closed) [next]", + reports, + ] + assert lines[-1] == ( + f"predicted: chain starts with {CHAIN_BILLING}; later children are chosen as each " + f"finishes; the run closes drained {CHAIN_AUTH} before its first pick" + ) + + @pytest.mark.parametrize("selector", [["--epic", "epic-cart"], ["--story", "1.1"]]) def test_dry_run_tickets_parent_refuses_epic_and_story(project, monkeypatch, capsys, selector): """`--epic`/`--story` on a parent: the dry run refuses with `run`'s own text and diff --git a/tests/test_initiativechain.py b/tests/test_initiativechain.py index 15231ac0a..c54fbfafb 100644 --- a/tests/test_initiativechain.py +++ b/tests/test_initiativechain.py @@ -242,3 +242,51 @@ def closes(child: ChildInitiative) -> bool: assert asked == ["auth"] assert pick.child is None and pick.end == "waiting" and pick.closed == () assert pick.passed_over == (PassedOver("auth", DRAINED),) + + +def test_after_close_carries_the_rejudged_children(): + """AUTH drained, BILLING and REPORTS both after it: the pick takes BILLING, + and ``children`` holds the view it was judged on — AUTH read ``done``, REPORTS + ready with no wait left — so a tagger reads REPORTS ready, not waiting. + + Ablation (performed): carry ``view.initiatives`` (the parent as ``next`` read + it) in ``next_child_after_close``'s ``children`` and this FAILS.""" + auth = _child("auth", ready=True, status="", closed=4) + billing = _child("billing", after=("auth",), waiting_on=("auth",), closed=0) + reports = _child("reports", after=("auth",), waiting_on=("auth",), closed=0) + + pick = next_child_after_close(_view(auth, billing, reports), _passes_drained) + + assert pick.child is not None and pick.child.slug == "billing" + assert pick.closed == ("auth",) and pick.waiting == () + assert [(c.slug, c.status, c.ready, c.waiting_on) for c in pick.children] == [ + ("auth", "done", False, ()), + ("billing", "in-progress", True, ()), + ("reports", "in-progress", True, ()), + ] + + +def test_after_close_readies_a_folder_less_dependent(): + """REPORTS, declared without a folder, after drained AUTH: once AUTH closes it + is ready (a tagger reads it as having no folder, not waiting), and the pick + passes it over ``no-folder`` on the way to nothing else.""" + auth = _child("auth", ready=True, status="", closed=4) + reports = _child("reports", folder=None, after=("auth",), waiting_on=("auth",)) + + pick = next_child_after_close(_view(auth, reports), _passes_drained) + + assert pick.child is None and pick.end == "passed-over" + assert pick.passed_over == (PassedOver("reports", NO_FOLDER),) + rejudged = pick.children[1] + assert (rejudged.slug, rejudged.ready, rejudged.folder) == ("reports", True, None) + + +def test_next_child_carries_the_view_children(): + """Without a close, ``children`` is the view's own children, as ``next`` read + them.""" + auth = _child("auth", ready=True) + billing = _child("billing", after=("auth",), waiting_on=("auth",)) + view = _view(auth, billing) + + assert next_child(view).children == view.initiatives + assert next_child_after_close(view).children == view.initiatives diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index a3d12ffc5..61b00cac5 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -1737,9 +1737,9 @@ def board(project: Path, initiative: str, folder: Path) -> data.TicketsBoard: # ------------------------------------------------------------- initiative chains # A v7 parent initiative over the sliced children AUTH and BILLING (after AUTH), # each holding `DEFAULT_TREE`, read by the real vendored `tickets.py`. Real-spawn -# budget: 6 (`next ` + the first child's `status` for the preview and for -# the drained preview, one `next ` for the blocked preview, one `status` for -# the chained run's board). +# budget: 10 (`next ` + the first child's `status` for the preview, for the +# drained preview and for each of the two second-dependent rows, one `next ` +# for the blocked preview, one `status` for the chained run's board). CHAIN_PARENT = "initiative-platform" CHAIN_AUTH, CHAIN_BILLING = "initiative-auth", "initiative-billing" @@ -1870,6 +1870,44 @@ def test_project_tickets_overview_closes_a_drained_child_first(tmp_path, monkeyp assert sorted(r.ref for r in board.rows) == ["1.1", "1.2", "2.1", "2.2"] +@pytest.mark.parametrize(("folder", "tag"), [(True, "ready"), (False, "no folder")]) +def test_project_tickets_overview_tags_each_dependent_of_a_closed_child( + tmp_path, monkeypatch, folder, tag +): + """AUTH drained, BILLING and REPORTS both after it: the preview shows BILLING's + board, and REPORTS — no longer waiting once a run closes AUTH — is tagged + `ready` with no waits, or `no folder` when it is declared but not sliced. + AUTH's status stays the tree as it reads now (`open`), as in `run --dry-run`. + + Ablation (performed): build the children from `view.initiatives` (the + unclosed tree) in `_parent_board` and REPORTS reads `waiting` — both rows + FAIL.""" + root = tmp_path / "proj" + parent = _chain_project(root, monkeypatch) + toml = parent / "tickets" / "tickets.toml" + toml.write_text( + toml.read_text(encoding="utf-8") + + '\n[[initiative]]\nid = 3\nslug = "initiative-reports"\ntitle = "Reports"\n' + 'after = [{ initiative = 1, needs = "auth live" }]\n', + encoding="utf-8", + ) + if folder: + write_ticket_tree(v7_layout(root, initiative="initiative-reports"), DEFAULT_TREE) + auth = v7_layout(root, initiative=CHAIN_AUTH) + write_ticket_tree(auth, tuple(dataclasses.replace(e, status="done") for e in DEFAULT_TREE)) + for epic, stem, ticket in CHAIN_PLANS: + write_plan(auth, epic, stem, ticket, "done") + + board = data.project_tickets_overview(root) + assert board.fault is None, board.fault + assert [(c.slug, c.status, c.tag, c.waiting_on) for c in board.children] == [ + (CHAIN_AUTH, "open", "closes first", ()), + (CHAIN_BILLING, "open", "first ready", ()), + ("initiative-reports", "open" if folder else "no-folder", tag, ()), + ] + assert board.initiative == CHAIN_BILLING and board.chain_end is None + + def test_project_tickets_overview_keeps_a_blocked_chain_blocked(tmp_path, monkeypatch): """BILLING waits on AUTH, open with every ticket left and itself waiting on IDENTITY, declared but not sliced: nothing is drained, nothing closes, and no From e5c4c774f0fa5acc7adc2614a771a3b21cdec19b Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 00:28:47 -0700 Subject: [PATCH 03/11] fix(tickets): gate a chain's run-start close and journal it in the first child's run `run` on a parent initiative closed drained children (an initiative-file write and a store commit) before the launch gates in `_launch_run` ran, so a run a dirty tree or an untrusted plugin then refused had already written and committed; the stale reconcile also ran after the held-run read. Split those gates out of `_launch_run` into `_launch_gates`: worktree-clean, the plugin grants read, base skills, stale reconcile, plugin trust. Each reads only `paths.repo_root`/`paths.project`, which `with_initiative` keeps, so none depends on the child. `cmd_run` on a parent now runs them before the start close and hands the grants to `_launch_run` (`plugin_grants=`), which then skips them: one grants read and one reconcile per launch (#496/#497). The child's store-topology refusal is the one child-dependent gate and stays after the pick, first in `_launch_run`. Unchained `run` and the chain driver's later launches keep their order (`_launch_run` runs the gates itself). `_ChainStartLog.replay` appends the start close's kept rows, in order, to the first child's run journal once it is composed, before `engine.run()`; a start that composes no run leaves them printed only. The relay is declared as a splat / dynamic-kind position in the portability guard. --- docs/reference/cli.md | 2 +- docs/tickets-guide.md | 2 + src/bmad_loop/cli.py | 108 ++++++++++++++++++++++---------- tests/test_cli.py | 100 ++++++++++++++++++++++++++++- tests/test_portability_guard.py | 11 ++++ 5 files changed, 186 insertions(+), 37 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 25f7bf19a..5ef5b7c37 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -165,7 +165,7 @@ bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] - In tickets mode `run` resolves the selectors against the active initiative before anything is created. `--epic` must name one epic of the initiative table. `--story` goes through `tickets.py find` and must name one ticket that `next` lists as ready to start or to refine (and that is in the `--epic` epic, when both are given). Anything else exits 1. The run then picks only within the selection, and finishes once it has nothing left to start there. `--dry-run` applies the same selection. - Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. - `[stories] source = "tickets"` runs the active initiative's ticket tree. Before anything is created, `run` executes the `tickets.*` preflight (capability probe included) and refuses with exit 1 on a problem, including a ticket store it cannot classify or drive (`tickets.store-topology` and the refusals below). The classified topology is recorded on the run, and `resume`, `resolve` and the TUI refuse a store that changed since; `branch_per = "run"` prints the `tickets.branch-per-run` warning. Each picked ticket is dispatched as `ticket ` and, once verified at `built`, marked `done` under `[tickets] mark_done = "auto"` (left at `built` under `"never"`): inside its unit's commit for a store tracked in the code checkout (T1), after the code lands for a shared store, with a plan-only commit in a store that is its own repository (T3/T4). Under worktree isolation a unit's branch is `bmad-loop///`. -- On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes. Before each pick, its first included, it marks every drained child's initiative `done` and prints `... closed (drained)`, or `... leaves open (: ...)` while a run holds the child, its first pick still finds work, or its store is refused. It prints `initiative chain : -> ` per switch, or `... ends ()` with each waiting child's unmet prerequisites and their status, and stops at a run that does not finish, including each child's pause at `epic-boundary` after every epic it finishes; `resume` or `resolve` driving that run to finished continues the chain. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run; `--max-stories` is one budget for the whole chain, and the chain ends `max-stories` once it is spent. +- On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes. Before each pick, its first included (after the clean-worktree, skills and plugin-trust checks, and journaled in the first child's run), it marks every drained child's initiative `done` and prints `... closed (drained)`, or `... leaves open (: ...)` while a run holds the child, its first pick still finds work, or its store is refused. It prints `initiative chain : -> ` per switch, or `... ends ()` with each waiting child's unmet prerequisites and their status, and stops at a run that does not finish, including each child's pause at `epic-boundary` after every epic it finishes; `resume` or `resolve` driving that run to finished continues the chain. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run; `--max-stories` is one budget for the whole chain, and the chain ends `max-stories` once it is spent. - `--dry-run` in tickets mode reads the active initiative through its `tickets.py` and marks nothing. It prints the startable tickets in build order (initiative epic table, then entry order), each with its badges, its `ticket ` dispatch (`thorough` when `[tickets] thorough_review` selects it, `Halt after planning.` on an unrefined plan-checkpoint ticket) and its env, then the first pause a run would reach (`predicted:`), the `next` group counts and the tree's problems. A `tickets.py` failure exits 1. On a parent initiative it lists the children in build order (status, closed and total tickets, unmet prerequisites, and a tag: `next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`), prints the `next` child's block, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the chain's end line when no child can start. It closes no drained child but picks as if the run had, and the predicted line names them. `--max-stories` caps the first child's block only, `--epic` and `--story` exit 1, and the banner warns when the preflight would refuse the parent. - Prints `run starting (attach: bmad-loop attach)`, then the run summary; exits 0 once the engine has run. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index bb3e8e4eb..fc63ab689 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -51,6 +51,8 @@ A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiati Closing a child writes `status: done` into its `.md`. It happens before every pick, both when `run` starts and after each child's run finishes, for every child whose tickets are all done or dropped. That includes a child a stopped or interrupted chain left open, and one drained outside the chain. A line names each child closed. A drained child is left open, with a line naming why, while a run of it is live, or while a run that builds it is paused or otherwise unfinished (resume that run). A sweep of it holds it only while live. It is also left open while its first pick still finds work, such as an epic awaiting closure, which a run of the child then does, and while its store is refused. +When `run` starts, it closes only once the launch checks that no child changes have passed (a clean worktree, the skills, plugin trust), so a run they refuse writes and commits nothing. The first child's run journal then records those closes, ahead of its own. + Where the store is versioned, a close is committed as `chore(tickets): done [bmad-loop ]` (`[bmad-loop chain ]` for a close before the first child's run), in the code repo (T1) or in the store's own repository (T3/T4). A close that cannot be committed ends the chain (`store-commit-failed`, exit 1). Each child's run pauses at `epic-boundary` after every epic it finishes, as a one-initiative run does, and no policy key skips that pause. Run `bmad-loop resolve ` to close the epic with bmad-agent-po and resume, or close and commit it yourself and `bmad-loop resume ` (see [closure](#epic-retrospective-and-closure)). The resumed run goes on with that child, and once its run finishes the chain goes on to the next child. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 4cfb7c5bd..6dd1c61e3 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4252,17 +4252,25 @@ def cmd_run(args: argparse.Namespace) -> int: return parent scope = tickets_source.EVERYTHING launch_paths = paths + plugin_grants: GrantSet | None = None + start_log = _ChainStartLog() if parent is not None: # A parent initiative has no tickets of its own: the run builds its children, # one run each, starting with the first one `next ` can start. if args.epic is not None or args.story: print(_chain_selector_refusal(parent), file=sys.stderr) return 1 + # The launch gates that do not depend on the child run ahead of the start + # close, so a run they refuse has written and committed nothing. + gated = _launch_gates(project, paths, pol, source) + if isinstance(gated, int): + return gated + plugin_grants = gated try: # A child a stopped, interrupted or crashed chain left drained and open, # or one drained outside the chain, is closed first so its dependents # are ready for the pick. - owed = _close_drained_children(project, paths, pol, parent, _ChainStartLog()) + owed = _close_drained_children(project, paths, pol, parent, start_log) pick = None if owed is not None else _chain_pick(project, paths, parent) except (tickettree.TicketsError, ValueError) as e: print(f"tickets mode: initiative chain {parent}: {e}", file=sys.stderr) @@ -4294,9 +4302,11 @@ def cmd_run(args: argparse.Namespace) -> int: spec_folder=spec_folder, scope=scope, parent_initiative=parent, + plugin_grants=plugin_grants, ) if isinstance(launched, int): return launched + start_log.replay(launched.journal) summary = launched.engine.run() print(summary.render()) return _drive_initiative_chain(project, paths, pol, launched) @@ -4312,19 +4322,22 @@ def _launch_run( spec_folder: str, scope: tickets_source.PickScope, parent_initiative: str | None = None, + plugin_grants: GrantSet | None = None, ) -> runsetup.ComposedRun | int: """Launch one run whose gates :func:`cmd_run` already passed (git floor, isolation, store topology, the source preflight, the ticket scope): the - worktree-clean check, skills, stale reconcile, plugin trust, the config pin, - and :func:`runsetup.compose_run`. Returns the composed run, ready for - ``engine.run()``, or an rc to abort with. + :func:`_launch_gates`, the config pin, and :func:`runsetup.compose_run`. + Returns the composed run, ready for ``engine.run()``, or an rc to abort with. ``parent_initiative`` is set only for a chained child run: ``paths`` are then the child's (``ProjectPaths.with_initiative``), whose store is judged on its - own here — each child folder can have its own topology — and the parent is - recorded on the run and armed on its run-end sweep. Unset, the store was - judged by the caller and is not judged again.""" - traits = worksource.traits_for(source) + own here, first — each child folder can have its own topology — and the + parent is recorded on the run and armed on its run-end sweep. Unset, the store + was judged by the caller and is not judged again. + + ``plugin_grants`` is what a caller that already ran :func:`_launch_gates` got + from them (``run`` on a parent, ahead of its start close): the gates are not + run again and those grants are used. None runs the gates here.""" tickets = source == worksource.SOURCE_TICKETS if ( parent_initiative is not None @@ -4333,29 +4346,11 @@ def _launch_run( ): return rc - if not verify.worktree_clean(paths.repo_root, project=paths.project): - print("git worktree is not clean — commit or stash first", file=sys.stderr) - return 1 - - # #496/#497: the plugin grants are read ONCE, here, and refused against before - # the run dir exists; the same frozen set reaches the skills preflight, the - # engine's registry build and the auto-sweep factory's closure, so no later - # layer reads the store. - plugin_grants = _launch_plugin_grants(project) - if not _require_base_skills( - project, - pol, - paths.repo_root, - plugin_grants, - require_stories=traits.stories_primitive, - require_tickets=tickets, - ): - return 1 - - _reconcile_stale(project, paths, pol) - - if (rc := _reject_plugin_trust(paths, pol, plugin_grants)) is not None: - return rc + if plugin_grants is None: + gated = _launch_gates(project, paths, pol, source) + if isinstance(gated, int): + return gated + plugin_grants = gated # The launch baseline for the auto-sweep child's config re-read (#461 point 4). # Taken ONCE, from the same `pol` the engine is about to freeze, and used for @@ -4414,6 +4409,42 @@ def _launch_run( return composed +def _launch_gates( + project: Path, paths: bmadconfig.ProjectPaths, pol, source: str +) -> GrantSet | int: + """The launch gates no child initiative changes, in order: the worktree-clean + check, the plugin grants read, the skills preflight, the stale reconcile and + plugin trust. Each reads ``paths.repo_root``/``paths.project`` alone, which + ``ProjectPaths.with_initiative`` keeps, so a parent's paths and its child's + answer alike. Returns the grants, or an rc to abort with. + + #496/#497: the plugin grants are read ONCE, here, and refused against before + the run dir exists; the same frozen set reaches the skills preflight, the + engine's registry build and the auto-sweep factory's closure, so no later + layer reads the store.""" + traits = worksource.traits_for(source) + if not verify.worktree_clean(paths.repo_root, project=paths.project): + print("git worktree is not clean — commit or stash first", file=sys.stderr) + return 1 + + plugin_grants = _launch_plugin_grants(project) + if not _require_base_skills( + project, + pol, + paths.repo_root, + plugin_grants, + require_stories=traits.stories_primitive, + require_tickets=source == worksource.SOURCE_TICKETS, + ): + return 1 + + _reconcile_stale(project, paths, pol) + + if (rc := _reject_plugin_trust(paths, pol, plugin_grants)) is not None: + return rc + return plugin_grants + + def _chain_parent(paths: bmadconfig.ProjectPaths) -> str | int | None: """The active initiative when it is a parent initiative — its ``tickets.toml`` lists ``[[initiative]]`` children, read as the tickets preflight reads it @@ -4594,9 +4625,11 @@ def _landed_a_ticket(state: RunState) -> bool: class _ChainStartLog(Journal): """Where the closes before a chain's first run journal: no run dir exists yet - (the start pick journals nothing either), so the rows are kept in memory and - nothing is written, while each close and skip is printed — the - :class:`_ConfirmMarkLog` shape.""" + (the start pick journals nothing either), so the rows are kept in memory, while + each close and skip is printed — the :class:`_ConfirmMarkLog` shape. Once the + first child's run is composed, :meth:`replay` appends them, in order, to its + journal; a start that composes no run (no child to pick, a close that could + not be committed, a refused launch) leaves them printed only.""" def __init__(self) -> None: # no run dir: nothing is written self.rows: list[dict[str, object]] = [] @@ -4604,6 +4637,13 @@ def __init__(self) -> None: # no run dir: nothing is written def append(self, kind: str, **fields: Any) -> None: self.rows.append({"kind": kind, **fields}) + def replay(self, journal: Journal) -> None: + """Append every kept row to ``journal``, in order, with its fields as kept.""" + for row in self.rows: + fields = dict(row) + kind = fields.pop("kind") + journal.append(str(kind), **fields) + def _child_runs_holding(project: Path) -> tuple[dict[str, tuple[str, str, str]], str | None]: """The child initiatives a recorded run of ``project`` still holds, as ``{slug: diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e06eb204..147e7883e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16683,14 +16683,16 @@ def test_run_after_a_stopped_chain_closes_the_child_it_left_open(project, monkey assert _initiative_status(project, CHAIN_AUTH) and _initiative_status(project, CHAIN_BILLING) subjects = git(root, "log", "--format=%s").splitlines() assert f"chore(tickets): {CHAIN_AUTH} done [bmad-loop chain {CHAIN_PARENT}]" in subjects - # the start close journals nothing: BILLING's run carries only its own close + # BILLING's run carries the start close of AUTH ahead of its own close rows = _chain_rows(built[1][2]) assert [e["kind"] for e in rows] == [ + "initiative-mark", + "store-committed", "initiative-mark", "store-committed", "initiative-chain-end", ] - assert rows[0]["slug"] == CHAIN_BILLING + assert [rows[0]["slug"], rows[2]["slug"]] == [CHAIN_AUTH, CHAIN_BILLING] def test_run_closes_a_sibling_drained_outside_the_chain(project, monkeypatch, capsys): @@ -16874,6 +16876,100 @@ def test_pre_pick_close_skips_a_drained_child_whose_file_holds_an_edit( assert "half-written note" in file.read_text(encoding="utf-8") +@pytest.mark.parametrize("gate", ["dirty-worktree", "plugin-trust"]) +def test_chain_start_refused_by_a_launch_gate_closes_nothing(project, monkeypatch, capsys, gate): + """A drained AUTH the start would close, and a launch gate that refuses — a + dirty worktree, or an enabled plugin nobody granted: `run` on the parent exits + 1 having written no `status: done` and made no commit, since the gates run + ahead of the start close. Ablation (performed): move the start close back ahead + of `_launch_gates` in `cmd_run` and AUTH is closed and committed — this FAILS.""" + policy = CHAIN_POLICY + (ENABLE_ROGUE if gate == "plugin-trust" else "") + built = _chain_project(project, monkeypatch, capsys, [], policy=policy) + root = project.project + if gate == "plugin-trust": + _write_trust_plugin(project) # committed with the drain below + _drain_child(root, CHAIN_AUTH) + if gate == "dirty-worktree": + (root / "scratch.txt").write_text("uncommitted\n", encoding="utf-8") + head = git(root, "rev-parse", "HEAD") + + assert cli.main(["run", "--project", str(root)]) == 1 + + captured = capsys.readouterr() + assert built == [] and not _initiative_status(project, CHAIN_AUTH) + assert git(root, "rev-parse", "HEAD") == head + assert "closed" not in captured.out + expected = "worktree is not clean" if gate == "dirty-worktree" else "plugins enable rogue" + assert expected in captured.err + + +def test_chain_start_close_is_journaled_in_the_first_childs_run(project, monkeypatch, capsys): + """AUTH was drained by hand: the start close's rows, kept in memory while no run + dir existed, land verbatim in BILLING's run journal after `run-start` and ahead + of BILLING's own close and `initiative-chain-end`. Ablation (performed): drop + the replay in `cmd_run` and BILLING's journal starts at its own close — this + FAILS.""" + from bmad_loop.journal import Journal + + built = _chain_project(project, monkeypatch, capsys, ["drain"]) + root = project.project + _drain_child(root, CHAIN_AUTH) + + assert cli.main(["run", "--project", str(root)]) == 0 + + ((_, state, run_dir),) = built + assert state.initiative == CHAIN_BILLING + kinds = [e["kind"] for e in Journal(run_dir).entries()] + start = kinds.index("run-start") + assert kinds[start + 1 : start + 3] == ["initiative-mark", "store-committed"] + rows = _chain_rows(run_dir) + assert [(e["kind"], e.get("slug") or e.get("story_key")) for e in rows] == [ + ("initiative-mark", CHAIN_AUTH), + ("store-committed", CHAIN_AUTH), + ("initiative-mark", CHAIN_BILLING), + ("store-committed", CHAIN_BILLING), + ("initiative-chain-end", None), + ] + assert _row(rows[0], "parent", "before", "after", "outcome") == { + "kind": "initiative-mark", + "parent": CHAIN_PARENT, + "before": "", + "after": "done", + "outcome": "written", + } + subject = f"chore(tickets): {CHAIN_AUTH} done [bmad-loop chain {CHAIN_PARENT}]" + assert git(root, "log", "-1", "--format=%s", rows[1]["commit"]) == subject + + +@pytest.mark.parametrize("chained", [True, False], ids=["chained", "unchained"]) +def test_run_reads_the_plugin_grants_once(project, monkeypatch, capsys, chained): + """#496/#497: one launch reads the grants once. A chained `run` reads them in + the gates ahead of the start close and hands that set to its first launch, + which does not read again; an unchained run reads them in its launch. Ablation + (performed): drop `plugin_grants=` from `cmd_run`'s `_launch_run` call and the + chained run reads twice — this FAILS.""" + reads: list[Path] = [] + real = cli._launch_plugin_grants + + def counted(project_root): + reads.append(project_root) + return real(project_root) + + if chained: + built = _chain_project(project, monkeypatch, capsys, ["drain"]) + _drain_child(project.project, CHAIN_AUTH) + monkeypatch.setattr(cli, "_launch_plugin_grants", counted) + assert cli.main(["run", "--project", str(project.project)]) == 0 + assert [s.initiative for _, s, _ in built] == [CHAIN_BILLING] + else: + _make_tickets_validate_pass(project, monkeypatch, capsys) + calls = _record_launch(project, monkeypatch) + monkeypatch.setattr(cli, "_launch_plugin_grants", counted) + cli.main(["run", "--project", str(project.project)]) # main reports `_Composed` + assert len(calls["compose"]) == 1 + assert reads == [project.project] + + def test_chain_driver_ignores_a_chained_childs_sweep(project, monkeypatch): """A chained child's run-end sweep carries the parent too: resumed to finished, it is not a child run, so the driver neither closes, picks nor launches. diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 42725f79a..792cbec62 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -1200,6 +1200,9 @@ ("engine.py", "_run_session"): 2, # One unresolved `**fields` forward: the forwarder's own hole. ("plugins/bus.py", "_log"): 1, + # One unresolved `**fields` relay: `_ChainStartLog.replay` re-appends the rows a + # chain's start close kept in memory into the first child's run journal. + ("cli.py", "replay"): 1, } # The FIELD names that actually flow through each declared hole above, keyed by the @@ -1300,6 +1303,11 @@ # routing set with this guard green. That is what the old comment's "the scan # reads them directly at their own sites" asserted and did not do. ("plugins/bus.py", "_log"): frozenset(), + # `_ChainStartLog.replay` relays rows kept by its own `append`, so the fields + # belong to the writers that journaled into it (`_close_drained_children`, + # `TicketTree.mark_initiative_done`), each a literal write the scan reads at its + # own site; the relay adds no name of its own. + ("cli.py", "replay"): frozenset(), } # ``(file, function name)`` of every helper that FORWARDS to ``journal.append`` with a @@ -1358,6 +1366,9 @@ # The forwarder passes its caller's `kind` straight through; every CALLER spells # a literal, and `JOURNAL_FORWARDERS` is what lets the scan read them there. ("plugins/bus.py", "_log"): 1, + # `_ChainStartLog.replay` relays each kept row's kind, which a literal write into + # the start log spelled at its own site. + ("cli.py", "replay"): 1, } # The kind SPELLINGS a dynamic-kind position mints itself, keyed by the same From 1256c43f231b02aee0561326d8698ab7ce572c15 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 00:38:45 -0700 Subject: [PATCH 04/11] fix(tickets): end a chain whose next launch is refused, and check a chained child's parent lists it A refused next-child launch now journals `initiative-chain-end` `launch-refused` (the pick, and an `error` naming the child and the rc) after `initiative-chain-next` in the finished run, prints the end line, and returns the refusal rc. compose_run's chained-child guard also reads the parent's `[[initiative]]` table (`install._initiative_children` on the child folder's sibling named for the parent) and raises ValueError when the child is not listed, the parent has no children table, or the read faults. --- docs/reference/cli.md | 1 + docs/reference/run-state.md | 2 +- docs/tickets-guide.md | 1 + src/bmad_loop/cli.py | 21 ++++++++++++-- src/bmad_loop/runsetup.py | 56 ++++++++++++++++++++++++++++--------- tests/test_cli.py | 54 +++++++++++++++++++++++++++++++++-- tests/test_runsetup.py | 44 +++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5ef5b7c37..eb50959f7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -166,6 +166,7 @@ bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] - Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. - `[stories] source = "tickets"` runs the active initiative's ticket tree. Before anything is created, `run` executes the `tickets.*` preflight (capability probe included) and refuses with exit 1 on a problem, including a ticket store it cannot classify or drive (`tickets.store-topology` and the refusals below). The classified topology is recorded on the run, and `resume`, `resolve` and the TUI refuse a store that changed since; `branch_per = "run"` prints the `tickets.branch-per-run` warning. Each picked ticket is dispatched as `ticket ` and, once verified at `built`, marked `done` under `[tickets] mark_done = "auto"` (left at `built` under `"never"`): inside its unit's commit for a store tracked in the code checkout (T1), after the code lands for a shared store, with a plan-only commit in a store that is its own repository (T3/T4). Under worktree isolation a unit's branch is `bmad-loop///`. - On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes. Before each pick, its first included (after the clean-worktree, skills and plugin-trust checks, and journaled in the first child's run), it marks every drained child's initiative `done` and prints `... closed (drained)`, or `... leaves open (: ...)` while a run holds the child, its first pick still finds work, or its store is refused. It prints `initiative chain : -> ` per switch, or `... ends ()` with each waiting child's unmet prerequisites and their status, and stops at a run that does not finish, including each child's pause at `epic-boundary` after every epic it finishes; `resume` or `resolve` driving that run to finished continues the chain. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run; `--max-stories` is one budget for the whole chain, and the chain ends `max-stories` once it is spent. +- A launch check that refuses the next child's run of a parent initiative (a dirty worktree, the skills, plugin trust, the child's store) ends the chain `launch-refused`, and `run` exits with that check's exit code. - `--dry-run` in tickets mode reads the active initiative through its `tickets.py` and marks nothing. It prints the startable tickets in build order (initiative epic table, then entry order), each with its badges, its `ticket ` dispatch (`thorough` when `[tickets] thorough_review` selects it, `Halt after planning.` on an unrefined plan-checkpoint ticket) and its env, then the first pause a run would reach (`predicted:`), the `next` group counts and the tree's problems. A `tickets.py` failure exits 1. On a parent initiative it lists the children in build order (status, closed and total tickets, unmet prerequisites, and a tag: `next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`), prints the `next` child's block, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the chain's end line when no child can start. It closes no drained child but picks as if the run had, and the predicted line names them. `--max-stories` caps the first child's block only, `--epic` and `--story` exit 1, and the banner warns when the preflight would refuse the parent. - Prints `run starting (attach: bmad-loop attach)`, then the run summary; exits 0 once the engine has run. diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md index ed7e1000f..2e351c8c0 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,7 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left`, `store-refused` or `uncommitted-edit`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed, not journaled: no run exists yet. +`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left`, `store-refused` or `uncommitted-edit`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). A next child whose launch a check refuses gets `initiative-chain-next` followed by `initiative-chain-end` with `end` `launch-refused` and an `error` naming the child and the exit code. The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed, not journaled: no run exists yet. A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index fc63ab689..e25b070ed 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -47,6 +47,7 @@ A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiati - a stop was asked of the run. - the `--max-stories` budget is spent (`max-stories`). - it would build the same child again after a run that landed no ticket (`stalled`). +- a launch check refuses the next child's run, such as a dirty worktree or the child's store (`launch-refused`). `run` exits with that check's exit code. - no child can start: some still wait on a prerequisite (`waiting`), every child left was passed over (`passed-over`), or every child is done or dropped (`all-done`). A child waiting on a dropped prerequisite still counts as waiting. The end line names each waiting child's unmet prerequisites with their status, for example `initiative-billing on initiative-search (dropped), initiative-ledger (no-folder)`; `open` is a prerequisite whose file sets no status. Closing a child writes `status: done` into its `.md`. It happens before every pick, both when `run` starts and after each child's run finishes, for every child whose tickets are all done or dropped. That includes a child a stopped or interrupted chain left open, and one drained outside the chain. A line names each child closed. A drained child is left open, with a line naming why, while a run of it is live, or while a run that builds it is paused or otherwise unfinished (resume that run). A sweep of it holds it only while live. It is also left open while its first pick still finds work, such as an epic awaiting closure, which a run of the child then does, and while its store is refused. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 6dd1c61e3..f0b2efc5b 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4564,7 +4564,8 @@ def _journal_chain_end( ) -> None: """``initiative-chain-end`` in the finished run's journal. ``end`` is a :data:`initiativechain.ChainEnd`, ``stalled``, ``stop-requested``, - ``max-stories``, ``store-commit-failed`` or ``fault``. Each prerequisite a + ``max-stories``, ``store-commit-failed``, ``launch-refused`` or ``fault``. + Each prerequisite a waiting child waits on is one row of three parallel lists: the child (``waiting_child``), the prerequisite (``waiting_on``) and its status (``waiting_on_status``, :func:`initiativechain.prerequisite_status`).""" @@ -4867,7 +4868,11 @@ def _drive_initiative_chain( passed-over slugs and why) or ``initiative-chain-end`` (``parent``, ``end``, the waiting slugs and what each waits on); the next run, once composed, ``initiative-chain`` (``parent``, ``previous_run``, ``previous_child``). One - line per switch is printed. + line per switch is printed. A next launch a gate refuses (a dirty tree, the + skills, plugin trust, the child's store) leaves ``initiative-chain-next`` + followed by ``initiative-chain-end`` ``launch-refused`` (the pick, and an + ``error`` naming the child and the rc), so the journal shows no switch to a + child that never got a run. Each next run launches with no ``--run-id`` (it mints its own; ``run``'s names the first child's run alone), the budget left as its ``--max-stories``, and @@ -4930,6 +4935,18 @@ def _drive_initiative_chain( parent_initiative=parent, ) if isinstance(launched, int): + _journal_chain_end( + finished.journal, + parent, + "launch-refused", + pick, + f"the launch of {child} was refused (exit {launched})", + ) + print( + f"initiative chain {parent}: ends (launch-refused): the launch of {child} " + "was refused", + file=sys.stderr, + ) return launched launched.journal.append( "initiative-chain", diff --git a/src/bmad_loop/runsetup.py b/src/bmad_loop/runsetup.py index 7991db205..6bd4799d8 100644 --- a/src/bmad_loop/runsetup.py +++ b/src/bmad_loop/runsetup.py @@ -41,7 +41,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol -from . import bmadconfig +from . import bmadconfig, install from . import policy as policy_mod from . import runs, ticketstore, worksource from .checks import Finding @@ -1378,6 +1378,40 @@ def _unwind_composition( journal.append("composition-unwind-failed", run_id=run_dir.name, error=detail) +def _chained_child_refusal(paths: bmadconfig.ProjectPaths, parent: str) -> str | None: + """Why ``paths`` are not rooted on a child initiative of ``parent``, or None. + The child must be listed in the parent's ``[[initiative]]`` table, read as the + tickets preflight reads it (``install._initiative_children``) from the + parent's folder: the child folder's sibling named ``parent``, the sibling + :meth:`~bmadconfig.ProjectPaths.with_initiative` derives the child from.""" + child, folder = paths.active_initiative, paths.initiative_folder + if paths.era != "v7" or folder is None or not child or child == parent: + rooted = repr(child) if child else "no initiative" + return ( + f"a chained run of parent initiative {parent!r} needs paths rooted on one " + f"of its child initiatives, not on {rooted} ({paths.era})" + ) + children, breakdown, fault = install._initiative_children(folder.parent / parent) + if fault: + return ( + f"a chained run of parent initiative {parent!r} cannot confirm {child!r} is " + f"one of its child initiatives: reading {breakdown} failed: {fault}" + ) + if children is None: + return ( + f"a chained run of parent initiative {parent!r} needs paths rooted on one " + f"of its child initiatives, not on {child!r}: {breakdown} lists no " + "`[[initiative]]` children" + ) + if child not in children: + listed = ", ".join(children) or "none" + return ( + f"a chained run of parent initiative {parent!r} needs paths rooted on one " + f"of its child initiatives, not on {child!r}: {breakdown} lists {listed}" + ) + return None + + def compose_run( *, project: Path, @@ -1433,21 +1467,17 @@ def compose_run( that child's (``ProjectPaths.with_initiative``), so the run pins the child as its ``initiative`` and folder, and records the parent (``RunState.parent_initiative``, which :func:`runs.paths_for_state` re-roots - on). Paths not rooted on a child of it — off v7, with no active initiative - folder, or naming the parent itself — raise ValueError before anything is - published. The caller arms ``sweep_factory`` with the same parent. + on). Paths not rooted on a child of it raise ValueError before anything is + published (:func:`_chained_child_refusal`): off v7, with no active initiative + folder, naming the parent itself, or naming an initiative the parent's + ``[[initiative]]`` table does not list — or a parent with no such table, or + whose table cannot be read. The caller arms ``sweep_factory`` with the same + parent. """ if parent_initiative is not None and ( - paths.era != "v7" - or paths.initiative_folder is None - or not paths.active_initiative - or paths.active_initiative == parent_initiative + refusal := _chained_child_refusal(paths, parent_initiative) ): - rooted = repr(paths.active_initiative) if paths.active_initiative else "no initiative" - raise ValueError( - f"a chained run of parent initiative {parent_initiative!r} needs paths rooted " - f"on one of its child initiatives, not on {rooted} ({paths.era})" - ) + raise ValueError(refusal) run_id = run_id or runs.new_run_id() run_dir = project / RUNS_DIR / run_id # Before the claim: a store that changed since the preflight leaves nothing behind. diff --git a/tests/test_cli.py b/tests/test_cli.py index 147e7883e..bda2f0ed4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16314,7 +16314,8 @@ def _chain_project(project, monkeypatch, capsys, outcomes, *, policy=None): * ``pause`` — paused, so not finished; ``pause-stop-discarded`` — paused, with a stop request the engine discarded (its journal row); * ``stop-lodged`` / ``stop-discarded`` — ``drain``, with a stop request left in - the run dir, or discarded by the engine (its journal row). + the run dir, or discarded by the engine (its journal row); + * ``drain-dirty`` — ``drain``, leaving an untracked file in the checkout. Returns the list each built engine appends ``(paths, state, run_dir)`` to.""" from bmad_loop.journal import save_state @@ -16349,9 +16350,11 @@ def run(self): self.state.record_pause("scripted pause", "ticket-tree", None) else: self.state.finished = True - if outcome in ("drain", "stop-lodged", "stop-discarded"): + if outcome in ("drain", "drain-dirty", "stop-lodged", "stop-discarded"): _drain_child(root, self.state.initiative) self.state.tasks["1.1"] = StoryTask("1.1", 0, Phase.DONE) + if outcome == "drain-dirty": + (root / "scratch.txt").write_text("uncommitted\n", encoding="utf-8") if outcome == "stop-lodged": (self.run_dir / runs.STOP_REQUEST_FILE).write_text("{}", encoding="utf-8") if outcome in ("stop-discarded", "pause-stop-discarded"): @@ -16661,6 +16664,53 @@ def test_run_on_a_parent_ends_naming_what_each_waiting_child_waits_on(project, m ) in capsys.readouterr().out +@pytest.mark.parametrize("gate", ["dirty-worktree", "store-refused"]) +def test_chain_next_launch_refused_ends_the_chain_in_the_finished_run( + project, monkeypatch, capsys, gate +): + """AUTH drains, BILLING is picked, and a launch check refuses BILLING's run — a + file AUTH's run left uncommitted, or BILLING's store: AUTH's journal ends + `initiative-chain-next` then `initiative-chain-end` `launch-refused` naming + BILLING and the rc, `run` exits with the refusal rc, and no second run dir is + made. Ablation (performed): drop the `launch-refused` journal call and AUTH's + journal ends at `initiative-chain-next` — this FAILS.""" + from bmad_loop import ticketstore + + outcome = "drain-dirty" if gate == "dirty-worktree" else "drain" + built = _chain_project(project, monkeypatch, capsys, [outcome]) + root = project.project + if gate == "store-refused": + real = ticketstore.store_conflict + + def store_conflict(*a, **kw): + if kw.get("initiative") == CHAIN_BILLING: + return "BILLING's store is refused" + return real(*a, **kw) + + monkeypatch.setattr(ticketstore, "store_conflict", store_conflict) + + rc = cli.main(["run", "--project", str(root)]) + + assert rc == 1 # what both gates return + ((_, state, run_dir),) = built + assert state.initiative == CHAIN_AUTH + nxt, end = _chain_rows(run_dir)[-2:] + assert (nxt["kind"], nxt["to_child"]) == ("initiative-chain-next", CHAIN_BILLING) + assert _row(end, "parent", "end", "waiting", "passed_over") == { + "kind": "initiative-chain-end", + "parent": CHAIN_PARENT, + "end": "launch-refused", + "waiting": [], + "passed_over": [], + } + assert end["error"] == f"the launch of {CHAIN_BILLING} was refused (exit {rc})" + assert [p.name for p in (root / runs.RUNS_DIR).iterdir() if p.is_dir()] == [run_dir.name] + err = capsys.readouterr().err + assert f"initiative chain {CHAIN_PARENT}: ends (launch-refused)" in err + expected = "worktree is not clean" if gate == "dirty-worktree" else "BILLING's store is refused" + assert expected in err + + def test_run_after_a_stopped_chain_closes_the_child_it_left_open(project, monkeypatch, capsys): """A stop asked of AUTH's run ends the chain ahead of the close, so AUTH is left drained and open and BILLING waits on it. The next `run` on the parent closes diff --git a/tests/test_runsetup.py b/tests/test_runsetup.py index 6e43105ef..4a8576c91 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -2568,6 +2568,21 @@ def test_compose_sweep_records_no_parent_off_v7(tmp_path): assert load_state(composed.run_dir).parent_initiative is None +def _parent_breakdown(project_v7): + return project_v7.initiative_folder / "tickets" / "tickets.toml" + + +def _list_children(project_v7, *slugs): + """Make the template's initiative a parent listing ``slugs`` as its + `[[initiative]]` children, as compose_run's membership check reads it.""" + rows = "".join( + f'\n[[initiative]]\nid = {i}\nslug = "{slug}"\ntitle = "{slug}"\n' + for i, slug in enumerate(slugs, 1) + ) + breakdown = _parent_breakdown(project_v7) + breakdown.write_text(breakdown.read_text(encoding="utf-8") + rows, encoding="utf-8") + + def _compose_chained_run(project_v7, paths, parent): return runsetup.compose_run( project=project_v7.project, @@ -2596,6 +2611,7 @@ def test_compose_run_records_a_chained_childs_parent_on_the_childs_paths(project ABLATION (performed): drop `parent_initiative=` from compose_run's `build_run_state` call and this FAILS.""" child_paths, child_folder = _chained_child_paths(project_v7) + _list_children(project_v7, CHILD) composed = _compose_chained_run(project_v7, child_paths, project_v7.initiative) state = load_state(composed.run_dir) assert state.parent_initiative == project_v7.initiative @@ -2619,6 +2635,34 @@ def test_compose_run_refuses_a_parent_without_child_rooted_paths(project_v7, roo assert not runs_dir.exists() or not any(runs_dir.iterdir()) +@pytest.mark.parametrize("parent", ["unlisted", "no-children-table", "unreadable"]) +def test_compose_run_refuses_a_child_its_parent_does_not_list(project_v7, parent): + """Child-rooted paths are not enough: the child must be one the parent's + `[[initiative]]` table lists. A parent listing other children, one with no + such table, or one whose table cannot be read raises before a run dir is + published, naming the parent and the child. + + ABLATION (performed): drop the membership check from `_chained_child_refusal` + and this FAILS.""" + child_paths, _ = _chained_child_paths(project_v7) + if parent == "unlisted": + _list_children(project_v7, "initiative-other") + elif parent == "unreadable": + _parent_breakdown(project_v7).write_text("[[initiative]\n", encoding="utf-8") + with pytest.raises(ValueError) as refused: + _compose_chained_run(project_v7, child_paths, project_v7.initiative) + message = str(refused.value) + assert repr(project_v7.initiative) in message and repr(CHILD) in message + expected = { + "unlisted": "lists initiative-other", + "no-children-table": "lists no `[[initiative]]` children", + "unreadable": "TOMLDecodeError", + }[parent] + assert expected in message + runs_dir = project_v7.project / runs.RUNS_DIR + assert not runs_dir.exists() or not any(runs_dir.iterdir()) + + @pytest.mark.parametrize("live", ["parent", "other", "child", "unset"]) def test_chained_sweep_resume_is_pinned_to_the_parent(project_v7, monkeypatch, live): """A chained child's sweep resumes only while the live config names its From dd987f12e0e92f2d08812727c61be0c7e82d9430 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 00:45:06 -0700 Subject: [PATCH 05/11] fix(resolve): re-read the epic-close pause before journaling its verdict The epic-close verify runs lock-free (it spawns tickets.py and git), so a rival `resume` could take the run before the verdict lock, and the journal then carried an `epic-close-verified`/`-unverified` row on a run that had already resumed. The verdict lock now reloads the state and, if the run is no longer paused at this epic's closure, journals nothing and refuses with the moved-pause message. The resume-time re-check stays; both share `_epic_pause_moved`. Tests: the verdict-lock reload (rival resume staged inside the check); chained-child rows for `_resolve_config_refusal`, the re-arm's store gate before the interactive session and after the confirm, which had no chained coverage (the code already judged the child's store; no fix needed); and a portability-guard pin that `cmd_resolve` calls `_resolve_config_refusal` both before `run_session` and after `_confirm`, which the per-function caller counts cannot see. --- src/bmad_loop/cli.py | 18 ++++++- tests/test_cli.py | 90 +++++++++++++++++++++++++++++++++ tests/test_portability_guard.py | 37 ++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index f0b2efc5b..90b7b5842 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -7976,6 +7976,12 @@ def _epic_close_check( return status, [f"epic {slug}: {line}" for line in missing] +def _epic_pause_moved(state: RunState, slug: str) -> bool: + """Whether ``state`` (re-read under the run lock) is no longer paused at epic + ``slug``'s closure: a rival resume took the run, or it paused elsewhere.""" + return state.paused_stage != PAUSE_EPIC_BOUNDARY or state.paused_story_key != slug + + def _resolve_epic_close( args: argparse.Namespace, project: Path, @@ -8166,6 +8172,15 @@ def _resolve_epic_close( if not runs.is_run(run_dir): print(f"no such run: {run_dir.name}", file=sys.stderr) return 1 + # The verify ran lock-free (it spawns tickets.py and git), so a rival resume + # may have taken the run meanwhile: a verdict on a pause that is gone is not + # this run's record. The resume below re-checks again after the confirm. + if _epic_pause_moved(load_state(run_dir, pin=state), slug): + print( + f"the pause for {slug} changed while resolve was in progress — not resuming", + file=sys.stderr, + ) + return 1 if missing: Journal(run_dir, pin=state).append( "epic-close-unverified", epic_ref=slug, missing=missing @@ -8186,8 +8201,7 @@ def _resolve_epic_close( return 0 with state_lock(run_dir): # The checks above ran lock-free: repeat them against the last writer's state. - fresh_state = load_state(run_dir, pin=state) - if fresh_state.paused_stage != PAUSE_EPIC_BOUNDARY or fresh_state.paused_story_key != slug: + if _epic_pause_moved(load_state(run_dir, pin=state), slug): print( f"the pause for {slug} changed while resolve was in progress — not resuming", file=sys.stderr, diff --git a/tests/test_cli.py b/tests/test_cli.py index bda2f0ed4..c8297cf71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6296,6 +6296,63 @@ def conflict(_project, source, _isolation, **kw): assert _state_bytes(run_dir) == before +@pytest.mark.parametrize("refused", ["child", "parent"]) +@pytest.mark.parametrize("mode", ["interactive", "--no-interactive"]) +def test_resolve_config_refusal_judges_a_chained_childs_own_store( + project, monkeypatch, capsys, mode, refused +): + """`_resolve_config_refusal` — the re-arm's store gate before the interactive + session and after the confirm — judges a chained child's OWN store + (`initiative=`): a refused child store refuses ahead of the session or + the re-arm, a refused parent store beside an acceptable child one does not. + The early `--adopt-branch` / `--reverify` gate has its own row above. Ablation + (performed): drop `initiative=` in `_resolve_config_refusal` and every row + FAILS.""" + from bmad_loop import resolve as resolve_mod + from bmad_loop import ticketstore + from bmad_loop.journal import load_state, save_state + + run_dir, rearms = _resolve_gate_run(project, monkeypatch, run_type="story", ledger_bytes=None) + child, parent = "initiative-child", "initiative-platform" + state = load_state(run_dir) + state.source = "tickets" + state.initiative, state.parent_initiative = child, parent + save_state(run_dir, state) + before = load_state(run_dir) + real = bmadconfig.load_paths(project.project) + loaded = dataclasses.replace( + real, era="v7", active_initiative=parent, initiative_folder=real.output_folder / parent + ) + monkeypatch.setattr(bmadconfig, "load_paths", lambda _p, *, want_tickets=False: loaded) + refused_store = child if refused == "child" else parent + judged: list[str | None] = [] + + def conflict(_project, _source, _isolation, *, recorded=None, initiative=None): + judged.append(initiative) + store = parent if initiative is None else initiative # unset: the live parent's + return "the ticket store is not supported" if store == refused_store else None + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: pytest.fail("built adapters")) + monkeypatch.setattr(resolve_mod, "run_session", lambda *a, **k: pytest.fail("conversed")) + + argv = ["resolve", "--project", str(project.project), "r1", "--resume"] + if mode == "--no-interactive": + argv.append(mode) + if refused == "child": + assert cli.main(argv) == 1 + assert "error: the ticket store is not supported" in capsys.readouterr().err + assert rearms == [] + _assert_escalation_intact(run_dir, before) + elif mode == "interactive": + with pytest.raises(pytest.fail.Exception, match="built adapters"): + cli.main(argv) # past the pre-session store gate, into the session + else: + assert cli.main(argv) == 0, capsys.readouterr().err + assert rearms == ["s1"] + assert judged == [child] + + def test_resolve_adopt_branch_refuses_a_task_with_no_recorded_worktree(tmp_path, capsys): from bmad_loop.journal import load_state, save_state @@ -17607,6 +17664,39 @@ def confirm(_q): ) +@pytest.mark.parametrize("closed", [True, False], ids=["verified", "unverified"]) +def test_resolve_epic_close_journals_no_verdict_on_a_pause_a_rival_took( + project, capsys, monkeypatch, closed +): + """The verify runs lock-free, so a rival `resume` can take the run before the + verdict is journaled: the pause is re-read under the lock that journals it, and + a pause that is gone gets no `epic-close-*` row, rc 1 and the moved-pause + refusal. No real spawn. Ablation (performed): drop that re-read and both rows + FAIL (the verdict row lands on the resumed run).""" + from bmad_loop.journal import load_state, save_state + + run_dir, resumed = _epic_close_run(project, monkeypatch, capsys) + _fake_epic_tree(monkeypatch, project, "done" if closed else "in-progress") + real_check = cli._epic_close_check + + def check_then_rival_resume(*a, **k): + verdict = real_check(*a, **k) + state = load_state(run_dir) + state.paused_stage = state.paused_reason = state.paused_story_key = None + save_state(run_dir, state) + return verdict + + monkeypatch.setattr(cli, "_epic_close_check", check_then_rival_resume) + + assert _resolve_epic(project, "--resume") == 1 + + assert resumed == [] and _epic_close_kinds(run_dir) == [] + assert ( + "the pause for epic-cart changed while resolve was in progress — not resuming" + in capsys.readouterr().err + ) + + def test_resolve_epic_close_refuses_an_unrecorded_store_topology(project, capsys, monkeypatch): """A tickets run that recorded no store topology (no initiative folder at its start, or a state written before the record existed) has no repo to check the diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 792cbec62..5daa1361a 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -5649,6 +5649,43 @@ def test_store_topology_refusal_sites_are_enumerated(): ) +def test_cmd_resolve_takes_the_config_refusal_before_the_session_and_after_the_confirm(): + """`cmd_resolve` reaches the isolation and store refusals through + `_resolve_config_refusal` at BOTH points: before the interactive session (the + `96aa09a9` arm — no conversation spent on a config knowable up front) and after + the confirm, on the re-read config the re-arm acts on. The caller tables count + the helper's own predicate calls once, so they cannot see either call drop + out; this pins both by position against the session and the confirm. + + Ablation (performed): delete the pre-session `_resolve_config_refusal` call in + `cmd_resolve` and this reddens.""" + tree = ast.parse((SRC / "cli.py").read_text(encoding="utf-8"), filename="cli.py") + (fn,) = ( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "cmd_resolve" + ) + + def lines(name: str) -> list[int]: + return sorted( + node.lineno + for node in ast.walk(fn) + if isinstance(node, ast.Call) and _called_name(node.func) == name + ) + + refusals = lines("_resolve_config_refusal") + (session,) = lines("run_session") + (confirm,) = lines("_confirm") + assert [line < session for line in refusals] == [True, False], ( + "cmd_resolve must call _resolve_config_refusal once before the interactive " + f"session (run_session at line {session}) and once after it: {refusals}" + ) + assert refusals[1] > confirm, ( + f"cmd_resolve's second _resolve_config_refusal (line {refusals[1]}) must " + f"follow the confirm (line {confirm})" + ) + + @pytest.mark.parametrize( ("label", "source", "fn"), [ From 68ecaf40586f9ca60eff5bdd1454b1ded4585e93 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 00:52:16 -0700 Subject: [PATCH 06/11] feat(mux): add a checked rename_window verb to the multiplexer seam TerminalMultiplexer.rename_window(target, name) renames a window so a later by-name lookup (the TUI's `-` control-window scan) finds it under the new name. Unlike select_window it is checked: a rename that did not land raises MultiplexerError, so the caller can say the window still has its old name. The method is non-abstract, as send_keys and capture_pane are, so released external backends keep loading. Its default raises MultiplexerError naming the backend. BaseTmuxBackend sends `rename-window -t `. It raises TmuxError on a nonzero exit (naming the target and the binary's stderr) and on a SubprocessError or OSError from the transport. Measured on tmux 3.8: rename-window turns the window's automatic-rename off, so the name sticks with no option write. Both binaries expand `#{...}` in the name as a format. psmux needs no override. The inherited argv is correct: - The module docstring says every seam verb takes the session-qualified `session:@N` id it mints and routes it to that session's server (psmux/psmux#483). The select-window test pins that verbatim pass-through. - psmux v3.4.0 source (src/server/connection.rs) puts rename-window through the validated -t path (#545): a miss is `can't find window`, not the active window. The outer -t is stripped before the name is read. - src/server/mod.rs CtrlReq::RenameWindow sets the window's manual_rename flag, which psmux's automatic rename skips. So no `automatic-rename off` write is needed. The backend's own option notes say the same thing: only unnamed windows are auto-renamed (_INITIAL_WINDOW_NAME), and automatic-rename is the one builtin window option with real per-window storage. Docs: the contract entry in the adapter authoring guide, and an operational note under External backends in multiplexer-backends.md. --- docs/adapter-authoring-guide.md | 1 + docs/multiplexer-backends.md | 4 +++ src/bmad_loop/adapters/multiplexer.py | 17 ++++++++++++ src/bmad_loop/adapters/tmux_base.py | 18 +++++++++++++ tests/test_multiplexer.py | 38 +++++++++++++++++++++++++++ tests/test_psmux_backend.py | 13 +++++++++ 6 files changed, 91 insertions(+) diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 31b90f74e..03a308e81 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -79,6 +79,7 @@ Rules that span methods: - `window_alive(session, window_id)`: `True` while the window is listed AND its pane is alive; a window the multiplexer keeps listed after its pane exited (`remain-on-exit on`) is not alive. Membership alone is `list_window_ids`. Raise when liveness cannot be known; never answer `False` for that. - `kill_window(target)`: tolerate the window already being gone; no-op on a transport failure. - `select_window(target)`: make the window current; best-effort. +- `rename_window(target, name)` (default: raises `MultiplexerError`): rename the window so a by-name lookup finds it under `name`, which must stick against any automatic rename. Checked: raise `MultiplexerError` when the rename did not land. - `set_window_option(target, option, value)` / `unset_window_option(target, option)`: best-effort. An unset option must read back as unset, not empty. - `show_window_option(target, option)`: the value, or `''` when unset or on failure. - `pipe_pane(window_id, log_file)`: tee the pane to `log_file`; tolerate a dead window. diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index c80c3aabc..99296fb3e 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -326,3 +326,7 @@ Operational notes for any external backend: - **The TUI's parked-prompt answer keys need `send_keys`.** The method is optional: a backend that does not override it refuses every answer, and the operator answers by attaching instead. See the [backend contract](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). +- **`rename_window(target, name)` is checked.** Unlike `select_window`, it raises + `MultiplexerError` when the rename did not land, and the new name must stick: a + multiplexer that auto-renames windows must not rename it back. The method is optional: + a backend that does not override it raises `MultiplexerError` for every rename. diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index 01ab3e55f..db6e91df8 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -375,6 +375,23 @@ def select_window(self, target: str) -> None: on a transport failure). ``target`` is a :meth:`target` token or a backend-native window id.""" + def rename_window(self, target: str, name: str) -> None: + """Rename the targeted window to ``name``, so a later by-name lookup + (the TUI's ``-`` control-window scan) finds it under the + new one. ``target`` is a :meth:`target` token or a backend-native + window id. The name must stick: a multiplexer that auto-renames + windows must not rename this one back. + + CHECKED, unlike :meth:`select_window`: a rename that did not land + raises :class:`MultiplexerError`, so the caller can say the window is + still under its old name. + + NON-abstract so released out-of-tree backends keep working unchanged: + the default raises :class:`MultiplexerError` ("cannot rename"), which + the caller reads as a rename that did not land. A backend that can + rename a window overrides this.""" + raise MultiplexerError(f"{type(self).__name__} cannot rename a window") + @abstractmethod def set_window_option(self, target: str, option: str, value: str) -> None: """Set a user option on the targeted window (best-effort: a no-op on a diff --git a/src/bmad_loop/adapters/tmux_base.py b/src/bmad_loop/adapters/tmux_base.py index 18c93462c..18e585930 100644 --- a/src/bmad_loop/adapters/tmux_base.py +++ b/src/bmad_loop/adapters/tmux_base.py @@ -955,6 +955,24 @@ def select_window(self, target: str) -> None: except (subprocess.SubprocessError, OSError): pass + def rename_window(self, target: str, name: str) -> None: + # Checked, unlike select_window: the caller must be able to say the + # rename did not land, so every failure raises the seam type. The new + # name sticks on both binaries with no option write: measured on tmux + # 3.8, rename-window turns the window's `automatic-rename` off, and + # psmux 3.4.0 sets the window's `manual_rename` flag (the one its + # automatic rename skips). Both expand `#{...}` in the name as a + # format, so a caller passes a name with no `#` in it. + try: + proc = self._run(["rename-window", "-t", target, name], check=False) + except (subprocess.SubprocessError, OSError) as exc: + raise TmuxError(f"{self._BINARY} rename-window on {target} failed: {exc}") from exc + if proc.returncode != 0: + raise TmuxError( + f"{self._BINARY} rename-window on {target} exited {proc.returncode}: " + f"{proc.stderr.strip() or '(no stderr)'}" + ) + def set_window_option(self, target: str, option: str, value: str) -> None: try: self._run(["set-option", "-w", "-t", target, option, value], check=False) diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index bb9d188ea..9e2d867aa 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -167,6 +167,14 @@ def test_a_backend_without_send_keys_inherits_the_seam_refusal(): StubMux().send_keys("@1", "Enter") +def test_a_backend_without_rename_window_inherits_the_seam_refusal(): + """A backend written before the verb existed (StubMux overrides no + `rename_window`) still constructs, and a rename raises the seam error the + caller reads as a rename that did not land, never a silent no-op.""" + with pytest.raises(MultiplexerError, match="StubMux cannot rename a window"): + StubMux().rename_window("=ctl:@1", "run-b") + + def test_window_alive_matches_a_session_qualified_id(monkeypatch): """A tmux-family backend may mint `session:@N` ids (psmux does, and a plugin subclass may too) while list-panes rows carry bare `@N`; the base compares @@ -370,6 +378,7 @@ def test_seam_methods_never_leak_raw_subprocess_error(boom_run, tmp_path): lambda: mux.set_session_option("s", "opt", "val"), lambda: mux.new_parked_window("s", "n", tmp_path, ["echo", "hi"], ""), lambda: mux.send_text("@1", "hi"), + lambda: mux.rename_window("@1", "n"), lambda: mux.has_session("s"), # already-correct raiser — lock it in ] for call in raisers: @@ -1304,6 +1313,35 @@ def test_kill_window_is_silent_when_the_kill_lands(monkeypatch, capsys): assert len(calls) == 1 # a landed kill never pays for the survivor probe +def test_rename_window_sends_the_target_and_name_verbatim(monkeypatch): + # One spawn, no option write: tmux's rename-window turns the window's + # automatic-rename off itself, so the name sticks with nothing else sent. + calls = _kill_fake(monkeypatch, kill_rc=0) + assert TmuxMultiplexer().rename_window("=ctl:@3", "run-b") is None + assert calls == [["tmux", "-u", "rename-window", "-t", "=ctl:@3", "run-b"]] + + +def test_rename_window_raises_when_the_rename_did_not_land(monkeypatch): + """A rename is CHECKED, unlike select_window: a nonzero exit raises the seam + type naming the target and the binary's own stderr, so the caller can say + the window is still under its old name. Ablation (performed): make + rename_window swallow failures the way select_window does and this FAILS.""" + _kill_fake(monkeypatch, kill_rc=1, kill_err="can't find window: @9\n") + with pytest.raises(tmux_base.TmuxError, match="rename-window on ctl:@9 exited 1") as excinfo: + TmuxMultiplexer().rename_window("ctl:@9", "run-b") + assert "can't find window: @9" in str(excinfo.value) + + +def test_rename_window_raises_on_a_transport_failure(monkeypatch): + def boom(*_a, **_k): + raise OSError("tmux vanished") + + monkeypatch.setattr(tmux_base.subprocess, "run", boom) + with pytest.raises(MultiplexerError, match="tmux vanished") as excinfo: + TmuxMultiplexer().rename_window("ctl:@3", "run-b") + assert not isinstance(excinfo.value, OSError) + + # -------------------------------- version() is one bounded line, always (#321) # # Consumers render version() inline (the `mux` table, validate's preflight diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py index 437a8eee4..392be584e 100644 --- a/tests/test_psmux_backend.py +++ b/tests/test_psmux_backend.py @@ -1250,6 +1250,19 @@ def test_select_window_sends_every_target_form_unrewritten(rec, target): assert len(rec.calls) == 1 +@pytest.mark.parametrize("target", ["ctl:@3", "=ctl:@3", "=ctl:run-a"]) +def test_rename_window_inherits_the_base_verb_unrewritten(rec, target): + # No override: psmux validates a rename-window target server-side (a miss + # is an error, not the active window, psmux/psmux#545) and routes the + # qualified id this backend mints to its session's server (psmux/psmux#483), + # so the target goes out verbatim. Its rename sets the window's + # manual_rename flag (source-read at v3.4.0), so automatic rename leaves + # the new name alone: one spawn, no `automatic-rename off` write. + PsmuxMultiplexer().rename_window(target, "run-b") + assert rec.argv[1:] == ["rename-window", "-t", target, "run-b"] + assert len(rec.calls) == 1 + + def test_tmux_backend_keeps_bare_tui_ids(monkeypatch, tmp_path): # The divergence is psmux-only: tmux ids are server-global, so qualifying # them would produce targets its own verbs do not accept. tmux's `-u` (#881) From 3e6a511a90a79b4dfacc25401e9a4e0b00b2a348 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 00:53:30 -0700 Subject: [PATCH 07/11] docs(run-state): the chain's run-start closes are journaled in the first child's run --- docs/reference/run-state.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md index 2e351c8c0..7ab77161f 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,9 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left`, `store-refused` or `uncommitted-edit`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). A next child whose launch a check refuses gets `initiative-chain-next` followed by `initiative-chain-end` with `end` `launch-refused` and an `error` naming the child and the exit code. The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed, not journaled: no run exists yet. +`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. + +When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left`, `store-refused` or `uncommitted-edit`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). A next child whose launch a check refuses gets `initiative-chain-next` followed by `initiative-chain-end` with `end` `launch-refused` and an `error` naming the child and the exit code. The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed as they happen and journaled in that child's run, after its `run-start` row; a start that composes no run (no child to pick, a close that cannot be committed, a refused launch) only prints them. A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. From 489a5e282ceae60de19e3d3b162142dcb6e44bc3 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 01:05:16 -0700 Subject: [PATCH 08/11] feat(tickets): a chained run claims its control window by renaming it A TUI-launched `run` on a parent initiative runs in the control window start_detached named `run-` for the first child's run. Each next child runs as a new run id in that same process and window, so the TUI's by-name lookup (`ctl_window_id`) found nothing for the current child's run, and `x` on the finished first run still targeted the window that now drove the next one. tui/launch.py: `claim_renamed_ctl_window(project, run_id)` renames the window this process runs in to the `run` name start_detached would have given `run_id`, using the new `_ctl_window_name` helper that start_detached now uses too. It then records and re-proves the window through `claim_ctl_window`. A rename that raises is reported in words and claims nothing, so the window keeps the previous run's name and record. Outside a control session it returns None and renames nothing. Nothing is re-stamped: the project tag is a window option, which a rename keeps, and a chain stays in one project. cli.py: `_drive_initiative_chain` makes the claim once the next run is composed and before its engine runs. It goes through `_claim_ctl_window` with `renamed=True`, so a fault is printed and journaled `ctl-window-claim-failed` in the new run. The claim is made under the new run's state lock, as every other claim is. The no-live-engine half of the precondition holds by construction: compose_run published this process's pid under that lock. A lock fault is reported the same way. The `_ctl_launch` accepted flag is left alone on a chain switch. It records whether the launched resume or resolve accepted its own run, so that a refusal before acceptance is journaled. By the time a chain switches, that acceptance has already happened: either the flag is already set, or the launch was a `run`, which has no flag at all. Tests: launch unit rows for rename-then-record, a failed rename, and the outside-a-session case. A chain-driver row checks that the claim is made for the new run id and that a fault is journaled in the new run. A real-tmux zero-token row with in-window probes monkeypatched. The transaction and e2e-count inventories are updated. --- docs/tickets-guide.md | 2 +- src/bmad_loop/cli.py | 41 +++++++++++++++++---- src/bmad_loop/tui/launch.py | 49 ++++++++++++++++++++++++- tests/test_cli.py | 33 +++++++++++++++++ tests/test_conftest.py | 2 +- tests/test_portability_guard.py | 3 ++ tests/test_tui_launch.py | 60 +++++++++++++++++++++++++++++++ tests/test_tui_launch_tmux_e2e.py | 48 +++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 9 deletions(-) diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index e25b070ed..396069806 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -62,7 +62,7 @@ Each child's run pauses at `epic-boundary` after every epic it finishes, as a on `bmad-loop run --dry-run` on a parent writes nothing. It lists the children in build order, each with its status, closed and total tickets, the prerequisites it waits on and a tag (`next`, `ready`, `ready, passed over ()`, `waiting`, `no folder`, `drained (the run closes it first)`, `done` or `dropped`). It then prints the `next` child's dry run, read through that child's folder, and ends `predicted: chain starts with ; later children are chosen as each finishes`, or with the end line `run` prints when no child can start. It closes no drained child, but picks as the tree would read once the run has closed them (a drained child with an epic still to close or break down stays open, as `run` leaves it); the predicted line names those the run closes before its first pick. Its `--max-stories` caps the first child's listing only, and its banner warns when the preflight would refuse the parent. -The TUI's start-run preview lists the same children, tags each drained child `closes first`, and shows the board of the first ready child once those are closed; `run --dry-run` names the exact first pick. A chained child run's [tickets board](tui-guide.md#tickets-board-middle-tickets-mode) shows that child's tickets. +The TUI's start-run preview lists the same children, tags each drained child `closes first`, and shows the board of the first ready child once those are closed; `run --dry-run` names the exact first pick. A chained child run's [tickets board](tui-guide.md#tickets-board-middle-tickets-mode) shows that child's tickets. A chain the TUI launched runs in one control window, which takes the name of each chained run as the chain moves on, so attach and stop on the current child's run reach it. ## Store layouts diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 90b7b5842..176207980 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4876,9 +4876,13 @@ def _drive_initiative_chain( Each next run launches with no ``--run-id`` (it mints its own; ``run``'s names the first child's run alone), the budget left as its ``--max-stories``, and - the finished run's spec folder. Like a fresh ``run`` it does not claim the - control window (``_claim_ctl_window``): that claim settles which of several - windows named for one run id drives it, and a new run id has none. + the finished run's spec folder. It runs in this process, so in the control + window the TUI opened for the first run, named for that run: once composed, + the next run renames that window for itself and claims it + (``_claim_ctl_window`` with ``renamed``), so the TUI's attach and stop on the + next run reach the window and those on the finished run no longer do. A + claim that does not land is printed and journaled ``ctl-window-claim-failed`` + in the next run, which goes on regardless. Returns 0, 1 for a tree fault or a close that could not be committed, or the refusal rc of the next launch.""" @@ -4954,6 +4958,21 @@ def _drive_initiative_chain( previous_run=state.run_id, previous_child=state.initiative, ) + # Under the run's state lock, as every claim is: the record's writers + # and cleanup's removal of the run serialize on it. The claim's other + # precondition, no live engine but this one, holds by construction: + # compose_run published this process's pid under the same lock, so no + # rival resume can accept the new run. + try: + with state_lock(launched.run_dir): + _claim_ctl_window(project, launched.run_dir, launched.state, renamed=True) + except OSError as exc: + _report_ctl_window_fault( + launched.run_dir, + f"could not lock run {launched.run_id} to claim this window as its " + f"control window: {exc}", + launched.state, + ) print(launched.engine.run().render()) finished = launched @@ -6631,11 +6650,20 @@ def __getattr__(self, name: str) -> Any: _CTL_REFUSAL_MAX_CHARS = 4000 -def _claim_ctl_window(project: Path, run_dir: Path, state: RunState) -> None: +def _claim_ctl_window( + project: Path, run_dir: Path, state: RunState, *, renamed: bool = False +) -> None: """Publish the control window this process runs in as the run's, once this process has accepted the run (`tui.launch.claim_ctl_window`). The caller holds the run's state lock and has just found no live engine under it (#920). + ``renamed`` claims it for the next run of a parent-initiative chain + (:func:`_drive_initiative_chain`), whose window still carries the name of the + run before: the window is renamed for this run first + (`tui.launch.claim_renamed_ctl_window`). That leaves the launch's accepted + flag alone: it is the launched command's acceptance of its own run, settled + before any chain switch. + A claim that did not land is journaled as well as printed: a TUI-launched resume prints into a detached window nobody is looking at, while the journal is the run's durable record, written through a journal pinned to the @@ -6649,10 +6677,11 @@ def _claim_ctl_window(project: Path, run_dir: Path, state: RunState) -> None: from .tui import launch # import-safe: launch.py has no textual imports ctl = _ctl_launch - if ctl is not None: + if ctl is not None and not renamed: ctl.accepted = True + claim = launch.claim_renamed_ctl_window if renamed else launch.claim_ctl_window try: - warning = launch.claim_ctl_window(project, run_dir.name, launched=ctl is not None) + warning = claim(project, run_dir.name, launched=ctl is not None) except (MultiplexerError, OSError) as exc: warning = f"could not record this window as run {run_dir.name}'s control window: {exc}" if warning is not None: diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index 19f5a2f27..0470203ec 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -51,6 +51,13 @@ _CTL_WINDOW_RE = re.compile(r"^(?:run|sweep|resume|resolve)-(.+)$") +def _ctl_window_name(kind: str, run_id: str) -> str: + """The name a control-session window hosting `run_id` carries: the one + start_detached mints and claim_renamed_ctl_window renames to, and the one + _CTL_WINDOW_RE parses back.""" + return f"{kind}-{run_id}" + + class LaunchError(Exception): pass @@ -425,6 +432,46 @@ def claim_ctl_window(project: Path, run_id: str, *, launched: bool = False) -> s ) +def claim_renamed_ctl_window(project: Path, run_id: str, *, launched: bool = False) -> str | None: + """claim_ctl_window for a run this process starts in a window it already + drives under another run's name: a parent-initiative chain builds each next + child as a new run, in the process and so the window of the run before it + (cli `_drive_initiative_chain`). The lookup reaches a run's window by name, + so the window is renamed first, to the `run` name start_detached would have + given `run_id`, and then claimed. Afterwards ctl_window_id answers the + window for `run_id` and no longer for the run before: that run's record + still names it, but the lookup re-proves a record by name and drops it. + + Nothing is re-stamped. The project tag is a window option, which a rename + keeps, and a chain stays in its project, so start_detached's tag still + proves the window ours. + + Same contract as claim_ctl_window: operator wording when the claim did not + land, None when it did or when this process is in no control session (an + operator's shell owns no window to rename). A rename that raises is worded + and claims nothing: the window keeps the previous run's name and record, + so `a` and `x` on that run still reach the window driving this one.""" + try: + win_id = get_multiplexer().current_window_id() if in_ctl_session() else None + except MultiplexerError: + if not launched: + raise + win_id = None + if not win_id: + # No window to rename: an operator's shell answers None from the claim, + # a launched window that cannot name itself the claim's wording. + return claim_ctl_window(project, run_id, launched=launched) + try: + get_multiplexer().rename_window(win_id, _ctl_window_name("run", run_id)) + except MultiplexerError as e: + return ( + f"this window could not be renamed for run {run_id} ({e}), so attach and stop " + f"from the TUI cannot find it as the run's control window — stop it with: " + f"bmad-loop stop {run_id}" + ) + return claim_ctl_window(project, run_id, launched=launched) + + def recorded_ctl_window(project: Path, run_id: str) -> str | None: """The window id the run's record names, or None (see _read_ctl_window).""" return _read_ctl_window(project, run_id) @@ -1483,7 +1530,7 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) win_id = ( mux.new_parked_window( ctl, - f"{kind}-{run_id}", + _ctl_window_name(kind, run_id), project, argv, RETURN_OPTION, diff --git a/tests/test_cli.py b/tests/test_cli.py index c8297cf71..7e1f70253 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16505,6 +16505,39 @@ def test_run_on_a_parent_builds_each_child_in_turn(project, monkeypatch, capsys) assert verify.worktree_clean(project.project, project=project.project) +def test_a_chain_switch_claims_the_window_for_the_next_run(project, monkeypatch, capsys): + """BILLING's run starts in the window AUTH's run was launched in, so the switch + claims it for BILLING's new run id (`claim_renamed_ctl_window`) once that run is + composed and before its engine runs; AUTH's run, the one the window was minted + for, claims nothing. A claim that did not land is printed and journaled + `ctl-window-claim-failed` in BILLING's run, which still runs. Ablation + (performed): drop the driver's claim and no claim is made — this FAILS.""" + from bmad_loop.tui import launch + + built = _chain_project(project, monkeypatch, capsys, ["drain", "drain"]) + claims: list = [] + + def claim(project_root, run_id, *, launched=False): + _, state, run_dir = built[-1] + claims.append((project_root, run_id, run_dir.name, state.finished, launched)) + return "this window could not be renamed for run X" + + monkeypatch.setattr(launch, "claim_renamed_ctl_window", claim) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + (_, _, first), (_, second_state, second) = built + # the new run's id, before its engine ran, from a `run` no TUI resume drives + assert claims == [(project.project, second.name, second.name, False, False)] + assert second_state.finished + assert "warning: this window could not be renamed for run X" in capsys.readouterr().err + from bmad_loop.journal import Journal + + faults = [e for e in Journal(second).entries() if e["kind"] == "ctl-window-claim-failed"] + assert [e["error"] for e in faults] == ["this window could not be renamed for run X"] + assert not [e for e in Journal(first).entries() if e["kind"] == "ctl-window-claim-failed"] + + def test_run_on_a_parent_with_nothing_to_build_says_why(project, monkeypatch, capsys): """Both children already done: `run` launches nothing, prints why the chain ends (`all-done`) and exits 0, with no run dir. Ablation (performed): drop the diff --git a/tests/test_conftest.py b/tests/test_conftest.py index 4c160f6bb..d95b738c9 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1681,7 +1681,7 @@ def _declares_loadgroup(addopts: object) -> bool: _OTHER_DEFS = "" # explicit catch-all; a module omitting it refuses unexplained defs _EXPECTED_E2E_DEF_COUNTS: dict[str, dict[str, int]] = { "test_generic_tmux.py": {"test_tmux_": 6}, - "test_tui_launch_tmux_e2e.py": {"test_e2e_": 1}, + "test_tui_launch_tmux_e2e.py": {"test_e2e_": 2}, "test_tmux_locale_e2e.py": {"test_e2e_": 1}, "test_launch_probe.py": {"test_e2e_": 1}, "test_stories_e2e.py": { diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 5daa1361a..3d6a3362d 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -293,6 +293,9 @@ # #925: a refused TUI-launched child's journal row, under the hold cleanup # removes the run in, so the append never recreates a removed run dir ("cli.py", "_journal_unaccepted_launch"), + # a chain switch's claim of its control window for the next run, under the + # hold every claim is made under (the record's writers, cleanup's removal) + ("cli.py", "_drive_initiative_chain"), ("journal.py", "save_state"), ("runs.py", "rearm_escalation"), ("runs.py", "rearm_for_reverify"), diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 15defc90f..ddd3c378f 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -1170,6 +1170,66 @@ def test_claim_outside_a_control_session_leaves_the_record(tmp_path: Path, monke assert record.read_text(encoding="utf-8") == "@2" +def test_a_chain_claim_renames_the_window_then_records_it(fake_run, tmp_path: Path, monkeypatch): + # A chain's next run B starts in the window run A was launched in. The + # window is renamed to the `run-B` start_detached would have minted, and + # only then recorded and re-proved by name. Ablation (performed): skip the + # rename and no rename-window is sent, so the first assertion FAILS. + _make_run(tmp_path, "A") + _write_record(tmp_path, "A", "@7") + _make_run(tmp_path, "B") + fake_run.windows = "@7\trun-B\n" # the listing after the rename + _in_ctl_window(monkeypatch, "@7") + real_record = launch._record_ctl_window + + def record(project: Path, run_id: str, win_id: str) -> None: + assert fake_run.by_verb("rename-window"), "recorded before the rename" + real_record(project, run_id, win_id) + + monkeypatch.setattr(launch, "_record_ctl_window", record) + assert launch.claim_renamed_ctl_window(tmp_path, "B") is None + assert fake_run.by_verb("rename-window") == [ + ["tmux", "-u", "rename-window", "-t", "@7", "run-B"] + ] + assert launch.recorded_ctl_window(tmp_path, "B") == "@7" + + +def test_a_chain_claim_whose_rename_fails_records_nothing(fake_run, tmp_path: Path, monkeypatch): + # The window keeps run A's name, so a record for B would name a window the + # lookup cannot reach as B's: B gets none, A keeps its own, and the + # operator hears. Ablation (performed): claim after a failed rename and B's + # record is written. + _make_run(tmp_path, "A") + a_record = _write_record(tmp_path, "A", "@7") + _make_run(tmp_path, "B") + _in_ctl_window(monkeypatch, "@7") + + def run(argv, **kwargs): + if argv[2] == "rename-window": + fake_run.calls.append(list(argv)) + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="no server\n") + return fake_run(argv, **kwargs) + + monkeypatch.setattr(tmux_base.subprocess, "run", run) + warning = launch.claim_renamed_ctl_window(tmp_path, "B") or "" + assert "could not be renamed for run B" in warning + assert "no server" in warning + assert launch.recorded_ctl_window(tmp_path, "B") is None + assert not (runs.run_dir_for(tmp_path, "B") / launch._CTL_WINDOW_FILE).exists() + assert a_record.read_text(encoding="utf-8") == "@7" + + +def test_a_chain_claim_outside_a_control_session_touches_nothing( + fake_run, tmp_path: Path, monkeypatch +): + # A chain driven from the operator's own shell has no window to rename. + _make_run(tmp_path, "B") + monkeypatch.setattr(launch, "in_ctl_session", lambda: False) + assert launch.claim_renamed_ctl_window(tmp_path, "B") is None + assert fake_run.calls == [] + assert launch.recorded_ctl_window(tmp_path, "B") is None + + def test_a_launched_claim_whose_session_probe_went_unanswered_drops_the_record( tmp_path: Path, monkeypatch ): diff --git a/tests/test_tui_launch_tmux_e2e.py b/tests/test_tui_launch_tmux_e2e.py index c7177052e..f3cc8d883 100644 --- a/tests/test_tui_launch_tmux_e2e.py +++ b/tests/test_tui_launch_tmux_e2e.py @@ -94,3 +94,51 @@ def test_e2e_parked_engine_resolves_the_launchers_root_on_a_stale_server( assert events == runs.events_dir_for(root, "") finally: multiplexer.get_multiplexer.cache_clear() + + +@real_mux_e2e +@pytest.mark.skipif(not HAVE_TMUX, reason="requires Linux with tmux on PATH") +def test_e2e_a_chain_claim_moves_the_control_window_to_the_next_run( + project, tmp_path, monkeypatch, private_tmux_server +): + """A parent-initiative chain builds its next child as run B in the window the + TUI minted for run A; the claim renames that window to `run-B` and records it, + so the lookup answers it for B and no longer for A, whose own record still + names it. On a real tmux server, so the rename, the tag a rename must keep + and the by-name re-prove are tmux's, not a fake's. + + The window is a real start_detached window for A (named and tagged as the + TUI mints them), parked on `bmad-loop --version`. The claim is called from + this process, not from inside the window: the in-window probes + (`in_ctl_session`, `current_window_id`) are monkeypatched to answer that + window, which is what a chained engine running in it would read.""" + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "state")) + multiplexer.get_multiplexer.cache_clear() + root = project.project + for run_id in ("A", "B"): + run_dir = runs.run_dir_for(root, run_id) + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "state.json").write_text("{}", encoding="utf-8") + try: + win = launch.start_detached(root, ["--version"], "A", "run") + assert win, "the control window was not minted" + (runs.run_dir_for(root, "A") / launch._CTL_WINDOW_FILE).write_text(win, encoding="utf-8") + assert launch.ctl_window_id(root, "A") == win + + monkeypatch.setattr(launch, "in_ctl_session", lambda: True) + mux = multiplexer.get_multiplexer() + monkeypatch.setattr(type(mux), "current_window_id", lambda self: win) + assert launch.claim_renamed_ctl_window(root, "B") is None + + name = subprocess.run( + ["tmux", "display-message", "-p", "-t", win, "#{window_name}"], + capture_output=True, + text=True, + ) + assert name.stdout.strip() == "run-B", name.stderr + assert launch.recorded_ctl_window(root, "B") == win + assert launch.ctl_window_lookup(root, "B") == (win, 0) # the tag survived + assert launch.ctl_window_id(root, "A") is None + finally: + multiplexer.get_multiplexer.cache_clear() From 02768a9fea2e371ad35280da96f479e721429f7d Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 01:09:38 -0700 Subject: [PATCH 09/11] chore: bump the pinned pyright from 1.1.411 to 1.1.414 A deliberate bump of the exact dev-group pin (relocked with `uv lock`, no `--upgrade`). The lock diff touches only pyright; its nodeenv and typing-extensions dependencies did not move. The `[tool.pyright]` keys the config comment vouches for (include, exclude, typeCheckingMode, strict, pythonVersion, venvPath, venv) are all still documented in the 1.1.414 configuration.md. None were renamed or removed in the 1.1.412, 1.1.413 or 1.1.414 release notes, so the "Verified against" note now names 1.1.414. New diagnostics under 1.1.414: none. `uv run pyright` reports 0 errors, 0 warnings, so no source changes were needed. --- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8875846d5..218523b3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dev = [ # checks that fail the build on untouched code. This is the single pin — the # typecheck job runs `uv run pyright`, so CI and `uv run pyright` locally are # the same resolved version and cannot drift apart (#245). - "pyright==1.1.411", + "pyright==1.1.414", ] [tool.hatch.build.targets.wheel] @@ -130,7 +130,7 @@ line_length = 100 [tool.pyright] # Stage 1 of #245 (assessment F-4): a basic-mode type-check gate so annotation -# drift stops being invisible. Verified against pyright 1.1.411 docs. +# drift stops being invisible. Verified against pyright 1.1.414 docs. include = ["src/bmad_loop"] # data/ ships skill assets + hook scripts that are templates / stand-alone (not # part of the package's import graph); type-checking them is noise. diff --git a/uv.lock b/uv.lock index 8af041304..27d8a9785 100644 --- a/uv.lock +++ b/uv.lock @@ -68,7 +68,7 @@ provides-extras = ["tui", "non-linux", "opencode"] [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.28" }, - { name = "pyright", specifier = "==1.1.411" }, + { name = "pyright", specifier = "==1.1.414" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=1.0" }, { name = "pytest-xdist", specifier = ">=3.6" }, @@ -282,15 +282,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.411" +version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, ] [[package]] From e4e9741203b578ab28eb9ece8d93b4d85233bc9b Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 01:17:21 -0700 Subject: [PATCH 10/11] fix(tickets): a TUI-launched chain's window claim knows it was launched start_detached passes `--ctl-launch` to every child it starts, a `run` included, but the process-wide `_ctl_launch` is set only by `_drive_ctl_launch`, which serves resume and resolve. So a chain started by a TUI `run` claimed each next run's window with `launched=False`. If its session probe then answered None at a switch (a non-zero display-message is a silent None), the claim took the window for an operator's shell. It renamed nothing, recorded nothing, printed nothing and journaled nothing. The window kept `run-`, so `x` on the finished A reached the window driving B, and attach and stop on B found nothing (#925). cli.py: `main` records the parsed `--ctl-launch` in `_ctl_minted` for the command's lifetime and resets it on exit. `_drive_initiative_chain` passes it as the chain claim's `launched`, so both entry points, `run` and `_resume_paused_run` (resume, resolve and the TUI gestures that spawn them), follow one rule: the process's own flag. `_claim_ctl_window` takes the new `launched` override. With None it keeps reading `_ctl_launch`, which a resume or resolve sets exactly when it was given the flag. `_ctl_launch` stays the resume/resolve acceptance flag. As before, a chain switch does not touch `accepted`. Tests: a chained `--ctl-launch run` whose session probe answers None at the switch prints the warning and journals `ctl-window-claim-failed` in the new run (ablation performed: `launched=False` FAILS). The same chain without the flag stays silent, with no row and no rename. --- src/bmad_loop/cli.py | 46 +++++++++++++++++++++--- src/bmad_loop/tui/launch.py | 6 +++- tests/test_cli.py | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 176207980..2e4647148 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4882,7 +4882,11 @@ def _drive_initiative_chain( (``_claim_ctl_window`` with ``renamed``), so the TUI's attach and stop on the next run reach the window and those on the finished run no longer do. A claim that does not land is printed and journaled ``ctl-window-claim-failed`` - in the next run, which goes on regardless. + in the next run, which goes on regardless. The claim's ``launched`` is + ``_ctl_minted``, this process's own ``--ctl-launch``, whichever command + started the chain: a launched ``run`` sets no ``_ctl_launch``, so a session + probe gone unanswered at its switch is reported as a launched resume's is + (#925), while an operator's shell still claims nothing in silence. Returns 0, 1 for a tree fault or a close that could not be committed, or the refusal rc of the next launch.""" @@ -4965,7 +4969,13 @@ def _drive_initiative_chain( # rival resume can accept the new run. try: with state_lock(launched.run_dir): - _claim_ctl_window(project, launched.run_dir, launched.state, renamed=True) + _claim_ctl_window( + project, + launched.run_dir, + launched.state, + renamed=True, + launched=_ctl_minted, + ) except OSError as exc: _report_ctl_window_fault( launched.run_dir, @@ -6644,6 +6654,14 @@ def __getattr__(self, name: str) -> Any: # ponytail: process-wide, which is what the flag is — one command per process. _ctl_launch: _CtlLaunch | None = None +# Whether this process was started with the hidden `--ctl-launch`: the launcher's +# word that it minted the window this process runs in (`tui.launch.start_detached`, +# for every kind it starts, a `run` included). Set by `main` from the parsed flag +# for the command's lifetime. Unlike `_ctl_launch`, which only a resume/resolve +# sets and which tracks its acceptance, it reaches every command, so a chain's +# window claims read it whichever command drives the chain. +_ctl_minted = False + # The most of a refused launch's reason one journal entry keeps: the line trim # bounds the count, not a line (a validator echoing one huge policy scalar), and # an exception's message skips it. @@ -6651,7 +6669,12 @@ def __getattr__(self, name: str) -> Any: def _claim_ctl_window( - project: Path, run_dir: Path, state: RunState, *, renamed: bool = False + project: Path, + run_dir: Path, + state: RunState, + *, + renamed: bool = False, + launched: bool | None = None, ) -> None: """Publish the control window this process runs in as the run's, once this process has accepted the run (`tui.launch.claim_ctl_window`). The caller holds @@ -6664,6 +6687,12 @@ def _claim_ctl_window( flag alone: it is the launched command's acceptance of its own run, settled before any chain switch. + ``launched`` is the claim's word that the TUI minted this window + (`tui.launch.claim_ctl_window`). The chain passes ``_ctl_minted``, the parsed + ``--ctl-launch``, since a launched ``run`` sets no ``_ctl_launch``; None reads + it off ``_ctl_launch``, which a resume/resolve sets exactly when it was given + the flag (:func:`_drive_ctl_launch`). + A claim that did not land is journaled as well as printed: a TUI-launched resume prints into a detached window nobody is looking at, while the journal is the run's durable record, written through a journal pinned to the @@ -6679,9 +6708,11 @@ def _claim_ctl_window( ctl = _ctl_launch if ctl is not None and not renamed: ctl.accepted = True + if launched is None: + launched = ctl is not None claim = launch.claim_renamed_ctl_window if renamed else launch.claim_ctl_window try: - warning = claim(project, run_dir.name, launched=ctl is not None) + warning = claim(project, run_dir.name, launched=launched) except (MultiplexerError, OSError) as exc: warning = f"could not record this window as run {run_dir.name}'s control window: {exc}" if warning is not None: @@ -11143,7 +11174,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--state-root", help=argparse.SUPPRESS) parser.add_argument("--registry-root", help=argparse.SUPPRESS) # Hidden, composed the same way: this process runs in a window the launcher - # minted (`_drive_ctl_launch`, `tui.launch.claim_ctl_window`, #925). + # minted (`_ctl_minted`, `_drive_ctl_launch`, `tui.launch.claim_ctl_window`, + # #925). parser.add_argument("--ctl-launch", action="store_true", help=argparse.SUPPRESS) sub = parser.add_subparsers(dest="command", required=True) @@ -11694,6 +11726,8 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: from .adapters.psmux_backend import note_displaced_registry note_displaced_registry(args.displaced_registry_root) + global _ctl_minted + _ctl_minted = bool(args.ctl_launch) try: # Install the policy [mux] backend choice before dispatch: several # handlers (probe/diagnose/attach/stop/cleanup/tui) reach the mux @@ -11729,6 +11763,8 @@ def add(name: str, func, help: str, *, aliases=()) -> argparse.ArgumentParser: # die to the parked control pane with a bare traceback. print(f"error: {e}", file=sys.stderr) return ExitCode.FAILURE + finally: + _ctl_minted = False if __name__ == "__main__": diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index 0470203ec..c068c420d 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -450,7 +450,11 @@ def claim_renamed_ctl_window(project: Path, run_id: str, *, launched: bool = Fal land, None when it did or when this process is in no control session (an operator's shell owns no window to rename). A rename that raises is worded and claims nothing: the window keeps the previous run's name and record, - so `a` and `x` on that run still reach the window driving this one.""" + so `a` and `x` on that run still reach the window driving this one. + + `launched` is the chain process's own `--ctl-launch`, whichever command + started the chain (a TUI `run` included): with it, a session probe that + goes unanswered is worded, not taken for an operator's shell.""" try: win_id = get_multiplexer().current_window_id() if in_ctl_session() else None except MultiplexerError: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e1f70253..b464ebfef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16538,6 +16538,76 @@ def claim(project_root, run_id, *, launched=False): assert not [e for e in Journal(first).entries() if e["kind"] == "ctl-window-claim-failed"] +def _unanswered_session_probe(monkeypatch) -> list: + """The chain's control-window probes as a launched window whose session probe + answers None (a non-zero `display-message` is a silent None): no session, so + no window to rename. Returns the list each rename_window appends its args to.""" + from bmad_loop.tui import launch + + renames: list = [] + + class Mux: + def current_session(self): + return None + + def current_window_id(self): + raise AssertionError("outside a control session, no window is named") + + def rename_window(self, *a): + renames.append(a) + + monkeypatch.setattr(launch, "get_multiplexer", Mux) + return renames + + +def test_a_launched_chain_whose_session_probe_is_unanswered_says_so(project, monkeypatch, capsys): + """A `run` the TUI launched carries `--ctl-launch` but sets no `_ctl_launch` + (only a resume/resolve does), so the switch reads the parsed flag: at the + switch its session probe answers None, the window cannot be renamed for + BILLING's run, and that is printed and journaled `ctl-window-claim-failed` in + BILLING's run (#925), never taken for an operator's shell. Ablation + (performed): pass `launched=False` from the chain driver and no row is + journaled — this FAILS.""" + from bmad_loop.journal import Journal + + built = _chain_project(project, monkeypatch, capsys, ["drain", "drain"]) + renames = _unanswered_session_probe(monkeypatch) + + assert cli.main(["--ctl-launch", "run", "--project", str(project.project)]) == 0 + + (_, _, first), (_, second_state, second) = built + assert second_state.finished + err = capsys.readouterr().err + assert f"warning: this window could not be recorded as run {second.name}'s" in err + faults = [e for e in Journal(second).entries() if e["kind"] == "ctl-window-claim-failed"] + assert [e["error"] for e in faults] == [ + f"this window could not be recorded as run {second.name}'s control window — " + "attach and stop from the TUI may target an older window for this run" + ] + assert not [e for e in Journal(first).entries() if e["kind"] == "ctl-window-claim-failed"] + assert renames == [] + + +def test_an_operator_shell_chain_claims_no_window_in_silence(project, monkeypatch, capsys): + """The same chain typed in an operator's own shell (no `--ctl-launch`): the + probe's None is the answer "no control session", so the switch renames + nothing, records nothing and says nothing.""" + from bmad_loop.journal import Journal + + built = _chain_project(project, monkeypatch, capsys, ["drain", "drain"]) + renames = _unanswered_session_probe(monkeypatch) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + (_, _, first), (_, second_state, second) = built + assert second_state.finished + assert "control window" not in capsys.readouterr().err + for run_dir in (first, second): + kinds = [e["kind"] for e in Journal(run_dir).entries()] + assert "ctl-window-claim-failed" not in kinds + assert renames == [] + + def test_run_on_a_parent_with_nothing_to_build_says_why(project, monkeypatch, capsys): """Both children already done: `run` launches nothing, prints why the chain ends (`all-done`) and exits 0, with no run dir. Ablation (performed): drop the From 1a972b69950c76c2003a19b8ae5bb4fd1b2d68c4 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 22:39:52 -0700 Subject: [PATCH 11/11] test(tui): the outside-a-session chain claim row fails when its gate goes `test_a_chain_claim_outside_a_control_session_touches_nothing` left `current_window_id` unpatched, so with the `in_ctl_session` gate in `claim_renamed_ctl_window` removed the probe still answered None and the row passed for the wrong reason. The row now has the multiplexer name window @7. Ablation (performed): drop the gate and @7 is renamed, so the `fake_run.calls == []` assertion FAILS. --- tests/test_tui_launch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index ddd3c378f..7710b7ce8 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -1222,9 +1222,12 @@ def run(argv, **kwargs): def test_a_chain_claim_outside_a_control_session_touches_nothing( fake_run, tmp_path: Path, monkeypatch ): - # A chain driven from the operator's own shell has no window to rename. + # A chain driven from the operator's own shell has no window to rename, + # even when the multiplexer would name the window it runs in. Ablation + # (performed): drop the in_ctl_session gate and @7 is renamed. _make_run(tmp_path, "B") monkeypatch.setattr(launch, "in_ctl_session", lambda: False) + monkeypatch.setattr(type(get_multiplexer()), "current_window_id", lambda self: "@7") assert launch.claim_renamed_ctl_window(tmp_path, "B") is None assert fake_run.calls == [] assert launch.recorded_ctl_window(tmp_path, "B") is None