devlyn-cli 4.3.30 → 4.3.32

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 (43) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +3 -1
  4. package/bin/devlyn.js +33 -8
  5. package/bin/harness-activation.js +4 -1
  6. package/bin/harness-status.js +15 -8
  7. package/bin/instruction-templates.json +2 -0
  8. package/bin/skill-history.json +12 -1
  9. package/config/skills/_shared/bring-in.py +10 -0
  10. package/config/skills/_shared/runtime-principles.md +1 -1
  11. package/config/skills/_shared/task-complete.py +319 -54
  12. package/config/skills/_shared/task-completion.md +5 -5
  13. package/config/skills/_shared/warm-build-dirs.py +13 -0
  14. package/config/skills/devlyn-ideate/references/executor.md +1 -1
  15. package/config/skills/devlyn-ideate/references/loop.md +1 -1
  16. package/config/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  17. package/config/skills/devlyn-ideate/scripts/queue.py +17 -1
  18. package/harness/AGENTS.md +2 -2
  19. package/harness/CLAUDE.md +2 -2
  20. package/native/claude/.claude-plugin/plugin.json +1 -1
  21. package/native/claude/skills/_shared/bring-in.py +10 -0
  22. package/native/claude/skills/_shared/runtime-principles.md +1 -1
  23. package/native/claude/skills/_shared/task-complete.py +319 -54
  24. package/native/claude/skills/_shared/task-completion.md +5 -5
  25. package/native/claude/skills/_shared/warm-build-dirs.py +13 -0
  26. package/native/claude/skills/devlyn-ideate/references/executor.md +1 -1
  27. package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
  28. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  29. package/native/claude/skills/devlyn-ideate/scripts/queue.py +17 -1
  30. package/native/codex/.agents/plugins/marketplace.json +1 -1
  31. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  32. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +10 -0
  33. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +1 -1
  34. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +319 -54
  35. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +5 -5
  36. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +13 -0
  37. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/executor.md +1 -1
  38. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
  39. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +11 -0
  40. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +17 -1
  41. package/optional-skills/code-review-standards/SKILL.md +10 -6
  42. package/package.json +1 -1
  43. package/release.json +45 -45
@@ -1480,17 +1480,24 @@ def cleanup_workspace(receipt, path, *, warnings):
1480
1480
  sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
1481
1481
 
1482
1482
 
1483
- def cleanup(receipt, path, pr, *, warnings=None):
1484
- warnings = [] if warnings is None else warnings
1483
+ def verify_delivery(receipt, path, pr):
1484
+ """Evidence that the accepted source is delivered, checked before anything is removed."""
1485
1485
  if pr is not None:
1486
1486
  require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
1487
1487
  sha = receipt["publish_sha"]
1488
- branch_ref = "refs/heads/" + receipt["branch"]
1489
1488
  # The recovery ref keeps the accepted commit reachable, so squash and
1490
1489
  # rebase merges need only the merge commit on base.
1491
1490
  verify_custody(receipt, path)
1492
1491
  gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
1493
1492
  gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
1493
+ # The base was fetched through the remote's name, so that name must still bind the receipt's repository.
1494
+ require(remote_url(receipt) == receipt["remote_url"], "remote changed during the delivery checks; retain resources")
1495
+
1496
+
1497
+ def remove_delivered(receipt, path, *, warnings=None):
1498
+ warnings = [] if warnings is None else warnings
1499
+ sha = receipt["publish_sha"]
1500
+ branch_ref = "refs/heads/" + receipt["branch"]
1494
1501
  cleanup_workspace(receipt, path, warnings=warnings)
1495
1502
  checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
1496
1503
  require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
@@ -1511,6 +1518,11 @@ def cleanup(receipt, path, pr, *, warnings=None):
1511
1518
  atomic_json(path, receipt)
1512
1519
 
1513
1520
 
1521
+ def cleanup(receipt, path, pr, *, warnings=None):
1522
+ verify_delivery(receipt, path, pr)
1523
+ remove_delivered(receipt, path, warnings=warnings)
1524
+
1525
+
1514
1526
  def resolve_receipt(path, *, operation_id=None):
1515
1527
  """Resolve a lost retirement reply without guessing between operations."""
1516
1528
  require(path.name == 'receipt.json' and path == path.resolve(), 'receipt locator must be nonsymlink and absolute')
@@ -1630,13 +1642,17 @@ def completion_result(receipt, path, status, *, warnings=None):
1630
1642
 
1631
1643
 
1632
1644
  def cleanup_delivered(receipt, path, pr, *, warnings=None):
1645
+ # Missing evidence leaves delivery unrecorded; once it holds, a refused removal retains what it guards.
1646
+ verify_delivery(receipt, path, pr)
1633
1647
  try:
