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 CHANGED
@@ -1564,8 +1564,8 @@ function cleanupCurrentFingerprints() {
1564
1564
  return Object.fromEntries([...CLEANUP_CORE, '_shared'].map((name) => [name, fingerprint(path.join(CONFIG_SOURCE, 'skills', name))]));
1565
1565
  }
1566
1566
 
1567
- function cleanupGit(root, args) {
1568
- return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8' });
1567
+ function cleanupGit(root, args, options = {}) {
1568
+ return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8', ...options });
1569
1569
  }
1570
1570
 
1571
1571
  let cleanupPythonBinary;
@@ -1834,43 +1834,114 @@ function cleanupDeliver(scope, items, current) {
1834
1834
  '--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
1835
1835
  '--remote', 'origin', '--base', scope.remote.base], scope.root).stdout);
1836
1836
  if (allocated?.status !== 'ALLOCATED') return { error: cleanupReason('delivery-failed', { detail: allocated?.reason || 'allocation failed' }) };
1837
- const release = (error) => {
1837
+ const release = (error = null) => {
1838
1838
  for (const args of [['worktree', 'remove', '--force', worktree], ['branch', '-D', branch]]) {
1839
1839
  const result = cleanupGit(scope.root, args);
1840
- if (result.error || result.status !== 0) return { error: cleanupReason(error.code, { ...error.params,
1840
+ if (result.error || result.status !== 0) return { error: cleanupReason(error?.code || 'delivery-failed', { ...error?.params,
1841
1841
  releaseError: result.error?.message || result.stderr.trim(), worktree, receipt: allocated.receipt }) };
1842
1842
  }
1843
1843
  fs.rmSync(path.dirname(allocated.receipt), { recursive: true, force: true });
1844
- return { error };
1844
+ return error ? { error } : {};
1845
1845
  };
1846
1846
  let prepared;
1847
- try { prepared = cleanupDeliveryCommit(worktree, items, current, release); }
1847
+ try { prepared = cleanupDeliveryCommit(worktree, items, current, release, cleanupRemovedUpstream(scope.root, worktree)); }
1848
1848
  catch (error) { return release(cleanupReason('apply-failed', { detail: error.message })); }
1849
1849
  if (prepared.error) return prepared;
1850
+ // Every reviewed copy is already gone from the remote base: the checkout only has to take it.
1851
+ if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
1850
1852
  const completed = lastJson(run(['complete', '--receipt', allocated.receipt, '--acceptance', prepared.acceptance, '--writers-stopped'], scope.root).stdout);
1851
1853
  const status = completed?.delivery_status || completed?.status;
1852
- if (status === 'COMPLETE' || status === 'MERGED') return { delivery: { state: 'merged', url: completed.pr || null } };
1854
+ if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
1853
1855
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
1854
1856
  const reason = completed?.reason || 'delivery did not complete';
1855
1857
  const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
1856
1858
  return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
1857
1859
  }
1858
1860
 
1861
+ // Whether the remote base (the allocated worktree's HEAD) removed a copy this checkout tracks: the
1862
+ // base holds no path of that name in any spelling, and the checkout holds the copy, under its
1863
+ // tracked spelling, as it was where the two histories meet. A copy committed only here, or changed
1864
+ // here since, is not one the base removed.
1865
+ function cleanupRemovedUpstream(root, worktree) {
1866
+ const read = (cwd, args, options) => {
1867
+ const result = cleanupGit(cwd, args, options);
1868
+ return !result.error && result.status === 0 ? result.stdout : null;
1869
+ };
1870
+ const object = (spec) => read(root, ['rev-parse', '--verify', '--quiet', spec])?.trim() || null;
1871
+ const base = read(worktree, ['rev-parse', 'HEAD'])?.trim();
1872
+ const fork = base ? read(root, ['merge-base', 'HEAD', base])?.trim() : null;
1873
+ let upstream; // the base's paths, folded to lower case, read once and only when needed
1874
+ return (relative) => {
1875
+ if (!fork) return false;
1876
+ if (upstream === undefined) {
1877
+ upstream = read(root, ['ls-tree', '-r', '-z', '--name-only', base], { maxBuffer: 1 << 30 })
1878
+ ?.split('\0').filter(Boolean).map((name) => name.toLowerCase()) ?? null;
1879
+ }
1880
+ const lower = relative.toLowerCase();
1881
+ if (!upstream || upstream.some((name) => name === lower || name.startsWith(`${lower}/`))) return false;
1882
+ // The tracked spelling, which a case-insensitive file system lets differ from the one on disk.
1883
+ const tracked = read(root, ['ls-files', '-z', '--', `:(icase,literal)${relative}`])?.split('\0').find(Boolean);
1884
+ const spelled = tracked?.slice(0, relative.length);
1885
+ if (!spelled || spelled.toLowerCase() !== lower) return false;
1886
+ const before = object(`${fork}:${spelled}`);
1887
+ return before !== null && before === object(`HEAD:${spelled}`);
1888
+ };
1889
+ }
1890
+
1891
+ // The copies the remote base dropped stay in this checkout until it takes that base: a checkout on
1892
+ // the base branch is fast-forwarded to it, as a task's owner reconciles after delivery. The base is
1893
+ // fetched into a ref this call owns (FETCH_HEAD can be rewritten by another fetch meanwhile) and
1894
+ // merged by its commit, only while the checkout is still the one checked before the fetch, and the
1895
+ // result must be that commit. Another branch, or a fast-forward Git refuses (local changes or ignored
1896
+ // files in the way, local commits), is left as it is.
1897
+ function cleanupUpdateCheckout(scope) {
1898
+ const failed = (detail) => ({ error: cleanupReason('update-checkout', { detail }) });
1899
+ const read = (args) => {
1900
+ const result = cleanupGit(scope.root, args);
1901
+ return !result.error && result.status === 0 ? result.stdout.trim() : null;
1902
+ };
1903
+ const checkout = () => [read(['symbolic-ref', '--quiet', '--short', 'HEAD']), read(['rev-parse', 'HEAD'])];
1904
+ const [branch, head] = checkout();
1905
+ if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
1906
+ const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
1907
+ try {
1908
+ const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`]);
1909
+ if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
1910
+ const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
1911
+ const [now, at] = checkout();
1912
+ if (!target || now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
1913
+ // The branch's own merge options (`--squash`, `-s ours`) would turn this into something else.
1914
+ const merged = cleanupGit(scope.root, ['-c', `branch.${branch}.mergeOptions=`,
1915
+ 'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
1916
+ if (merged.error || merged.status !== 0) return failed(merged.error?.message || merged.stderr.trim());
1917
+ const [after, landed] = checkout();
1918
+ return after === branch && landed === target ? {} : failed('the fast-forward did not land on the base');
1919
+ } finally {
1920
+ cleanupGit(scope.root, ['update-ref', '-d', ref]);
1921
+ }
1922
+ }
1923
+
1859
1924
  // Inside the allocated worktree: prove every reviewed item, remove them, commit, and write the
1860
1925
  // acceptance completion binds. A failure releases the worktree; so does a throw, in the caller.
1861
- function cleanupDeliveryCommit(worktree, items, current, release) {
1926
+ // With every reviewed copy already removed by the remote base, the worktree is released with
1927
+ // nothing to deliver.
1928
+ function cleanupDeliveryCommit(worktree, items, current, release, removedUpstream) {
1862
1929
  const fresh = cleanupProjectScope(worktree, current);
1863
1930
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
1864
1931
  const pairs = items.map((reviewed) => {
1865
1932
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
1866
1933
  return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path) };
1867
1934
  });
1868
- const stale = pairs.filter(({ reviewed, fresh, before }) => !fresh || fresh.action !== reviewed.action || fresh.route !== 'delivery'
1869
- || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256);
1935
+ const stale = pairs.filter(({ reviewed, fresh, before }) => (fresh
1936
+ ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
1937
+ || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
1938
+ : !removedUpstream(reviewed.relative)));
1870
1939
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
1871
1940
  if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
1941
+ const present = pairs.filter(({ fresh }) => fresh);
1942
+ if (!present.length) return release();
1872
1943
  const applied = [];
1873
- for (const { reviewed, fresh } of pairs) {
1944
+ for (const { reviewed, fresh } of present) {
1874
1945
  const error = cleanupApplyItem({ ...fresh, before: reviewed.before });
1875
1946
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
1876
1947
  applied.push(fresh.relative);
@@ -53,6 +53,7 @@
53
53
  "430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
54
54
  "450d578ef177a9e8f0b693940ad64b41c1d9719975ba6305f62a7c82ac3a892f",
55
55
  "4685be6a94ad5587ce53785c14a60cccf9a852d9d23391dba0a5bcd0c64931cf",
56
+ "484db9dd2635de5f5acbcf692e2f329f33c0a483951e27bcd77bcbafbc052e19",
56
57
  "4ecb004b24a8adfe41c7416c3fa24e62cf76baa3b276ae1ce5091caef77e57b8",
57
58
  "4fed91aed4dc1cd28a9e07229dbe9b7f6d8127e5f763dde8f76bb1b6f6855cce",
58
59
  "514bfaf5667af3c9d4d36f391e6567a5eb759c6dfb06a5c3ea23cf29692844ef",
@@ -82,6 +83,7 @@
82
83
  "6d1a5458b23196b6f6fde6b7e9dc097ed173cb5914de439e512a032ca1892812",
83
84
  "6e70044cab5a3fb1f78b7148c2111e4298ce8f5f8e902220430ca316bb61255d",
84
85
  "6fa2b5e089dcd00613c6e9800d2e763da9e5b2c80e6a7ef5d54d5ad7113e8399",
86
+ "713921f9a442770fc4de24b990dee6a4a5358a0d69bc26af80c33dc6f15a7274",
85
87
  "71fb125e20420d6f3fcd3396093fcdc8d552bab9626c3cbd962351e0d19adac8",
86
88
  "725395f70eec7e2ceae1f3aa122893433923b098a1e0d6202dd410979ef4eea0",
87
89
  "72baab9daecfdbcafb2f9484a16c2e89fc6ffc5fc693f1a2627042f1e8197f06",
@@ -338,6 +340,7 @@
338
340
  "65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
339
341
  "65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
340
342
  "694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
343
+ "6b403afbf5a117cc08603d3648b9dded4f9ac4ca02fc1d8e1f0035615148bb5c",
341
344
  "6c3e2e4b84b0b4f81aed3759704da7c14f3cbabf3beeb0d1654feda3a15a8675",
342
345
  "6d9df58c117165e9d297aa6be0df8d076bac1ed49dc29ae93906af1969798ab1",
343
346
  "6e7228610628fc4bca31dc0b0aaf5eb4445e1f123f2c54bd13921c5cd01129fe",
@@ -762,6 +765,8 @@
762
765
  "4.3.1",
763
766
  "4.3.2",
764
767
  "4.3.3",
765
- "4.3.4"
768
+ "4.3.4",
769
+ "4.3.5",
770
+ "4.3.6"
766
771
  ]
767
772
  }
@@ -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")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.4",
3
+ "version": "4.3.6",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }