Skip to content

Commit 0806b00

Browse files
authored
Merge pull request #148 from eduralph/feat/wave-based-batch
feat(flow): process batches as dependency waves (integration stacking + opt-in merge)
2 parents b1bda11 + 091f303 commit 0806b00

18 files changed

Lines changed: 1540 additions & 290 deletions

File tree

docs/07-publish-and-act.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ ready — the harness never does. gramps' governance makes this explicit
3737
(INTEGRATION §10): *"Eduard opens fork PRs as draft, marks ready himself; builder
3838
commits and stops — no push/PR-open/ready-mark without explicit instruction."*
3939

40+
This holds for a **batch**, too. A multi-id `pdca flow` runs the batch as dependency
41+
waves ([09 parallel lanes](../template/PCDA/quality-cycle/09-parallel-lanes.md)); in the
42+
default `stack` mode each wave's accepted work is folded onto a run-scoped integration
43+
branch the next wave builds on, and each dependent opens a **stacked** draft PR — the
44+
harness still never merges, so you review and merge the stack bottom-up yourself. Only the
45+
opt-in `[driver].wave_mode = "merge"` (own-repo / CD, where you hold merge rights on the
46+
base) relaxes this, `gh pr merge`-ing each wave before the next builds.
47+
4048
In gramps' git history you can see cycles land this way — each result bundle gets
4149
a branch and a PR:
4250

template/.claude/agents/planner.md.jinja

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,19 @@ human confirms — quantity is theirs to decide, not yours.
5050

