devlyn-cli 4.3.10 → 4.3.12
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/README.md +1 -1
- package/bin/devlyn.js +1 -1
- package/bin/skill-history.json +4 -0
- package/config/skills/_shared/platform-support.py +9 -0
- package/config/skills/_shared/task-complete.py +240 -12
- package/config/skills/_shared/task-completion.md +4 -1
- package/config/skills/devlyn-ideate/references/loop.md +1 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +13 -33
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/platform-support.py +9 -0
- package/native/claude/skills/_shared/task-complete.py +240 -12
- package/native/claude/skills/_shared/task-completion.md +4 -1
- package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +13 -33
- 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/platform-support.py +9 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +240 -12
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +4 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +13 -33
- package/package.json +1 -1
- package/release.json +24 -24
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import contextlib
|
|
11
|
+
import errno
|
|
11
12
|
import functools
|
|
12
13
|
import hashlib
|
|
13
14
|
import json
|
|
@@ -160,6 +161,93 @@ def registrations(receipt):
|
|
|
160
161
|
return rows
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
def worktree_roots(receipt):
|
|
165
|
+
anchor = Path(receipt["anchor"])
|
|
166
|
+
return (anchor.parent / ".devlyn-worktrees" / anchor.name,
|
|
167
|
+
anchor.parent / (anchor.name + ".devlyn.noindex"),
|
|
168
|
+
anchor.parent / (anchor.name + ".devlyn"))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_for_pruning(path):
|
|
172
|
+
try:
|
|
173
|
+
return path.resolve()
|
|
174
|
+
except RuntimeError as exc: # Python 3.12 raises RuntimeError for symlink loops.
|
|
175
|
+
raise CompletionError(f"parent cleanup cannot resolve {path}: {exc}") from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def prune_empty_parents(receipt, parent, root, *, include_root=False):
|
|
179
|
+
"""Remove only empty, unredirected directories within an explicit boundary."""
|
|
180
|
+
require(parent.is_relative_to(root), f"parent cleanup escapes root: {parent}")
|
|
181
|
+
keep_root = Path(receipt["keep_root"]) if receipt.get("keep_root") else None
|
|
182
|
+
registered = registrations(receipt)
|
|
183
|
+
while parent.is_relative_to(root) and (include_root or parent != root):
|
|
184
|
+
if keep_root is not None and keep_root.is_relative_to(parent):
|
|
185
|
+
break
|
|
186
|
+
require(parent == resolve_for_pruning(parent), f"parent cleanup traverses a symlink or junction: {parent}")
|
|
187
|
+
# resolve also checks ancestors; lstat rejects reparse points even when they resolve in place.
|
|
188
|
+
try:
|
|
189
|
+
info = parent.lstat()
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
info = None
|
|
192
|
+
require(info is None or not (stat.S_ISLNK(info.st_mode) or
|
|
193
|
+
getattr(info, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)),
|
|
194
|
+
f"parent cleanup traverses a symlink or junction: {parent}")
|
|
195
|
+
if any(Path(path).is_relative_to(parent) for path in registered):
|
|
196
|
+
return f"registered worktree retains parent: {parent}"
|
|
197
|
+
try:
|
|
198
|
+
parent.rmdir()
|
|
199
|
+
except FileNotFoundError:
|
|
200
|
+
pass # Already removed by another cleanup.
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
if exc.errno in {errno.ENOTEMPTY, errno.EEXIST}:
|
|
203
|
+
break
|
|
204
|
+
raise
|
|
205
|
+
if parent == root:
|
|
206
|
+
break
|
|
207
|
+
parent = parent.parent
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def prune_named_parents(receipt):
|
|
211
|
+
work = Path(receipt["worktree"])
|
|
212
|
+
for root in worktree_roots(receipt):
|
|
213
|
+
if work != root and work.is_relative_to(root):
|
|
214
|
+
require(work == resolve_for_pruning(work) and not os.path.lexists(work),
|
|
215
|
+
f"parent cleanup requires an absent, unredirected worktree: {work}")
|
|
216
|
+
require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
|
|
217
|
+
prune_empty_parents(receipt, work.parent, root, include_root=True)
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def emit_warning(warnings, message):
|
|
222
|
+
print(message, file=sys.stderr, flush=True)
|
|
223
|
+
warnings.append(message)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def create_worktree_directory(receipt, target, directory, warnings):
|
|
227
|
+
container = worktree_roots(receipt)[0].parent
|
|
228
|
+
for attempt in range(3):
|
|
229
|
+
try:
|
|
230
|
+
if target.is_relative_to(container) and target != container:
|
|
231
|
+
try:
|
|
232
|
+
container.mkdir()
|
|
233
|
+
except FileExistsError:
|
|
234
|
+
require(container.is_dir(), f"worktree container is not a directory: {container}")
|
|
235
|
+
else:
|
|
236
|
+
try:
|
|
237
|
+
shared("platform-support")["mark_hidden"](container)
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
emit_warning(warnings, f"visibility unchanged: {container}: could not set FILE_ATTRIBUTE_HIDDEN: {exc}; continuing")
|
|
240
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
try:
|
|
242
|
+
target.mkdir()
|
|
243
|
+
except FileExistsError as exc:
|
|
244
|
+
raise CompletionError(f"worktree directory {target} already exists and is not devlyn's; leave it; choose another --worktree path, delete receipt directory {directory}, then allocate again") from exc
|
|
245
|
+
return
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
if attempt == 2:
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
|
|
163
251
|
def remote_url(receipt):
|
|
164
252
|
values = [gref(receipt, "remote", "get-url", "--all", receipt["remote"]),
|
|
165
253
|
gref(receipt, "remote", "get-url", "--push", "--all", receipt["remote"])]
|
|
@@ -217,8 +305,9 @@ def ignore_task_records(work):
|
|
|
217
305
|
f"{directory} records are not ignored or .devlyn is dirty; repair the starting commit's .devlyn paths and ignore rules before allocating")
|
|
218
306
|
|
|
219
307
|
|
|
220
|
-
def allocate(args, *, exclude_receipts=()):
|
|
308
|
+
def allocate(args, *, exclude_receipts=(), warnings=None):
|
|
221
309
|
"""Reconcile older tasks, except receipts the queue has identified as invalid."""
|
|
310
|
+
warnings = [] if warnings is None else warnings
|
|
222
311
|
work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
|
|
223
312
|
common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
|
224
313
|
require(args.task.strip(), "task identity is required")
|
|
@@ -242,10 +331,14 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
242
331
|
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
243
332
|
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")
|
|
244
333
|
receipt["worktree"] = str(target)
|
|
334
|
+
if getattr(args, "keep_root", None) is not None:
|
|
335
|
+
keep_root = Path(args.keep_root).resolve()
|
|
336
|
+
require(target != keep_root and target.is_relative_to(keep_root), f"worktree must be below kept root: {keep_root}")
|
|
337
|
+
receipt["keep_root"] = str(keep_root)
|
|
245
338
|
key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
|
|
246
339
|
directory = common / "devlyn-completion" / key
|
|
247
340
|
path = directory / "receipt.json"
|
|
248
|
-
reconciled = [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)
|
|
341
|
+
reconciled = [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts, warnings=warnings)
|
|
249
342
|
measured = target
|
|
250
343
|
while not measured.exists():
|
|
251
344
|
require(measured.parent != measured, f"no existing ancestor of {target} to measure free space")
|
|
@@ -266,7 +359,21 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
266
359
|
# Persist prospective intent BEFORE native creation. An interrupted allocation
|
|
267
360
|
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
268
361
|
atomic_json(path, receipt)
|
|
269
|
-
|
|
362
|
+
for root in worktree_roots(receipt)[1:]:
|
|
363
|
+
try:
|
|
364
|
+
prune_empty_parents(receipt, root, root, include_root=True)
|
|
365
|
+
except (CompletionError, OSError) as exc:
|
|
366
|
+
emit_warning(warnings, f"legacy root cleanup retained: {root}: {exc}; continuing")
|
|
367
|
+
create_worktree_directory(receipt, target, directory, warnings)
|
|
368
|
+
try:
|
|
369
|
+
shared("platform-support")["mark_not_content_indexed"](target)
|
|
370
|
+
except OSError as exc:
|
|
371
|
+
warning = f"content indexing unchanged: {target}: could not set FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: {exc}; continuing"
|
|
372
|
+
emit_warning(warnings, warning)
|
|
373
|
+
try:
|
|
374
|
+
gref(receipt, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
|
|
375
|
+
except CompletionError as exc:
|
|
376
|
+
raise CompletionError(f"{exc}; remove the empty directory {target} this allocation created; if branch {args.branch} exists, inspect it and delete it only if nothing else owns it; delete receipt directory {directory}, then allocate again") from exc
|
|
270
377
|
try:
|
|
271
378
|
ignore_task_records(target)
|
|
272
379
|
except CompletionError as exc:
|
|
@@ -281,7 +388,7 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
281
388
|
atomic_json(path, owned)
|
|
282
389
|
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
|
|
283
390
|
"build_dirs": owned["build_dirs"],
|
|
284
|
-
"reconciled": reconciled}
|
|
391
|
+
"reconciled": reconciled, "warnings": warnings}
|
|
285
392
|
|
|
286
393
|
|
|
287
394
|
def exact_commit(receipt, value, flag):
|
|
@@ -637,7 +744,8 @@ def clean_scratch_command(args):
|
|
|
637
744
|
"receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
|
|
638
745
|
|
|
639
746
|
|
|
640
|
-
def cleanup(receipt, path, pr):
|
|
747
|
+
def cleanup(receipt, path, pr, *, warnings=None):
|
|
748
|
+
warnings = [] if warnings is None else warnings
|
|
641
749
|
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
642
750
|
sha = receipt["publish_sha"]
|
|
643
751
|
branch_ref = "refs/heads/" + receipt["branch"]
|
|
@@ -673,6 +781,10 @@ def cleanup(receipt, path, pr):
|
|
|
673
781
|
gref(receipt, "worktree", "remove", str(work))
|
|
674
782
|
else:
|
|
675
783
|
require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
|
|
784
|
+
try:
|
|
785
|
+
prune_named_parents(receipt)
|
|
786
|
+
except (CompletionError, OSError) as exc:
|
|
787
|
+
emit_warning(warnings, f"parent cleanup retained: {work.parent}: {exc}; continuing")
|
|
676
788
|
checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
|
|
677
789
|
require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
|
|
678
790
|
# Check ownership/ref identity once more immediately before each deletion.
|
|
@@ -708,7 +820,7 @@ def locked_receipt(path, *, blocking=True):
|
|
|
708
820
|
yield receipt
|
|
709
821
|
|
|
710
822
|
|
|
711
|
-
def completion_result(receipt, path, status):
|
|
823
|
+
def completion_result(receipt, path, status, *, warnings=None):
|
|
712
824
|
if receipt.get("delivery") != status:
|
|
713
825
|
receipt["delivery"] = status # Phase-independent checkpoint: the latest delivery outcome.
|
|
714
826
|
atomic_json(path, receipt)
|
|
@@ -729,13 +841,15 @@ def completion_result(receipt, path, status):
|
|
|
729
841
|
scratch["record_error"] = str(record_error)
|
|
730
842
|
retained = scratch["status"] == "RETAINED" or "workspace_cleanup" in receipt
|
|
731
843
|
return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and retained else status,
|
|
844
|
+
"warnings": [] if warnings is None else warnings,
|
|
732
845
|
"delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
|
|
733
846
|
"acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
|
|
734
847
|
"scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
|
|
735
848
|
"scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
|
|
736
849
|
|
|
737
850
|
|
|
738
|
-
def reconcile(common, allocated, anchor, *, exclude_receipts=()):
|
|
851
|
+
def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
|
|
852
|
+
warnings = [] if warnings is None else warnings
|
|
739
853
|
results = []
|
|
740
854
|
for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
|
|
741
855
|
if path == allocated or path in exclude_receipts:
|
|
@@ -762,14 +876,15 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=()):
|
|
|
762
876
|
repo_policy(receipt)
|
|
763
877
|
receipt["merge"] = pr
|
|
764
878
|
atomic_json(path, receipt)
|
|
765
|
-
cleanup(receipt, path, pr)
|
|
766
|
-
results.append(completion_result(receipt, path, "COMPLETE"))
|
|
879
|
+
cleanup(receipt, path, pr, warnings=warnings)
|
|
880
|
+
results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
|
|
767
881
|
except (Exception, SystemExit) as error:
|
|
768
882
|
results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
|
|
769
883
|
return results
|
|
770
884
|
|
|
771
885
|
|
|
772
|
-
def complete(args):
|
|
886
|
+
def complete(args, *, warnings=None):
|
|
887
|
+
warnings = [] if warnings is None else warnings
|
|
773
888
|
path = Path(args.receipt).absolute()
|
|
774
889
|
with locked_receipt(path) as receipt:
|
|
775
890
|
local = args.local_only or receipt.get("local_only")
|
|
@@ -781,7 +896,7 @@ def complete(args):
|
|
|
781
896
|
receipt["writers_released"] = True
|
|
782
897
|
atomic_json(path, receipt)
|
|
783
898
|
def result(status):
|
|
784
|
-
return completion_result(receipt, path, status)
|
|
899
|
+
return completion_result(receipt, path, status, warnings=warnings)
|
|
785
900
|
if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
|
|
786
901
|
return result("FAILED")
|
|
787
902
|
if args.local_only or receipt.get("local_only"):
|
|
@@ -802,6 +917,11 @@ def complete(args):
|
|
|
802
917
|
require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
|
|
803
918
|
if receipt.get("status") == "COMPLETE":
|
|
804
919
|
verify_files(path.parent / "custody", receipt["files"])
|
|
920
|
+
if receipt["linked"]:
|
|
921
|
+
try:
|
|
922
|
+
prune_named_parents(receipt)
|
|
923
|
+
except (CompletionError, OSError) as exc:
|
|
924
|
+
emit_warning(warnings, f"parent cleanup retained: {Path(receipt['worktree']).parent}: {exc}; continuing")
|
|
805
925
|
return result("COMPLETE")
|
|
806
926
|
if not receipt.get("acceptance"):
|
|
807
927
|
# Before first binding the owner has committed the product; inspect
|
|
@@ -887,7 +1007,7 @@ def complete(args):
|
|
|
887
1007
|
receipt["merge"] = pr
|
|
888
1008
|
atomic_json(path, receipt)
|
|
889
1009
|
try:
|
|
890
|
-
cleanup(receipt, path, pr)
|
|
1010
|
+
cleanup(receipt, path, pr, warnings=warnings)
|
|
891
1011
|
except (WritersUnobservable, WriterActive) as error:
|
|
892
1012
|
# Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
|
|
893
1013
|
receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
@@ -1157,6 +1277,114 @@ class CompletionTests(unittest.TestCase):
|
|
|
1157
1277
|
repository="test/project", base="main", remote="origin", worktree=str(worktree),
|
|
1158
1278
|
local_base=None, from_receipt=None, start=None)
|
|
1159
1279
|
|
|
1280
|
+
def test_allocation_marks_exclusively_created_empty_target_before_checkout(self):
|
|
1281
|
+
# Prediction: persisted intent precedes exclusive creation and marking; real Git then populates that directory.
|
|
1282
|
+
from unittest.mock import patch
|
|
1283
|
+
target = self.root / "new-parent" / "new"
|
|
1284
|
+
events = []
|
|
1285
|
+
save, mkdir = atomic_json, Path.mkdir
|
|
1286
|
+
|
|
1287
|
+
def persist(path, receipt):
|
|
1288
|
+
save(path, receipt)
|
|
1289
|
+
if receipt["allocation"] == "allocating":
|
|
1290
|
+
self.assertFalse(target.exists())
|
|
1291
|
+
self.assertEqual(read_json(path)["worktree"], str(target))
|
|
1292
|
+
events.append("intent")
|
|
1293
|
+
|
|
1294
|
+
def create(path, *args, **kwargs):
|
|
1295
|
+
if path == target:
|
|
1296
|
+
self.assertEqual(events, ["intent"])
|
|
1297
|
+
self.assertEqual(kwargs, {})
|
|
1298
|
+
events.append("mkdir")
|
|
1299
|
+
return mkdir(path, *args, **kwargs)
|
|
1300
|
+
|
|
1301
|
+
def mark(path):
|
|
1302
|
+
self.assertEqual(path, target)
|
|
1303
|
+
self.assertEqual(events, ["intent", "mkdir"])
|
|
1304
|
+
self.assertTrue(path.is_dir())
|
|
1305
|
+
self.assertEqual(list(path.iterdir()), [])
|
|
1306
|
+
events.append("mark")
|
|
1307
|
+
|
|
1308
|
+
target.parent.mkdir()
|
|
1309
|
+
with patch.dict(os.environ, self.env), patch.dict(allocate.__globals__, atomic_json=persist), \
|
|
1310
|
+
patch.object(Path, "mkdir", create), patch.dict(shared("platform-support"), mark_not_content_indexed=mark):
|
|
1311
|
+
result = allocate(self.allocation_args("new", target))
|
|
1312
|
+
self.assertEqual(events, ["intent", "mkdir", "mark"])
|
|
1313
|
+
self.assertEqual(result["warnings"], [])
|
|
1314
|
+
self.assertEqual((target / "product").read_text(), "baseline\n")
|
|
1315
|
+
self.assertTrue((target / ".git").is_file())
|
|
1316
|
+
receipt = read_json(Path(result["receipt"]))
|
|
1317
|
+
gitdir = Path(self.g("rev-parse", "--absolute-git-dir", work=target))
|
|
1318
|
+
self.assertEqual((receipt["allocation"], receipt["worktree_gitdir"]), ("owned", str(gitdir)))
|
|
1319
|
+
self.assertEqual(receipt["worktree_identity"], workspace_identity(target, gitdir))
|
|
1320
|
+
self.assertEqual(receipt["scratch_identity"], workspace_identity(Path(result["scratch"]), Path(result["receipt"]).parent))
|
|
1321
|
+
self.assertEqual(self.g("symbolic-ref", "--short", "HEAD", work=target), receipt["branch"])
|
|
1322
|
+
self.assertEqual(self.g("rev-parse", "HEAD", work=target), receipt["baseline"])
|
|
1323
|
+
|
|
1324
|
+
def test_allocation_marking_failure_warns_and_remains_owned(self):
|
|
1325
|
+
# Prediction: an attribute error is printed immediately and returned, while checkout and ownership succeed.
|
|
1326
|
+
from io import StringIO
|
|
1327
|
+
from unittest.mock import Mock, patch
|
|
1328
|
+
target = self.root / "new"
|
|
1329
|
+
stderr = StringIO()
|
|
1330
|
+
with patch.dict(os.environ, self.env), contextlib.redirect_stderr(stderr), \
|
|
1331
|
+
patch.dict(shared("platform-support"), mark_not_content_indexed=Mock(side_effect=OSError("fixture attribute denied"))):
|
|
1332
|
+
result = allocate(self.allocation_args("new", target))
|
|
1333
|
+
warning = f"content indexing unchanged: {target}: could not set FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: fixture attribute denied; continuing"
|
|
1334
|
+
self.assertEqual(result["warnings"], [warning])
|
|
1335
|
+
self.assertEqual(stderr.getvalue(), warning + "\n")
|
|
1336
|
+
self.assertEqual(read_json(Path(result["receipt"]))["allocation"], "owned")
|
|
1337
|
+
self.assertEqual((target / "product").read_text(), "baseline\n")
|
|
1338
|
+
|
|
1339
|
+
def test_allocation_refuses_raced_target_without_marking(self):
|
|
1340
|
+
# Prediction: a creator arriving after the absent check is never adopted, marked or removed.
|
|
1341
|
+
from unittest.mock import Mock, patch
|
|
1342
|
+
target = self.root / "new"
|
|
1343
|
+
save = atomic_json
|
|
1344
|
+
receipts = []
|
|
1345
|
+
|
|
1346
|
+
def race(path, receipt):
|
|
1347
|
+
save(path, receipt)
|
|
1348
|
+
receipts.append(path)
|
|
1349
|
+
target.mkdir()
|
|
1350
|
+
(target / "foreign").write_text("retain", encoding="utf-8")
|
|
1351
|
+
|
|
1352
|
+
mark = Mock()
|
|
1353
|
+
with patch.dict(os.environ, self.env), patch.dict(allocate.__globals__, atomic_json=race), \
|
|
1354
|
+
patch.dict(shared("platform-support"), mark_not_content_indexed=mark):
|
|
1355
|
+
with self.assertRaises(CompletionError) as raised:
|
|
1356
|
+
allocate(self.allocation_args("new", target))
|
|
1357
|
+
self.assertIn(f"already exists and is not devlyn's; leave it; choose another --worktree path, delete receipt directory {receipts[0].parent}, then allocate again", str(raised.exception))
|
|
1358
|
+
mark.assert_not_called()
|
|
1359
|
+
self.assertEqual((target / "foreign").read_text(), "retain")
|
|
1360
|
+
self.assertEqual(read_json(receipts[0])["allocation"], "allocating")
|
|
1361
|
+
self.assertEqual(self.g("branch", "--list", "task/new"), "")
|
|
1362
|
+
|
|
1363
|
+
def test_allocation_git_failure_retains_intent_and_names_recovery(self):
|
|
1364
|
+
# Prediction: a competing branch survives Git's refusal; recovery requires inspecting its ownership.
|
|
1365
|
+
from io import StringIO
|
|
1366
|
+
from unittest.mock import Mock, patch
|
|
1367
|
+
target = self.root / "new"
|
|
1368
|
+
stderr = StringIO()
|
|
1369
|
+
run = gref
|
|
1370
|
+
|
|
1371
|
+
def fail_checkout(receipt, *args):
|
|
1372
|
+
if args[:2] == ("worktree", "add"):
|
|
1373
|
+
self.assertIn("fixture attribute denied; continuing", stderr.getvalue())
|
|
1374
|
+
run(receipt, "branch", "task/new", receipt["baseline"])
|
|
1375
|
+
return run(receipt, *args)
|
|
1376
|
+
|
|
1377
|
+
with patch.dict(os.environ, self.env), contextlib.redirect_stderr(stderr), \
|
|
1378
|
+
patch.dict(allocate.__globals__, gref=fail_checkout), \
|
|
1379
|
+
patch.dict(shared("platform-support"), mark_not_content_indexed=Mock(side_effect=OSError("fixture attribute denied"))):
|
|
1380
|
+
with self.assertRaises(CompletionError) as raised:
|
|
1381
|
+
allocate(self.allocation_args("new", target))
|
|
1382
|
+
path, = (self.work / ".git/devlyn-completion").glob("*/receipt.json")
|
|
1383
|
+
self.assertEqual(read_json(path)["allocation"], "allocating")
|
|
1384
|
+
self.assertEqual(list(target.iterdir()), [])
|
|
1385
|
+
self.assertEqual(self.g("rev-parse", "task/new"), read_json(path)["baseline"])
|
|
1386
|
+
self.assertIn(f"remove the empty directory {target} this allocation created; if branch task/new exists, inspect it and delete it only if nothing else owns it; delete receipt directory {path.parent}, then allocate again", str(raised.exception))
|
|
1387
|
+
|
|
1160
1388
|
def test_disk_gate_refuses_without_allocation_artifacts(self):
|
|
1161
1389
|
# Prediction: below 10 GiB on a 100 GiB filesystem, no receipt, branch or worktree is created.
|
|
1162
1390
|
from unittest.mock import Mock, patch
|
|
@@ -54,12 +54,15 @@ not write to the shared checkout.
|
|
|
54
54
|
```sh
|
|
55
55
|
python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
56
56
|
--task '<task identity>' --branch '<absent task branch>' \
|
|
57
|
-
--worktree '<repo parent
|
|
57
|
+
--worktree '<repo parent>/.devlyn-worktrees/<repo name>/<task directory>' \
|
|
58
58
|
--repository '<owner/repo>' --remote origin --base main
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
62
62
|
must name an absent path.
|
|
63
|
+
Cleanup removes empty parents through the exact per-repo root shown above or its legacy `.devlyn.noindex`/`.devlyn` root, keeping the shared `.devlyn-worktrees` container. Allocation also sweeps empty legacy roots for this repo.
|
|
64
|
+
On Windows, allocation marks only a newly created shared container hidden and creates the worktree not content-indexed (content only; names stay indexed); files copied in keep their own attributes.
|
|
65
|
+
A marking failure continues allocation and is reported in `warnings`.
|
|
63
66
|
Remote allocation first cleans earlier merged deliveries, then allocation refuses with `BLOCKED:disk`
|
|
64
67
|
without creating a receipt, branch or worktree when that filesystem has less than max(10 GiB, 2%) free; free space and allocate again.
|
|
65
68
|
A publication allocation normally starts at the exact fetched remote base,
|
|
@@ -14,7 +14,7 @@ Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
|
|
|
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
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
|
-
- Worktrees default to `<repo parent
|
|
17
|
+
- Worktrees default to `<repo parent>/.devlyn-worktrees/<repo name>/<loop-id>/<task-id>`; tasks already allocated under an earlier default resume where their receipts record them. Cleanup removes empty parents through the per-repo root, keeping the shared `.devlyn-worktrees` container; explicit custom drain roots are recorded at allocation and kept, including when nested inside a devlyn-named root. On Windows, only a newly created shared container is marked hidden. Each allocation creates its worktree not content-indexed, under any root (content only; names stay indexed); files copied in keep their own attributes, and marking failures continue with `warnings`. Microsoft Defender scanning is unaffected; use a Dev Drive via `--worktree-root` or an administrator's Defender exclusion for the root.
|
|
18
18
|
|
|
19
19
|
The host supplies the executor and maps its configured engine to the argv; the loop has no model preference. Each `{packet}` in the argv is replaced by the task packet path, and each `{worktree_git_dir}` by the task worktree's absolute Git directory. The executor runs with the task worktree as its working directory and the null device as stdin; its stdout and stderr are appended to `executor.stdout` and `executor.stderr` in the packet's `evidence_dir`, never a pipe.
|
|
20
20
|
|
|
@@ -661,11 +661,13 @@ def clean(common, recorded):
|
|
|
661
661
|
return notes
|
|
662
662
|
|
|
663
663
|
|
|
664
|
-
def task_complete(action, *, exclude_receipts=(), **values):
|
|
664
|
+
def task_complete(action, *, exclude_receipts=(), warnings=None, **values):
|
|
665
665
|
helper = shared("task-complete")
|
|
666
666
|
try:
|
|
667
667
|
# Preserve the previous-receipt sweep, excluding only queue-invalid owners.
|
|
668
668
|
options = {"exclude_receipts": exclude_receipts} if action == "allocate" else {}
|
|
669
|
+
if action in {"allocate", "complete"}:
|
|
670
|
+
options["warnings"] = warnings
|
|
669
671
|
return helper[action](argparse.Namespace(**values), **options)
|
|
670
672
|
except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
|
|
671
673
|
# Inherited helper containment includes these three programming-error types; the controller boundary is narrower.
|
|
@@ -1079,7 +1081,8 @@ def allocate(v, row, opts):
|
|
|
1079
1081
|
deps = [v["states"][f"{loop}.{dep}"] for dep in package["tasks"][task]["depends_on"]]
|
|
1080
1082
|
values = {"repo": str(anchor), "task": identity, "branch": branch_of(identity), "repository": None,
|
|
1081
1083
|
"base": manifest["base_ref"], "remote": "origin", "worktree": str(opts.worktree_root / loop / task),
|
|
1082
|
-
"local_base": None, "from_receipt": None, "start": None
|
|
1084
|
+
"local_base": None, "from_receipt": None, "start": None,
|
|
1085
|
+
"keep_root": str(opts.keep_root) if opts.keep_root else None}
|
|
1083
1086
|
if is_local(v, loop):
|
|
1084
1087
|
tip, start = frontier(v, loop), local_start(v, loop)[0]
|
|
1085
1088
|
for dep in deps:
|
|
@@ -1113,18 +1116,7 @@ def allocate(v, row, opts):
|
|
|
1113
1116
|
if values["start"] and (drift := instruction_drift(anchor, start)):
|
|
1114
1117
|
return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
|
|
1115
1118
|
f"without them; commit them and push them to origin/{manifest['base_ref']}")
|
|
1116
|
-
|
|
1117
|
-
try:
|
|
1118
|
-
opts.worktree_root.mkdir(exist_ok=False)
|
|
1119
|
-
except FileExistsError:
|
|
1120
|
-
pass # An existing root belongs to its owner; never change its attributes.
|
|
1121
|
-
else:
|
|
1122
|
-
try:
|
|
1123
|
-
shared("platform-support")["mark_not_content_indexed"](opts.worktree_root)
|
|
1124
|
-
except OSError as exc:
|
|
1125
|
-
opts.root_diagnostics.append(
|
|
1126
|
-
f"content indexing unchanged: {opts.worktree_root}: could not set FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: {exc}; continuing")
|
|
1127
|
-
result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], **values)
|
|
1119
|
+
result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], warnings=opts.warnings, **values)
|
|
1128
1120
|
progress(identity, f"allocated {result['worktree']}")
|
|
1129
1121
|
return None
|
|
1130
1122
|
|
|
@@ -1340,7 +1332,7 @@ def advance(v, row, opts):
|
|
|
1340
1332
|
if receipt.get("delivery") not in SETTLED:
|
|
1341
1333
|
packet = read_json(path.parent / "packet.json")
|
|
1342
1334
|
# The plan's mode seeds the first delivery call; a later per-task --mode persists in the receipt.
|
|
1343
|
-
result = task_complete("complete", receipt=str(path), acceptance=None,
|
|
1335
|
+
result = task_complete("complete", warnings=opts.warnings, receipt=str(path), acceptance=None,
|
|
1344
1336
|
mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
|
|
1345
1337
|
progress(identity, f"delivery {result['status']}")
|
|
1346
1338
|
if result.get("merge_refused"):
|
|
@@ -1498,21 +1490,10 @@ def prune_parents(v, root):
|
|
|
1498
1490
|
try:
|
|
1499
1491
|
if work.exists():
|
|
1500
1492
|
continue
|
|
1501
|
-
require(work == work
|
|
1493
|
+
require(work == helper["resolve_for_pruning"](work) and not work.is_symlink() and not parent.is_symlink(),
|
|
1502
1494
|
f"symlink or escape at {work}")
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
require(not any(Path(path).is_relative_to(parent) for path in registered),
|
|
1506
|
-
f"registered worktree retains parent: {parent}")
|
|
1507
|
-
try:
|
|
1508
|
-
parent.rmdir()
|
|
1509
|
-
except FileNotFoundError:
|
|
1510
|
-
pass # A previous cleanup already removed this parent.
|
|
1511
|
-
except OSError as exc:
|
|
1512
|
-
if exc.errno in {errno.ENOTEMPTY, errno.EEXIST}:
|
|
1513
|
-
break
|
|
1514
|
-
raise
|
|
1515
|
-
parent = parent.parent
|
|
1495
|
+
retained = helper["prune_empty_parents"](receipt, parent, root)
|
|
1496
|
+
require(retained is None, retained)
|
|
1516
1497
|
except (LoopError, OSError, helper["CompletionError"]) as exc:
|
|
1517
1498
|
v["cleanup"].append(f"parent cleanup retained: {parent}: {exc}; remove it when empty or drain again")
|
|
1518
1499
|
|
|
@@ -1523,9 +1504,9 @@ def drain(args):
|
|
|
1523
1504
|
enforce_binding(common, anchor)
|
|
1524
1505
|
diagnostics = skill_diagnostics(anchor)
|
|
1525
1506
|
require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
|
|
1526
|
-
args.
|
|
1527
|
-
args.
|
|
1528
|
-
args.worktree_root =
|
|
1507
|
+
args.warnings = diagnostics["warnings"]
|
|
1508
|
+
args.keep_root = Path(args.worktree_root).resolve() if args.worktree_root else None
|
|
1509
|
+
args.worktree_root = args.keep_root or anchor.parent / ".devlyn-worktrees" / anchor.name
|
|
1529
1510
|
with lock(common, "drain.lock", blocking=False):
|
|
1530
1511
|
attempted, last, status, reason, refused = set(), None, "DRAINED", None, {}
|
|
1531
1512
|
try:
|
|
@@ -1561,7 +1542,6 @@ def drain(args):
|
|
|
1561
1542
|
last = view(anchor, common, args.local_only)
|
|
1562
1543
|
if last is None:
|
|
1563
1544
|
return {"status": status, "reason": reason, **diagnostics}
|
|
1564
|
-
last["cleanup"].extend(args.root_diagnostics)
|
|
1565
1545
|
annotate(last, refused)
|
|
1566
1546
|
prune_parents(last, args.worktree_root)
|
|
1567
1547
|
try:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.12",
|
|
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": {
|