1634
- cleanup(receipt, path, pr, warnings=warnings)
1648
+ remove_delivered(receipt, path, warnings=warnings)
1635
1649
  except (CompletionError, OSError) as error:
1636
- managed_outage = storage_call('managed_receipt', receipt)
1637
- if not isinstance(error, (WritersUnobservable, WriterActive)) and not managed_outage:
1650
+ if receipt.get('status') == 'COMPLETE':
1651
+ # Every workspace and ref removal finished and only the final record write failed: the resume ends COMPLETE,
1652
+ # or CLEANUP_PENDING while a scratch cleanup retained on its own remains.
1638
1653
  raise
1639
- reason = manual_removal(receipt, path, error) if managed_outage and isinstance(error, WritersUnobservable) else str(error)
1654
+ managed = storage_call('managed_receipt', receipt)
1655
+ reason = manual_removal(receipt, path, error) if managed and isinstance(error, WritersUnobservable) else str(error)
1640
1656
  receipt['workspace_cleanup'] = {'status': 'RETAINED', 'reason': reason, 'resume': receipt_resume('complete', path, receipt)}
1641
1657
  atomic_json(path, receipt)
1642
1658
 
@@ -1681,7 +1697,14 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
1681
1697
  if storage_call("managed_receipt", receipt):
1682
1698
  cleanup_delivered(receipt, path, pr, warnings=warnings)
1683
1699
  else:
1684
- cleanup(receipt, path, pr, warnings=warnings)
1700
+ try:
1701
+ cleanup(receipt, path, pr, warnings=warnings)
1702
+ except (CompletionError, OSError) as error:
1703
+ if "workspace_cleanup" in receipt:
1704
+ # A retained cleanup names its latest refusal, which may guard less than the first.
1705
+ receipt["workspace_cleanup"]["reason"] = str(error)
1706
+ atomic_json(path, receipt)
1707
+ raise
1685
1708
  results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
1686
1709
  except (Exception, SystemExit) as error:
1687
1710
  results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
@@ -1794,7 +1817,7 @@ def complete(args, *, warnings=None):
1794
1817
  inspect_workspace(receipt)
1795
1818
  verify_files(Path(receipt["worktree"]), receipt["files"])
1796
1819
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
1797
- refused = None
1820
+ refused = waits = None
1798
1821
  if not pr.get("autoMergeRequest"):
1799
1822
  if not info["mergeCommitAllowed"]:
1800
1823
  refused = "repository disallows merge commits"
@@ -1818,9 +1841,11 @@ def complete(args, *, warnings=None):
1818
1841
  break
1819
1842
  if attempt < 9:
1820
1843
  time.sleep(2)
1821
- if state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
1844
+ if state in {"BLOCKED", "UNKNOWN"}:
1845
+ waits = state
1846
+ elif state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
1822
1847
  refused = "pull request is not mergeable: " + state
1823
- if not refused:
1848
+ if not refused and not waits:
1824
1849
  try:
1825
1850
  gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
1826
1851
  except CompletionError as error:
@@ -1828,8 +1853,10 @@ def complete(args, *, warnings=None):
1828
1853
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
1829
1854
  validate_pr(receipt, pr)
1830
1855
  if pr["state"] != "MERGED":
1831
- # Merge only when the repository allows it; a refused request leaves the PR for a person.
1832
- return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
1856
+ # Only a refusal leaves the PR for a person; merge_waits: the resume merges once the PR is mergeable.
1857
+ if refused and not pr.get("autoMergeRequest"):
1858
+ return dict(result("PR"), merge_refused=refused)
1859
+ return dict(result("PENDING"), **({"merge_waits": waits} if waits else {}))
1833
1860
  if pr:
1834
1861
  receipt["merge"] = pr
1835
1862
  atomic_json(path, receipt)
@@ -2089,6 +2116,9 @@ class CompletionTests(unittest.TestCase):
2089
2116
  self.bin.mkdir()
2090
2117
  self.data = self.root / "gh.json"
2091
2118
  self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
2119
+ # A commit's detached automatic maintenance would still write into .git while a test reads the tree. A file, not
2120
+ # GIT_CONFIG_COUNT: the storage suite reuses this fixture with a native helper that refuses that variable.
2121
+ (self.root / "gitconfig").write_text("[maintenance]\n\tauto = false\n", encoding="utf-8")
2092
2122
  self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
2093
2123
  "GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
2094
2124
  "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
@@ -2144,6 +2174,14 @@ class CompletionTests(unittest.TestCase):
2144
2174
  repository="test/project", base="main", remote="origin", worktree=str(worktree),
2145
2175
  local_base=None, from_receipt=None, start=None)
2146
2176
 
