Skip to content

Commit 48ac4d6

Browse files
eduralphclaude
andcommitted
fix(flow): keep the batch sweep alive across an iterate-plan re-open (#105)
In a multi-bundle flow, signing a bundle off as iterate-plan left it stuck at UNPLANNED: the attempt was archived to iteration-v1/, but the re-plan + rebuild never ran. Single-issue flow() is fine (its for-max_iters loop re-plans the re-opened bundle); only the batch `_drive_and_act` loop was affected. Root cause: decisions are recorded apply_now=False, so an iterate-plan becomes ITERATE_PLAN and the archive→UNPLANNED happens in the NEXT pass's build-all — but the plan pre-pass runs before the archive, so that pass ends with the bundle freshly UNPLANNED and nothing awaiting sign-off. `if not pending: break` then exited before the pass that would re-plan it. Break only when the band made NO progress this pass: snapshot bundle states before build-all and, when nothing is pending, `continue` if any state changed (e.g. the iterate-plan re-open) and `break` only if all states held (genuinely terminal, or the planner declined an UNPLANNED bundle). Bounded by max_passes. Test: test_batch_iterate_plan_then_complete — a batch member iterate-plans, then both reach COMPLETE with the first attempt preserved in iteration-v1/. Full suite: 170 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 870e5d2 commit 48ac4d6

2 files changed

Lines changed: 37 additions & 1 deletion

File tree

template/src/pdca_harness/flow.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ def _drive_and_act(
361361
# Each bundle is isolated — one that raises (a leaf left it half-written) is
362362
# skipped this pass, never crashing the sweep and losing the others' progress.
363363
# Serial by default; fans out across cfg.lanes lanes when configured (docs 09).
364+
before = [state.state(d) for d in bundles]
364365
_build_all(cfg, bundles)
365366
# Sign-off, cheap-first, restricted to this batch. ONE interactive session
366367
# per chunk (≤ SIGNOFF_BATCH_SIZE) walks several bundles — like batch Plan —
@@ -369,7 +370,14 @@ def _drive_and_act(
369370
# build-all above applies all the iterations together.
370371
pending = [e.bundle for e in queue.awaiting_signoff(cfg) if e.bundle.name in names]
371372
if not pending:
372-
break
373+
# Break only when the band made NO progress this pass. iterate-plan archives
374+
# a bundle back to UNPLANNED — a HALTED state that needs the Plan pre-pass on a
375+
# LATER pass; on the pass where that archive happens nothing is awaiting
376+
# sign-off, so a bare `break` stranded it at UNPLANNED (#105). A state change
377+
# means progress (the re-open) — loop again so the next pass re-plans + rebuilds.
378+
if [state.state(d) for d in bundles] == before:
379+
break # genuinely stuck (all terminal / planner declined an UNPLANNED)
380+
continue # progress — give the re-opened bundle its Plan pass
373381
for chunk in _chunks(pending, SIGNOFF_BATCH_SIZE):
374382
# The session writes a decision per bundle as it goes; a dropped session
375383
# still leaves the finished ones, applied below. Isolate it so a crashed

template/tests/test_flow_slice.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,34 @@ def signoff_batch(cfg: Config, bundles: list[Path]) -> None:
190190
self.assertEqual(set(results), {"BATCH1", "BATCH2"})
191191
self.assertTrue(all(s == state.COMPLETE for s in results.values()))
192192

193+
def test_batch_iterate_plan_then_complete(self) -> None:
194+
# iterate-plan re-opens a batch member to UNPLANNED (archiving its attempt to
195+
# iteration-v1/); the sweep must keep looping so a LATER pass re-plans + rebuilds
196+
# it, rather than break at UNPLANNED when nothing is awaiting sign-off (#105).
197+
iterated = {"done": False}
198+
199+
def signoff_batch(cfg: Config, bundles: list[Path]) -> None:
200+
for d in bundles:
201+
summ = d / "SUMMARY.md"
202+
if d.name == "issue_BATCH1" and not iterated["done"]:
203+
iterated["done"] = True
204+
(d / leaves.SIGNOFF_DECISION).write_text("iterate-plan\n", encoding="utf-8")
205+
continue
206+
summ.write_text(summ.read_text().replace("- [ ]", "- [x]"), encoding="utf-8")
207+
(d / leaves.SIGNOFF_DECISION).write_text("accept\n", encoding="utf-8")
208+
209+
orig = leaves.run_signoff_batch
210+
leaves.run_signoff_batch = signoff_batch
211+
try:
212+
results = flow.flow_batch(self.cfg, today="2026-06-04", max_passes=6)
213+
finally:
214+
leaves.run_signoff_batch = orig
215+
self.assertTrue(iterated["done"])
216+
self.assertEqual(set(results), {"BATCH1", "BATCH2"})
217+
self.assertTrue(all(s == state.COMPLETE for s in results.values()))
218+
# the re-opened bundle re-planned, rebuilt, and preserved its first attempt
219+
self.assertTrue((self.cfg.bundle("BATCH1") / "iteration-v1").is_dir())
220+
193221
def test_batch_signoff_chunks_into_sessions(self) -> None:
194222
# The cheap-first queue is signed off in ONE session per chunk of
195223
# SIGNOFF_BATCH_SIZE (=5): six halted bundles → sessions of 5 then 1, all

0 commit comments

Comments
 (0)