devlyn-cli 4.3.15 → 4.3.17
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/skill-history.json +5 -0
- package/config/skills/_shared/task-complete.py +342 -26
- package/config/skills/_shared/task-completion.md +10 -4
- package/config/skills/devlyn-ideate/scripts/queue.py +7 -3
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +342 -26
- package/native/claude/skills/_shared/task-completion.md +10 -4
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +7 -3
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +342 -26
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +10 -4
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +7 -3
- package/package.json +1 -1
- package/release.json +16 -16
package/bin/skill-history.json
CHANGED
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"865e26f38b04dbaae6b0b91616110d6bd748909b7dec68f20cd7c2a240d2fa5a",
|
|
107
107
|
"8ab08b8f3d19e7fffc36fbc4d8c4dad83df02bcb83a9aabcb8db8b82fe12b384",
|
|
108
108
|
"8b34167328e88c73538b5ae2a30e452b544a44bc03f70a2c4f0278b33953afc4",
|
|
109
|
+
"8b87eadbb611342029cb9c153b32e2a483bb272dee70b49f0cae251b76a72ecf",
|
|
109
110
|
"8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
|
|
110
111
|
"8fb73c0d2472b3643ac6e3f0ad87d818694afebaf68fcad596496e703893c43d",
|
|
111
112
|
"9162fc2fed6f4a611d299485be3a397080beb3304865ce59b95576ba7d00650e",
|
|
@@ -160,6 +161,7 @@
|
|
|
160
161
|
"de4f504265e960cb8d81752e9ae922f664790ee026b4454f1b07252d1a6282a4",
|
|
161
162
|
"e0a305cf3bf47c8bc357195d4d50b0f6177069e870c33040c258c93edae68646",
|
|
162
163
|
"e115205a744fef918a59d1b3a601df09a8b6df1d3e60613b9e8b87240929f8d8",
|
|
164
|
+
"e19204055bb16fff4263f95fac092ee8f971ee9055b7a969497e5714782a454c",
|
|
163
165
|
"e41013abf5e17445ef83a86974ac1de77beaa61e484e1916577ecc64bb34c220",
|
|
164
166
|
"e725ec4eca42f737ee4bdaceb10c11db289414a1d4bb8be8fe103f89c89f07d5",
|
|
165
167
|
"e812d02ebdbdb8d82eb85842d676e21bedddb0d00154eed99b9d29b43cb8fc02",
|
|
@@ -361,6 +363,7 @@
|
|
|
361
363
|
"aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
|
|
362
364
|
"b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
|
|
363
365
|
"bdd0751eea04532c050334fda3bc5c61c6b429e485d97e0b8ebb4fae154da2c7",
|
|
366
|
+
"bf302e48c189813849849563a117b412cecb627323e8af7c12dc729ea3e6e4cd",
|
|
364
367
|
"c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
|
|
365
368
|
"c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
|
|
366
369
|
"ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
|
|
@@ -772,6 +775,8 @@
|
|
|
772
775
|
"4.3.13",
|
|
773
776
|
"4.3.14",
|
|
774
777
|
"4.3.15",
|
|
778
|
+
"4.3.16",
|
|
779
|
+
"4.3.17",
|
|
775
780
|
"4.3.2",
|
|
776
781
|
"4.3.3",
|
|
777
782
|
"4.3.4",
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import contextlib
|
|
11
|
+
import ctypes
|
|
11
12
|
import errno
|
|
12
13
|
import functools
|
|
13
14
|
import hashlib
|
|
@@ -628,6 +629,18 @@ def linux_process_status(process):
|
|
|
628
629
|
raise WritersUnobservable("unknown process status; retain tree until writer cessation can be established") from exc
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
def darwin_executable(pid):
|
|
633
|
+
# The real executable (ps prints argv[0]); only names a retention reason.
|
|
634
|
+
with contextlib.suppress(OSError):
|
|
635
|
+
proc_pidpath = ctypes.CDLL("/usr/lib/libproc.dylib").proc_pidpath
|
|
636
|
+
proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32]
|
|
637
|
+
proc_pidpath.restype = ctypes.c_int
|
|
638
|
+
buffer = ctypes.create_string_buffer(4096) # PROC_PIDPATHINFO_MAXSIZE
|
|
639
|
+
if proc_pidpath(pid, buffer, ctypes.sizeof(buffer)) > 0:
|
|
640
|
+
return os.fsdecode(buffer.value)
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
|
|
631
644
|
def stopped_writers(work):
|
|
632
645
|
outside(work)
|
|
633
646
|
# The owner assertion covers its actual children; an OS observation catches
|
|
@@ -637,10 +650,18 @@ def stopped_writers(work):
|
|
|
637
650
|
if result.returncode not in {0, 1} or result.stderr.strip():
|
|
638
651
|
raise WritersUnobservable("writer observation unavailable; retain tree and inspect writers")
|
|
639
652
|
pid = None
|
|
640
|
-
for line in result.stdout.
|
|
653
|
+
for line in result.stdout.split("\n"):
|
|
641
654
|
if line.startswith("p"):
|
|
642
655
|
pid = int(line[1:])
|
|
643
656
|
elif line.startswith("n") and pid != os.getpid():
|
|
657
|
+
executable = darwin_executable(pid)
|
|
658
|
+
if (executable is not None
|
|
659
|
+
and executable.startswith("/System/Library/Frameworks/Virtualization.framework/")
|
|
660
|
+
and executable.endswith("/com.apple.Virtualization.VirtualMachine")):
|
|
661
|
+
raise WriterActive(
|
|
662
|
+
f"active process {pid} is a virtual machine (Docker Desktop or another Virtualization.framework VM) "
|
|
663
|
+
"and holds task files, as it can after a bind mount until restarted; "
|
|
664
|
+
"stop containers using them and restart the VM's app before resume")
|
|
644
665
|
raise WriterActive(f"active process {pid} uses task files; stop/yield actual writers before resume")
|
|
645
666
|
elif sys.platform.startswith("linux"):
|
|
646
667
|
for process in Path("/proc").iterdir():
|
|
@@ -756,7 +777,8 @@ def clean_scratch_command(args):
|
|
|
756
777
|
|
|
757
778
|
def cleanup(receipt, path, pr, *, warnings=None):
|
|
758
779
|
warnings = [] if warnings is None else warnings
|
|
759
|
-
|
|
780
|
+
if pr is not None:
|
|
781
|
+
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
760
782
|
sha = receipt["publish_sha"]
|
|
761
783
|
branch_ref = "refs/heads/" + receipt["branch"]
|
|
762
784
|
# The recovery ref keeps the accepted commit reachable, so squash and
|
|
@@ -764,7 +786,7 @@ def cleanup(receipt, path, pr, *, warnings=None):
|
|
|
764
786
|
require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
|
|
765
787
|
verify_files(path.parent / "custody", receipt["files"])
|
|
766
788
|
require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
|
|
767
|
-
gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], remote_base(receipt))
|
|
789
|
+
gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
|
|
768
790
|
gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
|
|
769
791
|
work = Path(receipt["worktree"])
|
|
770
792
|
local = ref_sha(receipt, branch_ref)
|
|
@@ -866,26 +888,29 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
|
|
|
866
888
|
continue
|
|
867
889
|
try:
|
|
868
890
|
receipt = read_json(path)
|
|
869
|
-
if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("
|
|
891
|
+
if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
|
|
870
892
|
continue
|
|
871
893
|
if receipt.get("status") == "COMPLETE" and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
|
|
872
894
|
continue
|
|
873
895
|
with locked_receipt(path, blocking=False) as receipt:
|
|
896
|
+
if not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
|
|
897
|
+
continue
|
|
874
898
|
if receipt.get("status") == "COMPLETE":
|
|
875
899
|
if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
|
|
876
900
|
results.append(completion_result(receipt, path, "COMPLETE"))
|
|
877
901
|
continue
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
902
|
+
pr = None
|
|
903
|
+
if receipt.get("pr_number"):
|
|
904
|
+
pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
|
|
905
|
+
if pr["state"] != "MERGED":
|
|
906
|
+
continue
|
|
907
|
+
validate_pr(receipt, pr)
|
|
884
908
|
require(not receipt["linked"] or Path(receipt["worktree"]) != anchor, "cannot clean the current allocation's anchor checkout: " + str(anchor))
|
|
885
909
|
require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
|
|
886
910
|
repo_policy(receipt)
|
|
887
|
-
|
|
888
|
-
|
|
911
|
+
if pr:
|
|
912
|
+
receipt["merge"] = pr
|
|
913
|
+
atomic_json(path, receipt)
|
|
889
914
|
cleanup(receipt, path, pr, warnings=warnings)
|
|
890
915
|
results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
|
|
891
916
|
except (Exception, SystemExit) as error:
|
|
@@ -910,6 +935,9 @@ def complete(args, *, warnings=None):
|
|
|
910
935
|
if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
|
|
911
936
|
return result("FAILED")
|
|
912
937
|
if args.local_only or receipt.get("local_only"):
|
|
938
|
+
require(receipt.get("delivery") != "COMPLETE" and receipt.get("status") != "COMPLETE" and "workspace_cleanup" not in receipt,
|
|
939
|
+
f"{receipt['task']} delivery is already COMPLETE; "
|
|
940
|
+
"--local-only cannot rewrite its delivery, resume it without --local-only")
|
|
913
941
|
require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
|
|
914
942
|
"--local-only cannot rewrite its delivery, resume it without --local-only")
|
|
915
943
|
receipt["local_only"] = True
|
|
@@ -948,8 +976,17 @@ def complete(args, *, warnings=None):
|
|
|
948
976
|
pr = prs[0] if prs else None
|
|
949
977
|
if pr:
|
|
950
978
|
validate_pr(receipt, pr)
|
|
951
|
-
|
|
952
|
-
if
|
|
979
|
+
on_base = pr is None and receipt.get("delivery") == "COMPLETE"
|
|
980
|
+
if pr is None and not on_base:
|
|
981
|
+
ancestry = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "merge-base", "--is-ancestor",
|
|
982
|
+
receipt["publish_sha"], remote_base(receipt)], capture_output=True, text=True, encoding="utf-8")
|
|
983
|
+
require(ancestry.returncode in {0, 1}, "cannot check accepted commit on base: " + ancestry.stderr.strip())
|
|
984
|
+
on_base = ancestry.returncode == 0
|
|
985
|
+
if on_base:
|
|
986
|
+
require(remote_head(receipt, receipt["branch"]) in {None, receipt["publish_sha"]},
|
|
987
|
+
"remote task head changed; refusing to overwrite or merge")
|
|
988
|
+
if not on_base and (not pr or pr["state"] != "MERGED"):
|
|
989
|
+
actual = remote_head(receipt, receipt["branch"])
|
|
953
990
|
inspect_workspace(receipt)
|
|
954
991
|
verify_files(Path(receipt["worktree"]), receipt["files"])
|
|
955
992
|
require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
|
|
@@ -964,9 +1001,10 @@ def complete(args, *, warnings=None):
|
|
|
964
1001
|
require(len(prs) == 1, "created PR could not be reconciled; resume from receipt")
|
|
965
1002
|
pr = prs[0]
|
|
966
1003
|
validate_pr(receipt, pr)
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1004
|
+
if pr:
|
|
1005
|
+
receipt.update(pr_number=pr["number"], pr_url=pr["url"])
|
|
1006
|
+
atomic_json(path, receipt)
|
|
1007
|
+
if pr and mode == "pr" and pr["state"] == "OPEN":
|
|
970
1008
|
if pr.get("autoMergeRequest"):
|
|
971
1009
|
gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
|
|
972
1010
|
pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
|
|
@@ -974,7 +1012,7 @@ def complete(args, *, warnings=None):
|
|
|
974
1012
|
require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
|
|
975
1013
|
if pr["state"] == "OPEN":
|
|
976
1014
|
return result("PR")
|
|
977
|
-
if pr["state"] != "MERGED":
|
|
1015
|
+
if pr and pr["state"] != "MERGED":
|
|
978
1016
|
inspect_workspace(receipt)
|
|
979
1017
|
verify_files(Path(receipt["worktree"]), receipt["files"])
|
|
980
1018
|
require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
|
|
@@ -1014,12 +1052,13 @@ def complete(args, *, warnings=None):
|
|
|
1014
1052
|
if pr["state"] != "MERGED":
|
|
1015
1053
|
# Merge only when the repository allows it; a refused request leaves the PR for a person.
|
|
1016
1054
|
return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
|
|
1017
|
-
|
|
1018
|
-
|
|
1055
|
+
if pr:
|
|
1056
|
+
receipt["merge"] = pr
|
|
1057
|
+
atomic_json(path, receipt)
|
|
1019
1058
|
try:
|
|
1020
1059
|
cleanup(receipt, path, pr, warnings=warnings)
|
|
1021
1060
|
except (WritersUnobservable, WriterActive) as error:
|
|
1022
|
-
# Conservative retention:
|
|
1061
|
+
# Conservative retention: base containment settles delivery; workspace, task refs and custody stay, reported.
|
|
1023
1062
|
receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
1024
1063
|
sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
|
|
1025
1064
|
atomic_json(path, receipt)
|
|
@@ -1835,6 +1874,86 @@ class CompletionTests(unittest.TestCase):
|
|
|
1835
1874
|
child.wait(timeout=5)
|
|
1836
1875
|
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
1837
1876
|
|
|
1877
|
+
def test_darwin_writer_scan_retains_all_holders_and_names_virtual_machines(self):
|
|
1878
|
+
from unittest.mock import Mock, call, patch
|
|
1879
|
+
pid = os.getpid() + 1
|
|
1880
|
+
vm = "/System/Library/Frameworks/Virtualization.framework/Versions/A/XPCServices/com.apple.Virtualization.VirtualMachine.xpc/Contents/MacOS/com.apple.Virtualization.VirtualMachine"
|
|
1881
|
+
ordinary = f"active process {pid} uses task files; stop/yield actual writers before resume"
|
|
1882
|
+
vm_reason = (f"active process {pid} is a virtual machine (Docker Desktop or another Virtualization.framework VM) "
|
|
1883
|
+
"and holds task files, as it can after a bind mount until restarted; "
|
|
1884
|
+
"stop containers using them and restart the VM's app before resume")
|
|
1885
|
+
own = f"p{os.getpid()}\nn{self.work}\nn{self.work / 'product'}\n"
|
|
1886
|
+
lsof = call(["lsof", "-Fpn", "+D", str(self.work)], capture_output=True, text=True, encoding="utf-8")
|
|
1887
|
+
executable = Mock()
|
|
1888
|
+
with patch.object(sys, "platform", "darwin"), patch.object(subprocess, "run") as run, patch.dict(globals(), darwin_executable=executable):
|
|
1889
|
+
for path, reason in ((vm, vm_reason), (vm.replace("VirtualMachine", "EventTap"), ordinary),
|
|
1890
|
+
("/bin/bash", ordinary), (None, ordinary)):
|
|
1891
|
+
for name in ("product", "product\u2028p", "product\u2029p"):
|
|
1892
|
+
with self.subTest(executable=path, name=name):
|
|
1893
|
+
run.reset_mock()
|
|
1894
|
+
executable.reset_mock()
|
|
1895
|
+
executable.return_value = path
|
|
1896
|
+
records = (f"p{os.getpid()}\nn{self.work / name}\np{pid}\n"
|
|
1897
|
+
f"n{self.work / ('product' if name == 'product' else 'held')}\n")
|
|
1898
|
+
run.return_value = subprocess.CompletedProcess([], 0, records, "")
|
|
1899
|
+
with self.assertRaises(WriterActive) as raised:
|
|
1900
|
+
stopped_writers(self.work)
|
|
1901
|
+
self.assertEqual(str(raised.exception), reason)
|
|
1902
|
+
self.assertEqual(run.call_args_list, [lsof])
|
|
1903
|
+
executable.assert_called_once_with(pid)
|
|
1904
|
+
run.reset_mock()
|
|
1905
|
+
executable.reset_mock()
|
|
1906
|
+
run.return_value = subprocess.CompletedProcess([], 0, own, "")
|
|
1907
|
+
stopped_writers(self.work)
|
|
1908
|
+
self.assertEqual(run.call_args_list, [lsof])
|
|
1909
|
+
executable.assert_not_called()
|
|
1910
|
+
for code, stderr in ((0, "warning"), (1, "warning"), (2, ""), (-1, "")):
|
|
1911
|
+
with self.subTest(code=code, stderr=stderr):
|
|
1912
|
+
run.return_value = subprocess.CompletedProcess([], code, f"p{pid}\nn{self.work}\n", stderr)
|
|
1913
|
+
with self.assertRaises(WritersUnobservable):
|
|
1914
|
+
stopped_writers(self.work)
|
|
1915
|
+
executable.assert_not_called()
|
|
1916
|
+
run.return_value = subprocess.CompletedProcess([], 1, "", "")
|
|
1917
|
+
stopped_writers(self.work)
|
|
1918
|
+
executable.assert_not_called()
|
|
1919
|
+
|
|
1920
|
+
@unittest.skipUnless(sys.platform == "darwin", "requires macOS proc_pidpath")
|
|
1921
|
+
def test_darwin_executable(self):
|
|
1922
|
+
from unittest.mock import patch
|
|
1923
|
+
with self.subTest(process="self"):
|
|
1924
|
+
executable = darwin_executable(os.getpid())
|
|
1925
|
+
self.assertEqual(executable, os.path.realpath(executable))
|
|
1926
|
+
self.assertTrue(os.path.isfile(executable))
|
|
1927
|
+
self.assertTrue(os.access(executable, os.X_OK))
|
|
1928
|
+
with self.subTest(process="reaped"):
|
|
1929
|
+
with subprocess.Popen([sys.executable, "-c", "pass"]) as child:
|
|
1930
|
+
child.wait(timeout=5)
|
|
1931
|
+
self.assertIsNone(darwin_executable(child.pid))
|
|
1932
|
+
with self.subTest(error="lookup failed"), patch.object(ctypes, "CDLL") as library:
|
|
1933
|
+
library.return_value.proc_pidpath.return_value = 0
|
|
1934
|
+
self.assertIsNone(darwin_executable(os.getpid()))
|
|
1935
|
+
with self.subTest(error="library unavailable"), patch.object(ctypes, "CDLL", side_effect=OSError("libproc unavailable")):
|
|
1936
|
+
self.assertIsNone(darwin_executable(os.getpid()))
|
|
1937
|
+
|
|
1938
|
+
@unittest.skipUnless(sys.platform == "darwin", "requires macOS lsof descriptor access")
|
|
1939
|
+
def test_darwin_real_read_only_descriptors_and_scripts_block(self):
|
|
1940
|
+
script = self.work / "loop.sh"
|
|
1941
|
+
script.write_text("printf 'ready\\n'\nwhile :; do sleep 1; done\n", encoding="utf-8")
|
|
1942
|
+
for command in ([sys.executable, "-c",
|
|
1943
|
+
"import os, sys, time; f = open(sys.argv[1], 'rb'); "
|
|
1944
|
+
"d = os.open(sys.argv[2], os.O_RDONLY); print('ready', flush=True); time.sleep(30)",
|
|
1945
|
+
str(self.work / "product"), str(self.work)], ["/bin/bash", str(script)]):
|
|
1946
|
+
with self.subTest(command=command[0]):
|
|
1947
|
+
child = subprocess.Popen(command, cwd=self.root, stdout=subprocess.PIPE, text=True)
|
|
1948
|
+
try:
|
|
1949
|
+
self.assertEqual(child.stdout.readline(), "ready\n")
|
|
1950
|
+
with self.assertRaisesRegex(WriterActive, f"active process {child.pid} uses task files"):
|
|
1951
|
+
stopped_writers(self.work)
|
|
1952
|
+
finally:
|
|
1953
|
+
child.kill()
|
|
1954
|
+
child.wait(timeout=5)
|
|
1955
|
+
child.stdout.close()
|
|
1956
|
+
|
|
1838
1957
|
def test_linux_writer_scan_continues_after_vanished_fd(self):
|
|
1839
1958
|
from unittest.mock import patch
|
|
1840
1959
|
process = Path("/proc") / str(os.getpid() + 1)
|
|
@@ -2068,17 +2187,18 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2068
2187
|
with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
|
|
2069
2188
|
scratch_mounts()
|
|
2070
2189
|
|
|
2071
|
-
def accept(self):
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2190
|
+
def accept(self, *, unchanged=False):
|
|
2191
|
+
if not unchanged:
|
|
2192
|
+
(self.task / "product").write_text("accepted\n", encoding="utf-8")
|
|
2193
|
+
self.g("add", "product", work=self.task)
|
|
2194
|
+
self.g("commit", "-m", "scoped task", work=self.task)
|
|
2075
2195
|
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
2076
2196
|
evidence = self.task / ".devlyn"
|
|
2077
2197
|
evidence.mkdir(exist_ok=True)
|
|
2078
2198
|
(evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
|
|
2079
2199
|
a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
|
|
2080
2200
|
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
|
|
2081
|
-
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
|
|
2201
|
+
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "baseline\n" if unchanged else "accepted\n")
|
|
2082
2202
|
self.acceptance = evidence / "acceptance.json"
|
|
2083
2203
|
self.acceptance.write_text(json.dumps(a), encoding="utf-8")
|
|
2084
2204
|
|
|
@@ -2088,6 +2208,202 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2088
2208
|
command_args += ["--acceptance", self.acceptance]
|
|
2089
2209
|
return self.cli(*command_args, *args, success=success, cwd=cwd)
|
|
2090
2210
|
|
|
2211
|
+
def test_unchanged_at_base_cleans_without_publication(self):
|
|
2212
|
+
# Prediction: equality with base settles without push/PR, preserving custody and removing the tree, ref and scratch output.
|
|
2213
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2214
|
+
records = records_manifest(self.task)
|
|
2215
|
+
scratch = self.receipt.parent / "scratch"
|
|
2216
|
+
(scratch / "build").write_bytes(b"temporary output")
|
|
2217
|
+
result, _ = self.complete("--writers-stopped")
|
|
2218
|
+
self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
|
|
2219
|
+
self.assertFalse(self.task.exists())
|
|
2220
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2221
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2222
|
+
self.assertEqual((result["scratch_cleanup"]["status"], list(scratch.iterdir())), ("CLEAN", []))
|
|
2223
|
+
self.assertEqual(records_manifest(self.receipt.parent / "records"), records)
|
|
2224
|
+
receipt = read_json(self.receipt)
|
|
2225
|
+
verify_files(self.receipt.parent / "custody", receipt["files"])
|
|
2226
|
+
self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
|
|
2227
|
+
d = read_json(self.data)
|
|
2228
|
+
self.assertEqual((d["pushs"], d.get("creates", 0), d.get("deletes", 0)), (0, 0, 0))
|
|
2229
|
+
|
|
2230
|
+
def test_unchanged_ancestor_cleans_in_pr_mode(self):
|
|
2231
|
+
# Prediction: only the freshly fetched base contains the accepted ancestor; PR mode cleans without publication.
|
|
2232
|
+
self.allocate(); self.accept()
|
|
2233
|
+
stale = self.g("rev-parse", "refs/remotes/origin/main")
|
|
2234
|
+
advanced = self.g("commit-tree", self.sha + "^{tree}", "-p", self.sha, "-m", "advance remote base")
|
|
2235
|
+
self.g("push", "origin", advanced + ":refs/heads/main")
|
|
2236
|
+
self.configure(pushs=0)
|
|
2237
|
+
self.g("update-ref", "refs/remotes/origin/main", stale)
|
|
2238
|
+
for ref in ("main", "refs/remotes/origin/main"):
|
|
2239
|
+
self.assertEqual(self.run_cmd(["git", "-C", str(self.work), "merge-base", "--is-ancestor",
|
|
2240
|
+
self.sha, ref], success=False).returncode, 1)
|
|
2241
|
+
result, _ = self.complete("--mode", "pr", "--writers-stopped")
|
|
2242
|
+
self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
|
|
2243
|
+
self.assertEqual(self.g("rev-parse", "refs/remotes/origin/main"), advanced)
|
|
2244
|
+
self.assertEqual(self.g("rev-parse", "main"), stale)
|
|
2245
|
+
self.assertFalse(self.task.exists())
|
|
2246
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2247
|
+
d = read_json(self.data)
|
|
2248
|
+
self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
|
|
2249
|
+
|
|
2250
|
+
def test_unchanged_old_pushed_receipt_resumes_with_exact_delete(self):
|
|
2251
|
+
# Prediction: a pushed receipt without a PR resumes with only the exact-lease deletion and settles COMPLETE.
|
|
2252
|
+
from unittest.mock import Mock, patch
|
|
2253
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2254
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
2255
|
+
self.g("push", "origin", self.sha + ":refs/heads/task/fixture")
|
|
2256
|
+
receipt = read_json(self.receipt)
|
|
2257
|
+
receipt["pushed"] = True
|
|
2258
|
+
atomic_json(self.receipt, receipt)
|
|
2259
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
|
|
2260
|
+
run = Mock(wraps=gref)
|
|
2261
|
+
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, gref=run):
|
|
2262
|
+
result = complete(args)
|
|
2263
|
+
pushes = [call.args[1:] for call in run.call_args_list if call.args[1] == "push"]
|
|
2264
|
+
self.assertEqual(pushes, [("push", "--force-with-lease=refs/heads/task/fixture:" + self.sha, "origin", ":refs/heads/task/fixture")])
|
|
2265
|
+
self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
|
|
2266
|
+
self.assertFalse(self.task.exists())
|
|
2267
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2268
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2269
|
+
d = read_json(self.data)
|
|
2270
|
+
self.assertEqual((d["pushs"], d.get("creates", 0), d["deletes"]), (1, 0, 1))
|
|
2271
|
+
|
|
2272
|
+
def test_unchanged_writer_retention_resumes_to_complete(self):
|
|
2273
|
+
# Prediction: either writer refusal settles delivery but retains the tree/ref; resume cleans without push/PR.
|
|
2274
|
+
from unittest.mock import Mock, patch
|
|
2275
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2276
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
2277
|
+
for error in (WriterActive, WritersUnobservable):
|
|
2278
|
+
with self.subTest(error=error.__name__), patch.dict(os.environ, self.env), \
|
|
2279
|
+
patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=error("fixture writer retention"))):
|
|
2280
|
+
result = complete(args)
|
|
2281
|
+
self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
|
|
2282
|
+
self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
|
|
2283
|
+
self.assertIn(str(self.receipt), result["workspace_cleanup"]["resume"])
|
|
2284
|
+
self.assertTrue(self.task.exists())
|
|
2285
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2286
|
+
result, _ = self.complete(acceptance=False)
|
|
2287
|
+
self.assertEqual((result["status"], result["pr"], result["workspace_cleanup"]), ("COMPLETE", None, None))
|
|
2288
|
+
self.assertFalse(self.task.exists())
|
|
2289
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2290
|
+
d = read_json(self.data)
|
|
2291
|
+
self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
|
|
2292
|
+
|
|
2293
|
+
def test_unchanged_writer_retention_reconciles_on_allocate(self):
|
|
2294
|
+
# Prediction: the next allocation retries on-base delivery once writer observation succeeds.
|
|
2295
|
+
from unittest.mock import Mock, patch
|
|
2296
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2297
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
2298
|
+
with patch.dict(os.environ, self.env), \
|
|
2299
|
+
patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
|
|
2300
|
+
result = complete(args)
|
|
2301
|
+
self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
|
|
2302
|
+
self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
|
|
2303
|
+
receipt, task = self.receipt, self.task
|
|
2304
|
+
result = self.allocate("next")
|
|
2305
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2306
|
+
self.assertFalse(task.exists())
|
|
2307
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2308
|
+
|
|
2309
|
+
def test_unchanged_settled_resume_blocks_after_base_rewind(self):
|
|
2310
|
+
# Prediction: settled delivery never republishes after base rewind; cleanup blocks with the tree and refs kept.
|
|
2311
|
+
from unittest.mock import Mock, patch
|
|
2312
|
+
self.allocate(); self.accept()
|
|
2313
|
+
base = self.g("rev-parse", "main")
|
|
2314
|
+
self.g("push", "origin", self.sha + ":refs/heads/main")
|
|
2315
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
2316
|
+
with patch.dict(os.environ, self.env), \
|
|
2317
|
+
patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
|
|
2318
|
+
result = complete(args)
|
|
2319
|
+
self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
|
|
2320
|
+
self.assertIsNone(read_json(self.receipt).get("status"))
|
|
2321
|
+
self.g("push", "origin", "+" + base + ":refs/heads/main")
|
|
2322
|
+
self.configure(pushs=0)
|
|
2323
|
+
for mode in ("auto", "pr"):
|
|
2324
|
+
with self.subTest(mode=mode):
|
|
2325
|
+
result, _ = self.complete("--mode", mode, acceptance=False, success=False)
|
|
2326
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2327
|
+
self.assertIn("merge-base:", result["reason"])
|
|
2328
|
+
self.assertTrue(self.task.exists())
|
|
2329
|
+
receipt = read_json(self.receipt)
|
|
2330
|
+
self.assertEqual(receipt["delivery"], "COMPLETE")
|
|
2331
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
2332
|
+
self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
|
|
2333
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2334
|
+
d = read_json(self.data)
|
|
2335
|
+
self.assertEqual((d["pushs"], d.get("creates", 0), d.get("merges", 0)), (0, 0, 0))
|
|
2336
|
+
|
|
2337
|
+
def test_unchanged_interrupted_cleanup_refuses_local_only(self):
|
|
2338
|
+
# Prediction: both cleanup checkpoints prevent local-only relabeling before delivery is persisted.
|
|
2339
|
+
from unittest.mock import Mock, patch
|
|
2340
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2341
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
2342
|
+
for retained in (True, False):
|
|
2343
|
+
with self.subTest(retained=retained), patch.dict(os.environ, self.env), \
|
|
2344
|
+
patch.dict(complete.__globals__,
|
|
2345
|
+
stopped_writers=Mock(side_effect=WriterActive("fixture writer retention") if retained else None),
|
|
2346
|
+
completion_result=Mock(side_effect=RuntimeError("fixture interruption"))):
|
|
2347
|
+
with self.assertRaisesRegex(RuntimeError, "fixture interruption"):
|
|
2348
|
+
complete(args)
|
|
2349
|
+
receipt = read_json(self.receipt)
|
|
2350
|
+
self.assertIsNone(receipt.get("delivery"))
|
|
2351
|
+
self.assertFalse(receipt.get("pushed"))
|
|
2352
|
+
self.assertEqual("workspace_cleanup" in receipt, retained)
|
|
2353
|
+
self.assertEqual(receipt.get("status"), None if retained else "COMPLETE")
|
|
2354
|
+
result, _ = self.complete("--local-only", acceptance=False, success=False)
|
|
2355
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2356
|
+
self.assertIn("already COMPLETE", result["reason"])
|
|
2357
|
+
self.assertFalse(read_json(self.receipt).get("local_only"))
|
|
2358
|
+
self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
|
|
2359
|
+
|
|
2360
|
+
def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
|
|
2361
|
+
# Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard.
|
|
2362
|
+
self.allocate(); self.accept()
|
|
2363
|
+
self.complete("--mode", "pr")
|
|
2364
|
+
self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
|
|
2365
|
+
"--repo", "github.com/test/project"])
|
|
2366
|
+
self.g("commit", "--allow-empty", "-m", "foreign commit")
|
|
2367
|
+
foreign = self.g("rev-parse", "HEAD")
|
|
2368
|
+
self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
|
|
2369
|
+
result, _ = self.complete("--writers-stopped", success=False)
|
|
2370
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2371
|
+
self.assertIn("remote branch changed after merge; retain ref", result["reason"])
|
|
2372
|
+
self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
|
|
2373
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
|
|
2374
|
+
|
|
2375
|
+
def test_unchanged_foreign_remote_head_retains_worktree(self):
|
|
2376
|
+
# Prediction: a foreign remote task head refuses completion before worktree removal.
|
|
2377
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2378
|
+
self.g("commit", "--allow-empty", "-m", "foreign commit")
|
|
2379
|
+
self.g("push", "origin", "HEAD:refs/heads/task/fixture")
|
|
2380
|
+
result, _ = self.complete("--writers-stopped", success=False)
|
|
2381
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2382
|
+
self.assertIn("remote task head changed", result["reason"])
|
|
2383
|
+
self.assertTrue(self.task.exists())
|
|
2384
|
+
|
|
2385
|
+
def test_unchanged_scratch_retention_reconciles_on_allocate(self):
|
|
2386
|
+
# Prediction: status COMPLETE without a PR still retries retained scratch on the next allocation.
|
|
2387
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2388
|
+
self.accept(unchanged=True)
|
|
2389
|
+
(scratch / ".git").mkdir()
|
|
2390
|
+
result, _ = self.complete("--writers-stopped")
|
|
2391
|
+
self.assertEqual((result["status"], result["scratch_cleanup"]["status"]), ("CLEANUP_PENDING", "RETAINED"))
|
|
2392
|
+
receipt = self.receipt
|
|
2393
|
+
self.assertEqual(read_json(receipt)["status"], "COMPLETE")
|
|
2394
|
+
(scratch / ".git").rmdir()
|
|
2395
|
+
result = self.allocate("next")
|
|
2396
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2397
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
2398
|
+
|
|
2399
|
+
def test_unchanged_complete_refuses_local_only(self):
|
|
2400
|
+
# Prediction: a delivered on-base receipt cannot be rewritten as local-only.
|
|
2401
|
+
self.allocate(); self.accept(unchanged=True)
|
|
2402
|
+
self.assertEqual(self.complete("--writers-stopped")[0]["status"], "COMPLETE")
|
|
2403
|
+
result, _ = self.complete("--local-only", acceptance=False, success=False)
|
|
2404
|
+
self.assertIn("already COMPLETE", result["reason"])
|
|
2405
|
+
self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
|
|
2406
|
+
|
|
2091
2407
|
def test_reject_repository_rewrite_before_allocation(self):
|
|
2092
2408
|
intended = "https://github.com/test/project.git"
|
|
2093
2409
|
destination = "https://github.com/test/other.git"
|
|
@@ -200,7 +200,10 @@ python3 "$DEVLYN_SHARED_DIR/task-complete.py" complete \
|
|
|
200
200
|
Project policy is `git config --local devlyn.completionMode auto|pr`; absent means
|
|
201
201
|
`auto`. Invalid local values fail before external effects, even with an override.
|
|
202
202
|
Use `complete --mode auto|pr` for one task, or `--local-only`/`--no-push` to honor
|
|
203
|
-
the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed
|
|
203
|
+
the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed or delivery is `COMPLETE`.
|
|
204
|
+
|
|
205
|
+
With no matching PR and the accepted commit already on the fetched base, either
|
|
206
|
+
publication mode pushes no task commit and opens no PR. Delivery is `COMPLETE` with no `pr`; cleanup and retention work as for a merged delivery.
|
|
204
207
|
|
|
205
208
|
`pr` pushes the exact accepted task ref and creates/reuses its exact repository,
|
|
206
209
|
head and base PR. While OPEN, it cancels owned auto-merge, reports `PR` with its URL
|
|
@@ -230,18 +233,21 @@ must never be reported as accepted.
|
|
|
230
233
|
|
|
231
234
|
## Yield and retain recoverability
|
|
232
235
|
|
|
233
|
-
Cleanup requires actual matching MERGED evidence; linked-tree removal also
|
|
236
|
+
Cleanup requires actual matching MERGED evidence or, with no PR, the accepted commit on the fetched base; linked-tree removal also
|
|
234
237
|
requires the owner's recorded release. OS observation supplements it; neither the receipt
|
|
235
238
|
nor a scan guarantees exclusion of future writers. Processes with cwd or open
|
|
236
239
|
files inside the tree, inaccessible process state (on Linux, other users'
|
|
237
240
|
processes unless run as root), caller cwd inside the tree, dirty/untracked files,
|
|
238
241
|
a nested repository or worktree, or changed refs/Gitdir/registration retain
|
|
239
242
|
affected resources. Use `git worktree lock` to keep a tree.
|
|
240
|
-
|
|
243
|
+
A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
|
|
244
|
+
until it restarts; the helper names it when identifiable and retries cleanup on
|
|
245
|
+
the next allocation or resume after the restart.
|
|
246
|
+
On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged or on-base delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
|
|
241
247
|
|
|
242
248
|
The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
|
|
243
249
|
`refs/devlyn/completed/<id>` recovery ref. Check evidence is copied there
|
|
244
|
-
before publication; removal rechecks it, the recovery ref and the merge commit on base.
|
|
250
|
+
before publication; removal rechecks it, the recovery ref and the merge or accepted commit on base.
|
|
245
251
|
Ignored build output and other ignored files are disposable under native
|
|
246
252
|
non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
|
|
247
253
|
and preserved as `<receipt dir>/records/.devlyn`; changes after an earlier copy
|
|
@@ -1034,15 +1034,19 @@ def input_files(v, row, receipt, local):
|
|
|
1034
1034
|
|
|
1035
1035
|
|
|
1036
1036
|
def require_merged(v, row, base, *, allocated=False):
|
|
1037
|
-
"""auto/pr: every prerequisite's
|
|
1037
|
+
"""auto/pr: every prerequisite's delivered commit must be in the base, before allocation and before every execution."""
|
|
1038
1038
|
for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"]:
|
|
1039
1039
|
receipt = v["states"][f"{row['loop']}.{dep}"]["receipt"] or {}
|
|
1040
1040
|
merge = ((receipt.get("merge") or {}).get("mergeCommit") or {}).get("oid")
|
|
1041
|
+
kind = "merge"
|
|
1042
|
+
if not receipt.get("merge") and receipt.get("delivery") == "COMPLETE":
|
|
1043
|
+
merge = receipt.get("publish_sha")
|
|
1044
|
+
kind = "commit"
|
|
1041
1045
|
remedy = ("retain this allocation and plan the continuation as a new loop, or perform verified recovery of a never-executed allocation"
|
|
1042
|
-
if allocated else f"restore that
|
|
1046
|
+
if allocated else f"restore that {kind} on origin/{manifest_of(v, row['loop'])['base_ref']}, then drain again")
|
|
1043
1047
|
require(merge and ancestor(v["anchor"], merge, base),
|
|
1044
1048
|
f"{row['identity']}: {'allocated baseline' if allocated else 'base'} {base} lacks prerequisite {row['loop']}.{dep}'s "
|
|
1045
|
-
f"delivered
|
|
1049
|
+
f"delivered {kind} {merge}; {remedy}")
|
|
1046
1050
|
|
|
1047
1051
|
|
|
1048
1052
|
def package_drift(v, loop, start, where):
|