2177
+ def test_fixture_commit_starts_no_automatic_maintenance(self):
2178
+ # Its detached child would still write into .git while a test reads the tree or cleanup removes it.
2179
+ trace = self.root / "trace.json"
2180
+ self.env["GIT_TRACE2_EVENT"] = str(trace)
2181
+ self.g("commit", "-q", "--allow-empty", "-m", "traced")
2182
+ children = [line for line in trace.read_text(encoding="utf-8").splitlines() if '"event":"child_start"' in line]
2183
+ self.assertEqual(children, [])
2184
+
2147
2185
  def test_allocation_marks_exclusively_created_empty_target_before_checkout(self):
2148
2186
  # Prediction: persisted intent precedes exclusive creation and marking; real Git then populates that directory.
2149
2187
  from unittest.mock import patch
@@ -3118,7 +3156,7 @@ ctypes.CDLL(None).pthread_exit(None)
3118
3156
  error.storage_error = {'code': 'storage_unavailable'}
3119
3157
  raise error
3120
3158
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3121
- with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3159
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
3122
3160
  result = complete(args)
3123
3161
  self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3124
3162
  self.assertIn('complete --receipt', result['resume'])
@@ -3133,13 +3171,17 @@ ctypes.CDLL(None).pthread_exit(None)
3133
3171
  receipt.update(mode='managed', allocation='managed_owned', delivery='COMPLETE', writers_released=True,
3134
3172
  storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3135
3173
  atomic_json(self.receipt, receipt)
3174
+ calls = []
3136
3175
  def outage(*args, **kwargs):
3176
+ calls.append(args)
3137
3177
  error = CompletionError('authority offline during reconciliation')
3138
3178
  error.storage_error = {'code': 'storage_unavailable'}
3139
3179
  raise error
3140
- with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, cleanup=outage):
3180
+ with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
3141
3181
  [result] = reconcile(Path(receipt['common_gitdir']), self.receipt.with_name('other.json'), self.work)
3182
+ self.assertEqual(len(calls), 1)
3142
3183
  self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
3184
+ self.assertEqual(result['workspace_cleanup']['reason'], 'authority offline during reconciliation')
3143
3185
  self.assertIn('complete --receipt', result['resume'])
3144
3186
  self.assertTrue(self.task.exists())
3145
3187
 
@@ -3164,6 +3206,42 @@ ctypes.CDLL(None).pthread_exit(None)
3164
3206
  d = read_json(self.data)
3165
3207
  self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
3166
3208
 
3209
+ def test_unchanged_failed_final_receipt_write_raises_and_resumes_to_complete(self):
3210
+ # Prediction: every removal finished and only the final status-COMPLETE record write fails, so complete raises
3211
+ # that error and the printed resume ends COMPLETE. Before: retained as CLEANUP_PENDING on a COMPLETE receipt,
3212
+ # which every resume reported again.
3213
+ from unittest.mock import patch
3214
+ self.allocate(); self.accept(unchanged=True)
3215
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
3216
+ real, failed = atomic_json, []
3217
+ def final_write_fails(path, value):
3218
+ real(path, value)
3219
+ if value.get("status") == "COMPLETE" and "workspace_cleanup" not in value and not failed:
3220
+ failed.append(path)
3221
+ raise OSError(5, "fixture final receipt write failure")
3222
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3223
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, atomic_json=final_write_fails):
3224
+ with self.assertRaises(OSError):
3225
+ complete(args)
3226
+ self.assertFalse(self.task.exists())
3227
+ result, _ = self.complete("--writers-stopped", acceptance=False)
3228
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3229
+
3230
+ def test_unchanged_removal_os_error_is_retained_with_delivery_complete(self):
3231
+ # Prediction: an OSError raised while the workspace is removed (the records copy) is retained like a refusal:
3232
+ # delivery COMPLETE, CLEANUP_PENDING naming the error, tree and task ref kept. Fails if only CompletionError is retained.
3233
+ from unittest.mock import patch
3234
+ self.allocate(); self.accept(unchanged=True)
3235
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
3236
+ with patch.dict(os.environ, self.env), \
3237
+ patch.object(shutil, "copytree", side_effect=OSError(errno.ENOSPC, "fixture records copy failure")):
3238
+ result = complete(args)
3239
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3240
+ self.assertIn("fixture records copy failure", result["workspace_cleanup"]["reason"])
3241
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3242
+ self.assertTrue(self.task.exists())
3243
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3244
+
3167
3245
  def test_unchanged_writer_retention_reconciles_on_allocate(self):
3168
3246
  # Prediction: the next allocation retries on-base delivery once writer observation succeeds.
3169
3247
  from unittest.mock import Mock, patch
@@ -3232,7 +3310,8 @@ ctypes.CDLL(None).pthread_exit(None)
3232
3310
  self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
3233
3311
 
