devlyn-cli 4.2.5 → 4.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import argparse
13
13
  import contextlib
14
14
  import datetime
15
+ import errno
15
16
  import functools
16
17
  import hashlib
17
18
  import json
@@ -23,7 +24,6 @@ import shlex
23
24
  import subprocess
24
25
  import sys
25
26
  import tempfile
26
- import time
27
27
  import unittest
28
28
 
29
29
  SKILLS = Path(__file__).resolve().parents[2]
@@ -437,7 +437,7 @@ def parse_loop(data, loop, label):
437
437
  """A loop's queue file: its rows, each a task row of the loop, and the legacy row it replaces as (occurrence, line)."""
438
438
  require(data is not None, f"{label}: no such file")
439
439
  rows = parse_queue(data, label)
440
- require(all(row["identity"] and row["loop"] == loop for row in rows), f"{label}: every row must be a task row of loop {loop}")
440
+ require(rows and all(row["identity"] and row["loop"] == loop for row in rows), f"{label}: every row must be a task row of loop {loop}")
441
441
  targets = [(int(m[1]), m[2]) for line in data.decode("utf-8").split("\n") if (m := REPLACES_RE.fullmatch(line.removesuffix("\r")))]
442
442
  require(len(targets) <= 1, f"{label}: replaces more than one legacy row")
443
443
  return rows, targets[0] if targets else None
@@ -480,11 +480,25 @@ def queue_view(anchor, recorded):
480
480
  raw = [row for row in parse_queue(legacy.read_bytes() if legacy.exists() else b"") if not row["identity"]]
481
481
  loops = {loop: parse_loop(show(anchor, added["commit"], loop_queue(loop)), loop, f"{CAPTURES}{loop}:{loop_queue(loop)}")
482
482
  for loop, added in recorded.items()}
483
- for rel in sorted(filter(None, git(anchor, "ls-files", "-z", "--", ":(glob)docs/specs/*/queue.md").split("\0")), key=lambda rel: rel.split("/")[2]):
483
+ tracked = set(filter(None, git(anchor, "ls-files", "-z", "--", ":(glob)docs/specs/*/queue.md").split("\0")))
484
+ head = ref_value(anchor, "HEAD")
485
+ if head:
486
+ tracked.update(rel for rel in git(anchor, "ls-tree", "-r", "-z", "--name-only", head, "--", "docs/specs").split("\0")
487
+ if re.fullmatch(r"docs/specs/[^/]+/queue\.md", rel))
488
+ for rel in sorted(tracked, key=lambda rel: rel.split("/")[2]):
484
489
  loop, data = rel.split("/")[2], (anchor / rel).read_bytes() if (anchor / rel).is_file() else None
485
- # A file holding no task row of its directory's loop is not a loop's queue file.
486
- if loop not in recorded and data and any(row.get("loop") == loop for row in parse_queue(data, anchor / rel)):
487
- loops[loop] = parse_loop(data, loop, anchor / rel)
490
+ if loop not in recorded:
491
+ # A directory name alone proves nothing. A task row in the checkout or HEAD proves this is a loop queue;
492
+ # HEAD keeps that proof when the checkout loses its rows. Strict decoding/parsing follows only that proof.
493
+ known = False
494
+ for candidate in (data, show(anchor, head, rel) if head else None):
495
+ for line in (candidate or b"").decode("utf-8", errors="replace").splitlines():
496
+ match = ROW_RE.fullmatch(line)
497
+ item = ITEM_RE.fullmatch(match["text"].rstrip()) if match else None
498
+ if item and item["loop"] == loop and bool(item["rest"]) == (match["mark"] == "F"):
499
+ known = True
500
+ if known:
501
+ loops[loop] = parse_loop(data, loop, anchor / rel)
488
502
  claims = {}
489
503
  for loop, (_, target) in loops.items():
490
504
  if target:
@@ -518,32 +532,47 @@ def clean(common, recorded):
518
532
  notes = []
519
533
  for origin in dict.fromkeys(added["origin"] for added in recorded.values()):
520
534
  root = Path(origin)
521
- if not (root / "docs/specs").is_dir():
535
+ try:
536
+ if not (root / "docs/specs").is_dir():
537
+ continue
538
+ untracked = set(filter(None, git(root, "ls-files", "--others", "-z", "--", "docs/specs").split("\0")))
539
+ except (LoopError, OSError) as exc:
540
+ notes.append(f"package cleanup retained: {root}: {exc}")
522
541
  continue
523
- untracked = set(filter(None, git(root, "ls-files", "--others", "-z", "--", "docs/specs").split("\0")))
524
542
  for loop, added in recorded.items():
525
543
  package = f"docs/specs/{loop}/"
526
544
  if added["origin"] != origin or not any(rel.startswith(package) for rel in untracked):
527
545
  continue
528
- for rel, state in package_state(root, added["commit"], loop).items():
546
+ try:
547
+ states = package_state(root, added["commit"], loop)
548
+ except (LoopError, OSError) as exc:
549
+ notes.append(f"package cleanup retained: {root / package}: {exc}")
550
+ continue
551
+ for rel, state in states.items():
529
552
  if rel in untracked and state == "different":
530
553
  notes.append(f"{root / rel}: kept, it differs from the captured package {CAPTURES}{loop}")
531
554
  elif rel in untracked and state == "equal":
532
555
  try:
533
556
  (root / rel).unlink()
534
557
  except OSError as exc:
535
- notes.append(f"{root / rel}: incomplete cleanup: {exc}")
558
+ notes.append(f"package cleanup retained: {root / rel}: {exc}")
536
559
  for directory, _, _ in os.walk(root / package, topdown=False):
537
- with contextlib.suppress(OSError):
560
+ try:
538
561
  os.rmdir(directory)
