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.
- package/bin/instructions.js +23 -9
- package/bin/skill-history.json +5 -3
- package/config/skills/_shared/task-complete.py +144 -12
- package/config/skills/_shared/task-completion.md +5 -5
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/loop.md +12 -8
- package/config/skills/devlyn-ideate/scripts/queue.py +300 -100
- 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):
|
|
@@ -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"
|
|
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
|
|
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
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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=
|
|
1251
|
+
worktree=worktree,
|
|
1104
1252
|
cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
|
|
1105
|
-
branch=
|
|
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
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}:
|
|
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:
|