devlyn-cli 4.3.4 → 4.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/devlyn.js +82 -11
- package/bin/skill-history.json +6 -1
- package/config/skills/_shared/task-complete.py +227 -7
- package/config/skills/_shared/task-completion.md +5 -2
- package/config/skills/_shared/warm-build-dirs.py +10 -0
- package/config/skills/devlyn-ideate/references/loop.md +1 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +19 -2
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +227 -7
- package/native/claude/skills/_shared/task-completion.md +5 -2
- package/native/claude/skills/_shared/warm-build-dirs.py +10 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +19 -2
- 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 +227 -7
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +5 -2
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +10 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +19 -2
- package/package.json +1 -1
- package/release.json +23 -23
|
@@ -239,14 +239,25 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
239
239
|
policy(receipt, None)
|
|
240
240
|
receipt["remote_url"] = remote_url(receipt)
|
|
241
241
|
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
242
|
-
receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
|
|
243
242
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
244
243
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
245
244
|
receipt["worktree"] = str(target)
|
|
246
245
|
key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
|
|
247
246
|
directory = common / "devlyn-completion" / key
|
|
248
|
-
directory.mkdir(parents=True, exist_ok=False)
|
|
249
247
|
path = directory / "receipt.json"
|
|
248
|
+
reconciled = [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)
|
|
249
|
+
measured = target
|
|
250
|
+
while not measured.exists():
|
|
251
|
+
require(measured.parent != measured, f"no existing ancestor of {target} to measure free space")
|
|
252
|
+
measured = measured.parent
|
|
253
|
+
usage = shutil.disk_usage(measured)
|
|
254
|
+
floor = max(10 * 1024**3, (usage.total + 49) // 50)
|
|
255
|
+
require(usage.free >= floor,
|
|
256
|
+
f"BLOCKED:disk {usage.free} bytes ({usage.free / 1024**3:.2f} GiB) free is below the "
|
|
257
|
+
f"{floor} byte ({floor / 1024**3:.2f} GiB) floor on {measured}; free space "
|
|
258
|
+
"(for example, run Devlyn disk cleanup or remove finished task worktrees' build output), then allocate again")
|
|
259
|
+
receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
|
|
260
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
250
261
|
receipt["id"] = key
|
|
251
262
|
receipt["recovery_ref"] = "refs/devlyn/completed/" + key
|
|
252
263
|
scratch = directory / "scratch"
|
|
@@ -270,7 +281,7 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
270
281
|
atomic_json(path, owned)
|
|
271
282
|
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
|
|
272
283
|
"build_dirs": owned["build_dirs"],
|
|
273
|
-
"reconciled":
|
|
284
|
+
"reconciled": reconciled}
|
|
274
285
|
|
|
275
286
|
|
|
276
287
|
def exact_commit(receipt, value, flag):
|
|
@@ -451,7 +462,15 @@ def validate_pr(receipt, pr):
|
|
|
451
462
|
|
|
452
463
|
|
|
453
464
|
def workspace_identity(work, gitdir):
|
|
454
|
-
return [[
|
|
465
|
+
return [[info.st_dev, info.st_ino] for info in (work.stat(), gitdir.stat())]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def same_workspace_identity(current, recorded):
|
|
469
|
+
# Device numbers change across restarts: compare inodes and whether the pair shares a filesystem.
|
|
470
|
+
def shape(identity):
|
|
471
|
+
(first_dev, first_ino), (second_dev, second_ino) = identity
|
|
472
|
+
return first_ino, second_ino, first_dev == second_dev
|
|
473
|
+
return shape(current) == shape(recorded)
|
|
455
474
|
|
|
456
475
|
|
|
457
476
|
def inspect_workspace(receipt):
|
|
@@ -461,7 +480,7 @@ def inspect_workspace(receipt):
|
|
|
461
480
|
expected = "refs/heads/" + receipt["branch"]
|
|
462
481
|
require(row.get("branch") == expected, "foreign worktree branch; retain workspace")
|
|
463
482
|
require(git(work, "rev-parse", "--absolute-git-dir") == receipt["worktree_gitdir"] and str(Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()) == receipt["common_gitdir"], "worktree Gitdir ownership changed")
|
|
464
|
-
require(workspace_identity(work, Path(receipt["worktree_gitdir"]))
|
|
483
|
+
require(same_workspace_identity(workspace_identity(work, Path(receipt["worktree_gitdir"])), receipt["worktree_identity"]), "worktree/registration was replaced; receipt no longer owns it")
|
|
465
484
|
require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
|
|
466
485
|
require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
|
|
467
486
|
|
|
@@ -581,8 +600,8 @@ def clean_scratch(receipt, path, writers_stopped):
|
|
|
581
600
|
return {"status": "NOT_OWNED"}
|
|
582
601
|
require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
|
|
583
602
|
require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
|
|
584
|
-
identity =
|
|
585
|
-
require(
|
|
603
|
+
identity = workspace_identity(scratch, path.parent)
|
|
604
|
+
require(same_workspace_identity(identity, receipt["scratch_identity"]), "scratch directory was replaced; retain")
|
|
586
605
|
require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
|
|
587
606
|
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
|
|
588
607
|
stopped_writers(scratch)
|
|
@@ -592,6 +611,7 @@ def clean_scratch(receipt, path, writers_stopped):
|
|
|
592
611
|
for name in dirs + files:
|
|
593
612
|
item = Path(directory) / name
|
|
594
613
|
info = item.lstat()
|
|
614
|
+
# During this cleanup, device numbers must stay exact, including every child's device.
|
|
595
615
|
require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
|
|
596
616
|
size += info.st_size if stat.S_ISREG(info.st_mode) else 0
|
|
597
617
|
stopped_writers(scratch)
|
|
@@ -1061,9 +1081,21 @@ sys.exit(r.returncode)
|
|
|
1061
1081
|
|
|
1062
1082
|
class CompletionTests(unittest.TestCase):
|
|
1063
1083
|
def setUp(self):
|
|
1084
|
+
from unittest.mock import Mock, patch
|
|
1064
1085
|
self.temp = tempfile.TemporaryDirectory(prefix="devlyn-completion-")
|
|
1065
1086
|
self.addCleanup(self.temp.cleanup)
|
|
1066
1087
|
self.root = Path(self.temp.name).resolve()
|
|
1088
|
+
(self.root / "sitecustomize.py").write_text(
|
|
1089
|
+
"import importlib.machinery, importlib.util, pathlib, sys\n"
|
|
1090
|
+
"spec = importlib.machinery.PathFinder.find_spec('sitecustomize', [p for p in sys.path if pathlib.Path(p or '.').resolve() != pathlib.Path(__file__).resolve().parent])\n"
|
|
1091
|
+
"if spec is not None and spec.loader is not None:\n"
|
|
1092
|
+
" spec.loader.exec_module(importlib.util.module_from_spec(spec))\n"
|
|
1093
|
+
"import shutil\nfrom types import SimpleNamespace\n"
|
|
1094
|
+
"shutil.disk_usage = lambda path: SimpleNamespace(total=100 * 1024**3, free=100 * 1024**3)\n",
|
|
1095
|
+
encoding="utf-8")
|
|
1096
|
+
disk_usage = patch.object(shutil, "disk_usage", return_value=Mock(total=100 * 1024**3, free=100 * 1024**3))
|
|
1097
|
+
disk_usage.start()
|
|
1098
|
+
self.addCleanup(disk_usage.stop)
|
|
1067
1099
|
self.work = self.root / "work"
|
|
1068
1100
|
self.bare = self.root / "remote.git"
|
|
1069
1101
|
self.bin = self.root / "bin"
|
|
@@ -1075,6 +1107,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1075
1107
|
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
|
|
1076
1108
|
"FIXTURE_GH": str(self.data), "REAL_GIT": shutil.which("git"),
|
|
1077
1109
|
"GIT_CONFIG_COUNT": "0", "GIT_ALLOW_PROTOCOL": "file"}
|
|
1110
|
+
self.env["PYTHONPATH"] = str(self.root) + os.pathsep + self.env.get("PYTHONPATH", "")
|
|
1111
|
+
self.env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
1078
1112
|
for key in list(self.env):
|
|
1079
1113
|
if key in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"}:
|
|
1080
1114
|
del self.env[key]
|
|
@@ -1118,6 +1152,80 @@ class CompletionTests(unittest.TestCase):
|
|
|
1118
1152
|
self.task = Path(result["worktree"])
|
|
1119
1153
|
return result
|
|
1120
1154
|
|
|
1155
|
+
def allocation_args(self, name, worktree):
|
|
1156
|
+
return argparse.Namespace(repo=str(self.work), task=name, branch="task/" + name,
|
|
1157
|
+
repository="test/project", base="main", remote="origin", worktree=str(worktree),
|
|
1158
|
+
local_base=None, from_receipt=None, start=None)
|
|
1159
|
+
|
|
1160
|
+
def test_disk_gate_refuses_without_allocation_artifacts(self):
|
|
1161
|
+
# Prediction: below 10 GiB on a 100 GiB filesystem, no receipt, branch or worktree is created.
|
|
1162
|
+
from unittest.mock import Mock, patch
|
|
1163
|
+
target = self.root / "absent" / "new"
|
|
1164
|
+
before = self.g("worktree", "list", "--porcelain")
|
|
1165
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "disk_usage", return_value=Mock(total=100 * 1024**3, free=9 * 1024**3)) as usage:
|
|
1166
|
+
with self.assertRaisesRegex(CompletionError, r"^BLOCKED:disk") as raised:
|
|
1167
|
+
allocate(self.allocation_args("new", target))
|
|
1168
|
+
usage.assert_called_once_with(self.root)
|
|
1169
|
+
self.assertIn(str(9 * 1024**3), str(raised.exception))
|
|
1170
|
+
self.assertIn(str(10 * 1024**3), str(raised.exception))
|
|
1171
|
+
self.assertIn(str(self.root), str(raised.exception))
|
|
1172
|
+
self.assertIn("allocate again", str(raised.exception))
|
|
1173
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1174
|
+
self.assertFalse(target.exists())
|
|
1175
|
+
self.assertFalse(target.parent.exists())
|
|
1176
|
+
self.assertEqual(self.g("branch", "--list", "task/new"), "")
|
|
1177
|
+
self.assertEqual(self.g("worktree", "list", "--porcelain"), before)
|
|
1178
|
+
|
|
1179
|
+
def test_disk_gate_refuses_when_no_ancestor_exists(self):
|
|
1180
|
+
# Prediction: even if the filesystem root is absent, the walk stops before disk usage or artifact creation.
|
|
1181
|
+
from unittest.mock import patch
|
|
1182
|
+
target = self.root / "absent" / "new"
|
|
1183
|
+
exists = Path.exists
|
|
1184
|
+
with patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
|
|
1185
|
+
patch.object(shutil, "disk_usage") as usage:
|
|
1186
|
+
with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
|
|
1187
|
+
allocate(self.allocation_args("new", target))
|
|
1188
|
+
usage.assert_not_called()
|
|
1189
|
+
self.assertFalse(target.parent.exists())
|
|
1190
|
+
self.assertFalse(target.exists())
|
|
1191
|
+
self.assertFalse((self.work / ".git/devlyn-completion").exists())
|
|
1192
|
+
self.assertEqual(self.g("branch", "--list", "task/new"), "")
|
|
1193
|
+
|
|
1194
|
+
def test_disk_gate_uses_larger_floor_and_admits_boundary(self):
|
|
1195
|
+
# Prediction: 100 GiB uses a 10 GiB floor; 2 TiB uses the ceiling of 2%, and exactly-at-floor allocates.
|
|
1196
|
+
from unittest.mock import Mock, patch
|
|
1197
|
+
for name, total in (("small", 100 * 1024**3), ("large", 2 * 1024**4)):
|
|
1198
|
+
floor = max(10 * 1024**3, (total + 49) // 50)
|
|
1199
|
+
with self.subTest(name=name):
|
|
1200
|
+
target = self.root / name
|
|
1201
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "disk_usage", return_value=Mock(total=total, free=floor - 1)):
|
|
1202
|
+
with self.assertRaisesRegex(CompletionError, r"^BLOCKED:disk"):
|
|
1203
|
+
allocate(self.allocation_args(name, target))
|
|
1204
|
+
self.assertFalse(target.exists())
|
|
1205
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "disk_usage", return_value=Mock(total=total, free=floor)):
|
|
1206
|
+
result = allocate(self.allocation_args(name, target))
|
|
1207
|
+
self.assertEqual((result["status"], result["reconciled"]), ("ALLOCATED", []))
|
|
1208
|
+
self.assertTrue(Path(result["receipt"]).exists())
|
|
1209
|
+
self.assertEqual(Path(result["worktree"]), target)
|
|
1210
|
+
|
|
1211
|
+
def test_reconcile_precedes_refused_disk_gate(self):
|
|
1212
|
+
# Prediction: an earlier MERGED remote delivery is cleaned before the new allocation fails its disk gate.
|
|
1213
|
+
from unittest.mock import Mock, patch
|
|
1214
|
+
self.allocate(); self.accept()
|
|
1215
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1216
|
+
self.merge_pr()
|
|
1217
|
+
previous_receipt, previous_tree = self.receipt, self.task
|
|
1218
|
+
target = self.root / "next"
|
|
1219
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "disk_usage", return_value=Mock(total=100 * 1024**3, free=9 * 1024**3)):
|
|
1220
|
+
with self.assertRaisesRegex(CompletionError, r"^BLOCKED:disk"):
|
|
1221
|
+
allocate(self.allocation_args("next", target))
|
|
1222
|
+
self.assertEqual(json.loads(previous_receipt.read_text(encoding="utf-8"))["status"], "COMPLETE")
|
|
1223
|
+
self.assertFalse(previous_tree.exists())
|
|
1224
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1225
|
+
self.assertFalse(target.exists())
|
|
1226
|
+
self.assertFalse((self.work / ".git/devlyn-completion" / hashlib.sha256(b"task/next").hexdigest()[:24]).exists())
|
|
1227
|
+
self.assertEqual(self.g("branch", "--list", "task/next"), "")
|
|
1228
|
+
|
|
1121
1229
|
def allocate_legacy(self):
|
|
1122
1230
|
common = Path(self.g("rev-parse", "--absolute-git-dir"))
|
|
1123
1231
|
key = hashlib.sha256(b"task/fixture").hexdigest()[:24]
|
|
@@ -2621,6 +2729,118 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2621
2729
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2622
2730
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
2623
2731
|
|
|
2732
|
+
def renumber_receipt_devices(self):
|
|
2733
|
+
receipt = read_json(self.receipt)
|
|
2734
|
+
for key in ("worktree_identity", "scratch_identity"):
|
|
2735
|
+
for entry in receipt[key]:
|
|
2736
|
+
entry[0] += 10000
|
|
2737
|
+
atomic_json(self.receipt, receipt)
|
|
2738
|
+
return receipt
|
|
2739
|
+
|
|
2740
|
+
def test_device_renumbering_allows_completion(self):
|
|
2741
|
+
# Prediction: old device numbers do not invalidate unchanged inodes or require receipt migration.
|
|
2742
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2743
|
+
self.accept()
|
|
2744
|
+
receipt = self.renumber_receipt_devices()
|
|
2745
|
+
(scratch / "target").mkdir()
|
|
2746
|
+
(scratch / "target/object").write_bytes(b"rebuildable")
|
|
2747
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
2748
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2749
|
+
self.assertEqual(result["scratch_cleanup"], {"status": "CLEAN", "logical_bytes_removed": 11})
|
|
2750
|
+
self.assertFalse(self.task.exists())
|
|
2751
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
2752
|
+
saved = read_json(self.receipt)
|
|
2753
|
+
for key in ("worktree_identity", "scratch_identity"):
|
|
2754
|
+
self.assertEqual(saved[key], receipt[key])
|
|
2755
|
+
|
|
2756
|
+
def test_device_renumbering_allows_reconcile(self):
|
|
2757
|
+
# Prediction: allocation reconciles an earlier merged receipt after the same renumbering.
|
|
2758
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2759
|
+
self.accept()
|
|
2760
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
2761
|
+
self.merge_pr()
|
|
2762
|
+
self.renumber_receipt_devices()
|
|
2763
|
+
(scratch / "object").write_bytes(b"rebuildable")
|
|
2764
|
+
receipt, task = self.receipt, self.task
|
|
2765
|
+
result = self.allocate("next")
|
|
2766
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
2767
|
+
self.assertFalse(task.exists())
|
|
2768
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
2769
|
+
|
|
2770
|
+
def test_device_renumbering_rejects_replaced_directories(self):
|
|
2771
|
+
# Keep each original alive so the replacement cannot accidentally reuse its inode.
|
|
2772
|
+
from unittest.mock import patch
|
|
2773
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2774
|
+
receipt = self.renumber_receipt_devices()
|
|
2775
|
+
for directory in (self.task, Path(receipt["worktree_gitdir"]), scratch, self.receipt.parent):
|
|
2776
|
+
with self.subTest(directory=directory):
|
|
2777
|
+
original = self.root / "original"
|
|
2778
|
+
directory.rename(original)
|
|
2779
|
+
if directory == self.receipt.parent:
|
|
2780
|
+
directory.mkdir()
|
|
2781
|
+
(original / "scratch").rename(scratch)
|
|
2782
|
+
else:
|
|
2783
|
+
shutil.copytree(original, directory)
|
|
2784
|
+
try:
|
|
2785
|
+
self.assertNotEqual(directory.stat().st_ino, original.stat().st_ino)
|
|
2786
|
+
if directory == self.receipt.parent:
|
|
2787
|
+
self.assertEqual(scratch.stat().st_ino, receipt["scratch_identity"][0][1])
|
|
2788
|
+
with patch.dict(os.environ, self.env), self.assertRaisesRegex(CompletionError, "replaced"):
|
|
2789
|
+
if directory in (scratch, self.receipt.parent):
|
|
2790
|
+
clean_scratch(receipt, self.receipt, True)
|
|
2791
|
+
else:
|
|
2792
|
+
inspect_workspace(receipt)
|
|
2793
|
+
self.assertTrue(directory.is_dir())
|
|
2794
|
+
finally:
|
|
2795
|
+
if directory == self.receipt.parent:
|
|
2796
|
+
scratch.rename(original / "scratch")
|
|
2797
|
+
shutil.rmtree(directory)
|
|
2798
|
+
original.rename(directory)
|
|
2799
|
+
|
|
2800
|
+
def test_device_renumbering_rejects_changed_filesystem_relation(self):
|
|
2801
|
+
# Matching inodes alone must not accept a peer that moved to another filesystem.
|
|
2802
|
+
from unittest.mock import patch
|
|
2803
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2804
|
+
receipt = self.renumber_receipt_devices()
|
|
2805
|
+
original_stat = Path.stat
|
|
2806
|
+
for directory in (self.task, Path(receipt["worktree_gitdir"]), scratch, self.receipt.parent):
|
|
2807
|
+
def moved_stat(path, *args, **kwargs):
|
|
2808
|
+
info = original_stat(path, *args, **kwargs)
|
|
2809
|
+
if path == directory:
|
|
2810
|
+
fields = list(info)
|
|
2811
|
+
fields[2] += 1
|
|
2812
|
+
return os.stat_result(fields)
|
|
2813
|
+
return info
|
|
2814
|
+
with self.subTest(directory=directory), patch.dict(os.environ, self.env), patch.object(Path, "stat", moved_stat):
|
|
2815
|
+
with self.assertRaisesRegex(CompletionError, "replaced"):
|
|
2816
|
+
if directory in (scratch, self.receipt.parent):
|
|
2817
|
+
clean_scratch(receipt, self.receipt, True)
|
|
2818
|
+
else:
|
|
2819
|
+
inspect_workspace(receipt)
|
|
2820
|
+
# Preserve distinct-device relationships too: they must not collapse into one device.
|
|
2821
|
+
for key in ("worktree_identity", "scratch_identity"):
|
|
2822
|
+
receipt[key][0][0] += 1
|
|
2823
|
+
with self.subTest(identity=key), patch.dict(os.environ, self.env), self.assertRaisesRegex(CompletionError, "replaced"):
|
|
2824
|
+
if key == "scratch_identity":
|
|
2825
|
+
clean_scratch(receipt, self.receipt, True)
|
|
2826
|
+
else:
|
|
2827
|
+
inspect_workspace(receipt)
|
|
2828
|
+
receipt[key][0][0] -= 1
|
|
2829
|
+
|
|
2830
|
+
def test_device_renumbering_preserves_different_filesystems(self):
|
|
2831
|
+
self.assertTrue(same_workspace_identity([[10, 101], [20, 202]], [[-1, 101], [-2, 202]]))
|
|
2832
|
+
|
|
2833
|
+
def test_device_renumbering_during_scratch_cleanup_is_refused(self):
|
|
2834
|
+
# Reboot tolerance is only for persisted receipts, never a change during this operation.
|
|
2835
|
+
from unittest.mock import Mock, patch
|
|
2836
|
+
scratch = Path(self.allocate()["scratch"])
|
|
2837
|
+
receipt = self.renumber_receipt_devices()
|
|
2838
|
+
identity = workspace_identity(scratch, self.receipt.parent)
|
|
2839
|
+
changed = [[dev + 1, ino] for dev, ino in identity]
|
|
2840
|
+
with patch.dict(clean_scratch.__globals__, {"workspace_identity": Mock(side_effect=[identity, changed])}):
|
|
2841
|
+
with self.assertRaisesRegex(CompletionError, "changed before removal"):
|
|
2842
|
+
clean_scratch(receipt, self.receipt, True)
|
|
2843
|
+
|
|
2624
2844
|
def test_replaced_tree_and_unsafe_custody(self):
|
|
2625
2845
|
self.allocate(); self.accept()
|
|
2626
2846
|
elsewhere = self.root / "elsewhere"
|
|
@@ -60,6 +60,8 @@ python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
|
60
60
|
|
|
61
61
|
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
62
62
|
must name an absent path.
|
|
63
|
+
Remote allocation first cleans earlier merged deliveries, then allocation refuses with `BLOCKED:disk`
|
|
64
|
+
without creating a receipt, branch or worktree when that filesystem has less than max(10 GiB, 2%) free; free space and allocate again.
|
|
63
65
|
A publication allocation normally starts at the exact fetched remote base,
|
|
64
66
|
independent of the anchor's branch or dirty state, and leaves the anchor's
|
|
65
67
|
HEAD, index and files untouched. For a commit request, local-only work or
|
|
@@ -123,8 +125,9 @@ after merge, so a pending, failed or local-only task keeps it.
|
|
|
123
125
|
|
|
124
126
|
Allocation also returns a receipt-owned `scratch` directory for other
|
|
125
127
|
disposable build files, such as writable tool caches. Keep source checkouts,
|
|
126
|
-
Git data, lockfiles, reports and restart evidence outside scratch.
|
|
127
|
-
temporary build trees
|
|
128
|
+
Git data, lockfiles, reports and restart evidence outside scratch.
|
|
129
|
+
Direct work keeps its temporary files (logs, downloads, review outputs and build trees) outside the checkout: use scratch when allocated, otherwise the system temporary directory, and never create new top-level `.devlyn/<name>` folders for them.
|
|
130
|
+
Do not preserve temporary build trees by copying them into evidence custody.
|
|
128
131
|
Prefer `TemporaryDirectory`/`finally` for short-lived test fixtures.
|
|
129
132
|
|
|
130
133
|
After waiting for task writers, `complete --writers-stopped` empties this
|
|
@@ -678,6 +678,14 @@ class WarmTests(unittest.TestCase):
|
|
|
678
678
|
temporary = tempfile.TemporaryDirectory(prefix="warm-build-dirs-")
|
|
679
679
|
self.addCleanup(temporary.cleanup)
|
|
680
680
|
self.root = Path(temporary.name).resolve()
|
|
681
|
+
(self.root / "sitecustomize.py").write_text(
|
|
682
|
+
"import importlib.machinery, importlib.util, pathlib, sys\n"
|
|
683
|
+
"spec = importlib.machinery.PathFinder.find_spec('sitecustomize', [p for p in sys.path if pathlib.Path(p or '.').resolve() != pathlib.Path(__file__).resolve().parent])\n"
|
|
684
|
+
"if spec is not None and spec.loader is not None:\n"
|
|
685
|
+
" spec.loader.exec_module(importlib.util.module_from_spec(spec))\n"
|
|
686
|
+
"import shutil\nfrom types import SimpleNamespace\n"
|
|
687
|
+
"shutil.disk_usage = lambda path: SimpleNamespace(total=100 * 1024**3, free=100 * 1024**3)\n",
|
|
688
|
+
encoding="utf-8")
|
|
681
689
|
self.anchor, self.work = self.root / "anchor", self.root / "work"
|
|
682
690
|
self.staging = self.root / "scratch"
|
|
683
691
|
self.staging.mkdir()
|
|
@@ -689,6 +697,8 @@ class WarmTests(unittest.TestCase):
|
|
|
689
697
|
self.environment = mock.patch.dict(os.environ, {
|
|
690
698
|
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "FAKE_CARGO": str(self.fixture),
|
|
691
699
|
"FAKE_CARGO_LOG": str(self.log), "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1",
|
|
700
|
+
"PYTHONPATH": str(self.root) + os.pathsep + os.environ.get("PYTHONPATH", ""),
|
|
701
|
+
"PYTHONDONTWRITEBYTECODE": "1",
|
|
692
702
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
|
|
693
703
|
self.environment.start()
|
|
694
704
|
self.addCleanup(self.environment.stop)
|
|
@@ -12,7 +12,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
|
|
|
12
12
|
Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
|
|
13
13
|
|
|
14
14
|
- `status` takes `queue.lock` in the repository's Git directory and finishes `add`'s cleanup (step 1); it reports the running `skill` root and version, any `warnings`, reconciled counts (`pending`, `active`, `accepted`, `failed`, `blocked`, `legacy_pending`), the `next` task, `blockers`, deliveries still pending with their resume (drain again until the terminal commit is attached, then the printed completion command; add `--writers-stopped` only after stopping the task's writers), and under `cleanup` the package copies `add` kept or could not remove.
|
|
15
|
-
- `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (unreadable or ambiguous authoritative queue, invalid invocation, preflight refusal
|
|
15
|
+
- `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (unreadable or ambiguous authoritative queue, invalid invocation, preflight refusal, held drain lock or `BLOCKED:disk` allocation refusal that stops new starts and leaves its task pending). Progress lines `devlyn-loop: <task>: <event>` go to stderr.
|
|
16
16
|
- `--local-only` (alias `--no-push`) keeps the loop local. A manifest `delivery: local-only` and any earlier local receipt of the loop keep that restriction for later drains. A local loop needs no remote; nothing is fetched or pushed. An `auto`/`pr` loop none of whose tasks has a pushed PR runs as a local loop from its capture and the branch it was added on. Once one has, that PR is never rewritten to local and the loop is never run locally: its tasks wait, naming the PR, while other work continues.
|
|
17
17
|
- Worktrees default to `<repo parent>/<repo name>.devlyn.noindex/<loop-id>/<task-id>`; tasks already allocated under an earlier default resume where their receipts record them. On Windows, a default root newly created by the drain gets the not-content-indexed attribute, inherited by new children (content only; names stay indexed). Microsoft Defender scanning is unaffected; use a Dev Drive via `--worktree-root` or an administrator's Defender exclusion for the root.
|
|
18
18
|
|
|
@@ -1542,11 +1542,18 @@ def drain(args):
|
|
|
1542
1542
|
except (LoopError, OSError) as exc:
|
|
1543
1543
|
phase = last["states"][row["identity"]].get("phase", "allocation")
|
|
1544
1544
|
wait = f"{phase} blocked: {exc}"
|
|
1545
|
-
|
|
1545
|
+
disk_blocked = phase == "allocation" and str(exc).startswith("task-complete allocate: BLOCKED:disk")
|
|
1546
|
+
if disk_blocked:
|
|
1547
|
+
status, reason = "BLOCKED", str(exc)
|
|
1548
|
+
if not disk_blocked and phase in {"allocation", "inputs", "acceptance", "custody binding", "terminal transition"}:
|
|
1546
1549
|
wait += f"; inspect and repair {row['identity']}'s {phase} state, then drain again"
|
|
1547
1550
|
if wait:
|
|
1548
1551
|
refused[row["identity"]] = wait
|
|
1549
|
-
|
|
1552
|
+
if status == "BLOCKED":
|
|
1553
|
+
break
|
|
1554
|
+
if status == "BLOCKED" and reason and reason.startswith("task-complete allocate: BLOCKED:disk"):
|
|
1555
|
+
last = view(anchor, common, args.local_only)
|
|
1556
|
+
if status != "BLOCKED" and (any(state["kind"] in {"pending", "active"} for state in last["states"].values()) or counts(last)["legacy_pending"] or refused):
|
|
1550
1557
|
status = "WAITING"
|
|
1551
1558
|
except (LoopError, OSError) as exc:
|
|
1552
1559
|
status, reason = "BLOCKED", str(exc)
|
|
@@ -1799,9 +1806,19 @@ class QueueTests(unittest.TestCase):
|
|
|
1799
1806
|
self.temp = tempfile.TemporaryDirectory(prefix="devlyn-queue ✓ ")
|
|
1800
1807
|
self.addCleanup(self.temp.cleanup)
|
|
1801
1808
|
self.root = Path(self.temp.name).resolve()
|
|
1809
|
+
(self.root / "sitecustomize.py").write_text(
|
|
1810
|
+
"import importlib.machinery, importlib.util, pathlib, sys\n"
|
|
1811
|
+
"spec = importlib.machinery.PathFinder.find_spec('sitecustomize', [p for p in sys.path if pathlib.Path(p or '.').resolve() != pathlib.Path(__file__).resolve().parent])\n"
|
|
1812
|
+
"if spec is not None and spec.loader is not None:\n"
|
|
1813
|
+
" spec.loader.exec_module(importlib.util.module_from_spec(spec))\n"
|
|
1814
|
+
"import shutil\nfrom types import SimpleNamespace\n"
|
|
1815
|
+
"shutil.disk_usage = lambda path: SimpleNamespace(total=100 * 1024**3, free=100 * 1024**3)\n",
|
|
1816
|
+
encoding="utf-8")
|
|
1802
1817
|
self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
|
|
1803
1818
|
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
1804
1819
|
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"}
|
|
1820
|
+
self.env["PYTHONPATH"] = str(self.root) + os.pathsep + self.env.get("PYTHONPATH", "")
|
|
1821
|
+
self.env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
1805
1822
|
self.anchor = self.root / "repo"
|
|
1806
1823
|
subprocess.run(["git", "init", "-q", "--initial-branch=main", str(self.anchor)], check=True, env=self.env)
|
|
1807
1824
|
(self.anchor / ".gitignore").write_text(".devlyn/\n", encoding="utf-8")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.6",
|
|
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.6",
|
|
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": "6eb4d078cc4fe235517b7a08e8d6965462cd6c91ab1853cc2f8a9f0b829f6191",
|
|
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": "c0aa4b8a25ec6873011859bc5c76cad58d1cd468e13018ea85761b8db51b81e9",
|
|
168
168
|
"executable": false
|
|
169
169
|
},
|
|
170
170
|
{
|
|
@@ -244,17 +244,17 @@
|
|
|
244
244
|
},
|
|
245
245
|
{
|
|
246
246
|
"path": "config/skills/_shared/task-complete.py",
|
|
247
|
-
"sha256": "
|
|
247
|
+
"sha256": "06419e0133379880dc2e55523b8abd787b1b32023c02b8c6bb6a63a2fd8dd8c1",
|
|
248
248
|
"executable": false
|
|
249
249
|
},
|
|
250
250
|
{
|
|
251
251
|
"path": "config/skills/_shared/task-completion.md",
|
|
252
|
-
"sha256": "
|
|
252
|
+
"sha256": "cb1601c0054fc5ad47fc8af3da27384af27af320ec1e4254fb9b08744d76d58c",
|
|
253
253
|
"executable": false
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
"path": "config/skills/_shared/warm-build-dirs.py",
|
|
257
|
-
"sha256": "
|
|
257
|
+
"sha256": "ee9ccbc94b614ff5ae04142bca415a4d7d6a4f569066b494ffcf3d9c612051d7",
|
|
258
258
|
"executable": false
|
|
259
259
|
},
|
|
260
260
|
{
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
},
|
|
275
275
|
{
|
|
276
276
|
"path": "config/skills/devlyn-ideate/references/loop.md",
|
|
277
|
-
"sha256": "
|
|
277
|
+
"sha256": "1ecd5e8e53086c4b702fb1023904232aefc50405f86f792c8286255183b674cc",
|
|
278
278
|
"executable": false
|
|
279
279
|
},
|
|
280
280
|
{
|
|
@@ -294,7 +294,7 @@
|
|
|
294
294
|
},
|
|
295
295
|
{
|
|
296
296
|
"path": "config/skills/devlyn-ideate/scripts/queue.py",
|
|
297
|
-
"sha256": "
|
|
297
|
+
"sha256": "a170b4f23f02707a05356f31b184cc15978cbc75faf332b4d3e0eeee3073503d",
|
|
298
298
|
"executable": false
|
|
299
299
|
},
|
|
300
300
|
{
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
},
|
|
310
310
|
{
|
|
311
311
|
"path": "native/claude/.claude-plugin/plugin.json",
|
|
312
|
-
"sha256": "
|
|
312
|
+
"sha256": "4ca0474d48fdbee91769be02bd6a0b612313a390b0b0fe244679df0a43bc2e37",
|
|
313
313
|
"executable": false
|
|
314
314
|
},
|
|
315
315
|
{
|
|
@@ -384,17 +384,17 @@
|
|
|
384
384
|
},
|
|
385
385
|
{
|
|
386
386
|
"path": "native/claude/skills/_shared/task-complete.py",
|
|
387
|
-
"sha256": "
|
|
387
|
+
"sha256": "06419e0133379880dc2e55523b8abd787b1b32023c02b8c6bb6a63a2fd8dd8c1",
|
|
388
388
|
"executable": false
|
|
389
389
|
},
|
|
390
390
|
{
|
|
391
391
|
"path": "native/claude/skills/_shared/task-completion.md",
|
|
392
|
-
"sha256": "
|
|
392
|
+
"sha256": "cb1601c0054fc5ad47fc8af3da27384af27af320ec1e4254fb9b08744d76d58c",
|
|
393
393
|
"executable": false
|
|
394
394
|
},
|
|
395
395
|
{
|
|
396
396
|
"path": "native/claude/skills/_shared/warm-build-dirs.py",
|
|
397
|
-
"sha256": "
|
|
397
|
+
"sha256": "ee9ccbc94b614ff5ae04142bca415a4d7d6a4f569066b494ffcf3d9c612051d7",
|
|
398
398
|
"executable": false
|
|
399
399
|
},
|
|
400
400
|
{
|
|
@@ -414,7 +414,7 @@
|
|
|
414
414
|
},
|
|
415
415
|
{
|
|
416
416
|
"path": "native/claude/skills/devlyn-ideate/references/loop.md",
|
|
417
|
-
"sha256": "
|
|
417
|
+
"sha256": "1ecd5e8e53086c4b702fb1023904232aefc50405f86f792c8286255183b674cc",
|
|
418
418
|
"executable": false
|
|
419
419
|
},
|
|
420
420
|
{
|
|
@@ -434,17 +434,17 @@
|
|
|
434
434
|
},
|
|
435
435
|
{
|
|
436
436
|
"path": "native/claude/skills/devlyn-ideate/scripts/queue.py",
|
|
437
|
-
"sha256": "
|
|
437
|
+
"sha256": "a170b4f23f02707a05356f31b184cc15978cbc75faf332b4d3e0eeee3073503d",
|
|
438
438
|
"executable": false
|
|
439
439
|
},
|
|
440
440
|
{
|
|
441
441
|
"path": "native/codex/.agents/plugins/marketplace.json",
|
|
442
|
-
"sha256": "
|
|
442
|
+
"sha256": "f8189a7e93f4e5ff3334d8ad884fffa679a2729f10fff815d24c4f0067534c73",
|
|
443
443
|
"executable": false
|
|
444
444
|
},
|
|
445
445
|
{
|
|
446
446
|
"path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
|
|
447
|
-
"sha256": "
|
|
447
|
+
"sha256": "4ca0474d48fdbee91769be02bd6a0b612313a390b0b0fe244679df0a43bc2e37",
|
|
448
448
|
"executable": false
|
|
449
449
|
},
|
|
450
450
|
{
|
|
@@ -519,17 +519,17 @@
|
|
|
519
519
|
},
|
|
520
520
|
{
|
|
521
521
|
"path": "native/codex/plugins/devlyn/skills/_shared/task-complete.py",
|
|
522
|
-
"sha256": "
|
|
522
|
+
"sha256": "06419e0133379880dc2e55523b8abd787b1b32023c02b8c6bb6a63a2fd8dd8c1",
|
|
523
523
|
"executable": false
|
|
524
524
|
},
|
|
525
525
|
{
|
|
526
526
|
"path": "native/codex/plugins/devlyn/skills/_shared/task-completion.md",
|
|
527
|
-
"sha256": "
|
|
527
|
+
"sha256": "cb1601c0054fc5ad47fc8af3da27384af27af320ec1e4254fb9b08744d76d58c",
|
|
528
528
|
"executable": false
|
|
529
529
|
},
|
|
530
530
|
{
|
|
531
531
|
"path": "native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py",
|
|
532
|
-
"sha256": "
|
|
532
|
+
"sha256": "ee9ccbc94b614ff5ae04142bca415a4d7d6a4f569066b494ffcf3d9c612051d7",
|
|
533
533
|
"executable": false
|
|
534
534
|
},
|
|
535
535
|
{
|
|
@@ -549,7 +549,7 @@
|
|
|
549
549
|
},
|
|
550
550
|
{
|
|
551
551
|
"path": "native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md",
|
|
552
|
-
"sha256": "
|
|
552
|
+
"sha256": "1ecd5e8e53086c4b702fb1023904232aefc50405f86f792c8286255183b674cc",
|
|
553
553
|
"executable": false
|
|
554
554
|
},
|
|
555
555
|
{
|
|
@@ -569,7 +569,7 @@
|
|
|
569
569
|
},
|
|
570
570
|
{
|
|
571
571
|
"path": "native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py",
|
|
572
|
-
"sha256": "
|
|
572
|
+
"sha256": "a170b4f23f02707a05356f31b184cc15978cbc75faf332b4d3e0eeee3073503d",
|
|
573
573
|
"executable": false
|
|
574
574
|
},
|
|
575
575
|
{
|
|
@@ -1619,12 +1619,12 @@
|
|
|
1619
1619
|
},
|
|
1620
1620
|
{
|
|
1621
1621
|
"path": "package.json",
|
|
1622
|
-
"sha256": "
|
|
1622
|
+
"sha256": "4d754dc7026976c1e37d7d74b6733459fcd07b6a498bc3e8b4c4d575b02a7700",
|
|
1623
1623
|
"executable": false
|
|
1624
1624
|
}
|
|
1625
1625
|
],
|
|
1626
1626
|
"digest": {
|
|
1627
1627
|
"algorithm": "sha256",
|
|
1628
|
-
"value": "
|
|
1628
|
+
"value": "f4ee05e55111300285e2ec7b435934786fee40501365c8a2a2706b7ee168bc20"
|
|
1629
1629
|
}
|
|
1630
1630
|
}
|