From 7fb2e1a05ee2446d6baee464166b0137df7cdd95 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 19:14:57 -0700 Subject: [PATCH 1/7] feat(tickets): pin a chained child's run-end sweep to the parent and work the child's ledger (DW-574) What changed: - runsetup.compose_sweep takes parent_initiative (keyword, default None) and records it on the sweep's RunState (v7 only; None off v7). initiative and initiative_folder stay what the paths say: the child's, since the caller hands in child-rooted paths, so the engine works the child's deferred-work.md. - cli._sweep_factory and cli._start_sweep take parent_initiative (keyword, default None) and pass it down. The resume site passes state.parent_initiative (its paths are already re-rooted by runs.paths_for_state). cmd_run passes None, with a comment that the S3 chain driver supplies it there. cmd_sweep passes None. Step-3 decision: runsetup.sweep_initiative_conflict takes a keyword rerooted=False that names which paths the caller passes. The kind is never inferred from the values, because a live config naming the child loads paths that look re-rooted. For a sweep with parent_initiative set: - loaded paths (default) pass only while the live active_initiative equals parent_initiative; - re-rooted paths (rerooted=True) pass only when active_initiative and the initiative folder's name are state.initiative (the child). The refusal names the child and the parent to restore. Unchained sweeps judge exactly as before, with byte-identical text, whatever the keyword. Caller classification: - loaded: cli._prepare_resume_locked; the cmd_resolve entry gate; the TUI locked re-arm re-check when the lock-free state read failed. - re-rooted: runsetup.compose_resume; cmd_resolve's post-confirm re-check; runs.held_sweep_closes; the TUI lock-free re-arm check and its locked re-check after a readable state. Beyond the subject: - _prepare_resume_locked now calls runs.paths_for_state AFTER the sweep pin check, still ahead of every side effect. Before, a chained sweep whose parent moved raised paths_for_state's BmadConfigError and never reached the sweep refusal. - The cmd_resolve entry gate for sweeps judges bmadconfig.load_paths output instead of paths_for_state output. Unchained sweeps are unaffected (the two are identical for them). A chained sweep with a moved parent used to hit paths_for_state's config error, which skipped the gate, so reverify, adopt or the session ran before the resume refused. - docs/reference/run-state.md describes the chained sweep's pin. - Widened tests: stubs of sweep_initiative_conflict in test_cli.py (3) and test_tui_app.py (1) accept **_kw. The _prepare_resume_locked stub in test_tui_launch.py's refused-resume test returns a RunState instead of None, because the resume site now reads state.parent_initiative. test_chained_tickets_resume_builds_its_engine_on_the_childs_paths also asserts that the resume hands the child paths and the parent to _sweep_factory. Step-4 reader audit (parent_initiative in src/), on a SWEEP state carrying it: - runs.paths_for_state: re-roots a sweep on its child. This is intended (the sweep works the child's ledger), and every reopen site (held_sweep_closes, sweep ledger/reverify loaders, resolve, TUI re-arm) therefore gets child paths. - runs.tree_pin: called only by the TicketsEngine constructor and its tree reads, never for a sweep. - runs.recorded_tree_pin: callers are the status tickets footer (dispatched by source; a sweep's source is the sprint-status default), rearm_escalation (gated on source == tickets and a ticket binding) and the TUI replan (plan-checkpoint pauses only). None reaches a sweep. - runs.resolves_as_tickets: a sweep branches on initiative/store_topology, not on parent_initiative. A chained sweep pins the child, so it reloads as v7. Correct. - TicketsEngine constructor: never built for a sweep. - documents._tickets_projection: gated on source == tickets, so a sweep projects no tickets object. - worktree_flow mount provisioning: redirects a mount's active_initiative to state.initiative when parent_initiative is set. For a chained sweep's bundle that is the child, whose ledger the sweep works. Correct. - TUI: no direct reader; the paths_for_state and sweep-conflict callers are covered above. Nothing in the audit needed a fix. Not changed: the sweep factory's and the resume's store-topology gates classify the loaded (parent's) store without store_conflict(initiative=), as they do for a chained tickets run. Ablations performed (each test FAILS, then reverted): drop the parent from compose_sweep's RunState; make the conflict compare only state.initiative; re-root before the resume's sweep pin check; drop parent_initiative= from the factory's _start_sweep call and from _start_sweep's compose_sweep call; pass None at the resume site; judge re-rooted paths at the resolve entry gate. --- docs/reference/run-state.md | 2 +- src/bmad_loop/cli.py | 57 +++++++++--- src/bmad_loop/runs.py | 3 +- src/bmad_loop/runsetup.py | 65 +++++++++++++- src/bmad_loop/tui/app.py | 11 ++- tests/test_cli.py | 126 +++++++++++++++++++++++++- tests/test_runsetup.py | 174 ++++++++++++++++++++++++++++++++++++ tests/test_tui_app.py | 2 +- tests/test_tui_launch.py | 6 +- 9 files changed, 420 insertions(+), 26 deletions(-) diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md index eb8016f97..1a2d977dc 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -93,7 +93,7 @@ A tickets run's `state.json` also records `initiative` and `initiative_folder`, `parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. -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`: diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ea642e48d..19e18029b 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4276,7 +4276,10 @@ 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), + # no run starts chained yet: the S3 chain driver supplies a child's parent here + sweep_factory=_sweep_factory( + project, paths, trusted_digest, plugin_grants=plugin_grants, parent_initiative=None + ), make_adapters=_make_adapters, engine_cls=Engine, stories_engine_cls=StoriesEngine, @@ -4843,6 +4846,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 +4866,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 +4888,7 @@ def _start_sweep( profiles=profiles, on_started=on_started, plugin_grants=plugin_grants, + parent_initiative=parent_initiative, ) _warn_bypass_dropped( pol, @@ -4902,6 +4910,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 +4935,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)) @@ -5009,6 +5023,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 +5116,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 +5437,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 +5453,19 @@ 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. 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 @@ -6011,7 +6031,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, @@ -6502,9 +6530,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 ( @@ -6888,7 +6918,10 @@ def cmd_resolve(args: argparse.Namespace) -> int: 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 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..aa5ce1c99 100644 --- a/src/bmad_loop/runsetup.py +++ b/src/bmad_loop/runsetup.py @@ -985,7 +985,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 +999,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 +1038,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] @@ -1485,6 +1533,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 +1552,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 +1659,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 +1756,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..44fd9d4b3 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1661,7 +1661,9 @@ 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: conflict = ticketstore.store_conflict( self.project, @@ -1723,9 +1725,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..fbde72010 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10236,7 +10236,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 +10352,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 +10369,41 @@ 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" +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 @@ -11668,7 +11706,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 +11726,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 +11785,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) @@ -21577,6 +21657,46 @@ 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 + + # --- #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_runsetup.py b/tests/test_runsetup.py index bd3a11a63..dd3498338 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -2487,3 +2487,177 @@ 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 + + +@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..b37640379 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -9702,7 +9702,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..8b4d6ab46 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,9 @@ 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") + return (None, state, *(None,) * 6, PauseSnapshot(*(None,) * 5, False, False, None)) def compose(**_kwargs): raise Drove From e59580afe59b76fdb91db4eaafe0f2566f72a7bb Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 19:26:01 -0700 Subject: [PATCH 2/7] refactor(cli): extract cmd_run's launch sequence and seat a chained child's parent on compose_run (DW-574) Move cmd_run's post-scope launch sequence (worktree-clean check, base skills, stale reconcile, plugin trust, launch profiles + config pin, compose_run, the bypass-dropped warning and the "run ... starting" print) into `_launch_run(project, paths, pol, args, *, source, spec_folder, scope, parent_initiative=None)`, which returns the ComposedRun or an rc. cmd_run keeps the arg checks, dry-run, git floor, isolation, store topology, preflight and scope resolution, then runs the engine and prints the summary as before; unchained output, exit codes and gate order are unchanged. compose_run takes `parent_initiative` and records it on the RunState (tickets only, as initiative is): handed child-rooted paths, the run pins the child as its initiative and folder and records the parent. A parent given with paths not rooted on one of its children (off v7, no active initiative folder, or the parent itself) raises ValueError before the run dir is claimed. The run-start topology classification is made `chained` for such a run, matching ticketstore.store_conflict's judgement of a child. `_reject_store_topology` takes `initiative=` and forwards it to store_conflict only when set, so the unchained call is unchanged. `_launch_run` calls it with the child for a chained launch (each child folder can have its own topology) and not at all otherwise, since cmd_run already judged the store. The run-start `_sweep_factory` now receives the helper's `parent_initiative`, replacing the S3 placeholder. The portability guard's store-topology call-site inventory declares the new `_launch_run` site, covered by its refusal row in test_cli. --- src/bmad_loop/cli.py | 73 ++++++++++++++++++++++---- src/bmad_loop/runsetup.py | 38 ++++++++++++-- tests/test_cli.py | 91 +++++++++++++++++++++++++++++++++ tests/test_portability_guard.py | 1 + tests/test_runsetup.py | 51 ++++++++++++++++++ 5 files changed, 240 insertions(+), 14 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 19e18029b..635f42353 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -366,7 +366,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,8 +379,14 @@ 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) @@ -4223,6 +4234,47 @@ def cmd_run(args: argparse.Namespace) -> int: return resolved scope = resolved + launched = _launch_run( + project, paths, pol, args, source=source, spec_folder=spec_folder, scope=scope + ) + if isinstance(launched, int): + return launched + summary = launched.engine.run() + print(summary.render()) + return 0 + + +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 +4290,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,9 +4328,13 @@ def cmd_run(args: argparse.Namespace) -> int: max_stories=args.max_stories, source=source, spec_folder=spec_folder, - # no run starts chained yet: the S3 chain driver supplies a child's parent here + # 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=None + project, + paths, + trusted_digest, + plugin_grants=plugin_grants, + parent_initiative=parent_initiative, ), make_adapters=_make_adapters, engine_cls=Engine, @@ -4287,6 +4343,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, @@ -4296,9 +4353,7 @@ 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 _render_invocation(pol, project: Path, role: str, prompt: str) -> str: diff --git a/src/bmad_loop/runsetup.py b/src/bmad_loop/runsetup.py index aa5ce1c99..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): @@ -1393,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 @@ -1422,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) @@ -1462,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 diff --git a/tests/test_cli.py b/tests/test_cli.py index fbde72010..7d173d599 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15863,6 +15863,97 @@ 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)] + + 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.""" diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 88f70e424..296836b97 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -396,6 +396,7 @@ ("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", "cmd_sweep"): 1, # pre-session + post-confirm, as the #414 pair, + ahead of --reverify/--adopt-branch ("cli.py", "cmd_resolve"): 3, diff --git a/tests/test_runsetup.py b/tests/test_runsetup.py index dd3498338..6e43105ef 100644 --- a/tests/test_runsetup.py +++ b/tests/test_runsetup.py @@ -2568,6 +2568,57 @@ def test_compose_sweep_records_no_parent_off_v7(tmp_path): 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 From f4ce320716c2b593d915f7c5df7e860ea8d459de Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 19:45:42 -0700 Subject: [PATCH 3/7] feat(tickets): run a parent initiative as a chain of child runs (DW-574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: - cmd_run detects a parent initiative after its once-only gates, reading tickets.toml as the preflight does (install._initiative_children). It refuses --epic/--story with exit 1 before any run dir exists, picks the first child (initiativechain.next_child over `next `, with a drained probe), and either prints why there is nothing to build (the ChildPick end) and returns 0, or launches the child through _launch_run on paths.with_initiative(child) with parent_initiative set. - _drive_initiative_chain runs after engine.run(). It goes on only while RunState.finished is set, parent_initiative is set and replay_of is empty. Per switch: stop requested -> end; a drained child is marked done (mark_initiative_done on a main-checkout tree pinned as runs.tree_pin pins the run, journaled into the finished run); pick the next child; stall guard; journal; launch and run the next child. One line is printed per switch. - New journal kinds: initiative-chain-next and initiative-chain-end in the finished run, initiative-chain in the next run. They are routed in diagnostics (kind-scoped aliases and keylists in the `initiative` namespace, fail-closed schemas) and declared in the portability guard. - initiativechain gains DRAINED and drained(child), the pure "open, with every ticket closed" test that check_initiative_close writes on. - cli._fresh_pick is the dry-run's decide_pick call, factored out and shared with the drained probe: a fresh pick that is a Skip means drained. - Docs: tickets-guide, FEATURES, reference/cli and reference/run-state describe the chain. Decisions this brief did not settle: - The close is committed. In T1 the child's .md is tracked in the code checkout, so an uncommitted close fails the next launch's worktree-clean gate. Where the run's recorded store topology is versioned (T1 in the code repo, T3/T4 in the store's own repo), ticketstore.StoreCommitter commits exactly that file as `chore(tickets): done [bmad-loop ]`. It journals store-committed / store-commit-failed / store-commit-foreign-dirt with story_key=. A failed or foreign-blocked commit ends the chain as `store-commit-failed` with exit 1. Unversioned stores commit nothing. - "Drained", for the close, is the parent view's counts (drained(): at least one ticket, all done or dropped, child open). For the pass-over probe it is a fresh pick that returns a Skip, as specified. A child whose tickets are all done but whose epics are open halts the fresh pick at awaiting-closure, so it is built again rather than passed over. - Stall guard: the pick is the child just built and its run landed no ticket (no task DONE or AWAITING_OPERATOR). This is stricter than "no ticket dispatched": a run that only deferred or parked tickets would otherwise be relaunched without end. - Stop requested: the stop file is still lodged in the finished run dir (runs.graceful_stop_requested, which `bmad-loop stop` writes), OR the finished run journaled stop-request-discarded. The engine's finally discards a request that arrives once the queue is empty, so the file check alone would almost never fire. A child run is always fresh, so a discarded request was made while it built. The stop check runs before the close, per the brief's order, so a stopped chain leaves a drained child unmarked. - Field names: `from`/`to` are spelled from_child/to_child (`from` cannot be a keyword argument). The end cause is `end`, not `reason`, because diagnostics drops `reason` by name. The pass-over pairs are two parallel lists, passed_over (slugs) and passed_over_why (tokens). The new run's row carries previous_run and previous_child. - End tokens beyond ChainEnd: stalled, stop-requested, store-commit-failed, and fault (a tickets.py error, a bad child slug, or a BmadConfigError while re-rooting; exit 1, `error` journaled and dropped by diagnostics). - A child run that does not finish prints "stops at — run did not finish" and journals nothing, so the S3c resume can continue the chain without an end row on record. - --run-id names the first child's run only; later runs mint their own id. - The cmd_run start pick journals nothing (no run exists yet); the first child's RunState.parent_initiative records the chain. Beyond the subject: - _dry_run_tickets now calls _fresh_pick; behavior unchanged. - S3c hooks are marked where the --max-stories cap and the per-run _claim_ctl_window plug in (the next launch), and where each waiting child's unmet prerequisite joins the end journal. Ablations (performed, each FAILED then reverted): skip mark_initiative_done; drop the stall guard; drop the finished-only condition; drop the stop check; drop the --epic/--story refusal; drop the start-time no-child return; drop initiative-chain-end's keylist row. --- docs/FEATURES.md | 1 + docs/reference/cli.md | 1 + docs/reference/run-state.md | 2 +- docs/tickets-guide.md | 9 + src/bmad_loop/cli.py | 309 ++++++++++++++++++++++++++++++- src/bmad_loop/diagnostics.py | 21 +++ src/bmad_loop/initiativechain.py | 18 ++ tests/test_cli.py | 297 +++++++++++++++++++++++++++++ tests/test_diagnostics.py | 52 ++++++ tests/test_portability_guard.py | 12 ++ 10 files changed, 716 insertions(+), 6 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b249f0af5..0e98f44d9 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 a drained child's initiative `status: done` before it picks the next; the chain ends at the first child run that does not finish. - 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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fa77f94cb..561528ae3 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, marking a drained child's initiative `done` first. It prints `initiative chain : -> ` per switch, or `... ends ()`, and stops at a run that does not finish. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run. - `--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 1a2d977dc..ef7f11c3f 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,7 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. +`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. When `run` builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit when the child was closed, 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 and the passed-over ones). The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index d103c9f69..77294b112 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -41,6 +41,15 @@ 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`. When a child's run finishes with every ticket done or dropped, bmad-loop writes `status: done` into the child's `.md` before it picks the next child, so the children waiting on it become ready. Where the store is versioned, that write is committed as `chore(tickets): done [bmad-loop ]`, in the code repo (T1) or in the store's own repository (T3/T4). 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). Resuming that run builds that child only. +- a stop was asked of the run. +- 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`). + +`--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, and `--max-stories` applies to each child's run. + ## 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 635f42353..0995154f7 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, @@ -4227,21 +4228,53 @@ 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: + pick = _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.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, paths, pol, args, source=source, spec_folder=spec_folder, scope=scope + 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 0 + return _drive_initiative_chain(project, paths, pol, args, launched, spec_folder=spec_folder) def _launch_run( @@ -4356,6 +4389,272 @@ def _launch_run( 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 _chain_end_line(parent: str, end: str, pick: initiativechain.ChildPick | None) -> str: + """The human line for a chain that ends: why, what still waits, what was + passed over.""" + line = f"initiative chain {parent}: ends ({end})" + if pick is not None and pick.waiting: + line += f" — waiting: {', '.join(pick.waiting)}" + return line + ("" if pick is None else _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``, + ``store-commit-failed`` or ``fault``.""" + # S3c: name each waiting child's unmet prerequisite here. + extras = {} if error is None else {"error": error} + journal.append( + "initiative-chain-end", + parent=parent, + end=end, + waiting=[] if pick is None else list(pick.waiting), + 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``) — it was a fresh run, so the request was made + while it built and was meant for the chain.""" + if runs.graceful_stop_requested(finished.run_dir): + return True + return any(e.get("kind") == "stop-request-discarded" for e in finished.journal.entries()) + + +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()) + + +def _close_drained_child( + project: Path, paths: bmadconfig.ProjectPaths, finished: runsetup.ComposedRun, parent: str +) -> str | None: + """Mark the child the finished run built ``status: done`` when ``next + `` reads it drained (``initiativechain.drained``), through + ``TicketTree.mark_initiative_done`` on a main-checkout tree pinned as + ``runs.tree_pin`` pins the run, journaled into its journal; then commit the + file 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. Returns None, or the reason the chain cannot go + on: ``store-commit-failed`` (also journaled). Tree faults raise.""" + state = finished.state + slug = state.initiative + parent_folder = paths.initiative_folder + assert parent_folder is not None # _chain_parent + pin, _folder = runs.tree_pin(state, runs.paths_for_state(paths, state)) + assert pin is not None # a chained run pins its parent + tree = _chain_ticket_tree(project, pin) + child = tree.parent_next(parent_folder).child(slug) + if child is None or not initiativechain.drained(child): + return None + mark = tree.mark_initiative_done(parent_folder, slug, journal=finished.journal) + if mark.outcome != "written": + return None + recorded = runs.live_store_topology(state, project) + topology = ticketstore.StoreTopology.from_dict(recorded) if recorded else None + 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 + committer = ticketstore.StoreCommitter(repo, paths.repo_root) + message = f"chore(tickets): {slug} done [bmad-loop {state.run_id}]" + try: + result = committer.commit(slug, [mark.file], message) + except ticketstore.StoreCommitError as e: + finished.journal.append("store-commit-failed", story_key=slug, error=str(e)) + return "store-commit-failed" + if result.foreign: + finished.journal.append( + "store-commit-foreign-dirt", story_key=slug, count=len(result.foreign) + ) + return "store-commit-failed" + finished.journal.append("store-committed", story_key=slug, commit=result.sha) + return None + + +def _drive_initiative_chain( + project: Path, + paths: bmadconfig.ProjectPaths, + pol, + args: argparse.Namespace, + finished: runsetup.ComposedRun, + *, + spec_folder: str, +) -> int: + """Build a parent initiative's children one run after another, after the run + ``finished`` (``paths`` are the parent's, as ``run`` loaded them). 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 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`); a drained child is marked done + (:func:`_close_drained_child`) so its dependents become ready; the next child + is picked (:func:`_chain_pick`); picking the child just built again when its + run landed no ticket ends the chain 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); the next + run, once composed, ``initiative-chain`` (``parent``, ``previous_run``, + ``previous_child``). One line per switch is printed. + + Returns 0, 1 for a tree fault or a close that could not be committed, or the + refusal rc of the next launch.""" + # `--run-id` names the first child's run alone; each later run mints its own. + args = argparse.Namespace(**{**vars(args), "run_id": None}) + while True: + state = finished.state + parent = state.parent_initiative + if parent is None 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: + if (owed := _close_drained_child(project, paths, finished, parent)) is not None: + return _chain_end( + finished, + parent, + owed, + error=f"closing {state.initiative} could not be committed (see the " + f"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) + 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)}") + # S3c: hold `--max-stories` across the chain (each run takes the full cap + # today) and claim the control window per run (`_claim_ctl_window`). + launched = _launch_run( + project, + paths.with_initiative(child), + pol, + args, + source=worksource.SOURCE_TICKETS, + spec_folder=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: from .adapters import registry as adapter_registry from .adapters.profile import ProfileError, get_profile @@ -4692,7 +4991,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( diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 0e7d9b685..5794a2e9b 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -309,6 +309,15 @@ # 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"}, } # 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 +506,11 @@ # 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, or still + # waiting on a prerequisite, 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"}, } _JOURNAL_KIND_COUNTLIST_FIELDS: dict[str, frozenset[str]] = { "merge-preflight-refused": frozenset({"tolerated"}), @@ -606,6 +620,13 @@ # 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, and + # `previous_run` a run id; the slugs are kind-aliased or kind-keylisted, which + # runs first, and `error` is dropped by name. + "initiative-chain-next": frozenset({"passed_over_why"}), + "initiative-chain-end": frozenset({"end", "passed_over_why"}), + "initiative-chain": frozenset({"previous_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..02158924c 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -18,6 +18,9 @@ * Children that are not ready and not done or dropped are ``waiting`` on a prerequisite (a folder-less one included). +: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 marked done keeps its dependents here), ``passed-over`` when every child left @@ -31,11 +34,15 @@ 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.""" + _CLOSED: Final[frozenset[str]] = frozenset(("done", "dropped")) ChainEnd = Literal["waiting", "passed-over", "all-done"] @@ -62,6 +69,17 @@ class ChildPick: end: ChainEnd | None +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 next_child( view: ParentView, passes_over: Callable[[ChildInitiative], str | None] | None = None, diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d173d599..d2232a6d4 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, @@ -15954,6 +15955,302 @@ def launch(): 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 _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; + * ``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 == "pause": + self.state.record_pause("scripted pause", "ticket-tree", None) + else: + self.state.finished = True + if outcome in ("drain", "stop-lodged", "stop-discarded"): + child = v7_layout(root, initiative=self.state.initiative) + closed = tuple(dataclasses.replace(e, status="done") for e in DEFAULT_TREE) + write_ticket_tree(child, closed) + 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 {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 == "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) + + +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.""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 0826fe4a4..28b290bf7 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1206,6 +1206,58 @@ 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 three 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 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], + "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"}, + ] + + nxt, end, link, mark = (diagnostics._scrub_entry(r, pseudo, {}, None) for r in rows) + + assert "acme" not in json.dumps([nxt, end, link, mark]) + 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["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_portability_guard.py b/tests/test_portability_guard.py index 296836b97..601e754c8 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -602,6 +602,13 @@ # `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, 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"}), + "initiative-chain": frozenset({"previous_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"}), @@ -2253,6 +2260,11 @@ "ticket-mark", "ticket-plan-restored", "initiative-mark", + # cli.py — the initiative chain: per switch, the next child or the end in + # the finished run, and the chain link in the next run. + "initiative-chain-next", + "initiative-chain-end", + "initiative-chain", # worktree_flow.py "accepted-spec-delivery-unreachable", "accepted-spec-write-unreachable", From 5d7360cfc9550056427386e16ba8ca02fc0a8b22 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 20:03:09 -0700 Subject: [PATCH 4/7] feat(tickets): continue a parent initiative's chain after resume and cap it with one --max-stories (DW-574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: - _resume_paused_run calls _drive_initiative_chain after engine.run(), under the same conditions as cmd_run (finished, parent_initiative set, replay_of empty). Every cli path that drives a recorded run's engine goes through _resume_paused_run: resume, resolve's re-arm (cmd_resolve), _resolve_adopt, _resolve_reverify and the reverify replay. The TUI resume and resolve gestures launch those same CLI commands in a control window (tui.launch resume_detached / start_resolve_detached), and the TUI never runs an engine in-process. _prepare_resume_locked now also returns the loaded parent paths, the ones runs.paths_for_state re-rooted for the child, and the driver gets those paths and the resume's policy. - _drive_initiative_chain(project, paths, pol, finished) no longer takes args or spec_folder. Each next launch gets a Namespace with run_id=None, epic/story None (a tickets launch never reads them) and max_stories=, and the spec folder is the finished run's RunState.spec_folder ("" for tickets). - One --max-stories across the chain (_chain_budget): the next child's budget is the finished child's RunState.max_stories minus len(state.tasks). That is the engine's own count against the cap (Engine._dispatched_count, every task the run recorded, which also survives pause/resume). After the pick, a budget <= 0 ends the chain as `max-stories`. An unset cap stays None. - initiative-chain-end names what each waiting child waits on, as three parallel lists with one row per unmet prerequisite: waiting_child, waiting_on and waiting_on_status. initiativechain gains WaitingOn, ChildPick.waiting_on (default ()) and prerequisite_status(view, slug). The printed end line says the same thing in short: "— waiting: on