562
+ except OSError as exc:
563
+ if exc.errno not in {errno.ENOTEMPTY, errno.EEXIST, errno.ENOENT}:
564
+ notes.append(f"package cleanup retained: {directory}: {exc}")
539
565
  return notes
540
566
 
541
567
 
542
- def task_complete(action, **values):
568
+ def task_complete(action, *, exclude_receipts=(), **values):
543
569
  helper = shared("task-complete")
544
570
  try:
545
- return helper[action](argparse.Namespace(**values))
571
+ # Preserve the previous-receipt sweep, excluding only queue-invalid owners.
572
+ options = {"exclude_receipts": exclude_receipts} if action == "allocate" else {}
573
+ return helper[action](argparse.Namespace(**values), **options)
546
574
  except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
575
+ # Inherited helper containment includes these three programming-error types; the controller boundary is narrower.
547
576
  raise LoopError(f"task-complete {action}: {exc}") from exc
548
577
 
549
578
 
@@ -552,16 +581,60 @@ def derive_state(anchor, common, row, claims, rev):
552
581
  state = {"kind": {" ": "pending", "x": "accepted", "F": "failed"}[row["mark"]], "receipt": None, "path": None}
553
582
  require(len(claims) <= 1, f"conflicting receipts for {identity}: {', '.join(str(path) for path, _ in claims)}")
554
583
  if not claims:
584
+ path = receipt_path(common, identity)
585
+ if path.parent.exists() or path.parent.is_symlink():
586
+ state.update(kind="pending", barrier=True,
587
+ invalid=f"receipt missing at {path}; allocation ownership cannot be verified; inspect {path.parent} and any "
588
+ f"{branch_of(identity)} branch/worktree, remove only confirmed interrupted-allocation artifacts, then drain again")
555
589
  return state
556
590
  path, receipt = claims[0]
557
- require(receipt.get("branch") == branch_of(identity) and path == receipt_path(common, identity),
591
+ require(isinstance(receipt, dict), f"receipt {path} is not an object")
592
+ require(receipt.get("task") == identity and receipt.get("branch") == branch_of(identity) and path == receipt_path(common, identity)
593
+ and path == path.resolve(),
558
594
  f"receipt {path} claims {identity} from branch {receipt.get('branch')!r}; conflicting ownership, inspect it")
559
- if receipt.get("allocation") != "owned":
595
+ require(receipt.get("allocation") in ("owned", "allocating"),
596
+ f"receipt {path}: missing or malformed allocation checkpoint")
597
+ if receipt["allocation"] == "allocating":
560
598
  # Never adopted, and no executor ran: the task waits until the user removes what the allocation left.
599
+ state["kind"] = "pending"
561
600
  state["invalid"] = (f"allocation did not finish ({path}); remove its worktree {receipt.get('worktree')} and branch "
562
601
  f"{receipt['branch']} if present, delete {path.parent}, then drain again")
563
602
  return state
564
- result = None if not receipt.get("acceptance") else "failed" if receipt.get("product") == "FAILED" else "accepted"
603
+ require(all(isinstance(receipt.get(key), str) and receipt[key] and "\0" not in receipt[key] for key in
604
+ ("id", "baseline", "worktree", "common_gitdir", "recovery_ref")), f"receipt {path}: malformed allocation fields")
605
+ require(receipt["common_gitdir"] == str(common) and receipt["id"] == path.parent.name,
606
+ f"receipt {path}: conflicting common Gitdir/id binding")
607
+ require(receipt["recovery_ref"] == "refs/devlyn/completed/" + receipt["id"], f"receipt {path}: conflicting recovery ref ownership")
608
+ for key in ("delivery", "product", "pr_url", "base", "remote"):
609
+ require(key not in receipt or receipt[key] is None or isinstance(receipt[key], str), f"receipt {path}: malformed {key}")
610
+ for key in ("acceptance", "queue", "merge", "workspace_cleanup", "scratch_cleanup"):
611
+ require(key not in receipt or isinstance(receipt[key], dict), f"receipt {path}: malformed {key}")
612
+ if receipt.get("merge"):
613
+ merge = receipt["merge"].get("mergeCommit")
614
+ require(isinstance(merge, dict) and isinstance(merge.get("oid"), str) and SHA_RE.fullmatch(merge["oid"]),
615
+ f"receipt {path}: malformed merge binding")
616
+ for key in ("workspace_cleanup", "scratch_cleanup"):
617
+ if value := receipt.get(key):
618
+ require(isinstance(value.get("status"), str) and (key != "workspace_cleanup" and not value.get("reason") or
619
+ isinstance(value.get("reason"), str) and isinstance(value.get("resume"), str)), f"receipt {path}: malformed {key}")
620
+ require(not receipt.get("acceptance") or isinstance(receipt["acceptance"], dict)
621
+ and all(isinstance(receipt.get(key), str) and SHA_RE.fullmatch(receipt[key]) for key in ("source_sha", "publish_sha"))
622
+ and isinstance(receipt["acceptance"].get("inputs_sha"), str)
623
+ and isinstance(receipt["acceptance"].get("reasons"), list)
624
+ and all(isinstance(reason, str) for reason in receipt["acceptance"]["reasons"]), f"receipt {path}: malformed acceptance binding")
625
+ require(not receipt.get("queue") or isinstance(receipt["queue"], dict)
626
+ and all(isinstance(receipt["queue"].get(key), str) and "\0" not in receipt["queue"][key] for key in ("file", "commit")),
627
+ f"receipt {path}: malformed terminal binding")
628
+ if receipt.get("acceptance"):
629
+ verdict = receipt["acceptance"].get("verdict")
630
+ require(verdict in ("ACCEPTED", "FAILED"), f"receipt {path}: malformed acceptance verdict")
631
+ require(receipt.get("product") is None or receipt["product"] == verdict,
632
+ f"receipt {path}: inconsistent acceptance verdict/product")
633
+ require(isinstance(receipt.get("files"), dict) and receipt["files"], f"receipt {path}: malformed bound files")
634
+ for key in ("assumptions", "reasons"):
635
+ value = receipt["acceptance"].get(key, [])
636
+ require(isinstance(value, list) and all(isinstance(item, str) for item in value), f"receipt {path}: malformed acceptance {key}")
637
+ result = None if not receipt.get("acceptance") else "failed" if receipt["acceptance"]["verdict"] == "FAILED" else "accepted"
565
638
  require(row["mark"] == " " or result == state["kind"],
566
639
  f"conflicting terminal state for {identity}: queue row [{row['mark']}] but receipt {path} is {result or 'unbound'}")
567
640
  if result:
@@ -573,16 +646,34 @@ def derive_state(anchor, common, row, claims, rev):
573
646
  if receipt.get("queue"):
574
647
  marks = [r["mark"] for r in parse_queue(show(anchor, receipt["publish_sha"], receipt["queue"]["file"]) or b"") if r["identity"] == identity]
575
648
  require(marks == ["x" if result == "accepted" else "F"], f"{identity}: terminal commit {receipt['publish_sha']} does not carry its {result} mark")
649
+ require(result != "failed" or receipt["acceptance"]["reasons"], f"receipt {path}: failed acceptance has no reason")
576
650
  packet = path.parent / "packet.json"
577
- if not result and packet.exists():
651
+ if packet.exists():
578
652
  bound = read_json(packet)
579
- for key in ("contract", "expected"):
580
- rel = Path(bound[key]["path"]).relative_to(bound["worktree"]).as_posix()
581
- current = package_bytes(anchor, rev, rel)
582
- if current is None or hashlib.sha256(current).hexdigest() != bound[key]["sha256"]:
653
+ require(isinstance(bound, dict) and not acceptance()["PACKET_KEYS"] - bound.keys(), f"{packet}: malformed task packet")
654
+ require(bound["worktree"] == receipt["worktree"] and bound["task"] == identity and bound["branch"] == receipt["branch"]
655
+ and bound["allocation_base"] == receipt["baseline"],
656
+ f"{packet}: conflicting packet ownership")
657
+ for key, expected in (("receipt", path), ("scratch", path.parent / "scratch"), ("submission", path.parent / "submission.json"),
658
+ ("evidence_dir", Path(receipt["worktree"]) / ".devlyn/loop")):
659
+ require(bound.get(key) == str(expected), f"{packet}: malformed or conflicting {key} path")
660
+ require(isinstance(bound["inputs_sha"], str) and SHA_RE.fullmatch(bound["inputs_sha"]), f"{packet}: malformed inputs commit")
661
+ require(bound.get("delivery") in ("local-only", "auto", "pr"), f"{packet}: malformed delivery")
662
+ for key in ("requirements", "review_requirements"):
663
+ require(isinstance(bound[key], list) and all(isinstance(item, str) for item in bound[key]), f"{packet}: malformed {key}")
664
+ for key in ("contract", "expected", "meta"):
665
+ entry = bound[key]
666
+ require(isinstance(entry, dict) and isinstance(entry.get("path"), str) and isinstance(entry.get("sha256"), str)
667
+ and re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]), f"{packet}: malformed {key} binding")
668
+ candidate = Path(entry["path"])
669
+ require("\0" not in entry["path"] and candidate.is_absolute() and candidate.is_relative_to(Path(bound["worktree"]))
670
+ and ".." not in candidate.parts,
671
+ f"{packet}: {key} path escapes its worktree: {candidate}")
672
+ rel = candidate.relative_to(bound["worktree"]).as_posix()
673
+ current = package_bytes(anchor, rev, rel) if not result else None
674
+ if not result and key != "meta" and (current is None or hashlib.sha256(current).hexdigest() != bound[key]["sha256"]):
583
675
  # Drain fails this task alone; the revision is planned as a new task.
584
676
  state["inputs_changed"] = f"inputs-changed: {rel} changed after its inputs were committed; plan the revision as a new task"
585
- break
586
677
  state.update(kind=result or "active", path=path, receipt=receipt)
587
678
  return state
588
679
 
@@ -593,26 +684,45 @@ def view(anchor, common, local_only=False):
593
684
  recorded = records(common)
594
685
  cleanup = clean(common, recorded)
595
686
  rows = queue_view(anchor, recorded)
596
- claims, unreadable = {}, []
687
+ claims, unreadable, damaged = {}, [], {}
688
+ canonical = {receipt_path(common, row["identity"]): row["identity"] for row in rows if row["identity"]}
689
+ branches = {branch_of(identity): identity for identity in canonical.values()}
597
690
  for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
598
691
  try:
599
692
  receipt = read_json(path)
600
- claims.setdefault(receipt["task"], []).append((path, receipt))
601
- except (LoopError, KeyError, TypeError) as exc:
693
+ require(isinstance(receipt, dict), f"{path}: malformed receipt identity")
694
+ task, branch = receipt.get("task"), receipt.get("branch")
695
+ implicated = {task if isinstance(task, str) else None, canonical.get(path),
696
+ branches.get(branch) if isinstance(branch, str) else None} - {None}
697
+ require(implicated, f"{path}: malformed receipt identity")
698
+ for identity in implicated:
699
+ claims.setdefault(identity, []).append((path, receipt))
700
+ except (LoopError, OSError) as exc:
602
701
  unreadable.append(f"{path}: {exc}")
702
+ if identity := canonical.get(path):
703
+ damaged[identity] = str(exc)
603
704
  v = {"anchor": anchor, "common": common, "rows": rows, "records": recorded, "packages": {}, "manifests": {}, "states": {},
604
705
  "unreadable": unreadable, "cleanup": cleanup, "local_only": local_only}
