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.
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +240 -25
- package/config/skills/_shared/task-completion.md +7 -4
- package/config/skills/devlyn-ideate/scripts/queue.py +7 -3
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +240 -25
- package/native/claude/skills/_shared/task-completion.md +7 -4
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +7 -3
- 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/task-complete.py +240 -25
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +7 -4
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +7 -3
- package/package.json +1 -1
- package/release.json +16 -16
|
@@ -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
|
-
|
|
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("
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
909
|
-
|
|
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
|
-
|
|
973
|
-
if
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
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:
|
|
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
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
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
|
|
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
|
|
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
|
|
1049
|
+
f"delivered {kind} {merge}; {remedy}")
|
|
1046
1050
|
|
|
1047
1051
|
|
|
1048
1052
|
def package_drift(v, loop, start, where):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.17",
|
|
4
4
|
"description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
|
|
5
5
|
"homepage": "https://github.com/fysoul17/devlyn-cli#readme",
|
|
6
6
|
"bin": {
|
package/release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "devlyn-cli",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.17",
|
|
5
5
|
"principles": {
|
|
6
6
|
"defaults": "config/skills/_shared/runtime-principles.md",
|
|
7
7
|
"memory": {
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
"path": "bin/skill-history.json",
|
|
167
|
-
"sha256": "
|
|
167
|
+
"sha256": "87835f5fdc60d46f182516a4cb36dea7b17fe9bb507289d2801565c00964b676",
|
|
168
168
|
"executable": false
|
|
169
169
|
},
|
|
170
170
|
{
|
|
@@ -244,12 +244,12 @@
|
|
|
244
244
|
},
|
|
245
245
|
{
|
|
246
246
|
"path": "config/skills/_shared/task-complete.py",
|
|
247
|
-
"sha256": "
|
|
247
|
+
"sha256": "7d032cef34b8c3416cb17ceb23a269bc6011d506f610f0ff865eac061b4f2a78",
|
|
248
248
|
"executable": false
|
|
249
249
|
},
|
|
250
250
|
{
|
|
251
251
|
"path": "config/skills/_shared/task-completion.md",
|
|
252
|
-
"sha256": "
|
|
252
|
+
"sha256": "3cd317e94402fd6d8693f91e1d3d70c6928ef0dce18f355829de6c21847305c8",
|
|
253
253
|
"executable": false
|
|
254
254
|
},
|
|
255
255
|
{
|
|
@@ -294,7 +294,7 @@
|
|
|
294
294
|
},
|
|
295
295
|
{
|
|
296
296
|
"path": "config/skills/devlyn-ideate/scripts/queue.py",
|
|
297
|
-
"sha256": "
|
|
297
|
+
"sha256": "5f7fc0b5e1b21d740095f2754c34029dd80baf75842fb0ab08507d8a371614ab",
|
|
298
298
|
"executable": false
|
|
299
299
|
},
|
|
300
300
|
{
|
|
@@ -309,7 +309,7 @@
|
|
|
309
309
|
},
|
|
310
310
|
{
|
|
311
311
|
"path": "native/claude/.claude-plugin/plugin.json",
|
|
312
|
-
"sha256": "
|
|
312
|
+
"sha256": "9dd4c72c682e853aaa38e24ca880c6cfe772e73d905241ddde14de70ead9781c",
|
|
313
313
|
"executable": false
|
|
314
314
|
},
|
|
315
315
|
{
|
|
@@ -384,12 +384,12 @@
|
|
|
384
384
|
},
|
|
385
385
|
{
|
|
386
386
|
"path": "native/claude/skills/_shared/task-complete.py",
|
|
387
|
-
"sha256": "
|
|
387
|
+
"sha256": "7d032cef34b8c3416cb17ceb23a269bc6011d506f610f0ff865eac061b4f2a78",
|
|
388
388
|
"executable": false
|
|
389
389
|
},
|
|
390
390
|
{
|
|
391
391
|
"path": "native/claude/skills/_shared/task-completion.md",
|
|
392
|
-
"sha256": "
|
|
392
|
+
"sha256": "3cd317e94402fd6d8693f91e1d3d70c6928ef0dce18f355829de6c21847305c8",
|
|
393
393
|
"executable": false
|
|
394
394
|
},
|
|
395
395
|
{
|
|
@@ -434,17 +434,17 @@
|
|
|
434
434
|
},
|
|
435
435
|
{
|
|
436
436
|
"path": "native/claude/skills/devlyn-ideate/scripts/queue.py",
|
|
437
|
-
"sha256": "
|
|
437
|
+
"sha256": "5f7fc0b5e1b21d740095f2754c34029dd80baf75842fb0ab08507d8a371614ab",
|
|
438
438
|
"executable": false
|
|
439
439
|
},
|
|
440
440
|
{
|
|
441
441
|
"path": "native/codex/.agents/plugins/marketplace.json",
|
|
442
|
-
"sha256": "
|
|
442
|
+
"sha256": "cf1dc7e0ad0de8ae8adbd82a907000f12500b16463e75c6c54f7debb97fc5ab4",
|
|
443
443
|
"executable": false
|
|
444
444
|
},
|
|
445
445
|
{
|
|
446
446
|
"path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
|
|
447
|
-
"sha256": "
|
|
447
|
+
"sha256": "9dd4c72c682e853aaa38e24ca880c6cfe772e73d905241ddde14de70ead9781c",
|
|
448
448
|
"executable": false
|
|
449
449
|
},
|
|
450
450
|
{
|
|
@@ -519,12 +519,12 @@
|
|
|
519
519
|
},
|
|
520
520
|
{
|
|
521
521
|
"path": "native/codex/plugins/devlyn/skills/_shared/task-complete.py",
|
|
522
|
-
"sha256": "
|
|
522
|
+
"sha256": "7d032cef34b8c3416cb17ceb23a269bc6011d506f610f0ff865eac061b4f2a78",
|
|
523
523
|
"executable": false
|
|
524
524
|
},
|
|
525
525
|
{
|
|
526
526
|
"path": "native/codex/plugins/devlyn/skills/_shared/task-completion.md",
|
|
527
|
-
"sha256": "
|
|
527
|
+
"sha256": "3cd317e94402fd6d8693f91e1d3d70c6928ef0dce18f355829de6c21847305c8",
|
|
528
528
|
"executable": false
|
|
529
529
|
},
|
|
530
530
|
{
|
|
@@ -569,7 +569,7 @@
|
|
|
569
569
|
},
|
|
570
570
|
{
|
|
571
571
|
"path": "native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py",
|
|
572
|
-
"sha256": "
|
|
572
|
+
"sha256": "5f7fc0b5e1b21d740095f2754c34029dd80baf75842fb0ab08507d8a371614ab",
|
|
573
573
|
"executable": false
|
|
574
574
|
},
|
|
575
575
|
{
|
|
@@ -1714,12 +1714,12 @@
|
|
|
1714
1714
|
},
|
|
1715
1715
|
{
|
|
1716
1716
|
"path": "package.json",
|
|
1717
|
-
"sha256": "
|
|
1717
|
+
"sha256": "a776f2793ee5652635bdb59abac74a26bb31c33e414f583da35758ea0c256aee",
|
|
1718
1718
|
"executable": false
|
|
1719
1719
|
}
|
|
1720
1720
|
],
|
|
1721
1721
|
"digest": {
|
|
1722
1722
|
"algorithm": "sha256",
|
|
1723
|
-
"value": "
|
|
1723
|
+
"value": "47db7c280f96b88376d8311a266ca2cfa739579dde51145e448a8eef46098913"
|
|
1724
1724
|
}
|
|
1725
1725
|
}
|