Skip to content

Commit 678fec7

Browse files
eduralphclaude
andcommitted
feat(flow): Stacks on — auto-stacked dependency chains in one run (#123)
A single `flow` over a merge-gated, file-overlapping chain couldn't complete in one run: `Depends on (merged)` waits for a real merge, and a flow run only opens draft PRs, so the chain advanced only across separate invocations interleaved with human merges. Add an opt-in `Stacks on: <id>…` field: a dependent builds on its prerequisite's *just-produced branch* within the SAME run and publishes a separate stacked PR (`gh pr create --base <prereq-branch>`), so a planned refactor sequence (203 → 207 → 204) completes as a reviewable PR stack in one `flow` invocation. Keep `Depends on (merged)` (the conservative multi-run mode) intact. - brief.stacks_on() parses the field (shared _id_list, like depends_on_merged). - flow: _declared_deps includes stacks_on (DAG validation); _stacked_snapshot gates a dependent on its prereq being COMPLETE-with-a-published-branch (not merged), once per pass like _merged_snapshot; _deps_met honours it. A stack prerequisite publishes in-loop (the moment it's COMPLETE) — not the deferred end-publish — so a dependent's next pass can base on its branch; _publish_bundle dedups against the end-publish. - worktree: a stacked dependent's Do worktree bases off origin/<prereq-branch> (resolved from the prereq's publish.json), not the target base, so Do builds on the prereq's diff. - publish: a `Stacks on` bundle cuts its branch off the parent branch and targets the PR at it (`--base <parent-branch>`); publish.json records mode "stacked-pr" + stacks_on. Warns (best-effort, gh) if the repo disallows merge commits (a stack must merge-commit bottom-up, not squash). - docs 09 + brief field document Stacks on vs Depends on (merged), the bottom-up merge-commit rule, and the cross-run rebuild after a parent's branch changes. Scope note (settled with the maintainer): under publish-on-accept + eligibility waiting for COMPLETE, a prereq finishes ALL its iterations before its dependent ever builds, so the in-run "auto-rebuild the stack above on iterate" can't fire — the dependent always builds on the prereq's final branch. The cross-run rebuild (e.g. after a squash-merge) is the documented mechanism; no in-run cascade code is needed. Tests: stacks_on parsing; stacked-PR publish targets the parent branch + errors before the parent publishes; worktree bases off the parent branch (real git); a stacked chain completes in one flow_ids run (parent built+published before the dependent). 200 OK; lint_docs OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Eduard Ralph <15236434+eduralph@users.noreply.github.com>
1 parent 34c2db1 commit 678fec7

10 files changed

Lines changed: 275 additions & 37 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Manual wave-splitting (run a prerequisite batch to COMPLETE, *then* the next) en
5858

5959
- **`- **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.
6060
- **`- **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.
6162
- **`- **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.
6263

6364
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.

template/src/pdca_harness/brief.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,20 @@ def conflicts_with(brief_path: Path) -> list[str]:
118118
return _id_list(field(brief_path, "conflicts with", "conflicts_with"))
119119

120120

121+
def stacks_on(brief_path: Path) -> list[str]:
122+
"""Issue ids whose just-produced branch this bundle stacks on (issue #123).
123+
124+
The optional ``- **Stacks on:** <id>[, <id>…]`` field: build this bundle on top of a
125+
prerequisite's *produced patch branch* within the SAME ``flow`` run — not waiting for
126+
a merge (unlike ``Depends on (merged)``) — and publish it as a separate stacked PR
127+
(``gh pr create --base <prereq-branch>``). Use for a planned, file-overlapping refactor
128+
sequence so the whole chain completes in one run. Names the immediate parent(s); the
129+
worktree + PR base derive from the parent's ``publish.json`` (never hand-written — the
130+
branch doesn't exist at Plan time). Absent ⇒ ``[]``.
131+
"""
132+
return _id_list(field(brief_path, "stacks on", "stacks_on"))
133+
134+
121135
def onto_branch(brief_path: Path) -> tuple[str, str] | None:
122136
"""``(remote, branch)`` of an existing PR's head to stack a commit onto, or ``None``.
123137

template/src/pdca_harness/flow.py

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,33 @@ def flow(
199199
# byte-for-byte today's sort-by-name pool.
200200
# ----------------------------------------------------------------------------
201201
def _declared_deps(bp: Path) -> list[str]:
202-
"""All declared prerequisite ids — COMPLETE-gated (`Depends on`) and merge-gated
203-
(`Depends on (merged)`, #107) — for DAG validation and dispatch."""
204-
return brief.depends_on(bp) + brief.depends_on_merged(bp)
202+
"""All declared prerequisite ids — COMPLETE-gated (`Depends on`), merge-gated
203+
(`Depends on (merged)`, #107) and stack-gated (`Stacks on`, #123) — for DAG
204+
validation and dispatch."""
205+
return brief.depends_on(bp) + brief.depends_on_merged(bp) + brief.stacks_on(bp)
206+
207+
208+
def _prereq_published(cfg: Config, dep_id: str) -> bool:
209+
"""True iff prereq ``dep_id`` is COMPLETE and has a published branch (issue #123) — the
210+
foundation a ``Stacks on`` dependent builds + publishes on top of."""
211+
d = cfg.bundle(dep_id)
212+
if state.state(d) != state.COMPLETE:
213+
return False
214+
rec = publish._publish_record(d)
215+
return bool(rec and rec.get("branch"))
216+
217+
218+
def _stacked_snapshot(cfg: Config, bundles: list[Path]) -> set[str]:
219+
"""Prereq ids whose ``Stacks on`` foundation is ready now — COMPLETE with a published
220+
branch (issue #123). A stacked dependent is eligible once its parent has produced a
221+
branch to build on (not waiting for a *merge*, unlike `Depends on (merged)`), so the
222+
whole chain completes in one run. Computed once per pass, like :func:`_merged_snapshot`."""
223+
wanted: set[str] = set()
224+
for b in bundles:
225+
bp = b / "brief.md"
226+
if bp.exists():
227+
wanted.update(brief.stacks_on(bp))
228+
return {dep for dep in wanted if _prereq_published(cfg, dep)}
205229

206230

207231
def _merged_snapshot(cfg: Config, bundles: list[Path]) -> set[str]:
@@ -221,19 +245,21 @@ def _merged_snapshot(cfg: Config, bundles: list[Path]) -> set[str]:
221245
return {dep for dep in wanted if merged.is_merged(cfg, dep)}
222246

223247

224-
def _deps_met(cfg: Config, d: Path, merged_ids: set[str]) -> bool:
248+
def _deps_met(cfg: Config, d: Path, merged_ids: set[str], stacked_ids: set[str]) -> bool:
225249
"""True iff every prerequisite ``d`` declares is satisfied.
226250
227251
``Depends on`` prereqs must be COMPLETE; ``Depends on (merged)`` prereqs must be in
228-
``merged_ids`` (this pass's merged set, #107). An unplanned/reopened bundle (no brief
229-
yet) declares nothing, so it is eligible; its deps are honoured once it is re-planned.
252+
``merged_ids`` (this pass's merged set, #107); ``Stacks on`` prereqs must be in
253+
``stacked_ids`` (COMPLETE-with-a-published-branch, #123). An unplanned/reopened bundle
254+
(no brief yet) declares nothing, so it is eligible; its deps are honoured on re-plan.
230255
"""
231256
bp = d / "brief.md"
232257
if not bp.exists():
233258
return True
234259
return (all(state.state(cfg.bundle(dep)) == state.COMPLETE
235260
for dep in brief.depends_on(bp))
236-
and all(dep in merged_ids for dep in brief.depends_on_merged(bp)))
261+
and all(dep in merged_ids for dep in brief.depends_on_merged(bp))
262+
and all(dep in stacked_ids for dep in brief.stacks_on(bp)))
237263

238264

239265
def _conflict_map(cfg: Config, bundles: list[Path]) -> dict[str, set[str]]:
@@ -334,33 +360,38 @@ def _build_all(cfg: Config, bundles: list[Path]) -> None:
334360
Then advance one beat per still-running, deps-met bundle per round until nothing
335361
progresses: serial by default, or fanned across ``cfg.lanes`` workers when configured.
336362
"""
337-
# Merge-gate snapshot once per pass (#107) — keeps the gh merge check out of the beat
338-
# sweep / lane-pool dispatch; threaded into _deps_met below.
363+
# Eligibility snapshots once per pass — keep the gh merge check (#107) and the
364+
# stacked-branch check (#123) out of the beat sweep / lane-pool dispatch; threaded into
365+
# _deps_met below.
339366
merged_ids = _merged_snapshot(cfg, bundles)
367+
stacked_ids = _stacked_snapshot(cfg, bundles)
340368
# Serial Plan pre-pass — the interactive Plan beat must never enter the sweep/pool.
341369
for d in bundles:
342370
_isolate(d, "plan", lambda d=d: _plan_if_unplanned(cfg, d, None))
343371
if cfg.lanes <= 1 or len(bundles) <= 1:
344-
_beat_sweep_serial(cfg, bundles, merged_ids)
372+
_beat_sweep_serial(cfg, bundles, merged_ids, stacked_ids)
345373
else:
346-
_beat_sweep_pooled(cfg, bundles, merged_ids)
374+
_beat_sweep_pooled(cfg, bundles, merged_ids, stacked_ids)
347375

348376

349-
def _beat_sweep_serial(cfg: Config, bundles: list[Path], merged_ids: set[str]) -> None:
377+
def _beat_sweep_serial(cfg: Config, bundles: list[Path], merged_ids: set[str],
378+
stacked_ids: set[str]) -> None:
350379
"""Round-robin one beat per still-running, deps-met bundle (sort-by-name) until no
351380
bundle progresses — so the wave advances all Dos, then all Checks, then all assembles.
352381
A dep-blocked bundle simply isn't advanced (a later pass picks it up once its prereq
353-
is COMPLETE / merged); a bundle whose beat raises drops out (isolated)."""
382+
is COMPLETE / merged / published); a bundle whose beat raises drops out (isolated)."""
354383
while True:
355384
progressed = False
356385
for d in bundles:
357-
if _running(d) and _deps_met(cfg, d, merged_ids) and _advance_one(cfg, d):
386+
if (_running(d) and _deps_met(cfg, d, merged_ids, stacked_ids)
387+
and _advance_one(cfg, d)):
358388
progressed = True
359389
if not progressed:
360390
return
361391

362392

363-
def _beat_sweep_pooled(cfg: Config, bundles: list[Path], merged_ids: set[str]) -> None:
393+
def _beat_sweep_pooled(cfg: Config, bundles: list[Path], merged_ids: set[str],
394+
stacked_ids: set[str]) -> None:
364395
"""Pooled beat sweep: each round fans one beat across ``min(lanes, n)`` lane-pinned
365396
workers, conflict-aware, then **joins (a barrier per beat)** before the next round.
366397
@@ -373,7 +404,8 @@ def _beat_sweep_pooled(cfg: Config, bundles: list[Path], merged_ids: set[str]) -
373404
n_lanes = min(cfg.lanes, len(bundles))
374405
slot_of: dict[str, int] = {} # bundle name → its fixed lane slot (worktree affinity)
375406
while True:
376-
eligible = [d for d in bundles if _running(d) and _deps_met(cfg, d, merged_ids)]
407+
eligible = [d for d in bundles
408+
if _running(d) and _deps_met(cfg, d, merged_ids, stacked_ids)]
377409
if not eligible:
378410
return
379411
if not _run_beat_round_pooled(cfg, eligible, conflicts, slot_of, n_lanes):
@@ -422,6 +454,17 @@ def run_slot(slot: int, ds: list[Path]) -> None:
422454
return progressed[0]
423455

424456

457+
def _publish_bundle(cfg: Config, d: Path, *, by: str, today: str) -> None:
458+
"""Publish one COMPLETE bundle (Check's closing step), isolated so a single failure
459+
can't abort the batch (testbed #3); a non-zero return is loud, never silent (#97)."""
460+
rc = _isolate(d, "publish", lambda: publish.publish(
461+
cfg, d.name.removeprefix("issue_"),
462+
dry_run=cfg.publisher.mode == "stub", by=by, today=today, skip_if_no_target=True))
463+
if rc not in (0, None): # None ⇒ _isolate already logged an exception
464+
print(f"flow: {d.name} is COMPLETE but publish did not complete (rc {rc}) — NOT "
465+
f"published; run `pdca publish {d.name.removeprefix('issue_')}`.", file=sys.stderr)
466+
467+
425468
# ----------------------------------------------------------------------------
426469
# Shared multi-bundle driver: build all → cheap-first sign-off → publish → Act once.
427470
# ----------------------------------------------------------------------------
@@ -448,6 +491,12 @@ def _drive_and_act(
448491
# Reject an unschedulable declared-ordering graph (cycle / unresolved dep) before any
449492
# build touches a bundle (issue #36). No `Depends on` fields ⇒ no-op.
450493
_check_dep_graph(cfg, bundles)
494+
# Stack prerequisites (#123): bundles some other brief `Stacks on`. Each must publish
495+
# its branch DURING the loop — not the deferred end-publish — so a dependent's next pass
496+
# can base its worktree + PR on it. `published` dedups against the end-publish below.
497+
stack_prereqs = {cfg.bundle(dep).name
498+
for b in bundles for dep in brief.stacks_on(b / "brief.md")}
499+
published: set[str] = set()
451500
for _ in range(max_passes):
452501
# Build-all (unattended): advance each bundle to AWAITING_SIGNOFF / COMPLETE.
453502
# Each bundle is isolated — one that raises (a leaf left it half-written) is
@@ -483,6 +532,15 @@ def _drive_and_act(
483532
for d in chunk:
484533
_isolate(d, "sign-off", lambda d=d: _apply_decision(
485534
cfg, d, by=by, today=today, apply_now=False))
535+
# Publish a stack prerequisite the moment it's COMPLETE (#123) so its branch exists
536+
# for a dependent's next-pass build/publish; `published` keeps the end-loop from
537+
# re-publishing it. Independents / leaf dependents publish in the end-loop as before.
538+
if do_publish:
539+
for d in bundles:
540+
if (d.name in stack_prereqs and d.name not in published
541+
and state.state(d) == state.COMPLETE):
542+
_publish_bundle(cfg, d, by=by, today=today)
543+
published.add(d.name)
486544
if all(state.state(d) == state.COMPLETE for d in bundles):
487545
break
488546

@@ -491,17 +549,9 @@ def _drive_and_act(
491549
# Isolated like the other per-bundle loops — one bundle whose publish raises
492550
# must not abort the batch return / Act for the rest (testbed issue #3).
493551
for d in bundles:
494-
if state.state(d) == state.COMPLETE:
495-
rc = _isolate(d, "publish", lambda d=d: publish.publish(
496-
cfg, d.name.removeprefix("issue_"),
497-
dry_run=cfg.publisher.mode == "stub", by=by, today=today,
498-
skip_if_no_target=True))
499-
# rc != 0 (and not None — None means _isolate already logged an exception):
500-
# a publish that returned failure must not pass silently (#97).
501-
if rc not in (0, None):
502-
print(f"flow: {d.name} is COMPLETE but publish did not complete "
503-
f"(rc {rc}) — NOT published; run `pdca publish "
504-
f"{d.name.removeprefix('issue_')}`.", file=sys.stderr)
552+
if state.state(d) == state.COMPLETE and d.name not in published:
553+
_publish_bundle(cfg, d, by=by, today=today)
554+
published.add(d.name)
505555
if do_act:
506556
_maybe_run_act(cfg, today,
507557
any_complete=any(s == state.COMPLETE for s in results.values()))

0 commit comments

Comments
 (0)