devlyn-cli 4.3.21 → 4.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/bin/devlyn.js +26 -1
- package/bin/harness-activation.js +11 -13
- package/bin/instruction-templates.json +26 -0
- package/bin/instructions.js +1 -1
- package/bin/skill-history.json +15 -1
- package/config/skills/_shared/bring-in.py +810 -0
- package/config/skills/_shared/harness-store.py +18 -6
- package/config/skills/_shared/runtime-principles.md +2 -2
- package/config/skills/_shared/storage-checkout.py +117 -0
- package/config/skills/_shared/storage-client.py +1180 -0
- package/config/skills/_shared/task-complete.py +900 -88
- package/config/skills/_shared/task-completion.md +78 -6
- package/config/skills/_shared/warm-build-dirs.py +53 -3
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/loop.md +4 -4
- package/config/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/config/storage-client-contract.json +7 -0
- package/harness/AGENTS.md +2 -2
- package/harness/CLAUDE.md +2 -2
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/bring-in.py +810 -0
- package/native/claude/skills/_shared/harness-store.py +18 -6
- package/native/claude/skills/_shared/runtime-principles.md +2 -2
- package/native/claude/skills/_shared/storage-checkout.py +117 -0
- package/native/claude/skills/_shared/storage-client.py +1180 -0
- package/native/claude/skills/_shared/task-complete.py +900 -88
- package/native/claude/skills/_shared/task-completion.md +78 -6
- package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
- package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/claude/skills/devlyn-ideate/references/loop.md +4 -4
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
- package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +4 -4
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +70 -19
- package/optional-skills/better-auth-setup/SKILL.md +9 -3
- package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
- package/package.json +1 -1
- package/release.json +93 -43
|
@@ -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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"](
|
|
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(
|
|
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
|
-
|
|
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 "
|
|
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":
|
|
868
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1114
|
-
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action,
|
|
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
|
|
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.
|
|
2839
|
-
|
|
2840
|
-
self.
|
|
2841
|
-
self.
|
|
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"), "")
|