devlyn-cli 4.2.4 → 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.
- package/bin/devlyn.js +1 -0
- package/bin/instructions.js +23 -9
- package/bin/skill-history.json +7 -3
- package/config/skills/_shared/role-config.py +3 -1
- package/config/skills/_shared/task-complete.py +158 -15
- package/config/skills/_shared/task-completion.md +5 -5
- package/config/skills/devlyn-ideate/SKILL.md +4 -2
- package/config/skills/devlyn-ideate/references/loop.md +13 -9
- package/config/skills/devlyn-ideate/scripts/queue.py +310 -103
- package/node_modules/marked/LICENSE.md +44 -0
- package/node_modules/marked/README.md +106 -0
- package/node_modules/marked/bin/main.js +282 -0
- package/node_modules/marked/bin/marked.js +15 -0
- package/node_modules/marked/lib/marked.cjs +2211 -0
- package/node_modules/marked/lib/marked.cjs.map +7 -0
- package/node_modules/marked/lib/marked.d.cts +728 -0
- package/node_modules/marked/lib/marked.d.ts +728 -0
- package/node_modules/marked/lib/marked.esm.js +2189 -0
- package/node_modules/marked/lib/marked.esm.js.map +7 -0
- package/node_modules/marked/lib/marked.umd.js +2213 -0
- package/node_modules/marked/lib/marked.umd.js.map +7 -0
- package/node_modules/marked/man/marked.1 +111 -0
- package/node_modules/marked/man/marked.1.md +92 -0
- package/node_modules/marked/marked.min.js +69 -0
- package/node_modules/marked/package.json +111 -0
- package/package.json +5 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}:
|
|
558
|
+
notes.append(f"package cleanup retained: {root / rel}: {exc}")
|
|
536
559
|
for directory, _, _ in os.walk(root / package, topdown=False):
|
|
537
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
651
|
+
if packet.exists():
|
|
578
652
|
bound = read_json(packet)
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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):
|
|
@@ -843,9 +979,9 @@ def evidence_ignored(anchor, common, start):
|
|
|
843
979
|
|
|
844
980
|
|
|
845
981
|
def allocate(v, row, opts):
|
|
846
|
-
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository,
|
|
847
|
-
remote base holds another version of the loop's package or lacks the checkout's instructions
|
|
848
|
-
start before selection)."""
|
|
982
|
+
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, its base is not the
|
|
983
|
+
default, or its refreshed remote base holds another version of the loop's package or lacks the checkout's instructions
|
|
984
|
+
(waiting() checks a local start before selection)."""
|
|
849
985
|
identity, loop, task = row["identity"], row["loop"], row["task"]
|
|
850
986
|
package = v["packages"][loop]
|
|
851
987
|
manifest = package["manifest"]
|
|
@@ -868,21 +1004,30 @@ def allocate(v, row, opts):
|
|
|
868
1004
|
except helper["CompletionError"]:
|
|
869
1005
|
return (f"{manifest['delivery']} delivery needs an origin remote of the form https://github.com/<owner>/<repo>, "
|
|
870
1006
|
"git@github.com:<owner>/<repo> or ssh://git@github.com/<owner>/<repo>; set one, or drain with --local-only")
|
|
1007
|
+
try:
|
|
1008
|
+
default = helper["repository_info"](values["repository"])["defaultBranchRef"]["name"]
|
|
1009
|
+
except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
|
|
1010
|
+
return f"cannot check delivery repository: {exc}"
|
|
1011
|
+
if manifest["base_ref"] != default:
|
|
1012
|
+
return (f"{manifest['delivery']} delivery base {manifest['base_ref']} is not the repository's default branch {default}; "
|
|
1013
|
+
f"plan the loop on {default}, or drain with --local-only")
|
|
871
1014
|
try:
|
|
872
1015
|
start = helper["remote_base"]({"common_gitdir": str(common), "remote": "origin", "base": manifest["base_ref"]})
|
|
873
|
-
except helper["CompletionError"] as exc:
|
|
874
|
-
raise LoopError(f"
|
|
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
|
|
875
1019
|
values["start"] = start
|
|
876
1020
|
require_merged(v, row, start)
|
|
877
1021
|
if drift := package_drift(v, loop, start, f"origin/{manifest['base_ref']}"):
|
|
878
1022
|
return drift
|
|
879
1023
|
require(evidence_ignored(anchor, common, start),
|
|
880
1024
|
f"{identity}: .devlyn/ is not ignored in its start commit {start}, so loop evidence would dirty task source; commit a "
|
|
881
|
-
f"`.devlyn/` entry to .gitignore in the base the task starts from, or add `.devlyn/` to
|
|
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")
|
|
882
1027
|
if values["start"] and (drift := instruction_drift(anchor, start)):
|
|
883
1028
|
return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
|
|
884
1029
|
f"without them; commit them and push them to origin/{manifest['base_ref']}")
|
|
885
|
-
result = task_complete("allocate", **values)
|
|
1030
|
+
result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], **values)
|
|
886
1031
|
progress(identity, f"allocated {result['worktree']}")
|
|
887
1032
|
return None
|
|
888
1033
|
|
|
@@ -947,28 +1092,25 @@ def ensure_submission(identity, packet_path, packet, executor):
|
|
|
947
1092
|
again.
|
|
948
1093
|
|
|
949
1094
|
An attempt's start is recorded before the spawn and its exit after the wait, so a start without an exit may have
|
|
950
|
-
left a live executor: drain
|
|
951
|
-
|
|
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."""
|
|
952
1097
|
submission = Path(packet["submission"])
|
|
953
1098
|
worktree = Path(packet["worktree"])
|
|
954
1099
|
log = submission.with_name("executions.log")
|
|
955
|
-
|
|
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
|
|
956
1104
|
if events and events[-1].partition(" ")[2] == "start":
|
|
957
1105
|
helper = shared("task-complete")
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if not reported:
|
|
967
|
-
progress(identity, f"waiting for an interrupted execution to stop ({exc})")
|
|
968
|
-
reported = True
|
|
969
|
-
time.sleep(2)
|
|
970
|
-
except helper["CompletionError"] as exc:
|
|
971
|
-
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
|
|
972
1114
|
if submission.exists():
|
|
973
1115
|
return None
|
|
974
1116
|
argv = [part.replace("{packet}", str(packet_path)) for part in executor]
|
|
@@ -985,7 +1127,8 @@ def ensure_submission(identity, packet_path, packet, executor):
|
|
|
985
1127
|
stdout=stdout, stderr=stderr)
|
|
986
1128
|
except OSError as exc:
|
|
987
1129
|
record(log, f"not started: {one_line(str(exc))}")
|
|
988
|
-
raise LoopError(f"executor could not start: {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
|
|
989
1132
|
progress(identity, f"executing; output in {output}")
|
|
990
1133
|
code = child.wait()
|
|
991
1134
|
record(log, f"exit {code} (pid {child.pid})")
|
|
@@ -996,7 +1139,7 @@ def ensure_submission(identity, packet_path, packet, executor):
|
|
|
996
1139
|
|
|
997
1140
|
def terminal_queue(cwd, receipt, identity):
|
|
998
1141
|
"""The loop queue file of this bound result's only legal terminal transition: its row becomes [x] or [F] — <reason>."""
|
|
999
|
-
failed = receipt
|
|
1142
|
+
failed = receipt["acceptance"]["verdict"] == "FAILED"
|
|
1000
1143
|
suffix = f" — {one_line(receipt['acceptance']['reasons'][0])} (receipt {receipt['id']})" if failed else ""
|
|
1001
1144
|
rel = loop_queue(identity.split(".")[0])
|
|
1002
1145
|
return transition(show(cwd, receipt["acceptance"]["inputs_sha"], rel) or b"", identity, "F" if failed else "x", suffix)
|
|
@@ -1014,7 +1157,7 @@ def settle(v, row, receipt_file):
|
|
|
1014
1157
|
identity = row["identity"]
|
|
1015
1158
|
receipt = read_json(receipt_file)
|
|
1016
1159
|
worktree, source, branch = Path(receipt["worktree"]), receipt["source_sha"], receipt["branch"]
|
|
1017
|
-
failed = receipt
|
|
1160
|
+
failed = receipt["acceptance"]["verdict"] == "FAILED"
|
|
1018
1161
|
with lock(v["common"], "queue.lock", blocking=True):
|
|
1019
1162
|
head = git(worktree, "rev-parse", "refs/heads/" + branch)
|
|
1020
1163
|
if head == source:
|
|
@@ -1034,6 +1177,8 @@ def settle(v, row, receipt_file):
|
|
|
1034
1177
|
def advance(v, row, opts):
|
|
1035
1178
|
"""Run or resume one task from its durable state; never replays a bound result. Returns why it waits instead."""
|
|
1036
1179
|
identity = row["identity"]
|
|
1180
|
+
state = v["states"][identity]
|
|
1181
|
+
state["phase"] = "allocation"
|
|
1037
1182
|
path = receipt_path(v["common"], identity)
|
|
1038
1183
|
local = is_local(v, row["loop"])
|
|
1039
1184
|
if opts.local_only and not local:
|
|
@@ -1042,32 +1187,32 @@ def advance(v, row, opts):
|
|
|
1042
1187
|
return refused
|
|
1043
1188
|
receipt = read_json(path)
|
|
1044
1189
|
if not receipt.get("acceptance"):
|
|
1190
|
+
state["phase"] = "inputs"
|
|
1045
1191
|
failure = v["states"][identity].get("inputs_changed")
|
|
1046
1192
|
if not failure and not receipt.get("local_only"):
|
|
1047
|
-
require_merged(v, row, receipt["baseline"])
|
|
1193
|
+
require_merged(v, row, receipt["baseline"], allocated=True)
|
|
1048
1194
|
packet_path, packet = ensure_packet(v, row, path, receipt)
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
return str(exc)
|
|
1195
|
+
state["phase"] = "executor"
|
|
1196
|
+
failure = failure or ensure_submission(identity, packet_path, packet, opts.executor)
|
|
1197
|
+
state["phase"] = "acceptance"
|
|
1053
1198
|
try:
|
|
1054
1199
|
result = acceptance()["accept"](packet_path, packet["submission"], failure)
|
|
1055
1200
|
except acceptance()["AcceptanceError"] as exc:
|
|
1056
1201
|
raise LoopError(f"{identity}: acceptance could not run: {exc}") from exc
|
|
1057
1202
|
progress(identity, result["verdict"].lower() + "".join(f"; {reason}" for reason in result["reasons"][:3]))
|
|
1203
|
+
state["phase"] = "custody binding"
|
|
1058
1204
|
task_complete("accept", receipt=str(path), acceptance=str(Path(packet["worktree"]) / ".devlyn/loop/acceptance.json"))
|
|
1059
1205
|
progress(identity, "bound")
|
|
1206
|
+
state["phase"] = "terminal transition"
|
|
1060
1207
|
if not read_json(path).get("queue"):
|
|
1061
1208
|
settle(v, row, path)
|
|
1062
1209
|
receipt = read_json(path)
|
|
1210
|
+
state["phase"] = "delivery"
|
|
1063
1211
|
if receipt.get("delivery") not in SETTLED:
|
|
1064
1212
|
packet = read_json(path.parent / "packet.json")
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
|
|
1069
|
-
except LoopError as exc:
|
|
1070
|
-
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)
|
|
1071
1216
|
progress(identity, f"delivery {result['status']}")
|
|
1072
1217
|
if result.get("merge_refused"):
|
|
1073
1218
|
return f"merge refused: {result['merge_refused']}; a person merges the PR"
|
|
@@ -1086,6 +1231,16 @@ def summary(v, row):
|
|
|
1086
1231
|
item["reason"] = reason
|
|
1087
1232
|
if receipt:
|
|
1088
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)
|
|
1089
1244
|
item.update(allocation_base=receipt.get("baseline"), **{"source" if state["kind"] == "accepted" else "candidate": receipt.get("source_sha")},
|
|
1090
1245
|
terminal=(receipt.get("queue") or {}).get("commit"), delivery=receipt.get("delivery"), pr=receipt.get("pr_url"),
|
|
1091
1246
|
resume=None if not receipt.get("acceptance") or receipt.get("delivery") in SETTLED
|
|
@@ -1093,9 +1248,9 @@ def summary(v, row):
|
|
|
1093
1248
|
if receipt.get("queue") else "drain again: the terminal commit is not attached yet",
|
|
1094
1249
|
assumptions=acceptance_record.get("assumptions", []),
|
|
1095
1250
|
questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
|
|
1096
|
-
worktree=
|
|
1251
|
+
worktree=worktree,
|
|
1097
1252
|
cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
|
|
1098
|
-
branch=
|
|
1253
|
+
branch=branch,
|
|
1099
1254
|
recovery_ref=receipt.get("recovery_ref"),
|
|
1100
1255
|
custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
|
|
1101
1256
|
scratch=(f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("scratch_cleanup") or {}).get("reason")
|
|
@@ -1147,34 +1302,37 @@ def bring_in(v, loop):
|
|
|
1147
1302
|
def write_reports(v, status, reason):
|
|
1148
1303
|
paths = []
|
|
1149
1304
|
for loop in dict.fromkeys(row["loop"] for row in v["rows"] if row["identity"]):
|
|
1150
|
-
|
|
1151
|
-
lines = [f"# Drain report — {loop}", "", f"- Generated: {datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}",
|
|
1152
|
-
f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
|
|
1153
|
-
f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
|
|
1154
|
-
*([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
|
|
1305
|
+
path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
|
|
1155
1306
|
try:
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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))
|
|
1161
1334
|
except (LoopError, OSError) as exc:
|
|
1162
|
-
|
|
1163
|
-
lines += ["## Package decisions and assumptions", "", decisions, ""]
|
|
1164
|
-
labels = (("result", "Product"), ("reason", "Reason"), ("receipt", "Receipt"), ("custody", "Evidence custody"),
|
|
1165
|
-
("recovery_ref", "Recovery ref"), ("allocation_base", "Allocation base"), ("source", "Accepted source"), ("candidate", "Unaccepted source"),
|
|
1166
|
-
("terminal", "Terminal commit"), ("delivery", "Delivery"), ("pr", "PR"), ("resume", "Resume"),
|
|
1167
|
-
("assumptions", "Assumptions"), ("questions", "Unresolved questions"), ("worktree", "Retained worktree"),
|
|
1168
|
-
("cleanup", "Workspace cleanup"),
|
|
1169
|
-
("branch", "Branch"), ("scratch", "Scratch cleanup"))
|
|
1170
|
-
for item in items:
|
|
1171
|
-
lines += [f"## {item['identity']}", ""]
|
|
1172
|
-
lines += [f"- {label}: {'; '.join(value) if isinstance(value, list) else value}"
|
|
1173
|
-
for key, label in labels if (value := item.get(key)) not in (None, [], "")]
|
|
1174
|
-
lines.append("")
|
|
1175
|
-
path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
|
|
1176
|
-
acceptance()["atomic_write"](path, "\n".join(lines).encode("utf-8"))
|
|
1177
|
-
paths.append(str(path))
|
|
1335
|
+
v["cleanup"].append(f"report unavailable: {path}: {exc}; repair access and drain again")
|
|
1178
1336
|
return paths
|
|
1179
1337
|
|
|
1180
1338
|
|
|
@@ -1184,6 +1342,40 @@ def counts(v):
|
|
|
1184
1342
|
"legacy_pending": sum(1 for row in v["rows"] if not row["identity"] and row["mark"] == " ")}
|
|
1185
1343
|
|
|
1186
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
|
+
|
|
1187
1379
|
def drain(args):
|
|
1188
1380
|
anchor, common = repository(args.repo)
|
|
1189
1381
|
require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
|
|
@@ -1197,7 +1389,16 @@ def drain(args):
|
|
|
1197
1389
|
if row is None:
|
|
1198
1390
|
break
|
|
1199
1391
|
attempted.add(row["identity"])
|
|
1200
|
-
|
|
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:
|
|
1201
1402
|
refused[row["identity"]] = wait
|
|
1202
1403
|
if any(state["kind"] in {"pending", "active"} for state in last["states"].values()) or counts(last)["legacy_pending"] or refused:
|
|
1203
1404
|
status = "WAITING"
|
|
@@ -1208,6 +1409,7 @@ def drain(args):
|
|
|
1208
1409
|
if last is None:
|
|
1209
1410
|
return {"status": status, "reason": reason}
|
|
1210
1411
|
annotate(last, refused)
|
|
1412
|
+
prune_parents(last, args.worktree_root)
|
|
1211
1413
|
return {"status": status, "reason": reason, "reports": write_reports(last, status, reason), "counts": counts(last),
|
|
1212
1414
|
"tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
|
|
1213
1415
|
|
|
@@ -1608,7 +1810,7 @@ class QueueTests(unittest.TestCase):
|
|
|
1608
1810
|
common = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
|
1609
1811
|
with mock.patch.object(Path, "unlink", side_effect=PermissionError("unlink denied")):
|
|
1610
1812
|
notes = clean(common, records(common))
|
|
1611
|
-
self.assertEqual((sorted(notes), all(path.exists() for path in saved)), (sorted(f"{path}:
|
|
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))
|
|
1612
1814
|
self.assertEqual((clean(common, records(common)), any(path.exists() for path in saved)), ([], False))
|
|
1613
1815
|
|
|
1614
1816
|
def test_a_legacy_row_is_replaced_only_by_its_one_exact_claim(self):
|
|
@@ -1716,8 +1918,7 @@ class QueueTests(unittest.TestCase):
|
|
|
1716
1918
|
def stopped_writers(path):
|
|
1717
1919
|
checks.append(path)
|
|
1718
1920
|
observe(len(checks))
|
|
1719
|
-
with mock.patch.dict(helper, {"stopped_writers": stopped_writers}), mock.patch("subprocess.Popen", popen)
|
|
1720
|
-
mock.patch("time.sleep"):
|
|
1921
|
+
with mock.patch.dict(helper, {"stopped_writers": stopped_writers}), mock.patch("subprocess.Popen", popen):
|
|
1721
1922
|
try:
|
|
1722
1923
|
return ensure_submission("l.t", records / "packet.json", packet, ["executor", "{packet}"])
|
|
1723
1924
|
except Waiting as exc:
|
|
@@ -1744,6 +1945,12 @@ class QueueTests(unittest.TestCase):
|
|
|
1744
1945
|
with self.assertRaises(Crash):
|
|
1745
1946
|
drain(crash)
|
|
1746
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)
|
|
1747
1954
|
self.assertEqual((len(spawns), len(checks), failure and failure.partition(": ")[0]), outcome)
|
|
1748
1955
|
self.assertEqual(Path(packet["submission"]).exists(), crash is crash_after_spawn)
|
|
1749
1956
|
if crash is crash_after_spawn:
|