3234
3312
  def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
3235
- # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard.
3313
+ # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard, which keeps both
3314
+ # task refs and leaves the merged delivery COMPLETE (CLEANUP_PENDING). Before: BLOCKED, delivery unrecorded.
3236
3315
  self.allocate(); self.accept()
3237
3316
  self.complete("--mode", "pr")
3238
3317
  self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
@@ -3240,11 +3319,113 @@ ctypes.CDLL(None).pthread_exit(None)
3240
3319
  self.g("commit", "--allow-empty", "-m", "foreign commit")
3241
3320
  foreign = self.g("rev-parse", "HEAD")
3242
3321
  self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
3243
- result, _ = self.complete("--writers-stopped", success=False)
3244
- self.assertEqual(result["status"], "BLOCKED")
3245
- self.assertIn("remote branch changed after merge; retain ref", result["reason"])
3322
+ result, _ = self.complete("--writers-stopped")
3323
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3324
+ self.assertIn("remote branch changed after merge; retain ref", result["workspace_cleanup"]["reason"])
3246
3325
  self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
3247
3326
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
3327
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3328
+
3329
+ def test_merged_task_branch_checked_out_elsewhere_keeps_both_task_refs(self):
3330
+ # Prediction: with the task branch also checked out in another worktree, the merged delivery is COMPLETE and
3331
+ # reports CLEANUP_PENDING naming that worktree; neither task ref is deleted. Once that worktree leaves the branch,
3332
+ # the printed resume deletes both. Before: BLOCKED, delivery unrecorded.
3333
+ self.allocate(); self.accept()
3334
+ self.complete("--mode", "pr", "--writers-stopped")
3335
+ self.merge_pr()
3336
+ other = self.root / "other"
3337
+ self.g("worktree", "add", "--force", str(other), "task/fixture")
3338
+ result, _ = self.complete()
3339
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3340
+ self.assertIn("task branch is still checked out at " + str(other), result["workspace_cleanup"]["reason"])
3341
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3342
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3343
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3344
+ self.g("switch", "--detach", work=other)
3345
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
3346
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3347
+ self.assertFalse(self.task.exists())
3348
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3349
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
3350
+
3351
+ def test_delivery_evidence_failure_blocks_with_delivery_unrecorded(self):
3352
+ # Prediction: evidence is checked before any removal, so a PR view that is not merge evidence, or a changed
3353
+ # custody manifest, stops completion BLOCKED with delivery left at PR and nothing removed. Unchanged on origin/main;
3354
+ # fails if the evidence checks move inside the retention boundary.
3355
+ self.allocate(); self.accept()
3356
+ self.assertEqual(self.complete("--mode", "pr", "--writers-stopped")[0]["status"], "PR")
3357
+ pr = read_json(self.data)["prs"][0]
3358
+ manifest = self.receipt.parent / "manifest.json"
3359
+ for case, reason in (("merge evidence", "actual matching merge evidence is required"),
3360
+ ("custody manifest", "custody manifest changed")):
3361
+ with self.subTest(case=case):
3362
+ if case == "merge evidence":
3363
+ self.configure(prs=[dict(pr, state="MERGED", mergedAt=None, mergeCommit=None)])
3364
+ else:
3365
+ self.configure(prs=[pr])
3366
+ self.merge_pr()
3367
+ manifest.write_text("{}\n", encoding="utf-8")
3368
+ result, r = self.complete(success=False)
3369
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
3370
+ self.assertIn(reason, result["reason"])
3371
+ receipt = read_json(self.receipt)
3372
+ self.assertEqual((receipt["delivery"], receipt.get("status")), ("PR", None))
3373
+ self.assertNotIn("workspace_cleanup", receipt)
3374
+ self.assertTrue(self.task.exists())
3375
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3376
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3377
+
3378
+ def test_managed_delivery_evidence_failure_raises_with_delivery_unrecorded(self):
3379
+ # Prediction: a managed receipt's changed custody manifest also raises before any removal, delivery unrecorded.
3380
+ # Before: retained as CLEANUP_PENDING with delivery COMPLETE.
3381
+ from unittest.mock import patch
3382
+ self.allocate(); self.accept(unchanged=True)
3383
+ self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
3384
+ receipt = read_json(self.receipt)
3385
+ receipt.update(mode='managed', allocation='managed_owned', storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {
3386
+ 'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
3387
+ atomic_json(self.receipt, receipt)
3388
+ (self.receipt.parent / 'manifest.json').write_text('{}\n', encoding='utf-8')
3389
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
3390
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}):
3391
+ with self.assertRaisesRegex(CompletionError, 'custody manifest changed'):
3392
+ complete(args)
3393
+ receipt = read_json(self.receipt)
3394
+ self.assertEqual((receipt.get('delivery'), receipt.get('status')), (None, None))
3395
+ self.assertNotIn('workspace_cleanup', receipt)
3396
+ self.assertTrue(self.task.exists())
3397
+
3398
+ def test_remote_retargeted_during_delivery_checks_blocks_with_delivery_unrecorded(self):
3399
+ # Prediction: origin is retargeted, after the first URL check, to another repository whose base holds the accepted
3400
+ # commit, so both ancestry checks pass against it; the URL recheck after them stops completion BLOCKED with delivery
3401
+ # unrecorded and nothing removed. Before: the tree was removed, the deletion-time URL refusal was retained and
3402
+ # delivery was recorded COMPLETE for the receipt's repository, whose base lacks the commit.
3403
+ from unittest.mock import patch
3404
+ self.allocate(); self.accept()
3405
+ fork = self.root / "fork.git"
3406
+ self.run_cmd(["git", "clone", "--bare", "--quiet", str(self.bare), str(fork)])
3407
+ self.g("push", str(fork), self.sha + ":refs/heads/main", work=self.task)
3408
+ bare = ["git", "--git-dir", str(self.bare)]
3409
+ base = self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip()
3410
+ real, retargeted = repo_policy, []
3411
+ def retarget(receipt, **kwargs):
3412
+ if not retargeted:
3413
+ self.g("remote", "set-url", "origin", str(fork))
3414
+ retargeted.append(str(fork))
3415
+ return real(receipt, **kwargs)
3416
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
3417
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, repo_policy=retarget):
3418
+ with self.assertRaisesRegex(CompletionError, "remote must unambiguously identify one GitHub repository"):
3419
+ complete(args)
3420
+ self.assertEqual(retargeted, [str(fork)])
3421
+ receipt = read_json(self.receipt)
3422
+ self.assertEqual((receipt.get("delivery"), receipt.get("status")), (None, None))
3423
+ self.assertNotIn("workspace_cleanup", receipt)
3424
+ self.assertTrue(self.task.exists())
3425
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3426
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
3427
+ self.assertEqual(self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip(), base)
3428
+ self.assertEqual(self.run_cmd([*bare, "for-each-ref", "refs/heads/task/"]).stdout, "")
3248
3429
 