(), (); on ...". A child blocked by a dropped prerequisite still ends `waiting`. diagnostics: waiting_child and waiting_on are keylisted in the `initiative` namespace, and waiting_on_status joins the fail-closed schema (also in the portability guard's declared schema). - Both `# S3c:` comments are removed. Both were fulfilled. - Docs: tickets-guide, FEATURES, reference/cli and reference/run-state describe resume continuation, the shared budget and the waiting detail. Decisions this brief did not settle: - _claim_ctl_window: unchanged. A fresh `run` deliberately does not claim it. _drive_ctl_launch is gated to resume and resolve. The claim (#920) settles which of several control windows named - drives one run id: a resume mints a second window under an existing id. A fresh run's id is new, so nothing has to be superseded. Each later run the driver launches also mints a new id, so it gets the same treatment. Gap: that later run lives in a window named for the first run (run-, or resume- after a resume), so the TUI's attach and stop (`a`/`x`) cannot find it by the later run's id. `bmad-loop stop ` still works. A claim here would only record the window and print _unnamed_ctl_window's "not named for run" warning. - The budget check comes after the pick and before the stall guard, so a chain with nothing left still ends all-done/waiting/passed-over rather than max-stories. It also comes after the close, so the child that spent the budget is still marked done when drained. The end row carries the pick's waiting and passed-over detail. - Prerequisite status tokens: the child's file status (in-progress, dropped, done), `open` for a folder whose file sets no status, `no-folder` for a declared child with no folder, and `?` for a slug the parent does not list (upstream refuses that, but parse_parent_next does not check it). - The existing `waiting` list is kept. The per-prerequisite rows are parallel lists, following d3078957's passed_over/passed_over_why convention. - The driver also requires run_type != "sweep". A chained child's run-end sweep records parent_initiative too, so resuming that sweep to finished would otherwise close and relaunch children as though it were the child run. - _chain_stop_requested counts a stop-request-discarded row only after the journal's last run-resume. The engine also discards a pending request when a run pauses, and the operator's resume overrides that request. d3078957's assumption that "a child run is always fresh" no longer holds. Beyond the subject: - Test fakes: test_resume_crash_exit_is_failure_only_for_persisted_named_scope's fake ComposedRun now carries `state`, and the faked _prepare_resume_locked tuple in test_tui_launch gains its 10th element. - _chain_project gains a `pause-stop-discarded` outcome. Ablations (performed, each FAILED then reverted): no driver call after resume; budget not reduced (each child gets the finished run's own cap); waiting detail dropped from the journal row and the printed line; drop the sweep condition; scan the whole journal for a discarded stop. --- docs/FEATURES.md | 2 +- docs/reference/cli.md | 2 +- docs/reference/run-state.md | 2 +- docs/tickets-guide.md | 7 +- src/bmad_loop/cli.py | 146 +++++++++++++++++++-------- src/bmad_loop/diagnostics.py | 22 ++-- src/bmad_loop/initiativechain.py | 48 ++++++++- tests/test_cli.py | 168 ++++++++++++++++++++++++++++++- tests/test_diagnostics.py | 8 +- tests/test_initiativechain.py | 29 +++++- tests/test_portability_guard.py | 8 +- tests/test_tui_launch.py | 3 +- 12 files changed, 374 insertions(+), 71 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0e98f44d9..1673c31d6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -51,7 +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 a drained child's initiative `status: done` before it picks the next; the chain ends at the first child run that does not finish. + - `run` on a parent initiative builds its children one run each in build order, writing a drained child's initiative `status: done` before it picks the next, 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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 561528ae3..6b8b9291b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -165,7 +165,7 @@ bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] - In tickets mode `run` resolves the selectors against the active initiative before anything is created. `--epic` must name one epic of the initiative table. `--story` goes through `tickets.py find` and must name one ticket that `next` lists as ready to start or to refine (and that is in the `--epic` epic, when both are given). Anything else exits 1. The run then picks only within the selection, and finishes once it has nothing left to start there. `--dry-run` applies the same selection. - Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. - `[stories] source = "tickets"` runs the active initiative's ticket tree. Before anything is created, `run` executes the `tickets.*` preflight (capability probe included) and refuses with exit 1 on a problem, including a ticket store it cannot classify or drive (`tickets.store-topology` and the refusals below). The classified topology is recorded on the run, and `resume`, `resolve` and the TUI refuse a store that changed since; `branch_per = "run"` prints the `tickets.branch-per-run` warning. Each picked ticket is dispatched as `ticket ` and, once verified at `built`, marked `done` under `[tickets] mark_done = "auto"` (left at `built` under `"never"`): inside its unit's commit for a store tracked in the code checkout (T1), after the code lands for a shared store, with a plan-only commit in a store that is its own repository (T3/T4). Under worktree isolation a unit's branch is `bmad-loop///`. -- On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes, marking a drained child's initiative `done` first. It prints `initiative chain : -> ` per switch, or `... ends ()`, and stops at a run that does not finish. `--epic` and `--story` exit 1 there; `--run-id` names the first child's run. +- 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, marking a drained child's initiative `done` first. 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 ef7f11c3f..171fb75cd 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,7 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. When `run` builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit when the child was closed, 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 and the passed-over ones). The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). +`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 when the child was closed, 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`). A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index 77294b112..fb08e08e9 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -43,12 +43,13 @@ A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiati `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`. When a child's run finishes with every ticket done or dropped, bmad-loop writes `status: done` into the child's `.md` before it picks the next child, so the children waiting on it become ready. Where the store is versioned, that write is committed as `chore(tickets): done [bmad-loop ]`, in the code repo (T1) or in the store's own repository (T3/T4). 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). Resuming that run builds that child only. +- 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`). +- 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. -`--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, and `--max-stories` applies to each child's run. +`--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 diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 0995154f7..27c016ffe 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4274,7 +4274,7 @@ def cmd_run(args: argparse.Namespace) -> int: return launched summary = launched.engine.run() print(summary.render()) - return _drive_initiative_chain(project, paths, pol, args, launched, spec_folder=spec_folder) + return _drive_initiative_chain(project, paths, pol, launched) def _launch_run( @@ -4451,13 +4451,24 @@ def _passed_over_note(pick: initiativechain.ChildPick) -> str: 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, what was - passed over.""" + """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})" - if pick is not None and pick.waiting: - line += f" — waiting: {', '.join(pick.waiting)}" - return line + ("" if pick is None else _passed_over_note(pick)) + return line + ("" if pick is None else _waiting_note(pick) + _passed_over_note(pick)) def _journal_chain_end( @@ -4469,14 +4480,20 @@ def _journal_chain_end( ) -> None: """``initiative-chain-end`` in the finished run's journal. ``end`` is a :data:`initiativechain.ChainEnd`, ``stalled``, ``stop-requested``, - ``store-commit-failed`` or ``fault``.""" - # S3c: name each waiting child's unmet prerequisite here. + ``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, @@ -4505,11 +4522,15 @@ 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``) — it was a fresh run, so the request was made - while it built and was meant for the chain.""" + (``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 - return any(e.get("kind") == "stop-request-discarded" for e in finished.journal.entries()) + 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: @@ -4563,40 +4584,57 @@ def _close_drained_child( return None +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, - args: argparse.Namespace, finished: runsetup.ComposedRun, - *, - spec_folder: str, ) -> int: """Build a parent initiative's children one run after another, after the run - ``finished`` (``paths`` are the parent's, as ``run`` loaded them). 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 replays nothing (``replay_of`` empty); otherwise returns 0 at once. + ``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`); a drained child is marked done (:func:`_close_drained_child`) so its dependents become ready; the next child - is picked (:func:`_chain_pick`); picking the child just built again when its - run landed no ticket ends the chain 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); the next - run, once composed, ``initiative-chain`` (``parent``, ``previous_run``, - ``previous_child``). One line per switch is printed. + 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.""" - # `--run-id` names the first child's run alone; each later run mints its own. - args = argparse.Namespace(**{**vars(args), "run_id": None}) while True: state = finished.state parent = state.parent_initiative - if parent is None or state.replay_of or not state.finished: + 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 " @@ -4619,6 +4657,9 @@ def _drive_initiative_chain( 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) @@ -4631,15 +4672,13 @@ def _drive_initiative_chain( passed_over_why=[p.why for p in pick.passed_over], ) print(f"initiative chain {parent}: {state.initiative} -> {child}{_passed_over_note(pick)}") - # S3c: hold `--max-stories` across the chain (each run takes the full cap - # today) and claim the control window per run (`_claim_ctl_window`). launched = _launch_run( project, paths.with_initiative(child), pol, - args, + argparse.Namespace(run_id=None, epic=None, story=None, max_stories=budget), source=worksource.SOURCE_TICKETS, - spec_folder=spec_folder, + spec_folder=state.spec_folder, scope=tickets_source.EVERYTHING, parent_initiative=parent, ) @@ -6164,7 +6203,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: @@ -6349,7 +6399,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 @@ -6369,9 +6422,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 @@ -6432,11 +6494,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( diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 5794a2e9b..ea5991f75 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -506,11 +506,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, or still - # waiting on a prerequisite, aliased in the `initiative` namespace the + # `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"}, + "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"}), @@ -621,11 +627,13 @@ # 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, and - # `previous_run` a run id; the slugs are kind-aliased or kind-keylisted, which - # runs first, and `error` is dropped by name. + # 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-chain-next": frozenset({"passed_over_why"}), - "initiative-chain-end": frozenset({"end", "passed_over_why"}), + "initiative-chain-end": frozenset({"end", "passed_over_why", "waiting_on_status"}), "initiative-chain": frozenset({"previous_run"}), } diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py index 02158924c..b20cdcabd 100644 --- a/src/bmad_loop/initiativechain.py +++ b/src/bmad_loop/initiativechain.py @@ -16,7 +16,9 @@ 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. @@ -43,6 +45,12 @@ 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"] @@ -56,17 +64,30 @@ 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: @@ -80,6 +101,19 @@ def drained(child: ChildInitiative) -> bool: 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( view: ParentView, passes_over: Callable[[ChildInitiative], str | None] | None = None, @@ -87,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: @@ -96,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/tests/test_cli.py b/tests/test_cli.py index d2232a6d4..8457315a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10794,7 +10794,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 @@ -16000,7 +16001,8 @@ def _chain_project(project, monkeypatch, capsys, outcomes): * ``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`` — 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). @@ -16033,7 +16035,7 @@ def __init__(self, **kwargs): def run(self): assert script, f"run {len(built)} of the chain was never scripted" outcome = script.pop(0) - if outcome == "pause": + if outcome.startswith("pause"): self.state.record_pause("scripted pause", "ticket-tree", None) else: self.state.finished = True @@ -16048,7 +16050,7 @@ def run(self): 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 == "stop-discarded": + if outcome in ("stop-discarded", "pause-stop-discarded"): self.journal.append("stop-request-discarded") save_state(self.run_dir, self.state) return super().run() @@ -16223,6 +16225,164 @@ def test_run_on_a_parent_stops_on_a_stop_request(project, monkeypatch, capsys, h 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_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.""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 28b290bf7..5e76bc741 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1210,7 +1210,8 @@ def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): """The chain's three 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 previous run id stay readable, and `error` is dropped. + 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.""" @@ -1234,6 +1235,9 @@ def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): "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}", @@ -1254,6 +1258,8 @@ def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): 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 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 601e754c8..1162d2a74 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -603,11 +603,11 @@ # `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, and the previous run's id. The slugs - # are aliased or keylisted by `diagnostics`' kind-scoped tables; `error` is - # dropped by name. + # `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"}), + "initiative-chain-end": frozenset({"end", "passed_over_why", "waiting_on_status"}), "initiative-chain": frozenset({"previous_run"}), # runs.py: whether the re-arm moved its restore latch onto the evacuated copy. # `patch` and `stashed_to` are dropped by name. diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 8b4d6ab46..15defc90f 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -1342,7 +1342,8 @@ def prepare(_project, _run_dir, *, accept_baseline=False): engine["live"] = True # the pid this child publishes under the lock # the resumed state: the sweep factory reads its parent initiative state = RunState(run_id="RID", project=str(tmp_path), started_at="now") - return (None, state, *(None,) * 6, PauseSnapshot(*(None,) * 5, False, False, None)) + pause = PauseSnapshot(*(None,) * 5, False, False, None) + return (None, state, *(None,) * 6, pause, None) def compose(**_kwargs): raise Drove From 6020464f7d617b5afcd468ce4b5e496d6f8e4fe0 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 20:17:14 -0700 Subject: [PATCH 5/7] fix(tickets): close every drained child before a chain pick (DW-574) The defect (review finding 2 against d3078957): - Only _close_drained_child wrote a child initiative `status: done`, and only for the child of the run that had just finished. Neither cmd_run's start pick nor the per-switch pick closed any other drained child. The drained probe passed such a child over as `drained`, and its dependents stayed `waiting`. A stop (the chain ends `stop-requested` before the close), a Ctrl-C or crash between engine.run() and the close, or a sibling drained outside the chain all left a drained child open. Every later `run` on the parent then ended `waiting` until a human edited the child's file by hand. The fix: - _close_drained_children replaces _close_drained_child. It runs before every pick the chain makes: in cmd_run before the start pick, and in _drive_initiative_chain before each per-switch pick. The driver already runs after a resume (S3c-1), so the post-resume continuation gets it too. It closes every child of `next ` that initiativechain.drained reports, through the same path d3078957 used: mark_initiative_done on a main-checkout tree pinned to the parent, its initiative-mark row, then the StoreCommitter commit where the store is versioned, with store-committed / store-commit-failed / store-commit-foreign-dirt rows. The parent view is then re-read by _chain_pick, as before. - The stop check stays where d3078957 put it, ahead of the close. A stopped chain still leaves its drained child open, and the next `run`'s pre-pick close catches it. - A close that cannot be committed ends the chain as d3078957's did: end cause `store-commit-failed`, exit 1. In the driver this is the same _chain_end row. At run start nothing is journaled, and the same `ends (store-commit-failed): ...` line goes to stderr. The error text now names the child that failed, which need not be the one just built. - A drained child that is left open journals `initiative-close-skipped` (parent, slug, why, blocking_run, detail) and prints one line. Every close prints `initiative chain : closed (drained)`. Decisions: - Live runs. A child is held by a recorded run of it (state.initiative == slug, any parent) that is not finished. The status comes from runs.discover_runs, the classification `list` and the TUI use. RUNNING or UNKNOWN (an engine that is or may be alive) gives why=`run-live`. A runs dir that could not be read whole, or a run whose state.json does not load, gives `runs-unread` for every close (fail closed: it may hide a live run). - Unfinished but not live DOES block a build run's child: paused, stopped, crashed and interrupted runs give `run-unfinished`. Such a run still owns the child. Its resume drives the child's engine on, including any epic-closure or retrospective gate, and for a chained run that resume's driver closes the child itself. Closing under it would make the dependents buildable while a resumable run of the child is outstanding. The skip line names the run to resume or clean up. - A sweep holds its child only while live. A sweep works the child's ledger, writes no ticket or initiative status, and its resume does not continue the chain (S3c-1's run_type guard). If a paused sweep held its child, a chained child's run-end sweep that paused for decisions would strand the chain again. That is this defect, moved. - Journal rows at run start are printed only, not journaled. This follows d3078957's start pick, which journals nothing because no run exists yet. The rows go to an in-memory _ChainStartLog (the _ConfirmMarkLog shape) because mark_initiative_done needs a journal. The first child's run dir does not exist when the closes happen, and a close that fails must end the chain without one. The commit message stands in for the run id: `chore(tickets): done [bmad-loop chain ]`. Beyond the subject: - Fresh-pick guard (`work-left`). A drained child other than the one the finished run just built is closed only when its fresh pick (_fresh_pick, the drained probe) is a Skip. drained() reads counts only, so a child whose tickets are all done but whose epic awaits closure, or that declares an epic not yet broken down, reads drained. d3078957 builds that child rather than passing it over. Closing it unbuilt would skip that closure and could mark an initiative done with work undeclared. The finished run vouches for its own child, which keeps d3078957's close of it unconditional. - Store judgement. The built child commits in the topology its run recorded, as before. Any other child's store is classified now (ticketstore.assess_store, chained=True, as a chained launch judges it). A refused store leaves the child open (`store-refused`) instead of writing a status the chain could not commit. This is a new store_topology_conflict site, `_child_store`, declared in the portability guard's STORE_TOPOLOGY_CALLERS with its test. - The tree for the close is pinned to `parent` directly. The tree's own pin check enforces the live config. d3078957 derived the same pin via runs.tree_pin/paths_for_state from the finished run. - New journal kind initiative-close-skipped: routed in diagnostics (`parent`/`slug` aliased in the `initiative` namespace; `why` and `blocking_run` in the fail-closed schema; `detail` dropped by name) and declared in the portability guard. - Docs: tickets-guide (closing now has its own paragraph, under the 150-word block cap), reference/cli, reference/run-state, FEATURES. - Test helper _drain_child factored out of _chain_project's scripted engine. Ablations (performed, each FAILED then reverted): drop the pre-pick close in cmd_run (stopped-chain, drained-sibling and dead-sweep tests fail); drop the held-run check; let every unfinished sweep hold its child; drop the store judgement; drop the fresh-pick guard. --- docs/FEATURES.md | 2 +- docs/reference/cli.md | 2 +- docs/reference/run-state.md | 2 +- docs/tickets-guide.md | 6 +- src/bmad_loop/cli.py | 224 +++++++++++++++++++++++++------- src/bmad_loop/diagnostics.py | 7 +- tests/test_cli.py | 196 +++++++++++++++++++++++++++- tests/test_diagnostics.py | 17 ++- tests/test_portability_guard.py | 8 +- 9 files changed, 403 insertions(+), 61 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1673c31d6..214adf413 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -51,7 +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 a drained child's initiative `status: done` before it picks the next, under one `--max-stories` budget; a child run that does not finish holds the chain until `resume` or `resolve` drives it to finished, and the chain's end names what each waiting child waits on. + - `run` on a parent initiative builds its children one run each in build order, writing every drained child's initiative `status: done` before each pick (one a stopped chain left open included), under one `--max-stories` budget; a child run that does not finish holds the chain until `resume` or `resolve` drives it to finished, and the chain's end names what each waiting child waits on. - 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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6b8b9291b..2cb776e23 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -165,7 +165,7 @@ bmad-loop run [--project PROJECT] [--spec FOLDER] [--epic EPIC] [--story STORY] - In tickets mode `run` resolves the selectors against the active initiative before anything is created. `--epic` must name one epic of the initiative table. `--story` goes through `tickets.py find` and must name one ticket that `next` lists as ready to start or to refine (and that is in the `--epic` epic, when both are given). Anything else exits 1. The run then picks only within the selection, and finishes once it has nothing left to start there. `--dry-run` applies the same selection. - Refused with exit 1 before anything is created on: git below the supported floor, a dirty worktree, a worktree-isolation conflict, missing base skills, or a failing plugin-trust verdict. - `[stories] source = "tickets"` runs the active initiative's ticket tree. Before anything is created, `run` executes the `tickets.*` preflight (capability probe included) and refuses with exit 1 on a problem, including a ticket store it cannot classify or drive (`tickets.store-topology` and the refusals below). The classified topology is recorded on the run, and `resume`, `resolve` and the TUI refuse a store that changed since; `branch_per = "run"` prints the `tickets.branch-per-run` warning. Each picked ticket is dispatched as `ticket ` and, once verified at `built`, marked `done` under `[tickets] mark_done = "auto"` (left at `built` under `"never"`): inside its unit's commit for a store tracked in the code checkout (T1), after the code lands for a shared store, with a plan-only commit in a store that is its own repository (T3/T4). Under worktree isolation a unit's branch is `bmad-loop///`. -- On a parent initiative, `run` builds the children one run each: the first child `next ` lists as ready, skipping one with no folder or nothing left to dispatch, then the next after each run that finishes, marking a drained child's initiative `done` first. 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. +- 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 171fb75cd..ccaff0210 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,7 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit when the child was closed, 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`). +`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` or `store-refused`, `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. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. diff --git a/docs/tickets-guide.md b/docs/tickets-guide.md index fb08e08e9..246c22c8a 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -41,7 +41,7 @@ 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`. When a child's run finishes with every ticket done or dropped, bmad-loop writes `status: done` into the child's `.md` before it picks the next child, so the children waiting on it become ready. Where the store is versioned, that write is committed as `chore(tickets): done [bmad-loop ]`, in the code repo (T1) or in the store's own repository (T3/T4). One line per switch names the next child or why the chain ended. The chain ends when: +`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. @@ -49,6 +49,10 @@ A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiati - 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 diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 27c016ffe..fd63aa69b 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4245,10 +4245,21 @@ def cmd_run(args: argparse.Namespace) -> int: ) return 1 try: - pick = _chain_pick(project, paths, parent) + # 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 @@ -4539,48 +4550,168 @@ def _landed_a_ticket(state: RunState) -> bool: return any(task.phase in landed for task in state.tasks.values()) -def _close_drained_child( - project: Path, paths: bmadconfig.ProjectPaths, finished: runsetup.ComposedRun, parent: str +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 the child the finished run built ``status: done`` when ``next - `` reads it drained (``initiativechain.drained``), through - ``TicketTree.mark_initiative_done`` on a main-checkout tree pinned as - ``runs.tree_pin`` pins the run, journaled into its journal; then commit the - file 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. Returns None, or the reason the chain cannot go - on: ``store-commit-failed`` (also journaled). Tree faults raise.""" - state = finished.state - slug = state.initiative + """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. + + 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 - pin, _folder = runs.tree_pin(state, runs.paths_for_state(paths, state)) - assert pin is not None # a chained run pins its parent - tree = _chain_ticket_tree(project, pin) - child = tree.parent_next(parent_folder).child(slug) - if child is None or not initiativechain.drained(child): + 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 - mark = tree.mark_initiative_done(parent_folder, slug, journal=finished.journal) - if mark.outcome != "written": - return None - recorded = runs.live_store_topology(state, project) - topology = ticketstore.StoreTopology.from_dict(recorded) if recorded else None - 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 - committer = ticketstore.StoreCommitter(repo, paths.repo_root) - message = f"chore(tickets): {slug} done [bmad-loop {state.run_id}]" - try: - result = committer.commit(slug, [mark.file], message) - except ticketstore.StoreCommitError as e: - finished.journal.append("store-commit-failed", story_key=slug, error=str(e)) - return "store-commit-failed" - if result.foreign: - finished.journal.append( - "store-commit-foreign-dirt", story_key=slug, count=len(result.foreign) - ) - return "store-commit-failed" - finished.journal.append("store-committed", story_key=slug, commit=result.sha) + 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 + 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 @@ -4611,8 +4742,10 @@ def _drive_initiative_chain( returns 0 at once. Per switch, in order: a stop asked of the finished run ends the chain - (:func:`_chain_stop_requested`); a drained child is marked done - (:func:`_close_drained_child`) so its dependents become ready; the next child + (: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 @@ -4644,13 +4777,14 @@ def _drive_initiative_chain( if _chain_stop_requested(finished): return _chain_end(finished, parent, "stop-requested") try: - if (owed := _close_drained_child(project, paths, finished, parent)) is not None: + owed = _close_drained_children(project, paths, pol, parent, finished.journal, state) + if owed is not None: return _chain_end( finished, parent, - owed, - error=f"closing {state.initiative} could not be committed (see the " - f"journal of run {state.run_id}) — commit it, then run again", + "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: diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index ea5991f75..42f20e63b 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -318,6 +318,8 @@ }, "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 @@ -631,10 +633,13 @@ # `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. + # name. `initiative-close-skipped`'s `why` is a closed skip token (`run-live`, + # `run-unfinished`, `runs-unread`, `work-left`, `store-refused`) 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/tests/test_cli.py b/tests/test_cli.py index 8457315a5..4e8994654 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15994,6 +15994,18 @@ def _chain_config(sandbox): 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): @@ -16040,13 +16052,7 @@ def run(self): else: self.state.finished = True if outcome in ("drain", "stop-lodged", "stop-discarded"): - child = v7_layout(root, initiative=self.state.initiative) - closed = tuple(dataclasses.replace(e, status="done") for e in DEFAULT_TREE) - write_ticket_tree(child, closed) - 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 {self.state.initiative}") + _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") @@ -16357,6 +16363,182 @@ def test_run_on_a_parent_ends_naming_what_each_waiting_child_waits_on(project, m ) 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_chain_driver_ignores_a_chained_childs_sweep(project, monkeypatch): """A chained child's run-end sweep carries the parent too: resumed to finished, it is not a child run, so the driver neither closes, picks nor launches. diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 5e76bc741..fe51bb8fd 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1207,7 +1207,7 @@ def test_initiative_mark_aliases_slugs_and_drops_the_file_path(): def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): - """The chain's three kinds name the parent and child initiatives as scalars and + """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` @@ -1249,11 +1249,22 @@ def test_initiative_chain_rows_alias_every_slug_and_keep_their_tokens(): "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 = (diagnostics._scrub_entry(r, pseudo, {}, None) for r in rows) + 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]) + 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"] diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 1162d2a74..73f89508c 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -397,6 +397,7 @@ ("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, @@ -609,6 +610,9 @@ "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"}), @@ -2261,10 +2265,12 @@ "ticket-plan-restored", "initiative-mark", # cli.py — the initiative chain: per switch, the next child or the end in - # the finished run, and the chain link in the next run. + # 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", From 6fba40e6a965bea9d18f35e9ef25a5d6936f44d7 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 28 Oct 2026 20:32:13 -0700 Subject: [PATCH 6/7] fix(tickets): judge a chained child's store on its own initiative at recorded-run sites (DW-574) A recorded chained run (state.parent_initiative set) recorded its CHILD's store topology at launch (97364b98), but every reopen site still judged the live parent's store, so a child-store change went unseen, or the child was refused for the parent's layout. Each recorded-run site now passes initiative= to the store predicate. Unchained calls are unchanged: they never pass initiative. New helper cli._store_initiative(state): state.initiative when parent_initiative is set, else None. _reject_store_topology forwards initiative only when it is set. tui/app.py does not import cli, so it inlines the same conditional. Site classification (store_conflict / _reject_store_topology in src/): (a) recorded run, now judged on the child: - cli._prepare_resume_locked: the resume store gate - cli.cmd_resolve: the early gate ahead of --adopt-branch/--reverify, the pre-session gate, the post-confirm gate (3 sites) - cli._resolve_reverify_finished: the finished-run replay preflight - cli._sweep_factory's factory: the run-end sweep. It has no state, so it uses its parent_initiative closure with the re-rooted paths' active_initiative, as _launch_run does. - tui/app.py _ticket_replan_worker: the replan guard - tui/app.py _do_rearm: the re-arm guard (b) fresh parent-level launch, unchanged: - cli.cmd_run's once-only check - cli.cmd_sweep (a human sweep) - cli._warn_preflight_would_abort (the run/sweep dry-run banners; no state) - tui/app.py _guarded (the start-run modal; no state) (c) already correct: - cli._launch_run's per-child check - cli._reject_store_topology's own predicate call (the wrapper) Portability guard: no call-site counts changed, so STORE_TOPOLOGY_CALLERS is unchanged. Beyond the subject: - The resume comment notes that the store gate judges the child. - docs/reference/run-state.md: a chained child's store (and its sweep's) is classified on the child's folder on reopen. - The factory's kwargs dict is typed dict[str, Any] because pyright reads a str-valued spread as a possible `recorded` argument. Tests: chained/unchained rows for resume, the resolve early gate (both flags), the auto-sweep factory, the TUI replan and the TUI re-arm. Each asserts that the chained call carries initiative= and that the unchained call carries no initiative. Ablations performed: drop initiative= at the resume site, at the resolve early gate, in the factory, in the replan, and in the re-arm. Each chained row FAILS; all were reverted. The resolve pre-session/post-confirm sites and _resolve_reverify_finished have no dedicated row. --- docs/reference/run-state.md | 2 +- src/bmad_loop/cli.py | 35 ++++++++++-- src/bmad_loop/tui/app.py | 12 ++++ tests/test_cli.py | 109 ++++++++++++++++++++++++++++++++++++ tests/test_tui_app.py | 83 +++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 6 deletions(-) diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md index ccaff0210..983c4b3e1 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -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/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index fd63aa69b..2f0735480 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -394,6 +394,14 @@ def _reject_store_topology( 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.""" @@ -5513,8 +5521,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) @@ -5990,8 +6002,10 @@ def _prepare_resume_locked(project: Path, run_dir: Path, *, accept_baseline: boo 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. Ahead of every side effect: a - # config that no longer names the parent refuses here (BmadConfigError). + # 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 @@ -6008,7 +6022,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 @@ -7133,6 +7151,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 @@ -7228,6 +7247,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 @@ -7463,6 +7483,7 @@ 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 @@ -8142,7 +8163,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/tui/app.py b/src/bmad_loop/tui/app.py index 44fd9d4b3..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") @@ -1665,6 +1670,12 @@ def _do_rearm( 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( @@ -1673,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") diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e8994654..f56311d34 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6255,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 @@ -10377,6 +10417,38 @@ def factory(project_root, paths, digest, **kw): 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 ): @@ -22427,6 +22499,43 @@ def compose_sweep(**kw): 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_tui_app.py b/tests/test_tui_app.py index b37640379..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 From 6447f54003e5e9053b6a023b245b11e94d0ffba5 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 29 Oct 2026 18:54:24 -0700 Subject: [PATCH 7/7] fix(tickets): leave a drained child open while its file holds an uncommitted edit (DW-574) A drained child's close commits its whole initiative file, so an unstaged edit there was carried into the close commit: `run`'s worktree-clean gate runs after the pre-pick close, and a store with its own repository has no such gate at all. A versioned store whose child file differs from HEAD now skips the close as `uncommitted-edit`, and nothing is marked or committed. Also lists tests/test_initiativechain.py and tests/test_cli.py under the ticket-tree mode entry's Tested by. --- docs/FEATURES.md | 2 +- docs/reference/run-state.md | 2 +- src/bmad_loop/cli.py | 33 +++++++++++++++++++++++++++++++- src/bmad_loop/diagnostics.py | 5 +++-- tests/test_cli.py | 37 ++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 214adf413..373554693 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -60,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/run-state.md b/docs/reference/run-state.md index 983c4b3e1..ed7e1000f 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,7 +91,7 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null. -`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left` or `store-refused`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed, not journaled: no run exists yet. +`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise. A child run's `max_stories` is what was left of the chain's `--max-stories` when it launched. When `run`, or a resume of a child run, builds a parent's children, each finished child run journals the switch: `initiative-mark` and its store commit for each drained child closed, `initiative-close-skipped` (`parent`, `slug`, `why`: `run-live`, `run-unfinished`, `runs-unread`, `work-left`, `store-refused` or `uncommitted-edit`, `blocking_run`, `detail`) for each one left open, then `initiative-chain-next` (`parent`, `from_child`, `to_child`, and the children `passed_over` with `passed_over_why`) or `initiative-chain-end` (`parent`, `end`, the `waiting` children, one row per unmet prerequisite across `waiting_child`, `waiting_on` and `waiting_on_status`, and the passed-over ones). The next child's run journals `initiative-chain` (`parent`, `previous_run`, `previous_child`). The closes `run` makes before its first child's run are printed, not journaled: no run exists yet. A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on. The run-end sweep of a run that builds a child initiative works that child's ledger: its `initiative` and `initiative_folder` name the child and its `parent_initiative` the parent, and it is refused while the live `[core] active_initiative` names anything but the parent. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 2f0735480..d27c889d9 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -4652,7 +4652,11 @@ def _close_drained_children( (: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. + * ``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). @@ -4687,6 +4691,9 @@ def _close_drained_children( 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( @@ -4723,6 +4730,30 @@ def _close_drained_children( 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, diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 42f20e63b..bf9d21ab5 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -634,8 +634,9 @@ # `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`) and - # `blocking_run` a run id (or empty); `detail` is dropped by name. + # `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"}), diff --git a/tests/test_cli.py b/tests/test_cli.py index f56311d34..2e3cc937e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16611,6 +16611,43 @@ def test_pre_pick_close_skips_a_drained_child_whose_store_is_refused(project, mo 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.