5151
**Order the batch — set the scheduling fields (don't leave the order to chance).** When
5252
you brief several issues you are also deciding how they interleave: the flow runs the
53-
batch as a scheduled wave (docs 09). Before writing the briefs, map the batch's real
54-
shape — which issues build on another's change, and which touch the same files — then
55-
set, per brief, the three machine-parsed scheduling fields:
56-
57-
- **`Depends on:`** — a genuine build-on dependency; the flow holds this bundle until
58-
each prereq is COMPLETE.
59-
- **`Depends on (merged):`** — the stricter form: hold until the prereq's PR is *merged*;
60-
use when this issue edits files a prereq also edits, so Do builds on the merged result
61-
instead of colliding at merge time.
62-
- **`Conflicts with:`** — no dependency, but two issues edit a shared file, so they must
63-
never run in the same concurrent wave.
53+
batch as an ordered sequence of dependency **waves** (docs 09). Each wave's bundles build
54+
in parallel; its accepted work is folded onto the base the next wave builds on, so a
55+
dependent completes in the same run. Before writing the briefs, map the batch's real shape
56+
— which issues build on another's change, and which touch the same files — then set, per
57+
brief, the two machine-parsed scheduling fields:
58+
59+
- **`Depends on:`** — the PRIMARY field: a genuine build-on dependency. This bundle lands
60+
in a LATER wave than each prereq and builds on its accepted result. (This subsumes the
61+
old `Depends on (merged):` / `Stacks on:` — both are now just `Depends on`: the wave fold
62+
gives the dependent the prereq's diff without waiting for a human merge. Don't reach for
63+
them.)
64+
- **`Conflicts with:`** — no dependency, but two issues edit a shared file: they are
65+
scheduled into DIFFERENT waves, never built blind on the same base.
6466

6567
Set these from the batch's *real* dependency/conflict structure — an unordered batch
6668
either serialises needlessly or lets two bundles collide on a shared file and waste a Do.

template/PCDA/quality-cycle/09-parallel-lanes.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,24 @@ The first defense against integration tangling is to not create it. Assign work
5252

5353
Partitioning by issue id alone is not enough — it isolates the *runs* but not the *changes*. The information needed is already produced at Plan: root-cause analysis names the files / area a fix will touch. Lane assignment is therefore a **Plan-beat judgment** — the same place the human decides scope and which issues to brief ([03 - Cycle Automation](03-cycle-automation.md)) — not a mechanical sharding step. When the touched areas genuinely cannot be predicted, prefer fewer, broader lanes and lean on the integration check below.
5454

55-
### Declared ordering — `Depends on:` / `Conflicts with:` [built]
55+
### Declared ordering — `Depends on:` / `Conflicts with:` → dependency waves [built]
5656

57-
Manual wave-splitting (run a prerequisite batch to COMPLETE, *then* the next) enforces ordering by hand; it does not scale to a batch with a real dependency graph, which is exactly when the lane pool is most useful. A brief may instead **declare** its ordering constraints and let the scheduler enforce them:
57+
Manual wave-splitting (run a prerequisite batch to COMPLETE, *then* the next) enforces ordering by hand; it does not scale to a batch with a real dependency graph. A brief instead **declares** its ordering constraints and the scheduler computes the order: a batch handed to `pdca flow` runs as an ordered sequence of **dependency waves** (`waves.compute_waves`). Each wave holds only mutually-independent work; its bundles build in parallel, are signed off and published, and then the wave's accepted patches are **folded onto a run-scoped integration branch the next wave builds on** — so a dependent builds on its prerequisite's accepted result *within one run*, with no merge by the harness.
5858

59-
- **`- **Depends on:** <id>[, <id>…]`** — a topological gate. The in-driver pool dispatches a bundle only once every declared prerequisite is **COMPLETE** (signed off, not merely built). Because a prereq reaches COMPLETE only after its sign-off in an earlier pass, a dependent waits across passes — exactly the manual wave plan, now machine-enforced.
60-
- **`- **Depends on (merged):** <id>[, <id>…]`** — a *stricter* gate for a dependent that **edits files a prerequisite also edits**. `Depends on` waits only for COMPLETE — which means "a draft PR was opened", **not merged**. A dependent's Do runs in a worktree off the target base (`origin/<base>`), so a prereq whose PR is still open is *absent* from that base: the dependent is built without the predecessor's diff and its PR conflicts at merge. This field holds the dependent until the prereq's PR is **merged into the base** (read from the prereq's recorded PR via `gh pr view`), so Do genuinely builds on the merged result. Because the flow only *opens* draft PRs (it never merges), a merge-gated dependent is **held across `pdca flow` runs**: its prereq is published in one run, a human merges that PR, and a later `pdca flow <dependent>` finds it merged and proceeds. Best-effort and fail-closed: anything not confirmable as merged keeps the dependent safely blocked.
61-
- **`- **Stacks on:** <id>[, <id>…]`** — auto-stacked chains (issue #123). Like `Depends on (merged)` it is for a dependent that **edits files a prerequisite also edits**, but instead of *waiting for the prereq to merge* (multi-run), it builds the dependent on the prereq's **just-produced branch** within the **same `flow` run** and publishes a **separate stacked PR** (`gh pr create --base <prereq-branch>`, one PR per item showing only that item's increment). A stacked dependent is eligible once its prereq is **COMPLETE with a published branch**; its Do worktree bases off `origin/<prereq-branch>` and its PR targets that branch — the base is **derived from the prereq's `publish.json`**, never written in the brief (it doesn't exist at Plan time). So a planned, file-overlapping refactor sequence (`203 → 207 → 204`) completes as a reviewable PR stack in **one invocation** — independents still run in parallel; a `Stacks on` chain is sequential within itself. **Merge the stack bottom-up with merge-commit / rebase-merge, not squash** (a squash drops the parent's commits, so a child retargeted to the base re-shows the parent's diff until rebased); publish warns if the target repo disallows merge commits. If a parent's branch later changes (e.g. a squash-merge between runs), rebuild the items stacked above it (`signoff --iterate-do`) on the new foundation. Use `Depends on (merged)` instead when you'd rather wait for each PR to merge before the next builds.
62-
- **`- **Conflicts with:** <id>[, <id>…]`** — a same-wave exclusion. Two bundles that touch a shared resource (e.g. both edit one `ci.yml`) are **never in flight in the same concurrent wave**; the pool serializes them across lanes while still parallelizing everything else.
59+
- **`- **Depends on:** <id>[, <id>…]`** — the topological edge. The dependent lands in a **later wave** than every prerequisite and builds on the integration branch that carries the prereqs' accepted diffs. Because the fold carries the diff forward without a merge, this single field now **subsumes** the former `Depends on (merged)` and `Stacks on`.
60+
- **`- **Conflicts with:** <id>[, <id>…]`** — an undirected "edit a shared resource" relation. Because each wave is folded onto the base before the next builds, two conflicting bundles must not share a wave; the leveler **orients each conflict pair into different waves** (by a deterministic id order, unless a dependency path already separates them), so the later one rebuilds on the earlier's folded result instead of colliding.
61+
- **`- **Depends on (merged):** / **Stacks on:**`***deprecated, still parsed.* Both fold into a plain `Depends on` edge: the wave model gives the dependent the prerequisite's accepted diff (via the integration branch) without the old cross-run merge wait (`Depends on (merged)`) or the single-chain branch-stacking (`Stacks on`, whose multi-parent gap the integration branch fixes). Author `Depends on`.
6362

64-
The fields are **additive and backwards-compatible**: with none declared, every bundle is always eligible and dispatch is byte-for-byte the prior **sort-by-name pool**. An unschedulable graph — a cycle, or a dependency that is neither in the batch nor an already-COMPLETE bundle — is a **hard error rejected before any build** (`pdca flow` aborts up front). `pdca status` shows a `[blocked-by: <ids>]` flag so the queue reads as a DAG, not a flat list. Declared ordering complements lane planning: planning *avoids* integration tangling by code locality; `depends_on` / `conflicts_with` *enforce* the residual ordering that locality cannot express.
63+
With no fields declared the batch is a **single wave**, dispatched byte-for-byte the prior **sort-by-name pool**. An unschedulable graph — a cycle, or a dependency neither in the batch nor an already-COMPLETE bundle — is a **hard error rejected before any build** (`waves.compute_waves` raises; `pdca flow` aborts up front). `pdca status` shows a `[blocked-by: <ids>]` flag, and `pdca waves` prints the computed wave plan up front. A post-Do **overlap audit** flags two same-wave bundles whose patches touch a shared file but declared no `Conflicts with` — a likely planner omission.
64+
65+
### Carrying a wave forward — stack (default) vs merge [built]
66+
67+
How a wave's accepted work reaches the next wave's base is `[driver].wave_mode`:
68+
69+
- **`stack`** (default) — fold the accepted patches onto a run-scoped integration branch on `origin` (push-only, so a **fork** contributor can do it) and open each dependent's PR as a **stacked PR** against it. The harness never merges; you merge the PR stack bottom-up with a **merge commit** (not squash, which would drop a parent's commits). A non-applying patch (an undeclared overlap) STOPs the run. STOP discipline holds throughout.
70+
- **`merge`** (own-repo / continuous-delivery only) — `gh pr merge` each non-final wave's PRs so the next wave builds on the genuinely-merged base. Needs **merge rights** on the base remote (a fork lacks them upstream — keep `stack` there) and relaxes STOP discipline; fail-closed (a non-mergeable PR STOPs the run).
71+
72+
Optionally (`[driver].regate_between_waves`), the repo-scoped gates re-run over the folded integration tip before the next wave builds on it, so a combination that is red though each fix was green *alone* STOPs the run.
6573