3249
3430
  def test_unchanged_foreign_remote_head_retains_worktree(self):
3250
3431
  # Prediction: a foreign remote task head refuses completion before worktree removal.
@@ -3416,6 +3597,9 @@ ctypes.CDLL(None).pthread_exit(None)
3416
3597
 
3417
3598
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
3418
3599
  def test_nested_repository_or_worktree_retains_tree(self):
3600
+ # Prediction: the first call merges; a nested worktree, then a nested repository, under an ignored path leaves the
3601
+ # merged delivery COMPLETE (CLEANUP_PENDING) naming each, with the tree, the nested work and both task refs kept;
3602
+ # once both are gone the printed resume removes everything. Before: BLOCKED, delivery unrecorded.
3419
3603
  self.allocate(); self.accept()
3420
3604
  _, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
3421
3605
  "--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
@@ -3425,17 +3609,25 @@ ctypes.CDLL(None).pthread_exit(None)
3425
3609
  (nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
3426
3610
  clone = self.task / "ignored" / "clone"
3427
3611
  self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
3428
- for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)), lambda: shutil.rmtree(clone)):
3429
- _, r = self.complete("--mode", "auto", "--writers-stopped", success=False)
3430
- self.assertIn("nested repository or worktree", json.loads(r.stdout)["reason"])
3612
+ for name, remove in (("ignored/nested/", lambda: self.g("worktree", "remove", "--force", str(nested))),
3613
+ ("ignored/clone/", lambda: shutil.rmtree(clone))):
3614
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
3615
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3616
+ self.assertIn("nested repository or worktree inside the task tree: ", result["workspace_cleanup"]["reason"])
3617
+ self.assertIn(name, result["workspace_cleanup"]["reason"])
3618
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
3431
3619
  self.assertTrue(self.task.exists())
3432
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3620
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3621
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3433
3622
  if nested.exists():
3434
3623
  self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
3435
3624
  remove()
3436
- result, _ = self.complete("--mode", "auto", "--writers-stopped")
3437
- self.assertEqual(result["status"], "COMPLETE")
3625
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
3626
+ self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
3438
3627
  self.assertFalse(self.task.exists())
3628
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
3629
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
3630
+ self.assertEqual(read_json(self.data)["merges"], 1)
3439
3631
 
3440
3632
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
3441
3633
  def test_default_mode_merges_when_possible_and_cleans(self):
@@ -3577,6 +3769,44 @@ ctypes.CDLL(None).pthread_exit(None)
3577
3769
  self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3578
