devlyn-cli 4.3.21 → 4.3.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/devlyn.js +26 -1
  5. package/bin/instruction-templates.json +26 -0
  6. package/bin/skill-history.json +12 -1
  7. package/config/skills/_shared/bring-in.py +810 -0
  8. package/config/skills/_shared/harness-store.py +18 -6
  9. package/config/skills/_shared/runtime-principles.md +1 -1
  10. package/config/skills/_shared/storage-checkout.py +117 -0
  11. package/config/skills/_shared/storage-client.py +1180 -0
  12. package/config/skills/_shared/task-complete.py +900 -88
  13. package/config/skills/_shared/task-completion.md +78 -6
  14. package/config/skills/_shared/warm-build-dirs.py +53 -3
  15. package/config/skills/devlyn-ideate/SKILL.md +1 -1
  16. package/config/skills/devlyn-ideate/references/loop.md +2 -2
  17. package/config/skills/devlyn-ideate/scripts/queue.py +69 -18
  18. package/config/storage-client-contract.json +7 -0
  19. package/harness/AGENTS.md +2 -2
  20. package/harness/CLAUDE.md +2 -2
  21. package/native/claude/.claude-plugin/plugin.json +1 -1
  22. package/native/claude/skills/_shared/bring-in.py +810 -0
  23. package/native/claude/skills/_shared/harness-store.py +18 -6
  24. package/native/claude/skills/_shared/runtime-principles.md +1 -1
  25. package/native/claude/skills/_shared/storage-checkout.py +117 -0
  26. package/native/claude/skills/_shared/storage-client.py +1180 -0
  27. package/native/claude/skills/_shared/task-complete.py +900 -88
  28. package/native/claude/skills/_shared/task-completion.md +78 -6
  29. package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
  30. package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
  31. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
  32. package/native/claude/skills/devlyn-ideate/scripts/queue.py +69 -18
  33. package/native/codex/.agents/plugins/marketplace.json +1 -1
  34. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  35. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
  36. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
  37. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
  38. package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
  39. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
  40. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
  41. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
  42. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
  43. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
  44. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
  45. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +69 -18
  46. package/optional-skills/better-auth-setup/SKILL.md +9 -3
  47. package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
  48. package/package.json +1 -1
  49. package/release.json +91 -41
@@ -45,8 +45,8 @@ def require(condition, message):
45
45
  raise CompletionError(message)
46
46
 
47
47
 
48
- def command(argv, *, cwd=None, ok=(0,)):
49
- result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
48
+ def command(argv, *, cwd=None, ok=(0,), env=None):
49
+ result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8")
50
50
  require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
51
51
  return result.stdout.removesuffix("\n")
52
52
 
@@ -74,7 +74,17 @@ def atomic_json(path, value):
74
74
  stream.flush()
75
75
  os.fsync(stream.fileno())
76
76
  os.replace(temporary, path)
77
- if os.name != "nt":
77
+ managed = value.get("allocation") in ("managed_pending", "managed_owned")
78
+ if managed:
79
+ directory = path.parent
80
+ boundary = Path(value["common_gitdir"])
81
+ while True:
82
+ storage_call("sync_directory", directory)
83
+ if directory == boundary:
84
+ break
85
+ require(boundary in directory.parents, "managed receipt durability boundary changed")
86
+ directory = directory.parent
87
+ if os.name != "nt" and not managed:
78
88
  directory = os.open(path.parent, os.O_RDONLY)
79
89
  try:
80
90
  os.fsync(directory)
@@ -306,11 +316,523 @@ def ignore_task_records(work):
306
316
  f"{directory} records are not ignored or .devlyn is dirty; repair the starting commit's .devlyn paths and ignore rules before allocating")
307
317
 
308
318
 
