diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc79a618..674911421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,11 @@ breaking changes may land in a minor release. closes for unlanded code; a new sweep warns while such a run exists (DW-571). - Pass `--allow-all-urls` in copilot's `bypass_args` so unattended sessions fetch URLs without a permission prompt; this changes the copilot default config digest (DW-598). +- Build a parent initiative's children one run each when `run` targets a parent (was + 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). ### Removed diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 373554693..9d5fd4637 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -51,7 +51,8 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches - **Properties:** - A run is pinned to `[core] active_initiative` at start; a changed initiative pauses the run at `ticket-tree` instead of building another initiative's ticket. - The tree is read in the layout the installed `tickets.py` uses: a `tickets/` folder inside each initiative and epic, or the preview-era flat layout. A parent initiative passes the preflight only under worktree isolation without `gates.retrospective = "auto"`, with the store checks run for each sliced child (`tickets.initiative-parent`), and an epic declared but not sliced yet holds no tickets. - - `run` on a parent initiative builds its children one run each in build order, writing every drained child's initiative `status: done` before each pick (one a stopped chain left open included), under one `--max-stories` budget; a child run that does not finish holds the chain until `resume` or `resolve` drives it to finished, and the chain's end names what each waiting child waits on. + - `run` on a parent initiative builds its children one run each in build order, writing every drained child's initiative `status: done` before each pick (one a stopped chain left open included), under one `--max-stories` budget; a child run that does not finish holds the chain until `resume` or `resolve` drives it to finished, and the chain's end names what each waiting child waits on. Each child's run pauses at `epic-boundary` after every epic it finishes, as a one-initiative run does. + - `run --dry-run` on a parent initiative writes nothing: it lists the children in build order with a tag, the child a run builds first and that child's dry run; the TUI start-run preview lists the children and the first ready child's board, and a chained child run's dashboard board shows that child. - A hitl ticket pauses at `ticket-hitl` and a ticket that needs refining at `ticket-refine`, both before any session runs for it. - bmad-loop writes a plan's `status` and `blocked_*` only through `tickets.py mark`, under a lock, read back afterwards and checked against one plan transition table; it never writes `dropped` or an epic file. - Tickets this run did not start are never dispatched or marked. @@ -60,7 +61,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches - `run` refuses a tracker store and the multi-repo workspace layout before creating a run directory; resume and resolve refuse a store whose layout changed. - When a run without `--story` finishes an epic, it runs the retrospective and pauses once at `epic-boundary` for a person to close the epic; a `rejected` verdict pauses every later pick while the epic stays open, and an epic gated on an open one waits for its closure. - **Config:** `[stories] source = "tickets"`, `[core] active_initiative`, `[tickets] mark_done`, `[tickets] thorough_review`, `[gates] retrospective`; `bmad-loop run --epic ID|SLUG`, `--story REF`, `--dry-run`; `bmad-loop confirm ` -- **Tested by:** `tests/test_tickets_engine.py`, `tests/test_tickets_source.py`, `tests/test_tickettree.py`, `tests/test_ticketmodel.py`, `tests/test_ticketstore.py`, `tests/test_statemachine.py`, `tests/test_initiativechain.py`, `tests/test_cli.py`, `tests/test_tickets_e2e.py` +- **Tested by:** `tests/test_tickets_engine.py`, `tests/test_tickets_source.py`, `tests/test_tickettree.py`, `tests/test_ticketmodel.py`, `tests/test_ticketstore.py`, `tests/test_statemachine.py`, `tests/test_initiativechain.py`, `tests/test_cli.py`, `tests/test_tui_data.py`, `tests/test_tickets_e2e.py` - **Docs:** [Ticket-tree mode guide](tickets-guide.md), [Setup: BMAD v7](setup-guide.md#installing-on-bmad-v7-bmad-setup), [Configuration: [tickets]](reference/configuration.md#tickets), [Resolve in tickets mode](escalations-guide.md#resolve-in-tickets-mode), [TUI: tickets mode](tui-guide.md#tickets-mode) ### Trust-nothing verification diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2cb776e23..ee00f8758 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -165,8 +165,8 @@ 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; `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, `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. - 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 246c22c8a..e3e069c96 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -53,7 +53,13 @@ Closing a child writes `status: done` into its `.md`. It happens before e 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). -`--epic` and `--story` select within one initiative, so `run` on a parent refuses 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. +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. Close the epic with bmad-agent-po and commit it (see [closure](#epic-retrospective-and-closure)), then `bmad-loop resume `. The resume goes on with that child, and once its run finishes the chain goes on to the next child. + +`--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. + +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. ## Store layouts diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 928227b37..2f2ca5a62 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -164,6 +164,11 @@ ticket: state glyph and label, the ref, the epic folder, a three-slot flags cell and the title. The state is the plan's status when the ticket has a plan, else `planned` (or `refine` when it must be refined first). +A run that builds one child of a parent initiative shows that child's tickets, +read as `bmad-loop status` reads them: through the child's folder, with the tree +pinned to the parent that `[core] active_initiative` names. A config that no +longer names the parent shows as the board's fault. + `tickets.py` is the project's own code, so the board reads it on the poll worker, never the UI thread. It runs again only when a `tickets.toml` or `.md` file in the initiative tree (its `tickets/` folders and epic folders, in either layout), the @@ -393,8 +398,15 @@ To return to the default proportions, delete the `[tui]` size keys from - **source**: `sprint mode` (walks `sprint-status.yaml`), `stories mode` (folder-and-id dispatch) or `tickets mode` (the BMAD v7 ticket tree), prefilled from `[stories]`. Tickets mode previews the active initiative: its - ticket counts and the refs that pause on hitl or a checkpoint. A tickets pick - launches only while `[stories] source = "tickets"`. Under that policy a + 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 + 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. - **spec folder**: stories mode only. The epic spec folder holding diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index d27c889d9..2b1ad675f 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -2677,7 +2677,10 @@ def _warn_preflight_would_abort( ``require_sweep`` (the sweep dry-run) also mirrors `_require_sweep_skill`: the triage tree's ``bmad-loop-sweep`` is a sweep-only gate, so a story-run preview must not promise an abort over it. ``require_tickets`` (the tickets - dry-run) mirrors `_require_base_skills`' ``require_tickets`` finding set. + dry-run) mirrors `_require_base_skills`' ``require_tickets`` finding set and + the tickets preflight's refusal of a parent initiative + (`install.tickets_parent_problems`: one the isolation or retrospective setting + cannot chain, or a child store it refuses). Both mirror the store-topology refusal (`_reject_store_topology`) the real command applies: a tickets run's store, and the store of the source a sweep works. @@ -2700,6 +2703,13 @@ def _warn_preflight_would_abort( ) if p.severity == "problem" ] + if require_tickets: + problems += [ + p.message + for p in install.tickets_parent_problems( + paths, pol.scm.isolation, pol.gates.retrospective + ) + ] # the store the launch judges: a tickets run's, or the one a sweep works store_source = ( worksource.SOURCE_TICKETS @@ -4245,12 +4255,7 @@ def cmd_run(args: argparse.Namespace) -> int: # 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( - f"error: initiative {parent!r} is a parent initiative: --epic and --story " - "select within one initiative — set `[core] active_initiative` to the " - "child to scope a run", - file=sys.stderr, - ) + print(_chain_selector_refusal(parent), file=sys.stderr) return 1 try: # A child a stopped, interrupted or crashed chain left drained and open, @@ -4422,6 +4427,16 @@ def _chain_parent(paths: bmadconfig.ProjectPaths) -> str | int | None: return None if children is None else paths.active_initiative +def _chain_selector_refusal(parent: str) -> str: + """Why ``run`` (and its dry run) refuses ``--epic``/``--story`` on the parent + initiative ``parent``.""" + return ( + f"error: initiative {parent!r} is a parent initiative: --epic and --story " + "select within one initiative — set `[core] active_initiative` to the " + "child to scope a run" + ) + + def _chain_ticket_tree( project: Path, parent: str, folder: Path | None = None ) -> tickettree.TicketTree: @@ -4442,16 +4457,21 @@ def _fresh_pick( def _chain_pick( - project: Path, paths: bmadconfig.ProjectPaths, parent: str + project: Path, + paths: bmadconfig.ProjectPaths, + parent: str, + view: ticketmodel.ParentView | None = None, ) -> initiativechain.ChildPick: """The next child of the parent initiative ``parent`` (``paths`` are the - parent's): :func:`initiativechain.next_child` over ``next ``, passing - over as ``drained`` a ready child whose fresh pick (:func:`_fresh_pick`) finds - nothing to dispatch. Raises :class:`tickettree.TicketsError` on a tree fault and - ValueError on a child slug that is no folder name.""" + 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.""" folder = paths.initiative_folder assert folder is not None # _chain_parent - view = _chain_ticket_tree(project, parent).parent_next(folder) + if view is None: + view = _chain_ticket_tree(project, parent).parent_next(folder) def drained(child: ticketmodel.ChildInitiative) -> str | None: tree = _chain_ticket_tree( @@ -5187,13 +5207,114 @@ def _predicted_pause(decision: tickets_source.PickDecision, max_stories: int | N def _dry_run_tickets( paths: bmadconfig.ProjectPaths, pol, args: argparse.Namespace, spec_folder: str ) -> int: - """Print what a tickets run would dispatch from the active initiative: the - startable tickets in build order with their dispatch lines, the pause a fresh - run would reach first, the group counts and the tree's problems. Reads the - tree with ``tickets.py next``/``status`` only — it never marks a ticket.""" + """Print what a tickets run would dispatch from the active initiative + (:func:`_dry_run_ticket_block`), or, on a parent initiative, the chain it + would build (:func:`_dry_run_chain`). Reads only — it never marks a ticket.""" _warn_preflight_would_abort(paths, pol, require_tickets=True) _warn_bypass_dropped(pol, paths.project, ("dev", "review")) - tree = tickettree.TicketTree(paths.project) + parent = _chain_parent(paths) + if isinstance(parent, int): + return parent + if parent is not None: + return _dry_run_chain(paths, pol, args, parent) + return _dry_run_ticket_block(paths, pol, args, tickettree.TicketTree(paths.project)) + + +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).""" + 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): + return "drained (the run closes it first)" + if not child.ready: + return "waiting" + if child.folder is None: + return "no folder" + why = next((p.why for p in pick.passed_over if p.slug == child.slug), None) + return "ready" if why is None else f"ready, passed over ({why})" + + +def _dry_run_chain( + paths: bmadconfig.ProjectPaths, pol, args: argparse.Namespace, parent: str +) -> int: + """Print the chain a run of the parent initiative ``parent`` would build: its + children in build order, each with its status, ticket counts, what it waits on + and its tag (:func:`_chain_child_tag`); the first child's tickets block + (:func:`_dry_run_ticket_block`, read through that child's folder); and the + chain's predicted line. When no child can start, the line ``cmd_run`` ends the + 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 + 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) + return 1 + project = paths.project + folder = paths.initiative_folder + assert folder is not None # _chain_parent + try: + view = _chain_ticket_tree(project, parent).parent_next(folder) + pick = _chain_pick(project, paths, parent, view) + 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) + return 1 + except ValueError as e: + print(f"tickets mode: initiative chain {parent}: {e}", file=sys.stderr) + return 1 + print( + f"tickets mode: parent initiative {parent} — {len(view.initiatives)} children " + "in build order:" + ) + for position, child in enumerate(view.initiatives, 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 + ) + 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 + else "" + ) + if pick.child is None or child_paths is None: + print(f"\npredicted: {_chain_end_line(parent, pick.end or '', pick)}{closes}") + return 0 + print() + tree = _chain_ticket_tree(project, parent, child_paths.initiative_folder) + if (rc := _dry_run_ticket_block(child_paths, pol, args, tree)) != 0: + return rc + print( + f"predicted: chain starts with {pick.child.slug}; later children are chosen " + f"as each finishes{closes}" + ) + return 0 + + +def _dry_run_ticket_block( + paths: bmadconfig.ProjectPaths, + pol, + args: argparse.Namespace, + tree: tickettree.TicketTreeLike, +) -> int: + """Print what a tickets run of ``paths``' active initiative would dispatch, + read through ``tree``: the startable tickets in build order with their + dispatch lines, the pause a fresh run would reach first, the group counts and + the tree's problems. Reads the tree with ``tickets.py next``/``status`` only.""" try: next_view = tree.next() status_view = tree.status() diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py index b20cdcabd..b39814edc 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -90,6 +90,11 @@ class ChildPick: waiting_on: tuple[WaitingOn, ...] = () +def closed_tickets(child: ChildInitiative) -> int: + """How many of ``child``'s tickets are done or dropped (0 without a folder).""" + return sum(child.counts.get(state, 0) for state in TICKET_CLOSED_STATES) + + def drained(child: ChildInitiative) -> bool: """Whether ``child`` is open with every ticket closed: it has a folder, is not done or dropped, and counts at least one ticket, all done or dropped — the @@ -97,8 +102,7 @@ def drained(child: ChildInitiative) -> bool: if child.folder is None or child.status in _CLOSED: return False total = child.counts.get("total", 0) - closed = sum(child.counts.get(state, 0) for state in TICKET_CLOSED_STATES) - return total > 0 and closed == total + return total > 0 and closed_tickets(child) == total def prerequisite_status(view: ParentView, slug: str) -> str: diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index 52b36d277..cc5de2c44 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -993,6 +993,26 @@ def _tickets_parent_findings( ] +def tickets_parent_problems( + paths: bmadconfig.ProjectPaths, isolation: str, retrospective: str | None +) -> list[Finding]: + """The problems :func:`tickets_preflight` refuses a parent active initiative + with (:func:`_tickets_parent_findings`: ``tickets.initiative-parent`` and the + store of a child it refuses), or the fault that kept the parent read from + answering — for a caller that does not run the whole preflight (the dry-run + banner). Empty when the active initiative is no parent.""" + folder = paths.initiative_folder + if paths.era != "v7" or folder is None or not paths.active_initiative: + return [] + children, breakdown, fault = _initiative_children(folder) + if fault: + return [_probe_unreadable("tickets.initiative-parent", str(breakdown), fault)] + if children is None: + return [] + findings = _tickets_parent_findings(paths, children, isolation, retrospective, (), ()) + return [f for f in findings if f.severity == "problem"] + + def tickets_preflight( paths: bmadconfig.ProjectPaths, trees: Sequence[str], diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index e06ed7077..e8db9266b 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -38,6 +38,7 @@ from .. import ( bmadconfig, deferredwork, + initiativechain, parked, policy, sprintstatus, @@ -80,7 +81,9 @@ _StatSig, list_run_dirs, liveness, + paths_for_state, rebase_recorded_project_path, + tree_pin, ) # ------------------------------------------------------------- run watching @@ -1129,17 +1132,43 @@ def _stories_board(project: Path, state: RunState) -> list[stories.StoryRow] | N return stories_overview(project, state.spec_folder) +@dataclass(frozen=True) +class ChainChild: + """One child of a parent initiative on the start-run preview, in build order: + its status as a prerequisite (:func:`~bmad_loop.initiativechain.prerequisite_status`), + its closed and total ticket counts (0 and 0 without a folder), its tag + (:func:`_chain_tag`), and each prerequisite it still waits on with that + prerequisite's status.""" + + slug: str + status: str + closed: int + total: int + tag: str + waiting_on: tuple[initiativechain.WaitingOn, ...] + + @dataclass(frozen=True) class TicketsBoard: """The tickets-mode board for one initiative: ``tickets.py status``'s rows in the script's order, or why the tree could not be read. A read that failed carries ``fault`` (and no rows), never an empty board that would read as "no tickets". ``initiative`` is the one the board was read for (``""`` when there - was none to read).""" + was none to read). + + On a parent initiative (the start-run preview only) ``parent`` names it and + ``children`` lists its children in build order; the rows are those of the + child a run would build first (``initiative``), or, when no child can start, + there are none, ``initiative`` is ``""`` and ``chain_end`` says why + (:data:`~bmad_loop.initiativechain.ChainEnd`). A fault reading that child's + board keeps ``children``.""" rows: tuple[ticketmodel.TicketRow, ...] fault: str | None initiative: str + parent: str = "" + children: tuple[ChainChild, ...] = () + chain_end: str | None = None # A board read that failed is retried after this long even when no file changed: @@ -1147,9 +1176,9 @@ class TicketsBoard: # next edit could outlive its cause. An answer is kept until a file changes. TICKETS_FAULT_RETRY_S = 30.0 -# (project, initiative, initiative folder) -> (tree signature, board, monotonic -# time of the read) -_tickets_cache: dict[tuple[Path, str, Path], tuple[object, TicketsBoard, float]] = {} +# (project, initiative, initiative folder, parent the tree is pinned to or None) +# -> (tree signature, board, monotonic time of the read) +_tickets_cache: dict[tuple[Path, str, Path, str | None], tuple[object, TicketsBoard, float]] = {} def _tickets_tree_sig(project: Path, folder: Path) -> object: @@ -1203,13 +1232,27 @@ def _board_ticket_tree( return tickettree.TicketTree(project, pinned_initiative=initiative, folder=folder) -def initiative_board(project: Path, initiative: str, folder: Path) -> TicketsBoard: +def _parent_view(project: Path, parent: str, folder: Path) -> ticketmodel.ParentView: + """``tickets.py next `` on the parent initiative ``parent``, pinned to + it: its children, which are ready and what each waits on.""" + return tickettree.TicketTree(project, pinned_initiative=parent).parent_next(folder) + + +def initiative_board( + project: Path, initiative: str, folder: Path, *, parent: str | None = None +) -> TicketsBoard: """``initiative``'s board, stat-gated on :func:`_tickets_tree_sig`: the script is spawned only when that signature changes (or a cached fault is older than :data:`TICKETS_FAULT_RETRY_S`). ``tickets.py`` is project code (it imports the project's ``config_utils``) and may run for seconds, so call this - from a worker thread, never the UI thread.""" - key = (project, initiative, folder) + from a worker thread, never the UI thread. + + With ``parent``, ``initiative`` is a child of that parent initiative: the + tree is pinned to the parent (the live ``[core] active_initiative`` names it) + and reads through ``folder``, as a chained run's tree does + (:func:`~bmad_loop.runs.tree_pin`). Without, it is pinned to ``initiative`` + and reads the active initiative.""" + key = (project, initiative, folder, parent) sig = _tickets_tree_sig(project, folder) # before the read: an edit mid-read re-reads cached = _tickets_cache.get(key) if cached is not None and cached[0] == sig: @@ -1217,7 +1260,11 @@ def initiative_board(project: Path, initiative: str, folder: Path) -> TicketsBoa if board.fault is None or time.monotonic() - cached[2] < TICKETS_FAULT_RETRY_S: return board try: - view = _board_ticket_tree(project, initiative, folder=None).status() + if parent is None: + tree = _board_ticket_tree(project, initiative, folder=None) + else: + tree = _board_ticket_tree(project, parent, folder=folder) + view = tree.status() except tickettree.TicketsError as e: board = TicketsBoard((), f"tickets.py {e.kind}: {e}", initiative) else: @@ -1232,9 +1279,18 @@ def tickets_overview(project: Path, state: RunState) -> TicketsBoard: :func:`initiative_board` reads it — off the UI thread. The recorded folder moves with a renamed project (:func:`~bmad_loop.runs.rebase_recorded_project_path`), so the signature watches the tree the script reads. A run with no pinned - initiative has no tree to read and says so in ``fault``.""" + initiative has no tree to read and says so in ``fault``. + + A chained child run (``RunState.parent_initiative`` set) is pinned and read + as ``bmad-loop status`` reads it (``runs.recorded_tree_pin`` in the main + checkout): :func:`~bmad_loop.runs.tree_pin` over the live paths re-rooted on + the child (:func:`~bmad_loop.runs.paths_for_state`), from the stat-gated + tickets-mode paths. A config that cannot be read, or no longer names the + parent, is the board's ``fault``.""" if not state.initiative or not state.initiative_folder: return TicketsBoard((), "this run pinned no initiative", state.initiative) + if state.parent_initiative is not None: + return _chained_run_board(project, state) folder = Path(state.initiative_folder) if not folder.is_absolute(): folder = project / folder @@ -1242,21 +1298,111 @@ def tickets_overview(project: Path, state: RunState) -> TicketsBoard: return initiative_board(project, state.initiative, folder) +def _chained_run_board(project: Path, state: RunState) -> TicketsBoard: + """:func:`tickets_overview` for a chained child run (see there).""" + paths = _project_paths(project, want_tickets=True) + if paths is None: + why = _tickets_paths_fault.get(project, "the project is not initialized") + return TicketsBoard((), f"BMAD config unreadable: {why}", state.initiative) + try: + pin, folder = tree_pin(state, paths_for_state(paths, state)) + except (bmadconfig.BmadConfigError, ValueError) as e: + return TicketsBoard((), str(e), state.initiative) + if pin is None or folder is None: # tree_pin always roots a chained run on its child + return TicketsBoard((), "this run pinned no initiative", state.initiative) + return initiative_board(project, state.initiative, folder, parent=pin) + + def project_tickets_overview(project: Path) -> TicketsBoard: """The board a new tickets run would start from: the project's live ``[core] active_initiative``. A project that is not v7, or names no initiative, says so in ``fault``. Resolves as ``bmad-loop run`` does in tickets mode, so a mixed v6/v7 install previews its v7 initiative. Spawns like :func:`initiative_board` - — off the UI thread.""" + — off the UI thread. + + A parent initiative (its ``tickets.toml`` lists ``[[initiative]]`` children, + read as ``bmad-loop run`` detects one) previews as its chain: + :func:`_parent_board`. A ``tickets.toml`` that cannot be read is a fault.""" + from .. import install # lazy: pulls the adapters; keep this module import-light + paths = _project_paths(project, want_tickets=True) if paths is None: why = _tickets_paths_fault.get(project, "the project is not initialized") return TicketsBoard((), f"BMAD config unreadable: {why}", "") if paths.era != "v7" or paths.initiative_folder is None or not paths.active_initiative: return TicketsBoard((), "no v7 active initiative ([core] active_initiative)", "") + children, breakdown, fault = install._initiative_children(paths.initiative_folder) + if fault: + return TicketsBoard((), f"cannot read {breakdown}: {fault}", "") + if children is not None: + return _parent_board(project, paths) return initiative_board(project, paths.active_initiative, paths.initiative_folder) +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``.""" + 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 not child.ready: + return "waiting" + if child.folder is None: + return "no folder" + return "ready" + + +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. + + 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 + ``next``/``status`` read per child; ``run --dry-run`` prints that prediction.""" + parent = paths.active_initiative + folder = paths.initiative_folder + assert parent and folder is not None # project_tickets_overview + try: + 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 + ) + children = tuple( + ChainChild( + slug=c.slug, + status=initiativechain.prerequisite_status(view, c.slug), + closed=initiativechain.closed_tickets(c), + total=c.counts.get("total", 0), + 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 + ) + if pick.child is None: + return TicketsBoard((), None, "", parent=parent, children=children, chain_end=pick.end) + slug = pick.child.slug + try: + child_folder = paths.with_initiative(slug).initiative_folder + except ValueError as e: + return TicketsBoard( + (), f"initiative chain {parent}: {e}", slug, parent=parent, children=children + ) + assert child_folder is not None # with_initiative + board = initiative_board(project, slug, child_folder, parent=parent) + return TicketsBoard(board.rows, board.fault, slug, parent=parent, children=children) + + def _tickets_board(project: Path, state: RunState) -> TicketsBoard: return tickets_overview(project, state) diff --git a/src/bmad_loop/tui/screens/modals.py b/src/bmad_loop/tui/screens/modals.py index 5dc7a37bc..cfc96f7c6 100644 --- a/src/bmad_loop/tui/screens/modals.py +++ b/src/bmad_loop/tui/screens/modals.py @@ -180,6 +180,60 @@ def action_cancel(self) -> None: self.dismiss(None) +def _append_board(text: Text, board: data.TicketsBoard) -> None: + """A tickets preview's body under its header: the counts by derived state, + then the open tickets that pause a run (hitl, plan and done checkpoints).""" + counts: dict[str, int] = {} + for row in board.rows: + counts[row.state] = counts.get(row.state, 0) + 1 + if counts: + text.append("\n " + " · ".join(f"{n} {state}" for state, n in counts.items())) + flagged = [r for r in board.rows if r.state not in ("done", "dropped")] + for label, pick in ( + ("hitl", lambda r: r.hitl), + ("plan checkpoint", lambda r: r.plan_checkpoint), + ("done checkpoint", lambda r: r.done_checkpoint), + ): + refs = [tickets_source.row_label(r) for r in flagged if pick(r)] + if refs: + text.append(f"\n {label}: {', '.join(refs)}", style="magenta") + + +def _chain_preview(board: data.TicketsBoard) -> Text: + """The tickets preview of a parent initiative (:func:`data.project_tickets_overview`): + one line per child in build order with its status, ticket counts, tag and what + it waits on, then the board of the first ready child, or why no child can + start, or the fault that kept the children or that board from being read.""" + text = Text() + text.append( + f"parent initiative {board.parent} · {len(board.children)} children in build order", + style="bold", + ) + for position, child in enumerate(board.children, 1): + status = child.status + if child.total: + status += f"; {child.closed} of {child.total} tickets closed" + text.append(f"\n {position}. {child.slug} ({status}) ") + text.append(f"[{child.tag}]", style="cyan" if child.tag == "first ready" else "magenta") + if child.waiting_on: + waits = ", ".join(f"{w.prerequisite} ({w.status})" for w in child.waiting_on) + text.append(f" waits on: {waits}", style="dim") + if board.fault is not None: + text.append(f"\n⚠ tickets board unavailable: {board.fault}", style="red") + return text + if board.chain_end is not None: + text.append(f"\nno child can start ({board.chain_end})", style="yellow") + else: + 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 — " + "run --dry-run prints the exact chain prediction", + style="dim", + ) + return text + + class StartRunModal(BaseDialog): """Options for `bmad-loop run`. @@ -189,8 +243,9 @@ class StartRunModal(BaseDialog): present) and lists the linear schedule with independent spec/done checkpoint markers — the same projection `run --dry-run` prints. In tickets mode the preview is the active initiative's board (:func:`data.project_tickets_overview`), - read on a worker thread because it runs the project's ``tickets.py``. The - epic input is a number, or in tickets mode an epic id or slug + read on a worker thread because it runs the project's ``tickets.py``; on a + parent initiative it lists the children and the first ready one's board + (:func:`_chain_preview`). The epic input is a number, or in tickets mode an epic id or slug (:func:`epic_selector`). Returns ``{source, spec_folder, epic, story, max_stories, dry_run}``.""" @@ -330,25 +385,15 @@ def _show_tickets_preview(self, board: data.TicketsBoard, seq: int) -> None: return # dismissed while the read was in flight if source != worksource.SOURCE_TICKETS: return # the picker moved on; that source's preview stands + if board.parent: + body.update(_chain_preview(board)) + return if board.fault is not None: body.update(Text(f"⚠ tickets board unavailable: {board.fault}", style="red")) return - counts: dict[str, int] = {} - for row in board.rows: - counts[row.state] = counts.get(row.state, 0) + 1 text = Text() text.append(f"initiative {board.initiative} · {len(board.rows)} tickets", style="bold") - if counts: - text.append("\n " + " · ".join(f"{n} {state}" for state, n in counts.items())) - flagged = [r for r in board.rows if r.state not in ("done", "dropped")] - for label, pick in ( - ("hitl", lambda r: r.hitl), - ("plan checkpoint", lambda r: r.plan_checkpoint), - ("done checkpoint", lambda r: r.done_checkpoint), - ): - refs = [tickets_source.row_label(r) for r in flagged if pick(r)] - if refs: - text.append(f"\n {label}: {', '.join(refs)}", style="magenta") + _append_board(text, board) text.append("\n dry run prints the build order and the first pause", style="dim") body.update(text) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2e3cc937e..f0952df61 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15906,6 +15906,151 @@ def snapshot(): assert not runs_dir.exists() or not any(runs_dir.iterdir()) +def _chain_dry_run(project, monkeypatch, capsys, *argv): + """`run --dry-run` on the chain project with every write path refused: the tree's + write verbs and the drained-child close. Returns ``(rc, out, err, written)``, + ``written`` the ``git status --porcelain`` after (the initiative files, a + child's closure and its commit included).""" + from bmad_loop import tickettree + + def refuse(*_a, **_k): + raise AssertionError("the dry run reached a write") + + for verb in ("mark", "mark_initiative_done", "discard_unbuilt_plan", "restore_preimage"): + monkeypatch.setattr(tickettree.TicketTree, verb, refuse) + monkeypatch.setattr(cli, "_close_drained_children", refuse) + files = [ + v7_layout(project.project, initiative=slug).initiative_folder / f"{slug}.md" + for slug in (CHAIN_PARENT, CHAIN_AUTH, CHAIN_BILLING) + ] + before = [f.read_bytes() for f in files] + head = git(project.project, "rev-parse", "HEAD") + rc = cli.main(["run", "--project", str(project.project), "--dry-run", *argv]) + assert [f.read_bytes() for f in files] == before + assert git(project.project, "rev-parse", "HEAD") == head + assert not (project.project / runs.RUNS_DIR).exists() + out, err = capsys.readouterr() + return rc, out, err, git(project.project, "status", "--porcelain") + + +def test_dry_run_tickets_parent_previews_the_chain(project, monkeypatch, capsys): + """`run --dry-run` on a parent initiative lists its children in build order — + AUTH tagged `next`, BILLING `waiting` on it — then AUTH's own tickets block, + read through AUTH's folder by the same printer a one-initiative dry run uses, + and the chain's predicted line; it writes nothing (real `tickets.py`). + Ablation (performed): drop `_dry_run_tickets`' parent branch and the unpinned + tree refuses the parent — exit 1 — so this FAILS.""" + _chain_project(project, monkeypatch, capsys, []) + + rc, out, err, written = _chain_dry_run(project, monkeypatch, capsys) + + assert rc == 0, err + assert written == "" + lines = out.splitlines() + assert lines[:3] == [ + f"tickets mode: parent initiative {CHAIN_PARENT} — 2 children in build order:", + f" 1. {CHAIN_AUTH} (open; 0 of 4 tickets closed) [next]", + f" 2. {CHAIN_BILLING} (open; 0 of 4 tickets closed) [waiting] " + f"waits on: {CHAIN_AUTH} (open)", + ] + assert f"tickets mode: initiative {CHAIN_AUTH} — 1 ready of 4 tickets" in out + assert '"/bmad-build-auto ticket 1.1 Halt after planning."' in out + assert "predicted: run would pause after planning 1.1 (plan-checkpoint)" in out + assert "groups: ready_to_refine=0 ready_to_start=1" in out + assert lines[-1] == ( + f"predicted: chain starts with {CHAIN_AUTH}; later children are chosen as each " "finishes" + ) + + +def test_dry_run_tickets_parent_tags_a_drained_child_and_closes_nothing( + 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.""" + _chain_project(project, monkeypatch, capsys, []) + _drain_child(project.project, CHAIN_AUTH) + + rc, out, err, written = _chain_dry_run(project, monkeypatch, capsys) + + 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" + ) + assert not _initiative_status(project, CHAIN_AUTH) + + +@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 + exit 1, printing no preview. Ablation (performed): drop the dry run's refusal + and it previews the chain with exit 0 — this FAILS.""" + _chain_project(project, monkeypatch, capsys, []) + + rc, out, err, _ = _chain_dry_run(project, monkeypatch, capsys, *selector) + + assert rc == 1 + assert cli._chain_selector_refusal(CHAIN_PARENT) + "\n" in err + assert out == "" + + +def test_dry_run_tickets_parent_with_nothing_to_build_prints_the_end(project, monkeypatch, capsys): + """Both children done: the predicted line is the end line `run` prints + (`all-done`), with no tickets block, and exit 0. Ablation (performed): drop the + no-child branch and the preview reads a child that is not there — this FAILS.""" + _chain_project(project, monkeypatch, capsys, []) + for slug in (CHAIN_AUTH, CHAIN_BILLING): + file = v7_layout(project.project, initiative=slug).initiative_folder / f"{slug}.md" + file.write_text( + file.read_text(encoding="utf-8").replace( + "parent: none\n", "parent: none\nstatus: done\n" + ), + encoding="utf-8", + ) + git(project.project, "commit", "-qam", "children done") + + rc, out, err, written = _chain_dry_run(project, monkeypatch, capsys) + + assert rc == 0, err + assert written == "" + assert f" 1. {CHAIN_AUTH} (done; 0 of 4 tickets closed) [done]" in out + assert "build order (" not in out + assert out.splitlines()[-1] == f"predicted: initiative chain {CHAIN_PARENT}: ends (all-done)" + + +def test_dry_run_tickets_parent_warns_without_worktree_isolation(project, monkeypatch, capsys): + """Under `isolation = "none"` the real run refuses a parent at preflight + (`tickets.initiative-parent`); the dry run still previews the chain and its + banner names that refusal. Ablation (performed): drop the banner's + `tickets_parent_problems` mirror and the warning is absent — this FAILS.""" + _chain_project(project, monkeypatch, capsys, [], policy=TICKETS_POLICY) + + rc, out, err, _ = _chain_dry_run(project, monkeypatch, capsys) + + assert rc == 0 + assert "note: this preview is NOT runnable as-is" in err + assert ( + f" FAIL: active initiative {CHAIN_PARENT!r} is a parent initiative (children: " + f'{CHAIN_AUTH}, {CHAIN_BILLING}) and `[scm] isolation = "none"`' in err + ) + assert f"predicted: chain starts with {CHAIN_AUTH}" in out + + def test_run_tickets_launches_tickets_engine(project, capsys, monkeypatch): """A tickets project that validates runs: the tickets preflight passes (its capability probe executes the vendored script — 2 real spawns), and `run` @@ -16078,9 +16223,10 @@ def _drain_child(root, slug, *, epics=True): git(root, "commit", "-q", "-m", f"build {slug}") -def _chain_project(project, monkeypatch, capsys, outcomes): - """Every gate green on the parent layout, and `cli.TicketsEngine` playing one of - ``outcomes`` per run, in order (a run past the script fails the test): +def _chain_project(project, monkeypatch, capsys, outcomes, *, policy=None): + """Every gate green on the parent layout (under ``policy``, default + `CHAIN_POLICY`), and `cli.TicketsEngine` playing one of ``outcomes`` per run, + in order (a run past the script fails the test): * ``drain`` — every ticket and epic of the run's child closed and committed, one task landed, the run finished; @@ -16098,7 +16244,7 @@ def _chain_project(project, monkeypatch, capsys, outcomes): project, monkeypatch, capsys, - policy=CHAIN_POLICY, + policy=CHAIN_POLICY if policy is None else policy, skills=install_build_auto_v7_skill, bmad_config=_chain_config, ) diff --git a/tests/test_conftest.py b/tests/test_conftest.py index e915c1d7a..283cdc507 100644 --- a/tests/test_conftest.py +++ b/tests/test_conftest.py @@ -1693,7 +1693,7 @@ def _declares_loadgroup(addopts: object) -> bool: # `test_reap_identity_*`, `test_recorded_*`. _OTHER_DEFS: 28, }, - "test_tickets_e2e.py": {"test_e2e_tickets_": 14}, + "test_tickets_e2e.py": {"test_e2e_tickets_": 16}, "test_conftest.py": {"test_private_tmux_": 1}, } diff --git a/tests/test_tickets_e2e.py b/tests/test_tickets_e2e.py index 2cfa49ed5..4cd8cdda2 100644 --- a/tests/test_tickets_e2e.py +++ b/tests/test_tickets_e2e.py @@ -31,13 +31,18 @@ store repository (T3 nested, T4 outside), the preflight refusals of (12) a tracker store and (13) a workspace layout (T5), and (14) a worktree-isolated `sweep` over a shared store (T2–T4): its bundle plan and ledger close committed in the store -repository, or `store-unversioned` for T2. Rows 7–9 run over T1 and T3. A run -that finishes an epic it committed a ticket of always ends paused at -`epic-boundary` for a person to close the epic. +repository, or `store-unversioned` for T2, (15) a parent initiative built as a chain +of child runs under worktree isolation, each unit mount's own config naming its +child, the drained child closed and committed before the next child's run, and +(16) the same parent refused at preflight under `isolation = "none"`. Rows 7–9 run +over T1 and T3. A run that finishes an epic it committed a ticket of always ends +paused at `epic-boundary` for a person to close the epic. Real `tickets.py` spawns: every run and resolve spawns it through bmad-loop and -every fake ticket session once more; outside them this file spawns it twice (rows -3 and 4). Linux and real tmux only, so none of this reaches the Windows legs' +every fake ticket session once more; a parent's run and its chain-continuing +resume (row 15) spawn it more per chain step (`next `, each ready child's +`next`/`status` probe, the close); outside them this file spawns it twice (rows 3 +and 4). Linux and real tmux only, so none of this reaches the Windows legs' budget. """ @@ -53,6 +58,7 @@ import pytest from conftest import ( + V7_USER_CONFIG_REL, EntrySpec, EpicSpec, V7Layout, @@ -63,6 +69,7 @@ real_mux_e2e, remove_tree, ticket_slug, + v7_layout, write_ticket_tree, ) @@ -203,6 +210,9 @@ CLI = [sys.executable, "-m", "bmad_loop.cli"] EPIC = "epic-cart" EPIC2 = "epic-checkout" +# A parent initiative and its two children, CHILD_B after CHILD_A. +PARENT = "initiative-platform" +CHILD_A, CHILD_B = "initiative-auth", "initiative-billing" def _git(root: Path, *args: str) -> str: @@ -231,6 +241,7 @@ def _scaffold( retro: str = "never", isolation: str = "none", store: str = "repo", + children: tuple[str, ...] = (), ) -> V7Layout: """``sandbox`` (the `project_v7` fixture's copy of the per-worker v7 template) turned into a committed, clean tickets-mode sandbox: ``epics`` replace the @@ -242,7 +253,11 @@ def _scaffold( ``topology`` places the store: T1 tracked in the checkout (upstream's default), T2 in the checkout but gitignored and unversioned, T3 a gitignored repository nested in the checkout, T4 its own repository outside the project (beside - ``root``, with an absolute `output_folder`).""" + ``root``, with an absolute `output_folder`). + + ``children`` makes the active initiative the parent `PARENT` of those child + initiatives (:func:`_write_parent`), each holding ``epics``; the returned + layout is the parent's.""" root = sandbox.project # The template's tracked store goes wholesale, index included (a tracked path # stays tracked under a new ignore rule); the commit below records its deletion @@ -253,8 +268,12 @@ def _scaffold( with (root / ".gitignore").open("a", encoding="utf-8") as f: f.write("/_bmad-output/\n") out = str(root.parent / "store") if topology == "T4" else "_bmad-output" - layout = install_bmad_v7(root, output_folder=out, store=store) - write_ticket_tree(layout, epics) + if children: + layout = install_bmad_v7(root, initiative=PARENT, output_folder=out, store=store) + _write_parent(layout, children, epics) + else: + layout = install_bmad_v7(root, output_folder=out, store=store) + write_ticket_tree(layout, epics) install_build_auto_v7_skill(root) if retro == "auto": skill = root / ".claude" / "skills" / "bmad-retrospective" / "SKILL.md" @@ -299,6 +318,40 @@ def _scaffold( return layout +def _write_parent(layout: V7Layout, children: tuple[str, ...], epics: tuple[EpicSpec, ...]) -> None: + """The parent initiative at ``layout``: its own file and a `tickets/tickets.toml` + listing ``children`` as `[[initiative]]` tables in build order, each after the + one before it, and per child its folder holding ``epics``.""" + folder = layout.initiative_folder + (folder / ticketlayout.TICKETS).mkdir(parents=True, exist_ok=True) + (folder / f"{layout.initiative}.md").write_text( + f"---\ntype: initiative\ntitle: {layout.initiative}\nparent: none\n---\n\n" + f"# {layout.initiative}\n", + encoding="utf-8", + ) + tables = [] + for i, slug in enumerate(children, 1): + table = f'[[initiative]]\nid = {i}\nslug = "{slug}"\ntitle = "{slug}"\n' + if i > 1: + table += f'after = [{{ initiative = {i - 1}, needs = "{children[i - 2]} live" }}]\n' + tables.append(table) + (folder / ticketlayout.TICKETS / ticketlayout.BREAKDOWN).write_text( + "\n".join(tables), encoding="utf-8" + ) + for slug in children: + write_ticket_tree(_child(layout, slug), epics) + + +def _child(layout: V7Layout, slug: str) -> V7Layout: + """The layout of the child initiative ``slug`` of the parent at ``layout``.""" + return v7_layout( + layout.project, + initiative=slug, + output_folder=layout.output_folder.as_posix(), + store=layout.store, + ) + + def _tree_repo(layout: V7Layout, topology: str) -> Path: """The repository that versions the ticket tree: the code checkout for T1, the store's own repository for T3/T4 (T2 has none).""" @@ -384,8 +437,9 @@ def _run_dir(root: Path) -> Path: return runs[-1] -def _status_doc(root: Path) -> dict: - proc = _run(root, "status", _run_dir(root).name, "--json") +def _status_doc(root: Path, run_dir: Path | None = None) -> dict: + """``status --json`` of ``run_dir`` (default: the only, or latest, run).""" + proc = _run(root, "status", (run_dir or _run_dir(root)).name, "--json") _ok(proc) return json.loads(proc.stdout) @@ -401,10 +455,24 @@ def _prompts(root: Path) -> list[str]: def _journal(root: Path, kind: str) -> list[dict]: - lines = (_run_dir(root) / "journal.jsonl").read_text(encoding="utf-8").splitlines() + return _run_journal(_run_dir(root), kind) + + +def _run_journal(run_dir: Path, kind: str) -> list[dict]: + lines = (run_dir / "journal.jsonl").read_text(encoding="utf-8").splitlines() return [row for row in map(json.loads, lines) if row["kind"] == kind] +def _runs_by_initiative(root: Path) -> dict[str, Path]: + """Every run dir, keyed by the initiative its state pins (one run each).""" + runs: dict[str, Path] = {} + for run_dir in (root / ".bmad-loop" / "runs").iterdir(): + state = json.loads((run_dir / "state.json").read_text(encoding="utf-8")) + assert state["initiative"] not in runs, state["initiative"] + runs[state["initiative"]] = run_dir + return runs + + def _assert_ended_at_epic_boundary(doc: dict) -> None: """The end of every row that finishes the epic: a closure pause for the person who closes the epic with bmad-agent-po (bmad-loop never writes the epic file).""" @@ -1047,3 +1115,85 @@ def test_e2e_tickets_workspace_layout_refused(project_v7): assert "is not a git repository" in proc.stderr and "workspace" in proc.stderr, proc.stderr assert not (root / ".bmad-loop" / "runs").exists() assert "tickets.workspace-unsupported" in _validate_problems(root) + + +def test_e2e_tickets_parent_initiative_chain(project_v7): + """A parent initiative whose children A and B (B after A) hold one ticket each, + under `isolation = "worktree"`: `run` builds A's ticket in a unit mount whose own + gitignored config names A, and A's run pauses at its epic boundary. The person + closes A's epic; `resume` finishes A's run, bmad-loop closes A (`status: done`, + committed) and the chain launches B's run, which builds B's ticket and pauses + at B's epic boundary. Each run journals its side of the switch, and the user's + own `config.user.toml`, which names the parent, is never written. Ablation: + drop the child initiative from the mount config rewrite + (`ticketstore.redirect_mount_config`'s ``initiative`` in `WorktreeFlow`) and + the fake's `tickets.py find` reads the parent inside the mount, so A's run + escalates instead of building its ticket.""" + layout = _scaffold( + project_v7, + _epic(EntrySpec(1, "Cart model")), + isolation="worktree", + children=(CHILD_A, CHILD_B), + ) + root = layout.project + auth, billing = _child(layout, CHILD_A), _child(layout, CHILD_B) + user_config = (root / V7_USER_CONFIG_REL).read_bytes() + + _ok(_run(root, "run")) + + (auth_run,) = _runs_by_initiative(root).values() + doc = _status_doc(root, auth_run) + _assert_ended_at_epic_boundary(doc) + assert (doc["tickets"]["initiative"], doc["tickets"]["parent_initiative"]) == (CHILD_A, PARENT) + assert _plan_status(auth, "Cart model") == "done" + assert _plan_status(billing, "Cart model") == "absent" + + _close_epic(auth, "T1", EPIC) + _ok(_run(root, "resume", auth_run.name)) + + runs = _runs_by_initiative(root) + assert sorted(runs) == [CHILD_A, CHILD_B] and runs[CHILD_A] == auth_run + billing_run = runs[CHILD_B] + (nxt,) = _run_journal(auth_run, "initiative-chain-next") + assert (nxt["parent"], nxt["from_child"], nxt["to_child"]) == (PARENT, CHILD_A, CHILD_B) + (link,) = _run_journal(billing_run, "initiative-chain") + assert (link["parent"], link["previous_run"], link["previous_child"]) == ( + PARENT, + auth_run.name, + CHILD_A, + ) + assert _plan_status(auth, "Cart model") == "done" + assert _plan_status(billing, "Cart model") == "done" + for run_dir in (auth_run, billing_run): # each child's session ran in its unit mount + (cwd,) = [Path(p.read_text(encoding="utf-8")) for p in run_dir.glob("tasks/*/fake-cwd.txt")] + assert cwd.is_relative_to(run_dir / "worktrees"), cwd + initiative_rel = (auth.initiative_folder / f"{CHILD_A}.md").relative_to(root).as_posix() + assert status_of(parse_frontmatter(_git(root, "show", f"HEAD:{initiative_rel}"))) == "done" + assert _git(root, "log", "-1", "--format=%s", "--", initiative_rel).strip() == ( + f"chore(tickets): {CHILD_A} done [bmad-loop {auth_run.name}]" + ) + doc = _status_doc(root, billing_run) + _assert_ended_at_epic_boundary(doc) + assert (doc["tickets"]["initiative"], doc["tickets"]["parent_initiative"]) == (CHILD_B, PARENT) + assert (root / V7_USER_CONFIG_REL).read_bytes() == user_config + assert _git(root, "worktree", "list", "--porcelain").count("worktree ") == 1 + assert _clean(root) + + +def test_e2e_tickets_parent_initiative_refused_without_isolation(project_v7): + """The same parent under `isolation = "none"`: sessions in the main checkout + would read the parent from the user config, so `run` refuses it at preflight + before any run dir exists, and `validate` reports `tickets.initiative-parent`. + Ablation: drop the isolation cause in `install._tickets_parent_findings` and + the run composes (a run dir appears).""" + layout = _scaffold(project_v7, _epic(EntrySpec(1, "Cart model")), children=(CHILD_A, CHILD_B)) + root = layout.project + + proc = _run(root, "run") + + assert proc.returncode == 1, proc.stdout + proc.stderr + assert f"{PARENT!r} is a parent initiative" in proc.stderr, proc.stderr + assert '`[scm] isolation = "none"`' in proc.stderr, proc.stderr + assert not (root / ".bmad-loop" / "runs").exists() + assert "tickets.initiative-parent" in _validate_problems(root) + assert _clean(root) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index c3ab10d02..232eec2da 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3333,6 +3333,55 @@ def preview(proj): assert "init-stale" not in str(body.render()) +async def test_start_run_modal_previews_a_parent_initiative(project, monkeypatch): + """On a parent active initiative the tickets preview lists the children in + build order with their tags and what each waits on, then the first ready + child's board, and points at `run --dry-run` for the exact chain. Canned + board: nothing here runs tickets.py. + + Ablation (performed): drop the `board.parent` branch in + `_show_tickets_preview` and no child line renders — this FAILS.""" + from bmad_loop.initiativechain import WaitingOn + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(BmadLoopApp, "_stories_defaults", lambda self: ("tickets", "")) + children = ( + data.ChainChild("initiative-auth", "open", 1, 4, "first ready", ()), + data.ChainChild( + "initiative-billing", + "open", + 0, + 4, + "waiting", + (WaitingOn("initiative-billing", "initiative-auth", "open"),), + ), + ) + board = data.TicketsBoard( + (ticket_row("1.1", status="done"), ticket_row("1.2", plan_checkpoint=True)), + None, + "initiative-auth", + parent="initiative-platform", + children=children, + ) + monkeypatch.setattr(data, "project_tickets_overview", lambda proj: board) + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + await pilot.press("r") + await until(pilot, lambda: isinstance(app.screen, StartRunModal)) + await ready(pilot, "#ok") + body = app.screen.query_one("#preview-body", Static) + await until(pilot, lambda: "parent initiative initiative-platform" in str(body.render())) + rendered = str(body.render()) + assert "2 children in build order" in rendered + assert "1. initiative-auth (open; 1 of 4 tickets closed) [first ready]" in rendered + assert "2. initiative-billing (open; 0 of 4 tickets closed) [waiting]" in rendered + assert "waits on: initiative-auth (open)" in rendered + assert "first ready: initiative-auth · 2 tickets" in rendered + assert "plan checkpoint: 1.2" in rendered + assert "run --dry-run prints the exact chain prediction" in rendered + + async def test_start_run_modal_epic_text_validation_per_mode(project, monkeypatch): """The epic input is text: outside tickets mode it must still be a number (`run --epic` exits 2 on anything else there), so a slug keeps the modal open diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index d8fa69c69..3b24a2bab 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -13,13 +13,18 @@ import pytest from conftest import ( + DEFAULT_TREE, + V7_USER_CONFIG_REL, FakeTicketTree, install_bmad_central_config, install_bmad_config, install_bmad_v7, refuse_to_resolve, ticket_row, + v7_layout, + write_plan, write_sprint, + write_ticket_tree, ) from bmad_loop import bmadconfig, deferredwork, platform_util, policy @@ -1728,6 +1733,158 @@ def board(project: Path, initiative: str, folder: Path) -> data.TicketsBoard: assert data._project_paths(root) is sprint_paths # its own cache entry, untouched +# ------------------------------------------------------------- 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). + +CHAIN_PARENT = "initiative-platform" +CHAIN_AUTH, CHAIN_BILLING = "initiative-auth", "initiative-billing" +CHAIN_PLANS = ( + ("epic-cart", "story-cart-model-plan", 1), + ("epic-cart", "story-cart-rules-plan", 2), + ("epic-checkout", "story-checkout-form-plan", 1), + ("epic-checkout", "bug-checkout-crash-plan", 2), +) + + +def _chain_project(root: Path, monkeypatch) -> Path: + """`tests/test_cli.py`'s `_chain_config` shape under ``root``, with the board + and paths caches cleared. Returns the parent initiative's folder.""" + root.mkdir(parents=True, exist_ok=True) + install_bmad_v7(root, initiative=CHAIN_PARENT) + parent = v7_layout(root, initiative=CHAIN_PARENT).initiative_folder + (parent / "tickets").mkdir(parents=True, exist_ok=True) + (parent / f"{CHAIN_PARENT}.md").write_text( + "---\ntype: initiative\ntitle: Platform\nparent: none\n---\n\n# Platform\n", + encoding="utf-8", + ) + (parent / "tickets" / "tickets.toml").write_text( + f'[[initiative]]\nid = 1\nslug = "{CHAIN_AUTH}"\ntitle = "Auth"\n\n' + f'[[initiative]]\nid = 2\nslug = "{CHAIN_BILLING}"\ntitle = "Billing"\n' + 'after = [{ initiative = 1, needs = "auth live" }]\n', + encoding="utf-8", + ) + for slug in (CHAIN_AUTH, CHAIN_BILLING): + write_ticket_tree(v7_layout(root, initiative=slug), DEFAULT_TREE) + monkeypatch.setattr(data, "_tickets_cache", {}) + monkeypatch.setattr(data, "_tickets_paths_cache", {}) + monkeypatch.setattr(data, "_tickets_paths_fault", {}) + return parent + + +def _chained_state(root: Path) -> RunState: + """A chained child run of AUTH, as `bmad-loop run` on the parent records it.""" + return RunState( + run_id="r-chain", + project=str(root), + started_at="2026-10-28T10:00:00", + source="tickets", + initiative=CHAIN_AUTH, + initiative_folder=str(v7_layout(root, initiative=CHAIN_AUTH).initiative_folder), + parent_initiative=CHAIN_PARENT, + ) + + +def test_tickets_overview_reads_a_chained_run_through_its_child(tmp_path, monkeypatch): + """A chained child run's board is its child's tree, read pinned to the parent + (the live `[core] active_initiative` still names it) through the child's folder, + as `bmad-loop status` reads it (`runs.recorded_tree_pin`). With the live config + switched off the parent the board carries the fault — no spawn, no raise. + + Ablation (performed): drop the `recorded_tree_pin` read in `tickets_overview` + and the child pin is refused against the live parent — the first assertion + FAILS.""" + root = tmp_path / "proj" + _chain_project(root, monkeypatch) + state = _chained_state(root) + + board = data.tickets_overview(root, state) + assert board.fault is None, board.fault + assert board.initiative == CHAIN_AUTH + assert sorted(r.ref for r in board.rows) == ["1.1", "1.2", "2.1", "2.2"] + + (root / V7_USER_CONFIG_REL).write_text( + f'[core]\nactive_initiative = "{CHAIN_BILLING}"\n', encoding="utf-8" + ) + switched = data.tickets_overview(root, state) + assert switched.rows == () + assert switched.fault is not None and f"set it back to {CHAIN_PARENT!r}" in switched.fault + + +def test_project_tickets_overview_previews_a_parent_initiative(tmp_path, monkeypatch): + """On a parent active initiative the start-run preview names the parent, its + children in build order with their tags, and the board of the child a run + builds first — AUTH, read through its folder with the tree pinned to the parent. + + Ablation (performed): drop the parent branch in `project_tickets_overview` and + `tickets.py status` refuses the parent — the board is a fault and this FAILS.""" + root = tmp_path / "proj" + _chain_project(root, monkeypatch) + + board = data.project_tickets_overview(root) + assert board.fault is None, board.fault + assert board.parent == CHAIN_PARENT + assert [(c.slug, c.tag, c.status) for c in board.children] == [ + (CHAIN_AUTH, "first ready", "open"), + (CHAIN_BILLING, "waiting", "open"), + ] + assert [(c.closed, c.total) for c in board.children] == [(0, 4), (0, 4)] + assert [(w.prerequisite, w.status) for w in board.children[1].waiting_on] == [ + (CHAIN_AUTH, "open") + ] + assert board.initiative == CHAIN_AUTH + assert board.chain_end is None + 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. + + Ablation (performed): pass `next_child` no probe and AUTH is the first ready + child — this FAILS.""" + root = tmp_path / "proj" + _chain_project(root, monkeypatch) + auth = v7_layout(root, initiative=CHAIN_AUTH) + 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"), + ] + assert (board.children[0].closed, board.children[0].total) == (4, 4) + assert board.rows == () + assert board.initiative == "" + assert board.chain_end == "waiting" + + +def test_project_tickets_overview_faults_on_an_unreadable_parent_breakdown(tmp_path, monkeypatch): + """A `tickets.toml` the parent detection cannot parse is the preview's fault — + no spawn, no raise. + + Ablation (performed): ignore the fault from `_initiative_children` and the + tree is read for the active initiative — the spawn guard FAILS.""" + root = tmp_path / "proj" + parent = _chain_project(root, monkeypatch) + (parent / "tickets" / "tickets.toml").write_text("[[initiative]\n", encoding="utf-8") + + def no_tree(*args, **kwargs): + raise AssertionError("spawned tickets.py on an unreadable parent breakdown") + + monkeypatch.setattr(data, "_board_ticket_tree", no_tree) + monkeypatch.setattr(data, "_parent_view", no_tree) + board = data.project_tickets_overview(root) + assert board.rows == () + assert board.fault is not None and board.fault.startswith("cannot read ") + assert "TOMLDecodeError" in board.fault + + # ------------------------------------------------------------- deferred work