devlyn-cli 4.3.14 → 4.3.16

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.
@@ -106,6 +106,7 @@
106
106
  "865e26f38b04dbaae6b0b91616110d6bd748909b7dec68f20cd7c2a240d2fa5a",
107
107
  "8ab08b8f3d19e7fffc36fbc4d8c4dad83df02bcb83a9aabcb8db8b82fe12b384",
108
108
  "8b34167328e88c73538b5ae2a30e452b544a44bc03f70a2c4f0278b33953afc4",
109
+ "8b87eadbb611342029cb9c153b32e2a483bb272dee70b49f0cae251b76a72ecf",
109
110
  "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
110
111
  "8fb73c0d2472b3643ac6e3f0ad87d818694afebaf68fcad596496e703893c43d",
111
112
  "9162fc2fed6f4a611d299485be3a397080beb3304865ce59b95576ba7d00650e",
@@ -771,6 +772,8 @@
771
772
  "4.3.12",
772
773
  "4.3.13",
773
774
  "4.3.14",
775
+ "4.3.15",
776
+ "4.3.16",
774
777
  "4.3.2",
775
778
  "4.3.3",
776
779
  "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.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():
@@ -1835,6 +1856,86 @@ class CompletionTests(unittest.TestCase):
1835
1856
  child.wait(timeout=5)
1836
1857
  self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
1837
1858
 
1859
+ def test_darwin_writer_scan_retains_all_holders_and_names_virtual_machines(self):
1860
+ from unittest.mock import Mock, call, patch
1861
+ pid = os.getpid() + 1
1862
+ vm = "/System/Library/Frameworks/Virtualization.framework/Versions/A/XPCServices/com.apple.Virtualization.VirtualMachine.xpc/Contents/MacOS/com.apple.Virtualization.VirtualMachine"
1863
+ ordinary = f"active process {pid} uses task files; stop/yield actual writers before resume"
1864
+ vm_reason = (f"active process {pid} is a virtual machine (Docker Desktop or another Virtualization.framework VM) "
1865
+ "and holds task files, as it can after a bind mount until restarted; "
1866
+ "stop containers using them and restart the VM's app before resume")
1867
+ own = f"p{os.getpid()}\nn{self.work}\nn{self.work / 'product'}\n"
1868
+ lsof = call(["lsof", "-Fpn", "+D", str(self.work)], capture_output=True, text=True, encoding="utf-8")
1869
+ executable = Mock()
1870
+ with patch.object(sys, "platform", "darwin"), patch.object(subprocess, "run") as run, patch.dict(globals(), darwin_executable=executable):
1871
+ for path, reason in ((vm, vm_reason), (vm.replace("VirtualMachine", "EventTap"), ordinary),
1872
+ ("/bin/bash", ordinary), (None, ordinary)):
1873
+ for name in ("product", "product\u2028p", "product\u2029p"):
1874
+ with self.subTest(executable=path, name=name):
1875
+ run.reset_mock()
1876
+ executable.reset_mock()
1877
+ executable.return_value = path
1878
+ records = (f"p{os.getpid()}\nn{self.work / name}\np{pid}\n"
1879
+ f"n{self.work / ('product' if name == 'product' else 'held')}\n")
1880
+ run.return_value = subprocess.CompletedProcess([], 0, records, "")
1881
+ with self.assertRaises(WriterActive) as raised:
1882
+ stopped_writers(self.work)
1883
+ self.assertEqual(str(raised.exception), reason)
1884
+ self.assertEqual(run.call_args_list, [lsof])
1885
+ executable.assert_called_once_with(pid)
1886
+ run.reset_mock()
1887
+ executable.reset_mock()
1888
+ run.return_value = subprocess.CompletedProcess([], 0, own, "")
1889
+ stopped_writers(self.work)
1890
+ self.assertEqual(run.call_args_list, [lsof])
1891
+ executable.assert_not_called()
1892
+ for code, stderr in ((0, "warning"), (1, "warning"), (2, ""), (-1, "")):
1893
+ with self.subTest(code=code, stderr=stderr):
1894
+ run.return_value = subprocess.CompletedProcess([], code, f"p{pid}\nn{self.work}\n", stderr)
1895
+ with self.assertRaises(WritersUnobservable):
1896
+ stopped_writers(self.work)
1897
+ executable.assert_not_called()
1898
+ run.return_value = subprocess.CompletedProcess([], 1, "", "")
1899
+ stopped_writers(self.work)
1900
+ executable.assert_not_called()
1901
+
1902
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS proc_pidpath")
1903
+ def test_darwin_executable(self):
1904
+ from unittest.mock import patch
1905
+ with self.subTest(process="self"):
1906
+ executable = darwin_executable(os.getpid())
1907
+ self.assertEqual(executable, os.path.realpath(executable))
1908
+ self.assertTrue(os.path.isfile(executable))
1909
+ self.assertTrue(os.access(executable, os.X_OK))
1910
+ with self.subTest(process="reaped"):
1911
+ with subprocess.Popen([sys.executable, "-c", "pass"]) as child:
1912
+ child.wait(timeout=5)
1913
+ self.assertIsNone(darwin_executable(child.pid))
1914
+ with self.subTest(error="lookup failed"), patch.object(ctypes, "CDLL") as library:
1915
+ library.return_value.proc_pidpath.return_value = 0
1916
+ self.assertIsNone(darwin_executable(os.getpid()))
1917
+ with self.subTest(error="library unavailable"), patch.object(ctypes, "CDLL", side_effect=OSError("libproc unavailable")):
1918
+ self.assertIsNone(darwin_executable(os.getpid()))
1919
+
1920
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS lsof descriptor access")
1921
+ def test_darwin_real_read_only_descriptors_and_scripts_block(self):
1922
+ script = self.work / "loop.sh"
1923
+ script.write_text("printf 'ready\\n'\nwhile :; do sleep 1; done\n", encoding="utf-8")
1924
+ for command in ([sys.executable, "-c",
1925
+ "import os, sys, time; f = open(sys.argv[1], 'rb'); "
1926
+ "d = os.open(sys.argv[2], os.O_RDONLY); print('ready', flush=True); time.sleep(30)",
1927
+ str(self.work / "product"), str(self.work)], ["/bin/bash", str(script)]):
1928
+ with self.subTest(command=command[0]):
1929
+ child = subprocess.Popen(command, cwd=self.root, stdout=subprocess.PIPE, text=True)
1930
+ try:
1931
+ self.assertEqual(child.stdout.readline(), "ready\n")
1932
+ with self.assertRaisesRegex(WriterActive, f"active process {child.pid} uses task files"):
1933
+ stopped_writers(self.work)
1934
+ finally:
1935
+ child.kill()
1936
+ child.wait(timeout=5)
1937
+ child.stdout.close()
1938
+
1838
1939
  def test_linux_writer_scan_continues_after_vanished_fd(self):