6674
## Integration validation — at the merge boundary
6775

@@ -78,7 +86,7 @@ The lanes parallelize the **unattended** band only. The three human touch points
7886
- **Check sign-off** is interactive — converge here. (A *single-workspace* run can batch sign-off across the fanned-out bundles into one cheap-first session; independent lane *copies* each carry their own sign-off queue, so the human attends them in turn — an ergonomic cost of full copies versus an in-driver fan-out.)
7987
- **Act** runs once, across the completed cycles of all lanes — serial by nature.
8088

81-
So the shape is: **Plan (serial) → Do + Check fan out across lanes → sign-off (serial join) → publish → integration re-gate at the merge boundary → Act once.** Parallelism lives entirely in the unattended middle; planning and the merge re-gate carry correctness across the results.
89+
So the shape is, **per wave**: Plan (serial) → Do + Check fan out across lanes → sign-off (serial join) → publish → **fold onto the integration branch** the next wave builds on (or, opt-in `merge` mode, `gh pr merge`) → optional re-gate — repeated wave by wave, then **Act once** across the batch. Parallelism lives in the unattended middle of each wave; the wave ordering and the integration fold (then the human's bottom-up merge of the PR stack) carry correctness across the results.
8290

8391
## Two realizations — separate workspaces vs an in-driver pool
8492

template/pdca.toml.jinja

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ lanes = 1
3535
# `$PDCA_WORKTREE` too (see the gate examples below). On by default; best-effort (a target
3636
# that isn't a worktree-capable git checkout falls back to in-place). Set false to disable.
3737
worktree = true
38+
# Wave-based batch sequencing (#wave-model). When `pdca flow` is given several ids it runs
39+
# them as an ordered sequence of dependency waves (computed from each brief's `Depends on`
40+
# / `Conflicts with`): a wave's bundles build in parallel, then its accepted work reaches
41+
# the NEXT wave's base one of two ways.
42+
# wave_mode = "stack" (default) — fold each wave's accepted patches onto a run-scoped
43+
# integration branch the next wave builds on, and open each PR as a stacked PR. This
44+
# is PUSH-ONLY (a fork has push), keeps the STOP discipline (the harness never merges;
45+
# you merge the PR stack bottom-up with a merge commit, not squash), and completes a
46+
# dependent batch in one run. The fork-safe default.
47+
# wave_mode = "merge" (own-repo / continuous-delivery ONLY) — `gh pr merge` each
48+
# non-final wave's PRs so the next wave builds on the genuinely-merged base. Needs
49+
# MERGE RIGHTS on the base remote (a fork contributor lacks them upstream — keep
50+
# "stack" there) and relaxes the STOP discipline for the batch. Fail-closed: a
51+
# non-mergeable PR stops the run.
52+
wave_mode = "stack"
53+
# The `gh pr merge` strategy when wave_mode = "merge": "merge" (a merge commit — auditable,
54+
# bisectable; the default), "squash", or "rebase".
55+
merge_method = "merge"
56+
# Optional integration re-gate (#wave-model). After each wave folds (stack mode), run the
57+
# repo-scoped gates over the folded integration tip before the next wave builds on it, so a
58+
# combination that is red though each fix was green ALONE stops the run. Off by default
59+
# (needs your repo-scoped gates); in merge mode the PR's own CI is the merge-boundary check.
60+
regate_between_waves = false
3861
# Act cadence (issue #109). Act is a cross-cycle beat that yields a real delta only once
3962
# enough cycles have frozen to show a pattern, so `pdca flow` auto-runs it only when this
4063
# many cycles have frozen SINCE the last Act review — counted across flow invocations

template/src/pdca_harness/cli.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pathlib import Path
1717

1818
from . import (act, brief, driver, flow, gates, merged, publish, queue, revalidate,
19-
signoff, state)
19+
signoff, state, waves)
2020
from .config import Config
2121

