diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b249f0af5..373554693 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -51,6 +51,7 @@ 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. - 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. @@ -59,7 +60,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_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_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 fa77f94cb..2cb776e23 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -165,6 +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; `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. - 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 eb8016f97..ed7e1000f 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,9 +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. +`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. -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. +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. A pick pause, and a resume's stale-binding pause (`binding-moved`: an in-flight unit's ref no longer names its plan, or its verified plan is gone), journal `ticket-pick-halt` with its `cause` and `stage`: @@ -106,7 +106,7 @@ A pick pause, and a resume's stale-binding pause (`binding-moved`: an in-flight | `blocked-plan` | `escalation` | | `foreign-active`, `stalled`, `tree-fault`, `pin-mismatch`, `dirty-initiative`, `ignored-plan`, `binding-moved` | `ticket-tree` | -`store_topology` is the ticket store classified at the start of a tickets run or a v7 sweep: `layout`, `placement`, `versioning`, `store_root` and `store_repo`. Resume, resolve and the TUI re-classify the store and refuse when it differs, or when the initiative folder is gone. When no checkout carries the store, every artifact dir under it stays at the main checkout for a worktree unit, and the mount's `_bmad/custom/config.user.toml` is rewritten so `[core] output_folder` names that store. +`store_topology` is the ticket store classified at the start of a tickets run or a v7 sweep: `layout`, `placement`, `versioning`, `store_root` and `store_repo`. Resume, resolve and the TUI re-classify the store and refuse when it differs, or when the initiative folder is gone. A chained child run's store, and its run-end sweep's, is classified on the child's own folder, as at its launch. When no checkout carries the store, every artifact dir under it stays at the main checkout for a worktree unit, and the mount's `_bmad/custom/config.user.toml` is rewritten so `[core] output_folder` names that store. For a store tracked in the code checkout (T1), the commit window marks the plan itself, so the marked plan rides the unit's own commit. The save that records `COMMITTING` latches the plan's status and the sha256 of its bytes before that mark: `ticket_premark_status` and `ticket_premark_digest`, cleared when no T1 mark is owed or the plan cannot be read. A commit failure after a mark this window wrote puts the pre-mark bytes back. A resumed window whose mark is a no-op on a plan that reads another status than the latched one and no longer matches the digest finds it marked since that save, for example by an interrupted earlier window, and no bytes survive to restore it. If its commit then fails, the escalation names the plan left marked for a commit that does not exist, so the operator restores it by hand. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index d103c9f69..246c22c8a 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -41,6 +41,20 @@ bmad-loop reads each folder the way the installed `tickets.py` does: a folder wi A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiative]]` tables, holds no tickets of its own, and each child's folder sits beside the parent's. The preflight refuses a parent (`tickets.initiative-parent`) under `[scm] isolation = "none"`, because sessions in the main checkout read the parent from `config.user.toml`, and under `gates.retrospective = "auto"`, whose session also runs in the main checkout. Set `active_initiative` to one child, or switch to worktree isolation without the auto retrospective. There the parent is an ok line naming its children, and the store checks run once for each child that has a folder: a child declared but not sliced yet is skipped. Each child is held to the `config.user.toml` rule of a shared store whatever its layout (`tickets.own-repo-isolation`). An epic the initiative declares but has not sliced yet has no folder; it holds no tickets, and the tickets gated after it wait for it. +`bmad-loop run` on a parent builds its children one run at a time, taking the first child `next ` lists as ready. It passes over a ready child that has no folder yet (`no-folder`) and one whose first pick finds nothing to dispatch (`drained`). Each child's run records the parent as `parent_initiative`. Before every pick, bmad-loop closes each drained child (below), so the children waiting on it become ready. One line per switch names the next child or why the chain ended. The chain ends when: + +- a child's run does not finish (paused, escalated, stopped or crashed). Once `resume` or `resolve` (from the CLI or the TUI) drives that run to finished, the chain goes on to the next child as it does after `run`. +- 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`). +- 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. + +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. + ## Store layouts The ticket store is the initiative folder and its plans. Where it lives decides where a ticket's `done` mark lands. `validate --json` reports the layout as `ticket_store`, and every run records it. Resume, resolve and the TUI refuse a store whose layout changed since the run started. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ea642e48d..d27c889d9 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -30,6 +30,7 @@ events, frontmatter, gates, + initiativechain, install, machine, operatoractions, @@ -91,7 +92,7 @@ resume_skipped_escalation_keys, ) from .journal import Journal, load_state, save_state, state_lock -from .model import PauseSnapshot, RunState, StoryTask +from .model import PauseSnapshot, Phase, RunState, StoryTask from .platform_util import ( MAX_SEGMENT, LockUnavailableError, @@ -366,7 +367,12 @@ def _sweep_store_source(paths: bmadconfig.ProjectPaths, pol: policy_mod.Policy) def _reject_store_topology( - project: Path, pol, source: str, *, recorded: dict[str, str | None] | None = None + project: Path, + pol, + source: str, + *, + recorded: dict[str, str | None] | None = None, + initiative: str | None = None, ) -> int | None: """Refuse a tickets run whose ticket store tickets runs cannot drive (:func:`ticketstore.store_conflict`: a refused layout, or — given the run's @@ -374,14 +380,28 @@ def _reject_store_topology( started). Returns 1 to abort, None to proceed — the :func:`_reject_isolation_conflict` sibling, called beside it from every run-arming surface that returns an rc to a human; the surfaces without an rc - channel call the predicate itself. Outside tickets mode it is a no-op.""" - conflict = ticketstore.store_conflict(project, source, pol.scm.isolation, recorded=recorded) + channel call the predicate itself. Outside tickets mode it is a no-op. + ``initiative`` names a chained run's child initiative, whose store is judged + on its own (:func:`ticketstore.store_conflict`); unset, the predicate is + called exactly as before chains existed.""" + chained = {} if initiative is None else {"initiative": initiative} + conflict = ticketstore.store_conflict( + project, source, pol.scm.isolation, recorded=recorded, **chained + ) if conflict is None: return None print(f"error: {conflict}", file=sys.stderr) return 1 +def _store_initiative(state: RunState) -> str | None: + """The ``initiative`` a recorded run's store is judged on + (:func:`_reject_store_topology`): a chained run's child (``state.initiative`` + when ``state.parent_initiative`` is set — a child folder can have its own + topology), else None, so an unchained run is judged as before chains existed.""" + return state.initiative if state.parent_initiative is not None else None + + def _store_extra_args_roles(pol, source: str) -> tuple[str, ...]: """The roles a ``source`` run launches whose resolved adapter sets ``extra_args`` — for the ``tickets.store-sandbox`` warning.""" @@ -4216,13 +4236,97 @@ def cmd_run(args: argparse.Namespace) -> int: if (rc := _RUN_PREFLIGHTS[source](paths, pol, args, spec_folder)) is not None: return rc + parent = _chain_parent(paths) if tickets else None + if isinstance(parent, int): + return parent scope = tickets_source.EVERYTHING - if tickets: + launch_paths = paths + 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( + 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, + ) + return 1 + 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()) + 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) + return 1 + if pick is None: + print( + f"initiative chain {parent}: ends (store-commit-failed): {owed} — commit " + "it, then run again", + file=sys.stderr, + ) + return 1 + if pick.child is None: + print(_chain_end_line(parent, pick.end or "", pick)) + return 0 + print(f"initiative chain {parent}: building {pick.child.slug}{_passed_over_note(pick)}") + launch_paths = paths.with_initiative(pick.child.slug) + elif tickets: resolved = _resolve_ticket_scope(_selector_ticket_tree(paths.project), args) if isinstance(resolved, int): return resolved scope = resolved + launched = _launch_run( + project, + launch_paths, + pol, + args, + source=source, + spec_folder=spec_folder, + scope=scope, + parent_initiative=parent, + ) + if isinstance(launched, int): + return launched + summary = launched.engine.run() + print(summary.render()) + return _drive_initiative_chain(project, paths, pol, launched) + + +def _launch_run( + project: Path, + paths: bmadconfig.ProjectPaths, + pol, + args: argparse.Namespace, + *, + source: str, + spec_folder: str, + scope: tickets_source.PickScope, + parent_initiative: str | 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. + + ``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) + tickets = source == worksource.SOURCE_TICKETS + if ( + parent_initiative is not None + and (rc := _reject_store_topology(project, pol, source, initiative=paths.active_initiative)) + is not None + ): + 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 @@ -4238,7 +4342,7 @@ def cmd_run(args: argparse.Namespace) -> int: paths.repo_root, plugin_grants, require_stories=traits.stories_primitive, - require_tickets=source == worksource.SOURCE_TICKETS, + require_tickets=tickets, ): return 1 @@ -4276,7 +4380,14 @@ def cmd_run(args: argparse.Namespace) -> int: max_stories=args.max_stories, source=source, spec_folder=spec_folder, - sweep_factory=_sweep_factory(project, paths, trusted_digest, plugin_grants=plugin_grants), + # a chained child's run-end sweep works the child's ledger and pins the parent + sweep_factory=_sweep_factory( + project, + paths, + trusted_digest, + plugin_grants=plugin_grants, + parent_initiative=parent_initiative, + ), make_adapters=_make_adapters, engine_cls=Engine, stories_engine_cls=StoriesEngine, @@ -4284,6 +4395,7 @@ def cmd_run(args: argparse.Namespace) -> int: trusted_config_digest=trusted_digest, profiles=profiles, plugin_grants=plugin_grants, + parent_initiative=parent_initiative, ) _warn_bypass_dropped( pol, @@ -4293,9 +4405,466 @@ def cmd_run(args: argparse.Namespace) -> int: journal=composed.journal, ) print(f"run {composed.run_id} starting (attach: bmad-loop attach)") - summary = composed.engine.run() - print(summary.render()) - return 0 + return composed + + +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 + (``install._initiative_children``) — else None; exit 1 when the read faults.""" + folder = paths.initiative_folder + if paths.era != "v7" or folder is None or not paths.active_initiative: + return None + children, breakdown, fault = install._initiative_children(folder) + if fault: + print(f"tickets mode: cannot read {breakdown}: {fault}", file=sys.stderr) + return 1 + return None if children is None else paths.active_initiative + + +def _chain_ticket_tree( + project: Path, parent: str, folder: Path | None = None +) -> tickettree.TicketTree: + """A main-checkout tree pinned to the parent initiative ``parent`` (the live + ``[core] active_initiative``), reading through a child's ``folder`` when given.""" + return tickettree.TicketTree(project, pinned_initiative=parent, folder=folder) + + +def _fresh_pick( + next_view: ticketmodel.NextView, + status_view: ticketmodel.StatusView, + scope: tickets_source.PickScope = tickets_source.EVERYTHING, +) -> tickets_source.PickDecision: + """The pick a fresh tickets run makes first: no ticket touched, no closure, + approval or acknowledgement recorded. The dry run previews it; the initiative + chain reads a ``Skip`` as a child with nothing left to dispatch.""" + return tickets_source.decide_pick(next_view, status_view, {}, {}, (), scope) + + +def _chain_pick( + project: Path, paths: bmadconfig.ProjectPaths, parent: str +) -> 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.""" + folder = paths.initiative_folder + assert folder is not None # _chain_parent + view = _chain_ticket_tree(project, parent).parent_next(folder) + + 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.next_child(view, drained) + + +def _passed_over_note(pick: initiativechain.ChildPick) -> str: + """`` (passed over: a (drained), b (no-folder))``, or ``""``.""" + if not pick.passed_over: + return "" + return " (passed over: " + ", ".join(f"{p.slug} ({p.why})" for p in pick.passed_over) + ")" + + +def _waiting_note(pick: initiativechain.ChildPick) -> str: + """`` — waiting: b on a (in-progress); c on a (in-progress), d (no-folder)``: + each waiting child and the prerequisites it waits on, with their status; or + ``""``.""" + if not pick.waiting: + return "" + parts: list[str] = [] + for slug in pick.waiting: + on = [f"{w.prerequisite} ({w.status})" for w in pick.waiting_on if w.slug == slug] + parts.append(f"{slug} on {', '.join(on)}" if on else slug) + return " — waiting: " + "; ".join(parts) + + +def _chain_end_line(parent: str, end: str, pick: initiativechain.ChildPick | None) -> str: + """The human line for a chain that ends: why, what still waits and on what, + what was passed over.""" + line = f"initiative chain {parent}: ends ({end})" + return line + ("" if pick is None else _waiting_note(pick) + _passed_over_note(pick)) + + +def _journal_chain_end( + journal: Journal, + parent: str, + end: str, + pick: initiativechain.ChildPick | None = None, + error: str | None = None, +) -> 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 + 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`).""" + extras = {} if error is None else {"error": error} + waits = () if pick is None else pick.waiting_on + journal.append( + "initiative-chain-end", + parent=parent, + end=end, + waiting=[] if pick is None else list(pick.waiting), + waiting_child=[w.slug for w in waits], + waiting_on=[w.prerequisite for w in waits], + waiting_on_status=[w.status for w in waits], + passed_over=[] if pick is None else [p.slug for p in pick.passed_over], + passed_over_why=[] if pick is None else [p.why for p in pick.passed_over], + **extras, + ) + + +def _chain_end( + finished: runsetup.ComposedRun, + parent: str, + end: str, + pick: initiativechain.ChildPick | None = None, + *, + error: str | None = None, +) -> int: + """End the chain after ``finished``: journal it there, print why, and return + the rc (0 for an orderly end, 1 for a fault).""" + _journal_chain_end(finished.journal, parent, end, pick, error) + if error is None: + print(_chain_end_line(parent, end, pick)) + return 0 + print(f"initiative chain {parent}: ends ({end}): {error}", file=sys.stderr) + return 1 + + +def _chain_stop_requested(finished: runsetup.ComposedRun) -> bool: + """Whether a stop was asked of the finished run: a request still lodged + (``runs.graceful_stop_requested``, what ``bmad-loop stop`` writes), or one the + engine discarded because the run had nothing left to stop + (``stop-request-discarded``) since it last started — fresh, or resumed + (``run-resume``), so the request was made while it built and was meant for + the chain. A discard before the last resume was a request the operator's + resume overrode (the engine discards one pending when it pauses, too).""" + if runs.graceful_stop_requested(finished.run_dir): + return True + entries = finished.journal.entries() + since = max((i for i, e in enumerate(entries) if e.get("kind") == "run-resume"), default=-1) + return any(e.get("kind") == "stop-request-discarded" for e in entries[since + 1 :]) + + +def _landed_a_ticket(state: RunState) -> bool: + """Whether the run landed at least one ticket (a unit DONE or AWAITING_OPERATOR).""" + landed = (Phase.DONE, Phase.AWAITING_OPERATOR) + return any(task.phase in landed for task in state.tasks.values()) + + +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.""" + + def __init__(self) -> None: # no run dir: nothing is written + self.rows: list[dict[str, object]] = [] + + def append(self, kind: str, **fields: Any) -> None: + self.rows.append({"kind": 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: + (why, run_id, status)}`` with the first such run (oldest first), and what of + the runs dir could not be read, or None. + + A run holds its initiative while it is not finished: ``run-live`` when its + engine is running or may be (``runs.RUNNING``, ``runs.UNKNOWN``), else + ``run-unfinished`` — a paused, stopped, crashed or interrupted run, which a + resume drives on (and a chained one's resume closes its child itself). A sweep + holds its initiative only while live: it works the child's ledger, writes no + ticket or initiative status, and its resume does not go on with the chain. A + run whose state cannot be read joins the fault: it may hold any child.""" + infos, fault = runs.discover_runs(project) + faults = [] if fault is None else [fault] + held: dict[str, tuple[str, str, str]] = {} + for info in infos: + if info.status == runs.FINISHED: + continue + live = info.status in (runs.RUNNING, runs.UNKNOWN) + if info.run_type == "sweep" and not live: + continue + try: + state = load_state(info.run_dir) + except (OSError, ValueError, KeyError, TypeError) as e: + faults.append(f"run {info.run_id}: cannot read its state ({type(e).__name__}: {e})") + continue + if state.initiative and state.initiative not in held: + why = "run-live" if live else "run-unfinished" + held[state.initiative] = (why, info.run_id, info.status) + return held, "; ".join(faults) or None + + +def _child_store( + project: Path, + paths: bmadconfig.ProjectPaths, + pol, + slug: str, + built: RunState | None, +) -> ticketstore.StoreTopology | str | None: + """The store topology a close of child ``slug`` commits in: the one the run + ``built`` recorded when it built ``slug``, else ``slug``'s store classified now + (``ticketstore.assess_store``, as a chained launch judges it). None when there + is nothing to classify; the refusal message for a store tickets runs refuse.""" + if built is not None and built.initiative == slug: + recorded = runs.live_store_topology(built, project) + return ticketstore.StoreTopology.from_dict(recorded) if recorded else None + result = ticketstore.assess_store(paths.with_initiative(slug), pol.scm.isolation, chained=True) + if isinstance(result, ticketstore.StoreRefusal): + return ticketstore.store_topology_conflict(result) or result.code + return result + + +def _close_drained_children( + project: Path, + paths: bmadconfig.ProjectPaths, + pol, + parent: str, + journal: Journal, + built: RunState | None = None, +) -> str | None: + """Mark every child of the parent initiative ``parent`` that ``next `` + reads drained (``initiativechain.drained``) ``status: done``, so its + dependents become ready before the chain picks (``paths`` are the parent's). + ``built`` is the finished child run the driver closes after, or None before a + chain's first run. + + Each close goes through ``TicketTree.mark_initiative_done`` on a main-checkout + tree pinned to ``parent``, journaled into ``journal``; then the file is + committed where its store is versioned (the code repo for T1, the store's own + repo for T3/T4), through ``ticketstore.StoreCommitter`` and journaled as the + engine's store commits are. A drained child is left open, journaled as + ``initiative-close-skipped`` (``parent``, ``slug``, ``why``, ``blocking_run``, + ``detail``) and printed, when: + + * ``run-live`` / ``run-unfinished``: a recorded run still holds it + (:func:`_child_runs_holding`), or ``runs-unread``: the runs dir could not be + read whole, so one may; + * ``work-left``: it is not the child ``built`` built and its fresh pick + (:func:`_fresh_pick`) still finds work — an epic awaiting closure or not yet + broken down, which a run of the child does; a finished run vouches for its + own child; + * ``store-refused``: its store is one tickets runs refuse; + * ``uncommitted-edit``: its store is versioned and the child's file holds a + change git reports (``verify.path_clean``). The close commits the whole + file, so it would carry a person's edit in progress into its bookkeeping + commit. + + Returns None, or why the chain cannot go on: a close that could not be + committed (``store-commit-failed`` / ``store-commit-foreign-dirt`` journaled). + Tree faults raise.""" + parent_folder = paths.initiative_folder + assert parent_folder is not None # _chain_parent + tree = _chain_ticket_tree(project, parent) + drained = [c for c in tree.parent_next(parent_folder).initiatives if initiativechain.drained(c)] + if not drained: + return None + held, fault = _child_runs_holding(project) + label = built.run_id if built is not None else f"chain {parent}" + for child in drained: + slug = child.slug + skip: tuple[str, str, str] | None = None + if fault is not None: + skip = ("runs-unread", "", fault) + elif slug in held: + why, run_id, status = held[slug] + skip = (why, run_id, f"run {run_id} is {status}") + elif built is None or built.initiative != slug: + child_tree = _chain_ticket_tree( + project, parent, paths.with_initiative(slug).initiative_folder + ) + decision = _fresh_pick(child_tree.next(), child_tree.status()) + if not isinstance(decision, tickets_source.Skip): + skip = ("work-left", "", "its first pick still finds work") + topology = None + if skip is None: + store = _child_store(project, paths, pol, slug, built) + if isinstance(store, str): + skip = ("store-refused", "", store) + else: + topology = store + edited = _child_file_edited(paths, parent_folder, slug, topology) + if edited is not None: + skip = ("uncommitted-edit", "", edited) + if skip is not None: + why, run_id, detail = skip + journal.append( + "initiative-close-skipped", + parent=parent, + slug=slug, + why=why, + blocking_run=run_id, + detail=detail, + ) + print(f"initiative chain {parent}: leaves {slug} open ({why}: {detail})") + continue + mark = tree.mark_initiative_done(parent_folder, slug, journal=journal) + if mark.outcome != "written": + continue + print(f"initiative chain {parent}: closed {slug} (drained)") + if topology is None or topology.versioning == "none": + continue + repo = topology.store_repo if topology.store_repo is not None else paths.repo_root + committer = ticketstore.StoreCommitter(repo, paths.repo_root) + message = f"chore(tickets): {slug} done [bmad-loop {label}]" + try: + result = committer.commit(slug, [mark.file], message) + except ticketstore.StoreCommitError as e: + journal.append("store-commit-failed", story_key=slug, error=str(e)) + return f"closing {slug} could not be committed: {e}" + if result.foreign: + journal.append("store-commit-foreign-dirt", story_key=slug, count=len(result.foreign)) + return ( + f"closing {slug} could not be committed: {len(result.foreign)} path(s) " + "hold changes staged by someone else" + ) + journal.append("store-committed", story_key=slug, commit=result.sha) + return None + + +def _child_file_edited( + paths: bmadconfig.ProjectPaths, + parent_folder: Path, + slug: str, + topology: ticketstore.StoreTopology | None, +) -> str | None: + """Why closing child ``slug`` would commit a change the chain does not own: + its file ``/..//.md`` (the one + ``TicketTree.mark_initiative_done`` writes) differs from HEAD in the repo + its close commits in, or None. An unversioned store commits nothing, and a + file outside that repo is one ``StoreCommitter`` does not stage.""" + if topology is None or topology.versioning == "none": + return None + repo = topology.store_repo if topology.store_repo is not None else paths.repo_root + file = parent_folder.parent / slug / f"{slug}.md" + try: + rel = file.resolve().relative_to(repo.resolve()).as_posix() + except ValueError: + return None + if verify.path_clean(repo, rel): + return None + return f"{rel} has uncommitted changes in {repo} — commit or stash them" + + +def _chain_budget(state: RunState) -> int | None: + """What is left of the chain's ``--max-stories`` after the child run ``state``: + its own cap (the budget left when it launched) less the units it dispatched, + counted as the engine counts against the cap (``Engine._dispatched_count``: + every task the run recorded). None when the chain has no cap.""" + if state.max_stories is None: + return None + return state.max_stories - len(state.tasks) + + +def _drive_initiative_chain( + project: Path, + paths: bmadconfig.ProjectPaths, + pol, + finished: runsetup.ComposedRun, +) -> int: + """Build a parent initiative's children one run after another, after the run + ``finished`` — launched by ``run`` or driven by a resume of a recorded run + (``resume``, ``resolve``, and the TUI gestures that spawn them). ``paths`` are + the parent's, as the caller loaded them; ``pol`` is the policy it loaded. + Goes on only while the run just built finished (``RunState.finished``: not + paused, escalated, stopped or crashed), is a chained child run + (``parent_initiative`` set, and not a sweep: a chained child's run-end sweep + carries the parent too) and replays nothing (``replay_of`` empty); otherwise + returns 0 at once. + + Per switch, in order: a stop asked of the finished run ends the chain + (:func:`_chain_stop_requested`); every drained child — the one just built, and + any other a stopped chain or a hand left open — is marked done + (:func:`_close_drained_children`, which ``run`` also calls before its first + pick) so its dependents become ready; the next child + is picked (:func:`_chain_pick`); a spent ``--max-stories`` budget + (:func:`_chain_budget`) ends the chain as ``max-stories``; picking the child + just built again when its run landed no ticket ends it as ``stalled``, since + another run would do the same. The finished run's journal gets + ``initiative-chain-next`` (``parent``, ``from_child``, ``to_child``, the + 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. + + 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. + + Returns 0, 1 for a tree fault or a close that could not be committed, or the + refusal rc of the next launch.""" + while True: + state = finished.state + parent = state.parent_initiative + if parent is None or state.run_type == "sweep" or state.replay_of or not state.finished: + if parent is not None and not state.finished: + print( + f"initiative chain {parent}: stops at {state.initiative} — run " + f"{state.run_id} did not finish" + ) + return 0 + if _chain_stop_requested(finished): + return _chain_end(finished, parent, "stop-requested") + try: + owed = _close_drained_children(project, paths, pol, parent, finished.journal, state) + if owed is not None: + return _chain_end( + finished, + parent, + "store-commit-failed", + error=f"{owed} (see the journal of run {state.run_id}) — commit it, " + "then run again", + ) + pick = _chain_pick(project, paths, parent) + except (tickettree.TicketsError, ValueError, bmadconfig.BmadConfigError) as e: + return _chain_end(finished, parent, "fault", error=str(e)) + if pick.child is None: + return _chain_end(finished, parent, pick.end or "", pick) + budget = _chain_budget(state) + if budget is not None and budget <= 0: + return _chain_end(finished, parent, "max-stories", pick) + child = pick.child.slug + if child == state.initiative and not _landed_a_ticket(state): + return _chain_end(finished, parent, "stalled", pick) + finished.journal.append( + "initiative-chain-next", + parent=parent, + from_child=state.initiative, + to_child=child, + passed_over=[p.slug for p in pick.passed_over], + passed_over_why=[p.why for p in pick.passed_over], + ) + print(f"initiative chain {parent}: {state.initiative} -> {child}{_passed_over_note(pick)}") + launched = _launch_run( + project, + paths.with_initiative(child), + pol, + argparse.Namespace(run_id=None, epic=None, story=None, max_stories=budget), + source=worksource.SOURCE_TICKETS, + spec_folder=state.spec_folder, + scope=tickets_source.EVERYTHING, + parent_initiative=parent, + ) + if isinstance(launched, int): + return launched + launched.journal.append( + "initiative-chain", + parent=parent, + previous_run=state.run_id, + previous_child=state.initiative, + ) + print(launched.engine.run().render()) + finished = launched def _render_invocation(pol, project: Path, role: str, prompt: str) -> str: @@ -4634,7 +5203,7 @@ def _dry_run_tickets( scope = _resolve_ticket_scope(tree, args, (next_view, status_view)) if isinstance(scope, int): return scope - decision = tickets_source.decide_pick(next_view, status_view, {}, {}, (), scope) + decision = _fresh_pick(next_view, status_view, scope) rank = tickets_source.build_order(status_view) last = len(rank) ready = sorted( @@ -4843,6 +5412,7 @@ def _start_sweep( profiles=None, on_started: Callable[[], None] | None = None, plugin_grants: GrantSet, + parent_initiative: str | None = None, ) -> int: # The composition (run dir + state + pid + sweep.json + adapters + engine) # lives in runsetup; this stays compose -> render. SweepEngine and @@ -4862,6 +5432,9 @@ def _start_sweep( # requires it: the launch's frozen read (#496/#497). A default would let a new # caller silently refuse every granted plugin — or tempt one to read the store # here, below the launch. + # + # `parent_initiative` is a chained child's parent, for its run-end sweep + # (`_sweep_factory`); `paths` are then the child's. None for every other sweep. composed = runsetup.compose_sweep( project=project, paths=paths, @@ -4881,6 +5454,7 @@ def _start_sweep( profiles=profiles, on_started=on_started, plugin_grants=plugin_grants, + parent_initiative=parent_initiative, ) _warn_bypass_dropped( pol, @@ -4902,6 +5476,7 @@ def _sweep_factory( trusted_digest: str, *, plugin_grants: GrantSet, + parent_initiative: str | None = None, ): """Child-sweep launcher injected into story-run engines. Auto-triggered sweeps are unattended: never prompt, never run decision bundles. @@ -4926,7 +5501,12 @@ def _sweep_factory( launch (by anyone) bless code no human launched. The child's registry build judges the plugins on disk against these launch grants, so a plugin swapped after launch raises out of the engine build — before ``started`` — and the - parent records ``sweep-auto-not-started``.""" + parent records ``sweep-auto-not-started``. + + ``parent_initiative`` is the launching run's ``RunState.parent_initiative``: + for a chained child it is the parent, ``paths`` are re-rooted on the child + (``runs.paths_for_state``), and the child sweep works the child's ledger and + records the parent as its pin (``runsetup.compose_sweep``). None otherwise.""" def factory(trigger: str, *, started: Callable[[], None]) -> None: pol = policy_mod.load(_policy_path(project)) @@ -4972,8 +5552,12 @@ def factory(trigger: str, *, started: Callable[[], None]) -> None: conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation) if conflict is not None: raise RuntimeError(conflict) + # a chained child's sweep works the child's store: `paths` are re-rooted on it + chained: dict[str, Any] = ( + {} if parent_initiative is None else {"initiative": paths.active_initiative} + ) store = ticketstore.store_conflict( - project, _sweep_store_source(paths, pol), pol.scm.isolation + project, _sweep_store_source(paths, pol), pol.scm.isolation, **chained ) if store is not None: raise RuntimeError(store) @@ -5009,6 +5593,7 @@ def factory(trigger: str, *, started: Callable[[], None]) -> None: profiles=profiles, on_started=started, plugin_grants=plugin_grants, + parent_initiative=parent_initiative, ) return factory @@ -5101,6 +5686,8 @@ def cmd_sweep(args: argparse.Namespace) -> int: trigger="cli", run_id=args.run_id, plugin_grants=plugin_grants, + # a human-started sweep works the active initiative's ledger, never a child's + parent_initiative=None, ) @@ -5420,11 +6007,6 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo print(f"cannot resume {run_dir.name}: {refusal}", file=sys.stderr) return 1 paths = bmadconfig.load_paths(project, want_tickets=runs.resolves_as_tickets(state)) - # A chained run's engine works on its child initiative's paths; the gates below - # judge the loaded (parent's) paths as `run` does, since the tickets preflight - # classifies a parent and its children itself. Ahead of every side effect: a - # config that no longer names the parent refuses here (BmadConfigError). - run_paths = runs.paths_for_state(paths, state) sweep_options = None if state.run_type == "sweep": try: @@ -5441,11 +6023,21 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo except runsetup.SweepOptionsError as exc: print(f"cannot resume {run_dir.name}: {exc}", file=sys.stderr) return 1 - # v7: a sweep resumes only on the initiative it started on. Ahead of every - # side effect below; `compose_resume` raises the same text as a backstop. + # v7: a sweep resumes only on the initiative it started on (a chained + # child's sweep: while its parent is active), judged on the loaded paths + # ahead of the re-root below, so that refusal names the sweep's pin. Ahead + # of every side effect below; `compose_resume` judges the re-rooted paths + # as a backstop. if (conflict := runsetup.sweep_initiative_conflict(state, paths)) is not None: print(f"cannot resume {run_dir.name}: {conflict}", file=sys.stderr) return 1 + # A chained run's engine works on its child initiative's paths; the gates below + # judge the loaded (parent's) paths as `run` does, since the tickets preflight + # classifies a parent and its children itself — except the store refusal, which + # judges the child's own store (`_store_initiative`), as its launch did. Ahead of + # every side effect: a config that no longer names the parent refuses here + # (BmadConfigError). + run_paths = runs.paths_for_state(paths, state) pol = policy_mod.load(_policy_path(project)) # Resume re-reads config.yaml and policy.toml from disk, so it is a second # entrypoint into the same engine and gets the same refusal — a run started @@ -5461,7 +6053,11 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo store_source = runs.judged_store_source(state, _sweep_store_source(paths, pol)) if ( rc := _reject_store_topology( - project, pol, store_source, recorded=runs.live_store_topology(state, project) or None + project, + pol, + store_source, + recorded=runs.live_store_topology(state, project) or None, + initiative=_store_initiative(state), ) ) is not None: return rc @@ -5790,7 +6386,18 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo # SweepEngine and _make_adapters are handed in from this module's namespace so # the test suite's `monkeypatch.setattr(cli, "SweepEngine"/"Engine"/..., ...)` # still applies. - return run_paths, state, pol, journal, new_digest, profiles, sweep_options, plugin_grants, pause + return ( + run_paths, + state, + pol, + journal, + new_digest, + profiles, + sweep_options, + plugin_grants, + pause, + paths, + ) class _CtlLaunch: @@ -5975,7 +6582,10 @@ def _resume_paused_run(project: Path, run_dir: Path, *, accept_baseline: bool = """Resume a paused/interrupted run without holding its lock across execution. Also resolve's re-arm path, which never passes ``accept_baseline`` — its re-arm - (`runs.rearm_escalation`) already advances the baseline to HEAD.""" + (`runs.rearm_escalation`) already advances the baseline to HEAD. Every path + that drives a recorded run's engine comes through here, so a chained child + resumed to finished continues its parent's chain here + (:func:`_drive_initiative_chain`).""" with state_lock(run_dir): # Cleanup removes the run under this same hold. A resume that resolved the # path before cleanup won must not let Journal/save_state recreate it after @@ -5995,9 +6605,18 @@ def _resume_paused_run(project: Path, run_dir: Path, *, accept_baseline: bool = prepared = _prepare_resume_locked(project, run_dir, accept_baseline=accept_baseline) if isinstance(prepared, int): return prepared - paths, state, pol, journal, new_digest, profiles, sweep_options, plugin_grants, pause = ( - prepared - ) + ( + paths, + state, + pol, + journal, + new_digest, + profiles, + sweep_options, + plugin_grants, + pause, + loaded_paths, + ) = prepared _claim_ctl_window(project, run_dir, state) # Adapter construction and the engine lifetime are deliberately outside the @@ -6011,7 +6630,15 @@ def _resume_paused_run(project: Path, run_dir: Path, *, accept_baseline: bool = state=state, policy=pol, journal=journal, - sweep_factory=_sweep_factory(project, paths, new_digest, plugin_grants=plugin_grants), + # a chained child's run-end sweep works the child's ledger (`paths` are + # re-rooted on it) and pins the parent + sweep_factory=_sweep_factory( + project, + paths, + new_digest, + plugin_grants=plugin_grants, + parent_initiative=state.parent_initiative, + ), make_adapters=_make_adapters, engine_cls=Engine, stories_engine_cls=StoriesEngine, @@ -6050,11 +6677,11 @@ def _resume_paused_run(project: Path, run_dir: Path, *, accept_baseline: bool = raise summary = composed.engine.run() print(summary.render()) - return ( - ExitCode.FAILURE - if summary.crashed and sweep_options is not None and sweep_options.only_ids is not None - else ExitCode.OK - ) + if summary.crashed and sweep_options is not None and sweep_options.only_ids is not None: + return ExitCode.FAILURE + # A chained child resumed to finished goes on to its parent's next child, as + # after `run`; `loaded_paths` are the parent's, which `paths` were re-rooted from. + return _drive_initiative_chain(project, loaded_paths, pol, composed) def _restore_aborted_resume( @@ -6502,9 +7129,11 @@ def cmd_resolve(args: argparse.Namespace) -> int: # Re-judged after the confirm against the config the re-arm reads. An # unreadable config skips this early exit only, as the trust gate below # does; the resume's own config read refuses it. + # Judged on the loaded paths, as the resume judges them, so a chained child's + # sweep whose parent is no longer active is refused here too. if state.run_type == "sweep": try: - pin_paths = runs.paths_for_state(bmadconfig.load_paths(project), state) + pin_paths = bmadconfig.load_paths(project) except (bmadconfig.BmadConfigError, OSError): pin_paths = None if ( @@ -6553,6 +7182,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: policy_mod.load(_policy_path(project)), state.source, recorded=runs.live_store_topology(state, project) or None, + initiative=_store_initiative(state), ) ) is not None @@ -6648,6 +7278,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: pol, runs.judged_store_source(state, _sweep_store_source(pre_session_paths, pol)), recorded=runs.live_store_topology(state, project) or None, + initiative=_store_initiative(state), ) ) is not None: return rc @@ -6883,12 +7514,16 @@ def cmd_resolve(args: argparse.Namespace) -> int: pol, runs.judged_store_source(state, _sweep_store_source(paths, pol)), recorded=runs.live_store_topology(state, project) or None, + initiative=_store_initiative(state), ) ) is not None: return rc # The sweep's initiative pin, re-judged: the active initiative can change # during the resolve session, and the re-arm below precedes the resume. - if (conflict := runsetup.sweep_initiative_conflict(state, paths)) is not None: + # `paths` are re-rooted (`runs.paths_for_state`). + if ( + conflict := runsetup.sweep_initiative_conflict(state, paths, rerooted=True) + ) is not None: print(f"cannot resolve {args.run_id}: {conflict}", file=sys.stderr) return 1 # #496/#497: the resume's plugin-trust refusal, hoisted ahead of the re-arm @@ -7559,7 +8194,11 @@ def _resolve_reverify_finished( return rc if ( rc := _reject_store_topology( - project, pol, state.source, recorded=runs.live_store_topology(state, project) or None + project, + pol, + state.source, + recorded=runs.live_store_topology(state, project) or None, + initiative=_store_initiative(state), ) ) is not None: return rc diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 0e7d9b685..bf9d21ab5 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -309,6 +309,17 @@ # namespace so one initiative gets one alias. Kind-scoped: `parent` and `slug` # are generic names. "initiative-mark": {"parent": "initiative", "slug": "initiative"}, + # `cli._drive_initiative_chain`: the parent and the children it switches + # between, in the same `initiative` namespace. + "initiative-chain-next": { + "parent": "initiative", + "from_child": "initiative", + "to_child": "initiative", + }, + "initiative-chain-end": {"parent": "initiative"}, + "initiative-chain": {"parent": "initiative", "previous_child": "initiative"}, + # `cli._close_drained_children`: a drained child left open, and its parent. + "initiative-close-skipped": {"parent": "initiative", "slug": "initiative"}, } # Namespaces whose journalled value arrives in more than one shape and must be # reduced to its basename before it is aliased. `spec` is one: engine.py's @@ -497,6 +508,17 @@ # ship verbatim, `scrub_json` being the identity on a list of # identifier-shaped strings. "sweep-bundle-dwids-adopted": {"previous_dw_ids": "dw"}, + # `cli._drive_initiative_chain`: child initiative slugs passed over, still + # waiting on a prerequisite (`waiting`, and per prerequisite `waiting_child`), + # or waited on (`waiting_on`), aliased in the `initiative` namespace the + # kind-scoped `parent`/`*_child` rows use. + "initiative-chain-next": {"passed_over": "initiative"}, + "initiative-chain-end": { + "passed_over": "initiative", + "waiting": "initiative", + "waiting_child": "initiative", + "waiting_on": "initiative", + }, } _JOURNAL_KIND_COUNTLIST_FIELDS: dict[str, frozenset[str]] = { "merge-preflight-refused": frozenset({"tolerated"}), @@ -606,6 +628,19 @@ # collapses to `file_present`. `before`, `after` and `outcome` are closed # status/outcome tokens; `parent`/`slug` are kind-aliased, which runs first. "initiative-mark": frozenset({"before", "after", "outcome"}), + # `cli._drive_initiative_chain`: `passed_over_why` holds closed pass-over + # tokens (`no-folder`, `drained`), `end` a closed chain-end token, + # `waiting_on_status` closed prerequisite-status tokens (an initiative status, + # `open`, `no-folder`, `?`), and `previous_run` a run id; the slugs are + # kind-aliased or kind-keylisted, which runs first, and `error` is dropped by + # name. `initiative-close-skipped`'s `why` is a closed skip token (`run-live`, + # `run-unfinished`, `runs-unread`, `work-left`, `store-refused`, + # `uncommitted-edit`) and `blocking_run` a run id (or empty); `detail` is + # dropped by name. + "initiative-chain-next": frozenset({"passed_over_why"}), + "initiative-chain-end": frozenset({"end", "passed_over_why", "waiting_on_status"}), + "initiative-chain": frozenset({"previous_run"}), + "initiative-close-skipped": frozenset({"why", "blocking_run"}), } # Policy keys whose values can carry secrets/paths/free text. Dropped or reduced diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py index 22aad9536..b20cdcabd 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -16,7 +16,12 @@ and returns a reason to pass it over (``"drained"`` for a child with nothing left to dispatch), or None to take it. * Children that are not ready and not done or dropped are ``waiting`` on a - prerequisite (a folder-less one included). + prerequisite (a folder-less one included). ``waiting_on`` names, per waiting + child, each prerequisite it still waits on and that prerequisite's status + (:func:`prerequisite_status`). + +:func:`drained` says whether a child is open with every ticket closed — the child +whose initiative a chain marks ``done`` before it picks again. 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 @@ -31,11 +36,21 @@ from dataclasses import dataclass from typing import Final, Literal +from .statemachine import TICKET_CLOSED_STATES from .ticketmodel import ChildInitiative, ParentView NO_FOLDER: Final = "no-folder" """The pass-over reason for a ready child whose folder does not exist yet.""" +DRAINED: Final = "drained" +"""The pass-over reason for a ready child with nothing left to dispatch.""" + +OPEN: Final = "open" +"""A prerequisite's status when its folder exists and its file sets none.""" + +UNLISTED: Final = "?" +"""A prerequisite's status when the parent lists no such child.""" + _CLOSED: Final[frozenset[str]] = frozenset(("done", "dropped")) ChainEnd = Literal["waiting", "passed-over", "all-done"] @@ -49,17 +64,54 @@ class PassedOver: why: str +@dataclass(frozen=True) +class WaitingOn: + """One prerequisite a waiting child still waits on: the child, the + prerequisite, and the prerequisite's status (:func:`prerequisite_status`).""" + + slug: str + prerequisite: str + status: str + + @dataclass(frozen=True) class ChildPick: """:func:`next_child`'s answer. ``child`` is the child to build, or None and ``end`` says why the chain ends. ``passed_over`` holds the ready children 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.""" + 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.""" child: ChildInitiative | None passed_over: tuple[PassedOver, ...] waiting: tuple[str, ...] end: ChainEnd | None + waiting_on: tuple[WaitingOn, ...] = () + + +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 + child ``statemachine.check_initiative_close`` writes ``done``.""" + 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 + + +def prerequisite_status(view: ParentView, slug: str) -> str: + """The status of the child ``slug`` of ``view`` as a prerequisite: its file's + status (``in-progress``, ``dropped``, ``done``), :data:`OPEN` when its folder + exists and the file sets none, :data:`NO_FOLDER` when it is declared but not + created yet, or :data:`UNLISTED` when the parent lists no such child.""" + child = view.child(slug) + if child is None: + return UNLISTED + if child.folder is None: + return NO_FOLDER + return child.status or OPEN def next_child( @@ -69,7 +121,11 @@ def next_child( """The first ready child of ``view`` that has a folder and that ``passes_over`` does not pass over, in build order (see the module docstring).""" - waiting = tuple(c.slug for c in view.initiatives if not c.ready and c.status not in _CLOSED) + blocked = [c for c in view.initiatives if not c.ready and c.status not in _CLOSED] + waiting = tuple(c.slug for c in blocked) + waiting_on = tuple( + WaitingOn(c.slug, p, prerequisite_status(view, p)) for c in blocked for p in c.waiting_on + ) passed: list[PassedOver] = [] for child in view.initiatives: if not child.ready: @@ -78,7 +134,7 @@ 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) + return ChildPick(child, tuple(passed), waiting, None, waiting_on) 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) + return ChildPick(None, tuple(passed), waiting, end, waiting_on) diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 8638c9365..a23c0042a 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3737,7 +3737,8 @@ def held_sweep_closes(project: Path, run_dir: Path) -> list[str]: return [] from .runsetup import sweep_initiative_conflict # lazy: runsetup imports runs - if (conflict := sweep_initiative_conflict(state, paths)) is not None: + # `paths` are re-rooted (`paths_for_state`) + if (conflict := sweep_initiative_conflict(state, paths, rerooted=True)) is not None: # The live paths resolve another initiative's ledger, which holds nothing # under this run's op id: answering `[]` there would wave the holder through. raise HeldClosesUnknownError(conflict) diff --git a/src/bmad_loop/runsetup.py b/src/bmad_loop/runsetup.py index 3dc3e8d5f..7991db205 100644 --- a/src/bmad_loop/runsetup.py +++ b/src/bmad_loop/runsetup.py @@ -858,6 +858,7 @@ def build_run_state( initiative_folder: str = "", store_topology: dict[str, str | None] | None = None, ticket_epic_filter: str = "", + parent_initiative: str | None = None, ) -> RunState: """Assemble the launch-time :class:`RunState` for a fresh run. @@ -886,7 +887,9 @@ def build_run_state( for a tickets run, whose tree reads refuse once the live initiative differs; every other source records them empty. ``store_topology`` is the classified ticket store (``ticketstore.StoreTopology.to_dict``), and ``ticket_epic_filter`` - the ``--epic`` selector resolved to an epic slug, likewise tickets only.""" + the ``--epic`` selector resolved to an epic slug, likewise tickets only. + ``parent_initiative`` is a chained child run's parent (``initiative`` then + names the child), likewise tickets only.""" tickets = source == worksource.SOURCE_TICKETS return RunState( run_id=run_id, @@ -904,22 +907,24 @@ def build_run_state( initiative_folder=initiative_folder if tickets else "", store_topology=dict(store_topology or {}) if tickets else {}, ticket_epic_filter=ticket_epic_filter if tickets else "", + parent_initiative=parent_initiative if tickets else None, trusted_config_digest=trusted_config_digest, ) def _run_store_topology( - paths: bmadconfig.ProjectPaths, policy: Policy, source: str + paths: bmadconfig.ProjectPaths, policy: Policy, source: str, *, chained: bool = False ) -> dict[str, str | None]: """The ticket-store topology a tickets run records at its start (``{}`` for any other source, and when there is no initiative folder to classify — the preflight's ``tickets.initiative-*`` checks own that refusal). The run preflight refused every unrunnable store already, so a store refused here changed in between; that raises :class:`ticketstore.TicketStoreError` rather than starting - unrecorded.""" + unrecorded. ``chained``: ``paths`` are a chained run's, re-rooted on its child + (:func:`ticketstore.assess_store`).""" if source != worksource.SOURCE_TICKETS: return {} - result = ticketstore.assess_store(paths, policy.scm.isolation) + result = ticketstore.assess_store(paths, policy.scm.isolation, chained=chained) if result is None: return {} if isinstance(result, ticketstore.StoreRefusal): @@ -985,7 +990,9 @@ class SweepPinError(ValueError): """A sweep would resume under another BMAD v7 initiative than it started on.""" -def sweep_initiative_conflict(state: RunState, paths: bmadconfig.ProjectPaths) -> str | None: +def sweep_initiative_conflict( + state: RunState, paths: bmadconfig.ProjectPaths, *, rerooted: bool = False +) -> str | None: """The refusal text when a sweep would resume on another initiative than the one it pinned at launch (``RunState.initiative``), else None. Every story run is None here: a tickets run checks its pin on each tree read instead. @@ -997,9 +1004,27 @@ def sweep_initiative_conflict(state: RunState, paths: bmadconfig.ProjectPaths) - whose ids mean other entries. The live side is ``paths.active_initiative`` (``""`` off v7). A sweep that started with no initiative active pins ``""``; one written before the pin existed reads the same, which on a v7 - project with an initiative active is refused rather than guessed.""" + project with an initiative active is refused rather than guessed. + + A chained child's run-end sweep (``state.parent_initiative`` set) works the + child's ledger but is pinned to the PARENT, as the chained run is + (:func:`runs.paths_for_state`). ``rerooted`` names which paths the caller + passes, and is never inferred from their values — a live config naming the + child loads paths that look re-rooted: + + - ``rerooted=False`` (default): ``bmadconfig.load_paths`` output. Passes only + while the live ``active_initiative`` names ``state.parent_initiative``. + - ``rerooted=True``: :func:`runs.paths_for_state` output, which raised + unless the live config named the parent. Passes only when the paths are + rooted on the child (``active_initiative`` and the initiative folder's name + are ``state.initiative``). + + An unchained sweep is judged the same either way (``paths_for_state`` + returns its loaded paths unchanged).""" if state.run_type != "sweep": return None + if state.parent_initiative is not None: + return _chained_sweep_conflict(state, paths, rerooted=rerooted) pinned = state.initiative live = paths.active_initiative if paths.era == "v7" else "" if pinned == live: @@ -1018,6 +1043,34 @@ def sweep_initiative_conflict(state: RunState, paths: bmadconfig.ProjectPaths) - ) +def _chained_sweep_conflict( + state: RunState, paths: bmadconfig.ProjectPaths, *, rerooted: bool +) -> str | None: + """:func:`sweep_initiative_conflict` for a chained child's sweep.""" + parent = state.parent_initiative + child = state.initiative + live = paths.active_initiative if paths.era == "v7" else "" + folder = paths.initiative_folder if paths.era == "v7" else None + if rerooted: + if live == child and folder is not None and folder.name == child: + return None + found = ( + f"its paths are rooted on initiative {live!r} ({folder})" + if live + else "its paths are rooted on no initiative" + ) + else: + if live == parent: + return None + found = f"the active initiative is now {repr(live) if live else 'unset'}" + return ( + f"sweep {state.run_id} works child initiative {child!r} of parent initiative " + f"{parent!r}, but {found}; its ledger and pre-answers belong to that child. " + f"To resume it, set `[core] active_initiative` back to {parent!r} " + "(_bmad/custom/config.user.toml); otherwise start a new sweep" + ) + + @dataclass(frozen=True) class SweepResumeOptions: values: dict[str, Any] @@ -1345,6 +1398,7 @@ def compose_run( profiles: dict[str, CLIProfile] | None = None, plugin_grants: GrantSet = GrantSet.empty(), ticket_epic_filter: str = "", + parent_initiative: str | None = None, ) -> ComposedRun: """Stand up a run: allocate the run dir, persist state + pid, build the adapters, and wire the engine — everything ``cmd_run`` did inline between its @@ -1374,11 +1428,32 @@ def compose_run( ``ticket_epic_filter`` is a tickets run's ``--epic`` selector, already resolved to an epic slug by the caller (``epic_filter`` stays None for such a run, and ``story_filter`` carries the resolved ticket ref). + + ``parent_initiative`` is set only for a chained child run: ``paths`` are then + 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. """ + 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 + ): + 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})" + ) 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. - store_topology = _run_store_topology(paths, policy, source) + store_topology = _run_store_topology( + paths, policy, source, chained=parent_initiative is not None + ) # Outside the try below, and it must stay there: a collision refusal that # reached `_unwind_composition` would delete the run it exists to protect. composer_claim = _claim_run_dir(run_dir) @@ -1414,6 +1489,7 @@ def compose_run( ), store_topology=store_topology, ticket_epic_filter=ticket_epic_filter, + parent_initiative=parent_initiative, ) journal = Journal(run_dir, pin=state) # State becoming resumable and the pid making this process live are one @@ -1485,6 +1561,7 @@ def compose_sweep( profiles: dict[str, CLIProfile] | None = None, on_started: Callable[[], None] | None = None, plugin_grants: GrantSet = GrantSet.empty(), + parent_initiative: str | None = None, ) -> ComposedRun: """Stand up a sweep run: allocate the run dir, persist state + pid, record the sweep options, build the adapters, and wire the ``SweepEngine`` — everything @@ -1503,6 +1580,13 @@ def compose_sweep( suite's ``monkeypatch.setattr(cli, "SweepEngine"/"_make_adapters", ...)`` effective. + ``parent_initiative`` is set only for the run-end sweep of a chained child + (``RunState.parent_initiative`` of the run that triggered it): ``paths`` are + then that child's (:func:`runs.paths_for_state`), so the sweep works the + child's ledger and pins the child as its ``initiative``, and it records the + parent, which a resume finds still active (:func:`sweep_initiative_conflict`). + Recorded on v7 only. + ``plugin_grants`` is the launch's frozen grant set, as in :func:`compose_run`. For the auto-sweep child it is the set the PARENT's launch read, held by the factory closure: a plugin changed or granted since then is judged against the @@ -1603,6 +1687,8 @@ def compose_sweep( initiative_folder=( str(paths.initiative_folder) if v7 and paths.initiative_folder is not None else "" ), + # a chained child's sweep: the parent its pin names (above: the child) + parent_initiative=parent_initiative if v7 else None, store_topology=store_topology, ) journal = Journal(run_dir, pin=state) @@ -1698,7 +1784,8 @@ def compose_resume( effect (:func:`_engine_cls_for`).""" if state.run_type == "sweep": validate_sweep_options_version(state.sweep_options_version) - if (conflict := sweep_initiative_conflict(state, paths)) is not None: + # `paths` are the run's own: re-rooted on a chained sweep's child + if (conflict := sweep_initiative_conflict(state, paths, rerooted=True)) is not None: raise SweepPinError(conflict) story_engine = None else: diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index b1192f9f1..34393aa75 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1448,11 +1448,16 @@ def _ticket_replan_worker( else: conflict = bmadconfig.worktree_isolation_conflict(paths, isolation) if conflict is None: + # a chained run's store is its child's, as `cli._store_initiative` judges it + chained = ( + {} if recorded.parent_initiative is None else {"initiative": recorded.initiative} + ) conflict = ticketstore.store_conflict( self.project, worksource.SOURCE_TICKETS, isolation, recorded=runs.live_store_topology(recorded, self.project) or None, + **chained, ) if conflict is not None: self.call_from_thread(self.notify, conflict, severity="error") @@ -1661,8 +1666,16 @@ def _do_rearm( except bmadconfig.BmadConfigError as e: conflict = str(e) # a chained run's config no longer names its parent else: - conflict = runsetup.sweep_initiative_conflict(recorded, paths) + conflict = runsetup.sweep_initiative_conflict( + recorded, paths, rerooted=True + ) if recorded is not None and conflict is None: + # a chained run's store is its child's (`cli._store_initiative`) + chained = ( + {} + if recorded.parent_initiative is None + else {"initiative": recorded.initiative} + ) conflict = ticketstore.store_conflict( self.project, runs.judged_store_source( @@ -1671,6 +1684,7 @@ def _do_rearm( ), isolation, recorded=runs.live_store_topology(recorded, self.project) or None, + **chained, ) if conflict is not None: self.notify(conflict, severity="error") @@ -1723,9 +1737,12 @@ def _do_rearm( return if paths is not None: # the initiative pin, re-judged on the state the re-arm writes: - # the lock-free judgment above skips a state it could not read + # the lock-free judgment above skips a state it could not read, + # and then `paths` are the loaded ones, never re-rooted if ( - pinned := runsetup.sweep_initiative_conflict(fresh_state, paths) + pinned := runsetup.sweep_initiative_conflict( + fresh_state, paths, rerooted=recorded is not None + ) ) is not None: self.notify(pinned, severity="error") return diff --git a/tests/test_cli.py b/tests/test_cli.py index 9de906862..2e3cc937e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,6 +60,7 @@ v7_layout, write_gated_ledger, write_ledger, + write_plan, write_repo_root_override, write_script_launcher, write_spec, @@ -6254,6 +6255,46 @@ def conflict(_project, source, _isolation, *, recorded=None): assert _state_bytes(run_dir) == before +@pytest.mark.parametrize("chained", [True, False], ids=["chained", "unchained"]) +@pytest.mark.parametrize("flag", ["--adopt-branch", "--reverify"]) +def test_resolve_early_dispatch_judges_a_chained_childs_own_store( + tmp_path, monkeypatch, capsys, flag, chained +): + """The early store gate ahead of `--adopt-branch` / `--reverify` judges a + chained child's OWN store (`initiative=`), as the resume it ends in does; + an unchained run's call carries no `initiative`. Ablation (performed): drop + `initiative=` at that gate and the chained rows FAIL.""" + from bmad_loop import ticketstore + from bmad_loop.journal import load_state, save_state + + run_dir = _adoptable_run(tmp_path) + state = load_state(run_dir) + state.source = "tickets" + if chained: + state.initiative, state.parent_initiative = "initiative-child", "initiative-platform" + save_state(run_dir, state) + before = _state_bytes(run_dir) + seen: list[dict] = [] + + def conflict(_project, source, _isolation, **kw): + seen.append(kw) + return "the ticket store changed since this run started" + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + monkeypatch.setattr( + cli, "_resume_paused_run", lambda *a, **k: pytest.fail("a refusal must not resume") + ) + + rc = cli.main(["resolve", "--project", str(tmp_path), "r1", flag, "--resume"]) + + assert rc == 1 + assert "the ticket store changed since this run started" in capsys.readouterr().err + (kw,) = seen + assert kw.get("initiative") == ("initiative-child" if chained else None) + assert ("initiative" in kw) == chained + assert _state_bytes(run_dir) == before + + def test_resolve_adopt_branch_refuses_a_task_with_no_recorded_worktree(tmp_path, capsys): from bmad_loop.journal import load_state, save_state @@ -10236,7 +10277,7 @@ def test_sweep_resume_refuses_an_initiative_change_before_any_write(project, mon run_dir = _paused_run_for_resume(project, monkeypatch, run_type="sweep") runs.write_trusted_config_digest(project.project, run_dir.name, "OLDPIN") monkeypatch.setattr( - runsetup, "sweep_initiative_conflict", lambda state, paths: "initiative switched" + runsetup, "sweep_initiative_conflict", lambda state, paths, **_kw: "initiative switched" ) monkeypatch.setattr(cli, "SweepEngine", _StubEngine) @@ -10352,6 +10393,14 @@ def __init__(self, **kwargs): built.append(kwargs["paths"]) monkeypatch.setattr(cli, "TicketsEngine", Engine) + factories: list = [] + real_factory = cli._sweep_factory + + def factory(project_root, paths, digest, **kw): + factories.append((paths, kw["parent_initiative"])) + return real_factory(project_root, paths, digest, **kw) + + monkeypatch.setattr(cli, "_sweep_factory", factory) if live != parent: with pytest.raises(bmadconfig.BmadConfigError, match=f"set it back to {parent!r}"): @@ -10361,11 +10410,73 @@ def __init__(self, **kwargs): assert cli._resume_paused_run(project.project, run_dir) == 0 assert preflights == [loaded] (paths,) = built + # the run-end sweep works the child's ledger and pins the parent + assert factories == [(paths, parent)] assert paths.active_initiative == child assert paths.initiative_folder == real.output_folder / child assert paths.deferred_work == real.output_folder / child / "deferred-work.md" +@pytest.mark.parametrize("chained", [True, False], ids=["chained", "unchained"]) +def test_tickets_resume_judges_a_chained_childs_own_store(project, monkeypatch, chained): + """A chained child's resume judges the CHILD's store (`initiative=`, as + its launch did) against the topology it recorded; an unchained resume calls the + predicate exactly as before chains existed (no `initiative`). Ablation + (performed): drop `initiative=` at `_prepare_resume_locked`'s store check and + the chained row FAILS.""" + from bmad_loop import bmadconfig, ticketstore + + parent, child = "initiative-platform", "initiative-child" + chain = {"initiative": child, "parent_initiative": parent} if chained else {} + run_dir = _paused_run_for_resume(project, monkeypatch, source="tickets", **chain) + 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) + seen: list[dict] = [] + + def conflict(_project, source, _isolation, **kw): + seen.append(kw) + return "the ticket store changed since this run started" + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + + assert cli._resume_paused_run(project.project, run_dir) == 1 + (kw,) = seen + assert kw.keys() == ({"recorded", "initiative"} if chained else {"recorded"}) + assert kw.get("initiative") == (child if chained else None) + assert _resume_entries(run_dir) == [] + + +def test_chained_sweep_resume_refuses_a_moved_parent_with_the_sweep_pin( + project, monkeypatch, capsys +): + """A chained child's sweep resumed after `active_initiative` moved off its + parent is refused by the sweep pin, judged on the loaded paths ahead of the + re-root, so the refusal names the sweep and the parent to restore — before any + side effect. Ablation (performed): judge the pin after `runs.paths_for_state` + and the re-root's BmadConfigError escapes instead.""" + from bmad_loop import bmadconfig + + parent, child = "initiative-platform", "initiative-child" + run_dir = _paused_run_for_resume( + project, monkeypatch, run_type="sweep", initiative=child, parent_initiative=parent + ) + real = bmadconfig.load_paths(project.project) + other = "initiative-other" + loaded = dataclasses.replace( + real, era="v7", active_initiative=other, initiative_folder=real.output_folder / other + ) + monkeypatch.setattr(bmadconfig, "load_paths", lambda _p, *, want_tickets=False: loaded) + + assert cli._resume_paused_run(project.project, run_dir) == 1 + err = capsys.readouterr().err + assert f"cannot resume {run_dir.name}: sweep {run_dir.name} works child initiative" in err + assert f"set `[core] active_initiative` back to {parent!r}" in err + assert _resume_entries(run_dir) == [] + + # ------------------------------------------- resume re-stamps the code root # `repo_root:` is the one ProjectPaths member that decides which git TREE the run @@ -10755,7 +10866,8 @@ def test_resume_crash_exit_is_failure_only_for_persisted_named_scope( runsetup, "compose_resume", # ComposedRun always carries a journal; DW-410's launch warning reads it. - lambda **_kwargs: types.SimpleNamespace(engine=engine, journal=None), + # And its state, which the initiative chain reads after the engine. + lambda **kwargs: types.SimpleNamespace(engine=engine, journal=None, state=kwargs["state"]), ) assert cli._resume_paused_run(project.project, run_dir) == expected @@ -11668,7 +11780,7 @@ def test_resolve_refuses_a_sweep_on_a_switched_initiative_before_any_write( ) before = load_state(run_dir) monkeypatch.setattr( - runsetup, "sweep_initiative_conflict", lambda state, paths: "initiative switched" + runsetup, "sweep_initiative_conflict", lambda state, paths, **_kw: "initiative switched" ) effects: list = [] monkeypatch.setattr( @@ -11688,6 +11800,48 @@ def test_resolve_refuses_a_sweep_on_a_switched_initiative_before_any_write( _assert_escalation_intact(run_dir, before) +@pytest.mark.parametrize("flags", [[], ["--reverify"]], ids=["interactive", "reverify"]) +def test_resolve_refuses_a_chained_sweep_whose_parent_moved_before_any_write( + project, monkeypatch, capsys, flags +): + """A chained child's sweep resolved after `active_initiative` moved off its + parent is refused by the entry gate, judged on the loaded paths, with the + parent to restore — ahead of the session and the reverify transition. Ablation + (performed): judge the re-rooted paths there again (`runs.paths_for_state`, + whose config fault skips the gate) and the session/reverify recorders run.""" + from bmad_loop import resolve as resolve_mod + from bmad_loop.journal import load_state, save_state + + run_dir, rearms = _resolve_gate_run( + project, monkeypatch, run_type="sweep", ledger_bytes=READABLE_LEDGER + ) + parent = "initiative-platform" + state = load_state(run_dir) + state.initiative, state.parent_initiative = "initiative-child", parent + save_state(run_dir, state) + before = load_state(run_dir) + real = bmadconfig.load_paths(project.project) + other = "initiative-other" + loaded = dataclasses.replace( + real, era="v7", active_initiative=other, initiative_folder=real.output_folder / other + ) + monkeypatch.setattr(bmadconfig, "load_paths", lambda _p, *, want_tickets=False: loaded) + effects: list = [] + monkeypatch.setattr(resolve_mod, "build_context", lambda *a, **k: (None, [], [])) + monkeypatch.setattr(resolve_mod, "run_session", lambda *a, **k: effects.append("session")) + monkeypatch.setattr(cli, "_resolve_reverify", lambda *a, **k: effects.append("reverify") or 0) + + rc = cli.main(["resolve", "--project", str(project.project), "r1", *flags]) + + assert rc == 1 + err = capsys.readouterr().err + assert "cannot resolve r1: sweep r1 works child initiative 'initiative-child'" in err + assert f"set `[core] active_initiative` back to {parent!r}" in err + assert effects == [] + assert rearms == [] + _assert_escalation_intact(run_dir, before) + + def test_resolve_rejudges_a_sweeps_initiative_after_the_session(project, monkeypatch, capsys): """The active initiative can change while the interactive resolve session is open, and the re-arm precedes the resume that would refuse it: the pin is @@ -11705,7 +11859,7 @@ def test_resolve_rejudges_a_sweeps_initiative_after_the_session(project, monkeyp before = load_state(run_dir) sessions: list = [] - def judge(state, paths): + def judge(state, paths, **_kw): return "initiative switched" if sessions else None monkeypatch.setattr(runsetup, "sweep_initiative_conflict", judge) @@ -15783,6 +15937,771 @@ def __init__(self, **kw): assert "stub summary" in capsys.readouterr().out +class _Composed(Exception): + """Raised by a recording `runsetup.compose_run` stand-in: the launch got there.""" + + +def _record_launch(project, monkeypatch): + """Record the per-launch store checks, `_sweep_factory` calls and the + `compose_run` kwargs; compose_run raises `_Composed` so nothing is built.""" + calls: dict[str, list] = {"store": [], "factory": [], "compose": []} + real_store = cli._reject_store_topology + + def store(*a, **kw): + calls["store"].append(kw.get("initiative")) + return real_store(*a, **kw) + + def factory(project_root, paths, digest, **kw): + calls["factory"].append((paths, kw["parent_initiative"])) + return lambda _trigger, *, started: None + + def compose_run(**kw): + calls["compose"].append(kw) + raise _Composed + + monkeypatch.setattr(cli, "_reject_store_topology", store) + monkeypatch.setattr(cli, "_sweep_factory", factory) + monkeypatch.setattr(runsetup, "compose_run", compose_run) + return calls + + +def test_run_unchained_judges_the_store_once(project, capsys, monkeypatch): + """An unchained tickets run's store is judged by `cmd_run` alone: `_launch_run` + adds no second check, and no parent reaches compose_run or the sweep factory.""" + _make_tickets_validate_pass(project, monkeypatch, capsys) + calls = _record_launch(project, monkeypatch) + + cli.main(["run", "--project", str(project.project)]) # main reports `_Composed` + + assert calls["store"] == [None] + (kw,) = calls["compose"] + assert kw["parent_initiative"] is None + assert [parent for _paths, parent in calls["factory"]] == [None] + + +@pytest.mark.parametrize("refused", [False, True], ids=["launches", "store-refused"]) +def test_launch_run_judges_a_chained_childs_store_and_hands_on_the_parent( + project, capsys, monkeypatch, refused +): + """A chained child's launch judges the CHILD's store (`initiative=`) and + hands the parent to compose_run and to the run-end sweep factory, which gets the + child's paths; a refused child store aborts with rc 1 before anything is + composed. Ablation (performed): drop `initiative=` from `_launch_run`'s store + check and this FAILS.""" + from bmad_loop import tickets_source, ticketstore + + _make_tickets_validate_pass(project, monkeypatch, capsys) + calls = _record_launch(project, monkeypatch) + # no child folder on disk: the argument the check is handed is what is under test + conflict = "the child's store is refused" if refused else None + monkeypatch.setattr(ticketstore, "store_conflict", lambda *_a, **_k: conflict) + pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") + child = "initiative-child" + paths = bmadconfig.load_paths(project.project, want_tickets=True).with_initiative(child) + args = argparse.Namespace(run_id=None, epic=None, story=None, max_stories=None) + + def launch(): + return cli._launch_run( + project.project, + paths, + pol, + args, + source="tickets", + spec_folder="", + scope=tickets_source.EVERYTHING, + parent_initiative=V7_DEFAULT_INITIATIVE, + ) + + if refused: + assert launch() == 1 + assert calls["store"] == [child] + assert (calls["compose"], calls["factory"]) == ([], []) + assert "error: the child's store is refused" in capsys.readouterr().err + return + with pytest.raises(_Composed): + launch() + + assert calls["store"] == [child] + (kw,) = calls["compose"] + assert kw["parent_initiative"] == V7_DEFAULT_INITIATIVE + assert kw["paths"] is paths + assert calls["factory"] == [(paths, V7_DEFAULT_INITIATIVE)] + + +# ---- `run` on a parent initiative: one run per child (DW-574). The parent lists +# AUTH, then BILLING after AUTH; both hold `DEFAULT_TREE`. The real `tickets.py` +# answers every tree read and closes the child; a scripted engine plays each run. + +CHAIN_PARENT = "initiative-platform" +CHAIN_AUTH, CHAIN_BILLING = "initiative-auth", "initiative-billing" +CHAIN_POLICY = TICKETS_POLICY + '\n[scm]\nisolation = "worktree"\n' +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_config(sandbox): + """A v7 install whose active initiative is the parent `CHAIN_PARENT` over the + sliced children AUTH and BILLING (after AUTH), each holding `DEFAULT_TREE`.""" + root = sandbox.project + 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) + # a worktree mount's config rewrite names the child: the user layer stays untracked + ignore_before_commit(sandbox, "_bmad/custom/*.user.toml") + + +def _drain_child(root, slug, *, epics=True): + """Close every ticket of child ``slug`` — and every epic, unless ``epics`` is + False — and commit it: drained, open.""" + child = v7_layout(root, initiative=slug) + tree = tuple(dataclasses.replace(e, status="done") for e in DEFAULT_TREE) + write_ticket_tree(child, tree if epics else DEFAULT_TREE) + for epic, stem, ticket in CHAIN_PLANS: + write_plan(child, epic, stem, ticket, "done") + git(root, "add", "-A") + 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): + + * ``drain`` — every ticket and epic of the run's child closed and committed, + one task landed, the run finished; + * ``idle`` — finished with nothing dispatched; + * ``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). + + Returns the list each built engine appends ``(paths, state, run_dir)`` to.""" + from bmad_loop.journal import save_state + from bmad_loop.model import Phase, StoryTask + + _make_validate_pass( + project, + monkeypatch, + capsys, + policy=CHAIN_POLICY, + skills=install_build_auto_v7_skill, + bmad_config=_chain_config, + ) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES}) + root = project.project + built: list = [] + script = list(outcomes) + + class Engine(_StubEngine): + def __init__(self, **kwargs): + self.state, self.run_dir, self.journal = ( + kwargs["state"], + kwargs["run_dir"], + kwargs["journal"], + ) + built.append((kwargs["paths"], self.state, self.run_dir)) + + def run(self): + assert script, f"run {len(built)} of the chain was never scripted" + outcome = script.pop(0) + if outcome.startswith("pause"): + self.state.record_pause("scripted pause", "ticket-tree", None) + else: + self.state.finished = True + if outcome in ("drain", "stop-lodged", "stop-discarded"): + _drain_child(root, self.state.initiative) + self.state.tasks["1.1"] = StoryTask("1.1", 0, Phase.DONE) + if outcome == "stop-lodged": + (self.run_dir / runs.STOP_REQUEST_FILE).write_text("{}", encoding="utf-8") + if outcome in ("stop-discarded", "pause-stop-discarded"): + self.journal.append("stop-request-discarded") + save_state(self.run_dir, self.state) + return super().run() + + monkeypatch.setattr(cli, "TicketsEngine", Engine) + capsys.readouterr() + return built + + +def _chain_rows(run_dir): + from bmad_loop.journal import Journal + + kinds = ("initiative-mark", "store-committed", "initiative-chain", "initiative-chain-next") + kinds += ("initiative-chain-end",) + return [e for e in Journal(run_dir).entries() if e["kind"] in kinds] + + +def _row(entry, *fields): + return {f: entry[f] for f in ("kind", *fields)} + + +def _initiative_status(project, slug): + text = (v7_layout(project.project, initiative=slug).initiative_folder / f"{slug}.md").read_text( + encoding="utf-8" + ) + return "status: done" in text + + +def test_run_on_a_parent_builds_each_child_in_turn(project, monkeypatch, capsys): + """`run` on a parent builds AUTH; AUTH drains, so it is marked done (and the + close committed) before the pick, which makes BILLING ready; BILLING launches as + a chained run on its own paths, drains, and the chain ends `all-done`. Each run + dir journals its side of each switch. + + Ablation (performed): skip `mark_initiative_done` and BILLING stays waiting on + AUTH (passed over `drained`), so the chain ends `waiting` after one run — this + FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["drain", "drain"]) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + out = capsys.readouterr().out + assert [(p.active_initiative, s.initiative, s.parent_initiative) for p, s, _ in built] == [ + (CHAIN_AUTH, CHAIN_AUTH, CHAIN_PARENT), + (CHAIN_BILLING, CHAIN_BILLING, CHAIN_PARENT), + ] + (_, _, first), (billing_paths, second_state, second) = built + assert billing_paths.initiative_folder == ( + v7_layout(project.project, initiative=CHAIN_BILLING).initiative_folder + ) + assert f"initiative chain {CHAIN_PARENT}: building {CHAIN_AUTH}" in out + assert f"initiative chain {CHAIN_PARENT}: {CHAIN_AUTH} -> {CHAIN_BILLING}" in out + assert f"initiative chain {CHAIN_PARENT}: ends (all-done)" in out + first_rows = _chain_rows(first) + assert [e["kind"] for e in first_rows] == [ + "initiative-mark", + "store-committed", + "initiative-chain-next", + ] + mark, committed, nxt = first_rows + assert (mark["slug"], mark["parent"], mark["outcome"]) == (CHAIN_AUTH, CHAIN_PARENT, "written") + assert committed["story_key"] == CHAIN_AUTH + assert _row(nxt, "parent", "from_child", "to_child", "passed_over", "passed_over_why") == { + "kind": "initiative-chain-next", + "parent": CHAIN_PARENT, + "from_child": CHAIN_AUTH, + "to_child": CHAIN_BILLING, + "passed_over": [], + "passed_over_why": [], + } + link, mark2, _committed2, end = _chain_rows(second) + assert _row(link, "parent", "previous_run", "previous_child") == { + "kind": "initiative-chain", + "parent": CHAIN_PARENT, + "previous_run": first.name, + "previous_child": CHAIN_AUTH, + } + assert (mark2["slug"], mark2["outcome"]) == (CHAIN_BILLING, "written") + assert _row(end, "parent", "end", "waiting") == { + "kind": "initiative-chain-end", + "parent": CHAIN_PARENT, + "end": "all-done", + "waiting": [], + } + assert second_state.run_id == second.name != first.name + assert _initiative_status(project, CHAIN_AUTH) and _initiative_status(project, CHAIN_BILLING) + # both closes are committed: the tree is clean for the next launch + assert verify.worktree_clean(project.project, project=project.project) + + +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 + no-child return and this FAILS.""" + built = _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") + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + assert f"initiative chain {CHAIN_PARENT}: ends (all-done)" in capsys.readouterr().out + assert built == [] and not (project.project / runs.RUNS_DIR).exists() + + +@pytest.mark.parametrize("selector", [["--epic", "epic-cart"], ["--story", "1.1"]]) +def test_run_on_a_parent_refuses_epic_and_story(project, monkeypatch, capsys, selector): + """`--epic` and `--story` select within one initiative: on a parent they exit 1 + before any run dir exists. Ablation (performed): drop the refusal and the chain + launches its first child, the selector ignored — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, []) + + assert cli.main(["run", "--project", str(project.project), *selector]) == 1 + + err = capsys.readouterr().err + assert f"initiative {CHAIN_PARENT!r} is a parent initiative" in err + assert "--epic and --story select within one initiative" in err + assert built == [] and not (project.project / runs.RUNS_DIR).exists() + + +def test_run_on_a_parent_ends_stalled_when_a_child_dispatches_nothing(project, monkeypatch, capsys): + """A child whose run finished landing no ticket while the pick still takes it + would only be built again to the same end: the chain ends `stalled`, with no + second run. Ablation (performed): drop the stall guard and AUTH is launched + again — an unscripted run — so this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["idle"]) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + ((_, _, run_dir),) = built + rows = _chain_rows(run_dir) + assert [_row(e, "end") for e in rows] == [{"kind": "initiative-chain-end", "end": "stalled"}] + assert f"initiative chain {CHAIN_PARENT}: ends (stalled)" in capsys.readouterr().out + assert not _initiative_status(project, CHAIN_AUTH) + + +def test_run_on_a_parent_stops_at_a_child_that_did_not_finish(project, monkeypatch, capsys): + """A paused child run stops the chain: nothing is marked, picked or launched, + and no chain row is journaled — resuming it is the operator's. Ablation + (performed): drop the finished-only condition and the chain goes on to pick + (and end `stalled`) — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["pause"]) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + ((_, state, run_dir),) = built + assert _chain_rows(run_dir) == [] + out = capsys.readouterr().out + assert f"stops at {CHAIN_AUTH} — run {state.run_id} did not finish" in out + + +@pytest.mark.parametrize("how", ["stop-lodged", "stop-discarded"]) +def test_run_on_a_parent_stops_on_a_stop_request(project, monkeypatch, capsys, how): + """A stop asked of a child run ends the chain at the switch, ahead of the close + and the pick: whether the request is still lodged or the engine discarded it + because the run had already drained. Ablation (performed): drop the stop check + and AUTH is closed and BILLING launched — an unscripted run — so this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, [how]) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + ((_, _, run_dir),) = built + assert [_row(e, "end") for e in _chain_rows(run_dir)] == [ + {"kind": "initiative-chain-end", "end": "stop-requested"} + ] + assert not _initiative_status(project, CHAIN_AUTH) + + +@pytest.mark.parametrize("paused", ["pause", "pause-stop-discarded"]) +def test_resume_of_a_paused_child_continues_the_chain(project, monkeypatch, capsys, paused): + """A child run that paused stops the chain; resumed to finished, it goes on as + after `run`: AUTH is closed, BILLING launches as a chained run of the parent, + and BILLING's run names the resumed run as the one before it. A stop request + the engine discarded as the run paused is not a stop of the resumed run. + Ablations (performed): drop the driver call from `_resume_paused_run` and + BILLING never launches; scan the whole journal for a discarded stop and the + chain ends `stop-requested` — each FAILS.""" + built = _chain_project(project, monkeypatch, capsys, [paused, "drain", "drain"]) + assert cli.main(["run", "--project", str(project.project)]) == 0 + ((_, _, first),) = built + # the run's pid is this test process's own: read it as gone so resume proceeds + monkeypatch.setattr(runs, "engine_liveness", lambda _run_dir: "dead") + capsys.readouterr() + + assert cli.main(["resume", "--project", str(project.project), first.name]) == 0 + + out = capsys.readouterr().out + assert [(s.initiative, s.parent_initiative) for _, s, _ in built] == [ + (CHAIN_AUTH, CHAIN_PARENT), + (CHAIN_AUTH, CHAIN_PARENT), + (CHAIN_BILLING, CHAIN_PARENT), + ] + assert built[1][2] == first + second = built[2][2] + assert second != first + assert f"initiative chain {CHAIN_PARENT}: {CHAIN_AUTH} -> {CHAIN_BILLING}" in out + assert f"initiative chain {CHAIN_PARENT}: ends (all-done)" in out + assert [e["kind"] for e in _chain_rows(first)] == [ + "initiative-mark", + "store-committed", + "initiative-chain-next", + ] + link = _chain_rows(second)[0] + assert _row(link, "parent", "previous_run", "previous_child") == { + "kind": "initiative-chain", + "parent": CHAIN_PARENT, + "previous_run": first.name, + "previous_child": CHAIN_AUTH, + } + assert _initiative_status(project, CHAIN_AUTH) and _initiative_status(project, CHAIN_BILLING) + + +@pytest.mark.parametrize( + ("cap", "outcomes", "budgets", "end"), + [ + (None, ["drain", "drain"], [None, None], "all-done"), + ("3", ["drain", "drain"], [3, 2], "all-done"), + ("1", ["drain"], [1], "max-stories"), + ], + ids=["no-cap", "budget-left", "budget-spent"], +) +def test_run_on_a_parent_holds_one_max_stories_across_the_chain( + project, monkeypatch, capsys, cap, outcomes, budgets, end +): + """`--max-stories` caps the whole chain: each child launches with what the runs + before it left (its cap less the units it dispatched — one per scripted + drain), and a spent budget ends the chain `max-stories` once the drained + child is closed; no cap stays no cap. Ablation (performed): launch every child + with the finished run's own cap and BILLING's budget reads 3 — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, outcomes) + flags = [] if cap is None else ["--max-stories", cap] + + assert cli.main(["run", "--project", str(project.project), *flags]) == 0 + + assert [s.max_stories for _, s, _ in built] == budgets + rows = _chain_rows(built[-1][2]) + assert [_row(e, "end") for e in rows if e["kind"] == "initiative-chain-end"] == [ + {"kind": "initiative-chain-end", "end": end} + ] + assert f"initiative chain {CHAIN_PARENT}: ends ({end})" in capsys.readouterr().out + assert _initiative_status(project, CHAIN_AUTH) + + +CHAIN_SEARCH, CHAIN_LEDGER = "initiative-search", "initiative-ledger" + + +def test_run_on_a_parent_ends_naming_what_each_waiting_child_waits_on(project, monkeypatch, capsys): + """BILLING waits on AUTH, on SEARCH (dropped) and on LEDGER (declared, no + folder). Once AUTH drains and is closed, LEDGER is passed over and BILLING + still waits: the chain ends `waiting` — a dropped prerequisite included — and + both the journal row and the printed line name each unmet prerequisite with its + status. Ablation (performed): drop the waiting detail from the journal row and + the printed line and this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["drain"]) + root = project.project + parent = v7_layout(root, initiative=CHAIN_PARENT).initiative_folder + (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" }, ' + '{ initiative = 3, needs = "search live" }, { initiative = 4, needs = "ledger" }]\n\n' + f'[[initiative]]\nid = 3\nslug = "{CHAIN_SEARCH}"\ntitle = "Search"\n\n' + f'[[initiative]]\nid = 4\nslug = "{CHAIN_LEDGER}"\ntitle = "Ledger"\n', + encoding="utf-8", + ) + search = v7_layout(root, initiative=CHAIN_SEARCH) + write_ticket_tree(search, DEFAULT_TREE) + file = search.initiative_folder / f"{CHAIN_SEARCH}.md" + file.write_text( + file.read_text(encoding="utf-8").replace( + "parent: none\n", "parent: none\nstatus: dropped\n" + ), + encoding="utf-8", + ) + git(root, "add", "-A") + git(root, "commit", "-qm", "billing waits on search and ledger") + + assert cli.main(["run", "--project", str(root)]) == 0 + + ((_, state, run_dir),) = built + assert state.initiative == CHAIN_AUTH + end = _chain_rows(run_dir)[-1] + fields = ("end", "waiting", "waiting_child", "waiting_on", "waiting_on_status") + assert _row(end, *fields, "passed_over", "passed_over_why") == { + "kind": "initiative-chain-end", + "end": "waiting", + "waiting": [CHAIN_BILLING], + "waiting_child": [CHAIN_BILLING, CHAIN_BILLING], + "waiting_on": [CHAIN_SEARCH, CHAIN_LEDGER], + "waiting_on_status": ["dropped", "no-folder"], + "passed_over": [CHAIN_LEDGER], + "passed_over_why": ["no-folder"], + } + assert ( + f"initiative chain {CHAIN_PARENT}: ends (waiting) — waiting: {CHAIN_BILLING} on " + f"{CHAIN_SEARCH} (dropped), {CHAIN_LEDGER} (no-folder) (passed over: " + f"{CHAIN_LEDGER} (no-folder))" + ) in capsys.readouterr().out + + +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 + AUTH before its first pick — committed, with no run dir to journal into — and + launches BILLING. Ablation (performed): drop the pre-pick close in `cmd_run` + and AUTH is passed over `drained`, BILLING still waits, and the run ends + `waiting` launching nothing — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["stop-lodged", "drain"]) + root = project.project + assert cli.main(["run", "--project", str(root)]) == 0 + assert not _initiative_status(project, CHAIN_AUTH) + capsys.readouterr() + + assert cli.main(["run", "--project", str(root)]) == 0 + + out = capsys.readouterr().out + assert [s.initiative for _, s, _ in built] == [CHAIN_AUTH, CHAIN_BILLING] + assert f"initiative chain {CHAIN_PARENT}: closed {CHAIN_AUTH} (drained)" in out + assert f"initiative chain {CHAIN_PARENT}: building {CHAIN_BILLING}" in out + 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 + rows = _chain_rows(built[1][2]) + assert [e["kind"] for e in rows] == [ + "initiative-mark", + "store-committed", + "initiative-chain-end", + ] + assert rows[0]["slug"] == CHAIN_BILLING + + +def test_run_closes_a_sibling_drained_outside_the_chain(project, monkeypatch, capsys): + """AUTH was drained by hand and never built by the chain: `run` on the parent + closes it before the pick instead of passing it over `drained`, so BILLING is + built. Ablation (performed): drop the pre-pick close in `cmd_run` and the run + ends `waiting` launching nothing — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["drain"]) + _drain_child(project.project, CHAIN_AUTH) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + assert [s.initiative for _, s, _ in built] == [CHAIN_BILLING] + assert _initiative_status(project, CHAIN_AUTH) + assert f"initiative chain {CHAIN_PARENT}: ends (all-done)" in capsys.readouterr().out + + +def test_run_builds_a_drained_child_whose_epics_are_still_open(project, monkeypatch, capsys): + """AUTH's tickets are all done but its epics are open: drained by its counts, + yet its first pick halts at the epic closure, which a run of AUTH does. The + pre-pick close leaves it open (`work-left`) and the chain builds it, then + closes it after its run. Ablation (performed): drop the fresh-pick check and + AUTH is closed unbuilt, only BILLING launching — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["drain", "drain"]) + _drain_child(project.project, CHAIN_AUTH, epics=False) + + assert cli.main(["run", "--project", str(project.project)]) == 0 + + out = capsys.readouterr().out + assert ( + f"initiative chain {CHAIN_PARENT}: leaves {CHAIN_AUTH} open (work-left: its first " + "pick still finds work)" + ) in out + assert [s.initiative for _, s, _ in built] == [CHAIN_AUTH, CHAIN_BILLING] + assert _initiative_status(project, CHAIN_AUTH) and _initiative_status(project, CHAIN_BILLING) + + +def _record_child_run(root, run_id, *, run_type="story", paused=False): + """A recorded, unfinished run of AUTH under the parent.""" + from bmad_loop.journal import save_state + from bmad_loop.model import RunState + + state = RunState( + run_id=run_id, + project=str(root), + started_at="2099-01-01T00:00:00", + run_type=run_type, + initiative=CHAIN_AUTH, + parent_initiative=CHAIN_PARENT, + ) + if paused: + state.record_pause("scripted pause", "ticket-tree", None) + save_state(root / runs.RUNS_DIR / run_id, state) + + +@pytest.mark.parametrize( + ("run_type", "paused", "live", "why", "status"), + [ + ("story", False, "alive", "run-live", "running"), + ("story", False, "unknown", "run-live", "unknown"), + ("sweep", False, "alive", "run-live", "running"), + ("story", True, "dead", "run-unfinished", "paused"), + ("story", False, "dead", "run-unfinished", "interrupted"), + ], + ids=["live", "maybe-live", "live-sweep", "paused", "interrupted"], +) +def test_run_leaves_a_drained_child_open_while_a_run_holds_it( + project, monkeypatch, capsys, run_type, paused, live, why, status +): + """A drained AUTH whose recorded run is live — or a build run of it that is + paused or interrupted, which a resume drives on — is not closed: the skip is + printed, AUTH is passed over `drained`, and BILLING still waits on it. + Ablation (performed): drop the held-run check and AUTH is closed and BILLING + launched — an unscripted run — so this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, []) + root = project.project + _drain_child(root, CHAIN_AUTH) + run_id = "20990101-000000-0001" + _record_child_run(root, run_id, run_type=run_type, paused=paused) + monkeypatch.setattr(runs, "liveness", lambda _run_dir: live) + + assert cli.main(["run", "--project", str(root)]) == 0 + + out = capsys.readouterr().out + assert built == [] and not _initiative_status(project, CHAIN_AUTH) + assert ( + f"initiative chain {CHAIN_PARENT}: leaves {CHAIN_AUTH} open ({why}: run {run_id} " + f"is {status})" + ) in out + assert f"initiative chain {CHAIN_PARENT}: ends (waiting)" in out + + +@pytest.mark.parametrize("stage", ["paused", "stopped"]) +def test_run_closes_a_drained_child_whose_only_unfinished_run_is_a_dead_sweep( + project, monkeypatch, capsys, stage +): + """A sweep of AUTH that is not live holds nothing: it works the ledger and its + resume does not go on with the chain, so AUTH is closed and BILLING built. + Ablation (performed): let every unfinished sweep hold its child and BILLING + never launches — this FAILS.""" + built = _chain_project(project, monkeypatch, capsys, ["drain"]) + root = project.project + _drain_child(root, CHAIN_AUTH) + _record_child_run(root, "20990101-000000-0001", run_type="sweep", paused=stage == "paused") + if stage == "stopped": + state_file = root / runs.RUNS_DIR / "20990101-000000-0001" / "state.json" + doc = json.loads(state_file.read_text(encoding="utf-8")) + state_file.write_text(json.dumps({**doc, "stopped": True}), encoding="utf-8") + monkeypatch.setattr(runs, "liveness", lambda _run_dir: "dead") + + assert cli.main(["run", "--project", str(root)]) == 0 + + assert [s.initiative for _, s, _ in built] == [CHAIN_BILLING] + assert _initiative_status(project, CHAIN_AUTH) + + +def test_pre_pick_close_skips_a_drained_child_whose_store_is_refused(project, monkeypatch, capsys): + """A drained child whose store tickets runs now refuse is left open with the + refusal journaled (`store-refused`) rather than written into a store the chain + could not launch or commit in. Ablation (performed): drop the store judgement + and AUTH is marked — this FAILS.""" + from bmad_loop import ticketstore + + _chain_project(project, monkeypatch, capsys, []) + root = project.project + _drain_child(root, CHAIN_AUTH) + paths = bmadconfig.load_paths(root, want_tickets=True) + pol = cli.policy_mod.load(cli._policy_path(root)) + refusal = ticketstore.StoreRefusal("partial-tracking", root, "x.md") + monkeypatch.setattr(ticketstore, "assess_store", lambda *_a, **_k: refusal) + log = cli._ChainStartLog() + + assert cli._close_drained_children(root, paths, pol, CHAIN_PARENT, log) is None + + (row,) = log.rows + assert _row(row, "parent", "slug", "why", "blocking_run") == { + "kind": "initiative-close-skipped", + "parent": CHAIN_PARENT, + "slug": CHAIN_AUTH, + "why": "store-refused", + "blocking_run": "", + } + assert row["detail"] == ticketstore.store_topology_conflict(refusal) + assert not _initiative_status(project, CHAIN_AUTH) + + +def test_pre_pick_close_skips_a_drained_child_whose_file_holds_an_edit( + project, monkeypatch, capsys +): + """A drained child whose file holds an uncommitted edit is left open + (`uncommitted-edit`) with nothing marked or committed: the close commits the + whole file, so it would carry the edit into its bookkeeping commit. + Ablation (performed): drop the edit check and AUTH is marked and committed + with the edit inside — this FAILS.""" + _chain_project(project, monkeypatch, capsys, []) + root = project.project + _drain_child(root, CHAIN_AUTH) + file = v7_layout(root, initiative=CHAIN_AUTH).initiative_folder / f"{CHAIN_AUTH}.md" + with file.open("a", encoding="utf-8") as f: + f.write("\nhalf-written note\n") + head = git(root, "rev-parse", "HEAD") + paths = bmadconfig.load_paths(root, want_tickets=True) + pol = cli.policy_mod.load(cli._policy_path(root)) + log = cli._ChainStartLog() + + assert cli._close_drained_children(root, paths, pol, CHAIN_PARENT, log) is None + + (row,) = log.rows + assert _row(row, "parent", "slug", "why", "blocking_run") == { + "kind": "initiative-close-skipped", + "parent": CHAIN_PARENT, + "slug": CHAIN_AUTH, + "why": "uncommitted-edit", + "blocking_run": "", + } + assert f"initiative chain {CHAIN_PARENT}: leaves {CHAIN_AUTH} open (uncommitted-edit: " in ( + capsys.readouterr().out + ) + assert git(root, "rev-parse", "HEAD") == head + assert not _initiative_status(project, CHAIN_AUTH) + assert "half-written note" in file.read_text(encoding="utf-8") + + +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. + Ablation (performed): drop the sweep condition and the driver goes on to its + stop check — this FAILS.""" + from bmad_loop.model import RunState + + state = RunState( + run_id="20990101-000000-0001", + project=str(project.project), + started_at="2099-01-01T00:00:00", + finished=True, + run_type="sweep", + initiative=CHAIN_AUTH, + parent_initiative=CHAIN_PARENT, + ) + + def refuse(*_a, **_k): + raise AssertionError("the chain went on after a sweep") + + monkeypatch.setattr(cli, "_chain_stop_requested", refuse) + finished = types.SimpleNamespace(state=state) + + assert cli._drive_initiative_chain(project.project, None, None, finished) == 0 + + +def test_initiative_chain_drained_reads_an_open_child_with_every_ticket_closed(): + """`drained` is the close's own condition: an open child (folder, not done or + dropped) with at least one ticket and all of them done or dropped.""" + from bmad_loop import initiativechain + from bmad_loop.ticketmodel import ChildInitiative + + def child(status="", folder="c", **counts): + return ChildInitiative( + id="1", + slug="c", + title="c", + after=(), + folder=folder, + status=status, + counts=counts, + waiting_on=(), + ready=True, + ) + + assert initiativechain.drained(child(total=3, done=2, dropped=1)) + assert initiativechain.drained(child("in-progress", total=1, done=1)) + assert not initiativechain.drained(child(total=3, done=2)) + assert not initiativechain.drained(child(total=0)) + assert not initiativechain.drained(child("done", total=1, done=1)) + assert not initiativechain.drained(child("dropped", total=1, done=1)) + assert not initiativechain.drained(child(folder=None, total=1, done=1)) + + def test_epic_selector_keeps_ints_and_passes_text(): """`--epic`'s argparse type returns what `int()` reads (sprint mode unchanged, signs and padding included) and the text otherwise.""" @@ -21577,6 +22496,83 @@ def test_auto_sweep_launches_under_worktree_isolation_beside_a_nested_repo_root( assert len(launched) == 1 +@pytest.mark.parametrize("parent", [None, "initiative-platform"]) +def test_auto_sweep_hands_the_runs_parent_initiative_to_compose_sweep(project, monkeypatch, parent): + """The child-sweep factory hands the launching run's parent initiative (a + chained child's run-end sweep) through `_start_sweep` to `compose_sweep`, which + records it as the sweep's pin; None for every other run. Ablation (performed): + drop `parent_initiative=` from the factory's `_start_sweep` call and the + chained row FAILS.""" + from bmad_loop import bmadconfig + from bmad_loop.adapters.profile import get_profile + + class Composed(Exception): + pass + + composed: list = [] + + def compose_sweep(**kw): + composed.append(kw) + raise Composed + + install_bmad_config(project) + _write_policy(project.project, PIN_POLICY) + _pin_profile(project) + install_sweep_skill(project.project, get_profile("mycli", project.project).skill_tree) + write_sprint(project, {"1-1-a": "ready-for-dev"}) + monkeypatch.setattr(runsetup, "compose_sweep", compose_sweep) + paths = bmadconfig.load_paths(project.project) + factory = cli._sweep_factory( + project.project, + paths, + _config_pin(project), + plugin_grants=GrantSet.empty(), + parent_initiative=parent, + ) + with pytest.raises(Composed): + factory("epic-boundary", started=_never_started) + (kw,) = composed + assert kw["parent_initiative"] == parent + assert kw["paths"] is paths + + +@pytest.mark.parametrize("parent", [None, "initiative-platform"]) +def test_auto_sweep_judges_a_chained_childs_own_store(project, monkeypatch, parent): + """A chained child's run-end sweep factory judges the CHILD's store + (`initiative=` the re-rooted paths' child) before the child sweep launches; an + unchained run's call carries no `initiative`. Ablation (performed): drop the + `initiative` from the factory's store check and the chained row FAILS.""" + from bmad_loop import bmadconfig, ticketstore + from bmad_loop.adapters.profile import get_profile + + install_bmad_config(project) + _write_policy(project.project, PIN_POLICY) + _pin_profile(project) + install_sweep_skill(project.project, get_profile("mycli", project.project).skill_tree) + seen: list[dict] = [] + + def conflict(_project, _source, _isolation, **kw): + seen.append(kw) + return "the child's store is refused" + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + paths = bmadconfig.load_paths(project.project) + if parent is not None: # re-rooted on the child, as `runs.paths_for_state` does + paths = dataclasses.replace(paths, active_initiative="initiative-child") + factory = cli._sweep_factory( + project.project, + paths, + _config_pin(project), + plugin_grants=GrantSet.empty(), + parent_initiative=parent, + ) + with pytest.raises(RuntimeError, match="the child's store is refused"): + factory("epic-boundary", started=_never_started) + (kw,) = seen + assert ("initiative" in kw) == (parent is not None) + assert kw.get("initiative") == (None if parent is None else "initiative-child") + + # --- #461 point 4: the auto-sweep child's config re-read is integrity-pinned --- # A project whose whole host-exec surface is expressible: the verify command the diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 0826fe4a4..fe51bb8fd 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1206,6 +1206,75 @@ def test_initiative_mark_aliases_slugs_and_drops_the_file_path(): assert "acme" not in json.dumps(scrubbed) +def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): + """The chain's kinds name the parent and child initiatives as scalars and + as lists: every slug aliases in the one `initiative` namespace (so the parent + and `initiative-mark`'s `parent` read alike), the pass-over and end tokens and + the prerequisite statuses and the previous run id stay readable, and `error` + is dropped. + + Ablation: drop the kinds' alias rows and the scalars ship verbatim; drop their + keylist rows and the slug lists do.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + parent, auth, billing = ( + "initiative-acme-platform", + "initiative-acme-auth", + "initiative-acme-billing", + ) + rows = [ + { + "kind": "initiative-chain-next", + "parent": parent, + "from_child": auth, + "to_child": billing, + "passed_over": [auth], + "passed_over_why": ["drained"], + }, + { + "kind": "initiative-chain-end", + "parent": parent, + "end": "fault", + "waiting": [billing], + "waiting_child": [billing], + "waiting_on": [auth], + "waiting_on_status": ["dropped"], + "passed_over": [auth], + "passed_over_why": ["no-folder"], + "error": f"tickets.py fault: /home/acme/{auth}", + }, + { + "kind": "initiative-chain", + "parent": parent, + "previous_run": "20261028-000000-beef", + "previous_child": auth, + }, + {"kind": "initiative-mark", "parent": parent, "slug": auth, "outcome": "written"}, + { + "kind": "initiative-close-skipped", + "parent": parent, + "slug": auth, + "why": "run-unfinished", + "blocking_run": "20261028-000000-cafe", + "detail": f"run in /home/acme/{auth} is paused", + }, + ] + + nxt, end, link, mark, skipped = (diagnostics._scrub_entry(r, pseudo, {}, None) for r in rows) + + assert "acme" not in json.dumps([nxt, end, link, mark, skipped]) + assert (skipped["parent"], skipped["slug"]) == (mark["parent"], mark["slug"]) + assert (skipped["why"], skipped["blocking_run"]) == ("run-unfinished", "20261028-000000-cafe") + assert "detail" not in skipped + assert nxt["parent"] == end["parent"] == link["parent"] == mark["parent"] + assert nxt["from_child"] == nxt["passed_over"][0] == link["previous_child"] == mark["slug"] + assert nxt["to_child"] == end["waiting"][0] != nxt["from_child"] + assert (nxt["passed_over_why"], end["passed_over_why"]) == (["drained"], ["no-folder"]) + assert end["waiting_child"] == end["waiting"] and end["waiting_on"] == [nxt["from_child"]] + assert end["waiting_on_status"] == ["dropped"] + assert (end["end"], link["previous_run"]) == ("fault", "20261028-000000-beef") + assert "error" not in end and end["error_present"] is True + + @pytest.mark.parametrize( "kind", [ diff --git a/tests/test_initiativechain.py b/tests/test_initiativechain.py index fdd6b1c3b..e5140f3cc 100644 --- a/tests/test_initiativechain.py +++ b/tests/test_initiativechain.py @@ -3,7 +3,7 @@ from __future__ import annotations -from bmad_loop.initiativechain import NO_FOLDER, PassedOver, next_child +from bmad_loop.initiativechain import NO_FOLDER, PassedOver, WaitingOn, next_child from bmad_loop.ticketmodel import ChildInitiative, InitiativeEdge, ParentView @@ -132,3 +132,30 @@ def take(child: ChildInitiative) -> str | None: pick = next_child(_view(reports, billing), take) assert asked == ["billing"] and pick.child is billing + + +def test_waiting_children_name_each_prerequisite_with_its_status(): + """One row per prerequisite a waiting child waits on, in build order, whether + or not a child is taken: an initiative status, `open` for a folder with no status, `no-folder` for one + not created yet, and `?` for a slug the parent does not list.""" + auth = _child("auth", ready=True, status="") + search = _child("search", status="dropped") + ledger = _child("ledger", ready=True, folder=None) + billing = _child( + "billing", + after=("auth", "search", "ledger"), + waiting_on=("auth", "search", "ledger"), + ) + reports = _child("reports", status="in-progress", waiting_on=("billing", "gone")) + + pick = next_child(_view(auth, search, ledger, billing, reports)) + + assert pick.child is auth + assert pick.waiting == ("billing", "reports") + assert pick.waiting_on == ( + WaitingOn("billing", "auth", "open"), + WaitingOn("billing", "search", "dropped"), + WaitingOn("billing", "ledger", NO_FOLDER), + WaitingOn("reports", "billing", "in-progress"), + WaitingOn("reports", "gone", "?"), + ) diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 88f70e424..73f89508c 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -396,6 +396,8 @@ ("install.py", "_tickets_store_findings"): 1, # validate + run preflight findings ("cli.py", "_reject_store_topology"): 1, # the wrapper's own predicate call ("cli.py", "cmd_run"): 1, + ("cli.py", "_launch_run"): 1, # a chained child's own store, per child launch + ("cli.py", "_child_store"): 1, # a drained child's store, ahead of its close ("cli.py", "cmd_sweep"): 1, # pre-session + post-confirm, as the #414 pair, + ahead of --reverify/--adopt-branch ("cli.py", "cmd_resolve"): 3, @@ -601,6 +603,16 @@ # `diagnostics._JOURNAL_KIND_ALIAS_FIELDS`; `file` collapses to presence under # `diagnostics._JOURNAL_KIND_SCHEMAS`. "initiative-mark": frozenset({"before", "after", "outcome"}), + # cli.py, the initiative chain: closed pass-over tokens (`no-folder`, + # `drained`), a closed chain-end token, closed prerequisite-status tokens, and + # the previous run's id. The slugs are aliased or keylisted by `diagnostics`' + # kind-scoped tables; `error` is dropped by name. + "initiative-chain-next": frozenset({"passed_over_why"}), + "initiative-chain-end": frozenset({"end", "passed_over_why", "waiting_on_status"}), + "initiative-chain": frozenset({"previous_run"}), + # cli.py, the chain's pre-pick close: a closed skip token and the blocking run's + # id. `parent`/`slug` are kind-aliased; `detail` is dropped by name. + "initiative-close-skipped": frozenset({"why", "blocking_run"}), # runs.py: whether the re-arm moved its restore latch onto the evacuated copy. # `patch` and `stashed_to` are dropped by name. "restore-patch-evacuated": frozenset({"latched"}), @@ -2252,6 +2264,13 @@ "ticket-mark", "ticket-plan-restored", "initiative-mark", + # cli.py — the initiative chain: per switch, the next child or the end in + # the finished run, the chain link in the next run, and each drained child + # the pre-pick close leaves open. + "initiative-chain-next", + "initiative-chain-end", + "initiative-chain", + "initiative-close-skipped", # worktree_flow.py "accepted-spec-delivery-unreachable", "accepted-spec-write-unreachable", diff --git a/tests/test_runsetup.py b/tests/test_runsetup.py index bd3a11a63..6e43105ef 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -2487,3 +2487,228 @@ def test_sweep_initiative_conflict_ignores_story_runs_and_v6(): runsetup.sweep_initiative_conflict(pinned, v6) or "" ) assert "now unset" in (runsetup.sweep_initiative_conflict(pinned, v6) or "") + + +# ------------------------- v7: a chained child's run-end sweep (pinned to the parent) + +CHILD = "initiative-child" + + +def _chained_child_paths(project_v7): + """A v7 parent initiative with a committed child folder beside it, and the + paths a chained child run holds: the loaded (parent's) paths re-rooted on the + child, as `runs.paths_for_state` re-roots them.""" + from conftest import git + + root = project_v7.project + child_folder = project_v7.initiative_folder.parent / CHILD + shutil.copytree(project_v7.initiative_folder, child_folder) + git(root, "add", "-A") + git(root, "commit", "-q", "-m", "a child initiative") + return bmadconfig.load_paths(root).with_initiative(CHILD), child_folder + + +def _compose_chained_sweep(project_v7): + child_paths, child_folder = _chained_child_paths(project_v7) + composed = runsetup.compose_sweep( + project=project_v7.project, + paths=child_paths, + policy=policy_mod.loads(""), + run_id=RUN_ID, + prompting=False, + decisions_only=False, + max_bundles=None, + repeat=None, + max_cycles=None, + trigger="epic-boundary", + make_adapters=_accepting_adapters, + sweep_engine_cls=_CapturingEngine, + trusted_config_digest="deadbeef", + parent_initiative=project_v7.initiative, + ) + return composed, child_folder + + +def test_compose_sweep_pins_a_chained_childs_parent_and_works_the_childs_ledger(project_v7): + """A chained child's run-end sweep records the parent it is pinned to, keeps + the child as its initiative and folder, and its engine works the CHILD's + ledger, never the parent's. + + ABLATION (performed): drop `parent_initiative=` from compose_sweep's RunState + and this FAILS.""" + composed, child_folder = _compose_chained_sweep(project_v7) + state = load_state(composed.run_dir) + assert state.parent_initiative == project_v7.initiative + assert state.initiative == CHILD + assert state.initiative_folder == str(child_folder) + assert composed.state.parent_initiative == project_v7.initiative + ledger = composed.engine.kwargs["paths"].deferred_work + assert ledger == child_folder / "deferred-work.md" + assert ledger != project_v7.initiative_folder / "deferred-work.md" + + +def test_compose_sweep_records_no_parent_off_v7(tmp_path): + """Off v7 there is no initiative to chain through: the parent is never recorded.""" + composed = runsetup.compose_sweep( + project=tmp_path, + paths=_fake_paths(tmp_path), + policy=policy_mod.loads(""), + run_id=RUN_ID, + prompting=False, + decisions_only=False, + max_bundles=None, + repeat=None, + max_cycles=None, + trigger="cli", + make_adapters=_accepting_adapters, + sweep_engine_cls=_CapturingEngine, + trusted_config_digest="deadbeef", + parent_initiative="initiative-parent", + ) + assert load_state(composed.run_dir).parent_initiative is None + + +def _compose_chained_run(project_v7, paths, parent): + return runsetup.compose_run( + project=project_v7.project, + paths=paths, + policy=policy_mod.loads(""), + run_id=RUN_ID, + epic_filter=None, + story_filter=None, + max_stories=None, + source=worksource.SOURCE_TICKETS, + spec_folder="", + sweep_factory=lambda _trigger, *, started: None, + make_adapters=_accepting_adapters, + engine_cls=_CapturingEngine, + stories_engine_cls=_CapturingEngine, + tickets_engine_cls=_CapturingEngine, + trusted_config_digest="deadbeef", + parent_initiative=parent, + ) + + +def test_compose_run_records_a_chained_childs_parent_on_the_childs_paths(project_v7): + """A chained child run records its parent and pins the CHILD as its initiative + and folder, the shape `runs.paths_for_state` re-roots on at resume. + + 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) + composed = _compose_chained_run(project_v7, child_paths, project_v7.initiative) + state = load_state(composed.run_dir) + assert state.parent_initiative == project_v7.initiative + assert state.initiative == CHILD + assert state.initiative_folder == str(child_folder) + assert composed.engine.kwargs["state"].parent_initiative == project_v7.initiative + assert composed.engine.kwargs["paths"] is child_paths + + +@pytest.mark.parametrize("rooted", ["parent", "off-v7"]) +def test_compose_run_refuses_a_parent_without_child_rooted_paths(project_v7, rooted): + """A parent handed with paths that are not rooted on one of its children — the + parent's own, or a project off v7 — raises before a run dir is published.""" + if rooted == "parent": + paths = bmadconfig.load_paths(project_v7.project) + else: + paths = _fake_paths(project_v7.project) + with pytest.raises(ValueError, match="needs paths rooted on one of its child initiatives"): + _compose_chained_run(project_v7, paths, project_v7.initiative) + 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 + parent: the resume judges the loaded paths (`_prepare_resume_locked`), then + `compose_resume` the re-rooted ones. A config naming another initiative, the + child itself, or none is refused with the parent to restore, before any + session is killed. + + ABLATION (performed): make the conflict compare only `state.initiative` + against the live value and the parent row is refused while the child row + composes.""" + from conftest import V7_USER_CONFIG_REL + + project = project_v7.project + parent = project_v7.initiative + composed, _child_folder = _compose_chained_sweep(project_v7) + state = load_state(composed.run_dir) + (project_v7.output_folder / "other").mkdir() + value = {"parent": parent, "other": "other", "child": CHILD, "unset": None}[live] + (project / V7_USER_CONFIG_REL).write_text( + "[core]\n" + (f'active_initiative = "{value}"\n' if value else ""), encoding="utf-8" + ) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", killed.append) + + loaded = bmadconfig.load_paths(project, want_tickets=runs.resolves_as_tickets(state)) + conflict = runsetup.sweep_initiative_conflict(state, loaded) + if live != "parent": + assert conflict is not None + assert f"child initiative {CHILD!r} of parent initiative {parent!r}" in conflict + assert f"set `[core] active_initiative` back to {parent!r}" in conflict + assert "start a new sweep" in conflict + assert killed == [] + return + assert conflict is None + run_paths = runs.paths_for_state(loaded, state) + + def resume(paths): + return runsetup.compose_resume( + project=project, + paths=paths, + run_dir=composed.run_dir, + state=state, + policy=policy_mod.loads(""), + journal=Journal(composed.run_dir), + sweep_factory=lambda _trigger, *, started: None, + make_adapters=_accepting_adapters, + engine_cls=_CapturingEngine, + stories_engine_cls=_CapturingEngine, + sweep_engine_cls=_CapturingEngine, + ) + + # the backstop refuses the loaded (parent-rooted) paths handed in un-re-rooted + with pytest.raises(runsetup.SweepPinError, match=f"rooted on initiative {parent!r}"): + resume(loaded) + assert killed == [] + engine = resume(run_paths).engine + assert engine.kwargs["paths"].deferred_work == run_paths.initiative_folder / "deferred-work.md" + assert run_paths.initiative_folder.name == CHILD + + +def test_sweep_initiative_conflict_judges_a_chained_sweep_by_which_paths_it_gets(project_v7): + """Loaded paths pass only while they name the parent; re-rooted paths only + while they are rooted on the child. Loaded paths of a config naming the child + look re-rooted, so the kind is the caller's to name, never inferred. An + unchained sweep is judged the same either way.""" + loaded = bmadconfig.load_paths(project_v7.project) + parent = project_v7.initiative + rerooted = loaded.with_initiative(CHILD) + sweep = RunState( + run_id=RUN_ID, + project=str(project_v7.project), + started_at="now", + run_type="sweep", + initiative=CHILD, + parent_initiative=parent, + ) + judge = runsetup.sweep_initiative_conflict + assert judge(sweep, loaded) is None + assert judge(sweep, rerooted, rerooted=True) is None + # the re-rooted (child's) paths judged as loaded: the live value is not the parent + assert "the active initiative is now 'initiative-child'" in (judge(sweep, rerooted) or "") + # the loaded (parent's) paths judged as re-rooted: not rooted on the child + assert f"rooted on initiative {parent!r}" in (judge(sweep, loaded, rerooted=True) or "") + misfiled = dataclasses.replace(rerooted, initiative_folder=loaded.initiative_folder) + assert judge(sweep, misfiled, rerooted=True) is not None + v6 = _fake_paths(project_v7.project) + for kind in (False, True): + assert f"back to {parent!r}" in (judge(sweep, v6, rerooted=kind) or "") + unchained = dataclasses.replace(sweep, initiative=parent, parent_initiative=None) + for kind in (False, True): + assert judge(unchained, loaded, rerooted=kind) is None + assert judge(unchained, rerooted, rerooted=kind) == judge(unchained, rerooted) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 34a8bbfa7..c3ab10d02 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -6087,6 +6087,49 @@ def conflict(_project, source, _isolation, *, recorded=None): assert calls == [] +@pytest.mark.parametrize("chained", [True, False], ids=["chained", "unchained"]) +async def test_replan_judges_a_chained_childs_own_store(project_tree, monkeypatch, chained): + """The replan's ticket-store refusal judges a chained run's CHILD store + (`initiative=`), as the detached resume does; an unchained run's call + carries no `initiative`. Ablation (performed): drop the `initiative` in + `_ticket_replan_worker`'s store check and the chained row FAILS.""" + from bmad_loop import ticketstore + from bmad_loop.tui import app as app_mod + + calls: list[str] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + run_dir, plan = _tickets_paused_run( + project_tree.project, stage="plan-checkpoint", plan_status="ready-for-dev" + ) + if chained: + state = load_state(run_dir) + state.initiative, state.parent_initiative = "initiative-child", "initiative-platform" + save_state(run_dir, state) + seen: list[dict] = [] + + def conflict(_project, _source, _isolation, **kw): + seen.append(kw) + return "the ticket store changed since this run started" + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + tree = _replan_tree(plan) + monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin, folder=None: tree) + app = BmadLoopApp(project_tree.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await click(pilot, await ready(pilot, "#act-replan")) + await until( + pilot, + lambda: any("ticket store changed" in m for m in notifications(app)), + ) + (kw,) = seen + assert ("initiative" in kw) == chained + assert kw.get("initiative") == ("initiative-child" if chained else None) + assert (tree.marks, calls) == ([], []) + + async def test_replan_in_tickets_mode_refuses_a_worktree_isolation_conflict( project_tree, monkeypatch ): @@ -9663,6 +9706,46 @@ def conflict(_project, store_source, _isolation, *, recorded=None): assert "the ticket store is not supported" in notes +@pytest.mark.parametrize("chained", [True, False], ids=["chained", "unchained"]) +def test_escalation_rearm_judges_a_chained_childs_own_store(project_v7, monkeypatch, chained): + """The re-arm's ticket-store refusal judges a chained child's sweep on the + CHILD's store (`initiative=`), as the resume it folds in does; an + unchained sweep's call carries no `initiative`. Ablation (performed): drop the + `initiative` in `_do_rearm`'s store check and the chained row FAILS.""" + from conftest import escalated_run + + from bmad_loop import runs, ticketstore + from bmad_loop.journal import load_state + + run_dir = escalated_run(project_v7.project, "r1", story_key="1", run_type="sweep").run_dir + state = load_state(run_dir) + if chained: # a chained child's sweep, as `compose_sweep` pins it + state.initiative, state.parent_initiative = "initiative-child", project_v7.initiative + else: + state.initiative = project_v7.initiative + save_state(run_dir, state) + seen: list[dict] = [] + + def conflict(_project, _source, _isolation, **kw): + seen.append(kw) + return "the ticket store is not supported" + + monkeypatch.setattr(ticketstore, "store_conflict", conflict) + monkeypatch.setattr(BmadLoopApp, "_resolve_blocked_by_liveness", lambda *_a: False) + monkeypatch.setattr(runs, "rearm_escalation", lambda *_a, **_k: pytest.fail("re-armed")) + notes: list[str] = [] + app = BmadLoopApp(project_v7.project) + monkeypatch.setattr(app, "notify", lambda msg, **_k: notes.append(str(msg))) + monkeypatch.setattr(app, "_do_resume", lambda _run_id: None) + + app._do_rearm(run_dir.name, run_dir, "1") + + assert "the ticket store is not supported" in notes + (kw,) = seen + assert ("initiative" in kw) == chained + assert kw.get("initiative") == ("initiative-child" if chained else None) + + def test_escalation_rearm_refuses_a_sweep_on_a_switched_initiative(project_v7, monkeypatch): """`cli.cmd_resolve`'s re-arm parity for the sweep's initiative pin: a v7 sweep started with no initiative active, re-armed while one is, is toasted with the @@ -9702,7 +9785,7 @@ def test_escalation_rearm_rejudges_a_sweeps_initiative_under_the_lock(project_v7 run_dir = escalated_run(project_v7.project, "r1", story_key="1", run_type="sweep").run_dir judged: list = [] - def judge(state, paths): + def judge(state, paths, **_kw): judged.append(state) return "initiative switched" if len(judged) > 1 else None diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index ba07cad64..15defc90f 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -24,7 +24,7 @@ from bmad_loop import envvars, runs from bmad_loop.adapters import tmux_base from bmad_loop.adapters.multiplexer import PARKED_BANNER, MultiplexerError, get_multiplexer -from bmad_loop.model import PauseSnapshot +from bmad_loop.model import PauseSnapshot, RunState from bmad_loop.tui import launch # Every test here asserts tmux-specific argv/behaviour through the multiplexer @@ -1340,7 +1340,10 @@ def mint(argv, **kwargs): def prepare(_project, _run_dir, *, accept_baseline=False): engine["live"] = True # the pid this child publishes under the lock - return (*(None,) * 8, PauseSnapshot(None, None, None, None, None, False, False, None)) + # the resumed state: the sweep factory reads its parent initiative + state = RunState(run_id="RID", project=str(tmp_path), started_at="now") + pause = PauseSnapshot(*(None,) * 5, False, False, None) + return (None, state, *(None,) * 6, pause, None) def compose(**_kwargs): raise Drove