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.
- package/bin/devlyn.js +312 -157
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +102 -1
- package/config/skills/_shared/task-completion.md +3 -0
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +102 -1
- package/native/claude/skills/_shared/task-completion.md +3 -0
- 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 +102 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -0
- package/package.json +1 -1
- package/release.json +14 -14
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",
|
|
@@ -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.
|
|
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
|
|
@@ -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():
|
|
@@ -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
|
|
@@ -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():
|
|
@@ -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.
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
247
|
+
"sha256": "4d01c1a9d9c5dd54d884e8248f922404959def9a6e3b96da6497d2940295b8df",
|
|
248
248
|
"executable": false
|
|
249
249
|
},
|
|
250
250
|
{
|
|
251
251
|
"path": "config/skills/_shared/task-completion.md",
|
|
252
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
387
|
+
"sha256": "4d01c1a9d9c5dd54d884e8248f922404959def9a6e3b96da6497d2940295b8df",
|
|
388
388
|
"executable": false
|
|
389
389
|
},
|
|
390
390
|
{
|
|
391
391
|
"path": "native/claude/skills/_shared/task-completion.md",
|
|
392
|
-
"sha256": "
|
|
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": "
|
|
442
|
+
"sha256": "c218c7b7ee50bd44da1c4f173322cc2f4bca9bcca89beff870a730aaaf504ae5",
|
|
443
443
|
"executable": false
|
|
444
444
|
},
|
|
445
445
|
{
|
|
446
446
|
"path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
|
|
447
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
1717
|
+
"sha256": "031b1e8086ddf9f247312a33a95c5a02d7553b1eb41e93d61d8733ea258fe776",
|
|
1718
1718
|
"executable": false
|
|
1719
1719
|
}
|
|
1720
1720
|
],
|
|
1721
1721
|
"digest": {
|
|
1722
1722
|
"algorithm": "sha256",
|
|
1723
|
-
"value": "
|
|
1723
|
+
"value": "43b2c6d34c3fd114335d0952e258d8637b420978aa97d311a64c38e96701c0d1"
|
|
1724
1724
|
}
|
|
1725
1725
|
}
|