Skip to content

Commit 973d22c

Browse files
authored
Merge pull request #38 from eduralph/feat/dag-gated-dispatch
feat(flow): DAG-gated dispatch from declared depends_on / conflicts_with (#36)
2 parents 606c4eb + bdfca25 commit 973d22c

4 files changed

Lines changed: 281 additions & 10 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ 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]
56+
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:
58+
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+
- **`- **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.
61+
62+
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 batch` / `flow` abort 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+
5564
## Integration validation — at the merge boundary
5665

5766
Whatever planning misses, correctness-*under-combination* is established where the patches actually meet: the **merge boundary**, not the lane. The harness already has the primitive — the gates are **single-sourced** ([04 - Validation Tooling](04-validation-tooling.md) §Single-sourcing): the same `pdca gates` runs over a bundle (per-fix, in a lane) **and** over the working tree (repo-scoped — `gates.run_working_tree`, "the CI merge re-gate"). Run the repo-scoped re-gate over the **merged** tree and it sees the combination the per-lane gates could not.

template/src/pdca_harness/cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import sys
1414
from pathlib import Path
1515

16-
from . import act, driver, flow, gates, publish, queue, signoff, state
16+
from . import act, brief, driver, flow, gates, publish, queue, signoff, state
1717
from .config import Config
1818

1919
# Ordering for the cheap-first sign-off queue (docs 03 §sign-off queue).
@@ -205,10 +205,22 @@ def _status(cfg: Config, issue_id: str | None) -> int:
205205
if s == state.AWAITING_SIGNOFF:
206206
n = len(signoff.open_needs_human(d / "SUMMARY.md"))
207207
flag = " [cheap: confirm]" if n == 0 else f" [{n} NEEDS-HUMAN]"
208+
blocked = _blocked_by(cfg, d) if s != state.COMPLETE else []
209+
if blocked:
210+
flag += f" [blocked-by: {', '.join(blocked)}]"
208211
print(f"{s:18}{d.name}{flag}")
209212
return 0
210213

211214

