devlyn-cli 3.0.1 → 3.1.0

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.
@@ -0,0 +1,1169 @@
1
+ #!/usr/bin/env python3
2
+ """Outer-owner delivery and recoverable cleanup (task-completion spec, R1–7).
3
+
4
+ No phase/state writer, staging, scheduler, or implicit adoption. See
5
+ devlyn:resolve/references/task-completion.md for the owner acceptance contract.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import fcntl
12
+ import functools
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import runpy
19
+ import shlex
20
+ import shutil
21
+ import stat
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import unittest
26
+
27
+
28
+ class CompletionError(Exception):
29
+ pass
30
+
31
+
32
+ def require(condition, message):
33
+ if not condition:
34
+ raise CompletionError(message)
35
+
36
+
37
+ def command(argv, *, cwd=None, ok=(0,)):
38
+ result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
39
+ require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
40
+ return result.stdout.strip()
41
+
42
+
43
+ def git(work, *args):
44
+ return command(["git", "-C", str(work), *args])
45
+
46
+
47
+ @functools.lru_cache(maxsize=None)
48
+ def shared(name):
49
+ return runpy.run_path(str(Path(__file__).with_name(name + ".py")))
50
+
51
+
52
+ def read_json(path):
53
+ require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
54
+ return shared("archive_run")["loads_strict_json"](path.read_text())
55
+
56
+
57
+ def atomic_json(path, value):
58
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".receipt-")
59
+ try:
60
+ with os.fdopen(fd, "w") as stream:
61
+ json.dump(value, stream, sort_keys=True, indent=2)
62
+ stream.write("\n")
63
+ stream.flush()
64
+ os.fsync(stream.fileno())
65
+ os.replace(temporary, path)
66
+ directory = os.open(path.parent, os.O_RDONLY)
67
+ try:
68
+ os.fsync(directory)
69
+ finally:
70
+ os.close(directory)
71
+ finally:
72
+ Path(temporary).unlink(missing_ok=True)
73
+
74
+
75
+ def safe_path(root, relative):
76
+ require(root == root.resolve(), f"custody/workspace root traverses a symlink: {root}")
77
+ p = Path(relative)
78
+ require(not p.is_absolute() and p.parts and ".." not in p.parts, f"unsafe relative path: {relative}")
79
+ result = root / p
80
+ require(result.resolve().is_relative_to(root.resolve()), f"path escapes custody: {relative}")
81
+ for ancestor in [result, *result.parents]:
82
+ if ancestor == root:
83
+ break
84
+ require(not ancestor.is_symlink(), f"symlink is not owned evidence: {relative}")
85
+ return result
86
+
87
+
88
+ def file_record(path):
89
+ mode = path.lstat().st_mode
90
+ require(stat.S_ISREG(mode), f"evidence is not a regular file: {path}")
91
+ raw = path.read_bytes()
92
+ return {"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "mode": stat.S_IMODE(mode)}
93
+
94
+
95
+ def snapshot_files(root, paths):
96
+ result = {}
97
+ for relative in paths:
98
+ p = safe_path(root, relative)
99
+ if p.is_dir():
100
+ for child in sorted(p.rglob("*")):
101
+ rel = str(child.relative_to(root))
102
+ safe_path(root, rel)
103
+ if not child.is_dir():
104
+ result[rel] = file_record(child)
105
+ else:
106
+ result[str(Path(relative))] = file_record(p)
107
+ require(result, "acceptance has no evidence files")
108
+ return result
109
+
110
+
111
+ def verify_files(root, files):
112
+ for relative, expected in files.items():
113
+ require(file_record(safe_path(root, relative)) == expected, f"evidence bytes/mode changed: {relative}")
114
+
115
+
116
+ def custody(work, destination, files):
117
+ destination.mkdir(exist_ok=True)
118
+ for relative, expected in files.items():
119
+ source = safe_path(work, relative)
120
+ target = safe_path(destination, relative)
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ if not target.exists():
123
+ # Interrupted partial copies are diagnosed, never silently overwritten.
124
+ with source.open("rb") as incoming, target.open("xb") as outgoing:
125
+ shutil.copyfileobj(incoming, outgoing)
126
+ outgoing.flush()
127
+ os.fsync(outgoing.fileno())
128
+ target.chmod(expected["mode"])
129
+ require(file_record(target) == expected, f"custody copy mismatch; retain original: {relative}")
130
+ verify_files(work, files)
131
+ verify_files(destination, files)
132
+ atomic_json(destination.parent / "manifest.json", files)
133
+
134
+
135
+ def gref(receipt, *args):
136
+ return command(["git", "--git-dir", receipt["common_gitdir"], *args])
137
+
138
+
139
+ def ref_sha(receipt, ref):
140
+ return command(["git", "--git-dir", receipt["common_gitdir"], "rev-parse", "--verify", "--quiet", ref], ok=(0, 1)) or None
141
+
142
+
143
+ def registrations(receipt):
144
+ rows = {}
145
+ for raw in gref(receipt, "worktree", "list", "--porcelain", "-z").split("\0\0"):
146
+ row = dict(field.partition(" ")[::2] for field in raw.split("\0") if field)
147
+ if "worktree" in row:
148
+ rows[str(Path(row["worktree"]).resolve())] = row
149
+ return rows
150
+
151
+
152
+ def remote_url(receipt):
153
+ values = [gref(receipt, "remote", "get-url", "--all", receipt["remote"]),
154
+ gref(receipt, "remote", "get-url", "--push", "--all", receipt["remote"])]
155
+ # --get-url expands insteadOf; compare the stored literal remote config as
156
+ # well, while preserving Git's normal transport configuration.
157
+ literal = gref(receipt, "config", "--get-all", "remote." + receipt["remote"] + ".url")
158
+ urls = {"literal": literal, "fetch": values[0], "push": values[1]}
159
+ require(all(repository_from_url(url).lower() == receipt["repository"].lower() for url in urls.values()), "repository differs from remote")
160
+ return urls
161
+
162
+
163
+ def repository_from_url(url):
164
+ match = re.fullmatch(r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?", url)
165
+ require(match is not None, "remote must unambiguously identify one GitHub repository")
166
+ return match.group(1)
167
+
168
+
169
+ def policy(receipt, override):
170
+ config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True)
171
+ require(config.returncode in {0, 1}, "cannot read local completion policy: " + config.stderr.strip())
172
+ require(config.returncode == 1 or config.stdout in {"auto\n", "pr\n"}, "invalid local devlyn.completionMode; use auto|pr")
173
+ value = config.stdout.strip() if config.returncode == 0 else None
174
+ require(override is None or override in {"auto", "pr"}, "invalid task mode; use auto|pr")
175
+ return override or value or "auto"
176
+
177
+
178
+ def allocate(args):
179
+ work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
180
+ common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
181
+ require(args.task.strip(), "task identity is required")
182
+ git(work, "check-ref-format", "refs/heads/" + args.branch)
183
+ git(work, "check-ref-format", "refs/heads/" + args.base)
184
+ require(args.branch != args.base and args.branch not in {"main", "master"}, "cannot own a base/default branch")
185
+ require(re.fullmatch(r"[A-Za-z0-9_.-]+", args.remote), "unsafe remote name")
186
+ receipt = {"task": args.task, "repository": args.repository, "remote": args.remote,
187
+ "base": args.base, "branch": args.branch, "common_gitdir": str(common),
188
+ "anchor": str(work), "linked": bool(args.worktree), "allocation": "allocating"}
189
+ policy(receipt, None)
190
+ receipt["remote_url"] = remote_url(receipt)
191
+ require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
192
+ require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
193
+ require(git(work, "symbolic-ref", "--short", "HEAD") == args.base, "allocate from the retained base checkout")
194
+ require(not git(work, "status", "--porcelain", "--untracked-files=all"), "allocation requires clean tracked/untracked contents")
195
+ receipt["baseline"] = git(work, "rev-parse", "HEAD")
196
+ target = Path(args.worktree).absolute() if args.worktree else work
197
+ require(target == target.resolve(), "worktree path must not traverse symlinks")
198
+ if args.worktree:
199
+ require(not target.exists() and not target.is_relative_to(work) and not work.is_relative_to(target), "linked worktree must be an absent disjoint path")
200
+ receipt["worktree"] = str(target)
201
+ key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
202
+ directory = common / "devlyn-completion" / key
203
+ directory.mkdir(parents=True, exist_ok=False)
204
+ path = directory / "receipt.json"
205
+ receipt["id"] = key
206
+ receipt["recovery_ref"] = "refs/devlyn/completed/" + key
207
+ # Persist prospective intent BEFORE native creation. An interrupted allocation
208
+ # stays visibly incomplete; a later call may not adopt whatever now exists.
209
+ atomic_json(path, receipt)
210
+ if args.worktree:
211
+ git(work, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
212
+ else:
213
+ git(work, "switch", "-c", args.branch, receipt["baseline"])
214
+ receipt["worktree_gitdir"] = git(target, "rev-parse", "--absolute-git-dir")
215
+ receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
216
+ receipt["allocation"] = "owned"
217
+ atomic_json(path, receipt)
218
+ return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target)}
219
+
220
+
221
+ def pipeline_acceptance(work, acceptance, files, directory):
222
+ run_id = acceptance["run_id"]
223
+ require(re.fullmatch(r"[A-Za-z0-9_.-]+", run_id) and run_id not in {".", ".."}, "invalid intended run id")
224
+ archive = safe_path(work, ".devlyn/runs/" + run_id)
225
+ state_file = archive / "pipeline.state.json"
226
+ classifier = shared("terminal-claim-check")
227
+ classification, state = classifier["classify_state_bytes"](work, state_file, state_file.read_bytes(), archived=True)
228
+ require(classification.status == "CLEAN" and state["run_id"] == run_id, "intended archive is not terminal CLEAN")
229
+ require(state.get("mode") in {"spec", "free-form"}, "only normal archived runs may publish")
230
+ require((state.get("source") or {}).get("type") in {"spec", "generated"}, "normal archive must bind its source contract")
231
+ phases = state["phases"]
232
+ success = {"PASS", "PASS_WITH_ISSUES"}
233
+ verify = phases.get("verify") or {}
234
+ final = phases.get("final_report") or {}
235
+ require(verify.get("verdict") in success and final.get("verdict") == verify["verdict"], "archive did not finish successfully")
236
+ require(not any(str((phase or {}).get("verdict", "")).startswith("BLOCKED") for phase in phases.values()), "blocked phase takes terminal precedence")
237
+ for name in ("plan", "implement", "cleanup", "verify", "final_report", "build_gate"):
238
+ if name == "build_gate" and "build-gate" in (state.get("bypasses") or []):
239
+ continue
240
+ phase = phases.get(name) or {}
241
+ require(phase.get("completed_at") and phase.get("verdict") in success, f"required {name} evidence is incomplete")
242
+ require(phases["cleanup"].get("post_sha") == acceptance["source_sha"], "accepted source must equal cleanup.post_sha")
243
+ report_digest = shared("state-phase-write")["final_report_digest"](state, archive, str(archive / "final-report.md"))
244
+ require(final.get("output_sha256") == report_digest and final.get("artifacts", {}).get("log_file") == ".devlyn/final-report.md", "final report binding mismatch")
245
+ finish = read_json(archive / "finish-gate.summary.json")
246
+ require(finish.get("mode") == state["mode"] and finish.get("exit") == 0 and finish.get("offenders") == 0 and not finish.get("skipped") and not finish.get("malformed"), "finish gate was not clean")
247
+ require(read_json(archive / "verify-merge.summary.json").get("verdict") == verify["verdict"], "VERIFY summary disagrees with terminal result")
248
+ # Existing evidence validators expect the pre-archive .devlyn layout. Rebuild
249
+ # it in a temporary directory; never move or mutate the original archive.
250
+ with tempfile.TemporaryDirectory(prefix="validate-", dir=directory) as tmp:
251
+ reconstructed = Path(tmp)
252
+ devlyn = reconstructed / ".devlyn"
253
+ devlyn.mkdir()
254
+ prefix = f".devlyn/runs/{run_id}/"
255
+ for rel in files:
256
+ if rel.startswith(prefix):
257
+ dest = safe_path(devlyn, rel[len(prefix):])
258
+ dest.parent.mkdir(parents=True, exist_ok=True)
259
+ shutil.copyfile(safe_path(work, rel), dest)
260
+ archive_module = shared("archive_run")
261
+ try:
262
+ for validator in ("dynamic_evidence_artifacts", "dynamic_invocation_artifacts", "dynamic_judge_role_artifacts"):
263
+ archive_module[validator](devlyn, state)
264
+ except archive_module["ArchiveError"] as exc:
265
+ raise CompletionError(str(exc)) from exc
266
+ evidence_module = shared("process-evidence")
267
+ for carrier in state.get("process_evidence") or []:
268
+ if carrier["round"] == (phases.get(carrier["phase"]) or {}).get("round"):
269
+ require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
270
+ source = state.get("source") or {}
271
+ if source.get("spec_path"):
272
+ expected = Path(source["spec_path"]).with_suffix(".expected.json")
273
+ original = safe_path(work, str(expected))
274
+ if original.exists():
275
+ dest = safe_path(reconstructed, str(expected))
276
+ dest.parent.mkdir(parents=True, exist_ok=True)
277
+ shutil.copyfile(original, dest)
278
+ for phase in ("implement", "build_gate", "verify"):
279
+ obligations = evidence_module["declared_obligations"](reconstructed, state, phase)
280
+ if obligations:
281
+ carriers = [c for c in state.get("process_evidence") or [] if c.get("phase") == phase and c.get("round") == (phases.get(phase) or {}).get("round")]
282
+ require(len(carriers) == 1, f"missing/ambiguous current {phase} process evidence")
283
+ carrier = carriers[0]
284
+ evidence_module["validate_manifest"](reconstructed, carrier["manifest"]["path"], run_id, phase, carrier["round"], obligations)
285
+ if evidence_module["mechanical_evidence_required"](reconstructed, state):
286
+ require((devlyn / "spec-verify.results.json").is_file(), "required MECHANICAL results are missing")
287
+ carrier = shared("verify-merge-findings")["mechanical_evidence_carrier"](devlyn)
288
+ if carrier:
289
+ require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "MECHANICAL checks did not pass")
290
+
291
+
292
+ def bind_acceptance(receipt, path, supplied):
293
+ work = Path(receipt["worktree"])
294
+ if receipt.get("acceptance"):
295
+ if supplied:
296
+ require(file_record(Path(supplied))["sha256"] == receipt["acceptance_digest"], "acceptance changed; resume with the original acceptance")
297
+ verify_files(path.parent / "custody", receipt["files"])
298
+ if work.exists() and not receipt.get("cleanup_started"):
299
+ verify_files(work, receipt["files"])
300
+ return
301
+ require(supplied, "first completion requires explicit root acceptance")
302
+ acceptance_path = Path(supplied).absolute()
303
+ require(acceptance_path.is_relative_to(work), "acceptance must be a regular file in its task checkout")
304
+ safe_path(work, str(acceptance_path.relative_to(work)))
305
+ acceptance = read_json(acceptance_path)
306
+ require(acceptance.get("task") == receipt["task"], "acceptance belongs to another task")
307
+ sha = acceptance.get("source_sha")
308
+ require(isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40,64}", sha), "acceptance must name an exact commit")
309
+ require(gref(receipt, "rev-parse", sha+"^{commit}") == sha, "source is not a commit")
310
+ gref(receipt, "merge-base", "--is-ancestor", receipt["baseline"], sha)
311
+ publish = sha
312
+ queue = acceptance.get("queue")
313
+ if queue:
314
+ publish = queue["commit"]
315
+ safe_path(work, queue["file"])
316
+ require(gref(receipt, "rev-list", "--parents", "-n", "1", publish).split() == [publish, sha], "queue commit must be directly atop verified source with one parent")
317
+ require(gref(receipt, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", publish).strip("\0") == queue["file"], "queue commit must change exactly the declared queue file")
318
+ require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == publish and git(work, "rev-parse", "HEAD") == publish, "unverified source delta or changed task ref")
319
+ paths = [str(acceptance_path.relative_to(work))]
320
+ kind = acceptance.get("kind")
321
+ if kind == "direct":
322
+ checks = acceptance.get("checks")
323
+ require(isinstance(checks, list) and checks, "direct acceptance must name actual root-accepted checks")
324
+ for check in checks:
325
+ require(isinstance(check.get("command"), str) and check["command"].strip(), "direct check must name its command")
326
+ paths.append(check["evidence"])
327
+ elif kind == "pipeline":
328
+ paths.append(".devlyn/runs/" + acceptance["run_id"])
329
+ state = read_json(safe_path(work, paths[-1]) / "pipeline.state.json")
330
+ source = state.get("source") or {}
331
+ for key in ("spec_path", "criteria_path"):
332
+ if source.get(key):
333
+ if key == "criteria_path" and source.get("type") == "generated":
334
+ require(source[key] == ".devlyn/criteria.generated.md", "generated criteria path is not canonical")
335
+ relative = f".devlyn/runs/{acceptance['run_id']}/criteria.generated.md"
336
+ else:
337
+ relative = source[key]
338
+ paths.append(relative)
339
+ source_file = safe_path(work, relative)
340
+ if relative == source[key]:
341
+ committed = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "show", sha+":"+relative], capture_output=True)
342
+ require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
343
+ bound_digest = source.get(key.replace("_path", "_sha256"))
344
+ require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
345
+ expected = str(Path(source[key]).with_suffix(".expected.json"))
346
+ if safe_path(work, expected).exists():
347
+ paths.append(expected)
348
+ else:
349
+ raise CompletionError("acceptance kind must be direct|pipeline")
350
+ files = snapshot_files(work, paths)
351
+ if kind == "pipeline":
352
+ pipeline_acceptance(work, acceptance, files, path.parent)
353
+ custody(work, path.parent / "custody", files)
354
+ recovery = ref_sha(receipt, receipt["recovery_ref"])
355
+ require(recovery in {None, publish}, "recovery ref changed")
356
+ if recovery is None:
357
+ gref(receipt, "update-ref", receipt["recovery_ref"], publish, "0"*len(publish))
358
+ receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=publish, files=files)
359
+ atomic_json(path, receipt)
360
+
361
+
362
+ PR_FIELDS = "number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest"
363
+
364
+
365
+ def gh(receipt, *args):
366
+ return command(["gh", *args, "--repo", "github.com/"+receipt["repository"]])
367
+
368
+
369
+ def repo_policy(receipt):
370
+ info = json.loads(command(["gh", "repo", "view", "github.com/"+receipt["repository"], "--json", "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed"]))
371
+ require(info["nameWithOwner"].lower() == receipt["repository"].lower() and info["url"].lower() == "https://github.com/"+receipt["repository"].lower(), "GitHub repository identity changed")
372
+ require(info["defaultBranchRef"]["name"] == receipt["base"] and receipt["branch"] != info["defaultBranchRef"]["name"], "base/default branch changed; retain resources")
373
+ return info
374
+
375
+
376
+ def remote_head(receipt, branch):
377
+ rows = gref(receipt, "ls-remote", "--heads", receipt["remote"], "refs/heads/"+branch).splitlines()
378
+ require(len(rows) <= 1, "ambiguous remote ref")
379
+ return rows[0].split()[0] if rows else None
380
+
381
+
382
+ def validate_pr(receipt, pr):
383
+ owner, name = receipt["repository"].split("/")
384
+ require(pr["url"].lower() == f"https://github.com/{receipt['repository']}/pull/{pr['number']}".lower(), "PR repository mismatch")
385
+ require(pr["headRefName"] == receipt["branch"] and pr["baseRefName"] == receipt["base"] and pr["headRefOid"] == receipt["publish_sha"], "PR head/base/commit changed")
386
+ require(not pr["isCrossRepository"] and pr["headRepository"]["name"].lower() == name.lower() and pr["headRepositoryOwner"]["login"].lower() == owner.lower(), "PR head repository differs")
387
+ require(pr["state"] in {"OPEN", "MERGED"}, "PR is closed without merge; retain task")
388
+
389
+
390
+ def workspace_identity(work, gitdir):
391
+ return [[p.stat().st_dev, p.stat().st_ino] for p in (work, gitdir)]
392
+
393
+
394
+ def inspect_workspace(receipt, *, allow_returned=False):
395
+ work = Path(receipt["worktree"])
396
+ row = registrations(receipt).get(str(work))
397
+ require(row and "locked" not in row and "prunable" not in row and "detached" not in row, "worktree missing, detached or locked; retain and inspect registration")
398
+ expected = "refs/heads/" + receipt["branch"]
399
+ returned = allow_returned and not receipt["linked"] and row.get("branch") == "refs/heads/"+receipt["base"] and receipt.get("cleanup_started")
400
+ require(row.get("branch") == expected or returned, "foreign worktree branch; retain workspace")
401
+ require(git(work, "rev-parse", "--absolute-git-dir") == receipt["worktree_gitdir"] and str(Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()) == receipt["common_gitdir"], "worktree Gitdir ownership changed")
402
+ require(workspace_identity(work, Path(receipt["worktree_gitdir"])) == receipt["worktree_identity"], "worktree/registration was replaced; receipt no longer owns it")
403
+ if not returned:
404
+ require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
405
+ require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
406
+ return returned
407
+
408
+
409
+ def stopped_writers(work, *, linked):
410
+ require(not linked or not Path.cwd().resolve().is_relative_to(work), "caller cwd is inside removable worktree; yield it and resume from outside")
411
+ require(not linked or not Path(__file__).resolve().is_relative_to(work), "invoke the installed helper outside the removable worktree so resume remains available")
412
+ # The owner assertion covers its actual children; an OS observation catches
413
+ # other currently open files/cwds, not future writers or a universal lease.
414
+ if sys.platform == "darwin":
415
+ result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True)
416
+ require(result.returncode in {0, 1} and not result.stderr.strip(), "writer observation unavailable; retain tree and inspect writers")
417
+ pid = None
418
+ for line in result.stdout.splitlines():
419
+ if line.startswith("p"):
420
+ pid = int(line[1:])
421
+ elif line.startswith("n") and pid != os.getpid():
422
+ raise CompletionError(f"active process {pid} uses task files; stop/yield actual writers before resume")
423
+ elif sys.platform.startswith("linux"):
424
+ for process in Path("/proc").iterdir():
425
+ if not process.name.isdigit() or int(process.name) == os.getpid():
426
+ continue
427
+ try:
428
+ links = [process / "cwd", *(process / "fd").iterdir()]
429
+ for link in links:
430
+ target = Path(os.readlink(link))
431
+ require(not target.is_absolute() or not target.is_relative_to(work), f"active process {process.name} uses task files; stop/yield it before resume")
432
+ except FileNotFoundError:
433
+ continue # Process or fd exited during observation.
434
+ except PermissionError as exc:
435
+ raise CompletionError("unknown process access; retain tree until writer cessation can be established") from exc
436
+ else:
437
+ raise CompletionError("writer observation unsupported on this platform; retain workspace")
438
+
439
+
440
+ def cleanup(receipt, path, pr, writers_stopped):
441
+ require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
442
+ require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
443
+ sha = receipt["publish_sha"]
444
+ branch_ref = "refs/heads/" + receipt["branch"]
445
+ require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
446
+ verify_files(path.parent / "custody", receipt["files"])
447
+ require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
448
+ gref(receipt, "fetch", "--no-tags", receipt["remote"], "refs/heads/"+receipt["base"])
449
+ base_sha = gref(receipt, "rev-parse", "FETCH_HEAD")
450
+ gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], base_sha)
451
+ gref(receipt, "merge-base", "--is-ancestor", sha, pr["mergeCommit"]["oid"])
452
+ gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
453
+ work = Path(receipt["worktree"])
454
+ local = ref_sha(receipt, branch_ref)
455
+ require(local == sha or (local is None and receipt.get("cleanup_started")), "local task ref changed; retain")
456
+ if work.exists():
457
+ returned = inspect_workspace(receipt, allow_returned=True)
458
+ if not returned:
459
+ verify_files(work, receipt["files"])
460
+ if receipt["linked"]:
461
+ require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
462
+ ignored = git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0")
463
+ require(all(not p or p in receipt["files"] for p in ignored), "unknown ignored content; retain tree and arrange explicit custody outside this helper")
464
+ stopped_writers(work, linked=True)
465
+ inspect_workspace(receipt)
466
+ verify_files(work, receipt["files"])
467
+ receipt["cleanup_started"] = True
468
+ atomic_json(path, receipt)
469
+ gref(receipt, "worktree", "remove", str(work))
470
+ else:
471
+ # In-place ignored user data stays in the retained checkout. Native
472
+ # switch --no-overwrite-ignore prevents a base file replacing it.
473
+ stopped_writers(work, linked=False)
474
+ base_ref = "refs/heads/" + receipt["base"]
475
+ old_base = ref_sha(receipt, base_ref)
476
+ require(old_base, "local base branch is missing")
477
+ gref(receipt, "merge-base", "--is-ancestor", old_base, base_sha)
478
+ receipt["cleanup_started"] = True
479
+ atomic_json(path, receipt)
480
+ inspect_workspace(receipt, allow_returned=True)
481
+ if not returned:
482
+ other = [row for p, row in registrations(receipt).items() if p != str(work) and row.get("branch") == base_ref]
483
+ require(not other, "base checked out elsewhere; retain task checkout")
484
+ gref(receipt, "update-ref", base_ref, base_sha, old_base)
485
+ git(work, "switch", "--no-overwrite-ignore", receipt["base"])
486
+ else:
487
+ git(work, "merge", "--no-overwrite-ignore", "--ff-only", base_sha)
488
+ else:
489
+ require(receipt["linked"] and receipt.get("cleanup_started") and str(work) not in registrations(receipt), "missing/replaced worktree was not removed by this receipt")
490
+ require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch is still checked out; retain ref")
491
+ # Check ownership/ref identity once more immediately before each deletion.
492
+ repo_policy(receipt)
493
+ require(remote_url(receipt) == receipt["remote_url"], "remote changed before deletion")
494
+ remote = remote_head(receipt, receipt["branch"])
495
+ require(remote in {None, sha}, "remote branch changed after merge; retain ref")
496
+ if remote == sha:
497
+ # Deletion with an exact lease is the remote compare-and-delete primitive;
498
+ # product publication above never uses force or a lease.
499
+ gref(receipt, "push", f"--force-with-lease={branch_ref}:{sha}", receipt["remote"], ":"+branch_ref)
500
+ if ref_sha(receipt, branch_ref) is not None:
501
+ require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch became checked out; retain ref")
502
+ gref(receipt, "update-ref", "-d", branch_ref, sha)
503
+ receipt["status"] = "COMPLETE"
504
+ atomic_json(path, receipt)
505
+
506
+
507
+ @contextlib.contextmanager
508
+ def locked_receipt(path):
509
+ require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
510
+ require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
511
+ with (path.parent / "lock").open("a+") as lock:
512
+ fcntl.flock(lock, fcntl.LOCK_EX)
513
+ receipt = read_json(path)
514
+ require(path == Path(receipt["common_gitdir"]) / "devlyn-completion" / receipt["id"] / "receipt.json", "receipt/common Gitdir binding mismatch")
515
+ require(receipt["id"] == hashlib.sha256(receipt["branch"].encode()).hexdigest()[:24] and receipt["branch"] not in {receipt["base"], "main", "master"}, "receipt branch ownership changed")
516
+ require(receipt["allocation"] == "owned", "allocation was interrupted; no historical adoption is permitted")
517
+ yield receipt
518
+
519
+
520
+ def complete(args):
521
+ path = Path(args.receipt).absolute()
522
+ resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])
523
+ with locked_receipt(path) as receipt:
524
+ def result(status):
525
+ return {"status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
526
+ "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True}
527
+ if args.local_only or receipt.get("local_only"):
528
+ receipt["local_only"] = True
529
+ atomic_json(path, receipt)
530
+ return result("LOCAL_ONLY")
531
+ mode = policy(receipt, args.mode or receipt.get("mode_override"))
532
+ if args.mode:
533
+ receipt["mode_override"] = args.mode
534
+ atomic_json(path, receipt)
535
+ require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
536
+ if receipt.get("status") == "COMPLETE":
537
+ verify_files(path.parent / "custody", receipt["files"])
538
+ return result("COMPLETE")
539
+ if not receipt.get("cleanup_started"):
540
+ # Before first binding the owner has committed the product; inspect
541
+ # uses the actual branch head until acceptance pins its exact SHA.
542
+ current = dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"]))
543
+ inspect_workspace(current)
544
+ bind_acceptance(receipt, path, args.acceptance)
545
+ if not receipt.get("cleanup_started"):
546
+ inspect_workspace(receipt)
547
+ info = repo_policy(receipt)
548
+ if mode == "auto":
549
+ require(info["mergeCommitAllowed"], "repository disallows merge commits; use pr mode or resolve policy with repository owner")
550
+ if receipt.get("pr_number"):
551
+ pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
552
+ else:
553
+ prs = json.loads(gh(receipt, "pr", "list", "--head", receipt["branch"], "--base", receipt["base"], "--state", "all", "--json", PR_FIELDS))
554
+ require(len(prs) <= 1, "multiple PRs for task head/base; retain and resolve ambiguity")
555
+ pr = prs[0] if prs else None
556
+ if pr:
557
+ validate_pr(receipt, pr)
558
+ actual = remote_head(receipt, receipt["branch"])
559
+ if not pr or pr["state"] != "MERGED":
560
+ require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
561
+ require(actual is not None or (not pr and not receipt.get("pushed")), "published task ref disappeared; retain and inspect")
562
+ if actual is None:
563
+ inspect_workspace(receipt)
564
+ verify_files(Path(receipt["worktree"]), receipt["files"])
565
+ gref(receipt, "push", receipt["remote"], receipt["publish_sha"]+":refs/heads/"+receipt["branch"])
566
+ receipt["pushed"] = True
567
+ atomic_json(path, receipt)
568
+ if pr is None:
569
+ gh(receipt, "pr", "create", "--head", receipt["branch"], "--base", receipt["base"], "--title", receipt["task"], "--body", "Accepted task commit: " + receipt["publish_sha"])
570
+ prs = json.loads(gh(receipt, "pr", "list", "--head", receipt["branch"], "--base", receipt["base"], "--state", "all", "--json", PR_FIELDS))
571
+ require(len(prs) == 1, "created PR could not be reconciled; resume from receipt")
572
+ pr = prs[0]
573
+ validate_pr(receipt, pr)
574
+ receipt.update(pr_number=pr["number"], pr_url=pr["url"])
575
+ atomic_json(path, receipt)
576
+ if mode == "pr":
577
+ return result("PR")
578
+ if pr["state"] != "MERGED":
579
+ inspect_workspace(receipt)
580
+ verify_files(Path(receipt["worktree"]), receipt["files"])
581
+ require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
582
+ if not pr.get("autoMergeRequest"):
583
+ gh(receipt, "pr", "merge", str(pr["number"]), "--auto", "--merge", "--match-head-commit", receipt["publish_sha"])
584
+ pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
585
+ validate_pr(receipt, pr)
586
+ if pr["state"] != "MERGED":
587
+ return result("PENDING")
588
+ receipt["merge"] = pr
589
+ atomic_json(path, receipt)
590
+ cleanup(receipt, path, pr, args.writers_stopped)
591
+ return result("COMPLETE")
592
+
593
+
594
+ def main():
595
+ parser = argparse.ArgumentParser(description=__doc__)
596
+ parser.add_argument("--self-test", action="store_true")
597
+ actions = parser.add_subparsers(dest="action")
598
+ allocation = actions.add_parser("allocate")
599
+ allocation.add_argument("--repo", default=".")
600
+ for name in ("task", "branch", "repository", "base"):
601
+ allocation.add_argument("--"+name, required=True)
602
+ allocation.add_argument("--remote", default="origin")
603
+ allocation.add_argument("--worktree")
604
+ completion = actions.add_parser("complete")
605
+ completion.add_argument("--receipt", required=True)
606
+ completion.add_argument("--acceptance")
607
+ completion.add_argument("--mode", choices=("auto", "pr"))
608
+ completion.add_argument("--local-only", "--no-push", action="store_true")
609
+ completion.add_argument("--writers-stopped", action="store_true")
610
+ args = parser.parse_args()
611
+ if args.self_test:
612
+ return self_test()
613
+ if args.action is None:
614
+ parser.error("allocate or complete is required")
615
+ try:
616
+ result = allocate(args) if args.action == "allocate" else complete(args)
617
+ print(json.dumps(result, sort_keys=True))
618
+ return 0
619
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, SystemExit) as exc:
620
+ result = {"status": "BLOCKED", "reason": str(exc)}
621
+ if args.action == "complete":
622
+ result["receipt"] = str(Path(args.receipt).absolute())
623
+ result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", result["receipt"]])
624
+ print(json.dumps(result, sort_keys=True))
625
+ return 1
626
+
627
+
628
+ def self_test(names=None):
629
+ suite = (unittest.TestSuite(CompletionTests(name) for name in names) if names
630
+ else unittest.defaultTestLoader.loadTestsFromTestCase(CompletionTests))
631
+ result = unittest.TextTestRunner(verbosity=1).run(suite)
632
+ if result.wasSuccessful():
633
+ print(f"task-complete self-test: PASS ({result.testsRun} isolated tests)")
634
+ return 0 if result.wasSuccessful() else 1
635
+
636
+
637
+ # Fixtures use real Git and isolated config. Wrappers translate only transport
638
+ # to the local bare remote and inject interruption AFTER real effects; gh is fake.
639
+ FAKE_GH = r'''#!/usr/bin/env python3
640
+ import json, os, pathlib, subprocess, sys
641
+ p = pathlib.Path(os.environ['FIXTURE_GH'])
642
+ d = json.loads(p.read_text()); a = sys.argv[1:]
643
+ def save(): p.write_text(json.dumps(d))
644
+ def remote(ref):
645
+ r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True)
646
+ return r.stdout.strip() if r.returncode == 0 else None
647
+ if a[:2] == ['repo', 'view']:
648
+ assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
649
+ print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':'main'}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
650
+ elif a[:2] == ['pr', 'list']:
651
+ assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
652
+ print(json.dumps([d['pr']] if d.get('pr') else []))
653
+ elif a[:2] == ['pr', 'create']:
654
+ d['creates'] = d.get('creates',0)+1
655
+ assert not d.get('pr'), 'duplicate PR'
656
+ head = a[a.index('--head')+1]; base = a[a.index('--base')+1]
657
+ d['pr'] = {'number':1,'url':'https://github.com/test/project/pull/1','headRefName':head,'baseRefName':base,'headRefOid':remote('refs/heads/'+head),'headRepository':{'name':'project','owner':{'login':'test'}}, 'headRepositoryOwner':{'login':'test'},'isCrossRepository':False,'state':'OPEN','mergedAt':None,'mergeCommit':None,'autoMergeRequest':None}
658
+ save()
659
+ print(d['pr']['url'])
660
+ if d.pop('interrupt_create',False): save(); sys.exit(1)
661
+ elif a[:2] == ['pr', 'view']:
662
+ print(json.dumps(d['pr']))
663
+ elif a[:2] == ['pr', 'merge']:
664
+ assert '--admin' not in a and '--delete-branch' not in a
665
+ assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
666
+ sha = a[a.index('--match-head-commit')+1]
667
+ assert sha == remote('refs/heads/'+d['pr']['headRefName'])
668
+ d['merges'] = d.get('merges',0)+1
669
+ if not d.get('auto_allowed',True) and d.get('pending'):
670
+ save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
671
+ if d.get('pending'):
672
+ d['pr']['autoMergeRequest'] = {'enabledAt':'now'}
673
+ else:
674
+ g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
675
+ base = remote('refs/heads/main')
676
+ tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True).strip()
677
+ merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True).strip()
678
+ subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
679
+ d['pr'].update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
680
+ save()
681
+ if d.pop('interrupt_merge',False): save(); sys.exit(1)
682
+ else:
683
+ raise SystemExit('unexpected gh arguments: '+str(a))
684
+ '''
685
+
686
+ GIT_WRAPPER = r'''#!/usr/bin/env python3
687
+ import json, os, pathlib, subprocess, sys
688
+ a = sys.argv[1:]
689
+ p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text())
690
+ if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
691
+ subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['pr']['headRefName'],d['race_sha']])
692
+ p.write_text(json.dumps(d))
693
+ for operation in ('push', 'fetch', 'ls-remote'):
694
+ if operation in a and 'origin' in a:
695
+ prefix = a[:a.index(operation)]
696
+ url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True).strip()
697
+ if url in {'https://github.com/test/project.git', 'git@github.com:test/project.git', 'ssh://git@github.com/test/project.git'}:
698
+ a[a.index('origin')] = d['bare']
699
+ break
700
+ r = subprocess.run([os.environ['REAL_GIT'],*a])
701
+ event = 'delete' if 'push' in a and any(x.startswith(':refs/') for x in a) else 'push' if 'push' in a else 'remove' if 'worktree' in a and 'remove' in a else None
702
+ if r.returncode == 0 and event:
703
+ d = json.loads(p.read_text()); d[event+'s'] = d.get(event+'s',0)+1
704
+ fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d))
705
+ if fail: sys.exit(1)
706
+ sys.exit(r.returncode)
707
+ '''
708
+
709
+
710
+ class CompletionTests(unittest.TestCase):
711
+ def setUp(self):
712
+ self.temp = tempfile.TemporaryDirectory(prefix="devlyn-completion-")
713
+ self.addCleanup(self.temp.cleanup)
714
+ self.root = Path(self.temp.name).resolve()
715
+ self.work = self.root / "work"
716
+ self.bare = self.root / "remote.git"
717
+ self.bin = self.root / "bin"
718
+ self.bin.mkdir()
719
+ self.data = self.root / "gh.json"
720
+ self.data.write_text(json.dumps({"bare": str(self.bare)}))
721
+ self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
722
+ "GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
723
+ "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
724
+ "FIXTURE_GH": str(self.data), "REAL_GIT": shutil.which("git"),
725
+ "GIT_CONFIG_COUNT": "0", "GIT_ALLOW_PROTOCOL": "file"}
726
+ for key in list(self.env):
727
+ if key in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"}:
728
+ del self.env[key]
729
+ for name, body in (("gh", FAKE_GH), ("git", GIT_WRAPPER)):
730
+ path = self.bin / name
731
+ path.write_text(body)
732
+ path.chmod(0o755)
733
+ self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
734
+ self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
735
+ self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
736
+ (self.work / ".gitignore").write_text(".devlyn/\nignored/\n")
737
+ (self.work / "product").write_text("baseline\n")
738
+ self.g("add", ".")
739
+ self.g("commit", "-m", "baseline")
740
+ self.g("remote", "add", "origin", "https://github.com/test/project.git")
741
+ self.g("push", "origin", "main")
742
+ self.configure(pushs=0)
743
+
744
+ def run_cmd(self, args, *, cwd=None, success=True):
745
+ r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True)
746
+ if success:
747
+ self.assertEqual(r.returncode, 0, r.stderr + r.stdout)
748
+ return r
749
+
750
+ def g(self, *args, work=None):
751
+ return self.run_cmd(["git", "-C", str(work or self.work), *args]).stdout.strip()
752
+
753
+ def configure(self, **values):
754
+ d = json.loads(self.data.read_text()); d.update(values)
755
+ self.data.write_text(json.dumps(d))
756
+
757
+ def cli(self, *args, success=True, cwd=None):
758
+ r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
759
+ return json.loads(r.stdout), r
760
+
761
+ def allocate(self, linked=False):
762
+ args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", "main"]
763
+ if linked:
764
+ args += ["--worktree", self.root / "linked"]
765
+ result, _ = self.cli(*args)
766
+ self.receipt = Path(result["receipt"])
767
+ self.task = Path(result["worktree"])
768
+ return result
769
+
770
+ def accept(self, pipeline=False, queue=False, spec_expected=None):
771
+ if spec_expected is not None:
772
+ (self.task / "spec.md").write_text("# Fixture\nProduct contains accepted bytes.\n")
773
+ (self.task / "spec.expected.json").write_text(json.dumps(spec_expected))
774
+ self.g("add", "spec.md", "spec.expected.json", work=self.task)
775
+ (self.task / "product").write_text("accepted\n")
776
+ self.g("add", "product", work=self.task)
777
+ self.g("commit", "-m", "scoped task", work=self.task)
778
+ self.sha = self.g("rev-parse", "HEAD", work=self.task)
779
+ evidence = self.task / ".devlyn"
780
+ evidence.mkdir(exist_ok=True)
781
+ (evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n")
782
+ a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
783
+ "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
784
+ self.assertEqual((self.task / "product").read_text(), "accepted\n")
785
+ if pipeline:
786
+ a = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
787
+ archive = evidence / "runs" / a["run_id"]
788
+ archive.mkdir(parents=True)
789
+ self.archive = archive
790
+ report = "<!-- devlyn:final-report run_id=fixture-run -->\nFixture completed.\n"
791
+ (archive / "final-report.md").write_text(report)
792
+ phases = {n: {"started_at": "2026-09-10T00:00:00Z", "completed_at": "2026-09-10T00:00:01Z", "verdict": "PASS"}
793
+ for n in ("plan", "implement", "build_gate", "cleanup", "verify", "final_report")}
794
+ phases["cleanup"]["post_sha"] = self.sha
795
+ phases["final_report"].update(output_sha256=hashlib.sha256(report.encode()).hexdigest(), artifacts={"log_file": ".devlyn/final-report.md"})
796
+ criteria = "# Fixture acceptance\nProduct contains accepted bytes.\n"
797
+ (archive / "criteria.generated.md").write_text(criteria)
798
+ source = {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md", "criteria_sha256": hashlib.sha256(criteria.encode()).hexdigest()}
799
+ self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
800
+ if spec_expected is not None:
801
+ self.state["mode"] = "spec"
802
+ self.state["source"] = {"type": "spec", "spec_path": "spec.md", "spec_sha256": hashlib.sha256((self.task / "spec.md").read_bytes()).hexdigest()}
803
+ (archive / "pipeline.state.json").write_text(json.dumps(self.state))
804
+ (archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}))
805
+ (archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}))
806
+ if queue:
807
+ (self.task / "queue.md").write_text("- [x] fixture\n")
808
+ self.g("add", "queue.md", work=self.task)
809
+ self.g("commit", "-m", "terminal queue", work=self.task)
810
+ a["queue"] = {"commit": self.g("rev-parse", "HEAD", work=self.task), "file": "queue.md"}
811
+ self.acceptance = evidence / "acceptance.json"
812
+ self.acceptance.write_text(json.dumps(a))
813
+
814
+ def complete(self, *args, success=True, cwd=None, acceptance=True):
815
+ command_args = ["complete", "--receipt", self.receipt]
816
+ if acceptance:
817
+ command_args += ["--acceptance", self.acceptance]
818
+ return self.cli(*command_args, *args, success=success, cwd=cwd)
819
+
820
+ def test_reject_repository_rewrite_before_allocation(self):
821
+ intended = "https://github.com/test/project.git"
822
+ destination = "https://github.com/test/other.git"
823
+ self.g("config", "url." + destination + ".insteadOf", intended)
824
+ self.assertEqual(self.g("config", "--get", "remote.origin.url"), intended)
825
+ self.assertEqual(self.g("remote", "get-url", "--all", "origin"), destination)
826
+ self.assertEqual(self.g("remote", "get-url", "--push", "--all", "origin"), destination)
827
+ local_refs = self.g("show-ref")
828
+ remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
829
+ server = self.data.read_bytes()
830
+ result, r = self.cli("allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", "main", "--worktree", self.root / "linked", success=False)
831
+ self.assertEqual(self.g("show-ref"), local_refs, r.stdout)
832
+ self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
833
+ self.assertEqual(self.data.read_bytes(), server)
834
+ self.assertEqual(self.g("branch", "--show-current"), "main")
835
+ self.assertFalse((self.root / "linked").exists())
836
+ self.assertFalse((self.work / ".git/devlyn-completion").exists())
837
+ self.assertNotEqual(r.returncode, 0)
838
+ self.assertEqual(result["status"], "BLOCKED")
839
+ self.assertIn("repository differs from remote", result["reason"])
840
+
841
+ def test_same_repository_transport_alias(self):
842
+ intended = "https://github.com/test/project.git"
843
+ alias = "git@github.com:test/project.git"
844
+ self.g("config", "url." + alias + ".insteadOf", intended)
845
+ self.allocate(); self.accept()
846
+ self.assertEqual(json.loads(self.receipt.read_text())["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
847
+ result, _ = self.complete("--mode", "pr")
848
+ self.assertEqual(result["status"], "PR")
849
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
850
+ self.assertEqual(json.loads(self.data.read_text())["pushs"], 1)
851
+
852
+ def test_remote_rewrite_after_allocation_is_rejected(self):
853
+ self.allocate(); self.accept()
854
+ self.g("config", "url.git@github.com:test/project.git.insteadOf", "https://github.com/test/project.git")
855
+ local_refs = self.g("show-ref")
856
+ remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
857
+ server = self.data.read_bytes()
858
+ result, r = self.complete(success=False)
859
+ self.assertNotEqual(r.returncode, 0)
860
+ self.assertIn("remote URL changed since allocation", result["reason"])
861
+ self.assertEqual(self.g("show-ref"), local_refs)
862
+ self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
863
+ self.assertEqual(self.data.read_bytes(), server)
864
+ self.assertTrue(self.task.exists())
865
+
866
+ def test_reject_previously_bound_repository_rewrite(self):
867
+ self.allocate(); self.accept()
868
+ destination = "https://github.com/test/other.git"
869
+ self.g("config", "url." + destination + ".insteadOf", "https://github.com/test/project.git")
870
+ # Reproduce a receipt the old literal-only allocation check accepted.
871
+ receipt = json.loads(self.receipt.read_text())
872
+ receipt["remote_url"].update(fetch=destination, push=destination)
873
+ self.receipt.write_text(json.dumps(receipt))
874
+ local_refs = self.g("show-ref")
875
+ remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
876
+ server = self.data.read_bytes()
877
+ result, r = self.complete(success=False)
878
+ self.assertNotEqual(r.returncode, 0)
879
+ self.assertIn("repository differs from remote", result["reason"])
880
+ self.assertEqual(self.g("show-ref"), local_refs)
881
+ self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout, remote_refs)
882
+ self.assertEqual(self.data.read_bytes(), server)
883
+ self.assertTrue(self.task.exists())
884
+
885
+ def test_pr_pending_and_retry(self):
886
+ self.allocate(); self.accept()
887
+ result, _ = self.complete("--mode", "pr")
888
+ self.assertEqual(result["status"], "PR")
889
+ self.assertTrue(self.task.exists())
890
+ self.configure(pending=True)
891
+ for _ in range(2):
892
+ result, _ = self.complete("--mode", "auto")
893
+ self.assertEqual(result["status"], "PENDING")
894
+ d = json.loads(self.data.read_text())
895
+ self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
896
+
897
+ def test_in_place_retains_ignored_data(self):
898
+ self.allocate(); self.accept()
899
+ (self.task / "ignored").mkdir()
900
+ (self.task / "ignored" / "user.txt").write_text("retain me")
901
+ result, _ = self.complete("--writers-stopped")
902
+ self.assertEqual(result["status"], "COMPLETE")
903
+ self.assertEqual(self.g("branch", "--show-current"), "main")
904
+ self.assertEqual((self.task / "ignored/user.txt").read_text(), "retain me")
905
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
906
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
907
+ again, _ = self.complete("--writers-stopped", acceptance=False)
908
+ self.assertEqual(again["status"], "COMPLETE")
909
+
910
+ def test_linked_custody_and_interrupted_removal(self):
911
+ self.allocate(linked=True); self.accept(pipeline=True)
912
+ # Direct evidence unrelated to the pipeline is unknown ignored content.
913
+ (self.task / ".devlyn/checks.txt").unlink()
914
+ self.configure(interrupt_remove=True)
915
+ result, r = self.complete("--writers-stopped", success=False)
916
+ self.assertNotEqual(r.returncode, 0)
917
+ self.assertFalse(self.task.exists())
918
+ saved = json.loads(self.receipt.read_text())
919
+ recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
920
+ self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
921
+ result, _ = self.complete("--writers-stopped", acceptance=False)
922
+ self.assertEqual(result["status"], "COMPLETE")
923
+ self.assertEqual(self.g("show", saved["recovery_ref"]+":product"), "accepted")
924
+
925
+ def test_reject_acceptance_and_policy_before_push(self):
926
+ self.allocate(); self.accept(pipeline=True)
927
+ original = json.loads(json.dumps(self.state))
928
+ for case in ("failed", "verify-only", "blocked", "unfinished", "digest", "source"):
929
+ state = json.loads(json.dumps(original))
930
+ if case == "failed": state["phases"]["verify"]["verdict"] = "NEEDS_WORK"
931
+ if case == "verify-only": state["mode"] = "verify-only"
932
+ if case == "blocked": state["phases"]["implement"]["verdict"] = "BLOCKED"
933
+ if case == "unfinished": state["phases"]["verify"]["completed_at"] = None
934
+ if case == "digest": state["phases"]["final_report"]["output_sha256"] = "0"*64
935
+ if case == "source": state["phases"]["cleanup"]["post_sha"] = self.g("rev-parse", "main")
936
+ (self.archive / "pipeline.state.json").write_text(json.dumps(state))
937
+ with self.subTest(case=case):
938
+ _, r = self.complete(success=False)
939
+ self.assertNotEqual(r.returncode, 0)
940
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
941
+ (self.archive / "pipeline.state.json").write_text(json.dumps(original))
942
+ for value in ("typo", "", " auto"):
943
+ self.g("config", "--local", "devlyn.completionMode", value)
944
+ _, r = self.complete("--mode", "pr", success=False)
945
+ self.assertNotEqual(r.returncode, 0)
946
+ result, _ = self.complete("--local-only")
947
+ self.assertEqual(result["status"], "LOCAL_ONLY")
948
+ self.assertEqual(json.loads(self.data.read_text()).get("creates", 0), 0)
949
+
950
+ def test_queue_only_and_unverified_descendant(self):
951
+ self.allocate(); self.accept(pipeline=True, queue=True)
952
+ result, _ = self.complete("--mode", "pr")
953
+ self.assertEqual(result["status"], "PR")
954
+ (self.task / "product").write_text("unverified")
955
+ self.g("add", "product", work=self.task)
956
+ self.g("commit", "-m", "unverified", work=self.task)
957
+ _, r = self.complete(success=False)
958
+ self.assertNotEqual(r.returncode, 0)
959
+ self.assertEqual(json.loads(self.data.read_text()).get("merges", 0), 0)
960
+
961
+ def test_interrupted_external_effects(self):
962
+ self.allocate(); self.accept()
963
+ for effect in ("push", "create", "merge"):
964
+ self.configure(**{"interrupt_"+effect: True})
965
+ _, r = self.complete("--writers-stopped", success=False)
966
+ self.assertNotEqual(r.returncode, 0)
967
+ result, _ = self.complete("--writers-stopped")
968
+ self.assertEqual(result["status"], "COMPLETE")
969
+ d = json.loads(self.data.read_text())
970
+ self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
971
+
972
+ def test_retains_unknown_dirty_locked_and_active_tree(self):
973
+ self.allocate(linked=True); self.accept()
974
+ for case in ("ignored", "dirty", "untracked", "locked", "cwd", "no-yield"):
975
+ path = self.task / ("ignored/unknown" if case == "ignored" else "product" if case == "dirty" else "unknown")
976
+ if case in {"ignored", "dirty", "untracked"}:
977
+ path.parent.mkdir(exist_ok=True); path.write_text("do not delete")
978
+ if case == "locked": self.g("worktree", "lock", str(self.task))
979
+ with self.subTest(case=case):
980
+ _, r = self.complete(*([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
981
+ self.assertNotEqual(r.returncode, 0)
982
+ self.assertTrue(self.task.exists())
983
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
984
+ if case in {"ignored", "untracked"}: path.unlink()
985
+ if case == "dirty": path.write_text("accepted\n")
986
+ if case == "locked": self.g("worktree", "unlock", str(self.task))
987
+
988
+ def test_no_adoption_and_remote_head_race(self):
989
+ _, r = self.cli("allocate", "--repo", self.work, "--task", "foreign", "--branch", "main", "--repository", "test/project", "--base", "main", success=False)
990
+ self.assertNotEqual(r.returncode, 0)
991
+ self.allocate(); self.accept()
992
+ self.complete("--mode", "pr")
993
+ self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "refs/heads/task/fixture", self.g("rev-parse", "main")])
994
+ _, r = self.complete(success=False)
995
+ self.assertNotEqual(r.returncode, 0)
996
+ self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
997
+
998
+ def test_concurrent_completions(self):
999
+ self.allocate(); self.accept(); self.configure(pending=True)
1000
+ args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance)]
1001
+ procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for _ in range(2)]
1002
+ for proc in procs:
1003
+ out, err = proc.communicate(timeout=30)
1004
+ self.assertEqual(proc.returncode, 0, out+err)
1005
+ d = json.loads(self.data.read_text())
1006
+ self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
1007
+
1008
+ def test_custody_failure_and_evidence_tamper(self):
1009
+ self.allocate(linked=True); self.accept()
1010
+ bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1011
+ bad.parent.mkdir(parents=True)
1012
+ bad.write_text("partial interrupted copy")
1013
+ _, r = self.complete("--writers-stopped", success=False)
1014
+ self.assertNotEqual(r.returncode, 0)
1015
+ self.assertTrue(self.task.exists())
1016
+ self.assertEqual((self.task / ".devlyn/checks.txt").read_text(), "actual fixture check: accepted bytes\n")
1017
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1018
+ bad.unlink() # Owner repairs only the identified corrupt fixture copy.
1019
+ self.complete("--mode", "pr")
1020
+ (self.task / ".devlyn/checks.txt").write_text("altered after acceptance")
1021
+ _, r = self.complete("--mode", "auto", success=False)
1022
+ self.assertNotEqual(r.returncode, 0)
1023
+ self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
1024
+
1025
+ def test_actual_foreign_writer_and_registration(self):
1026
+ self.allocate(linked=True); self.accept()
1027
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
1028
+ try:
1029
+ _, r = self.complete("--writers-stopped", success=False)
1030
+ self.assertNotEqual(r.returncode, 0)
1031
+ self.assertTrue(self.task.exists())
1032
+ self.assertIn("active process", json.loads(r.stdout)["reason"])
1033
+ finally:
1034
+ child.terminate(); child.wait(timeout=5)
1035
+ # A foreign branch in the same registered tree never inherits ownership.
1036
+ self.g("switch", "-c", "foreign", work=self.task)
1037
+ _, r = self.complete("--writers-stopped", success=False)
1038
+ self.assertNotEqual(r.returncode, 0)
1039
+ self.assertTrue(self.task.exists())
1040
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1041
+
1042
+ def test_remote_compare_delete_race(self):
1043
+ self.allocate(); self.accept()
1044
+ race = self.g("rev-parse", "main")
1045
+ self.configure(remote_delete_race=True, race_sha=race)
1046
+ _, r = self.complete("--writers-stopped", success=False)
1047
+ self.assertNotEqual(r.returncode, 0)
1048
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
1049
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1050
+
1051
+ def test_policy_and_local_only_persist(self):
1052
+ self.allocate(); self.accept()
1053
+ self.configure(merge_allowed=False)
1054
+ _, r = self.complete(success=False)
1055
+ self.assertNotEqual(r.returncode, 0)
1056
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1057
+ self.complete("--mode", "pr")
1058
+ result, _ = self.complete()
1059
+ self.assertEqual(result["status"], "PR")
1060
+ self.configure(merge_allowed=True, auto_allowed=False, pending=True)
1061
+ _, r = self.complete("--mode", "auto", success=False)
1062
+ self.assertNotEqual(r.returncode, 0)
1063
+ self.assertIn("disallows auto merge", json.loads(r.stdout)["reason"])
1064
+ result, _ = self.complete("--no-push")
1065
+ self.assertEqual(result["status"], "LOCAL_ONLY")
1066
+ result, _ = self.complete(acceptance=False)
1067
+ self.assertEqual(result["status"], "LOCAL_ONLY")
1068
+
1069
+ def test_queue_commit_cannot_hide_product_changes(self):
1070
+ self.allocate(); self.accept(pipeline=True, queue=True)
1071
+ (self.task / "product").write_text("unverified extra\n")
1072
+ self.g("add", "product", work=self.task)
1073
+ self.g("commit", "--amend", "--no-edit", work=self.task)
1074
+ a = json.loads(self.acceptance.read_text())
1075
+ a["queue"]["commit"] = self.g("rev-parse", "HEAD", work=self.task)
1076
+ self.acceptance.write_text(json.dumps(a))
1077
+ _, r = self.complete(success=False)
1078
+ self.assertNotEqual(r.returncode, 0)
1079
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1080
+
1081
+ def test_pipeline_sealed_process_evidence(self):
1082
+ self.allocate(); self.accept(pipeline=True)
1083
+ evidence_module = shared("process-evidence")
1084
+ obligation = {"id": "actual-check", "phase": "verify", "argv": [sys.executable, "-c", "print('fixture passed')"], "exit_code": 0, "stdout_contains": ["fixture passed"], "timeout_sec": 10}
1085
+ relative = ".devlyn/process-evidence/fixture-run/verify/round-0/manifest.json"
1086
+ evidence_module["capture_process"](self.task, self.task / relative, "fixture-run", "verify", 0, obligation)
1087
+ carrier = evidence_module["validate_manifest"](self.task, relative, "fixture-run", "verify", 0, [obligation])
1088
+ results = {"commands": evidence_module["bound_carrier_summary_commands"](self.task, carrier), "process_evidence": carrier}
1089
+ self.state["phases"]["verify"]["round"] = 0
1090
+ self.state["process_evidence"] = [carrier]
1091
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1092
+ (self.archive / "spec-verify.results.json").write_text(json.dumps(results))
1093
+ shutil.move(str(self.task / ".devlyn/process-evidence"), str(self.archive / "process-evidence"))
1094
+ stdout = self.archive / carrier["streams"][0]["stdout"]["path"].removeprefix(".devlyn/")
1095
+ before = stdout.read_bytes()
1096
+ stdout.write_text("tampered")
1097
+ _, r = self.complete(success=False)
1098
+ self.assertNotEqual(r.returncode, 0)
1099
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1100
+ stdout.write_bytes(before)
1101
+ result, _ = self.complete("--mode", "pr")
1102
+ self.assertEqual(result["status"], "PR")
1103
+
1104
+ def test_in_place_resume_does_not_overwrite_ignored_collision(self):
1105
+ self.allocate(); self.accept()
1106
+ user_file = self.task / "ignored/user.txt"
1107
+ user_file.parent.mkdir()
1108
+ user_file.write_text("retained user data")
1109
+ self.configure(interrupt_delete=True)
1110
+ _, r = self.complete("--writers-stopped", success=False)
1111
+ self.assertNotEqual(r.returncode, 0)
1112
+ self.assertEqual(self.g("branch", "--show-current"), "main")
1113
+ other = self.root / "advance"
1114
+ self.run_cmd(["git", "clone", str(self.bare), str(other)])
1115
+ (other / "ignored").mkdir()
1116
+ (other / "ignored/user.txt").write_text("new upstream tracked file")
1117
+ self.g("add", "-f", "ignored/user.txt", work=other)
1118
+ self.g("commit", "-m", "upstream collision", work=other)
1119
+ self.g("push", "origin", "main", work=other)
1120
+ _, r = self.complete("--writers-stopped", acceptance=False, success=False)
1121
+ self.assertNotEqual(r.returncode, 0)
1122
+ self.assertEqual(user_file.read_text(), "retained user data")
1123
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1124
+
1125
+ def test_replaced_tree_and_unsafe_custody(self):
1126
+ self.allocate(linked=True); self.accept()
1127
+ elsewhere = self.root / "elsewhere"
1128
+ elsewhere.mkdir()
1129
+ (self.receipt.parent / "custody").symlink_to(elsewhere, target_is_directory=True)
1130
+ _, r = self.complete(success=False)
1131
+ self.assertNotEqual(r.returncode, 0)
1132
+ self.assertEqual(list(elsewhere.iterdir()), [])
1133
+ (self.receipt.parent / "custody").unlink()
1134
+ old = self.root / "old-tree"
1135
+ self.task.rename(old)
1136
+ shutil.copytree(old, self.task)
1137
+ _, r = self.complete(success=False)
1138
+ self.assertNotEqual(r.returncode, 0)
1139
+ self.assertIn("replaced", json.loads(r.stdout)["reason"])
1140
+ self.assertTrue(self.task.exists())
1141
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1142
+
1143
+ def test_spec_source_and_report_binding(self):
1144
+ self.allocate(); self.accept(pipeline=True, spec_expected={"verification_commands": []})
1145
+ report = self.archive / "final-report.md"
1146
+ original = report.read_bytes()
1147
+ report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n")
1148
+ self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(report.read_bytes()).hexdigest()
1149
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1150
+ _, r = self.complete(success=False)
1151
+ self.assertNotEqual(r.returncode, 0)
1152
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1153
+ report.write_bytes(original)
1154
+ self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(original).hexdigest()
1155
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1156
+ result, _ = self.complete("--mode", "pr")
1157
+ self.assertEqual(result["status"], "PR")
1158
+
1159
+ def test_missing_required_process_evidence(self):
1160
+ expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
1161
+ self.allocate(); self.accept(pipeline=True, spec_expected=expected)
1162
+ _, r = self.complete(success=False)
1163
+ self.assertNotEqual(r.returncode, 0)
1164
+ self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1165
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1166
+
1167
+
1168
+ if __name__ == "__main__":
1169
+ sys.exit(main())