1839
1940
  from unittest.mock import patch
1840
1941
  process = Path("/proc") / str(os.getpid() + 1)
@@ -237,6 +237,9 @@ files inside the tree, inaccessible process state (on Linux, other users'
237
237
  processes unless run as root), caller cwd inside the tree, dirty/untracked files,
238
238
  a nested repository or worktree, or changed refs/Gitdir/registration retain
239
239
  affected resources. Use `git worktree lock` to keep a tree.
240
+ A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
241
+ until it restarts; the helper names it when identifiable and retries cleanup on
242
+ the next allocation or resume after the restart.
240
243
  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.
241
244
 
242
245
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.14",
3
+ "version": "4.3.16",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -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():
@@ -1835,6 +1856,86 @@ class CompletionTests(unittest.TestCase):
1835
1856
  child.wait(timeout=5)
1836
1857
  self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
1837
1858
 
1859
+ def test_darwin_writer_scan_retains_all_holders_and_names_virtual_machines(self):
1860
+ from unittest.mock import Mock, call, patch
1861
+ pid = os.getpid() + 1
1862
+ vm = "/System/Library/Frameworks/Virtualization.framework/Versions/A/XPCServices/com.apple.Virtualization.VirtualMachine.xpc/Contents/MacOS/com.apple.Virtualization.VirtualMachine"
1863
+ ordinary = f"active process {pid} uses task files; stop/yield actual writers before resume"
1864
+ vm_reason = (f"active process {pid} is a virtual machine (Docker Desktop or another Virtualization.framework VM) "
1865
+ "and holds task files, as it can after a bind mount until restarted; "
1866
+ "stop containers using them and restart the VM's app before resume")
1867
+ own = f"p{os.getpid()}\nn{self.work}\nn{self.work / 'product'}\n"
1868
+ lsof = call(["lsof", "-Fpn", "+D", str(self.work)], capture_output=True, text=True, encoding="utf-8")
1869
+ executable = Mock()
1870
+ with patch.object(sys, "platform", "darwin"), patch.object(subprocess, "run") as run, patch.dict(globals(), darwin_executable=executable):
1871
+ for path, reason in ((vm, vm_reason), (vm.replace("VirtualMachine", "EventTap"), ordinary),
1872
+ ("/bin/bash", ordinary), (None, ordinary)):
1873
+ for name in ("product", "product\u2028p", "product\u2029p"):
1874
+ with self.subTest(executable=path, name=name):
1875
+ run.reset_mock()
1876
+ executable.reset_mock()
1877
+ executable.return_value = path
1878
+ records = (f"p{os.getpid()}\nn{self.work / name}\np{pid}\n"
1879
+ f"n{self.work / ('product' if name == 'product' else 'held')}\n")
1880
+ run.return_value = subprocess.CompletedProcess([], 0, records, "")
1881
+ with self.assertRaises(WriterActive) as raised:
1882
+ stopped_writers(self.work)
1883
+ self.assertEqual(str(raised.exception), reason)
1884
+ self.assertEqual(run.call_args_list, [lsof])
1885
+ executable.assert_called_once_with(pid)
1886
+ run.reset_mock()
1887
+ executable.reset_mock()
1888
+ run.return_value = subprocess.CompletedProcess([], 0, own, "")
1889
+ stopped_writers(self.work)
1890
+ self.assertEqual(run.call_args_list, [lsof])
1891
+ executable.assert_not_called()
1892
+ for code, stderr in ((0, "warning"), (1, "warning"), (2, ""), (-1, "")):
1893
+ with self.subTest(code=code, stderr=stderr):
1894
+ run.return_value = subprocess.CompletedProcess([], code, f"p{pid}\nn{self.work}\n", stderr)
1895
+ with self.assertRaises(WritersUnobservable):
1896
+ stopped_writers(self.work)
1897
+ executable.assert_not_called()
1898
+ run.return_value = subprocess.CompletedProcess([], 1, "", "")
1899
+ stopped_writers(self.work)
1900
+ executable.assert_not_called()
1901
+
1902
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS proc_pidpath")
1903
+ def test_darwin_executable(self):
1904
+ from unittest.mock import patch
1905
+ with self.subTest(process="self"):
1906
+ executable = darwin_executable(os.getpid())
1907
+ self.assertEqual(executable, os.path.realpath(executable))
1908
+ self.assertTrue(os.path.isfile(executable))
1909
+ self.assertTrue(os.access(executable, os.X_OK))
1910
+ with self.subTest(process="reaped"):
1911
+ with subprocess.Popen([sys.executable, "-c", "pass"]) as child:
1912
+ child.wait(timeout=5)
1913
+ self.assertIsNone(darwin_executable(child.pid))
1914
+ with self.subTest(error="lookup failed"), patch.object(ctypes, "CDLL") as library:
1915
+ library.return_value.proc_pidpath.return_value = 0
1916
+ self.assertIsNone(darwin_executable(os.getpid()))
1917
+ with self.subTest(error="library unavailable"), patch.object(ctypes, "CDLL", side_effect=OSError("libproc unavailable")):
1918
+ self.assertIsNone(darwin_executable(os.getpid()))
1919
+
1920
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS lsof descriptor access")
1921
+ def test_darwin_real_read_only_descriptors_and_scripts_block(self):
1922
+ script = self.work / "loop.sh"
1923
+ script.write_text("printf 'ready\\n'\nwhile :; do sleep 1; done\n", encoding="utf-8")
1924
+ for command in ([sys.executable, "-c",
1925
+ "import os, sys, time; f = open(sys.argv[1], 'rb'); "
1926
+ "d = os.open(sys.argv[2], os.O_RDONLY); print('ready', flush=True); time.sleep(30)",
1927
+ str(self.work / "product"), str(self.work)], ["/bin/bash", str(script)]):
1928
+ with self.subTest(command=command[0]):
1929
+ child = subprocess.Popen(command, cwd=self.root, stdout=subprocess.PIPE, text=True)
1930
+ try:
1931
+ self.assertEqual(child.stdout.readline(), "ready\n")
1932
+ with self.assertRaisesRegex(WriterActive, f"active process {child.pid} uses task files"):
1933
+ stopped_writers(self.work)
1934
+ finally:
1935
+ child.kill()
1936
+ child.wait(timeout=5)
1937
+ child.stdout.close()
1938
+
1838
1939
  def test_linux_writer_scan_continues_after_vanished_fd(self):