215+
def _blocked_by(cfg: Config, d: Path) -> list[str]:
216+
"""Declared `Depends on` ids of bundle ``d`` that are not yet COMPLETE (issue #36)."""
217+
bp = d / "brief.md"
218+
if not bp.exists():
219+
return []
220+
return [dep for dep in brief.depends_on(bp)
221+
if state.state(cfg.bundle(dep)) != state.COMPLETE]
222+
223+
212224
def _batch(cfg: Config, args: argparse.Namespace) -> int:
213225
"""Drive specific already-briefed issues through the FULL cycle, ending at Act.
214226

template/src/pdca_harness/flow.py

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@
2222
import datetime
2323
import sys
2424
import threading
25-
from collections import deque
2625
from pathlib import Path
2726

28-
from . import driver, lane, leaves, publish, queue, signoff, state
27+
from . import brief, driver, lane, leaves, publish, queue, signoff, state
2928
from .config import Config
3029

3130

@@ -168,6 +167,88 @@ def flow(
168167
return final
169168

170169

170+
# ----------------------------------------------------------------------------
171+
# Declared inter-bundle ordering (docs 09, issue #36). Bundles may declare
172+
# `Depends on:` / `Conflicts with:` in their brief; the scheduler gates dispatch on
173+
# them. With NO fields declared every bundle is always eligible, so dispatch is
174+
# byte-for-byte today's sort-by-name pool.
175+
# ----------------------------------------------------------------------------
176+
def _deps_met(cfg: Config, d: Path) -> bool:
177+
"""True iff every bundle ``d`` declares ``Depends on`` is COMPLETE.
178+
179+
An unplanned/reopened bundle (no brief yet) declares nothing, so it is eligible;
180+
its deps, if any, are honoured once it is re-planned on a later pass.
181+
"""
182+
bp = d / "brief.md"
183+
if not bp.exists():
184+
return True
185+
return all(state.state(cfg.bundle(dep)) == state.COMPLETE
186+
for dep in brief.depends_on(bp))
187+
188+
189+
def _conflict_map(cfg: Config, bundles: list[Path]) -> dict[str, set[str]]:
190+
"""Symmetric bundle-name → conflicting-bundle-names map, restricted to this wave.
191+
192+
A declared conflict naming a bundle outside the wave is moot (it cannot be
193+
co-scheduled with something that is not running) and is dropped.
194+
"""
195+
names = {b.name for b in bundles}
196+
conflicts: dict[str, set[str]] = {b.name: set() for b in bundles}
197+
for b in bundles:
198+
bp = b / "brief.md"
199+
if not bp.exists():
200+
continue
201+
for cid in brief.conflicts_with(bp):
202+
other = cfg.bundle(cid).name
203+
if other in names and other != b.name:
204+
conflicts[b.name].add(other)
205+
conflicts[other].add(b.name)
206+
return conflicts
207+
208+
209+
def _check_dep_graph(cfg: Config, bundles: list[Path]) -> None:
210+
"""Validate the declared `Depends on` DAG before any build (issue #36).
211+
212+
A dependency that is neither in this wave nor an already-COMPLETE bundle on
213+
disk is a misconfigured brief; a cycle is unschedulable. Both raise ``ValueError``
214+
so the run aborts before touching any bundle. No deps declared ⇒ no-op.
215+
"""
216+
names = {b.name for b in bundles}
217+
graph: dict[str, list[str]] = {}
218+
for b in bundles:
219+
bp = b / "brief.md"
220+
edges: list[str] = []
221+
for dep in (brief.depends_on(bp) if bp.exists() else []):
222+
dn = cfg.bundle(dep).name
223+
if dn in names:
224+
edges.append(dn)
225+
elif state.state(cfg.bundle(dep)) != state.COMPLETE:
226+
raise ValueError(
227+
f"{b.name}: declared dependency '{dep}' is neither in this batch "
228+
f"nor an existing COMPLETE bundle")
229+
graph[b.name] = edges
230+
231+
WHITE, GRAY, BLACK = 0, 1, 2
232+
color = dict.fromkeys(graph, WHITE)
233+
path: list[str] = []
234+
235+
def visit(n: str) -> None:
236+
color[n] = GRAY
237+
path.append(n)
238+
for m in graph[n]:
239+
if color[m] == GRAY:
240+
cyc = path[path.index(m):] + [m]
241+
raise ValueError("dependency cycle: " + " → ".join(cyc))
242+
if color[m] == WHITE:
243+
visit(m)
244+
path.pop()
245+
color[n] = BLACK
246+
247+
for n in graph:
248+
if color[n] == WHITE:
249+
visit(n)
250+
251+
171252
# ----------------------------------------------------------------------------
172253
# The unattended band: advance every bundle through Do + Check (docs 09). Serial by
173254
# default; a worker pool of cfg.lanes lanes when configured (PDCA_LANES / [driver].lanes).
@@ -185,27 +266,57 @@ def _build_all(cfg: Config, bundles: list[Path]) -> None:
185266
"""
186267
if cfg.lanes <= 1 or len(bundles) <= 1:
187268
for d in bundles:
269+
if not _deps_met(cfg, d):
270+
continue # a declared prereq isn't COMPLETE yet — a later pass picks it up
188271
def _build(d=d):
189272
_plan_if_unplanned(cfg, d, None) # iterate-plan may have re-opened it
190273
driver.run_issue(d, cfg)
191274
_isolate(d, "build/check", _build)
192275
return
193276

194-
# Serial Plan pre-pass — the interactive Plan beat stays out of the pool.
277+
# Serial Plan pre-pass — the interactive Plan beat stays out of the pool. After it
278+
# every bundle has a brief, so the declared-conflict map is complete.
195279
for d in bundles:
196280
_isolate(d, "plan", lambda d=d: _plan_if_unplanned(cfg, d, None))
281+
conflicts = _conflict_map(cfg, bundles)
197282
# Pooled drive — fixed lane slot per worker; gates read it via lane.current().
198-
work = deque(bundles)
199-
lock = threading.Lock()
283+
# A worker claims the first queued bundle whose declared deps are COMPLETE and which
284+
# conflicts with nothing currently in flight; with no fields declared the first
285+
# queued bundle is always eligible, so this is the same FIFO pool as before.
286+
remaining = list(bundles) # preserves the caller's sort-by-name order
287+
inflight: set[str] = set()
288+
cond = threading.Condition()
289+
290+
def _next_eligible() -> Path | None:
291+
# caller holds `cond`. Pop+return the first eligible bundle, else None.
292+
for i, d in enumerate(remaining):
293+
if _deps_met(cfg, d) and conflicts[d.name].isdisjoint(inflight):
294+
inflight.add(d.name)
295+
return remaining.pop(i)
296+
return None
200297

201298
def worker(slot: int) -> None:
202299
lane.set_current(slot)
203300
while True:
204-
with lock:
205-
if not work:
206-
return
207-
d = work.popleft()
301+
with cond:
302+
while True:
303+
if not remaining:
304+
return
305+
d = _next_eligible()
306+
if d is not None:
307+
break
308+
# Nothing eligible right now. If nothing is in flight to unblock the
309+
# rest, they are dep-blocked on prereqs that only go COMPLETE after a
310+
# later sign-off pass — leave them and exit. Otherwise wait for an
311+
# in-flight bundle to finish and re-check.
312+
if not inflight:
313+
cond.notify_all()
314+
return
315+
cond.wait()
208316
_isolate(d, "build/check", lambda d=d: driver.run_issue(d, cfg))
317+
with cond:
318+
inflight.discard(d.name)
319+
cond.notify_all()
209320

210321
threads = [threading.Thread(target=worker, args=(k,), name=f"pdca-lane{k}")
211322
for k in range(min(cfg.lanes, len(bundles)))]
@@ -238,6 +349,9 @@ def _drive_and_act(
238349
batch — the endpoint is Act, like any single cycle, just fanned over several bundles.
239350
"""
240351
names = {b.name for b in bundles}
352+
# Reject an unschedulable declared-ordering graph (cycle / unresolved dep) before any
353+
# build touches a bundle (issue #36). No `Depends on` fields ⇒ no-op.
354+
_check_dep_graph(cfg, bundles)
241355
for _ in range(max_passes):
242356
# Build-all (unattended): advance each bundle to AWAITING_SIGNOFF / COMPLETE.
243357
# Each bundle is isolated — one that raises (a leaf left it half-written) is

template/tests/test_flow_slice.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,5 +427,141 @@ def test_serial_path_sets_no_pdca_lane(self) -> None:
427427
self.assertEqual(val, "none")
428428

429429

430+
class DeclaredOrdering(unittest.TestCase):
431+
"""Declared inter-bundle ordering (docs 09 / issue #36): a brief may declare
432+
`Depends on:` (topological gate — a dependent isn't driven until its prereq is
433+
COMPLETE) and `Conflicts with:` (never co-scheduled in one concurrent wave). With
434+
no fields declared, dispatch is exactly today's sort-by-name pool."""
435+
436+
def setUp(self) -> None:
437+
self.tmp = Path(tempfile.mkdtemp())
438+
self.cfg = _stub_config(self.tmp)
439+
440+
def tearDown(self) -> None:
441+
shutil.rmtree(self.tmp, ignore_errors=True)
442+
443+
def _brief(self, iid: str, *, depends_on: str = "", conflicts_with: str = "") -> Path:
444+
d = self.cfg.bundle(iid)
445+
d.mkdir(parents=True)
446+
body = _TOY_BRIEF.format(slug=iid.lower())
447+
if depends_on:
448+
body += f"- **Depends on:** {depends_on}\n"
449+
if conflicts_with:
450+
body += f"- **Conflicts with:** {conflicts_with}\n"
451+
(d / "brief.md").write_text(body, encoding="utf-8")
452+
return d
453+
454+
def test_dependent_not_driven_until_prereq_complete(self) -> None:
455+
# AA depends on ZZ. Sort-by-name would build AA first; the gate must hold AA
456+
# until ZZ is COMPLETE (a later pass), proving ordering is by deps, not name.
457+
self._brief("AA", depends_on="ZZ")
458+
self._brief("ZZ")
459+
seen = {}
460+
real = driver.run_issue
461+
462+
def spy(d: Path, cfg: Config):
463+
if d.name == "issue_AA" and "zz_state" not in seen:
464+
seen["zz_state"] = state.state(cfg.bundle("ZZ"))
465+
return real(d, cfg)
466+
467+
driver.run_issue = spy
468+
try:
469+
results = flow.flow_ids(self.cfg, ["AA", "ZZ"], do_publish=False,
470+
do_act=False, today="2026-06-04")
471+
finally:
472+
driver.run_issue = real
473+
self.assertEqual(seen.get("zz_state"), state.COMPLETE) # ZZ done before AA built
474+
self.assertTrue(all(s == state.COMPLETE for s in results.values()))
475+
476+
def test_no_deps_keeps_sort_by_name_dispatch(self) -> None:
477+
# No Depends-on fields → the serial build order is exactly sort-by-name, byte
478+
# for byte today's behaviour.
479+
ids = ["N3", "N1", "N2"]
480+
for iid in ids:
481+
self._brief(iid)
482+
order: list[str] = []
483+
real = driver.run_issue
484+
485+
def spy(d: Path, cfg: Config):
486+
if d.name not in order:
487+
order.append(d.name)
488+
return real(d, cfg)
489+
490+
driver.run_issue = spy
491+
try:
492+
flow.flow_ids(self.cfg, ids, do_publish=False, do_act=False,
493+
today="2026-06-04")
494+
finally:
495+
driver.run_issue = real
496+
self.assertEqual(order, ["issue_N1", "issue_N2", "issue_N3"])
497+
498+
def test_conflict_pair_never_co_scheduled(self) -> None:
499+
# C conflicts with D; E/F are free. Under a 2-lane pool, C and D must never be
500+
# in flight together, while the free bundles still prove the pool parallelises.
501+
import threading
502+
import time
503+
504+
self._brief("C", conflicts_with="D")
505+
self._brief("D")
506+
self._brief("E")
507+
self._brief("F")
508+
self.cfg.lanes = 2
509+
510+
active: set[str] = set()
511+
together: set[tuple[str, str]] = set()
512+
max_conc = [0]
513+
lk = threading.Lock()
514+
real = driver.run_issue
515+
516+
def spy(d: Path, cfg: Config):
517+
with lk:
518+
active.add(d.name)
519+
max_conc[0] = max(max_conc[0], len(active))
520+
for a in active:
521+
for b in active:
522+
if a < b:
523+
together.add((a, b))
524+
time.sleep(0.05)
525+
try:
526+
return real(d, cfg)
527+
finally:
528+
with lk:
529+
active.discard(d.name)
530+
531+
driver.run_issue = spy
532+
try:
533+
results = flow.flow_ids(self.cfg, ["C", "D", "E", "F"], do_publish=False,
534+
do_act=False, today="2026-06-04")
535+
finally:
536+
driver.run_issue = real
537+
self.assertNotIn(("issue_C", "issue_D"), together) # conflict respected
538+
self.assertEqual(max_conc[0], 2) # pool genuinely concurrent
539+
self.assertTrue(all(s == state.COMPLETE for s in results.values()))
540+
541+
def test_dependency_cycle_is_rejected_before_build(self) -> None:
542+
# A↔B mutual dependency is unschedulable: reject up front, before any build.
543+
self._brief("CYA", depends_on="CYB")
544+
self._brief("CYB", depends_on="CYA")
545+
real = driver.run_issue
546+
built = {"n": 0}
547+
driver.run_issue = lambda d, cfg: (built.__setitem__("n", built["n"] + 1)
548+
or real(d, cfg))
549+
try:
550+
with self.assertRaises(ValueError):
551+
flow.flow_ids(self.cfg, ["CYA", "CYB"], do_publish=False,
552+
do_act=False, today="2026-06-04")
553+
finally:
554+
driver.run_issue = real
555+
self.assertEqual(built["n"], 0) # rejected before touching any bundle
556+
557+
def test_unresolved_dependency_is_rejected(self) -> None:
558+
# A dep that is neither in the wave nor an existing COMPLETE bundle is a
559+
# misconfigured brief — a hard error.
560+
self._brief("DEP1", depends_on="GHOST")
561+
with self.assertRaises(ValueError):
562+
flow.flow_ids(self.cfg, ["DEP1"], do_publish=False, do_act=False,
563+
today="2026-06-04")
564+
565+
430566
if __name__ == "__main__":
431567
unittest.main()

0 commit comments

Comments
 (0)