2222

@@ -80,6 +80,11 @@ def main(argv: list[str] | None = None) -> int:
8080
p_status = sub.add_parser("status", help="list bundle states (cheap-first queue)")
8181
p_status.add_argument("issue_id", nargs="?")
8282

83+
p_waves = sub.add_parser("waves",
84+
help="show the computed dependency-wave plan for a batch (no build)")
85+
p_waves.add_argument("issue_ids", nargs="*",
86+
help="ids to schedule; none → every in-flight briefed bundle")
87+
8388
sub.add_parser("queue", help="the cheap-first sign-off burn-down (AWAITING_SIGNOFF)")
8489

8590
p_gates = sub.add_parser("gates", help="run the deterministic Check gates (driver + CI share this)")
@@ -151,6 +156,8 @@ def main(argv: list[str] | None = None) -> int:
151156
return _flow(cfg, args)
152157
if args.cmd == "status":
153158
return _status(cfg, args.issue_id)
159+
if args.cmd == "waves":
160+
return _waves(cfg, args.issue_ids)
154161
if args.cmd == "queue":
155162
return _queue(cfg)
156163
if args.cmd == "gates":
@@ -304,6 +311,34 @@ def _status(cfg: Config, issue_id: str | None) -> int:
304311
return 0
305312

306313

314+
def _waves(cfg: Config, ids: list[str]) -> int:
315+
"""Print the computed dependency-wave plan for a batch — deterministic, no build
316+
(#wave-model). With no ids, schedules every in-flight briefed bundle. An unschedulable
317+
graph (cycle / unresolved dep) is reported, not run."""
318+
if ids:
319+
bundles = [cfg.bundle(i) for i in ids if (cfg.bundle(i) / "brief.md").exists()]
320+
elif cfg.bundle_root.exists():
321+
bundles = sorted((d for d in cfg.bundle_root.glob("issue_*")
322+
if d.is_dir() and (d / "brief.md").exists()
323+
and state.state(d) not in (state.COMPLETE, state.DISCONTINUED)),
324+
key=lambda p: p.name)
325+
else:
326+
bundles = []
327+
if not bundles:
328+
print("(no briefed bundles to schedule)")
329+
return 0
330+
try:
331+
plan = waves.compute_waves(cfg, bundles)
332+
except ValueError as exc:
333+
print(f"unschedulable: {exc}", file=sys.stderr)
334+
return 1
335+
print(f"{len(bundles)} bundle(s) → {len(plan)} wave(s) ({cfg.wave_mode} mode; "
336+
f"each wave builds on the prior's accepted work):")
337+
for k, wave in enumerate(plan):
338+
print(f" wave {k}: " + ", ".join(d.name.removeprefix("issue_") for d in wave))
339+
return 0
340+
341+
307342
def _publish_flag(d: Path) -> str:
308343
"""A COMPLETE bundle's publish state (#97): a real publish writes publish.json with the
309344
PR url; absent ⇒ accepted-but-unpublished (dry-run / no-target / failed / not-yet-run),
@@ -314,10 +349,16 @@ def _publish_flag(d: Path) -> str:
314349
return " [close: no PR]"
315350
return " [unpublished]"
316351
try:
317-
url = json.loads(pj.read_text(encoding="utf-8")).get("pr_url")
352+
rec = json.loads(pj.read_text(encoding="utf-8"))
318353
except (ValueError, OSError):
319354
return " [published]"
320-
return f" [PR {url}]" if url else " [published]"
355+
url, base = rec.get("pr_url"), rec.get("base")
356+
if not url:
357+
return " [published]"
358+
# A stacked PR (#wave-model / #123) targets the wave integration branch, not the base —
359+
# show ↑<base> so the human knows to merge the stack bottom-up.
360+
stacked = rec.get("mode") in ("stacked-pr", "stacked")
361+
return f" [PR {url}{f' ↑{base}' if stacked else ''}]"
321362

322363

323364
def _blocked_by(cfg: Config, d: Path) -> list[str]:

0 commit comments

Comments
 (0)