devlyn-cli 4.3.3 → 4.3.5

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.
Files changed (29) hide show
  1. package/bin/cleanup-history.json +76 -1
  2. package/bin/devlyn.js +172 -48
  3. package/bin/skill-history.json +134 -2
  4. package/config/skills/_shared/harness-store.py +6 -1
  5. package/config/skills/_shared/platform-support.py +12 -0
  6. package/config/skills/_shared/task-complete.py +227 -7
  7. package/config/skills/_shared/task-completion.md +5 -2
  8. package/config/skills/_shared/warm-build-dirs.py +10 -0
  9. package/config/skills/devlyn-ideate/references/loop.md +1 -1
  10. package/config/skills/devlyn-ideate/scripts/queue.py +19 -2
  11. package/native/claude/.claude-plugin/plugin.json +1 -1
  12. package/native/claude/skills/_shared/harness-store.py +6 -1
  13. package/native/claude/skills/_shared/platform-support.py +12 -0
  14. package/native/claude/skills/_shared/task-complete.py +227 -7
  15. package/native/claude/skills/_shared/task-completion.md +5 -2
  16. package/native/claude/skills/_shared/warm-build-dirs.py +10 -0
  17. package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
  18. package/native/claude/skills/devlyn-ideate/scripts/queue.py +19 -2
  19. package/native/codex/.agents/plugins/marketplace.json +1 -1
  20. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  21. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +6 -1
  22. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +12 -0
  23. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +227 -7
  24. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +5 -2
  25. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +10 -0
  26. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
  27. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +19 -2
  28. package/package.json +1 -1
  29. package/release.json +30 -30
@@ -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": [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)}
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 [[p.stat().st_dev, p.stat().st_ino] for p in (work, gitdir)]
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"])) == receipt["worktree_identity"], "worktree/registration was replaced; receipt no longer owns it")
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 = receipt["scratch_identity"]
585
- require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
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. Do not create unowned
127
- temporary build trees or preserve them by copying them into evidence custody.
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 or held drain lock). Progress lines `devlyn-loop: <task>: <event>` go to stderr.
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
- if phase in {"allocation", "inputs", "acceptance", "custody binding", "terminal transition"}:
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
- if any(state["kind"] in {"pending", "active"} for state in last["states"].values()) or counts(last)["legacy_pending"] or refused:
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",
3
+ "version": "4.3.5",
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": {