devlyn-cli 4.3.16 → 4.3.17

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.
@@ -777,7 +777,8 @@ def clean_scratch_command(args):
777
777
 
778
778
  def cleanup(receipt, path, pr, *, warnings=None):
779
779
  warnings = [] if warnings is None else warnings
780
- require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
780
+ if pr is not None:
781
+ require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
781
782
  sha = receipt["publish_sha"]
782
783
  branch_ref = "refs/heads/" + receipt["branch"]
783
784
  # The recovery ref keeps the accepted commit reachable, so squash and
@@ -785,7 +786,7 @@ def cleanup(receipt, path, pr, *, warnings=None):
785
786
  require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
786
787
  verify_files(path.parent / "custody", receipt["files"])
787
788
  require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
788
- gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], remote_base(receipt))
789
+ gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
789
790
  gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
790
791
  work = Path(receipt["worktree"])
791
792
  local = ref_sha(receipt, branch_ref)
@@ -887,26 +888,29 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
887
888
  continue
888
889
  try:
889
890
  receipt = read_json(path)
890
- if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
891
+ if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
891
892
  continue
892
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"):
893
894
  continue
894
895
  with locked_receipt(path, blocking=False) as receipt:
896
+ if not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
897
+ continue
895
898
  if receipt.get("status") == "COMPLETE":
896
899
  if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
897
900
  results.append(completion_result(receipt, path, "COMPLETE"))
898
901
  continue
899
- if not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
900
- continue
901
- pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
902
- if pr["state"] != "MERGED":
903
- continue
904
- validate_pr(receipt, pr)
902
+ pr = None
903
+ if receipt.get("pr_number"):
904
+ pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
905
+ if pr["state"] != "MERGED":
906
+ continue
907
+ validate_pr(receipt, pr)
905
908
  require(not receipt["linked"] or Path(receipt["worktree"]) != anchor, "cannot clean the current allocation's anchor checkout: " + str(anchor))
906
909
  require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
907
910
  repo_policy(receipt)
908
- receipt["merge"] = pr
909
- atomic_json(path, receipt)
911
+ if pr:
912
+ receipt["merge"] = pr
913
+ atomic_json(path, receipt)
910
914
  cleanup(receipt, path, pr, warnings=warnings)
911
915
  results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
912
916
  except (Exception, SystemExit) as error:
@@ -931,6 +935,9 @@ def complete(args, *, warnings=None):
931
935
  if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
932
936
  return result("FAILED")
933
937
  if args.local_only or receipt.get("local_only"):
938
+ require(receipt.get("delivery") != "COMPLETE" and receipt.get("status") != "COMPLETE" and "workspace_cleanup" not in receipt,
939
+ f"{receipt['task']} delivery is already COMPLETE; "
940
+ "--local-only cannot rewrite its delivery, resume it without --local-only")
934
941
  require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
935
942
  "--local-only cannot rewrite its delivery, resume it without --local-only")
936
943
  receipt["local_only"] = True
@@ -969,8 +976,17 @@ def complete(args, *, warnings=None):
969
976
  pr = prs[0] if prs else None
970
977
  if pr:
971
978
  validate_pr(receipt, pr)
972
- actual = remote_head(receipt, receipt["branch"])
973
- if not pr or pr["state"] != "MERGED":
979
+ on_base = pr is None and receipt.get("delivery") == "COMPLETE"
980
+ if pr is None and not on_base:
981
+ ancestry = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "merge-base", "--is-ancestor",
982
+ receipt["publish_sha"], remote_base(receipt)], capture_output=True, text=True, encoding="utf-8")
983
+ require(ancestry.returncode in {0, 1}, "cannot check accepted commit on base: " + ancestry.stderr.strip())
984
+ on_base = ancestry.returncode == 0
985
+ if on_base:
986
+ require(remote_head(receipt, receipt["branch"]) in {None, receipt["publish_sha"]},
987
+ "remote task head changed; refusing to overwrite or merge")
988
+ if not on_base and (not pr or pr["state"] != "MERGED"):
989
+ actual = remote_head(receipt, receipt["branch"])
974
990
  inspect_workspace(receipt)
975
991
  verify_files(Path(receipt["worktree"]), receipt["files"])
976
992
  require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
@@ -985,9 +1001,10 @@ def complete(args, *, warnings=None):
985
1001
  require(len(prs) == 1, "created PR could not be reconciled; resume from receipt")
986
1002
  pr = prs[0]
987
1003
  validate_pr(receipt, pr)