3770
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3579
3771
 
3772
+ def test_reconcile_checks_delivery_evidence_before_cleanup(self):
3773
+ # Prediction: a merged standalone receipt whose recovery ref moved gives a RETAINED reconcile row naming it, with
3774
+ # the tree and both task refs kept. Fails if reconcile's standalone cleanup skips verify_delivery.
3775
+ self.allocate(); self.accept()
3776
+ self.complete("--mode", "pr", "--writers-stopped")
3777
+ self.merge_pr()
3778
+ receipt, task = self.receipt, self.task
3779
+ saved = read_json(receipt)
3780
+ self.g("update-ref", saved["recovery_ref"], saved["baseline"])
3781
+ result = self.allocate("next")
3782
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
3783
+ self.assertIn("recovery reachability changed", result["reconciled"][0]["reason"])
3784
+ self.assertTrue(task.exists())
3785
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3786
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
3787
+
3788
+ def test_reconcile_replaces_a_retained_reason_with_its_latest_refusal(self):
3789
+ # Prediction: complete retains a merged delivery for a nested repository; once that is gone and the remote task
3790
+ # branch has moved, the next allocation's reconcile removes the tree, keeps the task ref and replaces the receipt's
3791
+ # reason with the remote-branch refusal. Before: the receipt still named the nested repository.
3792
+ self.allocate(); self.accept()
3793
+ clone = self.task / "ignored" / "clone"
3794
+ self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
3795
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
3796
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3797
+ self.assertIn("ignored/clone/", result["workspace_cleanup"]["reason"])
3798
+ shutil.rmtree(clone)
3799
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
3800
+ self.g("push", "origin", "+" + self.g("rev-parse", "HEAD") + ":refs/heads/task/fixture")
3801
+ receipt, task = self.receipt, self.task
3802
+ [row] = self.allocate("next")["reconciled"]
3803
+ self.assertEqual((row["receipt"], row["status"]), (str(receipt), "RETAINED"))
3804
+ self.assertIn("remote branch changed after merge; retain ref", row["reason"])
3805
+ self.assertFalse(task.exists())
3806
+ saved = read_json(receipt)["workspace_cleanup"]
3807
+ self.assertEqual((saved["status"], saved["reason"]), ("RETAINED", row["reason"]))
3808
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
3809
+
3580
3810
  def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
3581
3811
  self.allocate(); self.accept()
3582
3812
  self.complete("--mode", "pr", "--writers-stopped")
@@ -3741,16 +3971,17 @@ ctypes.CDLL(None).pthread_exit(None)
3741
3971
  other.write_bytes(b"owner data")
3742
3972
  link = self.task / ".devlyn/link"
3743
3973
  link.symlink_to(other)
3744
- result, _ = self.complete(success=False)
3745
- self.assertIn("path escapes custody", result["reason"])
3974
+ result, _ = self.complete()
3975
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3976
+ self.assertIn("path escapes custody", result["workspace_cleanup"]["reason"])
3746
3977
  self.assertTrue(self.task.exists())
3747
3978
  self.assertTrue(link.is_symlink())
3748
3979
  self.assertEqual(other.read_bytes(), b"owner data")
3749
3980
  link.unlink()
3750
3981
  if hasattr(os, "mkfifo"):
3751
3982
  os.mkfifo(link)
3752
- result, _ = self.complete(success=False)
3753
- self.assertIn("regular file", result["reason"])
3983
+ result, _ = self.complete()
3984
+ self.assertIn("regular file", result["workspace_cleanup"]["reason"])
3754
3985
  self.assertTrue(self.task.exists())
3755
3986
  link.unlink()
3756
3987
  saved = self.receipt.parent / "records/.devlyn"
@@ -3817,8 +4048,9 @@ ctypes.CDLL(None).pthread_exit(None)
3817
4048
  extra = generation / ".devlyn/unexpected"
3818
4049
  extra.write_bytes(b"keep this copy\n")
3819
4050
  mismatch = records_manifest(generation)
3820
- result, _ = self.complete(success=False)
3821
- self.assertIn(generation.name, result["reason"])
4051
+ result, _ = self.complete()
4052
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4053
+ self.assertIn(generation.name, result["workspace_cleanup"]["reason"])
3822
4054
  self.assertTrue(self.task.exists())
3823
4055
  self.assertEqual(records_manifest(generation), mismatch)
3824
4056
  self.assertEqual(records_manifest(saved), original)
@@ -3845,8 +4077,9 @@ ctypes.CDLL(None).pthread_exit(None)
3845
4077
  (work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
3846
4078
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3847
4079
  with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
3848
- with self.assertRaisesRegex(CompletionError, "changed after it was copied; stop its writer and resume"):
3849
- complete(args)
4080
+ result = complete(args)
4081
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4082
+ self.assertIn("changed after it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
3850
4083
  self.assertTrue(self.task.exists())
3851
4084
  self.assertEqual(records_manifest(saved), original)
3852
4085
  final = records_manifest(self.task)
@@ -3868,8 +4101,9 @@ ctypes.CDLL(None).pthread_exit(None)
3868
4101
  return result
3869
4102
  args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
3870
4103
  with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
3871
- with self.assertRaisesRegex(CompletionError, "changed while it was copied; stop its writer and resume"):
3872
- complete(args)
4104
+ result = complete(args)
4105
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4106
+ self.assertIn("changed while it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
3873
4107
  self.assertTrue(self.task.exists())
3874
4108
  self.assertFalse((self.receipt.parent / "records").exists())
3875
4109
  self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
@@ -3877,8 +4111,8 @@ ctypes.CDLL(None).pthread_exit(None)
3877
4111
  def test_linked_custody_and_interrupted_removal(self):
3878
4112
  self.allocate(); self.accept()
3879
4113
  self.configure(interrupt_remove=True)
3880
- result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
3881
- self.assertNotEqual(r.returncode, 0)
4114
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
4115
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
3882
4116
  self.assertFalse(self.task.exists())
3883
4117
  saved = json.loads(self.receipt.read_text(encoding="utf-8"))
3884
4118
  self.assertNotIn("cleanup_started", saved)
@@ -3923,15 +4157,24 @@ ctypes.CDLL(None).pthread_exit(None)
3923
4157
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
3924
4158
 
3925
4159
  def test_retains_dirty_locked_and_active_tree(self):
4160
+ # Prediction: the first call merges; each refused removal keeps the tree and task ref and reports CLEANUP_PENDING
4161
+ # with delivery COMPLETE, while a caller inside the tree is refused (BLOCKED) before anything. Before: all BLOCKED.
3926
4162
  self.allocate(); self.accept()
4163
+ reasons = {"no-yield": "resume with --writers-stopped", "dirty": "dirty/untracked workspace",
4164
+ "untracked": "dirty/untracked workspace", "locked": "detached or locked"}
3927
4165
  for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
3928
4166
  path = self.task / ("product" if case == "dirty" else "unknown")
3929
4167
  if case in {"dirty", "untracked"}:
3930
4168
  path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
3931
4169
  if case == "locked": self.g("worktree", "lock", str(self.task))
3932
4170
  with self.subTest(case=case):
3933
- _, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
3934
- self.assertNotEqual(r.returncode, 0)
4171
+ result, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
4172
+ if case == "cwd":
4173
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
4174
+ self.assertIn("caller cwd is inside", result["reason"])
4175
+ else:
4176
+ self.assertEqual((r.returncode, result["status"], result["delivery_status"]), (0, "CLEANUP_PENDING", "COMPLETE"))
4177
+ self.assertIn(reasons[case], result["workspace_cleanup"]["reason"])
3935
4178
  self.assertTrue(self.task.exists())
3936
4179
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
3937
4180
  if case == "untracked": path.unlink()
@@ -4004,10 +4247,11 @@ ctypes.CDLL(None).pthread_exit(None)
4004
4247
  self.assertTrue(self.task.exists())
4005
4248
  finally:
4006
4249
  child.terminate(); child.wait(timeout=5)
4007
- # A foreign branch in the same registered tree never inherits ownership.
4250
+ # A foreign branch in the same registered tree never inherits ownership; the merged delivery stays COMPLETE.
4008
4251
  self.g("switch", "-c", "foreign", work=self.task)
4009
- _, r = self.complete("--writers-stopped", success=False)
4010
- self.assertNotEqual(r.returncode, 0)
4252
+ result, _ = self.complete("--writers-stopped")
4253
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4254
+ self.assertIn("foreign worktree branch", result["workspace_cleanup"]["reason"])
4011
4255
  self.assertTrue(self.task.exists())
4012
4256
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
4013
4257
 
@@ -4015,8 +4259,8 @@ ctypes.CDLL(None).pthread_exit(None)
4015
4259
  self.allocate(); self.accept()
4016
4260
  race = self.g("rev-parse", "main")
4017
4261
  self.configure(remote_delete_race=True, race_sha=race)
4018
- _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
4019
- self.assertNotEqual(r.returncode, 0)
4262
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
4263
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4020
4264
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
4021
4265
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
4022
4266
 
@@ -4032,16 +4276,37 @@ ctypes.CDLL(None).pthread_exit(None)
4032
4276
  self.assertEqual(d["prs"][0]["state"], "MERGED")
4033
4277
  self.assertIsNone(d["prs"][0]["autoMergeRequest"])
4034
4278
 
4035
- def test_auto_disallowed_blocked_refuses_without_merge(self):
4279
+ def test_auto_disallowed_blocked_waits_and_its_resume_merges(self):
4280
+ # Prediction (B3): with auto-merge off, required checks or reviews not passing yet (BLOCKED) make no merge call
4281
+ # and report PENDING with merge_waits; once the PR is CLEAN the printed resume merges it directly and cleans.
4282
+ # Before: PR with merge_refused "not mergeable: BLOCKED", a refusal left for a person.
4283
+ self.allocate(); self.accept()
4284
+ self.configure(auto_allowed=False, merge_state="BLOCKED")
4285
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
4286
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
4287
+ self.assertNotIn("merge_refused", result)
4288
+ d = json.loads(self.data.read_text(encoding="utf-8"))
4289
+ self.assertEqual((d.get("merge_calls", []), d["prs"][0]["state"]), ([], "OPEN"))
4290
+ self.assertEqual(read_json(self.receipt)["delivery"], "PENDING")
4291
+ self.configure(merge_state="CLEAN")
4292
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
4293
+ self.assertEqual(result["status"], "COMPLETE")
4294
+ d = json.loads(self.data.read_text(encoding="utf-8"))
4295
+ self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
4296
+ "--repo", "github.com/test/project"]])
4297
+ self.assertEqual(d["prs"][0]["state"], "MERGED")
4298
+ self.assertFalse(self.task.exists())
4299
+
4300
+ def test_auto_disallowed_unmergeable_states_refuse_without_merge(self):
4036
4301
  self.allocate(); self.accept()
4037
- for state in ("BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
4302
+ for state in ("BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
4038
4303
  with self.subTest(state=state):
4039
4304
  self.configure(auto_allowed=False, merge_state=state)
4040
4305
  result, _ = self.complete("--mode", "auto")
4041
4306
  self.assertEqual(result["status"], "PR")
4042
4307
  self.assertIn("not mergeable: " + state, result["merge_refused"])
4043
4308
  d = json.loads(self.data.read_text(encoding="utf-8"))
4044
- self.assertEqual(d["merge_state_views"], ["BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
4309
+ self.assertEqual(d["merge_state_views"], ["BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
4045
4310
  self.assertEqual(d.get("merge_calls", []), [])
4046
4311
  self.assertEqual(d["prs"][0]["state"], "OPEN")
4047
4312
  self.assertIsNone(d["prs"][0]["autoMergeRequest"])
@@ -4065,12 +4330,12 @@ ctypes.CDLL(None).pthread_exit(None)
4065
4330
  self.assertEqual(result["status"], "PENDING")
4066
4331
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
4067
4332
 
4068
- def test_auto_disallowed_unknown_bound_refuses_without_merge(self):
4333
+ def test_auto_disallowed_unknown_bound_waits_without_merge(self):
4069
4334
  self.allocate(); self.accept()
4070
4335
  self.configure(auto_allowed=False, merge_state="UNKNOWN")
4071
4336
  result, _ = self.complete("--mode", "auto")
4072
- self.assertEqual(result["status"], "PR")
4073
- self.assertIn("not mergeable: UNKNOWN", result["merge_refused"])
4337
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "UNKNOWN"))
4338
+ self.assertNotIn("merge_refused", result)
4074
4339
  d = json.loads(self.data.read_text(encoding="utf-8"))
4075
4340
  self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
4076
4341
  self.assertEqual(d.get("merge_calls", []), [])
@@ -4154,8 +4419,8 @@ ctypes.CDLL(None).pthread_exit(None)
4154
4419
  self.assertNotIn("merge_refused", result)
4155
4420
  self.configure(merge_allowed=True, auto_allowed=False, pending=True)
4156
4421
  result, _ = self.complete("--mode", "auto")
4157
- self.assertEqual(result["status"], "PR")
4158
- self.assertIn("not mergeable: BLOCKED", result["merge_refused"])
4422
+ self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
4423
+ self.assertNotIn("merge_refused", result)
4159
4424
  self.assertTrue(self.task.exists())
4160
4425
  # Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
4161
4426
  result, _ = self.complete("--no-push", success=False)
@@ -4214,8 +4479,8 @@ ctypes.CDLL(None).pthread_exit(None)
4214
4479
  user_file.write_text("retained user data", encoding="utf-8")
4215
4480
  before = self.anchor_state()
4216
4481
  self.configure(interrupt_delete=True)
4217
- _, r = self.complete(acceptance=False, success=False)
4218
- self.assertNotEqual(r.returncode, 0)
4482
+ result, _ = self.complete(acceptance=False)
4483
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
4219
4484
  self.assertEqual(self.anchor_state(), before)
4220
4485
  other = self.root / "advance"
4221
4486
  self.run_cmd(["git", "clone", str(self.bare), str(other)])