1839
1940
  from unittest.mock import patch
1840
1941
  process = Path("/proc") / str(os.getpid() + 1)
@@ -237,6 +237,9 @@ files inside the tree, inaccessible process state (on Linux, other users'
237
237
  processes unless run as root), caller cwd inside the tree, dirty/untracked files,
238
238
  a nested repository or worktree, or changed refs/Gitdir/registration retain
239
239
  affected resources. Use `git worktree lock` to keep a tree.
240
+ A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
241
+ until it restarts; the helper names it when identifiable and retries cleanup on
242
+ the next allocation or resume after the restart.
240
243
  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.
241
244
 
242
245
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-14",
2
+ "name": "devlyn-harness-v4-3-16",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.14",
3
+ "version": "4.3.16",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -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():
@@ -1835,6 +1856,86 @@ class CompletionTests(unittest.TestCase):
1835
1856
  child.wait(timeout=5)
1836
1857
  self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
1837
1858
 
1859
+ def test_darwin_writer_scan_retains_all_holders_and_names_virtual_machines(self):
1860
+ from unittest.mock import Mock, call, patch
1861
+ pid = os.getpid() + 1
1862
+ vm = "/System/Library/Frameworks/Virtualization.framework/Versions/A/XPCServices/com.apple.Virtualization.VirtualMachine.xpc/Contents/MacOS/com.apple.Virtualization.VirtualMachine"
1863
+ ordinary = f"active process {pid} uses task files; stop/yield actual writers before resume"
1864
+ vm_reason = (f"active process {pid} is a virtual machine (Docker Desktop or another Virtualization.framework VM) "
1865
+ "and holds task files, as it can after a bind mount until restarted; "
1866
+ "stop containers using them and restart the VM's app before resume")
1867
+ own = f"p{os.getpid()}\nn{self.work}\nn{self.work / 'product'}\n"
1868
+ lsof = call(["lsof", "-Fpn", "+D", str(self.work)], capture_output=True, text=True, encoding="utf-8")
1869
+ executable = Mock()
1870
+ with patch.object(sys, "platform", "darwin"), patch.object(subprocess, "run") as run, patch.dict(globals(), darwin_executable=executable):
1871
+ for path, reason in ((vm, vm_reason), (vm.replace("VirtualMachine", "EventTap"), ordinary),
1872
+ ("/bin/bash", ordinary), (None, ordinary)):
1873
+ for name in ("product", "product\u2028p", "product\u2029p"):
1874
+ with self.subTest(executable=path, name=name):
1875
+ run.reset_mock()
1876
+ executable.reset_mock()
1877
+ executable.return_value = path
1878
+ records = (f"p{os.getpid()}\nn{self.work / name}\np{pid}\n"
1879
+ f"n{self.work / ('product' if name == 'product' else 'held')}\n")
1880
+ run.return_value = subprocess.CompletedProcess([], 0, records, "")
1881
+ with self.assertRaises(WriterActive) as raised:
1882
+ stopped_writers(self.work)
1883
+ self.assertEqual(str(raised.exception), reason)
1884
+ self.assertEqual(run.call_args_list, [lsof])
1885
+ executable.assert_called_once_with(pid)
1886
+ run.reset_mock()
1887
+ executable.reset_mock()
1888
+ run.return_value = subprocess.CompletedProcess([], 0, own, "")
1889
+ stopped_writers(self.work)
1890
+ self.assertEqual(run.call_args_list, [lsof])
1891
+ executable.assert_not_called()
1892
+ for code, stderr in ((0, "warning"), (1, "warning"), (2, ""), (-1, "")):
1893
+ with self.subTest(code=code, stderr=stderr):
1894
+ run.return_value = subprocess.CompletedProcess([], code, f"p{pid}\nn{self.work}\n", stderr)
1895
+ with self.assertRaises(WritersUnobservable):
1896
+ stopped_writers(self.work)
1897
+ executable.assert_not_called()
1898
+ run.return_value = subprocess.CompletedProcess([], 1, "", "")
1899
+ stopped_writers(self.work)
1900
+ executable.assert_not_called()
1901
+
1902
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS proc_pidpath")
1903
+ def test_darwin_executable(self):
1904
+ from unittest.mock import patch
1905
+ with self.subTest(process="self"):
1906
+ executable = darwin_executable(os.getpid())
1907
+ self.assertEqual(executable, os.path.realpath(executable))
1908
+ self.assertTrue(os.path.isfile(executable))
1909
+ self.assertTrue(os.access(executable, os.X_OK))
1910
+ with self.subTest(process="reaped"):
1911
+ with subprocess.Popen([sys.executable, "-c", "pass"]) as child:
1912
+ child.wait(timeout=5)
1913
+ self.assertIsNone(darwin_executable(child.pid))
1914
+ with self.subTest(error="lookup failed"), patch.object(ctypes, "CDLL") as library:
1915
+ library.return_value.proc_pidpath.return_value = 0
1916
+ self.assertIsNone(darwin_executable(os.getpid()))
1917
+ with self.subTest(error="library unavailable"), patch.object(ctypes, "CDLL", side_effect=OSError("libproc unavailable")):
1918
+ self.assertIsNone(darwin_executable(os.getpid()))
1919
+
1920
+ @unittest.skipUnless(sys.platform == "darwin", "requires macOS lsof descriptor access")
1921
+ def test_darwin_real_read_only_descriptors_and_scripts_block(self):
1922
+ script = self.work / "loop.sh"
1923
+ script.write_text("printf 'ready\\n'\nwhile :; do sleep 1; done\n", encoding="utf-8")
1924
+ for command in ([sys.executable, "-c",
1925
+ "import os, sys, time; f = open(sys.argv[1], 'rb'); "
1926
+ "d = os.open(sys.argv[2], os.O_RDONLY); print('ready', flush=True); time.sleep(30)",
1927
+ str(self.work / "product"), str(self.work)], ["/bin/bash", str(script)]):
1928
+ with self.subTest(command=command[0]):
1929
+ child = subprocess.Popen(command, cwd=self.root, stdout=subprocess.PIPE, text=True)
1930
+ try:
1931
+ self.assertEqual(child.stdout.readline(), "ready\n")
1932
+ with self.assertRaisesRegex(WriterActive, f"active process {child.pid} uses task files"):
1933
+ stopped_writers(self.work)
1934
+ finally:
1935
+ child.kill()
1936
+ child.wait(timeout=5)
1937
+ child.stdout.close()
1938
+
1838
1939
  def test_linux_writer_scan_continues_after_vanished_fd(self):