988
- receipt.update(pr_number=pr["number"], pr_url=pr["url"])
989
- atomic_json(path, receipt)
990
- if mode == "pr" and pr["state"] == "OPEN":
1004
+ if pr:
1005
+ receipt.update(pr_number=pr["number"], pr_url=pr["url"])
1006
+ atomic_json(path, receipt)
1007
+ if pr and mode == "pr" and pr["state"] == "OPEN":
991
1008
  if pr.get("autoMergeRequest"):
992
1009
  gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
993
1010
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
@@ -995,7 +1012,7 @@ def complete(args, *, warnings=None):
995
1012
  require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
996
1013
  if pr["state"] == "OPEN":
997
1014
  return result("PR")
998
- if pr["state"] != "MERGED":
1015
+ if pr and pr["state"] != "MERGED":
999
1016
  inspect_workspace(receipt)
1000
1017
  verify_files(Path(receipt["worktree"]), receipt["files"])
1001
1018
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
@@ -1035,12 +1052,13 @@ def complete(args, *, warnings=None):
1035
1052
  if pr["state"] != "MERGED":
1036
1053
  # Merge only when the repository allows it; a refused request leaves the PR for a person.
1037
1054
  return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
1038
- receipt["merge"] = pr
1039
- atomic_json(path, receipt)
1055
+ if pr:
1056
+ receipt["merge"] = pr
1057
+ atomic_json(path, receipt)
1040
1058
  try:
1041
1059
  cleanup(receipt, path, pr, warnings=warnings)
1042
1060
  except (WritersUnobservable, WriterActive) as error:
1043
- # Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
1061
+ # Conservative retention: base containment settles delivery; workspace, task refs and custody stay, reported.
1044
1062
  receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
1045
1063
  sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
1046
1064
  atomic_json(path, receipt)
@@ -2169,17 +2187,18 @@ ctypes.CDLL(None).pthread_exit(None)
2169
2187
  with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
2170
2188
  scratch_mounts()
2171
2189
 
2172
- def accept(self):
2173
- (self.task / "product").write_text("accepted\n", encoding="utf-8")
2174
- self.g("add", "product", work=self.task)
2175
- self.g("commit", "-m", "scoped task", work=self.task)
2190
+ def accept(self, *, unchanged=False):
2191
+ if not unchanged:
2192
+ (self.task / "product").write_text("accepted\n", encoding="utf-8")
2193
+ self.g("add", "product", work=self.task)
2194
+ self.g("commit", "-m", "scoped task", work=self.task)
2176
2195
  self.sha = self.g("rev-parse", "HEAD", work=self.task)
2177
2196
  evidence = self.task / ".devlyn"
2178
2197
  evidence.mkdir(exist_ok=True)
2179
2198
  (evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
2180
2199
  a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
2181
2200
  "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
2182
- self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
2201
+ self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "baseline\n" if unchanged else "accepted\n")
2183
2202
  self.acceptance = evidence / "acceptance.json"
2184
2203
  self.acceptance.write_text(json.dumps(a), encoding="utf-8")
2185
2204
 
@@ -2189,6 +2208,202 @@ ctypes.CDLL(None).pthread_exit(None)
2189
2208
  command_args += ["--acceptance", self.acceptance]
2190
2209
  return self.cli(*command_args, *args, success=success, cwd=cwd)
2191
2210
 