605
706
  errors = {}
606
707
  for row in v["rows"]:
607
708
  if row["identity"]:
608
- state = v["states"][row["identity"]] = derive_state(anchor, common, row, claims.get(row["identity"], []), capture(v, row["loop"]))
709
+ identity = row["identity"]
710
+ try:
711
+ require(identity not in damaged, damaged.get(identity))
712
+ state = derive_state(anchor, common, row, claims.get(identity, []), capture(v, row["loop"]))
713
+ except (LoopError, OSError) as exc:
714
+ # No acceptance/frontier may be inferred from conflicting evidence. Its possible writer or carrier
715
+ # still holds the loop barrier, even when no trustworthy receipt can be retained in the view.
716
+ state = {"kind": "pending", "receipt": None, "path": None, "barrier": True,
717
+ "invalid": f"recovery conflict: {exc}; inspect and restore {identity}'s verified state, then drain again"}
718
+ v["states"][identity] = state
609
719
  # A changed active task needs no current package text; a validation failure stops only its own task.
610
- if state["kind"] == "pending" or state["kind"] == "active" and "inputs_changed" not in state:
720
+ if "invalid" not in state and (state["kind"] == "pending" or state["kind"] == "active" and "inputs_changed" not in state):
611
721
  loop = row["loop"]
612
722
  if loop not in v["packages"] and loop not in errors:
613
723
  try:
614
724
  v["packages"][loop] = load_package(anchor, loop, capture(v, loop))
615
- except LoopError as exc:
725
+ except (LoopError, OSError) as exc:
616
726
  errors[loop] = str(exc)
617
727
  package = v["packages"].get(loop)
618
728
  if problem := errors.get(loop) or package["invalid"].get(row["task"]) or (
@@ -622,10 +732,15 @@ def view(anchor, common, local_only=False):
622
732
  state = v["states"].get(row["identity"])
623
733
  for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"] if state and state["kind"] == "pending" and "invalid" not in state else []:
624
734
  dep_state = v["states"].get(f"{row['loop']}.{dep}")
625
- require(dep_state is not None, f"{row['identity']} depends on {row['loop']}.{dep}, which is not queued")
735
+ if dep_state is None:
736
+ state["invalid"] = (f"{row['identity']} depends on {row['loop']}.{dep}, which is not queued; restore the authoritative "
737
+ "queue from verified state or plan the continuation as a new loop, then drain again")
738
+ break
626
739
  if dep_state["kind"] in {"failed", "blocked"}:
627
740
  state.update(kind="blocked", blocker=f"{row['loop']}.{dep}", root=dep_state.get("root") or dep_state)
628
741
  break
742
+ v["invalid_receipts"] = {path for identity, state in v["states"].items() if "invalid" in state
743
+ for path in (receipt_path(common, identity), *(path for path, _ in claims.get(identity, [])))}
629
744
  return v
630
745
 
631
746
 
@@ -662,8 +777,18 @@ def carrier(v, loop):
662
777
 
663
778
 
664
779
  def waiting(v, row):
780
+ # Eligibility precedes selection, so its task-local reads cannot reach advance's exception boundary.
781
+ try:
782
+ return wait_reason(v, row)
783
+ except (LoopError, OSError) as exc:
784
+ return f"allocation blocked: {exc}; inspect and repair {row['identity']}'s starting state, then drain again"
785
+
786
+
787
+ def wait_reason(v, row):
665
788
  if invalid := v["states"][row["identity"]].get("invalid"):
666
789
  return invalid
790
+ if barrier := recovery_barrier(v, row):
791
+ return barrier
667
792
  local = is_local(v, row["loop"])
668
793
  if v["local_only"] and not local:
669
794
  return published(v, row["loop"])
@@ -683,11 +808,17 @@ def waiting(v, row):
683
808
  return None
684
809
 
685
810
 
811
+ def recovery_barrier(v, row):
812
+ return next((f"waiting for {identity} (recovery conflict), since its possible writer or package carrier holds this loop"
813
+ for identity, state in v["states"].items() if identity != row["identity"]
814
+ and identity.split(".")[0] == row["loop"] and state.get("barrier")), None)
815
+
816
+
686
817
  def next_task(v, attempted):
687
818
  """Earliest unsettled receipt first, then the earliest eligible pending row in physical order."""
688
819
  for row in v["rows"]:
689
820
  state = v["states"].get(row["identity"])
690
- if state and state["receipt"] and row["identity"] not in attempted and "invalid" not in state and (
821
+ if state and state["receipt"] and row["identity"] not in attempted and "invalid" not in state and not recovery_barrier(v, row) and (
691
822
  state["kind"] == "active" or not state["receipt"].get("queue") or state["receipt"].get("delivery") not in SETTLED):
692
823
  return row
693
824
  return next((row for row in v["rows"] if row["identity"] not in attempted and v["states"].get(row["identity"], {}).get("kind") == "pending"
@@ -698,7 +829,8 @@ def annotate(v, refused):
698
829
  """Name each task's wait: a pending task's reason from waiting(), else why this drain did not advance it."""
699
830
  for row in v["rows"]:
700
831
  state = v["states"].get(row["identity"])
701
- if state and (reason := state["kind"] == "pending" and waiting(v, row) or refused.get(row["identity"])):
832
+ if state and (reason := refused.get(row["identity"]) or state["kind"] == "pending" and waiting(v, row)
833
+ or recovery_barrier(v, row)):
702
834
  state["waiting"] = reason
703
835
 
704
836
 
@@ -800,12 +932,16 @@ def input_files(v, row, receipt, local):
800
932
  return files
801
933
 
802
934
 
803
- def require_merged(v, row, base):
935
+ def require_merged(v, row, base, *, allocated=False):
804
936
  """auto/pr: every prerequisite's merge commit must be in the base, before allocation and before every execution."""
805
937
  for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"]:
806
938
  receipt = v["states"][f"{row['loop']}.{dep}"]["receipt"] or {}
807
939
  merge = ((receipt.get("merge") or {}).get("mergeCommit") or {}).get("oid")
808
- require(merge and ancestor(v["anchor"], merge, base), f"{row['identity']}: base {base} lacks the delivered prerequisite merge {merge}")
940
+ remedy = ("retain this allocation and plan the continuation as a new loop, or perform verified recovery of a never-executed allocation"
941
+ if allocated else f"restore that merge on origin/{manifest_of(v, row['loop'])['base_ref']}, then drain again")
942
+ require(merge and ancestor(v["anchor"], merge, base),
943
+ f"{row['identity']}: {'allocated baseline' if allocated else 'base'} {base} lacks prerequisite {row['loop']}.{dep}'s "
944
+ f"delivered merge {merge}; {remedy}")
809
945
 
810
946
 
811
947
  def package_drift(v, loop, start, where):
@@ -877,19 +1013,21 @@ def allocate(v, row, opts):
877
1013
  f"plan the loop on {default}, or drain with --local-only")
878
1014
  try:
879
1015
  start = helper["remote_base"]({"common_gitdir": str(common), "remote": "origin", "base": manifest["base_ref"]})
880
- except helper["CompletionError"] as exc:
881
- raise LoopError(f"{identity}: cannot refresh base {manifest['base_ref']}: {exc}") from exc
1016
+ except (helper["CompletionError"], OSError) as exc:
1017
+ raise LoopError(f"cannot refresh origin/{manifest['base_ref']}: {exc}; restore remote access or the base ref, "
1018
+ "then drain again; the next drain retries") from exc
882
1019
  values["start"] = start
883
1020
  require_merged(v, row, start)
884
1021
  if drift := package_drift(v, loop, start, f"origin/{manifest['base_ref']}"):
885
1022
  return drift
886
1023
  require(evidence_ignored(anchor, common, start),
887
1024
  f"{identity}: .devlyn/ is not ignored in its start commit {start}, so loop evidence would dirty task source; commit a "
888
- f"`.devlyn/` entry to .gitignore in the base the task starts from, or add `.devlyn/` to {common / 'info' / 'exclude'}")
1025
+ f"`.devlyn/` entry to .gitignore in the base the task starts from (push it for remote delivery), or add `.devlyn/` to "
1026
+ f"{common / 'info' / 'exclude'}; then drain again")
889
1027
  if values["start"] and (drift := instruction_drift(anchor, start)):
890
1028
  return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
891
1029
  f"without them; commit them and push them to origin/{manifest['base_ref']}")
892
- result = task_complete("allocate", **values)
1030
+ result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], **values)
893
1031
  progress(identity, f"allocated {result['worktree']}")
894
1032
  return None
895
1033
 
@@ -954,28 +1092,25 @@ def ensure_submission(identity, packet_path, packet, executor):
954
1092
  again.
955
1093
 
956
1094
  An attempt's start is recorded before the spawn and its exit after the wait, so a start without an exit may have
957
- left a live executor: drain waits until no process uses the worktree, then adopts its submission or runs the
958
- executor again. Where writers cannot be observed the task fails as interrupted-unobservable."""
1095
+ left a live executor: drain observes once and waits if it is active. Only established cessation permits adoption
1096
+ or another execution. Where writers cannot be observed the task fails as interrupted-unobservable."""
959
1097
  submission = Path(packet["submission"])
960
1098
  worktree = Path(packet["worktree"])
961
1099
  log = submission.with_name("executions.log")
962
- events = log.read_text(encoding="utf-8").splitlines() if log.exists() else []
1100
+ try:
1101
+ events = log.read_text(encoding="utf-8").splitlines() if log.exists() else []
1102
+ except UnicodeDecodeError as exc:
1103
+ raise LoopError(f"{log} is not UTF-8: {exc}; restore the execution history before draining again") from exc
963
1104
  if events and events[-1].partition(" ")[2] == "start":
964
1105
  helper = shared("task-complete")
965
- reported = False
966
- while True:
967
- try:
968
- helper["stopped_writers"](worktree)
969
- break
970
- except helper["WritersUnobservable"] as exc:
971
- return f"interrupted-unobservable: an interrupted executor may still be writing ({exc})"
972
- except helper["WriterActive"] as exc:
973
- if not reported:
974
- progress(identity, f"waiting for an interrupted execution to stop ({exc})")
975
- reported = True
976
- time.sleep(2)
977
- except helper["CompletionError"] as exc:
978
- raise LoopError(f"{identity}: cannot establish that an interrupted execution stopped ({exc})") from exc
1106
+ try:
1107
+ helper["stopped_writers"](worktree)
1108
+ except helper["WritersUnobservable"] as exc:
1109
+ return f"interrupted-unobservable: an interrupted executor may still be writing ({exc})"
1110
+ except helper["WriterActive"] as exc:
1111
+ raise Waiting(f"interrupted execution still active: {exc}; wait for or stop the named writer, then drain again") from exc
1112
+ except (helper["CompletionError"], OSError) as exc:
1113
+ raise LoopError(f"cannot establish that an interrupted execution stopped: {exc}; repair writer observation, then drain again") from exc
979
1114
  if submission.exists():
980
1115
  return None
981
1116
  argv = [part.replace("{packet}", str(packet_path)) for part in executor]
@@ -992,7 +1127,8 @@ def ensure_submission(identity, packet_path, packet, executor):
992
1127
  stdout=stdout, stderr=stderr)
993
1128
  except OSError as exc:
994
1129
  record(log, f"not started: {one_line(str(exc))}")
995
- raise LoopError(f"executor could not start: {exc}") from exc
1130
+ raise LoopError(f"executor could not start: {exc}; restore the configured executable, permissions or task workspace; "
1131
+ "the next drain retries") from exc
996
1132
  progress(identity, f"executing; output in {output}")
997
1133
  code = child.wait()
998
1134
  record(log, f"exit {code} (pid {child.pid})")
@@ -1003,7 +1139,7 @@ def ensure_submission(identity, packet_path, packet, executor):
1003
1139
 
1004
1140
  def terminal_queue(cwd, receipt, identity):
1005
1141
  """The loop queue file of this bound result's only legal terminal transition: its row becomes [x] or [F] — <reason>."""
1006
- failed = receipt.get("product") == "FAILED"
1142
+ failed = receipt["acceptance"]["verdict"] == "FAILED"
1007
1143
  suffix = f" — {one_line(receipt['acceptance']['reasons'][0])} (receipt {receipt['id']})" if failed else ""
1008
1144
  rel = loop_queue(identity.split(".")[0])
1009
1145
  return transition(show(cwd, receipt["acceptance"]["inputs_sha"], rel) or b"", identity, "F" if failed else "x", suffix)
@@ -1021,7 +1157,7 @@ def settle(v, row, receipt_file):
1021
1157
  identity = row["identity"]
1022
1158
  receipt = read_json(receipt_file)
1023
1159
  worktree, source, branch = Path(receipt["worktree"]), receipt["source_sha"], receipt["branch"]
1024
- failed = receipt.get("product") == "FAILED"
1160
+ failed = receipt["acceptance"]["verdict"] == "FAILED"
1025
1161
  with lock(v["common"], "queue.lock", blocking=True):
1026
1162
  head = git(worktree, "rev-parse", "refs/heads/" + branch)
1027
1163
  if head == source:
@@ -1041,6 +1177,8 @@ def settle(v, row, receipt_file):
1041
1177
  def advance(v, row, opts):
1042
1178
  """Run or resume one task from its durable state; never replays a bound result. Returns why it waits instead."""
1043
1179
  identity = row["identity"]
1180
+ state = v["states"][identity]
1181
+ state["phase"] = "allocation"
1044
1182
  path = receipt_path(v["common"], identity)
1045
1183
  local = is_local(v, row["loop"])
1046
1184
  if opts.local_only and not local:
@@ -1049,32 +1187,32 @@ def advance(v, row, opts):
1049
1187
  return refused
1050
1188
  receipt = read_json(path)
1051
1189
  if not receipt.get("acceptance"):
1190
+ state["phase"] = "inputs"
1052
1191
  failure = v["states"][identity].get("inputs_changed")
1053
1192
  if not failure and not receipt.get("local_only"):
1054
- require_merged(v, row, receipt["baseline"])
1193
+ require_merged(v, row, receipt["baseline"], allocated=True)
1055
1194
  packet_path, packet = ensure_packet(v, row, path, receipt)
1056
- try:
1057
- failure = failure or ensure_submission(identity, packet_path, packet, opts.executor)
1058
- except Waiting as exc:
1059
- return str(exc)
1195
+ state["phase"] = "executor"
1196
+ failure = failure or ensure_submission(identity, packet_path, packet, opts.executor)
1197
+ state["phase"] = "acceptance"
1060
1198
  try:
1061
1199
  result = acceptance()["accept"](packet_path, packet["submission"], failure)
1062
1200
  except acceptance()["AcceptanceError"] as exc:
1063
1201
  raise LoopError(f"{identity}: acceptance could not run: {exc}") from exc
1064
1202
  progress(identity, result["verdict"].lower() + "".join(f"; {reason}" for reason in result["reasons"][:3]))
1203
+ state["phase"] = "custody binding"
1065
1204
  task_complete("accept", receipt=str(path), acceptance=str(Path(packet["worktree"]) / ".devlyn/loop/acceptance.json"))
1066
1205
  progress(identity, "bound")
1206
+ state["phase"] = "terminal transition"
1067
1207
  if not read_json(path).get("queue"):
1068
1208
  settle(v, row, path)
1069
1209
  receipt = read_json(path)
1210
+ state["phase"] = "delivery"
1070
1211
  if receipt.get("delivery") not in SETTLED:
1071
1212
  packet = read_json(path.parent / "packet.json")
1072
- try:
1073
- # The plan's mode seeds the first delivery call; a later per-task --mode persists in the receipt.
1074
- result = task_complete("complete", receipt=str(path), acceptance=None,
1075
- mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
1076
- except LoopError as exc:
1077
- return f"delivery blocked: {exc}"
1213
+ # The plan's mode seeds the first delivery call; a later per-task --mode persists in the receipt.
1214
+ result = task_complete("complete", receipt=str(path), acceptance=None,
1215
+ mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
1078
1216
  progress(identity, f"delivery {result['status']}")
1079
1217
  if result.get("merge_refused"):
1080
1218
  return f"merge refused: {result['merge_refused']}; a person merges the PR"
@@ -1093,6 +1231,16 @@ def summary(v, row):
1093
1231
  item["reason"] = reason
1094
1232
  if receipt:
1095
1233
  acceptance_record = receipt.get("acceptance") or {}
1234
+ branch, worktree = None, None
1235
+ try:
1236
+ worktree = receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None
1237
+ if receipt.get("branch") and ref_value(v["anchor"], "refs/heads/" + receipt["branch"]):
1238
+ branch = receipt["branch"]
1239
+ except (LoopError, OSError) as exc:
1240
+ diagnostic = (f"task summary unavailable: {row['identity']} worktree {receipt.get('worktree')}, "
1241
+ f"branch {receipt.get('branch')}: {exc}; repair access and drain again")
1242
+ if diagnostic not in v["cleanup"]:
1243
+ v["cleanup"].append(diagnostic)
1096
1244
  item.update(allocation_base=receipt.get("baseline"), **{"source" if state["kind"] == "accepted" else "candidate": receipt.get("source_sha")},
1097
1245
  terminal=(receipt.get("queue") or {}).get("commit"), delivery=receipt.get("delivery"), pr=receipt.get("pr_url"),
1098
1246
  resume=None if not receipt.get("acceptance") or receipt.get("delivery") in SETTLED
@@ -1100,9 +1248,9 @@ def summary(v, row):
1100
1248
  if receipt.get("queue") else "drain again: the terminal commit is not attached yet",
1101
1249
  assumptions=acceptance_record.get("assumptions", []),
1102
1250
  questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
1103
- worktree=receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None,
1251
+ worktree=worktree,
1104
1252
  cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
1105
- branch=receipt.get("branch") if receipt.get("branch") and ref_value(v["anchor"], "refs/heads/" + receipt["branch"]) else None,
1253
+ branch=branch,
1106
1254
  recovery_ref=receipt.get("recovery_ref"),
1107
1255
  custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
1108
1256
  scratch=(f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("scratch_cleanup") or {}).get("reason")
@@ -1154,34 +1302,37 @@ def bring_in(v, loop):
1154
1302
  def write_reports(v, status, reason):
1155
1303
  paths = []
1156
1304
  for loop in dict.fromkeys(row["loop"] for row in v["rows"] if row["identity"]):
1157
- items = [summary(v, row) for row in v["rows"] if row.get("loop") == loop]
1158
- lines = [f"# Drain report — {loop}", "", f"- Generated: {datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}",
1159
- f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
1160
- f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
1161
- *([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
1305
+ path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
1162
1306
  try:
1163
- meta, _ = load_manifest(v["anchor"], loop, capture(v, loop))
1164
- decisions = sections(
1165
- JSON_FENCE_RE.sub("", meta),
1166
- v["anchor"] / f"docs/specs/{loop}/meta.md",
1167
- )["Decisions and assumptions"] or "None recorded."
1307
+ items = [summary(v, row) for row in v["rows"] if row.get("loop") == loop]
1308
+ lines = [f"# Drain report — {loop}", "", f"- Generated: {datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}",
1309
+ f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
1310
+ f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
1311
+ *([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
1312
+ try:
1313
+ meta, _ = load_manifest(v["anchor"], loop, capture(v, loop))
1314
+ decisions = sections(
1315
+ JSON_FENCE_RE.sub("", meta),
1316
+ v["anchor"] / f"docs/specs/{loop}/meta.md",
1317
+ )["Decisions and assumptions"] or "None recorded."
1318
+ except (LoopError, OSError) as exc:
1319
+ decisions = f"Unavailable: {exc}"
1320
+ lines += ["## Package decisions and assumptions", "", decisions, ""]
1321
+ labels = (("result", "Product"), ("reason", "Reason"), ("receipt", "Receipt"), ("custody", "Evidence custody"),
1322
+ ("recovery_ref", "Recovery ref"), ("allocation_base", "Allocation base"), ("source", "Accepted source"), ("candidate", "Unaccepted source"),
1323
+ ("terminal", "Terminal commit"), ("delivery", "Delivery"), ("pr", "PR"), ("resume", "Resume"),
1324
+ ("assumptions", "Assumptions"), ("questions", "Unresolved questions"), ("worktree", "Retained worktree"),
1325
+ ("cleanup", "Workspace cleanup"),
1326
+ ("branch", "Branch"), ("scratch", "Scratch cleanup"))
1327
+ for item in items:
1328
+ lines += [f"## {item['identity']}", ""]
1329
+ lines += [f"- {label}: {'; '.join(value) if isinstance(value, list) else value}"
1330
+ for key, label in labels if (value := item.get(key)) not in (None, [], "")]
1331
+ lines.append("")
1332
+ acceptance()["atomic_write"](path, "\n".join(lines).encode("utf-8"))
1333
+ paths.append(str(path))
1168
1334
  except (LoopError, OSError) as exc:
1169
- decisions = f"Unavailable: {exc}"
1170
- lines += ["## Package decisions and assumptions", "", decisions, ""]
1171
- labels = (("result", "Product"), ("reason", "Reason"), ("receipt", "Receipt"), ("custody", "Evidence custody"),
1172
- ("recovery_ref", "Recovery ref"), ("allocation_base", "Allocation base"), ("source", "Accepted source"), ("candidate", "Unaccepted source"),
1173
- ("terminal", "Terminal commit"), ("delivery", "Delivery"), ("pr", "PR"), ("resume", "Resume"),
1174
- ("assumptions", "Assumptions"), ("questions", "Unresolved questions"), ("worktree", "Retained worktree"),
1175
- ("cleanup", "Workspace cleanup"),
1176
- ("branch", "Branch"), ("scratch", "Scratch cleanup"))
1177
- for item in items:
1178
- lines += [f"## {item['identity']}", ""]
1179
- lines += [f"- {label}: {'; '.join(value) if isinstance(value, list) else value}"
1180
- for key, label in labels if (value := item.get(key)) not in (None, [], "")]
1181
- lines.append("")
1182
- path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
1183
- acceptance()["atomic_write"](path, "\n".join(lines).encode("utf-8"))
1184
- paths.append(str(path))
1335
+ v["cleanup"].append(f"report unavailable: {path}: {exc}; repair access and drain again")
1185
1336
  return paths
1186
1337
 
1187
1338
 
@@ -1191,6 +1342,40 @@ def counts(v):
1191
1342
  "legacy_pending": sum(1 for row in v["rows"] if not row["identity"] and row["mark"] == " ")}
1192
1343
 
1193
1344
 
1345
+ def prune_parents(v, root):
1346
+ """Retry empty-parent cleanup for completed, verified receipts; the drain alone knows this boundary."""
1347
+ for identity, state in v["states"].items():
1348
+ receipt = state["receipt"]
1349
+ if not receipt or receipt.get("delivery") != "COMPLETE" or not receipt.get("linked"):
1350
+ continue
1351
+ work = Path(receipt["worktree"])
1352
+ expected = root.joinpath(*identity.split("."))
1353
+ if work != expected:
1354
+ continue
1355
+ parent = work.parent
1356
+ helper = shared("task-complete")
1357
+ try:
1358
+ if work.exists():
1359
+ continue
1360
+ require(work == work.resolve() and not work.is_symlink() and not parent.is_symlink(),
1361
+ f"symlink or escape at {work}")
1362
+ registered = helper["registrations"](receipt)
1363
+ while parent != root and parent.is_relative_to(root):
1364
+ require(not any(Path(path).is_relative_to(parent) for path in registered),
1365
+ f"registered worktree retains parent: {parent}")
1366
+ try:
1367
+ parent.rmdir()
1368
+ except FileNotFoundError:
1369
+ pass # A previous cleanup already removed this parent.
1370
+ except OSError as exc:
1371
+ if exc.errno in {errno.ENOTEMPTY, errno.EEXIST}:
1372
+ break
1373
+ raise
1374
+ parent = parent.parent
1375
+ except (LoopError, OSError, helper["CompletionError"]) as exc:
1376
+ v["cleanup"].append(f"parent cleanup retained: {parent}: {exc}; remove it when empty or drain again")
1377
+
1378
+
1194
1379
  def drain(args):
1195
1380
  anchor, common = repository(args.repo)
1196
1381
  require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
@@ -1204,7 +1389,16 @@ def drain(args):
1204
1389
  if row is None:
1205
1390
  break
1206
1391
  attempted.add(row["identity"])
1207
- if wait := advance(last, row, args):
1392
+ try:
1393
+ wait = advance(last, row, args)
1394
+ except Waiting as exc:
1395
+ wait = str(exc)
1396
+ except (LoopError, OSError) as exc:
1397
+ phase = last["states"][row["identity"]].get("phase", "allocation")
1398
+ wait = f"{phase} blocked: {exc}"
1399
+ if phase in {"allocation", "inputs", "acceptance", "custody binding", "terminal transition"}:
1400
+ wait += f"; inspect and repair {row['identity']}'s {phase} state, then drain again"
1401
+ if wait:
1208
1402
  refused[row["identity"]] = wait
1209
1403
  if any(state["kind"] in {"pending", "active"} for state in last["states"].values()) or counts(last)["legacy_pending"] or refused:
1210
1404
  status = "WAITING"
@@ -1215,6 +1409,7 @@ def drain(args):
1215
1409
  if last is None:
1216
1410
  return {"status": status, "reason": reason}
1217
1411
  annotate(last, refused)
1412
+ prune_parents(last, args.worktree_root)
1218
1413
  return {"status": status, "reason": reason, "reports": write_reports(last, status, reason), "counts": counts(last),
1219
1414
  "tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
1220
1415
 
@@ -1615,7 +1810,7 @@ class QueueTests(unittest.TestCase):
1615
1810
  common = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir"))
1616
1811
  with mock.patch.object(Path, "unlink", side_effect=PermissionError("unlink denied")):
1617
1812
  notes = clean(common, records(common))
1618
- self.assertEqual((sorted(notes), all(path.exists() for path in saved)), (sorted(f"{path}: incomplete cleanup: unlink denied" for path in saved), True))
1813
+ self.assertEqual((sorted(notes), all(path.exists() for path in saved)), (sorted(f"package cleanup retained: {path}: unlink denied" for path in saved), True))
1619
1814
  self.assertEqual((clean(common, records(common)), any(path.exists() for path in saved)), ([], False))
1620
1815
 
1621
1816
  def test_a_legacy_row_is_replaced_only_by_its_one_exact_claim(self):
@@ -1723,8 +1918,7 @@ class QueueTests(unittest.TestCase):
1723
1918
  def stopped_writers(path):
1724
1919
  checks.append(path)
1725
1920
  observe(len(checks))
1726
- with mock.patch.dict(helper, {"stopped_writers": stopped_writers}), mock.patch("subprocess.Popen", popen), \
1727
- mock.patch("time.sleep"):
1921
+ with mock.patch.dict(helper, {"stopped_writers": stopped_writers}), mock.patch("subprocess.Popen", popen):
1728
1922
  try:
1729
1923
  return ensure_submission("l.t", records / "packet.json", packet, ["executor", "{packet}"])
1730
1924
  except Waiting as exc:
@@ -1751,6 +1945,12 @@ class QueueTests(unittest.TestCase):
1751
1945
  with self.assertRaises(Crash):
1752
1946
  drain(crash)
1753
1947
  failure = drain(spawn, observe)
1948
+ if crash is crash_after_spawn:
1949
+ # One observation per invocation, without sleep or a second spawn while the writer lives.
1950
+ self.assertEqual((len(spawns), len(checks)), (1, 1))
1951
+ self.assertIn("interrupted execution still active", failure)
1952
+ self.assertIn("interrupted execution still active", drain(spawn, observe))
1953
+ failure = drain(spawn, observe)
1754
1954
  self.assertEqual((len(spawns), len(checks), failure and failure.partition(": ")[0]), outcome)
1755
1955
  self.assertEqual(Path(packet["submission"]).exists(), crash is crash_after_spawn)
1756
1956
  if crash is crash_after_spawn: