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.
@@ -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
- gref(receipt, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
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>/<repo name>.devlyn.noindex/<task directory>' \
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>/<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.
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
- if os.name == "nt" and opts.default_worktree_root:
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.resolve() and not work.is_symlink() and not parent.is_symlink(),
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
- registered = helper["registrations"](receipt)
1504
- while parent != root and parent.is_relative_to(root):
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.default_worktree_root = args.worktree_root is None
1527
- args.root_diagnostics = []
1528
- args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn.noindex"
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.10",
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": {