319
+ def storage_call(name, *args, adapter=False, receipt_path=None, **kwargs):
320
+ module = shared("storage-checkout" if adapter else "storage-client")
321
+ try:
322
+ return module[name](*args, **kwargs)
323
+ except module["StorageRefusal"] as exc:
324
+ request = args[2] if adapter and len(args) == 3 else kwargs.get("receipt", {}).get("storage", {}).get("request", {})
325
+ path = receipt_path or request.get("external_receipt")
326
+ resume = exc.resume or (storage_resume(path, for_removal=name in ('enrollment', 'recovery_registration')) if path else None)
327
+ error = CompletionError(str(exc) + ('; resume ' + resume if resume else '; repair Devlyn enrollment and retry'))
328
+ error.storage_error = exc.result(receipt=path, resume=resume)
329
+ raise error from exc
330
+
331
+
332
+ def authority_binding(record):
333
+ return {key: record[key] for key in ('control_directory', 'control_identity')}
334
+
335
+
336
+ def managed_registration(receipt, *, recovery=False):
337
+ if not recovery:
338
+ decision = storage_call('enrollment', receipt=receipt)
339
+ require('storage' in receipt, 'managed receipt binding missing; retain and repair enrollment')
340
+ saved = receipt['storage']['decision']['registration']
341
+ if recovery:
342
+ current = storage_call('recovery_registration', receipt_path=receipt['storage'].get('request', {}).get('external_receipt'))
343
+ require(authority_binding(current) == authority_binding(saved),
344
+ 'recovery pins authority binding changed; retain and repair enrollment')
345
+ return {**current, 'helper_path': current['retained_helper']['path']}
346
+ require(decision['mode'] == 'managed', 'managed enrollment missing')
347
+ require(authority_binding(decision['registration']) == authority_binding(saved),
348
+ 'enrollment authority binding changed; retain original recovery binding')
349
+ return decision['registration']
350
+
351
+
352
+ def reply_matches(actual, expected):
353
+ """Compare requested bindings recursively while accepting additive replies."""
354
+ if isinstance(expected, dict):
355
+ return isinstance(actual, dict) and all(key in actual and reply_matches(actual[key], value)
356
+ for key, value in expected.items())
357
+ if isinstance(expected, list):
358
+ return isinstance(actual, list) and len(actual) == len(expected) and all(reply_matches(a, e) for a, e in zip(actual, expected))
359
+ return actual == expected
360
+
361
+
362
+ def finish_managed_allocation(receipt, path):
363
+ allocation_replayable(receipt, path)
364
+ registration = managed_registration(receipt)
365
+ state = receipt["storage"]
366
+ capabilities = storage_call("call", registration, "capabilities", {}, adapter=True)
367
+ require(capabilities["helper_release"]["sha256"] == registration["helper_release"]["sha256"], "helper release changed")
368
+ storage_rpc(registration, {"op": "hello"}, path)
369
+ request = state["request"]
370
+ result = storage_call("call", registration, "allocate", request, adapter=True)
371
+ binding = result["allocation_binding"]
372
+ expected = {key: request[key] for key in ("baseline", "branch", "owner")}
373
+ require(reply_matches(binding, expected)
374
+ and reply_matches(binding["publication_owner"], request["owner"])
375
+ and binding["destination"] == request["destination"]["path"]
376
+ and reply_matches(binding["destination_mode"], {key: value for key, value in request["destination"].items() if key != "path"})
377
+ and reply_matches(binding["adapter"], {key: request[key] for key in (
378
+ "operation_id", "repository", "control_directory", "external_receipt")})
379
+ and result["receipt"] == str(path.with_name("storage-allocation.json"))
380
+ and binding["receipt"] == result["receipt"], "adapter allocation binding changed")
381
+ require(result["closure_result"]["established_by"] != "legacy_unreserved", "managed allocation lacks reserved closure")
382
+ for publication in result["publications"]:
383
+ require(reply_matches(publication["cache"]["owner"], request["owner"])
384
+ and publication["lease"]["cache_id"] == publication["cache"]["cache_id"], "publication ownership changed")
385
+ state["result"] = result
386
+ # Save the complete public result before post-checkout processing; Rust's
387
+ # private v3 receipt remains untouched even after a lost Python write.
388
+ atomic_json(path, receipt)
389
+ work = Path(receipt["worktree"])
390
+ ignore_task_records(work)
391
+ receipt["worktree_gitdir"] = git(work, "rev-parse", "--absolute-git-dir")
392
+ receipt["worktree_identity"] = workspace_identity(work, Path(receipt["worktree_gitdir"]))
393
+ receipt["build_dirs"] = [{"tool": "cargo", "status": "cold", "reason": "managed warming deferred",
394
+ "path": str(Path(p["cache"]["location"]["parent"]) / p["cache"]["location"]["entry"]),
395
+ "cache_id": p["cache"]["cache_id"], "lease_id": p["lease"]["lease_id"]} for p in result["publications"]]
396
+ receipt["allocation"] = "managed_owned"
397
+ atomic_json(path, receipt)
398
+
399
+
400
+ def allocation_result(receipt, path, reconciled, warnings):
401
+ return {"status": "ALLOCATED", "receipt": str(path), "worktree": receipt["worktree"],
402
+ "scratch": str(path.parent / "scratch"), "build_dirs": receipt.get("build_dirs", []),
403
+ "reconciled": reconciled, "warnings": warnings + receipt.get("storage", {}).get("result", {}).get("warnings", [])}
404
+
405
+
406
+ def path_present(path):
407
+ """Only ENOENT proves absence; permission and other observation errors refuse."""
408
+ try:
409
+ Path(path).lstat()
410
+ return True
411
+ except FileNotFoundError:
412
+ return False
413
+
414
+
415
+ def managed_removal_gate(receipt, path, *, registration=None, begin=True):
416
+ """Durable fence and positive closure, including the exact empty cache set."""
417
+ retained_modules(receipt)
418
+ registration = registration or managed_registration(receipt)
419
+ state = receipt["storage"]
420
+ if "removal_request" not in state:
421
+ work = Path(receipt["worktree"])
422
+ state["removal_request"] = {"adapter_protocol": 1,
423
+ "allocation_operation_id": state["request"]["operation_id"], "external_receipt": str(path),
424
+ "removal_operation_id": storage_call("operation_id"),
425
+ "worktree_identity": storage_call("persistent_identity", work) if path_present(work) else None}
426
+ atomic_json(path, receipt)
427
+ result = storage_call("call", registration, "reconcile-for-removal", state["removal_request"], adapter=True)
428
+ require(result["operation_id"] == state["removal_request"]["removal_operation_id"], "removal fence ID changed")
429
+ require(result["closure_result"]["established_by"] != "legacy_unreserved", "managed removal lacks positive closure")
430
+ if "removal_result" in state:
431
+ require(state["removal_result"]["cache_ids"] == result["cache_ids"], "removal cache set changed")
432
+ state["removal_result"] = result
433
+ atomic_json(path, receipt)
434
+ if result["cache_ids"] and begin:
435
+ if "begin_removal" not in state:
436
+ state["begin_removal"] = {"op": "begin_removal", "operation_id": storage_call("operation_id"),
437
+ "cache_ids": result["cache_ids"]}
438
+ atomic_json(path, receipt)
439
+ try:
440
+ removal = storage_rpc(registration, state["begin_removal"], path)
441
+ except CompletionError as exc:
442
+ if getattr(exc, 'storage_error', {}).get('code') != 'storage_not_found' or path_present(receipt['worktree']):
443
+ raise
444
+ state.pop('removal', None)
445
+ state['removal_compacted'] = True
446
+ atomic_json(path, receipt)
447
+ return registration
448
+ require(removal["operation_id"] == state["begin_removal"]["operation_id"]
449
+ and sorted(e["cache_id"] for e in removal["entries"]) == sorted(result["cache_ids"]),
450
+ "authority removal binding or cache set changed")
451
+ require(not removal.get("finished_at") or not path_present(receipt["worktree"]),
452
+ "finished removal path is present; refusing deletion")
453
+ state["removal"] = removal
454
+ atomic_json(path, receipt)
455
+ return registration
456
+
457
+
458
+ def receipt_resume(action, path, receipt=None, *, flags=()):
459
+ receipt = read_json(Path(path)) if receipt is None else receipt
460
+ require(isinstance(receipt, dict) and isinstance(receipt.get('storage', {}), dict)
461
+ and isinstance(receipt.get('storage', {}).get('request', {}), dict),
462
+ f'invalid receipt operation binding: {path}; repair the retained receipt before resuming')
463
+ operation = receipt.get('storage', {}).get('request', {}).get('operation_id')
464
+ return shlex.join([sys.executable, str(Path(__file__).resolve()), action, '--receipt', str(path),
465
+ *(['--operation-id', operation] if operation else []), *flags])
466
+
467
+
468
+ def storage_resume(path, *, for_removal=False):
469
+ return receipt_resume('recover-storage', path, flags=['--for-removal'] if for_removal else [])
470
+
471
+
472
+ def allocation_replayable(receipt, path):
473
+ if (receipt.get('status') == 'ABANDONED' or receipt.get('storage', {}).get('removal_finished')
474
+ or 'removal_request' in receipt.get('storage', {})):
475
+ error = CompletionError('allocation is terminal or fenced; resume owner removal: ' + storage_resume(path, for_removal=True))
476
+ error.storage_error = {'receipt': str(path), 'resume': storage_resume(path, for_removal=True)}
477
+ raise error
478
+
479
+
480
+ def storage_rpc(registration, request, path=None):
481
+ module = shared("storage-client")
482
+ try:
483
+ return module["Client"](registration["control_directory"]).call(request)
484
+ except module["StorageRefusal"] as exc:
485
+ error = CompletionError(str(exc) + "; retain the receipt and replay the saved operation")
486
+ error.storage_error = exc.result(receipt=str(path) if path else None, resume=storage_resume(path) if path else None)
487
+ raise error from exc
488
+
489
+
490
+ def recover_storage(args):
491
+ path = resolve_receipt(Path(args.receipt).absolute(), operation_id=getattr(args, "operation_id", None))
492
+ with locked_receipt(path, operation_id=getattr(args, "operation_id", None), resume=True) as receipt:
493
+ args.operation_id = receipt.get("storage", {}).get("request", {}).get("operation_id")
494
+ require(storage_call("managed_receipt", receipt), "receipt is not managed")
495
+ if args.for_removal:
496
+ accepted = receipt.get('acceptance') or receipt.get('pushed') or receipt.get('pr_number')
497
+ if accepted and receipt.get('pr_number') and receipt.get('delivery') not in {'COMPLETE', 'CLOSED'}:
498
+ pr = json.loads(gh(receipt, 'pr', 'view', str(receipt['pr_number']), '--json', PR_FIELDS))
499
+ validate_pr(receipt, pr, allow_closed=True)
500
+ if pr['state'] == 'CLOSED':
501
+ require(not pr.get('mergedAt') and not pr.get('mergeCommit'), 'closed PR has merge evidence; retain')
502
+ receipt.update(closed_pr=pr, delivery='CLOSED')
503
+ atomic_json(path, receipt)
504
+ if accepted and receipt.get('delivery') in {'LOCAL_ONLY', 'FAILED', 'CLOSED'}:
505
+ require(receipt.get('writers_released'), 'release writers before owner removal; resume complete --writers-stopped')
506
+ verify_custody(receipt, path)
507
+ normalize_failed_checkout(receipt)
508
+ try:
509
+ cleanup_workspace(receipt, path, warnings=[])
510
+ except WritersUnobservable as exc:
511
+ raise WritersUnobservable(manual_removal(receipt, path, exc)) from exc
512
+ receipt.pop('workspace_cleanup', None)
513
+ return completion_result(receipt, path, receipt['delivery'])
514
+ if receipt['storage'].get('removal_finished') and (accepted or receipt.get('status') == 'ABANDONED'):
515
+ path = retire_terminal(receipt, path)
516
+ if receipt.get('status') == 'ABANDONED':
517
+ return {'status': 'ABANDONED', 'receipt': str(path)}
518
+ return {'status': 'REMOVAL_RECONCILED', 'receipt': str(path), **receipt['storage'].get('removal_result', {}),
519
+ 'resume': receipt_resume('complete', path, receipt)}
520
+ if accepted:
521
+ managed_removal_gate(receipt, path, registration=managed_registration(receipt, recovery=True), begin=False)
522
+ return {'status': 'REMOVAL_RECONCILED', 'receipt': str(path), **receipt['storage']['removal_result'],
523
+ 'resume': receipt_resume('complete', path, receipt)}
524
+ try:
525
+ return abandon_managed(receipt, path)
526
+ except WritersUnobservable as exc:
527
+ raise WritersUnobservable(manual_removal(receipt, path, exc)) from exc
528
+ allocation_replayable(receipt, path)
529
+ if receipt["allocation"] == "managed_pending":
530
+ finish_managed_allocation(receipt, path)
531
+ return allocation_result(receipt, path, [], [])
532
+
533
+
534
+ def finish_managed_removal(receipt, path, registration):
535
+ state = receipt['storage']
536
+ require(not path_present(receipt['worktree']), 'managed tree reappeared; retain')
537
+ removal = state.get('removal')
538
+ if removal and not removal.get('finished_at'):
539
+ finished = storage_rpc(registration, {'op': 'finish_removal', 'removal_id': removal['removal_id']}, path)
540
+ require(finished['removal_id'] == removal['removal_id']
541
+ and reply_matches(finished['entries'], [{key: entry[key] for key in ('cache_id', 'generation')}
542
+ for entry in removal['entries']]) and finished.get('finished_at'),
543
+ 'authority removal completion missing or changed')
544
+ state['removal_finish_result'] = finished
545
+ path.with_name('tree-removal.json').unlink(missing_ok=True)
546
+ storage_call('sync_directory', path.parent)
547
+ state['removal_finished'] = True
548
+ atomic_json(path, receipt)
549
+
550
+
551
+ def abandon_managed(receipt, path):
552
+ """Terminal, fenced disposal of a never-published allocation; retain its audit receipt."""
553
+ require(not receipt.get('acceptance') and not receipt.get('pushed') and not receipt.get('pr_number'),
554
+ 'accepted/published allocations must resume complete for removal')
555
+ registration = managed_registration(receipt, recovery=True)
556
+ work = Path(receipt['worktree'])
557
+ state = receipt['storage']
558
+ branch = 'refs/heads/' + receipt['branch']
559
+ require(ref_sha(receipt, branch) in (None, receipt['baseline']), 'abandoned task branch changed; retain source')
560
+ if state.get('removal_finished'):
561
+ # FinishRemoval may have persisted before branch deletion/ABANDONED.
562
+ # A surviving partial tree must still pass the original fenced checks.
563
+ if path_present(work):
564
+ require('removal_request' in state and 'tree_removal_digest' in state,
565
+ 'finished abandonment tree lacks its original fence; retain')
566
+ remove_managed_tree(receipt, path, registration=registration, abandonment=True)
567
+ elif not path_present(path.with_name('storage-allocation.json')):
568
+ require(not path_present(work) and str(work) not in registrations(receipt),
569
+ 'allocation without Rust receipt has a tree; retain and repair')
570
+ else:
571
+ if path_present(work):
572
+ if not path_present(work / '.git') and 'tree_removal_digest' not in state:
573
+ # Rust may have reserved/precreated an empty destination before
574
+ # Git established its registration. Closure still precedes deletion.
575
+ require(not any(work.iterdir()) and str(work) not in registrations(receipt),
576
+ 'partial checkout without Git metadata contains unknown files; retain')
577
+ if not shutil.rmtree.avoids_symlink_attacks:
578
+ raise WritersUnobservable('safe managed tree removal unsupported; retain')
579
+ stopped_writers(work)
580
+ managed_removal_gate(receipt, path, registration=registration)
581
+ require(storage_call('persistent_identity', work) == state['removal_request']['worktree_identity'],
582
+ 'abandoned root changed; retain')
583
+ work.rmdir()
584
+ storage_call('sync_directory', work.parent)
585
+ else:
586
+ if 'worktree_gitdir' not in receipt:
587
+ receipt['worktree_gitdir'] = git(work, 'rev-parse', '--absolute-git-dir')
588
+ receipt['worktree_identity'] = workspace_identity(work, Path(receipt['worktree_gitdir']))
589
+ remove_managed_tree(receipt, path, registration=registration, abandonment=True)
590
+ else:
591
+ managed_removal_gate(receipt, path, registration=registration)
592
+ if str(work) in registrations(receipt):
593
+ remove_managed_registration(receipt)
594
+ sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
595
+ sync_surviving_directory(work.parent)
596
+ finish_managed_removal(receipt, path, registration)
597
+ if state.get('removal_finished') and str(work) in registrations(receipt):
598
+ remove_managed_registration(receipt)
599
+ sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
600
+ if ref_sha(receipt, branch):
601
+ require(all(row.get('branch') != branch for row in registrations(receipt).values()),
602
+ 'abandoned task branch is still checked out; retain')
603
+ gref(receipt, 'update-ref', '-d', branch, receipt['baseline'])
604
+ receipt['status'] = 'ABANDONED'
605
+ state['removal_finished'] = True
606
+ atomic_json(path, receipt)
607
+ path = retire_terminal(receipt, path)
608
+ return {'status': 'ABANDONED', 'receipt': str(path)}
609
+
610
+
611
+ def managed_terminal(receipt):
612
+ return receipt.get('storage', {}).get('removal_finished') and (
613
+ receipt.get('status') == 'ABANDONED' or receipt.get('delivery') in {'COMPLETE', 'LOCAL_ONLY', 'FAILED', 'CLOSED'})
614
+
615
+
616
+ def retained_modules(receipt):
617
+ gitdir = receipt.get('worktree_gitdir')
618
+ if not gitdir:
619
+ common = receipt.get('common_gitdir')
620
+ if not common:
621
+ return
622
+ # A lost allocation reply may leave only Git's retained registration.
623
+ admins = Path(common) / 'worktrees'
624
+ if not path_present(admins):
625
+ return
626
+ matches = [admin for admin in admins.iterdir() if
627
+ Path((admin / 'gitdir').read_text(encoding='utf-8').strip()) == Path(receipt['worktree']) / '.git']
628
+ require(len(matches) <= 1, 'ambiguous worktree admin directories; retain')
629
+ if not matches:
630
+ return
631
+ gitdir = str(matches[0])
632
+ require(not path_present(Path(gitdir) / 'modules'),
633
+ 'retained populated submodules repositories block removal; preserve/publish their source and explicitly resolve '
634
+ + str(Path(gitdir) / 'modules') + '; retain')
635
+
636
+
637
+ def remove_managed_registration(receipt):
638
+ retained_modules(receipt)
639
+ require(not path_present(receipt['worktree']), 'managed tree reappeared; retain registration')
640
+ gref(receipt, 'worktree', 'remove', '--force', receipt['worktree'])
641
+
642
+
643
+ def retire_terminal(receipt, path):
644
+ if not storage_call('managed_receipt', receipt):
645
+ return path
646
+ if not managed_terminal(receipt) or path.parent.parent.name == 'retired':
647
+ return path
648
+ require(not path_present(receipt['worktree']), 'terminal managed tree reappeared; retain')
649
+ if receipt.get('status') == 'ABANDONED':
650
+ require(ref_sha(receipt, 'refs/heads/' + receipt['branch']) is None,
651
+ 'abandoned branch remains; resume owner removal')
652
+ retired = path.parent.parent / 'retired'
653
+ retired.mkdir(exist_ok=True)
654
+ require(retired.resolve() == retired and retired.is_dir(), 'retired receipt directory redirected; retain')
655
+ destination = retired / (path.parent.name + '.' + receipt['storage']['request']['operation_id'])
656
+ path.parent.rename(destination)
657
+ storage_call('sync_directory', retired)
658
+ storage_call('sync_directory', retired.parent)
659
+ return destination / path.name
660
+
661
+
662
+ def sync_surviving_directory(path):
663
+ while not path.exists():
664
+ require(not path_present(path) and path.parent != path, "directory durability ancestry changed")
665
+ path = path.parent
666
+ storage_call("sync_directory", path)
667
+
668
+
669
+ def removal_file_record(path):
670
+ # A leaf symlink is data, including a symlink to an external directory.
671
+ if stat.S_ISLNK(path.lstat().st_mode):
672
+ return {'type': 'symlink', 'target': os.readlink(path)}
673
+ return {'type': 'file', **file_record(path)}
674
+
675
+
676
+ def removal_eligibility(receipt, path, *, abandonment, partial):
677
+ work = Path(receipt['worktree'])
678
+ pointer = work / '.git'
679
+ if path_present(pointer):
680
+ require(stat.S_ISREG(pointer.lstat().st_mode)
681
+ and git(work, 'rev-parse', '--absolute-git-dir') == receipt['worktree_gitdir'],
682
+ 'managed Git pointer changed; retain')
683
+ else:
684
+ require(partial and not any(work.iterdir()), 'managed Git pointer missing before tree is empty; retain')
685
+ require(same_workspace_identity(workspace_identity(work, Path(receipt['worktree_gitdir'])), receipt['worktree_identity']),
686
+ 'partial removal workspace/registration changed')
687
+ row = registrations(receipt).get(str(work), {})
688
+ require(row.get('branch') == 'refs/heads/' + receipt['branch'] and 'locked' not in row,
689
+ 'partial removal registration changed')
690
+ argv = ['git', '--git-dir', receipt['worktree_gitdir'], '--work-tree', str(work)]
691
+ baseline = receipt['baseline'] if abandonment else receipt['publish_sha']
692
+ require(command([*argv, 'rev-parse', 'HEAD']) == baseline, 'removal HEAD changed; retain')
693
+ known = {}
694
+ for item in command([*argv, 'ls-tree', '-r', '-z', baseline]).split('\0'):
695
+ if item:
696
+ fields, relative = item.split('\t', 1)
697
+ mode, kind, oid = fields.split()
698
+ known[relative] = (mode, oid)
699
+ indexed = {}
700
+ for item in command([*argv, 'ls-files', '--stage', '-z']).split('\0'):
701
+ if item:
702
+ fields, relative = item.split('\t', 1)
703
+ mode, oid, stage = fields.split()
704
+ require(stage == '0' and known.get(relative) == (mode, oid), 'partial checkout index changed; retain: ' + relative)
705
+ indexed[relative] = (mode, oid)
706
+ require(abandonment or indexed == known, 'removal index changed; retain')
707
+ retained_modules(receipt)
708
+ require(not any(mode == '160000' and path_present(work / relative / '.git')
709
+ for relative, (mode, oid) in known.items()),
710
+ 'working tree contains populated submodules; preserve their source and explicitly resolve them; retain')
711
+ # Git's own comparison applies eol/clean filters and gitlink semantics. A
712
+ # private baseline index also handles an interrupted checkout's missing entries.
713
+ with tempfile.TemporaryDirectory(prefix='.removal-index-', dir=path.parent) as temporary:
714
+ env = {**os.environ, 'GIT_INDEX_FILE': str(Path(temporary) / 'index')}
715
+ command([*argv, 'read-tree', baseline], env=env)
716
+ changes = command([*argv, 'diff', '--no-renames', '--name-status', '-z', baseline, '--'], env=env).split('\0')
717
+ require(all(changes[i] == 'D' and (partial or abandonment) for i in range(0, len(changes)-1, 2)),
718
+ 'partial removal source changed; retain')
719
+ nested = command([*argv, 'ls-files', '--others', '--ignored', '--exclude-standard', '-z'])
720
+ require(not any(name.endswith('/') for name in nested.split('\0')), 'managed tree contains a nested repository; retain')
721
+ untracked = command([*argv, 'ls-files', '--others', '--exclude-standard', '-z'])
722
+ # ls-files uses the real index, so a surviving baseline entry missing from
723
+ # a partial-checkout index must not be mistaken for new source.
724
+ others = [name for name in untracked.split('\0') if name and name not in known]
725
+ if abandonment and not partial:
726
+ require(all(name == '.devlyn/.gitignore' and (work / name).read_bytes() == b'*\n' for name in others),
727
+ 'partial checkout contains new source; retain')
728
+ return {relative: removal_file_record(work / relative) for relative in others}
729
+
730
+
731
+ def remove_managed_tree(receipt, path, *, registration=None, abandonment=False):
732
+ work = Path(receipt['worktree'])
733
+ state = receipt['storage']
734
+ # No irreversible admission before platform support and all first-pass vetoes.
735
+ partial = 'tree_removal_digest' in state
736
+ abandonment = abandonment or state.get('abandonment', False)
737
+ if not partial and not abandonment:
738
+ removable(receipt, work)
739
+ if state.get('records_custody'):
740
+ saved = state['records_custody']
741
+ require(records_manifest(Path(saved['path'])) == saved['files'], 'retained task records changed; retain tree')
742
+ files = removal_eligibility(receipt, path, abandonment=abandonment, partial=partial)
743
+ sidecar = path.with_name('tree-removal.json')
744
+ if partial:
745
+ require(file_record(sidecar)['sha256'] == state['tree_removal_digest'], 'removal manifest changed; retain')
746
+ expected = read_json(sidecar)
747
+ require(all(relative in expected and record == expected[relative] for relative, record in files.items()),
748
+ 'partial removal file changed or new source; retain')
749
+ else:
750
+ expected = files
751
+ stopped_writers(work)
752
+ if not shutil.rmtree.avoids_symlink_attacks:
753
+ raise WritersUnobservable('safe managed tree removal unsupported; retain')
754
+ require(not any(mount.is_relative_to(work) for mount in scratch_mounts()), 'managed tree contains a mount; retain')
755
+ if not partial:
756
+ atomic_json(sidecar, files)
757
+ state['tree_removal_digest'] = file_record(sidecar)['sha256']
758
+ if abandonment:
759
+ state['abandonment'] = True
760
+ atomic_json(path, receipt)
761
+ if not state.get('removal_finished'):
762
+ managed_removal_gate(receipt, path, registration=registration)
763
+ require(storage_call('persistent_identity', work) == state['removal_request']['worktree_identity'], 'managed tree identity changed')
764
+ survivors = removal_eligibility(receipt, path, abandonment=abandonment, partial=True)
765
+ require(all(relative in expected and record == expected[relative] for relative, record in survivors.items()),
766
+ 'partial removal file changed or new source; retain')
767
+ stopped_writers(work)
768
+ require(not any(mount.is_relative_to(work) for mount in scratch_mounts()), 'managed tree contains a mount; retain')
769
+ fd = os.open(work, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
770
+ try:
771
+ require(os.path.samestat(os.fstat(fd), work.lstat()), 'managed tree changed before removal; retain')
772
+ empty_owned_tree(fd)
773
+ finally:
774
+ os.close(fd)
775
+ require(storage_call('persistent_identity', work) == state['removal_request']['worktree_identity'], 'managed root replaced; retain')
776
+ work.rmdir()
777
+ require(not path_present(work), 'managed tree removal incomplete; retain receipt')
778
+ storage_call('sync_directory', work.parent)
779
+
780
+
781
+ def empty_owned_tree(fd):
782
+ device = os.fstat(fd).st_dev
783
+ # Keep ignore rules while their children/siblings survive, and the worktree
784
+ # pointer until the entire tree is empty. Symlink directories are leaves.
785
+ for name in sorted(os.listdir(fd), key=lambda name: (name == '.git', name in ('.gitignore', '.gitattributes'), name)):
786
+ before = os.stat(name, dir_fd=fd, follow_symlinks=False)
787
+ require(before.st_dev == device, 'managed tree crosses a filesystem; retain')
788
+ if stat.S_ISDIR(before.st_mode):
789
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
790
+ try:
791
+ require(os.path.samestat(before, os.fstat(child)), 'managed directory changed before descent; retain')
792
+ empty_owned_tree(child)
793
+ finally:
794
+ os.close(child)
795
+ require(os.path.samestat(before, os.stat(name, dir_fd=fd, follow_symlinks=False)),
796
+ 'managed directory changed before unlink; retain')
797
+ os.rmdir(name, dir_fd=fd)
798
+ else:
799
+ os.unlink(name, dir_fd=fd)
800
+
801
+
309
802
  def allocate(args, *, exclude_receipts=(), warnings=None):
310
803
  """Reconcile older tasks, except receipts the queue has identified as invalid."""
311
804
  warnings = [] if warnings is None else warnings
312
805
  work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
313
806
  common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
807
+ key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
808
+ path = common / "devlyn-completion" / key / "receipt.json"
809
+ if path.exists() and storage_call("managed_receipt", read_json(path)):
810
+ with locked_receipt(path) as saved:
811
+ if saved.get('status') == 'ABANDONED' and saved.get('storage', {}).get('removal_finished'):
812
+ retire_terminal(saved, path)
813
+ else:
814
+ allocation_replayable(saved, path)
815
+ # A leftover operation never changes mode after unenrollment.
816
+ # Refuse this key with its retained recovery, without gating others.
817
+ managed_registration(saved)
818
+ require(saved["task"] == args.task and saved["branch"] == args.branch
819
+ and saved["worktree"] == str(Path(args.worktree).resolve())
820
+ and saved["anchor"] == str(work) and saved["remote"] == args.remote,
821
+ "managed replay arguments changed; resume the original receipt")
822
+ require((args.base is None or args.base == saved["base"])
823
+ and (args.repository is None or args.repository == saved["repository"]),
824
+ "managed replay repository/base changed")
825
+ if args.from_receipt:
826
+ predecessor = saved.get("allocated_from", {}).get("receipt")
827
+ require(predecessor and Path(args.from_receipt).resolve() == common / "devlyn-completion" / predecessor / "receipt.json",
828
+ "managed replay predecessor changed")
829
+ for supplied in (args.local_base, args.start):
830
+ require(supplied is None or supplied == saved["baseline"], "managed replay baseline changed")
831
+ if saved["allocation"] == "managed_pending":
832
+ finish_managed_allocation(saved, path)
833
+ return allocation_result(saved, path, [], warnings)
834
+ decision = storage_call("operation_enrollment")
835
+
314
836
  require(args.task.strip(), "task identity is required")
315
837
  git(work, "check-ref-format", "refs/heads/" + args.branch)
316
838
  if args.base is not None:
@@ -342,26 +864,44 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
342
864
  directory = common / "devlyn-completion" / key
343
865
  path = directory / "receipt.json"
344
866
  reconciled = [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts, warnings=warnings)
345
- measured = target
346
- while not measured.exists():
347
- require(measured.parent != measured, f"no existing ancestor of {target} to measure free space")
348
- measured = measured.parent
349
- usage = shutil.disk_usage(measured)
350
- floor = max(10 * 1024**3, (usage.total + 49) // 50)
351
- require(usage.free >= floor,
352
- f"BLOCKED:disk {usage.free} bytes ({usage.free / 1024**3:.2f} GiB) free is below the "
353
- f"{floor} byte ({floor / 1024**3:.2f} GiB) floor on {measured}; free space "
354
- "(for example, run Devlyn disk cleanup or remove finished task worktrees' build output), then allocate again")
867
+ if decision["mode"] == "standalone":
868
+ measured = target
869
+ while not measured.exists():
870
+ require(measured.parent != measured, f"no existing ancestor of {target} to measure free space")
871
+ measured = measured.parent
872
+ usage = shutil.disk_usage(measured)
873
+ floor = max(10 * 1024**3, (usage.total + 49) // 50)
874
+ require(usage.free >= floor,
875
+ f"BLOCKED:disk {usage.free} bytes ({usage.free / 1024**3:.2f} GiB) free is below the "
876
+ f"{floor} byte ({floor / 1024**3:.2f} GiB) floor on {measured}; free space "
877
+ "(for example, run Devlyn disk cleanup or remove finished task worktrees' build output), then allocate again")
355
878
  receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
356
- directory.mkdir(parents=True, exist_ok=False)
357
879
  receipt["id"] = key
358
880
  receipt["recovery_ref"] = "refs/devlyn/completed/" + key
359
- scratch = directory / "scratch"
360
- scratch.mkdir()
361
- receipt["scratch_identity"] = workspace_identity(scratch, directory)
362
- # Persist prospective intent BEFORE native creation. An interrupted allocation
363
- # stays visibly incomplete; a later call may not adopt whatever now exists.
364
- atomic_json(path, receipt)
881
+ if decision["mode"] == "managed":
882
+ receipt['mode'] = 'managed'
883
+ operation = storage_call("operation_id")
884
+ receipt['recovery_ref'] += '.' + operation
885
+ receipt.update(allocation="managed_pending", storage={"decision": decision, "request": {
886
+ "adapter_protocol": 1, "operation_id": operation, "control_directory": decision["registration"]["control_directory"],
887
+ "repository": str(work), "baseline": receipt["baseline"], "branch": args.branch,
888
+ "owner": {"repository": storage_call("persistent_identity", work), "allocation_id": operation, "receipt": str(path)},
889
+ "external_receipt": str(path), "destination": {"state": "absent", "path": str(target)},
890
+ "cache_policy": "cargo_worktree_v1"}})
891
+ if decision["mode"] == "managed":
892
+ directory.parent.mkdir(parents=True, exist_ok=True)
893
+ allocation_lock = (shared("platform-support")["file_lock"](directory.with_suffix('.lock'), blocking=True)
894
+ if decision["mode"] == "managed" else contextlib.nullcontext())
895
+ with allocation_lock:
896
+ directory.mkdir(parents=True, exist_ok=False)
897
+ scratch = directory / "scratch"
898
+ scratch.mkdir()
899
+ receipt["scratch_identity"] = workspace_identity(scratch, directory)
900
+ # Persist prospective intent BEFORE checkout, under the receipt lock.
901
+ atomic_json(path, receipt)
902
+ if decision["mode"] == "managed":
903
+ finish_managed_allocation(receipt, path)
904
+ return allocation_result(receipt, path, reconciled, warnings)
365
905
  for root in worktree_roots(receipt)[1:]:
366
906
  try:
367
907
  prune_empty_parents(receipt, root, root, include_root=True)
@@ -500,7 +1040,7 @@ def queue_commit(receipt, work, queue, source):
500
1040
 
501
1041
  def accept(args):
502
1042
  """Bind a result's source and evidence custody before terminal metadata or any delivery decision."""
503
- path = Path(args.receipt).absolute()
1043
+ path = resolve_receipt(Path(args.receipt).absolute())
504
1044
  with locked_receipt(path) as receipt:
505
1045
  refuse_retired_pipeline(receipt, path, args.acceptance, receipt.get("local_only"), [])
506
1046
  bind_acceptance(receipt, path, args.acceptance)
@@ -510,7 +1050,7 @@ def accept(args):
510
1050
 
511
1051
  def attach(args):
512
1052
  """Attach the queue-only terminal commit to an already bound result; the recovery ref moves with it."""
513
- path = Path(args.receipt).absolute()
1053
+ path = resolve_receipt(Path(args.receipt).absolute())
514
1054
  queue = {"commit": args.commit, "file": args.file}
515
1055
  with locked_receipt(path) as receipt:
516
1056
  require(receipt.get("acceptance"), "bind the result before attaching its terminal commit")
@@ -571,12 +1111,13 @@ def remote_head(receipt, branch):
571
1111
  return rows[0].split()[0] if rows else None
572
1112
 
573
1113
 
574
- def validate_pr(receipt, pr):
1114
+ def validate_pr(receipt, pr, *, allow_closed=False):
575
1115
  owner, name = receipt["repository"].split("/")
576
1116
  require(pr["url"].lower() == f"https://github.com/{receipt['repository']}/pull/{pr['number']}".lower(), "PR repository mismatch")
577
1117
  require(pr["headRefName"] == receipt["branch"] and pr["baseRefName"] == receipt["base"] and pr["headRefOid"] == receipt["publish_sha"], "PR head/base/commit changed")
578
1118
  require(not pr["isCrossRepository"] and pr["headRepository"]["name"].lower() == name.lower() and pr["headRepositoryOwner"]["login"].lower() == owner.lower(), "PR head repository differs")
579
- require(pr["state"] in {"OPEN", "MERGED"}, "PR is closed without merge; retain task")
1119
+ require(pr["state"] in ({"OPEN", "MERGED", "CLOSED"} if allow_closed else {"OPEN", "MERGED"}),
1120
+ "PR is closed without merge; retain task")
580
1121
 
581
1122
 
582
1123
  def workspace_identity(work, gitdir):
@@ -591,7 +1132,7 @@ def same_workspace_identity(current, recorded):
591
1132
  return shape(current) == shape(recorded)
592
1133
 
593
1134
 
594
- def inspect_workspace(receipt):
1135
+ def inspect_workspace(receipt, *, clean=True):
595
1136
  work = Path(receipt["worktree"])
596
1137
  row = registrations(receipt).get(str(work))
597
1138
  require(row and "locked" not in row and "prunable" not in row and "detached" not in row, "worktree missing, detached or locked; retain and inspect registration")
@@ -600,7 +1141,31 @@ def inspect_workspace(receipt):
600
1141
  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")
601
1142
  require(same_workspace_identity(workspace_identity(work, Path(receipt["worktree_gitdir"])), receipt["worktree_identity"]), "worktree/registration was replaced; receipt no longer owns it")
602
1143
  require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
603
- require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
1144
+ if clean:
1145
+ require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
1146
+
1147
+
1148
+ def normalize_failed_checkout(receipt):
1149
+ """The drain attaches its terminal commit without checking it out on FAILED."""
1150
+ work = Path(receipt['worktree'])
1151
+ if (receipt.get('delivery') != 'FAILED' or not receipt.get('queue') or not path_present(work)
1152
+ or receipt.get('storage', {}).get('tree_removal_digest')
1153
+ or not git(work, 'status', '--porcelain', '--untracked-files=all')):
1154
+ return
1155
+ inspect_workspace(receipt, clean=False)
1156
+ source, publish = receipt['source_sha'], receipt['publish_sha']
1157
+ require(receipt['queue']['commit'] == publish
1158
+ and not git(work, 'diff', '--cached', '--name-only', source, '--')
1159
+ and not git(work, 'diff', '--name-only', '--')
1160
+ and not git(work, 'ls-files', '--others', '--exclude-standard'),
1161
+ 'dirty FAILED checkout differs from the exact drain source; retain')
1162
+ git(work, 'read-tree', '-m', '-u', source, publish)
1163
+
1164
+
1165
+ def manual_removal(receipt, path, reason):
1166
+ return (str(reason) + '; stop all writers and remove the task checkout by hand: ' + receipt['worktree']
1167
+ + '; keep the task branch and receipt, then resume '
1168
+ + receipt_resume('recover-storage', path, receipt, flags=['--for-removal']))
604
1169
 
605
1170
 
606
1171
  def outside(work):
@@ -615,7 +1180,8 @@ def removable(receipt, work):
615
1180
  # native removal would delete it with its uncommitted work.
616
1181
  nested = [p for p in git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0") if p.endswith("/")]
617
1182
  require(not nested, "nested repository or worktree inside the task tree: " + ", ".join(nested) + "; retain")
618
- stopped_writers(work)
1183
+ if not storage_call('managed_receipt', receipt):
1184
+ stopped_writers(work)
619
1185
 
620
1186
 
621
1187
  def linux_process_status(process):
@@ -733,6 +1299,8 @@ def records_manifest(root):
733
1299
 
734
1300
  def clean_scratch(receipt, path, writers_stopped):
735
1301
  """Empty the owned build directory; keep its identity across resume/reuse."""
1302
+ if path.parent.parent.name == 'retired' and receipt.get('scratch_cleanup', {}).get('status') == 'CLEAN':
1303
+ return receipt['scratch_cleanup']
736
1304
  scratch = path.parent / "scratch"
737
1305
  if "scratch_identity" not in receipt:
738
1306
  return {"status": "NOT_OWNED"}
@@ -743,6 +1311,10 @@ def clean_scratch(receipt, path, writers_stopped):
743
1311
  require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
744
1312
  require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
745
1313
  stopped_writers(scratch)
1314
+ if storage_call("managed_receipt", receipt):
1315
+ closure = receipt.get("storage", {}).get("result", {}).get("closure_result")
1316
+ storage_call("validate_closure", closure, adapter=True)
1317
+ require(closure["established_by"] != "legacy_unreserved", "scratch cleanup lacks positive closure")
746
1318
  size = 0
747
1319
  for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
748
1320
  require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
@@ -768,35 +1340,40 @@ def clean_scratch(receipt, path, writers_stopped):
768
1340
 
769
1341
 
770
1342
  def clean_scratch_command(args):
771
- path = Path(args.receipt).absolute()
772
- with locked_receipt(path) as receipt:
1343
+ path = resolve_receipt(Path(args.receipt).absolute(), operation_id=getattr(args, 'operation_id', None))
1344
+ with locked_receipt(path, operation_id=getattr(args, 'operation_id', None), resume=True) as receipt:
1345
+ args.operation_id = receipt.get('storage', {}).get('request', {}).get('operation_id')
773
1346
  result = clean_scratch(receipt, path, args.writers_stopped)
1347
+ path = retire_terminal(receipt, path)
774
1348
  return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
775
1349
  "receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
776
1350
 
777
1351
 
778
- def cleanup(receipt, path, pr, *, warnings=None):
779
- warnings = [] if warnings is None else warnings
780
- if pr is not None:
781
- require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
1352
+ def verify_custody(receipt, path):
1353
+ require(ref_sha(receipt, receipt['recovery_ref']) == receipt['publish_sha'],
1354
+ 'recovery reachability changed; retain resources')
1355
+ verify_files(path.parent / 'custody', receipt['files'])
1356
+ require(read_json(path.parent / 'manifest.json') == receipt['files'], 'custody manifest changed')
1357
+
1358
+
1359
+ def cleanup_workspace(receipt, path, *, warnings):
782
1360
  sha = receipt["publish_sha"]
783
1361
  branch_ref = "refs/heads/" + receipt["branch"]
784
- # The recovery ref keeps the accepted commit reachable, so squash and
785
- # rebase merges need only the merge commit on base.
786
- require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
787
- verify_files(path.parent / "custody", receipt["files"])
788
- require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
789
- gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
790
- gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
791
1362
  work = Path(receipt["worktree"])
792
1363
  local = ref_sha(receipt, branch_ref)
793
1364
  require(local in {None, sha}, "local task ref changed; retain")
794
- if receipt["linked"]:
795
- if work.exists():
1365
+ removal_finished = receipt.get('storage', {}).get('removal_finished', False)
1366
+ if removal_finished:
1367
+ require(not path_present(work), 'finished managed removal path reappeared; retain')
1368
+ if receipt["linked"] and not removal_finished:
1369
+ present = path_present(work) if storage_call("managed_receipt", receipt) else work.exists()
1370
+ if present:
796
1371
  require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
797
1372
  require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
798
- removable(receipt, work)
799
- records = records_manifest(work)
1373
+ partial = storage_call("managed_receipt", receipt) and "tree_removal_digest" in receipt["storage"]
1374
+ if not partial:
1375
+ removable(receipt, work)
1376
+ records = {} if partial else records_manifest(work)
800
1377
  if records:
801
1378
  destination = path.parent / "records"
802
1379
  if os.path.lexists(destination) and records_manifest(destination) != records:
@@ -808,15 +1385,52 @@ def cleanup(receipt, path, pr, *, warnings=None):
808
1385
  require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
809
1386
  Path(temporary).rename(destination)
810
1387
  require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
811
- removable(receipt, work)
812
- require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
813
- gref(receipt, "worktree", "remove", str(work))
1388
+ if storage_call("managed_receipt", receipt):
1389
+ receipt["storage"]["records_custody"] = {"path": str(destination), "files": records}
1390
+ atomic_json(path, receipt)
1391
+ if not partial:
1392
+ removable(receipt, work)
1393
+ require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
1394
+ if storage_call("managed_receipt", receipt):
1395
+ remove_managed_tree(receipt, path, registration=managed_registration(receipt, recovery=True))
1396
+ if str(work) in registrations(receipt):
1397
+ remove_managed_registration(receipt)
1398
+ sync_surviving_directory(Path(receipt["common_gitdir"]) / "worktrees")
1399
+ else:
1400
+ gref(receipt, "worktree", "remove", str(work))
814
1401
  else:
815
- require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
1402
+ if storage_call("managed_receipt", receipt):
1403
+ managed_removal_gate(receipt, path, registration=managed_registration(receipt, recovery=True))
1404
+ if str(work) in registrations(receipt):
1405
+ remove_managed_registration(receipt)
1406
+ sync_surviving_directory(work.parent)
1407
+ sync_surviving_directory(Path(receipt["common_gitdir"]) / "worktrees")
1408
+ else:
1409
+ require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
1410
+ if storage_call("managed_receipt", receipt):
1411
+ finish_managed_removal(receipt, path, managed_registration(receipt, recovery=True))
816
1412
  try:
817
1413
  prune_named_parents(receipt)
818
1414
  except (CompletionError, OSError) as exc:
819
1415
  emit_warning(warnings, f"parent cleanup retained: {work.parent}: {exc}; continuing")
1416
+ if storage_call('managed_receipt', receipt) and receipt.get('storage', {}).get('removal_finished'):
1417
+ if str(work) in registrations(receipt):
1418
+ remove_managed_registration(receipt)
1419
+ sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
1420
+
1421
+
1422
+ def cleanup(receipt, path, pr, *, warnings=None):
1423
+ warnings = [] if warnings is None else warnings
1424
+ if pr is not None:
1425
+ require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
1426
+ sha = receipt["publish_sha"]
1427
+ branch_ref = "refs/heads/" + receipt["branch"]
1428
+ # The recovery ref keeps the accepted commit reachable, so squash and
1429
+ # rebase merges need only the merge commit on base.
1430
+ verify_custody(receipt, path)
1431
+ gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
1432
+ gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
1433
+ cleanup_workspace(receipt, path, warnings=warnings)
820
1434
  checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
821
1435
  require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
822
1436
  # Check ownership/ref identity once more immediately before each deletion.
@@ -836,19 +1450,69 @@ def cleanup(receipt, path, pr, *, warnings=None):
836
1450
  atomic_json(path, receipt)
837
1451
 
838
1452
 
1453
+ def resolve_receipt(path, *, operation_id=None):
1454
+ """Resolve a lost retirement reply without guessing between operations."""
1455
+ require(path.name == 'receipt.json' and path == path.resolve(), 'receipt locator must be nonsymlink and absolute')
1456
+ if path_present(path):
1457
+ if operation_id is None or read_json(path).get('storage', {}).get('request', {}).get('operation_id') == operation_id:
1458
+ return path
1459
+ require(path.parent.parent.name == 'devlyn-completion', 'receipt operation ID differs from saved resume')
1460
+ require(path.parent.parent.name == 'devlyn-completion', 'receipt locator is missing')
1461
+ candidates = []
1462
+ retired = path.parent.parent / 'retired'
1463
+ if path_present(retired):
1464
+ require(retired == retired.resolve() and retired.is_dir(), 'retired receipt directory redirected')
1465
+ for candidate in retired.glob(path.parent.name + '.*/receipt.json'):
1466
+ if operation_id is not None and candidate.parent.name != path.parent.name + '.' + operation_id:
1467
+ continue
1468
+ saved = read_json(candidate)
1469
+ operation = saved.get('storage', {}).get('request', {}).get('operation_id')
1470
+ require(candidate == candidate.resolve() and saved.get('id') == path.parent.name
1471
+ and Path(saved['common_gitdir']) / 'devlyn-completion' / saved['id'] / 'receipt.json' == path
1472
+ and saved.get('storage', {}).get('request', {}).get('external_receipt') == str(path)
1473
+ and candidate.parent.name == saved['id'] + '.' + str(operation)
1474
+ and managed_terminal(saved),
1475
+ 'retired receipt locator/operation binding changed; retain')
1476
+ if operation_id is None or operation == operation_id:
1477
+ candidates.append(candidate)
1478
+ require(len(candidates) == 1, 'receipt operation/locator missing or ambiguous; use the operation-specific retired receipt: '
1479
+ + ', '.join(map(str, candidates)))
1480
+ return candidates[0]
1481
+
1482
+
839
1483
  @contextlib.contextmanager
840
- def locked_receipt(path, *, blocking=True):
841
- require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
842
- require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
1484
+ def locked_receipt(path, *, blocking=True, operation_id=None, resume=False):
1485
+ require(path.name == "receipt.json" and (path.parent.parent.name == "devlyn-completion" or path.parent.parent.name == "retired" and path.parent.parent.parent.name == "devlyn-completion") and path == path.resolve(), "receipt must be its original external nonsymlink path")
1486
+ preview = read_json(path)
1487
+ if storage_call('managed_receipt', preview):
1488
+ root = path.parent.parent if path.parent.parent.name == 'devlyn-completion' else path.parent.parent.parent
1489
+ key = path.parent.name.split('.', 1)[0]
1490
+ lock = root / (key + '.lock')
1491
+ else:
1492
+ lock = path.parent / 'lock'
1493
+ require(not lock.is_symlink(), "receipt lock must not be a symlink")
843
1494
  with contextlib.ExitStack() as stack:
844
1495
  try:
845
- stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=blocking))
1496
+ stack.enter_context(shared("platform-support")["file_lock"](lock, blocking=blocking))
846
1497
  except (ImportError, OSError, AttributeError) as exc:
847
1498
  raise CompletionError(f"receipt lock unavailable: {path}: {exc}") from exc
848
1499
  receipt = read_json(path)
849
- require(path == Path(receipt["common_gitdir"]) / "devlyn-completion" / receipt["id"] / "receipt.json", "receipt/common Gitdir binding mismatch")
1500
+ require(receipt.get('id') == preview.get('id') and receipt.get('storage', {}).get('request', {}).get('operation_id')
1501
+ == preview.get('storage', {}).get('request', {}).get('operation_id'), 'receipt operation changed while locking')
1502
+ if resume:
1503
+ actual = receipt.get('storage', {}).get('request', {}).get('operation_id')
1504
+ require(operation_id is None or actual == operation_id, 'receipt operation ID differs from saved resume; retain both operations')
1505
+ original = Path(receipt['common_gitdir']) / 'devlyn-completion' / receipt['id'] / 'receipt.json'
1506
+ archived = original.parent.parent / 'retired' / (receipt['id'] + '.' + receipt.get('storage', {}).get('request', {}).get('operation_id', '')) / 'receipt.json'
1507
+ require(path == original or path == archived, 'receipt/common Gitdir binding mismatch')
850
1508
  require(receipt["id"] == hashlib.sha256(receipt["branch"].encode()).hexdigest()[:24] and receipt["branch"] not in {receipt["base"], "main", "master"}, "receipt branch ownership changed")
851
- require(receipt["allocation"] == "owned", "allocation was interrupted; no historical adoption is permitted")
1509
+ if storage_call("managed_receipt", receipt):
1510
+ require(receipt.get("storage", {}).get("decision", {}).get("registration") and receipt.get("storage", {}).get("request"), "Open Devlyn and repair this managed receipt binding before resuming")
1511
+ require(receipt["allocation"] in ("managed_pending", "managed_owned"), "Update Devlyn and devlyn-cli, then resume using this receipt.")
1512
+ else:
1513
+ require(receipt["allocation"] == "owned", "allocation was interrupted; no historical adoption is permitted")
1514
+ if path == archived:
1515
+ require(managed_terminal(receipt), 'retired receipt is not terminal')
852
1516
  yield receipt
853
1517
 
854
1518
 
@@ -857,21 +1521,31 @@ def completion_result(receipt, path, status, *, warnings=None):
857
1521
  receipt["delivery"] = status # Phase-independent checkpoint: the latest delivery outcome.
858
1522
  atomic_json(path, receipt)
859
1523
  released = receipt.get("writers_released", False)
860
- resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
861
- (["--writers-stopped"] if released else []))
862
1524
  scratch = {"status": "NOT_OWNED"}
863
- if "scratch_identity" in receipt:
1525
+ if path.parent.parent.name == "retired" and receipt.get("scratch_cleanup", {}).get("status") == "CLEAN":
1526
+ scratch = receipt["scratch_cleanup"]
1527
+ elif "scratch_identity" in receipt:
864
1528
  try:
865
1529
  scratch = clean_scratch(receipt, path, released)
866
1530
  except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
867
- scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
868
- sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
1531
+ scratch = {"status": "RETAINED", "reason": str(error), "resume": receipt_resume(
1532
+ 'clean-scratch', path, receipt, flags=['--writers-stopped'])}
869
1533
  receipt["scratch_cleanup"] = scratch
870
1534
  try:
871
1535
  atomic_json(path, receipt)
872
1536
  except OSError as record_error:
873
1537
  scratch["record_error"] = str(record_error)
874
- retained = scratch["status"] == "RETAINED" or "workspace_cleanup" in receipt
1538
+ retained = scratch['status'] == 'RETAINED' or 'workspace_cleanup' in receipt
1539
+ path = retire_terminal(receipt, path)
1540
+ if scratch.get('status') == 'RETAINED':
1541
+ scratch_resume = receipt_resume('clean-scratch', path, receipt, flags=['--writers-stopped'])
1542
+ if scratch.get('resume') != scratch_resume:
1543
+ scratch['resume'] = scratch_resume
1544
+ atomic_json(path, receipt)
1545
+ resume = receipt_resume('complete', path, receipt, flags=['--writers-stopped'] if released else [])
1546
+ if 'workspace_cleanup' in receipt and receipt['workspace_cleanup']['resume'] != resume:
1547
+ receipt['workspace_cleanup']['resume'] = resume
1548
+ atomic_json(path, receipt)
875
1549
  return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and retained else status,
876
1550
  "warnings": [] if warnings is None else warnings,
877
1551
  "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
@@ -880,22 +1554,40 @@ def completion_result(receipt, path, status, *, warnings=None):
880
1554
  "scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
881
1555
 
882
1556
 
1557
+ def cleanup_delivered(receipt, path, pr, *, warnings=None):
1558
+ try:
1559
+ cleanup(receipt, path, pr, warnings=warnings)
1560
+ except (CompletionError, OSError) as error:
1561
+ managed_outage = storage_call('managed_receipt', receipt)
1562
+ if not isinstance(error, (WritersUnobservable, WriterActive)) and not managed_outage:
1563
+ raise
1564
+ reason = manual_removal(receipt, path, error) if managed_outage and isinstance(error, WritersUnobservable) else str(error)
1565
+ receipt['workspace_cleanup'] = {'status': 'RETAINED', 'reason': reason, 'resume': receipt_resume('complete', path, receipt)}
1566
+ atomic_json(path, receipt)
1567
+
1568
+
883
1569
  def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
884
1570
  warnings = [] if warnings is None else warnings
885
1571
  results = []
886
- for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
1572
+ directory = common / "devlyn-completion"
1573
+ for path in sorted([*directory.glob("*/receipt.json"), *directory.glob("retired/*/receipt.json")]):
887
1574
  if path == allocated or path in exclude_receipts:
888
1575
  continue
889
1576
  try:
890
1577
  receipt = read_json(path)
891
- if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
1578
+ retired = path.parent.parent.name == 'retired'
1579
+ if retired and receipt.get('delivery') != 'COMPLETE':
1580
+ continue
1581
+ if receipt.get("allocation") not in ("owned", "managed_owned") or not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
892
1582
  continue
893
- if receipt.get("status") == "COMPLETE" and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
1583
+ if (retired or receipt.get("status") == "COMPLETE") and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
894
1584
  continue
895
1585
  with locked_receipt(path, blocking=False) as receipt:
1586
+ if retired and receipt.get('delivery') != 'COMPLETE':
1587
+ continue
896
1588
  if not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
897
1589
  continue
898
- if receipt.get("status") == "COMPLETE":
1590
+ if retired or receipt.get("status") == "COMPLETE":
899
1591
  if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
900
1592
  results.append(completion_result(receipt, path, "COMPLETE"))
901
1593
  continue
@@ -911,7 +1603,10 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
911
1603
  if pr:
912
1604
  receipt["merge"] = pr
913
1605
  atomic_json(path, receipt)
914
- cleanup(receipt, path, pr, warnings=warnings)
1606
+ if storage_call("managed_receipt", receipt):
1607
+ cleanup_delivered(receipt, path, pr, warnings=warnings)
1608
+ else:
1609
+ cleanup(receipt, path, pr, warnings=warnings)
915
1610
  results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
916
1611
  except (Exception, SystemExit) as error:
917
1612
  results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
@@ -920,18 +1615,26 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
920
1615
 
921
1616
  def complete(args, *, warnings=None):
922
1617
  warnings = [] if warnings is None else warnings
923
- path = Path(args.receipt).absolute()
924
- with locked_receipt(path) as receipt:
925
- local = args.local_only or receipt.get("local_only")
926
- refuse_retired_pipeline(receipt, path, args.acceptance, local, [*(["--mode", args.mode] if args.mode and not local else []),
927
- *(["--writers-stopped"] if args.writers_stopped else [])])
1618
+ path = resolve_receipt(Path(args.receipt).absolute(), operation_id=getattr(args, "operation_id", None))
1619
+ with locked_receipt(path, operation_id=getattr(args, "operation_id", None), resume=True) as receipt:
1620
+ args.operation_id = receipt.get("storage", {}).get("request", {}).get("operation_id")
1621
+ retired = path.parent.parent.name == 'retired' and 'workspace_cleanup' not in receipt
1622
+ if not retired:
1623
+ local = args.local_only or receipt.get("local_only")
1624
+ refuse_retired_pipeline(receipt, path, args.acceptance, local, [*(["--mode", args.mode] if args.mode and not local else []),
1625
+ *(["--writers-stopped"] if args.writers_stopped else [])])
928
1626
  if args.writers_stopped:
929
1627
  if receipt["linked"]:
930
1628
  outside(Path(receipt["worktree"]))
931
1629
  receipt["writers_released"] = True
932
1630
  atomic_json(path, receipt)
1631
+ if retired:
1632
+ require(receipt.get('delivery') in {'COMPLETE', 'LOCAL_ONLY', 'FAILED', 'CLOSED'}, 'abandoned receipt has no delivery; allocate anew')
1633
+ return completion_result(receipt, path, receipt['delivery'], warnings=warnings)
933
1634
  def result(status):
934
1635
  return completion_result(receipt, path, status, warnings=warnings)
1636
+ if receipt.get('delivery') == 'CLOSED':
1637
+ return result('CLOSED')
935
1638
  if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
936
1639
  return result("FAILED")
937
1640
  if args.local_only or receipt.get("local_only"):
@@ -1055,13 +1758,7 @@ def complete(args, *, warnings=None):
1055
1758
  if pr:
1056
1759
  receipt["merge"] = pr
1057
1760
  atomic_json(path, receipt)
1058
- try:
1059
- cleanup(receipt, path, pr, warnings=warnings)
1060
- except (WritersUnobservable, WriterActive) as error:
1061
- # Conservative retention: base containment settles delivery; workspace, task refs and custody stay, reported.
1062
- receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
1063
- sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
1064
- atomic_json(path, receipt)
1761
+ cleanup_delivered(receipt, path, pr, warnings=warnings)
1065
1762
  return result("COMPLETE")
1066
1763
 
1067
1764
 
@@ -1089,13 +1786,19 @@ def main():
1089
1786
  attachment.add_argument("--file", required=True)
1090
1787
  completion = actions.add_parser("complete")
1091
1788
  completion.add_argument("--receipt", required=True)
1789
+ completion.add_argument("--operation-id")
1092
1790
  completion.add_argument("--acceptance")
1093
1791
  completion.add_argument("--mode", choices=("auto", "pr"))
1094
1792
  completion.add_argument("--local-only", "--no-push", action="store_true")
1095
1793
  completion.add_argument("--writers-stopped", action="store_true")
1096
1794
  scratch_cleanup = actions.add_parser("clean-scratch")
1097
1795
  scratch_cleanup.add_argument("--receipt", required=True)
1796
+ scratch_cleanup.add_argument("--operation-id")
1098
1797
  scratch_cleanup.add_argument("--writers-stopped", action="store_true")
1798
+ recovery = actions.add_parser("recover-storage")
1799
+ recovery.add_argument("--receipt", required=True)
1800
+ recovery.add_argument("--operation-id")
1801
+ recovery.add_argument("--for-removal", action="store_true")
1099
1802
  args = parser.parse_args()
1100
1803
  if args.self_test:
1101
1804
  return self_test()
@@ -1103,15 +1806,24 @@ def main():
1103
1806
  parser.error("allocate, accept, attach, complete or clean-scratch is required")
1104
1807
  try:
1105
1808
  result = {"allocate": allocate, "accept": accept, "attach": attach,
1809
+ "recover-storage": recover_storage,
1106
1810
  "clean-scratch": clean_scratch_command, "complete": complete}[args.action](args)
1107
1811
  print(json.dumps(result, sort_keys=True))
1108
1812
  return 0
1109
1813
  except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
1110
1814
  result = {"status": "BLOCKED", "reason": str(exc)}
1111
- if args.action != "allocate":
1815
+ if hasattr(exc, "storage_error"):
1816
+ result["storage_error"] = exc.storage_error
1817
+ if exc.storage_error.get("receipt"):
1818
+ result.update(receipt=exc.storage_error["receipt"], resume=exc.storage_error["resume"])
1819
+ if args.action != "allocate" and not result.get("receipt"):
1112
1820
  result["receipt"] = str(Path(args.receipt).absolute())
1113
- if args.action in {"complete", "clean-scratch"}:
1114
- result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
1821
+ if args.action == "recover-storage" and not result.get('resume'):
1822
+ result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action,
1823
+ '--receipt', result['receipt'], *(['--operation-id', args.operation_id] if args.operation_id else []),
1824
+ *(['--for-removal'] if args.for_removal else [])])
1825
+ if args.action in {"complete", "clean-scratch"} and not result.get("resume"):
1826
+ result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--operation-id", args.operation_id] if getattr(args, "operation_id", None) else []), *(["--writers-stopped"] if args.writers_stopped else [])])
1115
1827
  print(json.dumps(result, sort_keys=True))