1839
1940
  from unittest.mock import patch
1840
1941
  process = Path("/proc") / str(os.getpid() + 1)
@@ -237,6 +237,9 @@ files inside the tree, inaccessible process state (on Linux, other users'
237
237
  processes unless run as root), caller cwd inside the tree, dirty/untracked files,
238
238
  a nested repository or worktree, or changed refs/Gitdir/registration retain
239
239
  affected resources. Use `git worktree lock` to keep a tree.
240
+ A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
241
+ until it restarts; the helper names it when identifiable and retries cleanup on
242
+ the next allocation or resume after the restart.
240
243
  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.
241
244
 
242
245
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.3.14",
3
+ "version": "4.3.16",
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": {
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "devlyn-cli",
4
- "version": "4.3.14",
4
+ "version": "4.3.16",
5
5
  "principles": {
6
6
  "defaults": "config/skills/_shared/runtime-principles.md",
7
7
  "memory": {
@@ -129,7 +129,7 @@
129
129
  },
130
130
  {
131
131
  "path": "bin/devlyn.js",
132
- "sha256": "bbf2a2ee5309eab804cc3e03fd4c9bd0e0c71db67cfacc764c2f5e611968b4bf",
132
+ "sha256": "616c6cd8c52d310dc5c5040c4d40e1dedb6bc50ca088428a5dcea8ac805ea188",
133
133
  "executable": true
134
134
  },
135
135
  {
@@ -164,7 +164,7 @@
164
164
  },
165
165
  {
166
166
  "path": "bin/skill-history.json",
167
- "sha256": "eb54a16283da49f9817a7b306f3be3c26bb0a01b8372a8377ee68461789ae43a",
167
+ "sha256": "bb0616e1499249bfa9f2bbe5d36adefb1c4fd8e671f7c62be248db444619630e",
168
168
  "executable": false
169
169
  },
170
170
  {
@@ -244,12 +244,12 @@
244
244
  },
245
245
  {
246
246
  "path": "config/skills/_shared/task-complete.py",
247
- "sha256": "f9709f6855f511401205788aecf8aa59e268855b7673b84ed617adee56a245dc",
247
+ "sha256": "4d01c1a9d9c5dd54d884e8248f922404959def9a6e3b96da6497d2940295b8df",
248
248
  "executable": false
249
249
  },
250
250
  {
251
251
  "path": "config/skills/_shared/task-completion.md",
252
- "sha256": "8b9a8706ab912165f2eef66bc39f2261f88df2b1b682d8d90d7f05f6014e965e",
252
+ "sha256": "dc608ff5de6cf03b0649cdb35b7ed6ded64f1b2164bf41cc522fd12ac93a2730",
253
253
  "executable": false
254
254
  },
255
255
  {
@@ -309,7 +309,7 @@
309
309
  },
310
310
  {
311
311
  "path": "native/claude/.claude-plugin/plugin.json",
312
- "sha256": "69ae38cdcbcd7e7c695bf44241103657690be50e971baba15ec592b3e29c71df",
312
+ "sha256": "66df694e66f27457ab9b801058af9a06ed6a9ad4c22805f6a35d2e500e0fc624",
313
313
  "executable": false
314
314
  },
315
315
  {
@@ -384,12 +384,12 @@
384
384
  },
385
385
  {
386
386
  "path": "native/claude/skills/_shared/task-complete.py",
387
- "sha256": "f9709f6855f511401205788aecf8aa59e268855b7673b84ed617adee56a245dc",
387
+ "sha256": "4d01c1a9d9c5dd54d884e8248f922404959def9a6e3b96da6497d2940295b8df",
388
388
  "executable": false
389
389
  },
390
390
  {
391
391
  "path": "native/claude/skills/_shared/task-completion.md",
392
- "sha256": "8b9a8706ab912165f2eef66bc39f2261f88df2b1b682d8d90d7f05f6014e965e",
392
+ "sha256": "dc608ff5de6cf03b0649cdb35b7ed6ded64f1b2164bf41cc522fd12ac93a2730",
393
393
  "executable": false
394
394
  },
395
395
  {
@@ -439,12 +439,12 @@
439
439
  },
440
440
  {
441
441
  "path": "native/codex/.agents/plugins/marketplace.json",
442
- "sha256": "9cd5c06de1b20c1fec885ad85ee1409505c4d03e74e60ca6774247aa93269b8d",
442
+ "sha256": "c218c7b7ee50bd44da1c4f173322cc2f4bca9bcca89beff870a730aaaf504ae5",
443
443
  "executable": false
444
444
  },
445
445
  {
446
446
  "path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
447
- "sha256": "69ae38cdcbcd7e7c695bf44241103657690be50e971baba15ec592b3e29c71df",
447
+ "sha256": "66df694e66f27457ab9b801058af9a06ed6a9ad4c22805f6a35d2e500e0fc624",
448
448
  "executable": false
449
449
  },
450
450
  {
@@ -519,12 +519,12 @@
519
519
  },
520
520
  {
521
521
  "path": "native/codex/plugins/devlyn/skills/_shared/task-complete.py",
522
- "sha256": "f9709f6855f511401205788aecf8aa59e268855b7673b84ed617adee56a245dc",
522
+ "sha256": "4d01c1a9d9c5dd54d884e8248f922404959def9a6e3b96da6497d2940295b8df",
523
523
  "executable": false
524
524
  },
525
525
  {
526
526
  "path": "native/codex/plugins/devlyn/skills/_shared/task-completion.md",
527
- "sha256": "8b9a8706ab912165f2eef66bc39f2261f88df2b1b682d8d90d7f05f6014e965e",
527
+ "sha256": "dc608ff5de6cf03b0649cdb35b7ed6ded64f1b2164bf41cc522fd12ac93a2730",
528
528
  "executable": false
529
529
  },
530
530
  {
@@ -1714,12 +1714,12 @@
1714
1714
  },
1715
1715
  {
1716
1716
  "path": "package.json",
1717
- "sha256": "bac25159f616f9a0583452dbf8eb902d2d746d095f2f70526792efa6e34fac0f",
1717
+ "sha256": "031b1e8086ddf9f247312a33a95c5a02d7553b1eb41e93d61d8733ea258fe776",
1718
1718
  "executable": false
1719
1719
  }
1720
1720
  ],
1721
1721
  "digest": {
1722
1722
  "algorithm": "sha256",
1723
- "value": "4f420dc3d285818f7e5ed5bc007c4a718a2d2ed690c862321cc84fc14b907b2f"
1723
+ "value": "43b2c6d34c3fd114335d0952e258d8637b420978aa97d311a64c38e96701c0d1"
1724
1724
  }
1725
1725
  }