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
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
|
|
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
|
-
|
|
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 }) =>
|
|
1869
|
-
|
|
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
|
|
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);
|
package/bin/skill-history.json
CHANGED
|
@@ -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":
|
|
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")
|