1116
1828
  return 1
1117
1829
 
@@ -1249,11 +1961,42 @@ sys.exit(r.returncode)
1249
1961
 
1250
1962
 
1251
1963
  class CompletionTests(unittest.TestCase):
1964
+ def test_standalone_without_devlyn_keeps_existing_allocation_and_warm_behavior(self):
1965
+ result = self.allocate()
1966
+ receipt = read_json(self.receipt)
1967
+ self.assertEqual((result["status"], receipt["allocation"], result["build_dirs"]), ("ALLOCATED", "owned", []))
1968
+ self.assertNotIn("storage", receipt)
1969
+ self.assertFalse((Path(self.env["HOME"]) / ".devlyn").exists())
1970
+ self.assertEqual(self.g("rev-parse", "HEAD", work=self.task), receipt["baseline"])
1971
+
1972
+ def test_managed_receipt_states_refuse_before_any_cleanup(self):
1973
+ from unittest.mock import patch
1974
+ common = self.root / "common"
1975
+ branch = "task/managed"
1976
+ identity = hashlib.sha256(branch.encode()).hexdigest()[:24]
1977
+ path = common / "devlyn-completion" / identity / "receipt.json"
1978
+ path.parent.mkdir(parents=True)
1979
+ for state in ("managed_pending", "managed_owned"):
1980
+ with self.subTest(state=state):
1981
+ atomic_json(path, {"id": identity, "branch": branch, "base": "main",
1982
+ "common_gitdir": str(common), "allocation": state})
1983
+ before = path.read_bytes()
1984
+ with patch.dict(globals(), cleanup=lambda *args: self.fail("managed cleanup ran")):
1985
+ with self.assertRaisesRegex(CompletionError, "Open Devlyn"):
1986
+ with locked_receipt(path):
1987
+ self.fail("managed receipt reached legacy operations")
1988
+ self.assertEqual(path.read_bytes(), before)
1989
+
1252
1990
  def setUp(self):
