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.
@@ -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.splitlines():
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
- require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
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("local_only"):
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
- if not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
879
- continue
880
- pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
881
- if pr["state"] != "MERGED":
882
- continue
883
- validate_pr(receipt, pr)
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
- receipt["merge"] = pr
888
- atomic_json(path, receipt)
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
- actual = remote_head(receipt, receipt["branch"])
952
- if not pr or pr["state"] != "MERGED":
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
- receipt.update(pr_number=pr["number"], pr_url=pr["url"])
968
- atomic_json(path, receipt)
969
- if mode == "pr" and pr["state"] == "OPEN":
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
- receipt["merge"] = pr
1018
- atomic_json(path, receipt)
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: the merge settles delivery; workspace, task refs and custody stay, reported.
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
- (self.task / "product").write_text("accepted\n", encoding="utf-8")
2073
- self.g("add", "product", work=self.task)
2074
- self.g("commit", "-m", "scoped task", work=self.task)
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
- 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 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.
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 merge commit must be in the base, before allocation and before every execution."""
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 merge on origin/{manifest_of(v, row['loop'])['base_ref']}, then drain again")
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 merge {merge}; {remedy}")
1049
+ f"delivered {kind} {merge}; {remedy}")
1046
1050
 
1047
1051
 
1048
1052
  def package_drift(v, loop, start, where):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.3.15",
3
+ "version": "4.3.17",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {