diff --git a/AGENTS.md b/AGENTS.md index fbf2d9828..88758e3a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Things that break silently. Never violate; when in doubt, read the named module' - No LLM calls in the orchestrator control loop. - Sessions complete only on hook Stop events or window death — never on LLM prose. Never add another completion path. Post-session state is re-verified deterministically (`verify.py`). -- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. `TicketTree.mark` is the orchestrator's sole write path to a ticket plan's `status`/`blocked_*` in tickets mode, and `TicketTree.restore_preimage` the sole byte-exact restore of a pre-image captured by the same transaction; legal plan transitions live in `statemachine.py`; the build skill writes plan status itself and bmad-loop never marks while a session for that ticket is live. +- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. `TicketTree.mark` is the orchestrator's sole write path to a ticket plan's `status`/`blocked_*` in tickets mode, and `TicketTree.restore_preimage` the sole byte-exact restore of a pre-image captured by the same transaction; legal plan transitions live in `statemachine.py`; the build skill writes plan status itself and bmad-loop never marks while a session for that ticket is live. `TicketTree.mark_initiative_done` is the sole write path to a child initiative file's `status` (transition in `statemachine.py`): only for a drained child of a chained run, never while a session for that child is live. - All git subprocess calls in `src/bmad_loop` go through the `_run_git` chokepoint in `verify.py` (timeouts, `LC_ALL=C`) — no bare subprocess git; `tests/test_portability_guard.py` enforces it. Tests, `scripts/`, and CI workflows deliberately spawn their own git: a harness must not depend on the artifact it validates. - Every new policy field needs an entry in `src/bmad_loop/data/settings/core.toml` (a sync test enforces defaults/options match `policy.py`). New core env vars register in `envvars.py`; plugin-owned env-var families stay with their plugin. - Version strings are stamped only by `scripts/sync_version.py` from `src/bmad_loop/__init__.py` — never hand-edit pyproject.toml, module.yaml, marketplace.json, bmod.toml, or uv.lock versions. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c5c41f966..b249f0af5 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -50,7 +50,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches - **Description:** An opt-in third story source for BMAD v7 projects. It drives the same pipeline from the active initiative's ticket tree through the project's own `tickets.py`, dispatching each ticket as `/ ticket ` in initiative build order, and, under the default `mark_done`, marks a verified ticket `done` itself. - **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 is refused (`tickets.initiative-parent`), and an epic declared but not sliced yet holds no tickets. + - 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. - 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 800a20e6c..fa77f94cb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -557,7 +557,7 @@ Each task: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `tokens` (`i `epic` is always an integer. A unit of a source with no integer epics (stories, sweep, tickets) reports `0`; a ticket's epic is in its `ticket` object. -`tickets` (run level): `initiative` and `initiative_folder` (the pinned initiative), `epic_filter` (the `--epic` slug, or null), `store_topology` (`{layout, placement, versioning, store_root, store_repo}`, or null when none was recorded), `epic_closures` (epic slug → recorded closure verdict) and `epic_closures_acked` (epic slugs whose closure pause was resumed past). +`tickets` (run level): `initiative` and `initiative_folder` (the pinned initiative), `parent_initiative` (the parent initiative whose child the run builds, or null), `epic_filter` (the `--epic` slug, or null), `store_topology` (`{layout, placement, versioning, store_root, store_repo}`, or null when none was recorded), `epic_closures` (epic slug → recorded closure verdict) and `epic_closures_acked` (epic slugs whose closure pause was resumed past). `ticket` (per task): `ref`, `id`, `epic_slug`, `epic_id`, `title`, `plan` (the absolute plan path recorded at the pick), `done_pending` (bool: a shared-store `done` mark is still owed), `park_pending` (the owed park's reason, or null) and `commit_pending` (bool: under `mark_done = "never"`, the store-repository commit of the `built` plan is still owed). It is null for a task with no ticket. A plan's live status is not part of the document, because it is not in `state.json`; the text board reads it. @@ -712,11 +712,11 @@ Every finding's `check` is one of these ids. Severity is `ok`, `warning` or `pro | `tickets.uv-missing` | problem | tickets mode: `uv` is not on PATH (`bmad-build-auto` runs `tickets.py` with `uv run`) | | `tickets.initiative-unset` | problem | tickets mode: no `[core] active_initiative` is set | | `tickets.initiative-missing` | problem | tickets mode: the active initiative's folder does not exist | -| `tickets.initiative-parent` | problem | tickets mode: the active initiative is a parent initiative (its `tickets.toml` lists `[[initiative]]` children); set one child active | +| `tickets.initiative-parent` | ok / problem | tickets mode: the active initiative is a parent initiative; a problem unless worktree isolation runs without the auto retrospective | | `tickets.store-not-repo` | problem | tickets mode: the ticket store is a tracker, or its config cannot be read; bmad-loop drives repo stores only | | `tickets.store-topology` | ok / problem | tickets mode: the classified ticket-store layout; a problem for a store git cannot classify, or one tracked outside `repo_root` (worktree: the project) | | `tickets.tree-untracked` | problem | tickets mode: the initiative folder is only partly tracked, or neither tracked nor gitignored inside the code checkout | -| `tickets.own-repo-isolation` | problem | tickets mode, worktree isolation: a shared store needs the mount redirect, but `_bmad/custom/config.user.toml` is tracked or not gitignored | +| `tickets.own-repo-isolation` | problem | tickets mode, worktree isolation: the mount redirect (shared store, or a parent's child) needs `_bmad/custom/config.user.toml` untracked and ignored | | `tickets.workspace-unsupported` | problem | tickets mode: the project is not a git repository (a BMAD workspace); not supported yet | | `tickets.store-sandbox` | warning | tickets mode: sessions write a shared store outside their cwd while a role's `extra_args` replace the profile's bypass flags or a role runs sandboxed | | `tickets.branch-per-run` | warning | tickets mode: `[scm] branch_per = "run"` builds every ticket on one branch, which build-auto's branch-fit check may HALT | diff --git a/docs/reference/run-state.md b/docs/reference/run-state.md index 0efdb4714..eb8016f97 100644 --- a/docs/reference/run-state.md +++ b/docs/reference/run-state.md @@ -91,6 +91,8 @@ 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 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 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/docs/tickets-guide.md b/docs/tickets-guide.md index 2bb7728cf..d103c9f69 100644 --- a/docs/tickets-guide.md +++ b/docs/tickets-guide.md @@ -9,7 +9,7 @@ Tickets mode is **experimental**. It is built and tested against BMAD-METHOD mai 1. **Install BMAD v7 and bmad-loop.** Follow [Installing on BMAD v7](setup-guide.md#installing-on-bmad-v7-bmad-setup): `npx skills add bmad-code-org/bmad-loop`, then `/bmad setup bmad-loop`, then `/bmad-loop-setup`. The module record requires `bmad` and `bmad-build-auto` at 6.13.0 or newer and recommends `bmad-agent-po` and `bmad-retrospective`, which tickets mode uses. 2. **Plan the tree with Sofia.** Talk to Sofia, the Product Owner agent (`bmad-agent-po`): she creates the initiative, slices it into epics and stories, writes and refines the tickets, and readies them. She also replans mid-flight and runs the epic closure check. On v7 she replaces `bmad-spec`, `bmad-ticket` and `bmad-correct-course`. 3. **Select the source.** Set `[stories] source = "tickets"` in `.bmad-loop/policy.toml`. -4. **Pick an initiative.** `[core] active_initiative` in `_bmad/custom/config.user.toml` must name an initiative whose folder exists, and not a parent initiative. Sofia records the initiative she creates there, and the `bmad` skill shows, switches, creates or clears it, so you rarely edit the file by hand. +4. **Pick an initiative.** `[core] active_initiative` in `_bmad/custom/config.user.toml` must name an initiative whose folder exists. A parent initiative passes the preflight only under `[scm] isolation = "worktree"` without `gates.retrospective = "auto"`. Sofia records the initiative she creates there, and the `bmad` skill shows, switches, creates or clears it, so you rarely edit the file by hand. 5. **Use a repo store.** With no `_bmad/custom/ticketing-store-config.toml` the store is the repo store. Sofia writes that file only when you connect a tracker or switch stores, and it must then say `store = "repo"`: tracker stores are refused. 6. **Check it.** Run `bmad-loop validate --probes`. Every `run` repeats the same `tickets.*` preflight and refuses on any problem. @@ -19,7 +19,7 @@ Tickets mode is **experimental**. It is built and tested against BMAD-METHOD mai | `tickets.config-utils-missing` | `_bmad/scripts/config_utils.py` exists; `tickets.py` loads it. | | `tickets.uv-missing` | `uv` is on `PATH`. build-auto runs `tickets.py` with `uv run`. | | `tickets.initiative-unset`, `tickets.initiative-missing` | `active_initiative` is set and its folder exists. | -| `tickets.initiative-parent` | the active initiative is no parent initiative: a run builds one child at a time. | +| `tickets.initiative-parent` | the active initiative is no parent, unless worktree isolation runs without the auto retrospective. | | `tickets.store-not-repo` | the store is a repo store, not a tracker. | | `tickets.store-topology` and its siblings | the store layout is one tickets runs can drive (see [Store layouts](#store-layouts)). | | `tickets.branch-per-run` | a warning: `[scm] branch_per = "run"` puts every ticket on one branch. | @@ -39,7 +39,7 @@ An initiative folder holds its own `.md` and, in a `tickets/` folder bmad-loop reads each folder the way the installed `tickets.py` does: a folder with a `tickets/` folder inside it is read there, and any other folder is read as the preview-era flat layout. A preview-era tree therefore still runs with the preview-era `tickets.py` it was installed with. The current `tickets.py` refuses that layout; `bmad migrate method` moves the tree into `tickets/` folders. -A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiative]]` tables, is refused (`tickets.initiative-parent`): set `active_initiative` to one child. 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. +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. ## Store layouts diff --git a/pyproject.toml b/pyproject.toml index 45dc245c7..8875846d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,7 @@ strict = [ "src/bmad_loop/plugins/grants.py", "src/bmad_loop/worksource.py", "src/bmad_loop/ticketmodel.py", + "src/bmad_loop/initiativechain.py", "src/bmad_loop/ticketstore.py", "src/bmad_loop/usagereset.py", "src/bmad_loop/parked.py", diff --git a/src/bmad_loop/bmadconfig.py b/src/bmad_loop/bmadconfig.py index 674821b13..e530a036d 100644 --- a/src/bmad_loop/bmadconfig.py +++ b/src/bmad_loop/bmadconfig.py @@ -66,7 +66,7 @@ import errno import stat import tomllib -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Literal @@ -167,6 +167,29 @@ def rebased(self, new_root: Path) -> ProjectPaths: shared_store=self.shared_store, ) + def with_initiative(self, child: str) -> ProjectPaths: + """These paths re-rooted on ``child``, a child initiative of the active + (parent) one: its folder is the parent folder's sibling named ``child`` + (upstream ``tickets.py``'s ``folder.parent / slug``), and both artifact + dirs — so the ``deferred-work.md`` ledger and the sprint-status path with + them — move onto it, as :func:`_assemble_v7` places them for an active + initiative. ``active_initiative`` names the child; every other member is + kept. Pure: nothing is read, written or canonicalized. Raises ValueError + off v7, with no active initiative folder, or for a ``child`` that is not a + single folder name.""" + if self.era != "v7" or self.initiative_folder is None: + raise ValueError("with_initiative needs a v7 project with an active initiative folder") + if child in ("", ".", "..") or Path(child).name != child or "\\" in child: + raise ValueError(f"child initiative {child!r} is not a single folder name") + folder = self.initiative_folder.parent / child + return replace( + self, + implementation_artifacts=folder, + planning_artifacts=folder, + active_initiative=child, + initiative_folder=folder, + ) + def in_shared_store(self, path: Path) -> bool: """True when ``path`` is the shared ticket store or lies under it.""" store = self.shared_store diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index a29a09235..ea642e48d 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3248,6 +3248,7 @@ def _validate_tickets_mode_queue( sandboxed_roles=( _store_sandboxed_roles(pol, worksource.SOURCE_TICKETS) if pol is not None else () ), + retrospective=pol.gates.retrospective if pol is not None else None, ) ) tickets_probs = install.missing_tickets_support(project, dev_trees) @@ -4139,6 +4140,7 @@ def _check_tickets_preflight(paths: bmadconfig.ProjectPaths, pol) -> int | None: isolation=pol.scm.isolation, extra_args_roles=_store_extra_args_roles(pol, worksource.SOURCE_TICKETS), sandboxed_roles=_store_sandboxed_roles(pol, worksource.SOURCE_TICKETS), + retrospective=pol.gates.retrospective, ) for warning in (f for f in findings if f.severity == "warning"): print(f"warning: {warning.message}", file=sys.stderr) @@ -4693,7 +4695,7 @@ def _print_sprint_status(state: RunState, project: Path) -> None: """``status``'s queue footer for a sprint-status run: the board's remaining actionable stories, or nothing when the config or board cannot be read.""" try: - paths = bmadconfig.load_paths(project) + paths = runs.paths_for_state(bmadconfig.load_paths(project), state) ss = sprintstatus.load(paths.sprint_status) remaining = [s.key for s in ss.stories if s.status in sprintstatus.ACTIONABLE_STATUSES] print(f"sprint backlog remaining: {len(remaining)}") @@ -4727,11 +4729,15 @@ def _owed_post_landing_label(task: StoryTask) -> str: return "done" if task.ticket_done_pending else "built" -def _status_ticket_tree(state: RunState, project: Path) -> tickettree.TicketTreeLike: +def _status_ticket_tree( + project: Path, pinned_initiative: str | None, *, folder: Path | None = None +) -> tickettree.TicketTreeLike: """The tree ``status`` reads a tickets run's board from: the project's - installed ``tickets.py``, pinned to the run's initiative, so a board for - another initiative is never shown under this run.""" - return tickettree.TicketTree(project, pinned_initiative=state.initiative or None) + installed ``tickets.py``, pinned to the run's initiative (a chained run's + parent), so a board for another initiative is never shown under this run; + ``folder`` is a parent's child folder to read through + (:func:`runs.recorded_tree_pin`).""" + return tickettree.TicketTree(project, pinned_initiative=pinned_initiative, folder=folder) def _print_tickets_status(state: RunState, project: Path) -> None: @@ -4756,7 +4762,10 @@ def _print_tickets_status(state: RunState, project: Path) -> None: ) print(f"tickets: this run is limited to {selected}") try: - status_view = _status_ticket_tree(state, project).status() + pin, tree_folder = runs.recorded_tree_pin(state, project, project) + status_view = _status_ticket_tree(project, pin, folder=tree_folder).status() + except bmadconfig.BmadConfigError as e: + print(f"tickets board unavailable: {e}") except tickettree.TicketsError as e: print(f"tickets board unavailable: tickets.py {e.kind}: {e}") else: @@ -5411,6 +5420,11 @@ 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: @@ -5776,7 +5790,7 @@ 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 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 class _CtlLaunch: @@ -6242,7 +6256,7 @@ def _resolve_restore_patch( # is_relative_to(project) check would reject every legitimate restore there. tickets = state.source == worksource.SOURCE_TICKETS try: - paths = bmadconfig.load_paths(project, want_tickets=tickets) + paths = runs.paths_for_state(bmadconfig.load_paths(project, want_tickets=tickets), state) except bmadconfig.BmadConfigError as e: return None, f"cannot validate the restore patch path against the project config: {e}" # tickets mode: an earlier re-arm moved the plan's intent-gap patch to the run dir @@ -6490,7 +6504,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: # does; the resume's own config read refuses it. if state.run_type == "sweep": try: - pin_paths = bmadconfig.load_paths(project) + pin_paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except (bmadconfig.BmadConfigError, OSError): pin_paths = None if ( @@ -6510,7 +6524,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: # config read refuses it, as before. if args.resume is not False and not getattr(args, "abandon", False): try: - trust_paths = bmadconfig.load_paths(project) + trust_paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except (bmadconfig.BmadConfigError, OSError): trust_paths = None if ( @@ -6602,7 +6616,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: # only, no new failure mode: `load_paths` is a read, and the arm that cannot # read degrades exactly as it did when it sat lower. try: - pre_session_paths = bmadconfig.load_paths(project) + pre_session_paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except (bmadconfig.BmadConfigError, OSError): pre_session_code_root = state.code_root else: @@ -6812,7 +6826,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: # AFTER the confirm, deliberately: a cancelled resolve writes nothing, so the # divergence is still there for `resume` to report on its own terms. try: - paths = bmadconfig.load_paths(project) + paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except (bmadconfig.BmadConfigError, OSError) as e: paths = None # An observation, so it degrades: without the config this process cannot NAME @@ -7253,7 +7267,7 @@ def _resolve_abandon( print(f"error: {refusal}", file=sys.stderr) return 1 try: - paths = bmadconfig.load_paths(project) + paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except (bmadconfig.BmadConfigError, OSError) as e: print( f"error: cannot read the BMAD config to abandon run {args.run_id}: {e}", file=sys.stderr @@ -7532,7 +7546,7 @@ def _resolve_reverify_finished( print(f"error: {refusal}", file=sys.stderr) return 1 try: - paths = bmadconfig.load_paths(project) + paths = runs.paths_for_state(bmadconfig.load_paths(project), state) except bmadconfig.BmadConfigError as e: print(f"error: cannot read the BMAD config ({e})", file=sys.stderr) return 1 diff --git a/src/bmad_loop/diagnostics.py b/src/bmad_loop/diagnostics.py index 3f2185acb..0e7d9b685 100644 --- a/src/bmad_loop/diagnostics.py +++ b/src/bmad_loop/diagnostics.py @@ -304,6 +304,11 @@ "ticket-refine-pause": {"ref": "ticket"}, "ticket-pick-halt": {"ref": "ticket"}, "ticket-story-approved": {"ref": "ticket"}, + # A BMAD v7 initiative slug (the parent's and the closed child's folder names): + # the customer's initiative name, identifier-shaped, aliased in its own + # namespace so one initiative gets one alias. Kind-scoped: `parent` and `slug` + # are generic names. + "initiative-mark": {"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 @@ -595,6 +600,12 @@ # recovery withhold) write `cycle` and `bundles_not_run`; `reason` is a DROP # field and `story_keys` a `_JOURNAL_KEYLIST_FIELDS` name, both reached first. "sweep-bundles-withheld": frozenset({"cycle", "bundles_not_run", "reason", "story_keys"}), + # `tickettree.TicketTree.mark_initiative_done`: `file` is the child initiative + # file's absolute host path, which the by-name benign `file` (a code-constant + # basename elsewhere) would ship through `scrub_json`; left off the schema, it + # 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"}), } # Policy keys whose values can carry secrets/paths/free text. Dropped or reduced diff --git a/src/bmad_loop/documents.py b/src/bmad_loop/documents.py index 427d8911e..0edacb63b 100644 --- a/src/bmad_loop/documents.py +++ b/src/bmad_loop/documents.py @@ -286,14 +286,17 @@ def _ticket_projection(task: StoryTask) -> dict[str, object] | None: def _tickets_projection(state: RunState) -> dict[str, object] | None: - """The run-level ``tickets`` object: the initiative the run is pinned to, its - ``--epic`` selector, the classified ticket store and the epic closures it - recorded. None unless the run's source is ``tickets``.""" + """The run-level ``tickets`` object: the initiative the run is pinned to (and, + for a parent's child, the parent), its ``--epic`` selector, the classified + ticket store and the epic closures it recorded. None unless the run's source + is ``tickets``.""" if state.source != SOURCE_TICKETS: return None return { "initiative": state.initiative, "initiative_folder": state.initiative_folder, + # the parent initiative whose child this run builds, or null + "parent_initiative": state.parent_initiative, # the `--epic` selector as an epic slug, or null for every epic "epic_filter": state.ticket_epic_filter or None, # `ticketstore.StoreTopology.to_dict`, or null when none was recorded diff --git a/src/bmad_loop/initiativechain.py b/src/bmad_loop/initiativechain.py new file mode 100644 index 000000000..22aad9536 --- /dev/null +++ b/src/bmad_loop/initiativechain.py @@ -0,0 +1,84 @@ +"""Which child of a BMAD v7 parent initiative a tickets chain builds next. + +A parent initiative's ``tickets.toml`` lists ``[[initiative]]`` children, each a +folder beside the parent's; ``tickets.py next`` on the parent +(:func:`~bmad_loop.ticketmodel.parse_parent_next`) reports them in build order +with the ones it lists as ``ready`` — nothing left to wait on, and not done or +dropped. :func:`next_child` walks the ready children in that order and takes the +first one it can build. Pure: the view and the caller's probe are the only +inputs, and every child it does not take is reported, so the caller can journal +why. + +* A ready child whose folder does not exist yet (declared, not sliced) is passed + over as ``no-folder``; upstream counts it as ready, but there is no tree to + build from. +* ``passes_over``, when given, is asked about each ready child that has a folder + 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). + +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 +unbuilt was passed over, and ``all-done`` when every child is done or dropped +(or the parent lists none). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final, Literal + +from .ticketmodel import ChildInitiative, ParentView + +NO_FOLDER: Final = "no-folder" +"""The pass-over reason for a ready child whose folder does not exist yet.""" + +_CLOSED: Final[frozenset[str]] = frozenset(("done", "dropped")) + +ChainEnd = Literal["waiting", "passed-over", "all-done"] + + +@dataclass(frozen=True) +class PassedOver: + """A ready child :func:`next_child` did not take, and why.""" + + slug: str + why: 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.""" + + child: ChildInitiative | None + passed_over: tuple[PassedOver, ...] + waiting: tuple[str, ...] + end: ChainEnd | None + + +def next_child( + view: ParentView, + passes_over: Callable[[ChildInitiative], str | None] | None = None, +) -> ChildPick: + """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) + passed: list[PassedOver] = [] + for child in view.initiatives: + if not child.ready: + continue + why = NO_FOLDER if child.folder is None else None + 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) + 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) diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index d6c253b34..52b36d277 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -834,6 +834,8 @@ def _tickets_store_findings( isolation: str, extra_args_roles: Sequence[str], sandboxed_roles: Sequence[str] = (), + *, + chained: bool = False, ) -> list[Finding]: """The store-topology findings for the active initiative's folder (it exists): the classified layout (``tickets.store-topology``), or the refusal under the @@ -841,8 +843,10 @@ def _tickets_store_findings( ``tickets.store-sandbox`` warning when sessions write a shared store outside their cwd while a role's ``extra_args`` replace the profile's bypass flags or a role runs ``adapter.permission_mode = "sandboxed"``, whose sandbox confines - writes to the working directory.""" - result = ticketstore.assess_store(paths, isolation) + writes to the working directory. ``chained``: ``paths`` are re-rooted on a + child initiative of the active (parent) one, classified as a chained run + (:func:`ticketstore.classify_store`), and every finding names the child.""" + result = ticketstore.assess_store(paths, isolation, chained=chained) if result is None: return [] conflict = ticketstore.store_topology_conflict(result) @@ -851,14 +855,20 @@ def _tickets_store_findings( "repo_root": str(paths.repo_root), "isolation": isolation, } + prefix = "" + if chained: + detail["initiative"] = paths.active_initiative + prefix = f"child initiative {paths.active_initiative!r}: " if isinstance(result, ticketstore.StoreRefusal): assert conflict is not None - return [Finding(result.check, "problem", conflict, {**detail, "refusal": result.code})] + return [ + Finding(result.check, "problem", prefix + conflict, {**detail, "refusal": result.code}) + ] findings = [ Finding( "tickets.store-topology", "ok" if conflict is None else "problem", - conflict or f"ticket store {ticketstore.describe_topology(result)}", + prefix + (conflict or f"ticket store {ticketstore.describe_topology(result)}"), {**detail, "topology": result.to_dict()}, ) ] @@ -879,19 +889,110 @@ def _tickets_store_findings( Finding( "tickets.store-sandbox", "warning", - f"sessions write the shared ticket store {result.store_root} outside their " - f"working directory, and {'; '.join(causes)} — a sandboxed CLI may refuse " - "those writes", + f"{prefix}sessions write the shared ticket store {result.store_root} outside " + f"their working directory, and {'; '.join(causes)} — a sandboxed CLI may " + "refuse those writes", { "store_root": str(result.store_root), "roles": list(extra_args_roles), "sandboxed_roles": list(sandboxed_roles), + **({"initiative": paths.active_initiative} if chained else {}), }, ) ) return findings +def _tickets_parent_findings( + paths: bmadconfig.ProjectPaths, + children: Sequence[str], + isolation: str, + retrospective: str | None, + extra_args_roles: Sequence[str], + sandboxed_roles: Sequence[str], +) -> list[Finding]: + """The findings for an active initiative that is a parent of ``children``. + + Refused (a ``tickets.initiative-parent`` problem) unless ``[scm] isolation`` + is ``"worktree"`` — sessions in the main checkout read the user config, which + names the parent; only a worktree mount can name a child — and + ``gates.retrospective`` is not ``"auto"``, whose session runs in the main + checkout and reads the parent. Accepted, it is an ok line naming the children, + followed by :func:`_tickets_store_findings` for each child that has a folder + (``paths`` re-rooted on it, classified as chained); a child declared but not + sliced yet has none and is skipped. A child slug that is no single folder + name is refused, and a child folder that cannot be statted is a + ``tickets.initiative-missing`` problem naming the error.""" + listed = ", ".join(children) or "none listed" + detail: dict[str, object] = { + "initiative": paths.active_initiative, + "children": list(children), + "isolation": isolation, + "retrospective": retrospective, + } + causes: list[str] = [] + if isolation != "worktree": + causes.append( + f'`[scm] isolation = "{isolation}"` runs sessions in the main checkout, which ' + 'reads the parent from the user config — set `isolation = "worktree"`' + ) + if retrospective == "auto": + causes.append( + '`gates.retrospective = "auto"` runs its session in the main checkout, which ' + 'reads the parent — set it to "notify" or "never"' + ) + child_paths: list[bmadconfig.ProjectPaths] = [] + if not causes: + for child in children: + try: + child_paths.append(paths.with_initiative(child)) + except ValueError: + causes.append(f"child initiative {child!r} is not a single folder name") + if causes: + return [ + Finding( + "tickets.initiative-parent", + "problem", + f"active initiative {paths.active_initiative!r} is a parent initiative " + f"(children: {listed}) and {'; '.join(causes)} — or set `[core] " + "active_initiative` to one child (ask the `bmad` skill to switch it)", + detail, + ) + ] + store_findings: list[Finding] = [] + skipped: list[str] = [] + for child in child_paths: + folder = child.initiative_folder + assert folder is not None and child.active_initiative is not None # with_initiative + present, fault = _probe_fault(folder, "dir") + if fault: + store_findings.append( + _probe_unreadable("tickets.initiative-missing", str(folder), fault) + ) + elif not present: + skipped.append(child.active_initiative) + else: + store_findings.extend( + _tickets_store_findings( + child, isolation, extra_args_roles, sandboxed_roles, chained=True + ) + ) + message = ( + f"active initiative {paths.active_initiative!r} is a parent initiative " + f"(children: {listed}), accepted under worktree isolation" + ) + if skipped: + message += ( + f"; {', '.join(skipped)} {'has' if len(skipped) == 1 else 'have'} no folder " + "yet (declared, not sliced) and the store checks skip " + f"{'it' if len(skipped) == 1 else 'them'}" + ) + return [ + Finding("tickets.initiative-parent", "ok", message, {**detail, "skipped": skipped}), + *store_findings, + ] + + def tickets_preflight( paths: bmadconfig.ProjectPaths, trees: Sequence[str], @@ -901,19 +1002,25 @@ def tickets_preflight( isolation: str = "none", extra_args_roles: Sequence[str] = (), sandboxed_roles: Sequence[str] = (), + retrospective: str | None = None, ) -> list[Finding]: """Tickets mode's project checks, as :class:`Finding` s (``paths`` is the paths loaded for tickets mode, ``trees`` the dev skill trees, ``branch_per`` the policy's ``[scm] branch_per``; None skips its check; ``isolation`` the policy's ``[scm] isolation``; ``extra_args_roles`` the launch roles whose resolved adapter sets ``extra_args``; ``sandboxed_roles`` those whose - ``permission_mode`` is not bypass). + ``permission_mode`` is not bypass; ``retrospective`` the policy's + ``gates.retrospective``, None read as not ``"auto"``). Static checks, in order: the script (``tickets.script-missing``), the BMad config reader it imports (``tickets.config-utils-missing``), ``uv`` on PATH (``tickets.uv-missing``: bmad-build-auto runs the script through uv even when bmad-loop runs it directly), the active initiative (``tickets.initiative-unset`` - / ``tickets.initiative-missing``), the store topology + / ``tickets.initiative-missing``), a parent initiative + (``tickets.initiative-parent``, :func:`_tickets_parent_findings`: refused + unless ``isolation`` is ``"worktree"`` and ``retrospective`` is not + ``"auto"``, else an ok line and the store topology of each child that has a + folder), the store topology (:func:`_tickets_store_findings`: ``tickets.store-topology``, ``tickets.tree-untracked``, ``tickets.own-repo-isolation``, ``tickets.workspace-unsupported``, and the ``tickets.store-sandbox`` warning), @@ -1020,15 +1127,14 @@ def tickets_preflight( _probe_unreadable("tickets.initiative-parent", str(breakdown), parent_fault) ) elif children is not None: - findings.append( - Finding( - "tickets.initiative-parent", - "problem", - f"active initiative {paths.active_initiative!r} is a parent " - f"initiative (children: {', '.join(children) or 'none listed'}) " - "— set `[core] active_initiative` to one child (ask the `bmad` " - "skill to switch it)", - {"initiative": paths.active_initiative, "children": list(children)}, + findings.extend( + _tickets_parent_findings( + paths, + children, + isolation, + retrospective, + extra_args_roles, + sandboxed_roles, ) ) else: diff --git a/src/bmad_loop/model.py b/src/bmad_loop/model.py index ecba9d79b..110c6242b 100644 --- a/src/bmad_loop/model.py +++ b/src/bmad_loop/model.py @@ -1431,6 +1431,12 @@ class RunState: # the pin existed. initiative: str = "" initiative_folder: str = "" + # BMAD v7 tickets runs only: the parent initiative whose child this run + # builds (`initiative`/`initiative_folder` then name the child, a sibling + # folder of the parent's), or None for a run that builds the active + # initiative itself — every run today, and every state.json written before + # the field existed. + parent_initiative: str | None = None # tickets mode only. `ticket_epic_filter`: the `--epic` selector as an epic # slug ("" = every epic). `ticket_story_approvals`: refs whose refined # `plan_checkpoint` story file an operator approved this run, so the pick @@ -1677,6 +1683,7 @@ def to_dict(self) -> dict[str, Any]: "spec_folder": self.spec_folder, "initiative": self.initiative, "initiative_folder": self.initiative_folder, + "parent_initiative": self.parent_initiative, "ticket_epic_filter": self.ticket_epic_filter, "ticket_story_approvals": list(self.ticket_story_approvals), "ticket_epic_closures": dict(self.ticket_epic_closures), @@ -1729,6 +1736,7 @@ def from_dict(cls, d: dict[str, Any]) -> "RunState": spec_folder=str(d.get("spec_folder", "")), initiative=str(d.get("initiative", "")), initiative_folder=str(d.get("initiative_folder", "")), + parent_initiative=str(d.get("parent_initiative") or "") or None, ticket_epic_filter=str(d.get("ticket_epic_filter", "")), ticket_story_approvals=[str(r) for r in d.get("ticket_story_approvals", [])], ticket_epic_closures={ diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index cb1f28041..8638c9365 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -3721,7 +3721,9 @@ def held_sweep_closes(project: Path, run_dir: Path) -> list[str]: return [] try: paths = ticketstore.with_shared_store( - bmadconfig.load_paths(project, want_tickets=resolves_as_tickets(state)), + paths_for_state( + bmadconfig.load_paths(project, want_tickets=resolves_as_tickets(state)), state + ), live_store_topology(state, project), ) except Exception as e: # a config or topology fault is "unknown", never "none" @@ -4578,7 +4580,7 @@ def unreadable_sweep_ledger(project: Path, run_dir: Path) -> str | None: """ try: state = load_state(run_dir) - paths = bmadconfig.load_paths(project) + paths = paths_for_state(bmadconfig.load_paths(project), state) except Exception: # Broad on purpose: a missing/corrupt state.json, a missing BMAD config and # a run cleanup removed mid-command all already have owners downstream @@ -4817,6 +4819,84 @@ def resolves_as_tickets(state: RunState) -> bool: return state.source == worksource.SOURCE_TICKETS +def paths_for_state(paths: bmadconfig.ProjectPaths, state: RunState) -> bmadconfig.ProjectPaths: + """``paths`` (``bmadconfig.load_paths`` output) as the recorded run ``state`` + sees them: re-rooted on its child initiative for a chained run + (``state.parent_initiative`` set — ``ProjectPaths.with_initiative``, so its + ledger, sprint path and artifact dirs are the child's), else ``paths`` itself. + + A chained run reopens only while the live ``[core] active_initiative`` still + names its parent: re-rooted on any other initiative, the child folder would be + located from a config the run never launched under, and an engine handed the + loaded paths unchanged would read and write the other initiative's ledger and + sprint path before its tree pin could pause it. So a moved live value — or + paths that cannot be re-rooted (off v7, no initiative folder, a child slug that + is no folder name) — raises :class:`~bmad_loop.bmadconfig.BmadConfigError`, + the type every caller of ``load_paths`` already refuses or degrades on, naming + the value to restore. Pure: nothing is read.""" + parent = state.parent_initiative + if parent is None: + return paths + if paths.active_initiative != parent: + live = repr(paths.active_initiative) if paths.active_initiative else "unset" + raise bmadconfig.BmadConfigError( + f"run {state.run_id} builds child initiative {state.initiative!r} of parent " + f"initiative {parent!r}, but `[core] active_initiative` is {live} — set it " + f"back to {parent!r} to reopen this run" + ) + try: + return paths.with_initiative(state.initiative) + except ValueError as e: + raise bmadconfig.BmadConfigError( + f"run {state.run_id}: cannot locate child initiative {state.initiative!r} " + f"of parent initiative {parent!r}: {e}" + ) from e + + +def tree_pin(state: RunState, paths: bmadconfig.ProjectPaths) -> tuple[str | None, Path | None]: + """``(pinned_initiative, folder)`` for a main-checkout tree of the run + ``state`` over ``paths``. A chained run (``state.parent_initiative`` set) pins + the parent — the live ``[core] active_initiative`` still names it — and + reads and writes through the child's folder (``paths`` are the child's: + :func:`paths_for_state`); any other run pins its own initiative and reads it + directly. A worktree mount's tree is never chained: its config names the + child (``ticketstore.redirect_mount_config``). + + Raises ValueError on a chained ``state`` whose ``paths`` are not re-rooted on + its child — ``active_initiative`` not the child, or an initiative folder not + named for it: the parent's own paths would route the tree through the + parent's folder and read and mark the wrong tree.""" + if state.parent_initiative is None: + return state.initiative or None, None + folder = paths.initiative_folder + if ( + paths.active_initiative != state.initiative + or folder is None + or (folder.name != state.initiative) + ): + raise ValueError( + f"run {state.run_id} builds child initiative {state.initiative!r} of " + f"{state.parent_initiative!r}, but its paths are rooted on initiative " + f"{paths.active_initiative!r} ({folder}) — re-root them with paths_for_state" + ) + return state.parent_initiative, folder + + +def recorded_tree_pin(state: RunState, project: Path, root: Path) -> tuple[str | None, Path | None]: + """``(pinned_initiative, folder)`` for a tree of the recorded run ``state`` + built at ``root``, as :meth:`TicketsEngine._workspace_ticket_tree` pins its + trees: in the main checkout ``project`` (``root == project``), :func:`tree_pin` + over the run's own paths (:func:`paths_for_state` over ``project``'s config, + loaded for a chained run only); in a worktree mount, the run's initiative with + no folder, since the mount's config names the child. Raises + :class:`~bmad_loop.bmadconfig.BmadConfigError` when a chained run's config + cannot be read or no longer names its parent.""" + if state.parent_initiative is None or root != project: + return state.initiative or None, None + paths = bmadconfig.load_paths(project, want_tickets=True) + return tree_pin(state, paths_for_state(paths, state)) + + def live_store_topology(state: RunState, project_root: Path) -> dict[str, str | None]: """`state.store_topology` located on the live project: its `store_root` and `store_repo` moved with a renamed project (`rebase_recorded_project_path`), so a @@ -5714,12 +5794,13 @@ def _redrive_reads_the_upstream_artifacts(state: RunState) -> bool: def _rearm_ticket_tree( - project_root: Path, pinned_initiative: str | None + project_root: Path, pinned_initiative: str | None, *, folder: Path | None = None ) -> tickettree.TicketTreeLike: """The tree a tickets-mode re-arm marks its plan through: the installed ``tickets.py`` under ``project_root`` (the plan's live root), pinned to the run's - initiative. Tests replace it with a ``FakeTicketTree``.""" - return tickettree.TicketTree(project_root, pinned_initiative=pinned_initiative) + initiative, through a parent's child ``folder`` when given. Tests replace it + with a ``FakeTicketTree``.""" + return tickettree.TicketTree(project_root, pinned_initiative=pinned_initiative, folder=folder) def _rearm_ticket_plan( @@ -6686,6 +6767,19 @@ def _rearm_escalation_locked( # is where the run was launched and nothing re-stamps it, so after a project move it # names a directory that is no longer there. See the `project_root` note above. live_project = project_root if project_root is not None else Path(state.project) + # tickets mode: the pin of the tree the plan's status moves through, resolved + # before the first write — a chained run whose config no longer names its parent + # refuses here with the escalation still armed, not inside the transaction below. + ticket_pin: tuple[str | None, Path | None] = (state.initiative or None, None) + if state.source == worksource.SOURCE_TICKETS and task.ticket is not None and task.spec_file: + try: + ticket_pin = recorded_tree_pin( + state, live_project, live_spec_root(task, state, live_project) + ) + except bmadconfig.BmadConfigError as e: + raise RearmError( + f"cannot pin ticket {task.ticket.ref}'s tree to re-open its plan: {e}" + ) from e journal = _RearmJournal(run_dir, pin=state) # DW-446: reconcile the mint-time root identities before the first pinned write @@ -6995,8 +7089,9 @@ def _rearm_escalation_locked( ) from e spec_before = None if state.source == worksource.SOURCE_TICKETS and task.ticket is not None: + pin, folder = ticket_pin ticket_tree = _rearm_ticket_tree( - live_spec_root(task, state, live_project), state.initiative or None + live_spec_root(task, state, live_project), pin, folder=folder ) try: if ticket_tree is not None and task.ticket is not None: @@ -7690,7 +7785,7 @@ def _reverify_root_refusal( the caller locates it — the finished-run replay rebases it onto a moved project; ``None`` reads it as recorded.""" try: - paths = bmadconfig.load_paths(project_root) + paths = paths_for_state(bmadconfig.load_paths(project_root), state) except bmadconfig.BmadConfigError as e: return None, f"cannot read the BMAD config to locate the code root ({e})" if str(paths.repo_root) != str(state.code_root if code_root is None else code_root): diff --git a/src/bmad_loop/statemachine.py b/src/bmad_loop/statemachine.py index f3b4fba3d..11117cd4d 100644 --- a/src/bmad_loop/statemachine.py +++ b/src/bmad_loop/statemachine.py @@ -7,7 +7,9 @@ when its caller opts in explicitly (:func:`check_board_regression`). :data:`PLAN_TRANSITIONS` is the BMAD v7 ticket-plan side: every ``status`` write ``tickettree.TicketTree.mark`` makes names a :data:`PlanWriteReason`, and -:func:`check_plan_transition` decides it. +:func:`check_plan_transition` decides it. :func:`check_initiative_close` decides +the one write bmad-loop makes to a child initiative's own file +(``tickettree.TicketTree.mark_initiative_done``). """ from __future__ import annotations @@ -252,3 +254,53 @@ def refuse(why: str) -> PlanTransitionRefused: return True allowed = sorted(rule.sources) + (["blocked (operator park)"] if rule.parked else []) raise refuse(f"{reason} writes only from {allowed}") + + +# ---- BMAD v7 child initiatives ----------------------------------------------- +# +# A child initiative's status is its own file's frontmatter `status` (`""`, +# `in-progress`, `done` or `dropped`), and `tickets.py` has no verb that writes it. +# A chained run closes a drained child itself so the children after it stop +# waiting: the only write is to `done`, and only once every ticket is closed. + +INITIATIVE_DONE: Final = "done" +# The ticket states that count as closed for a drain (upstream's `next` reads the +# same two as finished). +TICKET_CLOSED_STATES: Final = frozenset({"done", "dropped"}) +_INITIATIVE_OPEN: Final = frozenset({"", "in-progress"}) + + +class InitiativeCloseRefused(IllegalTransition): + """A child-initiative close :func:`check_initiative_close` does not allow; + nothing was written.""" + + def __init__(self, slug: str, current: str, why: str) -> None: + super().__init__( + f"{slug}: initiative {current or '(no status)'} -> {INITIATIVE_DONE} is not a " + f"legal close: {why}" + ) + self.slug = slug + self.current = current + self.why = why + + +def check_initiative_close(slug: str, current: str, *, total: int, closed: int) -> bool: + """Decide closing child initiative ``slug``: True to write ``done``, False for + a no-op, else raise :class:`InitiativeCloseRefused`. + + ``current`` is the file's normalized ``status`` (``""`` when unset); ``total`` + and ``closed`` count the child's tickets and those done or dropped. An + already-``done`` child is a no-op; a ``dropped`` one is never reopened or + overridden; an open one (unset or ``in-progress``) is written only when drained: + at least one ticket and every ticket closed.""" + if current == INITIATIVE_DONE: + return False + if current == "dropped": + raise InitiativeCloseRefused(slug, current, "a dropped initiative is final") + if current not in _INITIATIVE_OPEN: + raise InitiativeCloseRefused(slug, current, f"closes only from {sorted(_INITIATIVE_OPEN)}") + if total <= 0 or closed != total: + raise InitiativeCloseRefused( + slug, current, f"not drained: {closed} of {total} tickets done or dropped" + ) + return True diff --git a/src/bmad_loop/ticketmodel.py b/src/bmad_loop/ticketmodel.py index ed6da9072..ecc0523db 100644 --- a/src/bmad_loop/ticketmodel.py +++ b/src/bmad_loop/ticketmodel.py @@ -30,9 +30,14 @@ reads as refined. A parent initiative (one whose ``tickets.toml`` lists ``[[initiative]]`` -children) answers ``status``/``next`` with its children instead of tickets; -:class:`TicketsParentInitiative` refuses that document, since a run builds one -child initiative at a time. +children) answers ``status``/``next`` with its children instead of tickets +(upstream ``parent_view``); :class:`TicketsParentInitiative` refuses that +document from :func:`parse_next`/:func:`parse_status`, since a run builds one +child initiative at a time. :func:`parse_parent_next` reads ``next`` on a +parent as a :class:`ParentView`: each child in build order, with its folder +(None for a child declared but not created yet, which then carries no +``status``/``counts``), its ``after`` edges, the prerequisites it is still +``waiting_on``, and whether upstream lists it as ``ready``. ``parse_help`` and :func:`capability_problems` gate the installed script on the verbs and ``mark`` statuses bmad-loop drives, from its ``--help`` output (the @@ -88,6 +93,10 @@ ) _ROW_STATUSES: Final[frozenset[str]] = frozenset(("", *PLAN_STATUSES)) +# An initiative or epic file's own `status` (`tickets.py` CONTAINER_STATUSES); +# `""` when the file sets none. +CONTAINER_STATUSES: Final[tuple[str, ...]] = ("in-progress", "done", "dropped") +_CONTAINER_STATUSES: Final[frozenset[str]] = frozenset(("", *CONTAINER_STATUSES)) class TicketsSchemaError(ValueError): @@ -232,6 +241,56 @@ class StatusView: backlog_error: str | None = None +@dataclass(frozen=True) +class InitiativeEdge: + """One declared ``after`` of a parent's ``[[initiative]]`` table: the child + slug it needs (upstream resolves an id to the slug) and why.""" + + initiative: str + needs: str + + +@dataclass(frozen=True) +class ChildInitiative: + """One child of a parent initiative, as ``parent_view`` prints it. + + ``folder`` is the child's folder name (a sibling of the parent's), or None + when the parent declares the child and its folder does not exist yet: such a + child has no ``status`` (``""``) and no ``counts`` (empty). ``status`` is the + child's own file's (``""`` when unset); ``waiting_on`` names the ``after`` + children whose status is not ``done``. ``ready`` is whether ``next`` listed + the child: nothing to wait on and not done or dropped — a folder-less child + included.""" + + id: str + slug: str + title: str + after: tuple[InitiativeEdge, ...] + folder: str | None + status: str + counts: Mapping[str, int] + waiting_on: tuple[str, ...] + ready: bool + + +@dataclass(frozen=True) +class ParentView: + """``next`` on a parent initiative: its children in build order (the + ``[[initiative]]`` table's order), the slugs ``next`` lists as ``ready`` in + that order, and upstream's ``order_conflict`` (an ``after`` naming a child + later in the order), kept opaque.""" + + store: str + folder: str + initiatives: tuple[ChildInitiative, ...] + ready: tuple[str, ...] + order_conflict: tuple[Mapping[str, object], ...] + + def child(self, slug: str) -> ChildInitiative | None: + """The child named ``slug``, or None when the parent lists no such child.""" + return next((c for c in self.initiatives if c.slug == slug), None) + + @dataclass(frozen=True) class FindResult: """``find ``: the one row a reference names, plus where it lives. @@ -561,6 +620,66 @@ def parse_status(doc: object, *, store: str | None = None) -> StatusView: ) +def _child(doc: object, command: str, ready: frozenset[str]) -> ChildInitiative: + d = _obj(doc, command, "initiatives") + edges: list[InitiativeEdge] = [] + for raw in _list(_need(d, command, "after"), command, "after"): + e = _obj(raw, command, "after") + edges.append( + InitiativeEdge( + initiative=_str(e, command, "initiative"), needs=_str(e, command, "needs") + ) + ) + slug = _str(d, command, "slug") + folder = _opt_str(d, command, "folder") + status = "" + counts: Mapping[str, int] = {} + # Upstream adds `status` and `counts` only once the child's folder exists. + if folder is not None: + status = _str(d, command, "status") + if status not in _CONTAINER_STATUSES: + raise TicketsSchemaError(command, "status", f"unknown initiative status {status!r}") + counts = _counts(d, command) + return ChildInitiative( + id=_scalar_text(_need(d, command, "id"), command, "id"), + slug=slug, + title=_str(d, command, "title"), + after=tuple(edges), + folder=folder, + status=status, + counts=counts, + waiting_on=_texts(d, command, "waiting_on", required=True), + ready=slug in ready, + ) + + +def parse_parent_next(doc: object) -> ParentView: + """``next``'s document on a parent initiative (upstream ``parent_view`` plus + ``ready``). Any other document — a plain initiative's ``next``, or a + parent's ``status``, which carries no ``ready`` — is a schema error, as is a + ``ready`` slug the parent does not list or two children with one slug.""" + command = "next" + d = _obj(doc, command, "document") + ready = _texts(d, command, "ready", required=True) + children = tuple( + _child(raw, command, frozenset(ready)) + for raw in _list(_need(d, command, "initiatives"), command, "initiatives") + ) + slugs = [c.slug for c in children] + if len(set(slugs)) != len(slugs): + raise TicketsSchemaError(command, "initiatives", f"a slug is listed twice: {slugs}") + unknown = [s for s in ready if s not in slugs] + if unknown: + raise TicketsSchemaError(command, "ready", f"names no listed initiative: {unknown}") + return ParentView( + store=_str(d, command, "store"), + folder=_str(d, command, "folder"), + initiatives=children, + ready=ready, + order_conflict=_objects(d, command, "order_conflict"), + ) + + def parse_find(doc: object) -> FindResult: """``find``'s document: a row plus where the ticket lives.""" command = "find" diff --git a/src/bmad_loop/tickets_engine.py b/src/bmad_loop/tickets_engine.py index 6ad880621..9fc1d0383 100644 --- a/src/bmad_loop/tickets_engine.py +++ b/src/bmad_loop/tickets_engine.py @@ -5,10 +5,15 @@ ``tickets.py`` (:class:`~bmad_loop.tickets_source.TicketsWorkSource`, whose pure :func:`~bmad_loop.tickets_source.decide_pick` holds the selection rules). The tree is reached only through a :class:`~bmad_loop.tickettree.TicketTreeLike`, which -``ticket_tree_factory`` builds from ``(project_root, pinned_initiative)`` — the +``ticket_tree_factory`` builds from ``(project_root, pinned_initiative)`` (and a +child ``folder``) — the real :class:`~bmad_loop.tickettree.TicketTree` by default, an in-memory fake in tests. The tree is pinned to ``RunState.initiative``, so a resume never builds -another initiative's ticket. +another initiative's ticket; a chained run, which builds one child initiative of +the parent ``RunState.parent_initiative``, pins the parent and reads the child +through its folder (:func:`~bmad_loop.runs.tree_pin`, re-exported here); its paths +must be re-rooted on the child (:func:`~bmad_loop.runs.paths_for_state`), and +parent-rooted ones are refused at construction. The unit key is the ticket ref (``E.M``); every unit's epic is 0 and the epic lifecycle is keyed by slug here, not by the base engine's numbered epics. @@ -94,10 +99,10 @@ import re import stat import time -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, NoReturn +from typing import Any, NoReturn, Protocol from . import deferredwork, gates, ticketlayout, ticketstore, verify from .adapters.base import SessionResult, SpecSnapshot @@ -127,7 +132,12 @@ result_mapping, ) from .recovery_flow import OwnedPlanRefused, OwnedPlanWriter -from .runs import graceful_stop_requested, live_store_topology, rebase_recorded_project_path +from .runs import ( + graceful_stop_requested, + live_store_topology, + rebase_recorded_project_path, + tree_pin, +) from .statemachine import OPERATOR_PARK_PREFIX, PlanWriteReason, advance from .ticketmodel import FindResult, StatusView, TicketRow from .tickets_source import ( @@ -175,8 +185,15 @@ from .worksource import Halt as WorkHalt from .worksource import WorkRef, WorkSource -TicketTreeFactory = Callable[[Path, str | None], TicketTreeLike] -"""Builds the run's tree from ``(project_root, pinned_initiative)``.""" + +class TicketTreeFactory(Protocol): + """Builds the run's tree from ``(project_root, pinned_initiative)``, and + ``folder``: a parent initiative's child folder the tree reads and writes + through, or None for the active initiative (see :func:`~bmad_loop.runs.tree_pin`).""" + + def __call__( + self, project_root: Path, pinned_initiative: str | None, *, folder: Path | None = None + ) -> TicketTreeLike: ... def _pick_scope(state: RunState) -> PickScope: @@ -296,9 +313,12 @@ def _with_trailers(message: str, trailers: list[str]) -> str: return body + "\n\n" + "\n".join(trailers) -def default_ticket_tree(project_root: Path, pinned_initiative: str | None) -> TicketTreeLike: - """The project's installed ``tickets.py``, pinned to ``pinned_initiative``.""" - return TicketTree(project_root, pinned_initiative=pinned_initiative) +def default_ticket_tree( + project_root: Path, pinned_initiative: str | None, *, folder: Path | None = None +) -> TicketTreeLike: + """The project's installed ``tickets.py``, pinned to ``pinned_initiative``; + ``folder`` routes every read and write through a parent's child folder.""" + return TicketTree(project_root, pinned_initiative=pinned_initiative, folder=folder) class _TicketPlanWriter: @@ -359,6 +379,10 @@ def __init__( # redirected to it. Read from the topology the run recorded at its start, # moved with a renamed project. state, paths = kwargs.get("state"), kwargs.get("paths") + if state is not None and paths is not None: + # a chained run's paths must be its child's (`runs.paths_for_state`): + # parent-rooted ones raise here, before the base engine touches them + tree_pin(state, paths) if state is not None and paths is not None and state.store_topology: kwargs["paths"] = ticketstore.with_shared_store( paths, live_store_topology(state, paths.project) @@ -379,11 +403,11 @@ def store_topology(self) -> ticketstore.StoreTopology | None: @property def ticket_tree(self) -> TicketTreeLike: - """The run's tree, built once from the main checkout and the pin.""" + """The run's tree, built once from the main checkout, the pin and, for a + chained run, the child folder (:func:`~bmad_loop.runs.tree_pin`).""" if self._ticket_tree is None: - self._ticket_tree = self._ticket_tree_factory( - self.paths.project, self.state.initiative or None - ) + pin, folder = tree_pin(self.state, self.paths) + self._ticket_tree = self._ticket_tree_factory(self.paths.project, pin, folder=folder) return self._ticket_tree def _make_work_source(self) -> WorkSource: @@ -1714,11 +1738,13 @@ def _verify_review(self, task: StoryTask): def _workspace_ticket_tree(self) -> TicketTreeLike: """The tree that owns the active workspace's plans: the run's own in place, one built for the unit worktree's project under isolation (its - ``find`` names the worktree's copy of the plan).""" + ``find`` names the worktree's copy of the plan). A mount's tree is pinned + to ``state.initiative`` with no folder even on a chained run: the mount's + config names the child as its active initiative.""" project = self.workspace.paths.project if project == self.paths.project: return self.ticket_tree - return self._ticket_tree_factory(project, self.state.initiative or None) + return self._ticket_tree_factory(project, self.state.initiative or None, folder=None) def _spec_status_write( self, diff --git a/src/bmad_loop/ticketstore.py b/src/bmad_loop/ticketstore.py index 2929e03b9..87448b9d6 100644 --- a/src/bmad_loop/ticketstore.py +++ b/src/bmad_loop/ticketstore.py @@ -23,8 +23,12 @@ A store no checkout carries (T2–T4, T6) is SHARED: under worktree isolation the mount's highest config layer, ``_bmad/custom/config.user.toml``, is rewritten to point ``[core] output_folder`` at the main checkout's store -(:func:`redirect_mount_store`), so ``tickets.py`` and the renderer inside the mount -resolve the one store every session reads and writes. +(:func:`redirect_mount_config`), so ``tickets.py`` and the renderer inside the mount +resolve the one store every session reads and writes. A CHAINED run (one child +initiative of a parent, the live ``active_initiative`` naming the parent) gets the +same rewrite of ``[core] active_initiative`` to the child, on every layout, so +inside the mount the child is a plain active initiative; the user's own file in the +main checkout is never written. Two parts. The PURE part (pyright-strict) holds the data — :class:`StoreFacts`, :class:`StoreTopology`, :class:`StoreRefusal` — the classifier @@ -32,7 +36,7 @@ refusal text, the :func:`bmadconfig.worktree_isolation_conflict` pattern) and the config rewrite :func:`redirected_user_config_text`. The PROBE part reads git and the disk: :func:`probe_store_facts` (every git call through ``verify``'s -``_run_git`` chokepoint), :func:`assess_store` and :func:`redirect_mount_store`. +``_run_git`` chokepoint), :func:`assess_store` and :func:`redirect_mount_config`. A store with its own repository (T3/T4) is committed by :class:`StoreCommitter`: the marked plan (and the ledger or retro document the run wrote there) alone, by @@ -218,7 +222,9 @@ def _within(path: Path, root: Path) -> bool: return path == root or path.is_relative_to(root) -def classify_store(facts: StoreFacts, isolation: str) -> StoreTopology | StoreRefusal: +def classify_store( + facts: StoreFacts, isolation: str, *, chained: bool = False +) -> StoreTopology | StoreRefusal: """The layout ``facts`` describe under ``[scm] isolation``, or why it is refused. - The project is not a repository: a workspace (T5), deferred. @@ -234,7 +240,10 @@ def classify_store(facts: StoreFacts, isolation: str) -> StoreTopology | StoreRe A shared layout under worktree isolation needs the redirect, so it also needs :data:`USER_CONFIG_REL` untracked and gitignored: the rewrite must neither ride - a commit nor dirty the mount.""" + a commit nor dirty the mount. A ``chained`` run (one child initiative of a + parent) under worktree isolation rewrites the mount's ``active_initiative`` + whatever the layout, so it needs the same of every layout, T1 included; without + isolation there is no mount and nothing is rewritten.""" initiative = facts.initiative if not facts.project_is_repo or facts.repo_toplevel is None: return StoreRefusal("workspace-unsupported", facts.project) @@ -261,14 +270,15 @@ def classify_store(facts: StoreFacts, isolation: str) -> StoreTopology | StoreRe return StoreRefusal("tracked-outside-checkout", initiative, str(facts.repo_root)) if isolation == "worktree" and not _within(initiative, facts.project): return StoreRefusal("tracked-outside-project", initiative) - return StoreTopology("T1", "checkout", "code-repo", facts.store_root) - if any(states): + result = StoreTopology("T1", "checkout", "code-repo", facts.store_root) + elif any(states): untracked = ", ".join(rel for rel, tracked in facts.tracked if not tracked) return StoreRefusal("partial-tracking", initiative, untracked) - if not facts.ignored: + elif not facts.ignored: return StoreRefusal("store-not-ignored", initiative, "an untracked folder") - result = StoreTopology("T2", shared, "none", facts.store_root) - if result.needs_redirect(isolation): + else: + result = StoreTopology("T2", shared, "none", facts.store_root) + if result.needs_redirect(isolation) or (chained and isolation == "worktree"): if facts.user_config_tracked: return StoreRefusal("user-config-tracked", facts.project / USER_CONFIG_REL) if not facts.user_config_ignored: @@ -380,8 +390,9 @@ def _refusal_text(refusal: StoreRefusal) -> str: state = "tracked by" if code == "user-config-tracked" else "not gitignored in" return ( f'isolation = "worktree" points each unit worktree at the shared ticket ' - f"store by rewriting {path}, which is {state} the code repository — the " - "rewrite would show up as a change in the unit's commit. Untrack it and " + f"store or a chained run's child initiative by rewriting {path}, which is " + f"{state} the code repository — the rewrite would show up as a change in " + "the unit's commit. Untrack it and " "keep `*.user.toml` in _bmad/custom/.gitignore (as `bmad setup` writes " 'it), or set `isolation = "none"`' ) @@ -389,7 +400,6 @@ def _refusal_text(refusal: StoreRefusal) -> str: _SECTION_RE = re.compile(r"^\s*\[(?P[^\]]+)\]\s*(?:#.*)?$") -_OUTPUT_FOLDER_RE = re.compile(r"^\s*output_folder\s*=") def _toml_basic_string(value: str) -> str: @@ -397,18 +407,52 @@ def _toml_basic_string(value: str) -> str: return f'"{escaped}"' -def redirected_user_config_text(text: str, main_store: Path) -> str: +def _core_values(main_store: Path | None, initiative: str | None) -> dict[str, str]: + """The ``[core]`` keys a mount rewrite sets, in the order they are written. + Raises ValueError when neither is given (a caller fault) and + :class:`TicketStoreError` on a blank ``initiative``, which would read as no + active initiative.""" + values: dict[str, str] = {} + if main_store is not None: + values["output_folder"] = str(main_store) + if initiative is not None: + if not initiative.strip(): + raise TicketStoreError( + "a blank initiative cannot be written as [core] active_initiative" + ) + values["active_initiative"] = initiative + if not values: + raise ValueError("a mount config rewrite needs main_store, initiative, or both") + return values + + +def _describe_values(values: Mapping[str, str]) -> str: + named = { + "output_folder": "the shared ticket store", + "active_initiative": "the child initiative", + } + return " and ".join(f"{named[key]} {value}" for key, value in values.items()) + + +def redirected_user_config_text( + text: str, *, main_store: Path | None, initiative: str | None +) -> str: """``text`` (a ``config.user.toml``) with ``[core] output_folder`` set to - ``main_store``, every other byte kept: any ``output_folder`` line in the - ``[core]`` table is dropped and the new one written right under its header, or - a ``[core]`` table is appended. A CRLF file stays CRLF. + ``main_store`` and ``[core] active_initiative`` set to ``initiative`` — each + only when given, at least one required — every other byte kept: any line of a + key being set in the ``[core]`` table is dropped and the new ones written right + under its header, or a ``[core]`` table is appended. A CRLF file stays CRLF. Re-parsed before it is returned: a shape this line edit cannot reach — an - inline ``core = {…}`` table, dotted ``core.x`` keys — fails the parse or the + inline ``core = {…}`` table, dotted ``core.x`` keys — fails the parse or a value check and raises :class:`TicketStoreError`, never a silently wrong redirect.""" + values = _core_values(main_store, initiative) newline = "\r\n" if "\r\n" in text else "\n" - line = f"output_folder = {_toml_basic_string(str(main_store))}" + lines = "".join( + f"{key} = {_toml_basic_string(value)}{newline}" for key, value in values.items() + ) + replaced = re.compile(r"^\s*(?:" + "|".join(map(re.escape, values)) + r")\s*=") out: list[str] = [] section: str | None = None inserted = False @@ -420,28 +464,29 @@ def redirected_user_config_text(text: str, main_store: Path) -> str: if section == "core" and not inserted: if not raw.endswith(("\n", "\r")): out[-1] = raw + newline - out.append(line + newline) + out.append(lines) inserted = True continue - if section == "core" and _OUTPUT_FOLDER_RE.match(raw): + if section == "core" and replaced.match(raw): continue out.append(raw) if not inserted: if out and not out[-1].endswith(("\n", "\r")): out.append(newline) - out.append(f"{newline if out else ''}[core]{newline}{line}{newline}") + out.append(f"{newline if out else ''}[core]{newline}{lines}") result = "".join(out) try: parsed = tomllib.loads(result) except tomllib.TOMLDecodeError as e: raise TicketStoreError(f"{USER_CONFIG_REL.as_posix()} cannot take the redirect: {e}") from e core: object = parsed.get("core") - value = cast("dict[str, object]", core).get("output_folder") if isinstance(core, dict) else None - if value != str(main_store): - raise TicketStoreError( - f"{USER_CONFIG_REL.as_posix()} cannot take the redirect: [core] output_folder " - f"reads {value!r} after the rewrite" - ) + table = cast("dict[str, object]", core) if isinstance(core, dict) else {} + for key, value in values.items(): + if table.get(key) != value: + raise TicketStoreError( + f"{USER_CONFIG_REL.as_posix()} cannot take the redirect: [core] {key} " + f"reads {table.get(key)!r} after the rewrite" + ) return result @@ -540,13 +585,14 @@ def probe_store_facts(paths: bmadconfig.ProjectPaths, initiative: Path) -> Store def assess_store( - paths: bmadconfig.ProjectPaths, isolation: str + paths: bmadconfig.ProjectPaths, isolation: str, *, chained: bool = False ) -> StoreTopology | StoreRefusal | None: - """Probe and classify the store of ``paths`` (loaded for tickets mode). None - when there is no active initiative folder to classify — the tickets preflight - reports that on its own (``tickets.initiative-unset`` / ``-missing``). A git - fault — or an initiative folder that cannot be statted — is a ``probe-failed`` - refusal, never a guess.""" + """Probe and classify the store of ``paths`` (loaded for tickets mode; a + ``chained`` run's paths re-rooted on its child, see :func:`classify_store`). + None when there is no active initiative folder to classify — the tickets + preflight reports that on its own (``tickets.initiative-unset`` / + ``-missing``). A git fault — or an initiative folder that cannot be statted — + is a ``probe-failed`` refusal, never a guess.""" initiative = paths.initiative_folder if paths.era != "v7" or initiative is None: return None @@ -556,32 +602,42 @@ def assess_store( facts = probe_store_facts(paths, initiative) except verify.GitError as e: return StoreRefusal("probe-failed", initiative, str(e)) - return classify_store(facts, isolation) + return classify_store(facts, isolation, chained=chained) -def redirect_mount_store(mount_project: Path, main_store: Path) -> None: - """Point the worktree mount at ``mount_project`` to the main checkout's store: +def redirect_mount_config( + mount_project: Path, *, main_store: Path | None, initiative: str | None +) -> None: + """Point the worktree mount at ``mount_project`` to the main checkout's store + (``main_store``) and/or a chained run's child initiative (``initiative``): rewrite its :data:`USER_CONFIG_REL` (created when absent) so ``[core] - output_folder`` is ``main_store``, then read the merged config back the way - ``tickets.py`` does. Raises :class:`TicketStoreError` when the file cannot be - rewritten or the merged value is not ``main_store``.""" + output_folder`` / ``[core] active_initiative`` hold them + (:func:`redirected_user_config_text`; at least one required), then read each + merged value back the way ``tickets.py`` does. Raises :class:`TicketStoreError` + when the file cannot be rewritten or a merged value is not the one written.""" path = mount_project / USER_CONFIG_REL + values = _core_values(main_store, initiative) + what = _describe_values(values) try: try: # only absence reads as empty: Path.is_file folds any stat fault to False text = path.read_bytes().decode("utf-8") except (FileNotFoundError, NotADirectoryError): text = "" - data = redirected_user_config_text(text, main_store).encode("utf-8") + data = redirected_user_config_text( + text, main_store=main_store, initiative=initiative + ).encode("utf-8") path.parent.mkdir(parents=True, exist_ok=True) atomic_write_bytes(path, data) - merged = bmadconfig.read_core_value(mount_project, "output_folder") + merged = {key: bmadconfig.read_core_value(mount_project, key) for key in values} except (OSError, UnicodeDecodeError, bmadconfig.BmadConfigError) as e: - raise TicketStoreError(f"cannot redirect {path} to {main_store}: {e}") from e - if merged is None or merged[0] != str(main_store): - raise TicketStoreError( - f"redirected {path}, but the merged [core] output_folder reads " - f"{None if merged is None else merged[0]!r}, not {main_store}" - ) + raise TicketStoreError(f"cannot point {path} at {what}: {e}") from e + for key, value in values.items(): + hit = merged[key] + if hit is None or hit[0] != value: + raise TicketStoreError( + f"redirected {path}, but the merged [core] {key} reads " + f"{None if hit is None else hit[0]!r}, not {value}" + ) def sweep_store_source(paths: bmadconfig.ProjectPaths, stories_source: str) -> str: @@ -593,21 +649,35 @@ def sweep_store_source(paths: bmadconfig.ProjectPaths, stories_source: str) -> s def store_conflict( - project: Path, source: str, isolation: str, *, recorded: Mapping[str, object] | None = None + project: Path, + source: str, + isolation: str, + *, + recorded: Mapping[str, object] | None = None, + initiative: str | None = None, ) -> str | None: """The store-topology refusal for a ``source`` run of ``project`` under ``isolation``, or None — the predicate every run-arming surface calls (and ``cli._reject_store_topology`` wraps). None outside tickets mode. Loads the paths itself with ``want_tickets``, so a caller holding v6-era paths of a mixed install still classifies the v7 store. ``recorded`` is the run's - ``RunState.store_topology`` on resume (empty or None: nothing recorded).""" + ``RunState.store_topology`` on resume (empty or None: nothing recorded). + ``initiative`` names a chained run's child initiative of the active (parent) + one: the paths are re-rooted on it (``ProjectPaths.with_initiative``) and + classified as ``chained``.""" if source != "tickets": return None try: paths = bmadconfig.load_paths(project, want_tickets=True) except bmadconfig.BmadConfigError as e: return f"cannot read the BMAD config to classify the ticket store: {e}" - return store_topology_conflict(assess_store(paths, isolation), recorded=recorded or None) + if initiative is not None: + try: + paths = paths.with_initiative(initiative) + except ValueError as e: + return f"cannot classify the ticket store of child initiative {initiative!r}: {e}" + result = assess_store(paths, isolation, chained=initiative is not None) + return store_topology_conflict(result, recorded=recorded or None) def with_shared_store( diff --git a/src/bmad_loop/tickettree.py b/src/bmad_loop/tickettree.py index 51120390c..9084673f1 100644 --- a/src/bmad_loop/tickettree.py +++ b/src/bmad_loop/tickettree.py @@ -12,6 +12,17 @@ merge the BMad config layers. Running it is the same trust step as install's render probe running the project's ``render_skill.py``. +Child folders. A parent initiative's children are initiative folders beside +it, and the live ``[core] active_initiative`` names the parent while a run +builds one child. A :class:`TicketTree` given ``folder`` (the child's absolute +path) passes it to the script as the ```` positional on every tree read and +write — ``next ``, ``status ``, ``find -- ``, +``mark … -- `` — while the pin still names the parent; with +no ``folder`` the argv is the active initiative's, unchanged. ```` makes +upstream skip the backlog view and the backlog fallback of ``find``. +:meth:`TicketTree.parent_next` reads the parent's own ``next`` (its children, +:class:`ticketmodel.ParentView`). + Spawning. :meth:`TicketTree._call` is the single spawn point (a portability guard holds every other module off the script). It goes through :func:`childrun.run_argv` — no shell, stdin closed, stop-aware, whole-tree kill @@ -51,28 +62,33 @@ ``status``/``next`` view (its child initiatives, no tickets). Writes. :meth:`TicketTree.mark` is bmad-loop's sole write path to a plan's -``status`` and ``blocked_*`` fields, and :meth:`TicketTree.restore_preimage` -the sole byte-exact restore of a pre-image the same transaction captured -(:meth:`TicketTree.discard_unbuilt_plan` deletes a plan that holds only -frontmatter, for a rollback). All three hold the plan's advisory sidecar lock -(``runs.lock_path_for(plan)``) for their whole read-decide-write-read-back, -and every status write is decided by ``statemachine.check_plan_transition`` -under a :data:`~bmad_loop.statemachine.PlanWriteReason`. The lock is held across -bounded child processes only (one ``find`` and at most one ``mark``, each under +``status`` and ``blocked_*`` fields, :meth:`TicketTree.mark_initiative_done` the +sole write path to a child initiative file's ``status`` (``done``, for a +drained child of a chained run — no ``tickets.py`` verb writes it), and +:meth:`TicketTree.restore_preimage` the sole byte-exact restore of a pre-image +the same transaction captured, of either file (:meth:`TicketTree.discard_unbuilt_plan` +deletes a plan that holds only frontmatter, for a rollback). All four hold the +file's advisory sidecar lock (``runs.lock_path_for()``) for their whole +read-decide-write-read-back. Every plan status write is decided by +``statemachine.check_plan_transition`` under a +:data:`~bmad_loop.statemachine.PlanWriteReason`, and an initiative close by +``statemachine.check_initiative_close``. The lock is held across bounded child +processes only (one ``find`` or ``next`` and at most one ``mark``, each under :data:`TICKETS_TIMEOUT_S`) and never across a session or a pause: the build -skill writes plan status itself, so bmad-loop never marks while a session for -that ticket is live. Two more errors belong to writes: +skill writes plan status itself, so bmad-loop never marks a ticket, or closes +a child, while a session for it is live. More errors belong to writes: - :class:`TicketsLockBusy` — the plan lock could not be acquired (Windows gives up after about 10 s; POSIX waits). - :class:`TicketsPlanMismatch` — ``find`` names a different plan than the one the caller expected, so the write could land on the wrong file. - :class:`TicketsTransitionRefused` — the transition table refused the write - (also a ``statemachine.PlanTransitionRefused``). + (also a ``statemachine.PlanTransitionRefused``); :class:`TicketsInitiativeRefused` + is the initiative-close twin (also a ``statemachine.InitiativeCloseRefused``). - :class:`TicketsReadBackMismatch` — the plan read back after a write does not hold what was written. - :class:`TicketsPlanIOError` — bmad-loop's own read, write or delete of the - plan file raised ``OSError``. + plan file raised ``OSError`` (or its edit of an initiative file failed). ``v6_key`` claims. :func:`read_v6_key_entries` is a native read of a field ``tickets.py`` ignores, not a reimplementation of the script: the v6→v7 @@ -114,8 +130,16 @@ ticketmodel, ) from .process_host import ProcessHostError -from .statemachine import PlanTransitionRefused, PlanWriteReason, check_plan_transition -from .ticketmodel import Capability, FindResult, NextView, StatusView +from .statemachine import ( + INITIATIVE_DONE, + TICKET_CLOSED_STATES, + InitiativeCloseRefused, + PlanTransitionRefused, + PlanWriteReason, + check_initiative_close, + check_plan_transition, +) +from .ticketmodel import Capability, FindResult, NextView, ParentView, StatusView if TYPE_CHECKING: from .journal import Journal @@ -227,6 +251,13 @@ class TicketsTransitionRefused(TicketsError, PlanTransitionRefused): kind = "refused" +class TicketsInitiativeRefused(TicketsError, InitiativeCloseRefused): + """The initiative-close rule refused a write; nothing was written. Also an + instance of :class:`statemachine.InitiativeCloseRefused`.""" + + kind = "refused" + + class TicketsReadBackMismatch(TicketsError): """The plan read back after a write does not hold what was written.""" @@ -234,7 +265,9 @@ class TicketsReadBackMismatch(TicketsError): class TicketsPlanIOError(TicketsError): - """bmad-loop's own native read, write or delete of a plan raised ``OSError``.""" + """bmad-loop's own native read, write or delete of a plan raised ``OSError``, + or of an initiative file failed (``OSError``, undecodable bytes, or a + ``status`` no line edit can safely move).""" kind = "plan-io" @@ -268,6 +301,21 @@ class PlanMark: created: bool = False +@dataclass(frozen=True) +class InitiativeMark: + """What one :meth:`TicketTree.mark_initiative_done` did. ``before``/``after`` + are the child's ``status`` (``""`` when unset); ``outcome`` is ``written`` or + ``noop``; ``preimage`` is the file's bytes before a ``written`` close (None on + a no-op), for :meth:`TicketTree.restore_preimage` to undo it.""" + + slug: str + file: Path + before: str + after: str + outcome: str + preimage: bytes | None = None + + @runtime_checkable class TicketTreeLike(Protocol): """What the engine needs from a ticket tree; :class:`TicketTree` is the real @@ -335,7 +383,10 @@ class TicketTree: pinned ``RunState.initiative``: when set, every tree read first checks the live ``[core] active_initiative`` and refuses on a difference, so a run never reads (or later writes) another initiative's tree. None means no pin - (validate, preflight).""" + (validate, preflight). ``folder`` is a child initiative's folder under a + parent: every read and write names it as the script's ```` (see the + module docstring), and the pin — the parent's slug — is checked as before. + None reads the active initiative.""" def __init__( self, @@ -343,6 +394,7 @@ def __init__( script: Path | None = None, pinned_initiative: str | None = None, *, + folder: Path | None = None, timeout: float = TICKETS_TIMEOUT_S, ) -> None: self._project_root = Path(project_root) @@ -350,6 +402,7 @@ def __init__( Path(script) if script is not None else self._project_root / bmadconfig.V7_MARKER_REL ) self._pinned_initiative = pinned_initiative + self._folder = None if folder is None else Path(folder) self.timeout = timeout @property @@ -360,6 +413,14 @@ def project_root(self) -> Path: def pinned_initiative(self) -> str | None: return self._pinned_initiative + @property + def folder(self) -> Path | None: + return self._folder + + def _dir(self) -> list[str]: + """The ```` positional: the child folder, or nothing.""" + return [] if self._folder is None else [str(self._folder)] + # ----------------------------------------------------------- launch def _script_stat(self) -> os.stat_result: @@ -445,18 +506,29 @@ def capability(self) -> Capability: return cap def next(self) -> NextView: - """``next`` on the active initiative (plus the backlog), as build-auto runs it.""" - return _parsed(ticketmodel.parse_next, self._call(["next"], verb="next")) + """``next`` on the tree's folder, else on the active initiative (plus the + backlog), as build-auto runs it.""" + return _parsed(ticketmodel.parse_next, self._call(["next", *self._dir()], verb="next")) def status(self, folder: Path | None = None) -> StatusView: - """``status`` on ``folder``, or on the active initiative (plus the backlog).""" - tail = ["status"] if folder is None else ["status", str(folder)] + """``status`` on ``folder``, else on the tree's folder, else on the active + initiative (plus the backlog).""" + target = folder if folder is not None else self._folder + tail = ["status"] if target is None else ["status", str(target)] return _parsed(ticketmodel.parse_status, self._call(tail, verb="status")) def find(self, ref: str) -> FindResult: - """``find `` on the active initiative, then the backlog.""" + """``find `` in the tree's folder, else on the active initiative, then + the backlog.""" # `--` keeps a ref that starts with `-` from reading as an option. - return _parsed(ticketmodel.parse_find, self._call(["find", "--", ref], verb="find")) + tail = ["find", *self._dir(), "--", ref] + return _parsed(ticketmodel.parse_find, self._call(tail, verb="find")) + + def parent_next(self, parent: Path) -> ParentView: + """``next `` on a parent initiative's folder: its children, which + are ready and what each waits on. Pin-checked like every tree read.""" + doc = self._call(["next", str(parent)], verb="next") + return _parsed(ticketmodel.parse_parent_next, doc) # ----------------------------------------------------------- writes @@ -530,7 +602,8 @@ def mark( result = PlanMark(ref, plan, before, before, "noop") else: flags = ["--blocked", blocked] if blocked is not None else [] - doc = self._call(["mark", *flags, "--", ref, target], verb="mark") + tail = ["mark", *flags, *self._dir(), "--", ref, target] + doc = self._call(tail, verb="mark") report = _parsed(ticketmodel.parse_mark, doc) _, after, blocked_after, blocked_at_after = _plan_fields(plan) if ( @@ -634,13 +707,108 @@ def discard_unbuilt_plan(self, ref: str, expected_plan: Path, journal: Journal) ) return result + def mark_initiative_done(self, parent: Path, slug: str, *, journal: Journal) -> InitiativeMark: + """Close the drained child initiative ``slug`` of the parent initiative + folder ``parent``: write ``status: done`` into the child's own file + ``/..//.md``, which no ``tickets.py`` verb writes — + bmad-loop's sole write path to a child initiative's ``status``. + + Under the file's sidecar lock (``runs.lock_path_for``, as :meth:`mark`): + ``next `` (:meth:`parent_next`, pin-checked) must list ``slug`` + with its folder, else :class:`TicketsPlanMismatch` (the cross-check + :meth:`mark` makes with ``find``); the child's ``status`` and ticket + ``counts`` from that view go to ``statemachine.check_initiative_close`` + (:class:`TicketsInitiativeRefused` on a refusal); a write captures the + file's bytes, sets ``status`` natively, and reads it back, which must say + ``done`` (:class:`TicketsReadBackMismatch`). The result carries the + pre-image for :meth:`restore_preimage`. Every call journals one + ``initiative-mark`` (outcome ``written``, ``noop``, ``refused`` or + ``failed``) and every failure raises a :class:`TicketsError`. + + Decision record — the edit and its confinement. The write goes through + ``verify.set_frontmatter_field``, not ``set_frontmatter_status``: an + unset child may carry no ``status:`` line at all, and only the former + inserts a missing key. ``keep_bom=True`` because the script reads the + file as ``utf-8-sig``: a BOM must neither hide the block nor be dropped. + ``confine_root`` is the folder holding the parent and its children + (``parent.parent``, where upstream's ``parent_view`` looks for each + child), not the project root: it contains the child file in the repo + store and in an external store alike, so both take the confined arm, + which walks ``/.md`` without following a link and refuses a + ``..``. The root itself is not pinned (no ``root_identity``): it is the + folder ``tickets.py`` just read, with the same trust.""" + from . import verify # lazy: verify carries the whole git surface + + parent = Path(parent) + file = parent.parent / slug / f"{slug}.md" + before = after = "" + try: + with self._plan_lock(file): + child = self.parent_next(parent).child(slug) + if child is None or child.folder != slug: + shown = "no such child" if child is None else "the child has no folder" + raise TicketsPlanMismatch(f"{slug}: next {parent} lists {shown}") + before = after = child.status + closed = sum(child.counts.get(state, 0) for state in TICKET_CLOSED_STATES) + try: + write = check_initiative_close( + slug, before, total=child.counts.get("total", 0), closed=closed + ) + except InitiativeCloseRefused as e: + raise TicketsInitiativeRefused(e.slug, e.current, e.why) from e + if not write: + result = InitiativeMark(slug, file, before, before, "noop") + else: + try: + preimage = file.read_bytes() + verify.set_frontmatter_field( + file, + "status", + INITIATIVE_DONE, + confine_root=parent.parent, + keep_bom=True, + ) + except (OSError, ValueError, frontmatter.FrontmatterWriteError) as e: + raise TicketsPlanIOError(f"{slug}: cannot close {file}: {e}") from e + after = _plan_fields(file)[1] + if after != INITIATIVE_DONE: + raise TicketsReadBackMismatch( + f"{slug}: {file} reads back status {after!r} after closing it" + ) + result = InitiativeMark(slug, file, before, after, "written", preimage) + except TicketsError as e: + if not isinstance(e, TicketsInitiativeRefused | TicketsPlanMismatch): + after = _status_or_unknown(file) + _journal_initiative( + journal, + parent=parent, + slug=slug, + file=file, + before=before, + after=after, + outcome=_failure_outcome(e), + ) + raise + _journal_initiative( + journal, + parent=parent, + slug=slug, + file=file, + before=result.before, + after=result.after, + outcome=result.outcome, + ) + return result + def restore_preimage(self, plan: Path, data: bytes | None, journal: Journal) -> None: - """Put ``plan`` back to the exact bytes a transaction captured before - writing it (``None``: the plan did not exist, so remove it). The sole - byte-exact restore of a plan, under the same lock as :meth:`mark`, for - undoing a write the same transaction made — never for a status change of - its own. No child is spawned. The bytes are read back and must match; - journals ``ticket-plan-restored``.""" + """Put ``plan`` — a ticket plan, or a child initiative's file that + :meth:`mark_initiative_done` closed — back to the exact bytes a + transaction captured before writing it (``None``: the file did not + exist, so remove it). The sole byte-exact restore of either, under the + same lock as :meth:`mark` and :meth:`mark_initiative_done` (both lock the + file's own path), for undoing a write the same transaction made — never + for a status change of its own. No child is spawned. The bytes are read + back and must match; journals ``ticket-plan-restored``.""" plan = Path(plan) before = after = "?" restored = False @@ -711,8 +879,29 @@ def _status_or_unknown(plan: Path) -> str: def _failure_outcome(error: TicketsError) -> str: - return ( - "refused" if isinstance(error, TicketsTransitionRefused | TicketsPlanMismatch) else "failed" + refusals = TicketsTransitionRefused | TicketsInitiativeRefused | TicketsPlanMismatch + return "refused" if isinstance(error, refusals) else "failed" + + +def _journal_initiative( + journal: Journal, + *, + parent: Path, + slug: str, + file: Path, + before: str, + after: str, + outcome: str, +) -> None: + # `parent` is the parent's folder name (its slug), `file` the child file's path. + journal.append( + "initiative-mark", + parent=parent.name, + slug=slug, + file=str(file), + before=before, + after=after, + outcome=outcome, ) diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 31d603737..b1192f9f1 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -98,12 +98,13 @@ def _engine_possibly_live(run_dir: Path) -> bool: def _replan_ticket_tree( - project_root: Path, pinned_initiative: str | None + project_root: Path, pinned_initiative: str | None, *, folder: Path | None = None ) -> tickettree.TicketTreeLike: """The tree a tickets-mode "Request replan" marks the plan through: the installed ``tickets.py`` under ``project_root`` (the plan's live root), pinned to the run's - initiative. Tests replace it with a ``FakeTicketTree``.""" - return tickettree.TicketTree(project_root, pinned_initiative=pinned_initiative) + initiative, through a parent's child ``folder`` when given. Tests replace it + with a ``FakeTicketTree``.""" + return tickettree.TicketTree(project_root, pinned_initiative=pinned_initiative, folder=folder) # Textual has no unlimited toast lifetime (`timeout=None` is the 5s default), and @@ -1025,7 +1026,7 @@ def done(verb: str | None) -> None: if spec_path is None: self.notify("no spec file to reset for replan", severity="error") return - ticket: tuple[str, TicketBinding, str | None] | None = None + ticket: tuple[str, TicketBinding] | None = None if state.source == worksource.SOURCE_TICKETS: # R13: a ticket's plan is re-opened only through the chokepoint, # so a paused task with no recorded binding has no ref to mark. @@ -1036,7 +1037,7 @@ def done(verb: str | None) -> None: severity="error", ) return - ticket = (task.story_key, task.ticket, state.initiative or None) + ticket = (task.story_key, task.ticket) self._do_replan( run_id, spec_path, @@ -1304,14 +1305,14 @@ def _do_replan( confine_root: Path, *, root_identity: os.stat_result | None = None, - ticket: tuple[str, TicketBinding, str | None] | None = None, + ticket: tuple[str, TicketBinding] | None = None, ) -> None: """Request-replan: reset the planned spec to draft + strip its Auto Run Result, then resume — the next dispatch re-enters step-02 planning. Uses the same devcontract primitives the engine's repair path uses. - `ticket` is `(paused key, its recorded binding, pinned initiative)` for a - tickets-mode run. The plan is + `ticket` is `(paused key, its recorded binding)` for a tickets-mode run. + The plan is then re-opened to `draft` only through `TicketTree.mark(reason="replan")`, on a worker thread (`_ticket_replan_worker`), never by a raw frontmatter write; the Auto Run Result is left alone. @@ -1349,9 +1350,9 @@ def _do_replan( self.notify(f"replan: no spec at {spec_path} — not resuming", severity="error") return if ticket is not None: - key, binding, initiative = ticket + key, binding = ticket self._ticket_replan_worker( - run_id, run_dir, spec_path, confine_root, key, binding, initiative, root_identity + run_id, run_dir, spec_path, confine_root, key, binding, root_identity ) return try: @@ -1395,7 +1396,6 @@ def _ticket_replan_worker( tree_root: Path, key: str, binding: TicketBinding, - initiative: str | None, root_identity: os.stat_result | None, ) -> None: """Re-open a ticket's plan to `draft` through the chokepoint, then resume. @@ -1411,7 +1411,9 @@ def _ticket_replan_worker( re-read pause must still be `key`'s with the `binding` the modal showed, or another controller has moved the run to a newer checkpoint the operator never reviewed. `tree_root` is the plan's live root (`_paused_spec_root`), the tree a - re-arm marks through too. `root_identity` pins it as it pins the stories-mode + re-arm marks through too, pinned from the re-read state as a re-arm pins it + (`runs.recorded_tree_pin`: a chained run's parent and child folder in the main + checkout, its child in a mount). `root_identity` pins it as it pins the stories-mode reset (DW-423): a worktree mount swapped for a link refuses before the tree's `tickets.py` is run. Every UI update marshals through `call_from_thread`.""" ref = binding.ref @@ -1439,7 +1441,12 @@ def _ticket_replan_worker( ) return else: - conflict = bmadconfig.worktree_isolation_conflict(paths, isolation) + try: + paths = runs.paths_for_state(paths, recorded) + except bmadconfig.BmadConfigError as e: + conflict = str(e) # a chained run whose config no longer names its parent + else: + conflict = bmadconfig.worktree_isolation_conflict(paths, isolation) if conflict is None: conflict = ticketstore.store_conflict( self.project, @@ -1481,7 +1488,8 @@ def _ticket_replan_worker( ) return require_root_pinned(tree_root, root_identity) - mark = _replan_ticket_tree(tree_root, initiative).mark( + pin, folder = runs.recorded_tree_pin(fresh, self.project, tree_root) + mark = _replan_ticket_tree(tree_root, pin, folder=folder).mark( ref, "draft", reason="replan", @@ -1502,7 +1510,7 @@ def _ticket_replan_worker( severity="error", ) return - except (OSError, KeyError, ValueError) as e: + except (OSError, KeyError, ValueError, bmadconfig.BmadConfigError) as e: self.call_from_thread( self.notify, f"replan failed: {e} — not resuming", severity="error" ) @@ -1648,7 +1656,12 @@ def _do_rearm( except (OSError, KeyError, ValueError): recorded = None # the locked re-read below names an unreadable state if recorded is not None: - conflict = runsetup.sweep_initiative_conflict(recorded, paths) + try: + paths = runs.paths_for_state(paths, recorded) + 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) if recorded is not None and conflict is None: conflict = ticketstore.store_conflict( self.project, diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index 93fa535ae..e06ed7077 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -1193,11 +1193,14 @@ def _tickets_tree_sig(project: Path, folder: Path) -> object: ) -def _board_ticket_tree(project: Path, initiative: str) -> tickettree.TicketTreeLike: +def _board_ticket_tree( + project: Path, initiative: str, *, folder: Path | None = None +) -> tickettree.TicketTreeLike: """The tree the board reads: the project's installed ``tickets.py``, pinned to ``initiative`` so a switched ``active_initiative`` reads as a fault, not - as another initiative's board. A seam: tests swap in a ``FakeTicketTree``.""" - return tickettree.TicketTree(project, pinned_initiative=initiative) + as another initiative's board, through a parent's child ``folder`` when + given. A seam: tests swap in a ``FakeTicketTree``.""" + return tickettree.TicketTree(project, pinned_initiative=initiative, folder=folder) def initiative_board(project: Path, initiative: str, folder: Path) -> TicketsBoard: @@ -1214,7 +1217,7 @@ def initiative_board(project: Path, initiative: str, folder: Path) -> TicketsBoa if board.fault is None or time.monotonic() - cached[2] < TICKETS_FAULT_RETRY_S: return board try: - view = _board_ticket_tree(project, initiative).status() + view = _board_ticket_tree(project, initiative, folder=None).status() except tickettree.TicketsError as e: board = TicketsBoard((), f"tickets.py {e.kind}: {e}", initiative) else: diff --git a/src/bmad_loop/verify.py b/src/bmad_loop/verify.py index 9d7ee9c5b..4172a63e5 100644 --- a/src/bmad_loop/verify.py +++ b/src/bmad_loop/verify.py @@ -8277,6 +8277,7 @@ def set_frontmatter_field( *, confine_root: Path, root_identity: os.stat_result | None = None, + keep_bom: bool = False, ) -> bool: """Rewrite (or insert) a scalar ``:`` line in a spec's `---`…`---` frontmatter block. @@ -8319,18 +8320,24 @@ def set_frontmatter_field( Use the BYTES helper and not the text one: `atomic_write_text` keeps ``Path.write_text``'s translating newline default, which would relay ``\\n``→``\\r\\n`` on Windows and undo the paragraph above. + + ``keep_bom`` reads a file the way the BMAD v7 scripts do (``utf-8-sig``): a + leading UTF-8 byte-order mark is set aside before the split and written back + ahead of the edit. Off, a BOM hides the block (``False``, nothing to change), + which matches what `read_frontmatter` sees in a spec. """ if not path.is_file(): return False text = path.read_bytes().decode("utf-8") - split = _split_frontmatter(text) + bom = "\ufeff" if keep_bom and text.startswith("\ufeff") else "" + split = _split_frontmatter(text[len(bom) :]) if split is None: return False before, block, after = split edited = _edit_frontmatter_block(block, key, value, insert=True) if edited is None: return False - payload = (before + edited + after).encode("utf-8") + payload = (bom + before + edited + after).encode("utf-8") if path.is_relative_to(confine_root): atomic_write_bytes_confined( path, diff --git a/src/bmad_loop/worktree_flow.py b/src/bmad_loop/worktree_flow.py index 9d2510969..fb43da920 100644 --- a/src/bmad_loop/worktree_flow.py +++ b/src/bmad_loop/worktree_flow.py @@ -2664,16 +2664,25 @@ def _record_pin(rel: str, dialect: str, text: str) -> None: # A shared ticket store (no checkout carries its own copy) is read and # written in place: point the mount's `tickets.py` and renderer at it before # anything inside the mount resolves `output_folder`, and before the - # escalating gates below, so a failed redirect never reaches a session. - if self.paths.shared_store is not None: + # escalating gates below, so a failed redirect never reaches a session. A + # chained run's child initiative is written the same way on every layout: + # the live `active_initiative` names the parent, and inside the mount the + # child is the active initiative (the user's own file is never written). + store = self.paths.shared_store + child = self.state.initiative if self.state.parent_initiative is not None else None + if store is not None or child is not None: try: - ticketstore.redirect_mount_store( - unit.workspace.paths.project, self.paths.shared_store + ticketstore.redirect_mount_config( + unit.workspace.paths.project, main_store=store, initiative=child ) except ticketstore.TicketStoreError as e: + targets = [ + *(["the shared ticket store"] if store is not None else []), + *([f"the child initiative {child!r}"] if child is not None else []), + ] reason = ( - f"cannot point the worktree for {task.story_key} at the shared ticket " - f"store: {e}" + f"cannot point the worktree for {task.story_key} at " + f"{' and '.join(targets)}: {e}" ) self.escalate_unit(task, reason) # always raises RunPaused # Before any session launches in the worktree: a CLI that gates sessions diff --git a/tests/test_bmadconfig.py b/tests/test_bmadconfig.py index 73f3e4d40..78b8f9861 100644 --- a/tests/test_bmadconfig.py +++ b/tests/test_bmadconfig.py @@ -1149,6 +1149,40 @@ def test_v7_ledger_falls_back_to_output_folder(tmp_path: Path) -> None: assert paths.deferred_work == paths.output_folder / "deferred-work.md" +def test_with_initiative_reroots_on_the_child_folder(tmp_path: Path) -> None: + """A parent's child is the parent folder's sibling (upstream's + ``folder.parent / slug``): both artifact dirs, the ledger and the sprint + path move onto it, and nothing else changes.""" + paths = bmadconfig.load_paths(_v7(tmp_path, initiative="initiative-platform")) + child = paths.with_initiative("initiative-auth") + folder = paths.output_folder / "initiative-auth" + assert child.active_initiative == "initiative-auth" + assert child.initiative_folder == folder + assert child.implementation_artifacts == child.planning_artifacts == folder + assert child.deferred_work == folder / "deferred-work.md" + assert child.sprint_status == folder / "sprint-status.yaml" + unchanged = ("project", "output_folder", "repo_root", "era", "ignored_v6_keys", "shared_store") + assert all(getattr(child, f) == getattr(paths, f) for f in unchanged) + # pure: the child folder need not exist, and the parent is untouched + assert not folder.exists() + assert paths.active_initiative == "initiative-platform" + + +@pytest.mark.parametrize("child", ["", ".", "..", "a/b", "a\\b"]) +def test_with_initiative_refuses_a_non_folder_name(tmp_path: Path, child: str) -> None: + paths = bmadconfig.load_paths(_v7(tmp_path, initiative="initiative-platform")) + with pytest.raises(ValueError, match="single folder name"): + paths.with_initiative(child) + + +def test_with_initiative_refuses_without_an_active_initiative(tmp_path: Path) -> None: + root = _v7(tmp_path) + _write_user_config(root, "# no initiative\n") + paths = bmadconfig.load_paths(root) + with pytest.raises(ValueError, match="active initiative folder"): + paths.with_initiative("initiative-auth") + + def test_v6_keys_present_keep_v6_era_even_with_tickets_py(project) -> None: """A complete v6 config resolves exactly as before when the v7 marker appears beside it: the marker alone never flips the era. Ablation: gate the v7 branch on diff --git a/tests/test_cli.py b/tests/test_cli.py index 8437c7786..9de906862 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,6 +28,7 @@ UNDECODABLE_LEDGER, UNRESOLVABLE, V7_DEFAULT_INITIATIVE, + V7_USER_CONFIG_REL, FakeTicketTree, _reverify_run, assert_run_state_lock_held, @@ -3123,6 +3124,7 @@ def test_status_json_tickets_run_is_additive(project, capsys): assert doc["tickets"] == { "initiative": V7_DEFAULT_INITIATIVE, "initiative_folder": f"/p/_bmad-output/{V7_DEFAULT_INITIATIVE}", + "parent_initiative": None, "epic_filter": "epic-cart", "store_topology": _TICKETS_TOPOLOGY, "epic_closures": {"epic-cart": "accepted"}, @@ -3145,6 +3147,19 @@ def test_status_json_tickets_run_is_additive(project, capsys): assert second["ticket"]["park_pending"] == "awaiting operator: needs a decision" +def test_status_json_tickets_names_a_parent_initiative(project, capsys): + """A run building a parent's child names the parent in the run-level + `tickets` object, still at schema 1 (an added key, nothing removed or + retyped).""" + _make_tickets_status_run(project.project, parent_initiative="initiative-platform") + + doc = _status_json(project, capsys) + + assert doc["schema_version"] == cli.STATUS_SCHEMA_VERSION == 1 + assert doc["tickets"]["parent_initiative"] == "initiative-platform" + assert doc["tickets"]["initiative"] == V7_DEFAULT_INITIATIVE + + @pytest.mark.parametrize("source", ["sprint-status", "stories"]) def test_status_json_ticket_keys_null_outside_tickets(project, capsys, source): """Outside a tickets run both keys are present and null — even when the state @@ -3202,8 +3217,8 @@ def _board_tree(monkeypatch, tree): """Point `status`'s tickets board at ``tree``; returns the pins it was built with.""" pins: list[str | None] = [] - def build(state, _project): - pins.append(state.initiative or None) + def build(_project, pin, *, folder=None): + pins.append(pin) return tree monkeypatch.setattr(cli, "_status_ticket_tree", build) @@ -3320,6 +3335,41 @@ def test_status_text_tickets_board_reports_wrapper_fault(project, capsys, monkey assert "mark or store commit owed after landing" in out +def test_status_text_chained_tickets_board_reads_the_child_through_the_parent( + project_v7, capsys, monkeypatch +): + """A chained run's board is read from the main checkout pinned to the parent + the live config names, through the child's folder — never the parent's own + board under the child's name; a config that no longer names the parent is + printed as an unavailable board. Ablation (performed): pass `folder=None` at + the status site and this FAILS.""" + child = "initiative-child" + tree = FakeTicketTree(project_v7.project, status_view=fake_status_view(())) + built: list[tuple[Path, str | None, Path | None]] = [] + + def build(root, pin, *, folder=None): + built.append((root, pin, folder)) + return tree + + monkeypatch.setattr(cli, "_status_ticket_tree", build) + _make_tickets_status_run( + project_v7.project, initiative=child, parent_initiative=project_v7.initiative + ) + + assert cli.main(["status", "--project", str(project_v7.project)]) == 0 + capsys.readouterr() + folder = project_v7.initiative_folder.parent / child + assert built == [(project_v7.project, project_v7.initiative, folder)] + + (project_v7.project / V7_USER_CONFIG_REL).write_text( + '[core]\nactive_initiative = "initiative-other"\n', encoding="utf-8" + ) + assert cli.main(["status", "--project", str(project_v7.project)]) == 0 + out = capsys.readouterr().out + assert "tickets board unavailable: run 20990101-000000-0001 builds child initiative" in out + assert len(built) == 1 + + def test_list_document_library_call_matches_the_cli(project, capsys): # cmd_list sources its RunInfos from the same core reader (#650): the run # inventory lives in bmad_loop.runs, so a library consumer gets identical @@ -10270,6 +10320,52 @@ def skills(*a, **kw): assert _resume_entries(run_dir) == [] +@pytest.mark.parametrize("live", ["initiative-platform", "initiative-other"]) +def test_chained_tickets_resume_builds_its_engine_on_the_childs_paths( + project, monkeypatch, capsys, live +): + """A chained tickets run resumes with the launch-parity gates judging the + loaded (parent's) paths — the tickets preflight classifies a parent and its + children itself — and its engine built on the child's (`runs.paths_for_state`). + A config whose `active_initiative` moved off the parent refuses ahead of every + side effect. Ablation (performed): return the loaded `paths` from + `_prepare_resume_locked` and the engine row FAILS.""" + from bmad_loop import bmadconfig + + parent, child = "initiative-platform", "initiative-child" + run_dir = _paused_run_for_resume( + project, monkeypatch, source="tickets", initiative=child, parent_initiative=parent + ) + real = bmadconfig.load_paths(project.project) + loaded = dataclasses.replace( + real, era="v7", active_initiative=live, initiative_folder=real.output_folder / live + ) + monkeypatch.setattr(bmadconfig, "load_paths", lambda _p, *, want_tickets=False: loaded) + preflights: list = [] + monkeypatch.setattr(cli, "_check_tickets_preflight", lambda p, _pol: preflights.append(p)) + monkeypatch.setattr(cli, "_require_base_skills", lambda *_a, **_k: True) + monkeypatch.setattr(cli, "_reject_store_topology", lambda *_a, **_k: None) + built: list = [] + + class Engine(_StubEngine): + def __init__(self, **kwargs): + built.append(kwargs["paths"]) + + monkeypatch.setattr(cli, "TicketsEngine", Engine) + + if live != parent: + with pytest.raises(bmadconfig.BmadConfigError, match=f"set it back to {parent!r}"): + cli._resume_paused_run(project.project, run_dir) + assert (preflights, built, _resume_entries(run_dir)) == ([], [], []) + return + assert cli._resume_paused_run(project.project, run_dir) == 0 + assert preflights == [loaded] + (paths,) = built + assert paths.active_initiative == child + assert paths.initiative_folder == real.output_folder / child + assert paths.deferred_work == real.output_folder / child / "deferred-work.md" + + # ------------------------------------------- resume re-stamps the code root # `repo_root:` is the one ProjectPaths member that decides which git TREE the run diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index c2d36a5f9..0826fe4a4 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1173,6 +1173,39 @@ def test_ticket_mark_ref_is_pseudonymized(): assert pseudo.entries() == [("ticket", "story-acme-vault-rotation", scrubbed["ref"])] +def test_initiative_mark_aliases_slugs_and_drops_the_file_path(): + """`initiative-mark` names two initiative folders and the closed child's file: + both slugs alias in one `initiative` namespace, the path collapses to presence, + and the status/outcome tokens stay readable. + + Ablation: drop the kind's `_JOURNAL_KIND_SCHEMAS` row and `file` ships through + `scrub_json`; drop its alias row and the slugs ship verbatim.""" + pseudo = sanitize.Pseudonymizer(salt=b"fixed") + raw = { + "kind": "initiative-mark", + "parent": "initiative-acme-platform", + "slug": "initiative-acme-auth", + "file": "/home/acme/store/initiative-acme-auth/initiative-acme-auth.md", + "before": "in-progress", + "after": "done", + "outcome": "written", + } + + scrubbed = diagnostics._scrub_entry(raw, pseudo, {}, None) + + assert scrubbed["parent"].startswith("initiative-") and scrubbed["slug"].startswith( + "initiative-" + ) + assert scrubbed["parent"] != scrubbed["slug"] + assert "file" not in scrubbed and scrubbed["file_present"] is True + assert (scrubbed["before"], scrubbed["after"], scrubbed["outcome"]) == ( + "in-progress", + "done", + "written", + ) + assert "acme" not in json.dumps(scrubbed) + + @pytest.mark.parametrize( "kind", [ diff --git a/tests/test_engine_worktree.py b/tests/test_engine_worktree.py index 6fa49a7c0..1de99fe55 100644 --- a/tests/test_engine_worktree.py +++ b/tests/test_engine_worktree.py @@ -13550,7 +13550,7 @@ def _mount_aware(engine, tree, main): `tickets.py --project-root ` would; the main checkout reads it as is.""" from test_tickets_engine import _MountTree - engine._ticket_tree_factory = lambda root, pin: ( + engine._ticket_tree_factory = lambda root, pin, folder=None: ( tree if root == main else _MountTree(tree, main, root) ) return engine diff --git a/tests/test_initiativechain.py b/tests/test_initiativechain.py new file mode 100644 index 000000000..fdd6b1c3b --- /dev/null +++ b/tests/test_initiativechain.py @@ -0,0 +1,134 @@ +"""initiativechain.next_child: which ready child of a parent initiative a chain +builds next, and why the chain ends when none is taken.""" + +from __future__ import annotations + +from bmad_loop.initiativechain import NO_FOLDER, PassedOver, next_child +from bmad_loop.ticketmodel import ChildInitiative, InitiativeEdge, ParentView + + +def _child( + slug: str, + *, + ready: bool = False, + folder: str | None = "", + status: str = "in-progress", + after: tuple[str, ...] = (), + waiting_on: tuple[str, ...] = (), +) -> ChildInitiative: + """A child as `parse_parent_next` builds it; ``folder=""`` means "its slug", + and a folder-less child carries no status, as upstream prints it.""" + return ChildInitiative( + id=slug, + slug=slug, + title=slug.title(), + after=tuple(InitiativeEdge(a, "") for a in after), + folder=(slug if folder == "" else folder), + status=(status if folder is not None else ""), + counts={}, + waiting_on=waiting_on, + ready=ready, + ) + + +def _view(*children: ChildInitiative) -> ParentView: + return ParentView( + store="repo", + folder="initiative-platform", + initiatives=children, + ready=tuple(c.slug for c in children if c.ready), + order_conflict=(), + ) + + +def test_takes_the_first_ready_child_in_build_order(): + done = _child("auth", status="done") + billing = _child("billing", ready=True, after=("auth",)) + reports = _child("reports", ready=True) + search = _child("search", after=("billing",), waiting_on=("billing",)) + + pick = next_child(_view(done, billing, reports, search)) + + assert pick.child is billing + assert (pick.passed_over, pick.end) == ((), None) + assert pick.waiting == ("search",) + + +def test_waiting_only_takes_no_child_and_ends_waiting(): + """Ablation (performed): drop the `ready` filter in `next_child` and the + waiting child (it has a folder and is not done) is taken — this FAILS.""" + done = _child("auth", status="done") + billing = _child("billing", after=("payments",), waiting_on=("payments",)) + payments = _child("payments", folder=None, after=("auth",), waiting_on=("billing",)) + + pick = next_child(_view(done, billing, payments)) + + assert pick.child is None and pick.end == "waiting" + assert pick.waiting == ("billing", "payments") + assert pick.passed_over == () + + +def test_folder_less_ready_child_is_passed_over_and_reported(): + reports = _child("reports", ready=True, folder=None) + billing = _child("billing", ready=True) + + pick = next_child(_view(reports, billing)) + + assert pick.child is billing + assert pick.passed_over == (PassedOver("reports", NO_FOLDER),) + + +def test_only_folder_less_ready_children_end_passed_over(): + pick = next_child( + _view(_child("auth", status="done"), _child("reports", ready=True, folder=None)) + ) + + assert pick.child is None and pick.end == "passed-over" + assert pick.passed_over == (PassedOver("reports", NO_FOLDER),) + assert pick.waiting == () + + +def test_empty_parent_ends_all_done(): + pick = next_child(_view()) + + assert (pick.child, pick.passed_over, pick.waiting, pick.end) == (None, (), (), "all-done") + + +def test_every_child_closed_ends_all_done(): + pick = next_child(_view(_child("auth", status="done"), _child("legacy", status="dropped"))) + + assert pick.child is None and pick.end == "all-done" and pick.waiting == () + + +def test_drained_child_keeps_its_dependents_waiting(): + """A child the probe passes over (nothing left to dispatch) whose initiative + is not marked done leaves its dependents waiting: the chain ends `waiting`, + naming both.""" + auth = _child("auth", ready=True) + billing = _child("billing", after=("auth",), waiting_on=("auth",)) + asked: list[str] = [] + + def drained(child: ChildInitiative) -> str | None: + asked.append(child.slug) + return "drained" + + pick = next_child(_view(auth, billing), drained) + + assert asked == ["auth"] + assert pick.child is None and pick.end == "waiting" + assert pick.passed_over == (PassedOver("auth", "drained"),) + assert pick.waiting == ("billing",) + + +def test_probe_is_not_asked_about_a_folder_less_child(): + reports = _child("reports", ready=True, folder=None) + billing = _child("billing", ready=True) + asked: list[str] = [] + + def take(child: ChildInitiative) -> str | None: + asked.append(child.slug) + return None + + pick = next_child(_view(reports, billing), take) + + assert asked == ["billing"] and pick.child is billing diff --git a/tests/test_install.py b/tests/test_install.py index 3733b36f0..c8b0c59f2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12018,14 +12018,15 @@ def _tickets_paths(layout): return bmadconfig.load_paths(layout.project, want_tickets=True) -def _tickets_findings(layout, monkeypatch, *, probe=False, uv="/usr/bin/uv"): +def _tickets_findings(layout, monkeypatch, *, probe=False, uv="/usr/bin/uv", **kwargs): """`tickets_preflight` on ``layout`` with uv pinned present (or absent with - ``uv=None``) — the host's PATH must not decide a project check.""" + ``uv=None``) — the host's PATH must not decide a project check. ``kwargs`` go + to the preflight.""" monkeypatch.setattr( install_mod.shutil, "which", lambda tool: uv if tool == "uv" else f"/usr/bin/{tool}" ) return install_mod.tickets_preflight( - _tickets_paths(layout), [".claude/skills"], probe_capability=probe + _tickets_paths(layout), [".claude/skills"], probe_capability=probe, **kwargs ) @@ -12145,28 +12146,121 @@ def _make_parent(layout, *, flat: bool = False) -> Path: return path +def _slice_children(layout, *slugs: str) -> None: + """Give each child initiative in ``slugs`` a committed folder beside the active + initiative's, a copy of its ticket tree (call before `_make_parent`).""" + for slug in slugs: + shutil.copytree(layout.initiative_folder, layout.initiative_folder.parent / slug) + git(layout.project, "add", "-A") + git(layout.project, "commit", "-q", "-m", "child initiatives") + + def test_tickets_preflight_refuses_a_parent_initiative(project_v7, monkeypatch): """A parent initiative has no tickets of its own (tickets.py `status`/`next` - print its children, every other verb refuses), so a run needs one child - active: a problem naming the children, read from its `tickets.toml` without a - spawn, and the store checks are skipped. Ablation (performed): drop the - parent check and this FAILS (the store checks pass instead).""" + print its children, every other verb refuses), and without worktree isolation + every session reads the parent from the user config: a problem naming the + children, read from its `tickets.toml` without a spawn, and the store checks + are skipped. Ablation (performed): drop the isolation check and this FAILS + (the parent is accepted instead).""" from bmad_loop.checks import VALIDATE_CHECKS + _slice_children(project_v7, "initiative-auth", "initiative-billing") _make_parent(project_v7) - findings = _tickets_findings(project_v7, monkeypatch) + findings = _tickets_findings(project_v7, monkeypatch, isolation="none") (problem,) = [f for f in findings if f.severity == "problem"] assert problem.check == "tickets.initiative-parent" assert problem.detail == { "initiative": project_v7.initiative, "children": ["initiative-auth", "initiative-billing"], + "isolation": "none", + "retrospective": None, } assert "(children: initiative-auth, initiative-billing)" in problem.message + assert '`isolation = "worktree"`' in problem.message assert "`[core] active_initiative`" in problem.message assert "tickets.store-topology" not in _ids(findings) assert set(_ids(findings)) <= VALIDATE_CHECKS +def test_tickets_preflight_accepts_a_parent_under_worktree_isolation(project_v7, monkeypatch): + """Under worktree isolation, with no auto retrospective, a parent is an ok line + naming its children, and each child's store is classified on its own folder. + Ablation (performed): drop the per-child store loop and this FAILS.""" + from bmad_loop.checks import VALIDATE_CHECKS + + _slice_children(project_v7, "initiative-auth", "initiative-billing") + _make_parent(project_v7) + findings = _tickets_findings( + project_v7, monkeypatch, isolation="worktree", retrospective="notify" + ) + assert [f for f in findings if f.severity == "problem"] == [] + (parent,) = [f for f in findings if f.check == "tickets.initiative-parent"] + assert parent.severity == "ok" + assert parent.detail["children"] == ["initiative-auth", "initiative-billing"] + assert parent.detail["skipped"] == [] + stores = [f for f in findings if f.check == "tickets.store-topology"] + assert [f.detail["initiative"] for f in stores] == ["initiative-auth", "initiative-billing"] + root = project_v7.initiative_folder.parent + assert [f.detail["path"] for f in stores] == [ + str(root / "initiative-auth"), + str(root / "initiative-billing"), + ] + assert all(f.severity == "ok" and f.detail["topology"]["layout"] == "T1" for f in stores) + assert stores[0].message.startswith("child initiative 'initiative-auth': ") + assert set(_ids(findings)) <= VALIDATE_CHECKS + + +def test_tickets_preflight_refuses_a_parent_under_auto_retrospective(project_v7, monkeypatch): + """The auto retrospective session runs in the main checkout and reads the + parent, so even worktree isolation refuses a parent while it is on. + Ablation (performed): drop the retrospective check and this FAILS.""" + _slice_children(project_v7, "initiative-auth", "initiative-billing") + _make_parent(project_v7) + findings = _tickets_findings( + project_v7, monkeypatch, isolation="worktree", retrospective="auto" + ) + (problem,) = [f for f in findings if f.severity == "problem"] + assert problem.check == "tickets.initiative-parent" + assert '`gates.retrospective = "auto"`' in problem.message + assert "`[scm] isolation" not in problem.message + assert "tickets.store-topology" not in _ids(findings) + + +def test_tickets_preflight_skips_a_parents_unsliced_child(project_v7, monkeypatch): + """A child declared but not sliced yet has no folder: the ok line says it is + skipped, and only the sliced child's store is classified.""" + _slice_children(project_v7, "initiative-auth") + _make_parent(project_v7) + findings = _tickets_findings(project_v7, monkeypatch, isolation="worktree") + assert [f for f in findings if f.severity == "problem"] == [] + (parent,) = [f for f in findings if f.check == "tickets.initiative-parent"] + assert parent.severity == "ok" + assert parent.detail["skipped"] == ["initiative-billing"] + assert "initiative-billing has no folder yet (declared, not sliced)" in parent.message + stores = [f for f in findings if f.check == "tickets.store-topology"] + assert [f.detail["initiative"] for f in stores] == ["initiative-auth"] + + +def test_tickets_preflight_refuses_a_parents_child_on_a_tracked_user_config( + project_v7, monkeypatch +): + """A chained run rewrites the mount's `config.user.toml` on every layout, so a + tracked personal layer refuses each child even on T1, which needs no store + redirect (the same project as a plain initiative runs).""" + _slice_children(project_v7, "initiative-auth", "initiative-billing") + git(project_v7.project, "add", "-f", "_bmad/custom/config.user.toml") + git(project_v7.project, "commit", "-q", "-m", "track the personal layer") + plain = _tickets_findings(project_v7, monkeypatch, isolation="worktree") + assert [f for f in plain if f.severity == "problem"] == [] + _make_parent(project_v7) + findings = _tickets_findings(project_v7, monkeypatch, isolation="worktree") + problems = [f for f in findings if f.severity == "problem"] + assert [f.check for f in problems] == ["tickets.own-repo-isolation"] * 2 + assert [f.detail["initiative"] for f in problems] == ["initiative-auth", "initiative-billing"] + assert [f.detail["refusal"] for f in problems] == ["user-config-tracked"] * 2 + assert "config.user.toml" in problems[0].message + + def test_tickets_preflight_reads_a_parent_in_the_flat_layout_too(project_v7, monkeypatch): """A preview-era tree keeps `tickets.toml` in the initiative folder itself: the same parent check reads it there (upstream's `tickets/`-is-a-directory rule).""" diff --git a/tests/test_model.py b/tests/test_model.py index 3e6053888..df1127ff3 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -181,6 +181,28 @@ def test_old_state_json_defaults_initiative_empty(): assert back.initiative == "" and back.initiative_folder == "" +def test_run_state_round_trips_parent_initiative(): + assert _state().parent_initiative is None + assert _state().to_dict()["parent_initiative"] is None + chained = _state(initiative="initiative-auth", parent_initiative="initiative-platform") + d = json.loads(json.dumps(chained.to_dict())) + assert d["parent_initiative"] == "initiative-platform" + back = RunState.from_dict(d) + assert back.parent_initiative == "initiative-platform" + assert back.initiative == "initiative-auth" + + +def test_old_state_json_without_parent_initiative_loads_unchained(): + """A state.json written before the field existed reads as a run that builds + the active initiative itself. Ablation (performed): drop the `from_dict` + default (`d["parent_initiative"]`) and this FAILS with a KeyError.""" + d = json.loads(json.dumps(_state(initiative="initiative-checkout").to_dict())) + del d["parent_initiative"] + back = RunState.from_dict(d) + assert back.parent_initiative is None + assert back.initiative == "initiative-checkout" + + def _binding(**kw) -> TicketBinding: base = dict( ref="2.1", diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index 1360905e7..88f70e424 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -596,6 +596,11 @@ # by name. "ticket-mark": frozenset({"before", "after", "outcome", "created"}), "ticket-plan-restored": frozenset({"before", "after", "outcome"}), + # tickettree.py: a child initiative's `status` tokens (`?` when unreadable) + # and a closed outcome slug. `parent`/`slug` are aliased by + # `diagnostics._JOURNAL_KIND_ALIAS_FIELDS`; `file` collapses to presence under + # `diagnostics._JOURNAL_KIND_SCHEMAS`. + "initiative-mark": frozenset({"before", "after", "outcome"}), # 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"}), @@ -2243,9 +2248,10 @@ "ticket-status-write-refused", # tickettree.py — the ticket-plan chokepoint: one `ticket-mark` per # mark/discard call (any outcome), one `ticket-plan-restored` per - # byte-exact restore. + # byte-exact restore, one `initiative-mark` per child-initiative close. "ticket-mark", "ticket-plan-restored", + "initiative-mark", # worktree_flow.py "accepted-spec-delivery-unreachable", "accepted-spec-write-unreachable", @@ -10353,9 +10359,11 @@ def test_journal_routing_tables_are_read_from_diagnostics(): # can neither name a field nothing produces nor quietly introduce one that # bypassed the routed/benign decision. `type` and `severity` reach the # journal only through the allowlisted splat, so the inventory there is - # where they are declared. + # where they are declared. A benign row on this kind alone accounts for the + # name on this kind's schema only. unaccounted = names - JOURNAL_ROUTED_FIELDS - JOURNAL_BENIGN_FIELDS unaccounted -= frozenset().union(*JOURNAL_SPLAT_FIELDS.values()) + unaccounted -= JOURNAL_KIND_BENIGN_FIELDS.get(kind, frozenset()) assert unaccounted == set(), ( f"{kind}'s declared schema names fields the guard does not account for: " f"{sorted(unaccounted)}" diff --git a/tests/test_resolve.py b/tests/test_resolve.py index fc18e5231..b6e2a54c0 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -265,6 +265,25 @@ def test_set_frontmatter_field_inserts_without_introducing_a_foreign_line_ending assert verify.read_frontmatter(spec)["baseline_revision"] == "abc123" +def test_set_frontmatter_field_keep_bom_edits_behind_a_byte_order_mark(tmp_path): + """A leading BOM hides the block unless `keep_bom` (a v7 file read as + `utf-8-sig`): then the key is inserted behind it and the BOM is kept. + + Ablation: drop the BOM peel and the `keep_bom` call returns False.""" + spec = tmp_path / "initiative.md" + original = "\ufeff---\ntype: initiative\n---\n\nbody\n".encode("utf-8") + spec.write_bytes(original) + assert verify.set_frontmatter_field(spec, "status", "done", confine_root=tmp_path) is False + assert spec.read_bytes() == original + assert ( + verify.set_frontmatter_field(spec, "status", "done", confine_root=tmp_path, keep_bom=True) + is True + ) + assert spec.read_bytes().decode("utf-8") == ( + "\ufeff---\ntype: initiative\nstatus: done\n---\n\nbody\n" + ) + + # --- set_frontmatter_field: atomic write (#379) --- # # Three rows for the three distinct choices at this call site — that it routes @@ -5809,7 +5828,7 @@ def _fake_rearm_tree(monkeypatch, plan: Path, *, status: str = "blocked"): row = ticket_row("1.1", status=status, blocked_reason="intent gap") tree = FakeTicketTree(plan.parent, finds={"1.1": fake_find(row, plan.parent)}) tree.finds["1.1"] = dataclasses.replace(tree.finds["1.1"], plan=str(plan)) - monkeypatch.setattr(runs, "_rearm_ticket_tree", lambda _root, _pin: tree) + monkeypatch.setattr(runs, "_rearm_ticket_tree", lambda _root, _pin, folder=None: tree) return tree @@ -5967,6 +5986,68 @@ def test_rearm_tickets_uses_mark_and_clears_blocked_fields(project_v7): assert load_state(run_dir).tasks[outcome.story_key].phase == Phase.PENDING +@pytest.mark.parametrize("in_mount", [False, True]) +def test_rearm_chained_ticket_tree_pins_like_the_engine( + project_v7, tmp_path, monkeypatch, in_mount +): + """A chained run's re-arm marks the plan through a tree pinned as the engine + pins it: in the main checkout, the parent the live config names, through the + child's folder; in a worktree mount, the child with no folder (the mount's + config names it). Ablation (performed): pass `state.initiative` and + `folder=None` again and the main-checkout row FAILS.""" + child = "initiative-child" + if in_mount: + root = tmp_path / "wt" / "u1" + plan = root / "epic-cart" / "story-cart-model-plan.md" + plan.parent.mkdir(parents=True) + plan.write_text("---\nticket: 1\nstatus: blocked\n---\n" + TICKET_PLAN_BODY, "utf-8") + run_dir, state, task = _escalated_run( + tmp_path, spec_file=str(plan), source="tickets", worktree_path=str(root) + ) + task.ticket = _ticket_binding(plan) + else: + root = project_v7.project + plan = _blocked_ticket_plan(project_v7) + run_dir, state, _ = _escalated_ticket_run(root, plan) + state.initiative, state.parent_initiative = child, project_v7.initiative + save_state(run_dir, state) + tree = _fake_rearm_tree(monkeypatch, plan) + built: list = [] + + def factory(project_root, pin, *, folder=None): + built.append((project_root, pin, folder)) + return tree + + monkeypatch.setattr(runs, "_rearm_ticket_tree", factory) + + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + if in_mount: + assert built == [(root, child, None)] + else: + folder = project_v7.initiative_folder.parent / child + assert built == [(root, project_v7.initiative, folder)] + assert [(m.target, m.reason) for m in tree.marks] == [("ready-for-dev", "rearm")] + + +def test_rearm_chained_ticket_refuses_a_moved_parent_before_any_write(project_v7, monkeypatch): + """A chained run whose live config no longer names its parent refuses the + re-arm before its first write: the escalation stays armed, the plan as it + was.""" + plan = _blocked_ticket_plan(project_v7) + before = plan.read_bytes() + run_dir, state, _ = _escalated_ticket_run(project_v7.project, plan) + state.initiative, state.parent_initiative = "initiative-child", "initiative-platform" + save_state(run_dir, state) + monkeypatch.setattr(runs, "_rearm_ticket_tree", lambda *_a, **_k: pytest.fail("built")) + + with pytest.raises(runs.RearmError, match="set it back to 'initiative-platform'"): + runs.rearm_escalation(run_dir, isolated_redrive=False, resolution_recorded=True) + + assert load_state(run_dir).tasks["6-4-cli-list-command"].phase == Phase.ESCALATED + assert plan.read_bytes() == before + + def test_rearm_rollback_restores_plan_bytes_on_fault(project_v7, monkeypatch): """A fault after the mark — here `save_state` itself — rolls the plan back to the exact bytes the re-arm found, through the chokepoint's `restore_preimage` (journaled diff --git a/tests/test_runs.py b/tests/test_runs.py index a0b5722f0..f2c1c2dbd 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -1,6 +1,7 @@ """Run-directory helper tests.""" import contextlib +import dataclasses import errno import json import os @@ -12077,3 +12078,80 @@ def broken(_run_dir): [row] = runs.prior_sweep_closes(tmp_path, exclude="x") assert row.run_id == "20260101-000000-aaaa" and row.dw_ids == () assert row.fault and "bogus" in row.fault + + +# ---------------------------------------- a chained run's recorded paths and pin + +CHILD = "initiative-child" + + +def _chained(parent: str | None, initiative: str = CHILD) -> RunState: + return RunState( + run_id="r-chained", + project="/p", + started_at="now", + source="tickets", + initiative=initiative, + parent_initiative=parent, + ) + + +def test_paths_for_state_keeps_an_unchained_runs_paths(project_v7): + """An ordinary run reopens on the loaded paths themselves.""" + paths = bmadconfig.load_paths(project_v7.project, want_tickets=True) + assert runs.paths_for_state(paths, _chained(None, project_v7.initiative)) is paths + + +def test_paths_for_state_reroots_a_chained_run_on_its_child(project_v7): + """A chained run reopens on its child initiative: the folder is the parent's + sibling named for the child, and the ledger, sprint path and both artifact + dirs move onto it. Ablation (performed): return `paths` unconditionally and + this FAILS.""" + paths = bmadconfig.load_paths(project_v7.project, want_tickets=True) + child = runs.paths_for_state(paths, _chained(project_v7.initiative)) + folder = project_v7.initiative_folder.parent / CHILD + assert child.active_initiative == CHILD and child.initiative_folder == folder + assert child.implementation_artifacts == child.planning_artifacts == folder + assert child.deferred_work == folder / "deferred-work.md" + assert child.sprint_status == folder / "sprint-status.yaml" + + +def test_paths_for_state_refuses_a_chained_run_whose_parent_moved(project_v7): + """A live `active_initiative` that no longer names the run's parent refuses + with a config error naming the value to restore — never the loaded (other) + initiative's paths, nor a child located from them.""" + paths = bmadconfig.load_paths(project_v7.project, want_tickets=True) + with pytest.raises(bmadconfig.BmadConfigError, match="set it back to 'initiative-platform'"): + runs.paths_for_state(paths, _chained("initiative-platform")) + + +def test_paths_for_state_refuses_a_child_it_cannot_locate(project_v7): + """A child slug that is no folder name is a config error, not a ValueError + escaping past every `load_paths` caller's handler.""" + paths = bmadconfig.load_paths(project_v7.project, want_tickets=True) + with pytest.raises(bmadconfig.BmadConfigError, match="cannot locate child initiative"): + runs.paths_for_state(paths, _chained(project_v7.initiative, initiative="a/b")) + + +def test_tree_pin_refuses_parent_rooted_paths(project_v7): + """`tree_pin` on a chained run handed the parent's own paths (`load_paths` + output, not re-rooted) refuses instead of routing the tree through the + parent's folder as if it were the child's; so does a folder not named for the + child. Ablation (performed): delete the guard and this FAILS.""" + parent = bmadconfig.load_paths(project_v7.project, want_tickets=True) + state = _chained(project_v7.initiative) + with pytest.raises(ValueError, match="re-root them with paths_for_state"): + runs.tree_pin(state, parent) + elsewhere = parent.with_initiative(CHILD) + misfiled = dataclasses.replace(elsewhere, initiative_folder=parent.initiative_folder) + with pytest.raises(ValueError, match="re-root them with paths_for_state"): + runs.tree_pin(state, misfiled) + assert runs.tree_pin(state, elsewhere) == (project_v7.initiative, elsewhere.initiative_folder) + + +def test_recorded_tree_pin_pins_a_mount_to_the_child(tmp_path, monkeypatch): + """A tree built in a worktree mount pins the chained run's child with no + folder — the mount's config names it — and reads no config at all.""" + monkeypatch.setattr(bmadconfig, "load_paths", lambda *a, **k: pytest.fail("read config")) + state = _chained("initiative-platform") + assert runs.recorded_tree_pin(state, tmp_path, tmp_path / "wt" / "u1") == (CHILD, None) diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index ae363678c..c778d0d1b 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -7,11 +7,14 @@ PLAN_AWAITING_OPERATOR, PLAN_STATUS_ORDER, PLAN_TRANSITIONS, + TICKET_CLOSED_STATES, TRANSITIONS, IllegalTransition, + InitiativeCloseRefused, PlanTransitionRefused, advance, check_board_regression, + check_initiative_close, check_plan_transition, ) from bmad_loop.ticketmodel import PLAN_STATUSES @@ -428,3 +431,70 @@ def test_unknown_reason_is_refused(): check_plan_transition( "1.1", "built", "done", "promote", has_plan=True, blocked_reason="" # type: ignore[arg-type] ) + + +# ---- child initiative close --------------------------------------------------- + + +def _close(current: str, total: int, closed: int) -> str: + try: + return ( + "write" + if check_initiative_close("auth", current, total=total, closed=closed) + else "noop" + ) + except InitiativeCloseRefused: + return "refuse" + + +@pytest.mark.parametrize( + ("current", "expected"), + [("", "write"), ("in-progress", "write"), ("done", "noop"), ("dropped", "refuse")], +) +def test_initiative_close_from_each_container_status(current, expected): + """A drained child closes from unset or in-progress, is a no-op when done, and + a dropped one is never reopened. Which rule refuses `dropped` is pinned by + the next row.""" + assert _close(current, 4, 4) == expected + + +def test_dropped_tickets_count_as_closed(): + """A drain counts dropped tickets with done ones: the caller sums these.""" + assert TICKET_CLOSED_STATES == {"done", "dropped"} + + +def test_dropped_initiative_refused_as_final(): + """Ablation: delete the dropped arm and the refusal comes from the open-status + check, so the `final` match reddens.""" + with pytest.raises(InitiativeCloseRefused, match="a dropped initiative is final"): + check_initiative_close("auth", "dropped", total=4, closed=4) + + +@pytest.mark.parametrize("current", ["", "in-progress"]) +@pytest.mark.parametrize(("total", "closed"), [(4, 3), (4, 0), (0, 0)]) +def test_initiative_close_refused_until_drained(current, total, closed): + """An open child with a ticket left open, or with no tickets at all, is not + drained. Ablation: delete the drained check and every row reddens.""" + assert _close(current, total, closed) == "refuse" + + +def test_initiative_close_done_is_a_noop_even_when_not_drained(): + """`done` is answered before the drain: a closed child stays a no-op.""" + assert _close("done", 4, 1) == "noop" + + +@pytest.mark.parametrize("current", ["blocked", "built", "draft", "ready-for-dev", "none"]) +def test_initiative_close_refuses_any_other_status(current): + assert _close(current, 4, 4) == "refuse" + + +def test_initiative_close_refused_is_an_illegal_transition(): + with pytest.raises(IllegalTransition) as caught: + check_initiative_close("billing", "in-progress", total=3, closed=2) + err = caught.value + assert isinstance(err, InitiativeCloseRefused) + assert (err.slug, err.current) == ("billing", "in-progress") + assert str(err) == ( + "billing: initiative in-progress -> done is not a legal close: " + "not drained: 2 of 3 tickets done or dropped" + ) diff --git a/tests/test_ticketmodel.py b/tests/test_ticketmodel.py index 4a093954a..b1d2a2e66 100644 --- a/tests/test_ticketmodel.py +++ b/tests/test_ticketmodel.py @@ -21,6 +21,7 @@ parse_help, parse_mark, parse_next, + parse_parent_next, parse_requires_python, parse_status, requires_python_satisfied, @@ -376,6 +377,151 @@ def test_next_parent_view_raises_parent_initiative(): assert not isinstance(plain.value, ticketmodel.TicketsParentInitiative) +def _parent_next() -> dict[str, object]: + """`next` on a parent: `_parent_view` plus a third child, declared and not + created yet with nothing to wait on, and `ready`.""" + doc = _parent_view() + doc["initiatives"].append( + { + "id": "r1", + "slug": "initiative-reports", + "title": "Reports", + "after": [], + "folder": None, + "waiting_on": [], + } + ) + doc["ready"] = ["initiative-auth", "initiative-reports"] + return doc + + +def test_parent_next_reads_children_in_build_order(): + view = parse_parent_next(_parent_next()) + assert (view.store, view.folder, view.order_conflict) == ("repo", "initiative-platform", ()) + assert view.ready == ("initiative-auth", "initiative-reports") + auth, billing, reports = view.initiatives + assert (auth.id, auth.slug, auth.title, auth.folder) == ( + "1", + "initiative-auth", + "Auth", + "initiative-auth", + ) + assert (auth.status, dict(auth.counts), auth.ready) == ( + "in-progress", + {"total": 2, "planned": 2}, + True, + ) + assert view.child("initiative-auth") is auth and view.child("initiative-x") is None + + +def test_parent_next_waiting_child_is_not_ready(): + """Ablation: read `ready` as true for every child and this reddens.""" + billing = parse_parent_next(_parent_next()).initiatives[1] + assert billing.waiting_on == ("initiative-auth",) and not billing.ready + assert billing.after == (ticketmodel.InitiativeEdge("initiative-auth", ""),) + + +def test_parent_next_folderless_child_is_data_not_an_error(): + """A child the parent declares before its folder exists: no status, no + counts, and still ready when it waits on nothing (as upstream lists it).""" + reports = parse_parent_next(_parent_next()).initiatives[2] + assert (reports.id, reports.folder, reports.status, dict(reports.counts)) == ( + "r1", + None, + "", + {}, + ) + assert reports.ready and reports.waiting_on == () + + +def test_parent_next_keeps_order_conflicts(): + doc = _parent_next() + doc["order_conflict"] = [{"initiative": "initiative-auth", "after": "initiative-billing"}] + assert parse_parent_next(doc).order_conflict == ( + {"initiative": "initiative-auth", "after": "initiative-billing"}, + ) + + +def _child_set(i: int, key: str, value: object): + def edit(doc: dict) -> None: + doc["initiatives"][i][key] = value + + return edit + + +def _child_drop(i: int, key: str): + def edit(doc: dict) -> None: + del doc["initiatives"][i][key] + + return edit + + +def _top_set(key: str, value: object): + def edit(doc: dict) -> None: + doc[key] = value + + return edit + + +def _top_drop(key: str): + def edit(doc: dict) -> None: + del doc[key] + + return edit + + +@pytest.mark.parametrize( + ("edit", "field"), + [ + (_top_drop("ready"), "ready"), + (_top_set("ready", "initiative-auth"), "ready"), + (_top_set("ready", [1]), "ready"), + (_top_set("ready", ["initiative-gone"]), "ready"), + (_top_drop("initiatives"), "initiatives"), + (_top_set("initiatives", {}), "initiatives"), + (_top_set("initiatives", ["initiative-auth"]), "initiatives"), + (_top_drop("store"), "store"), + (_top_drop("folder"), "folder"), + (_top_set("order_conflict", ["x"]), "order_conflict"), + (_child_drop(0, "id"), "id"), + (_child_set(0, "id", True), "id"), + (_child_set(0, "slug", 1), "slug"), + (_child_set(1, "slug", "initiative-auth"), "initiatives"), + (_child_drop(0, "title"), "title"), + (_child_drop(0, "after"), "after"), + (_child_set(1, "after", ["initiative-auth"]), "after"), + (_child_set(1, "after", [{"initiative": 1, "needs": ""}]), "initiative"), + (_child_set(1, "after", [{"initiative": "initiative-auth"}]), "needs"), + (_child_drop(0, "folder"), "folder"), + (_child_set(0, "folder", 3), "folder"), + (_child_drop(0, "status"), "status"), + (_child_set(0, "status", "closed"), "status"), + (_child_drop(0, "counts"), "counts"), + (_child_set(0, "counts", {"total": "2"}), "total"), + (_child_drop(0, "waiting_on"), "waiting_on"), + (_child_set(1, "waiting_on", [1]), "waiting_on"), + ], +) +def test_parent_next_schema_errors(edit, field): + doc = _parent_next() + edit(doc) + with pytest.raises(TicketsSchemaError) as caught: + parse_parent_next(doc) + assert caught.value.command == "next" and caught.value.field == field + + +@pytest.mark.parametrize( + "doc", + [_parent_view(), _next(), "not an object"], + ids=["parent-status", "plain-next", "not-an-object"], +) +def test_parent_next_refuses_other_documents(doc): + """A parent's `status` has no `ready`, a plain initiative's `next` lists no + children: neither reads as a parent's `next`.""" + with pytest.raises(TicketsSchemaError): + parse_parent_next(doc) + + def _find(**over: object) -> dict[str, object]: doc = _row(file="story-cart-model.md", refined=True) doc.update( diff --git a/tests/test_tickets_e2e.py b/tests/test_tickets_e2e.py index 2ea161376..2cfa49ed5 100644 --- a/tests/test_tickets_e2e.py +++ b/tests/test_tickets_e2e.py @@ -795,7 +795,7 @@ def test_e2e_tickets_worktree_isolation_finds_tree(project_v7, topology): and the merge carries the `done` plan back to main. T2 (an ignored, unversioned store): the mount is redirected to the main store, the plan is written there and marked `done` after the merge, and `store-unversioned` is journaled once. - Ablation: drop the redirect (`redirect_mount_store`) and T2's fake writes a plan + Ablation: drop the redirect (`redirect_mount_config`) and T2's fake writes a plan the main store never sees.""" layout = _scaffold( project_v7, diff --git a/tests/test_tickets_engine.py b/tests/test_tickets_engine.py index defc95d4a..f9c9a1894 100644 --- a/tests/test_tickets_engine.py +++ b/tests/test_tickets_engine.py @@ -101,7 +101,7 @@ def make_engine( ) built: list[tuple[Path, str | None]] = [] - def factory(root: Path, pin: str | None) -> FakeTicketTree: + def factory(root: Path, pin: str | None, *, folder: Path | None = None) -> FakeTicketTree: built.append((root, pin)) return tree @@ -139,6 +139,86 @@ def test_engine_pins_source_and_builds_the_tree_from_the_pin(project_v7): assert engine.tree_builds == [(project_v7.project, project_v7.initiative)] +@pytest.mark.parametrize("chained", [False, True]) +def test_chained_trees_pin_the_parent_in_place_and_the_child_in_a_mount( + project_v7, tmp_path, chained +): + """A chained run (one child initiative of the parent the live config names) + builds its main tree pinned to the parent and routed through the child's + folder, and a unit mount's tree pinned to the child with no folder — the + mount's config names the child. An unchained run pins its own initiative in + both, with no folder. Ablation (performed): pin the main tree to + `state.initiative` in `tree_pin` and the chained row FAILS.""" + parent, child = project_v7.initiative, "initiative-child" + paths = load_paths(project_v7.project, want_tickets=True) + if chained: + paths = paths.with_initiative(child) + run_dir = project_v7.project / ".bmad-loop" / "runs" / "test-run" + state = RunState( + run_id="test-run", + project=str(project_v7.project), + started_at="now", + initiative=child if chained else parent, + initiative_folder=str(paths.initiative_folder), + parent_initiative=parent if chained else None, + ) + tree = _tree(project_v7) + built: list[tuple[Path, str | None, Path | None]] = [] + + def factory(root, pin, folder=None): + built.append((root, pin, folder)) + return tree + + engine = TicketsEngine( + paths=paths, + policy=_policy(), + adapter=MockAdapter([]), + run_dir=run_dir, + journal=Journal(run_dir), + state=state, + ticket_tree_factory=factory, + ) + assert engine.ticket_tree is tree + mount = (tmp_path / "mount").resolve() + engine.workspace = Workspace(root=mount, paths=engine.paths.rebased(mount)) + engine._workspace_ticket_tree() + + if chained: + assert built == [ + (project_v7.project, parent, paths.initiative_folder), + (mount, child, None), + ] + assert paths.initiative_folder == project_v7.initiative_folder.parent / child + else: + assert built == [(project_v7.project, parent, None), (mount, parent, None)] + + +def test_chained_engine_refuses_parent_rooted_paths(project_v7): + """A chained run's engine built on the parent's own paths (`load_paths` + output a caller forgot to re-root with `runs.paths_for_state`) refuses at + construction — loudly, before any ledger or tree I/O — instead of reading + and marking the parent's folder as the child's. Ablation (performed): drop + the `tree_pin` call in `TicketsEngine.__init__` and this FAILS.""" + run_dir = project_v7.project / ".bmad-loop" / "runs" / "test-run" + state = RunState( + run_id="test-run", + project=str(project_v7.project), + started_at="now", + initiative="initiative-child", + parent_initiative=project_v7.initiative, + ) + with pytest.raises(ValueError, match="re-root them with paths_for_state"): + TicketsEngine( + paths=load_paths(project_v7.project, want_tickets=True), + policy=_policy(), + adapter=MockAdapter([]), + run_dir=run_dir, + journal=Journal(run_dir), + state=state, + ticket_tree_factory=lambda *_a, **_k: pytest.fail("built a tree"), + ) + + @pytest.mark.parametrize(("layout", "shared"), [("T1", False), ("T3", True)]) def test_engine_shares_the_store_the_run_recorded(project_v7, layout, shared): """A run that recorded a shared store (here T3, its own nested repository) @@ -1938,7 +2018,7 @@ def test_mark_done_failure_pauses_and_resume_marks_again(project_v7, monkeypatch run_dir=engine.run_dir, journal=Journal(engine.run_dir), state=state, - ticket_tree_factory=lambda root, pin: tree, + ticket_tree_factory=lambda root, pin, folder=None: tree, ) resumed.run() @@ -1989,7 +2069,7 @@ def test_worktree_unit_marks_the_mounts_plan_on_an_epic_branch(project_v7): engine, _ = make_engine( project_v7, tree, policy=pol, script=[_builds(project_v7, tree, plan)], max_stories=1 ) - engine._ticket_tree_factory = lambda root, pin: ( # type: ignore[method-assign] + engine._ticket_tree_factory = lambda root, pin, folder=None: ( # type: ignore[method-assign] tree if root == main else _MountTree(tree, main, root) ) @@ -2298,7 +2378,7 @@ def _resumed(engine: TicketsEngine, tree: FakeTicketTree, script: list): run_dir=engine.run_dir, journal=Journal(engine.run_dir), state=state, - ticket_tree_factory=lambda root, pin: tree, + ticket_tree_factory=lambda root, pin, folder=None: tree, ) return resumed, adapter @@ -3597,7 +3677,7 @@ def _resume(engine: TicketsEngine, tree: FakeTicketTree) -> TicketsEngine: run_dir=engine.run_dir, journal=Journal(engine.run_dir), state=state, - ticket_tree_factory=lambda root, pin: tree, + ticket_tree_factory=lambda root, pin, folder=None: tree, ) @@ -3887,7 +3967,7 @@ def test_shared_store_record_moves_with_a_renamed_project(project_v7_store_repo, run_dir=engine.run_dir, journal=Journal(engine.run_dir), state=engine.state, - ticket_tree_factory=lambda root, pin: tree, + ticket_tree_factory=lambda root, pin, folder=None: tree, ) assert resumed.paths.shared_store == live.store_root diff --git a/tests/test_ticketstore.py b/tests/test_ticketstore.py index 65c69c78c..92568cee0 100644 --- a/tests/test_ticketstore.py +++ b/tests/test_ticketstore.py @@ -193,6 +193,30 @@ def test_tracked_user_config_refuses_redirect(over, code): assert isinstance(classify_store(_ignored(**over), "none"), StoreTopology) +@pytest.mark.parametrize( + ("over", "code"), + [ + ({"user_config_tracked": True, "user_config_ignored": False}, "user-config-tracked"), + ({"user_config_ignored": False}, "user-config-not-ignored"), + ], +) +def test_chained_run_refuses_an_unsafe_user_config_on_a_tracked_store(over, code): + """A chained run under worktree isolation rewrites the mount's + `config.user.toml` (its child as `active_initiative`) on every layout, so a + tracked store (T1), which needs no redirect, is refused like a shared one when + the file is tracked or not gitignored. An unchained T1 run writes nothing there + and still runs; without isolation there is no mount, so a chained run writes + nothing either and runs. Ablation (performed): drop `chained` from the + precondition in `classify_store` and both rows FAIL.""" + refused = classify_store(_facts(**over), "worktree", chained=True) + assert isinstance(refused, StoreRefusal) and refused.code == code + assert "chained run's child initiative" in (store_topology_conflict(refused) or "") + t1 = StoreTopology("T1", "checkout", "code-repo", STORE) + assert classify_store(_facts(**over), "worktree") == t1 + assert classify_store(_facts(**over), "none", chained=True) == t1 + assert classify_store(_facts(), "worktree", chained=True) == t1 + + @pytest.mark.parametrize("layout", ["T2", "T3", "T4", "T6"]) def test_shared_layouts_run(layout): """Every classified shared layout runs: its plans are marked after the code @@ -256,12 +280,12 @@ def test_with_shared_store_sets_the_store_only_when_shared(): def _redirected(text: str) -> dict: - return tomllib.loads(redirected_user_config_text(text, MAIN)) + return tomllib.loads(redirected_user_config_text(text, main_store=MAIN, initiative=None)) def test_redirected_user_config_text_inserts_into_core_table(): text = '[core]\nactive_initiative = "initiative-demo"\n' - out = redirected_user_config_text(text, MAIN) + out = redirected_user_config_text(text, main_store=MAIN, initiative=None) assert out.startswith("[core]\noutput_folder = ") assert tomllib.loads(out)["core"] == { "output_folder": str(MAIN), @@ -280,9 +304,9 @@ def test_redirected_user_config_text_replaces_existing_output_folder(): def test_redirected_user_config_text_appends_core_table_when_absent(): assert _redirected('[modules.x]\nk = "v"')["core"] == {"output_folder": str(MAIN)} - assert redirected_user_config_text("", MAIN) == f'[core]\noutput_folder = "{MAIN}"\n'.replace( - "\\", "\\\\" - ) + assert redirected_user_config_text( + "", main_store=MAIN, initiative=None + ) == f'[core]\noutput_folder = "{MAIN}"\n'.replace("\\", "\\\\") @pytest.mark.parametrize( @@ -290,12 +314,12 @@ def test_redirected_user_config_text_appends_core_table_when_absent(): ) def test_redirected_user_config_text_refuses_inline_core_table(text): with pytest.raises(TicketStoreError): - redirected_user_config_text(text, MAIN) + redirected_user_config_text(text, main_store=MAIN, initiative=None) def test_redirected_user_config_text_preserves_crlf(): text = '# mine\r\n[core]\r\nactive_initiative = "x"\r\n' - out = redirected_user_config_text(text, MAIN) + out = redirected_user_config_text(text, main_store=MAIN, initiative=None) assert out.count("\r\n") == out.count("\n") == 4 assert out.startswith("# mine\r\n[core]\r\noutput_folder = ") @@ -304,6 +328,58 @@ def test_redirected_user_config_text_preserves_crlf(): def test_redirected_user_config_text_escapes_the_path(raw): """Quotes and backslashes (a Windows path) survive as a TOML basic string.""" store = Path(raw) - assert tomllib.loads(redirected_user_config_text("", store))["core"]["output_folder"] == str( - store + assert tomllib.loads(redirected_user_config_text("", main_store=store, initiative=None))[ + "core" + ]["output_folder"] == str(store) + + +def test_redirected_user_config_text_writes_only_the_initiative(): + """A chained run over a tracked store sets `active_initiative` alone: the + existing value is replaced in place under `[core]`, no `output_folder` appears, + and every other line is kept.""" + text = '# mine\n[core]\nactive_initiative = "initiative-parent"\nlang = "en"\n' + out = redirected_user_config_text(text, main_store=None, initiative="initiative-child") + assert out == '# mine\n[core]\nactive_initiative = "initiative-child"\nlang = "en"\n' + + +def test_redirected_user_config_text_writes_both_values(): + text = '[core]\nactive_initiative = "initiative-parent"\noutput_folder = "x"\n[other]\nk = 1\n' + data = tomllib.loads( + redirected_user_config_text(text, main_store=MAIN, initiative="initiative-child") ) + assert data == { + "core": {"output_folder": str(MAIN), "active_initiative": "initiative-child"}, + "other": {"k": 1}, + } + appended = redirected_user_config_text("", main_store=MAIN, initiative="initiative-child") + assert tomllib.loads(appended)["core"] == data["core"] + + +def test_redirected_user_config_text_keeps_crlf_with_both_values(): + text = '[core]\r\nactive_initiative = "p"\r\n' + out = redirected_user_config_text(text, main_store=MAIN, initiative="c") + assert out.count("\r\n") == out.count("\n") == 3 + assert tomllib.loads(out)["core"] == {"output_folder": str(MAIN), "active_initiative": "c"} + + +def test_redirected_user_config_text_escapes_the_initiative(): + raw = 'odd "quoted" \\ name' + out = redirected_user_config_text("", main_store=None, initiative=raw) + assert tomllib.loads(out)["core"] == {"active_initiative": raw} + + +def test_redirected_user_config_text_needs_a_value(): + """Nothing to write is a caller fault; a blank initiative would read as no + active initiative, so it is refused rather than written.""" + with pytest.raises(ValueError): + redirected_user_config_text("", main_store=None, initiative=None) + with pytest.raises(TicketStoreError, match="blank initiative"): + redirected_user_config_text("", main_store=MAIN, initiative=" ") + + +@pytest.mark.parametrize( + "text", ['core = { active_initiative = "x" }\n', 'core.active_initiative = "x"\n'] +) +def test_redirected_user_config_text_refuses_inline_core_table_for_the_initiative(text): + with pytest.raises(TicketStoreError): + redirected_user_config_text(text, main_store=None, initiative="initiative-child") diff --git a/tests/test_ticketstore_probe.py b/tests/test_ticketstore_probe.py index 9dc6f44b7..b81f0f3eb 100644 --- a/tests/test_ticketstore_probe.py +++ b/tests/test_ticketstore_probe.py @@ -1,12 +1,13 @@ """The probe half of `ticketstore` on real git repositories: what `probe_store_facts` reads, what `assess_store` / `store_conflict` answer for each -sandbox layout, and `redirect_mount_store` on disk. Real-spawn budget: 0 spawns of +sandbox layout, and `redirect_mount_config` on disk. Real-spawn budget: 0 spawns of tickets.py (git only).""" from __future__ import annotations import dataclasses import os +import shutil import tomllib from pathlib import Path @@ -348,11 +349,11 @@ def test_store_conflict_refuses_a_recorded_store_whose_initiative_is_gone( assert str(recorded.store_root) in text -def test_redirect_mount_store_rewrites_the_user_layer(project_v7, tmp_path): +def test_redirect_mount_config_rewrites_the_user_layer(project_v7, tmp_path): """The mount's personal layer points `output_folder` at the main store, the other keys kept, and the merged config reads it back the way tickets.py does.""" store = tmp_path / "main-store" - ticketstore.redirect_mount_store(project_v7.project, store) + ticketstore.redirect_mount_config(project_v7.project, main_store=store, initiative=None) text = (project_v7.project / ticketstore.USER_CONFIG_REL).read_text(encoding="utf-8") assert tomllib.loads(text)["core"] == { "output_folder": str(store), @@ -362,15 +363,78 @@ def test_redirect_mount_store_rewrites_the_user_layer(project_v7, tmp_path): assert git(project_v7.project, "status", "--porcelain") == "" -def test_redirect_mount_store_refuses_an_unredirectable_config(project_v7, tmp_path): +def test_redirect_mount_config_writes_a_childs_initiative(project_v7, tmp_path): + """A chained mount's personal layer names the child as `active_initiative`, + alone or beside the store redirect, and the merged config reads both back the + way tickets.py does.""" + ticketstore.redirect_mount_config( + project_v7.project, main_store=None, initiative="initiative-child" + ) + path = project_v7.project / ticketstore.USER_CONFIG_REL + assert tomllib.loads(path.read_text(encoding="utf-8"))["core"] == { + "active_initiative": "initiative-child" + } + assert bmadconfig.active_initiative(project_v7.project) == "initiative-child" + + store = tmp_path / "main-store" + ticketstore.redirect_mount_config( + project_v7.project, main_store=store, initiative="initiative-other" + ) + assert bmadconfig.read_core_value(project_v7.project, "output_folder")[0] == str(store) + assert bmadconfig.active_initiative(project_v7.project) == "initiative-other" + assert git(project_v7.project, "status", "--porcelain") == "" + + +def test_redirect_mount_config_refuses_a_value_the_merge_does_not_read_back( + project_v7, monkeypatch +): + """Each written key is read back from the merged layers; one that merges to + anything else (here: a stubbed read) is refused, naming the key.""" + real = bmadconfig.read_core_value + + def read(project, key): + return ("initiative-parent", "x") if key == "active_initiative" else real(project, key) + + monkeypatch.setattr(bmadconfig, "read_core_value", read) + with pytest.raises(ticketstore.TicketStoreError, match="active_initiative"): + ticketstore.redirect_mount_config( + project_v7.project, main_store=None, initiative="initiative-child" + ) + + +def test_store_conflict_classifies_a_chained_childs_store(project_v7): + """`store_conflict(initiative=)` classifies the child folder beside the active + (parent) initiative as a chained run: on a tracked store it runs while the + personal layer is untracked, and once that file is tracked only the chained + call is refused — the mount rewrite would ride the unit commit.""" + root = project_v7.project + child = project_v7.initiative_folder.parent / "initiative-child" + shutil.copytree(project_v7.initiative_folder, child) + git(root, "add", "-A") + git(root, "commit", "-q", "-m", "a child initiative") + assert ticketstore.store_conflict(root, "tickets", "worktree", initiative=child.name) is None + + git(root, "add", "-f", ticketstore.USER_CONFIG_REL.as_posix()) + git(root, "commit", "-q", "-m", "track the personal layer") + text = ticketstore.store_conflict(root, "tickets", "worktree", initiative=child.name) + assert text is not None and "config.user.toml" in text + assert ticketstore.store_conflict(root, "tickets", "worktree") is None + assert ticketstore.store_conflict(root, "tickets", "none", initiative=child.name) is None + bad = ticketstore.store_conflict(root, "tickets", "worktree", initiative="a/b") + assert bad is not None and "child initiative 'a/b'" in bad + + +def test_redirect_mount_config_refuses_an_unredirectable_config(project_v7, tmp_path): (project_v7.project / ticketstore.USER_CONFIG_REL).write_text( 'core = { active_initiative = "x" }\n', encoding="utf-8" ) with pytest.raises(ticketstore.TicketStoreError): - ticketstore.redirect_mount_store(project_v7.project, tmp_path / "store") + ticketstore.redirect_mount_config( + project_v7.project, main_store=tmp_path / "store", initiative=None + ) -def test_redirect_mount_store_refuses_an_unreadable_user_layer(project_v7, tmp_path, monkeypatch): +def test_redirect_mount_config_refuses_an_unreadable_user_layer(project_v7, tmp_path, monkeypatch): """A personal layer that exists but cannot be read is refused, never rewritten from empty: Python 3.14's `Path.is_file` reads a stat fault as False, which would drop the user's other keys.""" @@ -389,6 +453,8 @@ def read_bytes(self): monkeypatch.setattr(Path, "is_file", is_file) monkeypatch.setattr(Path, "read_bytes", read_bytes) with pytest.raises(ticketstore.TicketStoreError, match="Permission denied"): - ticketstore.redirect_mount_store(project_v7.project, tmp_path / "store") + ticketstore.redirect_mount_config( + project_v7.project, main_store=tmp_path / "store", initiative=None + ) monkeypatch.undo() assert path.read_bytes() == before diff --git a/tests/test_tickettree.py b/tests/test_tickettree.py index bf02701cc..8cf5bb685 100644 --- a/tests/test_tickettree.py +++ b/tests/test_tickettree.py @@ -1,11 +1,14 @@ """`bmad_loop.tickettree`: contract rows against the vendored `tickets.py` on `project_v7`, plus error mapping through fake scripts and canned child results. -Real-spawn budget: **14 spawns of the vendored `tickets.py`** — next 1, status 1, +Real-spawn budget: **24 spawns of the vendored `tickets.py`** — next 1, status 1, find 2 (a hit and a miss), capability 2 (`--help`, `mark --help`; the cached -second call spawns nothing), and the mark chokepoint 8 (write + read back: find +second call spawns nothing), the mark chokepoint 8 (write + read back: find and mark, 2; refused below built: find, 1; missing plan: find, 1; park then -re-arm: 2 × find and mark, 4). The fake-script rows add 5 short Python children +re-arm: 2 × find and mark, 4), and a parent initiative's child folder 4 +(`next `, 1; `next `, then find and mark, 3), and closing a +drained child 6 (LF, CRLF and BOM files: the close's `next ` and the +check's, 2 each). The fake-script rows add 5 short Python children (timeout 1, exit-2 JSON 1, exit-2 text 1, capability 2); every other row maps a canned `childrun.ChildRun`, answers `find`/`mark` in-process, or refuses before spawning, at no spawn cost. The Windows leg sits at its time budget, so keep new @@ -13,6 +16,7 @@ from __future__ import annotations +import codecs import contextlib import dataclasses import json @@ -23,6 +27,7 @@ import pytest from conftest import ( + DEFAULT_TREE, V7_USER_CONFIG_REL, EntrySpec, EpicSpec, @@ -36,18 +41,20 @@ write_ticket_tree, ) -from bmad_loop import childrun, frontmatter, platform_util, runs, tickettree +from bmad_loop import childrun, frontmatter, platform_util, runs, ticketlayout, tickettree from bmad_loop.childrun import ChildRun from bmad_loop.journal import Journal -from bmad_loop.statemachine import PlanTransitionRefused +from bmad_loop.statemachine import InitiativeCloseRefused, PlanTransitionRefused from bmad_loop.ticketmodel import NEXT_GROUPS, capability_problems from bmad_loop.tickettree import ( TicketsCrashed, TicketsError, TicketsFault, + TicketsInitiativeRefused, TicketsInterrupted, TicketsLockBusy, TicketsNoMatch, + TicketsParentInitiative, TicketsPinMismatch, TicketsPlanIOError, TicketsPlanMismatch, @@ -454,6 +461,378 @@ def test_find_passes_the_ref_after_a_separator(tmp_path, monkeypatch): assert seen[0][-3:] == ["find", "--", "-x"] +# ---------------------------------------------------------------- child folder + + +@pytest.mark.parametrize("folder", [None, "child"], ids=["active", "child-folder"]) +def test_dir_positional_rides_only_with_a_folder(tmp_path, monkeypatch, folder): + """Every tree read names the child folder as `` when the tree has one, + and the argv is the active initiative's, unchanged, when it has none. No + spawn (canned child). Ablation: drop `*self._dir()` from a verb's tail and + its child-folder row reddens.""" + seen = canned(monkeypatch, ChildRun(1, "", '{"error": "stop here"}')) + child = tmp_path / "out" / "initiative-auth" + tree = tree_at(tmp_path, folder=child if folder else None) + for call in (tree.next, tree.status, lambda: tree.find("1.1")): + with pytest.raises(TicketsFault): + call() + tails = [argv[argv.index("--project-root") + 2 :] for argv in seen] + if folder is None: + assert tree.folder is None + assert tails == [["next"], ["status"], ["find", "--", "1.1"]] + else: + assert tree.folder == child + assert tails == [ + ["next", str(child)], + ["status", str(child)], + ["find", str(child), "--", "1.1"], + ] + + +def test_explicit_status_folder_overrides_the_tree_folder(tmp_path, monkeypatch): + seen = canned(monkeypatch, ChildRun(1, "", '{"error": "stop here"}')) + other = tmp_path / "elsewhere" + with pytest.raises(TicketsFault): + tree_at(tmp_path, folder=tmp_path / "child").status(other) + assert seen[0][-2:] == ["status", str(other)] + + +PARENT = "initiative-platform" +AUTH, BILLING, REPORTS = "initiative-auth", "initiative-billing", "initiative-reports" + + +def _parent_initiative(project: V7Layout) -> tuple[Path, V7Layout, V7Layout]: + """Turn `project_v7` into a parent initiative made active: `PARENT` lists + `AUTH`, `BILLING` (after `AUTH`) and `REPORTS` (declared, no folder), and + both existing children hold `DEFAULT_TREE` — the same refs in each, so only + the `` tells them apart. Returns the parent folder and the two child + layouts.""" + root = project.project + parent = project.output_folder / PARENT + (parent / ticketlayout.TICKETS).mkdir(parents=True) + (parent / f"{PARENT}.md").write_text( + "---\ntype: initiative\ntitle: Platform\nparent: none\n---\n\n# Platform\n", + encoding="utf-8", + ) + (parent / ticketlayout.TICKETS / ticketlayout.BREAKDOWN).write_text( + f'[[initiative]]\nid = 1\nslug = "{AUTH}"\ntitle = "Auth"\n\n' + f'[[initiative]]\nid = 2\nslug = "{BILLING}"\ntitle = "Billing"\n' + 'after = [{ initiative = 1, needs = "auth live" }]\n\n' + f'[[initiative]]\nid = 3\nslug = "{REPORTS}"\ntitle = "Reports"\n', + encoding="utf-8", + ) + children = [] + for slug in (AUTH, BILLING): + layout = v7_layout(root, initiative=slug) + write_ticket_tree(layout, DEFAULT_TREE) + children.append(layout) + (root / V7_USER_CONFIG_REL).write_text( + f'[core]\nactive_initiative = "{PARENT}"\n', encoding="utf-8" + ) + return parent, children[0], children[1] + + +def test_parent_next_against_vendored_tickets_py(project_v7: V7Layout, spawns): + """`next ` on the live script parses as a `ParentView`: build order, + the waiting child not ready, the folder-less child ready. 1 real spawn.""" + parent, _, _ = _parent_initiative(project_v7) + view = TicketTree(project_v7.project, pinned_initiative=PARENT).parent_next(parent) + assert spawns[0][-2:] == ["next", str(parent)] and len(spawns) == 1 + assert (view.store, view.folder) == ("repo", PARENT) + assert [c.slug for c in view.initiatives] == [AUTH, BILLING, REPORTS] + assert view.ready == (AUTH, REPORTS) + auth, billing, reports = view.initiatives + assert (auth.folder, auth.status, auth.counts["total"], auth.ready) == (AUTH, "", 4, True) + assert billing.waiting_on == (AUTH,) and not billing.ready + assert billing.after[0].initiative == AUTH and billing.after[0].needs == "auth live" + assert (reports.folder, reports.ready, dict(reports.counts)) == (None, True, {}) + + +def test_child_folder_reads_and_marks_a_child_ticket_under_the_parent_pin( + project_v7: V7Layout, spawns, journal +): + """With the parent active and pinned, a tree on `BILLING`'s folder reads its + `next` and marks its 1.1 — the plan in `BILLING`, never `AUTH`'s twin. 3 real + spawns (next, then the mark's find and mark). + + Ablation: drop the `` from `find`/`mark` and the script, reading the + active parent, refuses with "a parent initiative holds initiatives" (a + `TicketsFault`), so this reddens.""" + _, auth, billing = _parent_initiative(project_v7) + tree = TicketTree( + project_v7.project, pinned_initiative=PARENT, folder=billing.initiative_folder + ) + view = tree.next() + assert view.folder == BILLING and [r.ref for r in view.ready_to_start] == ["1.1"] + assert view.backlog is None + plan = _cart_plan(billing, "built") + twin = _cart_plan(auth, "built") + + result = tree.mark("1.1", "done", reason="accept", expected_plan=plan, journal=journal) + + assert (result.plan, result.outcome) == (plan, "written") + assert _fields(plan) == ("done", "") and _fields(twin) == ("built", "") + child = str(billing.initiative_folder) + tails = [a[a.index("--project-root") + 2 :] for a in spawns] + assert tails == [ + ["next", child], + ["find", child, "--", "1.1"], + ["mark", child, "--", "1.1", "done"], + ] + + +def test_child_folder_pin_still_names_the_parent(project_v7: V7Layout, monkeypatch): + """The pin stays the parent's: the live value naming the child is a + mismatch, and nothing is spawned.""" + seen = canned(monkeypatch, ChildRun(0, "{}", "")) + _parent_initiative(project_v7) + (project_v7.project / V7_USER_CONFIG_REL).write_text( + f'[core]\nactive_initiative = "{BILLING}"\n', encoding="utf-8" + ) + folder = project_v7.output_folder / BILLING + tree = TicketTree(project_v7.project, pinned_initiative=PARENT, folder=folder) + for call in (tree.next, lambda: tree.find("1.1"), lambda: tree.parent_next(folder.parent)): + with pytest.raises(TicketsPinMismatch) as caught: + call() + assert (caught.value.pinned, caught.value.live) == (PARENT, BILLING) + assert seen == [] + + +def test_parent_next_on_a_plain_initiative_is_a_schema_error(tmp_path, monkeypatch): + """`next` on a folder that is no parent lists no `ready` children: version + skew or a wrong folder, never an empty parent. No spawn (canned child).""" + doc = {"store": "repo", "folder": "initiative-demo", "ready_to_start": []} + canned(monkeypatch, ChildRun(0, json.dumps(doc), "")) + with pytest.raises(TicketsSchemaError) as caught: + tree_at(tmp_path).parent_next(tmp_path / "initiative-demo") + assert not isinstance(caught.value, TicketsParentInitiative) + assert caught.value.field == "ready" + + +# ------------------------------------------------- closing a drained child initiative +# +# The real-spawn rows (6 spawns, stated above) drain AUTH and close it against the +# vendored script; every other row answers `next ` with a canned document. + +AUTH_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 _initiative_marks(journal: Journal) -> list[dict[str, object]]: + return [e for e in journal.entries() if e["kind"] == "initiative-mark"] + + +@pytest.mark.parametrize("shape", ["lf", "crlf", "bom"]) +def test_mark_initiative_done_closes_a_drained_child_for_the_script( + project_v7: V7Layout, spawns, journal, shape +): + """AUTH's tickets all done and its file carrying no `status:` line: the close + inserts `status: done` before the closing `---` (the block's own line ending, + a BOM kept), every other byte stays, and the script then reads AUTH done and + BILLING ready. 2 real spawns per shape (the close's `next`, the check's). + + Ablation: make the key insert a no-op (`_edit_frontmatter_block` returns None + for a missing key) and the read-back raises `TicketsReadBackMismatch`.""" + parent, auth, _ = _parent_initiative(project_v7) + for epic, stem, ticket in AUTH_PLANS: + write_plan(auth, epic, stem, ticket, "done") + file = auth.initiative_folder / f"{AUTH}.md" + nl, bom = ("\r\n" if shape == "crlf" else "\n"), ("\ufeff" if shape == "bom" else "") + lf = file.read_text(encoding="utf-8") + assert "status" not in lf + original = (bom + lf.replace("\n", nl)).encode("utf-8") + assert original.startswith(codecs.BOM_UTF8) == (shape == "bom") + file.write_bytes(original) + tree = TicketTree(project_v7.project, pinned_initiative=PARENT) + + result = tree.mark_initiative_done(parent, AUTH, journal=journal) + + assert (result.slug, result.file, result.outcome) == (AUTH, file, "written") + assert (result.before, result.after, result.preimage) == ("", "done", original) + closed = lf.replace("parent: none\n---", "parent: none\nstatus: done\n---") + assert file.read_bytes() == (bom + closed.replace("\n", nl)).encode("utf-8") + view = tree.parent_next(parent) + assert len(spawns) == 2 and spawns[0][-2:] == ["next", str(parent)] + auth_row, billing_row = view.child(AUTH), view.child(BILLING) + assert auth_row is not None and billing_row is not None + assert auth_row.status == "done" and dict(auth_row.counts) == {"total": 4, "done": 4} + assert billing_row.ready and billing_row.waiting_on == () and BILLING in view.ready + (mark,) = _initiative_marks(journal) + assert {k: mark[k] for k in ("parent", "slug", "file", "before", "after", "outcome")} == { + "parent": PARENT, + "slug": AUTH, + "file": str(file), + "before": "", + "after": "done", + "outcome": "written", + } + + +def _child_doc(slug: str, status: str | None, counts: dict[str, int]) -> dict[str, object]: + """One `initiatives` row as upstream prints it; ``status`` None is a child + with no folder (no `status`, no `counts`).""" + row: dict[str, object] = { + "id": slug[-1], + "slug": slug, + "title": slug, + "after": [], + "folder": None, + "waiting_on": [], + } + if status is not None: + row.update(folder=slug, status=status, counts=counts) + return row + + +def _closing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + status: str, + counts: dict[str, int] | None = None, +) -> tuple[TicketTree, Path, Path, list[list[str]]]: + """A parent `PARENT` under `tmp_path / "out"` whose child AUTH's file holds + ``status`` (no line when ``""``), and every spawn answered with the parent's + `next`: AUTH at ``status`` with ``counts`` (drained by default) and the + folder-less REPORTS. Returns the tree, the parent, AUTH's file and the argvs.""" + out = tmp_path / "out" + parent = out / PARENT + parent.mkdir(parents=True) + file = out / AUTH / f"{AUTH}.md" + file.parent.mkdir() + line = f"status: {status}\n" if status else "" + file.write_text(f"---\ntype: initiative\ntitle: Auth\n{line}---\n\n# Auth\n", encoding="utf-8") + counts = {"total": 2, "done": 2} if counts is None else counts + doc = { + "store": "repo", + "folder": PARENT, + "initiatives": [_child_doc(AUTH, status, counts), _child_doc(REPORTS, None, {})], + "ready": [REPORTS], + "order_conflict": [], + } + seen = canned(monkeypatch, ChildRun(0, json.dumps(doc), "")) + return tree_at(tmp_path), parent, file, seen + + +def test_mark_initiative_done_from_in_progress_writes(tmp_path, monkeypatch, journal): + """in-progress -> done replaces the line in place, under the file's lock, and + the pre-image is the file as it was. Dropped tickets count as closed.""" + tree, parent, file, seen = _closing( + tmp_path, monkeypatch, "in-progress", {"total": 3, "done": 1, "dropped": 2} + ) + original = file.read_bytes() + real = tree.parent_next + held: list[bool] = [] + + def probe(folder: Path): + held.append(_lock_is_held(file)) + return real(folder) + + monkeypatch.setattr(tree, "parent_next", probe) + + result = tree.mark_initiative_done(parent, AUTH, journal=journal) + + assert (result.before, result.after, result.outcome) == ("in-progress", "done", "written") + assert result.preimage == original + assert file.read_bytes() == original.replace(b"status: in-progress", b"status: done") + assert held == [True] and not _lock_is_held(file) and len(seen) == 1 + assert [e["outcome"] for e in _initiative_marks(journal)] == ["written"] + + +def test_mark_initiative_done_on_a_done_child_is_a_noop(tmp_path, monkeypatch, journal): + tree, parent, file, _ = _closing(tmp_path, monkeypatch, "done") + before = (file.read_bytes(), file.stat().st_mtime_ns) + + result = tree.mark_initiative_done(parent, AUTH, journal=journal) + + assert (result.before, result.after, result.outcome) == ("done", "done", "noop") + assert result.preimage is None + assert (file.read_bytes(), file.stat().st_mtime_ns) == before + assert [e["outcome"] for e in _initiative_marks(journal)] == ["noop"] + + +@pytest.mark.parametrize( + ("status", "counts", "why"), + [ + ("dropped", None, "a dropped initiative is final"), + ("in-progress", {"total": 4, "done": 3}, "not drained: 3 of 4"), + ("", {"total": 4, "done": 2, "in-progress": 2}, "not drained: 2 of 4"), + ("", {"total": 0}, "not drained: 0 of 0"), + ], +) +def test_mark_initiative_done_refusals_leave_the_file( + tmp_path, monkeypatch, journal, status, counts, why +): + """A dropped child is never overridden, and an open child with a ticket left + open (or no tickets) is not drained: refused, the file untouched. + + Ablation: delete the drained check (the dropped arm) in + `check_initiative_close` and the not-drained rows (the dropped row) redden.""" + tree, parent, file, _ = _closing(tmp_path, monkeypatch, status, counts) + before = (file.read_bytes(), file.stat().st_mtime_ns) + + with pytest.raises(TicketsInitiativeRefused, match=why) as caught: + tree.mark_initiative_done(parent, AUTH, journal=journal) + + assert isinstance(caught.value, InitiativeCloseRefused) and caught.value.kind == "refused" + assert (file.read_bytes(), file.stat().st_mtime_ns) == before + marks = _initiative_marks(journal) + assert [(m["before"], m["after"], m["outcome"]) for m in marks] == [(status, status, "refused")] + + +@pytest.mark.parametrize(("slug", "shown"), [(BILLING, "no such child"), (REPORTS, "no folder")]) +def test_mark_initiative_done_refuses_a_child_the_parent_does_not_list( + tmp_path, monkeypatch, journal, slug, shown +): + """A slug the parent does not list, or the folder-less REPORTS, is refused + before any decision: the cross-check `mark` makes with `find`.""" + tree, parent, file, _ = _closing(tmp_path, monkeypatch, "") + original = file.read_bytes() + + with pytest.raises(TicketsPlanMismatch, match=shown): + tree.mark_initiative_done(parent, slug, journal=journal) + + assert file.read_bytes() == original + marks = _initiative_marks(journal) + assert [(m["slug"], m["outcome"]) for m in marks] == [(slug, "refused")] + assert marks[0]["file"] == str(parent.parent / slug / f"{slug}.md") + + +def test_mark_initiative_done_lock_busy(tmp_path, monkeypatch, journal): + """A held lock is a typed `TicketsLockBusy` that spawns, reads and writes nothing.""" + tree, parent, file, seen = _closing(tmp_path, monkeypatch, "") + original = file.read_bytes() + + @contextlib.contextmanager + def busy(path, *, blocking=True): + raise platform_util.LockUnavailableError(f"{path} is held") + yield # pragma: no cover + + monkeypatch.setattr(platform_util, "file_lock", busy) + with pytest.raises(TicketsLockBusy): + tree.mark_initiative_done(parent, AUTH, journal=journal) + assert seen == [] and file.read_bytes() == original + assert [e["outcome"] for e in _initiative_marks(journal)] == ["failed"] + + +def test_restore_preimage_undoes_an_initiative_close(tmp_path, monkeypatch, journal): + """The close's pre-image, handed to `restore_preimage`, puts the original + bytes back — the undo a later caller makes.""" + tree, parent, file, _ = _closing(tmp_path, monkeypatch, "") + original = file.read_bytes() + result = tree.mark_initiative_done(parent, AUTH, journal=journal) + assert result.preimage == original and file.read_bytes() != original + + tree.restore_preimage(result.file, result.preimage, journal) + + assert file.read_bytes() == original + restored = [e for e in journal.entries() if e["kind"] == "ticket-plan-restored"] + assert [(e["before"], e["after"], e["outcome"]) for e in restored] == [("done", "", "restored")] + + # ---------------------------------------------------------------- fake diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 0e4a1bb75..34a8bbfa7 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -5927,7 +5927,7 @@ async def test_replan_in_tickets_mode_goes_through_mark(project_tree, monkeypatc tree.finds["1.1"] = dataclasses.replace(tree.finds["1.1"], plan=str(plan)) opened: list[tuple[Path, str | None]] = [] - def factory(root: Path, pin: str | None): + def factory(root: Path, pin: str | None, *, folder: Path | None = None): opened.append((root, pin)) return tree @@ -5948,6 +5948,55 @@ def factory(root: Path, pin: str | None): assert [(e["reason"], e["after"]) for e in marks] == [("replan", "draft")] +async def test_replan_of_a_chained_run_marks_the_child_through_the_parent( + project_tree, monkeypatch +): + """A chained run's replan marks the plan through the main-checkout tree pinned + as its engine pins it — the parent the live config names, routed through the + child's folder — read from the run state re-read under the lock. Ablation + (performed): pass the run's initiative and `folder=None` again and this FAILS.""" + from bmad_loop import bmadconfig + 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") + root = project_tree.project + run_dir, plan = _tickets_paused_run( + root, stage="plan-checkpoint", plan_status="ready-for-dev", initiative="init-child" + ) + state = load_state(run_dir) + state.parent_initiative = "init-a" + save_state(run_dir, state) + parent = root / "_bmad-output" / "init-a" + loaded = bmadconfig.ProjectPaths( + project=root, + implementation_artifacts=parent, + planning_artifacts=parent, + era="v7", + active_initiative="init-a", + initiative_folder=parent, + ) + monkeypatch.setattr(bmadconfig, "load_paths", lambda _p, *, want_tickets=False: loaded) + tree = _replan_tree(plan) + opened: list[tuple[Path, str | None, Path | None]] = [] + + def factory(tree_root: Path, pin: str | None, *, folder: Path | None = None): + opened.append((tree_root, pin, folder)) + return tree + + monkeypatch.setattr(app_mod, "_replan_ticket_tree", factory) + _no_store_conflict(monkeypatch) + app = BmadLoopApp(root) + 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: calls == ["20260611-100000-aaaa"]) + assert opened == [(root, "init-a", root / "_bmad-output" / "init-child")] + assert [(m.target, m.reason) for m in tree.marks] == [("draft", "replan")] + + async def test_replan_in_tickets_mode_refused_mark_does_not_resume(project_tree, monkeypatch): """A transition the chokepoint refuses (here: the plan is already `draft`, a no-op) or a `tickets.py` fault leaves the run paused with a visible error. @@ -5966,7 +6015,7 @@ async def test_replan_in_tickets_mode_refused_mark_does_not_resume(project_tree, row = ticket_row("1.1", status="draft") tree = FakeTicketTree(plan.parent, finds={"1.1": fake_find(row, plan.parent)}) tree.finds["1.1"] = dataclasses.replace(tree.finds["1.1"], plan=str(plan)) - monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin: tree) + monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin, folder=None: tree) _no_store_conflict(monkeypatch) app = BmadLoopApp(project_tree.project) async with app.run_test() as pilot: @@ -6024,7 +6073,7 @@ def conflict(_project, source, _isolation, *, recorded=None): monkeypatch.setattr(ticketstore, "store_conflict", conflict) tree = _replan_tree(plan) - monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin: tree) + 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) @@ -6062,7 +6111,7 @@ async def test_replan_in_tickets_mode_refuses_a_worktree_isolation_conflict( project_tree.project, stage="plan-checkpoint", plan_status="ready-for-dev" ) tree = _replan_tree(plan) - monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin: tree) + 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) @@ -6100,7 +6149,7 @@ async def test_replan_in_tickets_mode_refuses_a_worktree_mount_swapped_for_a_lin roots: list[Path] = [] tree = _replan_tree(plan) - def make_tree(root, _pin): + def make_tree(root, _pin, folder=None): roots.append(root) return tree @@ -6138,7 +6187,7 @@ async def test_replan_in_tickets_mode_refuses_a_newer_checkpoint(project_tree, m project_tree.project, stage="plan-checkpoint", plan_status="ready-for-dev" ) tree = _replan_tree(plan) - monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin: tree) + 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) @@ -12493,7 +12542,7 @@ async def test_replan_locked_reread_refuses_a_runs_dir_swapped_for_a_foreign_tre project_tree.project, stage="plan-checkpoint", plan_status="ready-for-dev" ) tree = _replan_tree(plan) - monkeypatch.setattr(app_mod, "_replan_ticket_tree", lambda _root, _pin: tree) + 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) diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index a95bedb32..d8fa69c69 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -1525,7 +1525,7 @@ def _fake_board_tree(monkeypatch, root: Path) -> FakeTicketTree: ) seen: list[tuple[Path, str]] = [] - def board_tree(project: Path, initiative: str) -> FakeTicketTree: + def board_tree(project: Path, initiative: str, *, folder: Path | None = None) -> FakeTicketTree: seen.append((project, initiative)) return tree @@ -1679,7 +1679,7 @@ def test_tickets_overview_unavailable_without_initiative(tmp_path, monkeypatch): Ablation (performed): drop the initiative check in `tickets_overview` and the tree is read for initiative "" — this FAILS.""" - def no_tree(project: Path, initiative: str): + def no_tree(project: Path, initiative: str, *, folder: Path | None = None): raise AssertionError(f"read the tree for {initiative!r}") monkeypatch.setattr(data, "_board_ticket_tree", no_tree) diff --git a/tests/test_worktree_flow.py b/tests/test_worktree_flow.py index 54117fd3c..01bd72d6d 100644 --- a/tests/test_worktree_flow.py +++ b/tests/test_worktree_flow.py @@ -11,6 +11,7 @@ import json import os import shutil +import tomllib from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -1475,7 +1476,9 @@ def stop(*_args, **_kwargs): monkeypatch.setattr(worktree_flow, "provision_worktree", provision) monkeypatch.setattr(worktree_flow, "worktree_seed_undelivered", stop) - state = SimpleNamespace(target_branch="main", run_id="run-1", source="sprint", tasks={}) + state = SimpleNamespace( + target_branch="main", run_id="run-1", source="sprint", tasks={}, parent_initiative=None + ) flow = _make_flow( tmp_path, paths=paths, @@ -2425,18 +2428,23 @@ class _AfterRedirect(Exception): there without a session.""" -def _isolated_v7_flow(tmp_path, monkeypatch, layout): +def _isolated_v7_flow(tmp_path, monkeypatch, layout, *, shared=True, child=None): """`run_isolated` over a REAL git worktree of ``layout``'s code repo with the - store shared, provisioning stubbed (it copies only the personal config layer, - as `_seed_bmad_tree` does) and the first result probe raising - :class:`_AfterRedirect`. Returns ``(flow, unit, task)``.""" + store shared (unless not ``shared``), provisioning stubbed (it copies only the + personal config layer, as `_seed_bmad_tree` does) and the first result probe + raising :class:`_AfterRedirect`. A ``child`` makes the run chained: it builds + that child initiative of the layout's (parent) initiative, on the child's + paths. Returns ``(flow, unit, task)``.""" import dataclasses import bmad_loop.worktree_flow as worktree_flow from bmad_loop import bmadconfig, ticketstore paths = bmadconfig.load_paths(layout.project, want_tickets=True) - paths = dataclasses.replace(paths, shared_store=paths.output_folder) + if shared: + paths = dataclasses.replace(paths, shared_store=paths.output_folder) + if child is not None: + paths = paths.with_initiative(child) wt = tmp_path / "mount" git(layout.project, "worktree", "add", "-q", "-b", "bmad-loop/run-1/1.1", str(wt)) unit = UnitWorkspace( @@ -2457,7 +2465,14 @@ def stop(*_a, **_k): monkeypatch.setattr(worktree_flow, "provision_worktree", provision) monkeypatch.setattr(worktree_flow, "worktree_seed_undelivered", stop) - state = SimpleNamespace(target_branch="main", run_id="run-1", source="tickets", tasks={}) + state = SimpleNamespace( + target_branch="main", + run_id="run-1", + source="tickets", + tasks={}, + initiative=child or layout.initiative, + parent_initiative=None if child is None else layout.initiative, + ) flow = _make_flow( tmp_path, paths=paths, @@ -2489,6 +2504,37 @@ def test_run_isolated_redirects_ignored_store(tmp_path, monkeypatch, project_v7_ assert task.phase != Phase.ESCALATED +def test_run_isolated_writes_a_chained_childs_initiative_into_the_mount( + tmp_path, monkeypatch, project_v7 +): + """A chained run over a tracked store (T1, no redirect of its own) still gets + its mount's personal config rewritten — `active_initiative` names the child, + and only that key changes — while the user's own file in the main checkout + keeps its bytes (its `active_initiative` still names the parent). Ablation + (performed): drop the initiative from the `redirect_mount_config` call site in + `run_isolated` and this FAILS (the mount still names the parent).""" + from bmad_loop import bmadconfig, ticketstore + + main_config = project_v7.project / ticketstore.USER_CONFIG_REL + before = main_config.read_bytes() + flow, unit, task = _isolated_v7_flow( + tmp_path, monkeypatch, project_v7, shared=False, child="initiative-child" + ) + + with pytest.raises(_AfterRedirect): + flow.run_isolated(task, lambda _t: pytest.fail("drove a session")) + + assert bmadconfig.active_initiative(unit.path) == "initiative-child" + expected = tomllib.loads(before.decode("utf-8")) + expected["core"]["active_initiative"] = "initiative-child" + mounted = (unit.path / ticketstore.USER_CONFIG_REL).read_text(encoding="utf-8") + assert tomllib.loads(mounted) == expected # no output_folder on a T1 store + assert main_config.read_bytes() == before + assert bmadconfig.active_initiative(project_v7.project) == project_v7.initiative + assert git(unit.path, "status", "--porcelain") == "" + assert task.phase != Phase.ESCALATED + + def test_redirect_failure_escalates_unit(tmp_path, monkeypatch, project_v7_store_repo): """A redirect that cannot be written escalates the unit before any result probe or session. Ablation (performed): drop the `TicketStoreError` catch in @@ -2500,7 +2546,7 @@ def test_redirect_failure_escalates_unit(tmp_path, monkeypatch, project_v7_store def refuse(*_a, **_k): raise ticketstore.TicketStoreError("config.user.toml cannot take the redirect") - monkeypatch.setattr(ticketstore, "redirect_mount_store", refuse) + monkeypatch.setattr(ticketstore, "redirect_mount_config", refuse) with pytest.raises(_Pause) as excinfo: flow.run_isolated(task, lambda _t: pytest.fail("drove a session")) @@ -2510,3 +2556,29 @@ def refuse(*_a, **_k): assert "cannot take the redirect" in excinfo.value.reason assert flow.journal.events() == ["worktree-opened", "story-escalated"] assert flow.calls.pauses == [(excinfo.value.reason, "1.1")] + + +def test_chained_redirect_failure_names_the_child_initiative(tmp_path, monkeypatch, project_v7): + """A chained mount whose config cannot take the child escalates the unit + before any session, the reason naming the child initiative (and no shared + store on a T1 layout).""" + from bmad_loop import ticketstore + + flow, _unit, task = _isolated_v7_flow( + tmp_path, monkeypatch, project_v7, shared=False, child="initiative-child" + ) + seen = {} + + def refuse(_project, **kwargs): + seen.update(kwargs) + raise ticketstore.TicketStoreError("config.user.toml cannot take the redirect") + + monkeypatch.setattr(ticketstore, "redirect_mount_config", refuse) + + with pytest.raises(_Pause) as excinfo: + flow.run_isolated(task, lambda _t: pytest.fail("drove a session")) + + assert seen == {"main_store": None, "initiative": "initiative-child"} + assert task.phase == Phase.ESCALATED + assert "the child initiative 'initiative-child'" in excinfo.value.reason + assert "shared ticket store" not in excinfo.value.reason