1253
1991
  from unittest.mock import Mock, patch
1254
1992
  self.temp = tempfile.TemporaryDirectory(prefix="devlyn-completion-")
1255
1993
  self.addCleanup(self.temp.cleanup)
1256
1994
  self.root = Path(self.temp.name).resolve()
1995
+ test_home = self.root / "home"
1996
+ test_home.mkdir()
1997
+ home_env = patch.dict(os.environ, {"HOME": str(test_home), "USERPROFILE": str(test_home)})
1998
+ home_env.start()
1999
+ self.addCleanup(home_env.stop)
1257
2000
  (self.root / "sitecustomize.py").write_text(
1258
2001
  "import importlib.machinery, importlib.util, pathlib, sys\n"
1259
2002
  "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"
@@ -2269,6 +3012,62 @@ ctypes.CDLL(None).pthread_exit(None)
2269
3012
  d = read_json(self.data)
2270
3013
  self.assertEqual((d["pushs"], d.get("creates", 0), d["deletes"]), (1, 0, 1))
2271
3014
 
3015
+ def test_managed_local_only_scratch_cleanup_requires_closure_without_removal(self):
3016
+ from unittest.mock import patch
3017
+ self.allocate(); self.accept(unchanged=True)
3018
+ self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
3019
+ receipt = read_json(self.receipt)
3020
+ receipt.update(mode='managed', allocation='managed_owned', storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {
3021
+ 'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3022
+ atomic_json(self.receipt, receipt)
3023
+ (self.receipt.parent / 'scratch/cache').write_text('scratch')
3024
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=True, writers_stopped=True)
3025
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {},
3026
+ managed_removal_gate=lambda *args: self.fail('scratch began checkout removal'),
3027
+ stopped_writers=lambda work: None, scratch_mounts=lambda: []):
3028
+ result = complete(args)
3029
+ self.assertEqual((result['status'], result['scratch_cleanup']['status']), ('LOCAL_ONLY', 'CLEAN'))
3030
+ self.assertNotIn('removal_request', read_json(self.receipt)['storage'])
3031
+ self.assertTrue(self.task.exists())
3032
+
3033
+ def test_managed_storage_outage_after_delivery_is_cleanup_pending(self):
3034
+ from unittest.mock import patch
3035
+ self.allocate(); self.accept(unchanged=True)
3036
+ self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
3037
+ receipt = read_json(self.receipt)
3038
+ receipt.update(mode='managed', allocation='managed_owned', storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {
3039
+ 'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3040
+ atomic_json(self.receipt, receipt)
3041
+ def outage(*args, **kwargs):
3042
+ error = CompletionError('authority offline')
3043
+ error.storage_error = {'code': 'storage_unavailable'}
3044
+ raise error
3045
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3046
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3047
+ result = complete(args)
3048
+ self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3049
+ self.assertIn('complete --receipt', result['resume'])
3050
+ self.assertIn('authority offline', result['workspace_cleanup']['reason'])
3051
+ self.assertTrue(self.task.exists())
3052
+
3053
+ def test_reconcile_managed_storage_outage_preserves_complete_delivery(self):
3054
+ from unittest.mock import patch
3055
+ self.allocate(); self.accept(unchanged=True)
3056
+ self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
3057
+ receipt = read_json(self.receipt)
3058
+ receipt.update(mode='managed', allocation='managed_owned', delivery='COMPLETE', writers_released=True,
3059
+ storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3060
+ atomic_json(self.receipt, receipt)
3061
+ def outage(*args, **kwargs):
3062
+ error = CompletionError('authority offline during reconciliation')
3063
+ error.storage_error = {'code': 'storage_unavailable'}
3064
+ raise error
3065
+ with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3066
+ [result] = reconcile(Path(receipt['common_gitdir']), self.receipt.with_name('other.json'), self.work)
3067
+ self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3068
+ self.assertIn('complete --receipt', result['resume'])
3069
+ self.assertTrue(self.task.exists())
3070
+
2272
3071
  def test_unchanged_writer_retention_resumes_to_complete(self):
2273
3072
  # Prediction: either writer refusal settles delivery but retains the tree/ref; resume cleans without push/PR.
2274
3073
  from unittest.mock import Mock, patch
@@ -2588,6 +3387,21 @@ ctypes.CDLL(None).pthread_exit(None)
2588
3387
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
2589
3388
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
2590
3389
 
3390
+ def test_standalone_cleanup_checks_writers_before_reading_records(self):
3391
+ from unittest.mock import patch
3392
+ self.allocate(); self.accept()
3393
+ receipt = read_json(self.receipt)
3394
+ with patch.dict(os.environ, self.env):
3395
+ bind_acceptance(receipt, self.receipt, str(self.acceptance))
3396
+ receipt['writers_released'] = True
3397
+ with patch.dict(os.environ, self.env), patch.dict(cleanup_workspace.__globals__,
3398
+ stopped_writers=unittest.mock.Mock(side_effect=WritersUnobservable('retain workspace')),
3399
+ records_manifest=unittest.mock.Mock(side_effect=AssertionError('records read before writer check'))), \
3400
+ self.assertRaisesRegex(WritersUnobservable, 'retain workspace'):
3401
+ cleanup_workspace(receipt, self.receipt, warnings=[])
3402
+ self.assertTrue(self.task.exists())
3403
+ self.assertFalse((self.receipt.parent / 'records').exists())
3404
+
2591
3405
  def test_release_is_refused_from_inside_the_task_tree(self):
2592
3406
  self.allocate(); self.accept()
2593
3407
  _, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
@@ -2831,17 +3645,15 @@ ctypes.CDLL(None).pthread_exit(None)
2831
3645
  child.terminate(); child.wait(timeout=5)
2832
3646
  child.stdout.close()
2833
3647
 
2834
- def test_reconcile_retains_malformed_receipt_without_failing_allocation(self):
3648
+ def test_invalid_receipt_evidence_blocks_new_allocation(self):
2835
3649
  self.allocate()
3650
+ (Path(self.env["HOME"]) / ".devlyn/storage-client-recovery").mkdir(parents=True)
2836
3651
  receipt, task = self.receipt, self.task
2837
3652
  receipt.write_text("[]", encoding="utf-8")
2838
- result = self.allocate("next")
2839
- self.assertEqual(result["status"], "ALLOCATED")
2840
- self.assertTrue(self.task.exists())
2841
- self.assertEqual(len(result["reconciled"]), 1)
2842
- self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
2843
- self.assertEqual(result["reconciled"][0]["receipt"], str(receipt))
2844
- self.assertTrue(result["reconciled"][0]["reason"])
3653
+ result, _ = self.cli("allocate", "--repo", self.work, "--task", "next", "--branch", "task/next",
3654
+ "--worktree", self.root / "next", success=False)
3655
+ self.assertEqual(result["status"], "BLOCKED")
3656
+ self.assertFalse((self.root / "next").exists())
2845
3657
  self.assertEqual(receipt.read_text(), "[]")
2846
3658
  self.assertTrue(task.exists())
2847
3659
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")