2211
+ def test_unchanged_at_base_cleans_without_publication(self):
2212
+ # Prediction: equality with base settles without push/PR, preserving custody and removing the tree, ref and scratch output.
2213
+ self.allocate(); self.accept(unchanged=True)
2214
+ records = records_manifest(self.task)
2215
+ scratch = self.receipt.parent / "scratch"
2216
+ (scratch / "build").write_bytes(b"temporary output")
2217
+ result, _ = self.complete("--writers-stopped")
2218
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2219
+ self.assertFalse(self.task.exists())
2220
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2221
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2222
+ self.assertEqual((result["scratch_cleanup"]["status"], list(scratch.iterdir())), ("CLEAN", []))
2223
+ self.assertEqual(records_manifest(self.receipt.parent / "records"), records)
2224
+ receipt = read_json(self.receipt)
2225
+ verify_files(self.receipt.parent / "custody", receipt["files"])
2226
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
2227
+ d = read_json(self.data)
2228
+ self.assertEqual((d["pushs"], d.get("creates", 0), d.get("deletes", 0)), (0, 0, 0))
2229
+
2230
+ def test_unchanged_ancestor_cleans_in_pr_mode(self):
2231
+ # Prediction: only the freshly fetched base contains the accepted ancestor; PR mode cleans without publication.
2232
+ self.allocate(); self.accept()
2233
+ stale = self.g("rev-parse", "refs/remotes/origin/main")
2234
+ advanced = self.g("commit-tree", self.sha + "^{tree}", "-p", self.sha, "-m", "advance remote base")
2235
+ self.g("push", "origin", advanced + ":refs/heads/main")
2236
+ self.configure(pushs=0)
2237
+ self.g("update-ref", "refs/remotes/origin/main", stale)
2238
+ for ref in ("main", "refs/remotes/origin/main"):
2239
+ self.assertEqual(self.run_cmd(["git", "-C", str(self.work), "merge-base", "--is-ancestor",
2240
+ self.sha, ref], success=False).returncode, 1)
2241
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
2242
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2243
+ self.assertEqual(self.g("rev-parse", "refs/remotes/origin/main"), advanced)
2244
+ self.assertEqual(self.g("rev-parse", "main"), stale)
2245
+ self.assertFalse(self.task.exists())
2246
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2247
+ d = read_json(self.data)
2248
+ self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
2249
+
2250
+ def test_unchanged_old_pushed_receipt_resumes_with_exact_delete(self):
2251
+ # Prediction: a pushed receipt without a PR resumes with only the exact-lease deletion and settles COMPLETE.
2252
+ from unittest.mock import Mock, patch
2253
+ self.allocate(); self.accept(unchanged=True)
2254
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
2255
+ self.g("push", "origin", self.sha + ":refs/heads/task/fixture")
2256
+ receipt = read_json(self.receipt)
2257
+ receipt["pushed"] = True
2258
+ atomic_json(self.receipt, receipt)
2259
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
2260
+ run = Mock(wraps=gref)
2261
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, gref=run):
2262
+ result = complete(args)
2263
+ pushes = [call.args[1:] for call in run.call_args_list if call.args[1] == "push"]
2264
+ self.assertEqual(pushes, [("push", "--force-with-lease=refs/heads/task/fixture:" + self.sha, "origin", ":refs/heads/task/fixture")])
2265
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2266
+ self.assertFalse(self.task.exists())
2267
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2268
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2269
+ d = read_json(self.data)
2270
+ self.assertEqual((d["pushs"], d.get("creates", 0), d["deletes"]), (1, 0, 1))
2271
+
2272
+ def test_unchanged_writer_retention_resumes_to_complete(self):
2273
+ # Prediction: either writer refusal settles delivery but retains the tree/ref; resume cleans without push/PR.
2274
+ from unittest.mock import Mock, patch
2275
+ self.allocate(); self.accept(unchanged=True)
2276
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2277
+ for error in (WriterActive, WritersUnobservable):
2278
+ with self.subTest(error=error.__name__), patch.dict(os.environ, self.env), \
2279
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=error("fixture writer retention"))):
2280
+ result = complete(args)
2281
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2282
+ self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
2283
+ self.assertIn(str(self.receipt), result["workspace_cleanup"]["resume"])
2284
+ self.assertTrue(self.task.exists())
2285
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
2286
+ result, _ = self.complete(acceptance=False)
2287
+ self.assertEqual((result["status"], result["pr"], result["workspace_cleanup"]), ("COMPLETE", None, None))
2288
+ self.assertFalse(self.task.exists())
2289
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2290
+ d = read_json(self.data)
2291
+ self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
2292
+
2293
+ def test_unchanged_writer_retention_reconciles_on_allocate(self):
2294
+ # Prediction: the next allocation retries on-base delivery once writer observation succeeds.
2295
+ from unittest.mock import Mock, patch
2296
+ self.allocate(); self.accept(unchanged=True)
2297
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2298
+ with patch.dict(os.environ, self.env), \
2299
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
2300
+ result = complete(args)
2301
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2302
+ self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
2303
+ receipt, task = self.receipt, self.task
2304
+ result = self.allocate("next")
2305
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
2306
+ self.assertFalse(task.exists())
2307
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2308
+
2309
+ def test_unchanged_settled_resume_blocks_after_base_rewind(self):
2310
+ # Prediction: settled delivery never republishes after base rewind; cleanup blocks with the tree and refs kept.
2311
+ from unittest.mock import Mock, patch
2312
+ self.allocate(); self.accept()
2313
+ base = self.g("rev-parse", "main")
2314
+ self.g("push", "origin", self.sha + ":refs/heads/main")
2315
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2316
+ with patch.dict(os.environ, self.env), \
2317
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
2318
+ result = complete(args)
2319
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2320
+ self.assertIsNone(read_json(self.receipt).get("status"))
2321
+ self.g("push", "origin", "+" + base + ":refs/heads/main")
2322
+ self.configure(pushs=0)
2323
+ for mode in ("auto", "pr"):
2324
+ with self.subTest(mode=mode):
2325
+ result, _ = self.complete("--mode", mode, acceptance=False, success=False)
2326
+ self.assertEqual(result["status"], "BLOCKED")
2327
+ self.assertIn("merge-base:", result["reason"])
2328
+ self.assertTrue(self.task.exists())
2329
+ receipt = read_json(self.receipt)
2330
+ self.assertEqual(receipt["delivery"], "COMPLETE")
2331
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
2332
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
2333
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2334
+ d = read_json(self.data)
2335
+ self.assertEqual((d["pushs"], d.get("creates", 0), d.get("merges", 0)), (0, 0, 0))
2336
+
2337
+ def test_unchanged_interrupted_cleanup_refuses_local_only(self):
2338
+ # Prediction: both cleanup checkpoints prevent local-only relabeling before delivery is persisted.
2339
+ from unittest.mock import Mock, patch
2340
+ self.allocate(); self.accept(unchanged=True)
2341
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2342
+ for retained in (True, False):
2343
+ with self.subTest(retained=retained), patch.dict(os.environ, self.env), \
2344
+ patch.dict(complete.__globals__,
2345
+ stopped_writers=Mock(side_effect=WriterActive("fixture writer retention") if retained else None),
2346
+ completion_result=Mock(side_effect=RuntimeError("fixture interruption"))):
2347
+ with self.assertRaisesRegex(RuntimeError, "fixture interruption"):
2348
+ complete(args)
2349
+ receipt = read_json(self.receipt)
2350
+ self.assertIsNone(receipt.get("delivery"))
2351
+ self.assertFalse(receipt.get("pushed"))
2352
+ self.assertEqual("workspace_cleanup" in receipt, retained)
2353
+ self.assertEqual(receipt.get("status"), None if retained else "COMPLETE")
2354
+ result, _ = self.complete("--local-only", acceptance=False, success=False)
2355
+ self.assertEqual(result["status"], "BLOCKED")
2356
+ self.assertIn("already COMPLETE", result["reason"])
2357
+ self.assertFalse(read_json(self.receipt).get("local_only"))
2358
+ self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
2359
+
2360
+ def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
2361
+ # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard.
2362
+ self.allocate(); self.accept()
2363
+ self.complete("--mode", "pr")
2364
+ self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
2365
+ "--repo", "github.com/test/project"])
2366
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
2367
+ foreign = self.g("rev-parse", "HEAD")
2368
+ self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
2369
+ result, _ = self.complete("--writers-stopped", success=False)
2370
+ self.assertEqual(result["status"], "BLOCKED")
2371
+ self.assertIn("remote branch changed after merge; retain ref", result["reason"])
2372
+ self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
2373
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
2374
+
2375
+ def test_unchanged_foreign_remote_head_retains_worktree(self):
2376
+ # Prediction: a foreign remote task head refuses completion before worktree removal.
2377
+ self.allocate(); self.accept(unchanged=True)
2378
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
2379
+ self.g("push", "origin", "HEAD:refs/heads/task/fixture")
2380
+ result, _ = self.complete("--writers-stopped", success=False)
2381
+ self.assertEqual(result["status"], "BLOCKED")
2382
+ self.assertIn("remote task head changed", result["reason"])
2383
+ self.assertTrue(self.task.exists())
2384
+
2385
+ def test_unchanged_scratch_retention_reconciles_on_allocate(self):
2386
+ # Prediction: status COMPLETE without a PR still retries retained scratch on the next allocation.
2387
+ scratch = Path(self.allocate()["scratch"])
2388
+ self.accept(unchanged=True)
2389
+ (scratch / ".git").mkdir()
2390
+ result, _ = self.complete("--writers-stopped")
2391
+ self.assertEqual((result["status"], result["scratch_cleanup"]["status"]), ("CLEANUP_PENDING", "RETAINED"))
2392
+ receipt = self.receipt
2393
+ self.assertEqual(read_json(receipt)["status"], "COMPLETE")
2394
+ (scratch / ".git").rmdir()
2395
+ result = self.allocate("next")
2396
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
2397
+ self.assertFalse(any(scratch.iterdir()))
2398
+
2399
+ def test_unchanged_complete_refuses_local_only(self):
2400
+ # Prediction: a delivered on-base receipt cannot be rewritten as local-only.
2401
+ self.allocate(); self.accept(unchanged=True)
2402
+ self.assertEqual(self.complete("--writers-stopped")[0]["status"], "COMPLETE")
2403
+ result, _ = self.complete("--local-only", acceptance=False, success=False)
2404
+ self.assertIn("already COMPLETE", result["reason"])
2405
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
2406
+
2192
2407
  def test_reject_repository_rewrite_before_allocation(self):
2193
2408
  intended = "https://github.com/test/project.git"
2194
2409
  destination = "https://github.com/test/other.git"
@@ -200,7 +200,10 @@ python3 "$DEVLYN_SHARED_DIR/task-complete.py" complete \
200
200
  Project policy is `git config --local devlyn.completionMode auto|pr`; absent means
201
201
  `auto`. Invalid local values fail before external effects, even with an override.
202
202
  Use `complete --mode auto|pr` for one task, or `--local-only`/`--no-push` to honor
203
- the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed.
203
+ the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed or delivery is `COMPLETE`.
204
+
205
+ With no matching PR and the accepted commit already on the fetched base, either
206
+ publication mode pushes no task commit and opens no PR. Delivery is `COMPLETE` with no `pr`; cleanup and retention work as for a merged delivery.
204
207
 
205
208
  `pr` pushes the exact accepted task ref and creates/reuses its exact repository,
206
209
  head and base PR. While OPEN, it cancels owned auto-merge, reports `PR` with its URL
@@ -230,7 +233,7 @@ must never be reported as accepted.
230
233
 
231
234
  ## Yield and retain recoverability
232
235
 
233
- Cleanup requires actual matching MERGED evidence; linked-tree removal also
236
+ Cleanup requires actual matching MERGED evidence or, with no PR, the accepted commit on the fetched base; linked-tree removal also
234
237
  requires the owner's recorded release. OS observation supplements it; neither the receipt
235
238
  nor a scan guarantees exclusion of future writers. Processes with cwd or open
236
239
  files inside the tree, inaccessible process state (on Linux, other users'
@@ -240,11 +243,11 @@ affected resources. Use `git worktree lock` to keep a tree.
240
243
  A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
241
244
  until it restarts; the helper names it when identifiable and retries cleanup on
242
245
  the next allocation or resume after the restart.
243
- On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
246
+ On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged or on-base delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
244
247
 
245
248
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
246
249
  `refs/devlyn/completed/<id>` recovery ref. Check evidence is copied there
247
- before publication; removal rechecks it, the recovery ref and the merge commit on base.
250
+ before publication; removal rechecks it, the recovery ref and the merge or accepted commit on base.
248
251
  Ignored build output and other ignored files are disposable under native
249
252
  non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
250
253
  and preserved as `<receipt dir>/records/.devlyn`; changes after an earlier copy
@@ -1034,15 +1034,19 @@ def input_files(v, row, receipt, local):
1034
1034
 
1035
1035
 
1036
1036
  def require_merged(v, row, base, *, allocated=False):
1037
- """auto/pr: every prerequisite's merge commit must be in the base, before allocation and before every execution."""
1037
+ """auto/pr: every prerequisite's delivered commit must be in the base, before allocation and before every execution."""
1038
1038
  for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"]:
1039
1039
  receipt = v["states"][f"{row['loop']}.{dep}"]["receipt"] or {}
1040
1040
  merge = ((receipt.get("merge") or {}).get("mergeCommit") or {}).get("oid")
1041
+ kind = "merge"
1042
+ if not receipt.get("merge") and receipt.get("delivery") == "COMPLETE":
1043
+ merge = receipt.get("publish_sha")
1044
+ kind = "commit"
1041
1045
  remedy = ("retain this allocation and plan the continuation as a new loop, or perform verified recovery of a never-executed allocation"
1042
- if allocated else f"restore that merge on origin/{manifest_of(v, row['loop'])['base_ref']}, then drain again")
1046
+ if allocated else f"restore that {kind} on origin/{manifest_of(v, row['loop'])['base_ref']}, then drain again")
1043
1047
  require(merge and ancestor(v["anchor"], merge, base),
1044
1048
  f"{row['identity']}: {'allocated baseline' if allocated else 'base'} {base} lacks prerequisite {row['loop']}.{dep}'s "
1045
- f"delivered merge {merge}; {remedy}")
1049
+ f"delivered {kind} {merge}; {remedy}")
1046
1050
 
1047
1051
 
1048
1052
  def package_drift(v, loop, start, where):
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-16",
2
+ "name": "devlyn-harness-v4-3-17",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.16",
3
+ "version": "4.3.17",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }