devlyn-cli 4.0.1 → 4.2.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.
- package/AGENTS.md +11 -95
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +83 -114
- package/bin/devlyn.js +330 -472
- package/bin/instruction-templates.json +120 -2
- package/bin/instructions.js +35 -8
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/package.json +2 -3
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- package/config/templates/template-product-spec.md +0 -680
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Outer-owner delivery and recoverable cleanup (task-completion spec, R1–7).
|
|
3
3
|
|
|
4
4
|
No phase/state writer, staging, scheduler, or implicit adoption. See
|
|
5
|
-
|
|
5
|
+
_shared/task-completion.md for the owner acceptance contract.
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
@@ -28,6 +28,14 @@ class CompletionError(Exception):
|
|
|
28
28
|
pass
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
class WritersUnobservable(CompletionError):
|
|
32
|
+
"""Writer cessation cannot be observed here, so owned resources are retained, never deleted."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WriterActive(CompletionError):
|
|
36
|
+
"""An observed process still uses the owned files: wait for it or stop it, then resume."""
|
|
37
|
+
|
|
38
|
+
|
|
31
39
|
def require(condition, message):
|
|
32
40
|
if not condition:
|
|
33
41
|
raise CompletionError(message)
|
|
@@ -50,7 +58,7 @@ def shared(name):
|
|
|
50
58
|
|
|
51
59
|
def read_json(path):
|
|
52
60
|
require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
|
|
53
|
-
return shared("
|
|
61
|
+
return shared("expected-contract")["loads_strict_json"](path.read_text(encoding="utf-8"))
|
|
54
62
|
|
|
55
63
|
|
|
56
64
|
def atomic_json(path, value):
|
|
@@ -180,19 +188,24 @@ def allocate(args):
|
|
|
180
188
|
common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
|
181
189
|
require(args.task.strip(), "task identity is required")
|
|
182
190
|
git(work, "check-ref-format", "refs/heads/" + args.branch)
|
|
183
|
-
|
|
191
|
+
if args.base is not None:
|
|
192
|
+
git(work, "check-ref-format", "refs/heads/" + args.base)
|
|
184
193
|
require(args.branch != args.base and args.branch not in {"main", "master"}, "cannot own a base/default branch")
|
|
185
194
|
require(re.fullmatch(r"[A-Za-z0-9_.-]+", args.remote), "unsafe remote name")
|
|
186
195
|
receipt = {"task": args.task, "repository": args.repository, "remote": args.remote,
|
|
187
196
|
"base": args.base, "branch": args.branch, "common_gitdir": str(common),
|
|
188
197
|
"anchor": str(work), "linked": True, "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
198
|
require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
local = local_baseline(receipt, args)
|
|
200
|
+
if local:
|
|
201
|
+
receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
|
|
202
|
+
else:
|
|
203
|
+
require(args.repository and args.base, "a remote allocation needs --repository <owner/repo> and --base <branch>; allocate local work with --local-base or --from-receipt")
|
|
204
|
+
policy(receipt, None)
|
|
205
|
+
receipt["remote_url"] = remote_url(receipt)
|
|
206
|
+
require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
|
|
207
|
+
receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
|
|
208
|
+
target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
|
|
196
209
|
require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
|
|
197
210
|
receipt["worktree"] = str(target)
|
|
198
211
|
key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
|
|
@@ -213,78 +226,49 @@ def allocate(args):
|
|
|
213
226
|
receipt["allocation"] = "owned"
|
|
214
227
|
atomic_json(path, receipt)
|
|
215
228
|
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
|
|
216
|
-
"reconciled": reconcile(common, path, work)}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
require(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
try:
|
|
260
|
-
for validator in ("dynamic_evidence_artifacts", "dynamic_invocation_artifacts", "dynamic_judge_role_artifacts"):
|
|
261
|
-
archive_module[validator](devlyn, state)
|
|
262
|
-
except archive_module["ArchiveError"] as exc:
|
|
263
|
-
raise CompletionError(str(exc)) from exc
|
|
264
|
-
evidence_module = shared("process-evidence")
|
|
265
|
-
for carrier in state.get("process_evidence") or []:
|
|
266
|
-
if carrier["round"] == (phases.get(carrier["phase"]) or {}).get("round"):
|
|
267
|
-
require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
|
|
268
|
-
source = state.get("source") or {}
|
|
269
|
-
if source.get("spec_path"):
|
|
270
|
-
expected = Path(source["spec_path"]).with_name("spec.expected.json")
|
|
271
|
-
original = safe_path(work, str(expected))
|
|
272
|
-
if original.exists():
|
|
273
|
-
dest = safe_path(reconstructed, str(expected))
|
|
274
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
-
shutil.copyfile(original, dest)
|
|
276
|
-
for phase in ("implement", "build_gate", "verify"):
|
|
277
|
-
obligations = evidence_module["declared_obligations"](reconstructed, state, phase)
|
|
278
|
-
if obligations:
|
|
279
|
-
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")]
|
|
280
|
-
require(len(carriers) == 1, f"missing/ambiguous current {phase} process evidence")
|
|
281
|
-
carrier = carriers[0]
|
|
282
|
-
evidence_module["validate_manifest"](reconstructed, carrier["manifest"]["path"], run_id, phase, carrier["round"], obligations)
|
|
283
|
-
if evidence_module["mechanical_evidence_required"](reconstructed, state):
|
|
284
|
-
require((devlyn / "spec-verify.results.json").is_file(), "required MECHANICAL results are missing")
|
|
285
|
-
carrier = shared("verify-merge-findings")["mechanical_evidence_carrier"](devlyn)
|
|
286
|
-
if carrier:
|
|
287
|
-
require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "MECHANICAL checks did not pass")
|
|
229
|
+
"reconciled": [] if local else reconcile(common, path, work)}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def exact_commit(receipt, value, flag):
|
|
233
|
+
require(value is None or re.fullmatch(r"[0-9a-f]{40,64}", value) and ref_sha(receipt, value+"^{commit}") == value,
|
|
234
|
+
f"{flag} must name an exact local commit")
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def local_baseline(receipt, args):
|
|
239
|
+
"""A local loop starts from its recorded base commit or a receipt-bound accepted predecessor, never a fetch."""
|
|
240
|
+
require(sum(map(bool, (args.local_base, args.from_receipt, args.start))) <= 1, "use one of --local-base, --from-receipt or --start")
|
|
241
|
+
if args.from_receipt:
|
|
242
|
+
with locked_receipt(Path(args.from_receipt).absolute(), blocking=False) as predecessor:
|
|
243
|
+
require(predecessor["common_gitdir"] == receipt["common_gitdir"], "predecessor receipt belongs to another repository")
|
|
244
|
+
require(predecessor.get("acceptance") and predecessor.get("product") != "FAILED", "predecessor has no accepted result")
|
|
245
|
+
require(ref_sha(predecessor, predecessor["recovery_ref"]) == predecessor["publish_sha"], "predecessor recovery ref changed")
|
|
246
|
+
gref(predecessor, "merge-base", "--is-ancestor", predecessor["source_sha"], predecessor["publish_sha"])
|
|
247
|
+
receipt["allocated_from"] = {"receipt": predecessor["id"], "source_sha": predecessor["source_sha"]}
|
|
248
|
+
return predecessor["source_sha"]
|
|
249
|
+
return exact_commit(receipt, args.local_base, "--local-base")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def refuse_retired_pipeline(receipt, path, supplied, local, flags):
|
|
253
|
+
"""Refuse an unbound acceptance of an archived resolve run before anything is written: binding its archive
|
|
254
|
+
needed the retired helpers. An unreadable acceptance is not classified here; binding reports it as before."""
|
|
255
|
+
if receipt.get("acceptance") or not supplied:
|
|
256
|
+
return
|
|
257
|
+
acceptance_path = Path(supplied).absolute()
|
|
258
|
+
try:
|
|
259
|
+
acceptance = read_json(acceptance_path)
|
|
260
|
+
except (CompletionError, OSError, ValueError):
|
|
261
|
+
return
|
|
262
|
+
if not isinstance(acceptance, dict) or acceptance.get("kind") != "pipeline":
|
|
263
|
+
return
|
|
264
|
+
command = shlex.join(["python3", "package/config/skills/_shared/task-complete.py", "complete", "--receipt", str(path),
|
|
265
|
+
"--acceptance", str(acceptance_path), *(["--local-only"] if local else []), *flags])
|
|
266
|
+
raise CompletionError(
|
|
267
|
+
"pipeline acceptance (an archived resolve run) was retired after devlyn-cli 4.1.0; nothing was changed. Finish the run "
|
|
268
|
+
f"with that release: run `npm pack devlyn-cli@4.1.0`, extract the tarball, then run `{command}` there. "
|
|
269
|
+
+ ("That local-only completion ends as LOCAL_ONLY without binding the acceptance, as every 4.1.0 local-only completion did."
|
|
270
|
+
if local else "That completion binds the pipeline acceptance before publishing; delivery of a receipt it binds then "
|
|
271
|
+
"resumes with this helper."))
|
|
288
272
|
|
|
289
273
|
|
|
290
274
|
def bind_acceptance(receipt, path, supplied):
|
|
@@ -295,7 +279,8 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
295
279
|
verify_files(path.parent / "custody", receipt["files"])
|
|
296
280
|
return
|
|
297
281
|
require(supplied, "first completion requires explicit root acceptance")
|
|
298
|
-
|
|
282
|
+
named = Path(supplied).absolute()
|
|
283
|
+
acceptance_path = named.parent.resolve() / named.name # Its directory's real path, as allocate names the worktree.
|
|
299
284
|
require(acceptance_path.is_relative_to(work), "acceptance must be a regular file in its task checkout")
|
|
300
285
|
safe_path(work, str(acceptance_path.relative_to(work)))
|
|
301
286
|
acceptance = read_json(acceptance_path)
|
|
@@ -304,14 +289,10 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
304
289
|
require(isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40,64}", sha), "acceptance must name an exact commit")
|
|
305
290
|
require(gref(receipt, "rev-parse", sha+"^{commit}") == sha, "source is not a commit")
|
|
306
291
|
gref(receipt, "merge-base", "--is-ancestor", receipt["baseline"], sha)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
safe_path(work, queue["file"])
|
|
312
|
-
require(gref(receipt, "rev-list", "--parents", "-n", "1", publish).split() == [publish, sha], "queue commit must be directly atop verified source with one parent")
|
|
313
|
-
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")
|
|
314
|
-
require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == publish and git(work, "rev-parse", "HEAD") == publish, "unverified source delta or changed task ref")
|
|
292
|
+
# A failed loop result is never published, so its checkout stays as the executor left it (as in attach).
|
|
293
|
+
failed = acceptance.get("kind") == "loop" and acceptance.get("verdict") == "FAILED"
|
|
294
|
+
require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == sha and (failed or git(work, "rev-parse", "HEAD") == sha),
|
|
295
|
+
"unverified source delta or changed task ref")
|
|
315
296
|
paths = [str(acceptance_path.relative_to(work))]
|
|
316
297
|
kind = acceptance.get("kind")
|
|
317
298
|
if kind == "direct":
|
|
@@ -320,42 +301,66 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
320
301
|
for check in checks:
|
|
321
302
|
require(isinstance(check.get("command"), str) and check["command"].strip(), "direct check must name its command")
|
|
322
303
|
paths.append(check["evidence"])
|
|
323
|
-
elif kind == "
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if source.get(key):
|
|
329
|
-
if key == "criteria_path" and source.get("type") == "generated":
|
|
330
|
-
require(source[key] == ".devlyn/criteria.generated.md", "generated criteria path is not canonical")
|
|
331
|
-
relative = f".devlyn/runs/{acceptance['run_id']}/criteria.generated.md"
|
|
332
|
-
else:
|
|
333
|
-
relative = source[key]
|
|
334
|
-
paths.append(relative)
|
|
335
|
-
source_file = safe_path(work, relative)
|
|
336
|
-
if relative == source[key]:
|
|
337
|
-
committed = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "show", sha+":"+relative], capture_output=True)
|
|
338
|
-
require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
|
|
339
|
-
bound_digest = source.get(key.replace("_path", "_sha256"))
|
|
340
|
-
require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
|
|
341
|
-
source_path = Path(source[key])
|
|
342
|
-
expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
|
|
343
|
-
if safe_path(work, expected).exists():
|
|
344
|
-
paths.append(expected)
|
|
304
|
+
elif kind == "loop":
|
|
305
|
+
require(acceptance.get("verdict") in {"ACCEPTED", "FAILED"}, "loop result verdict must be ACCEPTED or FAILED")
|
|
306
|
+
evidence = acceptance.get("evidence")
|
|
307
|
+
require(isinstance(evidence, list) and all(isinstance(p, str) for p in evidence), "loop result must list its evidence files")
|
|
308
|
+
paths.extend(evidence)
|
|
345
309
|
else:
|
|
346
|
-
raise CompletionError("acceptance kind must be direct|
|
|
310
|
+
raise CompletionError("acceptance kind must be direct|loop")
|
|
347
311
|
files = snapshot_files(work, paths)
|
|
348
|
-
if kind == "pipeline":
|
|
349
|
-
pipeline_acceptance(work, acceptance, files, path.parent)
|
|
350
312
|
custody(work, path.parent / "custody", files)
|
|
351
313
|
recovery = ref_sha(receipt, receipt["recovery_ref"])
|
|
352
|
-
require(recovery in {None,
|
|
314
|
+
require(recovery in {None, sha}, "recovery ref changed")
|
|
353
315
|
if recovery is None:
|
|
354
|
-
gref(receipt, "update-ref", receipt["recovery_ref"],
|
|
355
|
-
receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=
|
|
316
|
+
gref(receipt, "update-ref", receipt["recovery_ref"], sha, "0"*len(sha))
|
|
317
|
+
receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=sha, files=files)
|
|
318
|
+
if acceptance.get("verdict") == "FAILED":
|
|
319
|
+
# Failed results keep custody and a recovery ref, but never become a frontier or a publishable product.
|
|
320
|
+
receipt["product"] = "FAILED"
|
|
356
321
|
atomic_json(path, receipt)
|
|
357
322
|
|
|
358
323
|
|
|
324
|
+
def queue_commit(receipt, work, queue, source):
|
|
325
|
+
safe_path(work, queue["file"])
|
|
326
|
+
commit = queue["commit"]
|
|
327
|
+
require(gref(receipt, "rev-list", "--parents", "-n", "1", commit).split() == [commit, source], "queue commit must be directly atop verified source with one parent")
|
|
328
|
+
require(gref(receipt, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit).strip("\0") == queue["file"], "queue commit must change exactly the declared queue file")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def accept(args):
|
|
332
|
+
"""Bind a result's source and evidence custody before terminal metadata or any delivery decision."""
|
|
333
|
+
path = Path(args.receipt).absolute()
|
|
334
|
+
with locked_receipt(path) as receipt:
|
|
335
|
+
refuse_retired_pipeline(receipt, path, args.acceptance, receipt.get("local_only"), [])
|
|
336
|
+
bind_acceptance(receipt, path, args.acceptance)
|
|
337
|
+
return {"status": "FAILED" if receipt.get("product") == "FAILED" else "ACCEPTED", "receipt": str(path),
|
|
338
|
+
"source_sha": receipt["source_sha"], "recovery_ref": receipt["recovery_ref"]}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def attach(args):
|
|
342
|
+
"""Attach the queue-only terminal commit to an already bound result; the recovery ref moves with it."""
|
|
343
|
+
path = Path(args.receipt).absolute()
|
|
344
|
+
queue = {"commit": args.commit, "file": args.file}
|
|
345
|
+
with locked_receipt(path) as receipt:
|
|
346
|
+
require(receipt.get("acceptance"), "bind the result before attaching its terminal commit")
|
|
347
|
+
if receipt.get("queue") != queue:
|
|
348
|
+
require(not receipt.get("queue"), "a different terminal commit is already attached")
|
|
349
|
+
work = Path(receipt["worktree"])
|
|
350
|
+
queue_commit(receipt, work, queue, receipt["source_sha"])
|
|
351
|
+
# A failed result is never published, so its checkout stays as left; a publishable one must be checked out.
|
|
352
|
+
require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == args.commit
|
|
353
|
+
and (receipt.get("product") == "FAILED" or git(work, "rev-parse", "HEAD") == args.commit),
|
|
354
|
+
"task ref and HEAD must be the terminal commit")
|
|
355
|
+
recovery = ref_sha(receipt, receipt["recovery_ref"])
|
|
356
|
+
require(recovery in {receipt["source_sha"], args.commit}, "recovery ref changed")
|
|
357
|
+
if recovery != args.commit:
|
|
358
|
+
gref(receipt, "update-ref", receipt["recovery_ref"], args.commit, receipt["source_sha"])
|
|
359
|
+
receipt.update(queue=queue, publish_sha=args.commit)
|
|
360
|
+
atomic_json(path, receipt)
|
|
361
|
+
return {"status": "ATTACHED", "receipt": str(path), "source_sha": receipt["source_sha"], "terminal_sha": args.commit}
|
|
362
|
+
|
|
363
|
+
|
|
359
364
|
PR_FIELDS = "number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest"
|
|
360
365
|
|
|
361
366
|
|
|
@@ -428,13 +433,14 @@ def stopped_writers(work):
|
|
|
428
433
|
# other currently open files/cwds, not future writers or a universal lease.
|
|
429
434
|
if sys.platform == "darwin":
|
|
430
435
|
result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True, encoding="utf-8")
|
|
431
|
-
|
|
436
|
+
if result.returncode not in {0, 1} or result.stderr.strip():
|
|
437
|
+
raise WritersUnobservable("writer observation unavailable; retain tree and inspect writers")
|
|
432
438
|
pid = None
|
|
433
439
|
for line in result.stdout.splitlines():
|
|
434
440
|
if line.startswith("p"):
|
|
435
441
|
pid = int(line[1:])
|
|
436
442
|
elif line.startswith("n") and pid != os.getpid():
|
|
437
|
-
raise
|
|
443
|
+
raise WriterActive(f"active process {pid} uses task files; stop/yield actual writers before resume")
|
|
438
444
|
elif sys.platform.startswith("linux"):
|
|
439
445
|
for process in Path("/proc").iterdir():
|
|
440
446
|
if not process.name.isdigit() or int(process.name) == os.getpid():
|
|
@@ -448,13 +454,14 @@ def stopped_writers(work):
|
|
|
448
454
|
if link == process / "cwd":
|
|
449
455
|
break
|
|
450
456
|
continue
|
|
451
|
-
|
|
457
|
+
if target.is_absolute() and target.is_relative_to(work):
|
|
458
|
+
raise WriterActive(f"active process {process.name} uses task files; stop/yield it before resume")
|
|
452
459
|
except FileNotFoundError:
|
|
453
460
|
continue # Process exited during observation.
|
|
454
461
|
except PermissionError as exc:
|
|
455
|
-
raise
|
|
462
|
+
raise WritersUnobservable("unknown process access; retain tree until writer cessation can be established") from exc
|
|
456
463
|
else:
|
|
457
|
-
raise
|
|
464
|
+
raise WritersUnobservable("writer observation unsupported on this platform; retain workspace")
|
|
458
465
|
|
|
459
466
|
|
|
460
467
|
def scratch_mounts():
|
|
@@ -579,6 +586,7 @@ def cleanup(receipt, path, pr):
|
|
|
579
586
|
if ref_sha(receipt, branch_ref) is not None:
|
|
580
587
|
require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch became checked out; retain ref")
|
|
581
588
|
gref(receipt, "update-ref", "-d", branch_ref, sha)
|
|
589
|
+
receipt.pop("workspace_cleanup", None)
|
|
582
590
|
receipt["status"] = "COMPLETE"
|
|
583
591
|
atomic_json(path, receipt)
|
|
584
592
|
|
|
@@ -600,6 +608,9 @@ def locked_receipt(path, *, blocking=True):
|
|
|
600
608
|
|
|
601
609
|
|
|
602
610
|
def completion_result(receipt, path, status):
|
|
611
|
+
if receipt.get("delivery") != status:
|
|
612
|
+
receipt["delivery"] = status # Phase-independent checkpoint: the latest delivery outcome.
|
|
613
|
+
atomic_json(path, receipt)
|
|
603
614
|
released = receipt.get("writers_released", False)
|
|
604
615
|
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
|
|
605
616
|
(["--writers-stopped"] if released else []))
|
|
@@ -615,11 +626,12 @@ def completion_result(receipt, path, status):
|
|
|
615
626
|
atomic_json(path, receipt)
|
|
616
627
|
except OSError as record_error:
|
|
617
628
|
scratch["record_error"] = str(record_error)
|
|
618
|
-
|
|
629
|
+
retained = scratch["status"] == "RETAINED" or "workspace_cleanup" in receipt
|
|
630
|
+
return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and retained else status,
|
|
619
631
|
"delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
|
|
620
632
|
"acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
|
|
621
633
|
"scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
|
|
622
|
-
"scratch_cleanup": scratch}
|
|
634
|
+
"scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
|
|
623
635
|
|
|
624
636
|
|
|
625
637
|
def reconcile(common, allocated, anchor):
|
|
@@ -659,6 +671,9 @@ def reconcile(common, allocated, anchor):
|
|
|
659
671
|
def complete(args):
|
|
660
672
|
path = Path(args.receipt).absolute()
|
|
661
673
|
with locked_receipt(path) as receipt:
|
|
674
|
+
local = args.local_only or receipt.get("local_only")
|
|
675
|
+
refuse_retired_pipeline(receipt, path, args.acceptance, local, [*(["--mode", args.mode] if args.mode and not local else []),
|
|
676
|
+
*(["--writers-stopped"] if args.writers_stopped else [])])
|
|
662
677
|
if args.writers_stopped:
|
|
663
678
|
if receipt["linked"]:
|
|
664
679
|
outside(Path(receipt["worktree"]))
|
|
@@ -666,9 +681,18 @@ def complete(args):
|
|
|
666
681
|
atomic_json(path, receipt)
|
|
667
682
|
def result(status):
|
|
668
683
|
return completion_result(receipt, path, status)
|
|
684
|
+
if receipt.get("product") == "FAILED":
|
|
685
|
+
return result("FAILED")
|
|
669
686
|
if args.local_only or receipt.get("local_only"):
|
|
687
|
+
require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
|
|
688
|
+
"--local-only cannot rewrite its delivery, resume it without --local-only")
|
|
670
689
|
receipt["local_only"] = True
|
|
671
690
|
atomic_json(path, receipt)
|
|
691
|
+
if args.acceptance or receipt.get("acceptance"):
|
|
692
|
+
# Persist acceptance custody before the local-only return.
|
|
693
|
+
if not receipt.get("acceptance"):
|
|
694
|
+
inspect_workspace(dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"])))
|
|
695
|
+
bind_acceptance(receipt, path, args.acceptance)
|
|
672
696
|
return result("LOCAL_ONLY")
|
|
673
697
|
mode = policy(receipt, args.mode or receipt.get("mode_override"))
|
|
674
698
|
if args.mode:
|
|
@@ -737,7 +761,13 @@ def complete(args):
|
|
|
737
761
|
return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
|
|
738
762
|
receipt["merge"] = pr
|
|
739
763
|
atomic_json(path, receipt)
|
|
740
|
-
|
|
764
|
+
try:
|
|
765
|
+
cleanup(receipt, path, pr)
|
|
766
|
+
except (WritersUnobservable, WriterActive) as error:
|
|
767
|
+
# Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
|
|
768
|
+
receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
769
|
+
sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
|
|
770
|
+
atomic_json(path, receipt)
|
|
741
771
|
return result("COMPLETE")
|
|
742
772
|
|
|
743
773
|
|
|
@@ -747,10 +777,22 @@ def main():
|
|
|
747
777
|
actions = parser.add_subparsers(dest="action")
|
|
748
778
|
allocation = actions.add_parser("allocate")
|
|
749
779
|
allocation.add_argument("--repo", default=".")
|
|
750
|
-
for name in ("task", "branch"
|
|
780
|
+
for name in ("task", "branch"):
|
|
751
781
|
allocation.add_argument("--"+name, required=True)
|
|
782
|
+
allocation.add_argument("--base")
|
|
783
|
+
allocation.add_argument("--repository")
|
|
752
784
|
allocation.add_argument("--remote", default="origin")
|
|
753
785
|
allocation.add_argument("--worktree", required=True)
|
|
786
|
+
allocation.add_argument("--local-base")
|
|
787
|
+
allocation.add_argument("--from-receipt")
|
|
788
|
+
allocation.add_argument("--start")
|
|
789
|
+
binding = actions.add_parser("accept")
|
|
790
|
+
binding.add_argument("--receipt", required=True)
|
|
791
|
+
binding.add_argument("--acceptance", required=True)
|
|
792
|
+
attachment = actions.add_parser("attach")
|
|
793
|
+
attachment.add_argument("--receipt", required=True)
|
|
794
|
+
attachment.add_argument("--commit", required=True)
|
|
795
|
+
attachment.add_argument("--file", default="docs/specs/queue.md")
|
|
754
796
|
completion = actions.add_parser("complete")
|
|
755
797
|
completion.add_argument("--receipt", required=True)
|
|
756
798
|
completion.add_argument("--acceptance")
|
|
@@ -764,16 +806,17 @@ def main():
|
|
|
764
806
|
if args.self_test:
|
|
765
807
|
return self_test()
|
|
766
808
|
if args.action is None:
|
|
767
|
-
parser.error("allocate, complete or clean-scratch is required")
|
|
809
|
+
parser.error("allocate, accept, attach, complete or clean-scratch is required")
|
|
768
810
|
try:
|
|
769
|
-
result =
|
|
770
|
-
|
|
811
|
+
result = {"allocate": allocate, "accept": accept, "attach": attach,
|
|
812
|
+
"clean-scratch": clean_scratch_command, "complete": complete}[args.action](args)
|
|
771
813
|
print(json.dumps(result, sort_keys=True))
|
|
772
814
|
return 0
|
|
773
815
|
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
|
|
774
816
|
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
775
|
-
if args.action
|
|
817
|
+
if args.action != "allocate":
|
|
776
818
|
result["receipt"] = str(Path(args.receipt).absolute())
|
|
819
|
+
if args.action in {"complete", "clean-scratch"}:
|
|
777
820
|
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
|
|
778
821
|
print(json.dumps(result, sort_keys=True))
|
|
779
822
|
return 1
|
|
@@ -794,6 +837,7 @@ FAKE_GH = r'''#!/usr/bin/env python3
|
|
|
794
837
|
import json, os, pathlib, subprocess, sys
|
|
795
838
|
p = pathlib.Path(os.environ['FIXTURE_GH'])
|
|
796
839
|
d = json.loads(p.read_text(encoding="utf-8")); a = sys.argv[1:]
|
|
840
|
+
prs = d.setdefault('prs', []) # PR number n is prs[n-1]; several may be open at once.
|
|
797
841
|
def save(): p.write_text(json.dumps(d), encoding="utf-8")
|
|
798
842
|
def remote(ref):
|
|
799
843
|
r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True, encoding="utf-8")
|
|
@@ -803,38 +847,44 @@ if a[:2] == ['repo', 'view']:
|
|
|
803
847
|
print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
|
|
804
848
|
elif a[:2] == ['pr', 'list']:
|
|
805
849
|
assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
|
|
806
|
-
print(json.dumps([
|
|
850
|
+
print(json.dumps([pr for pr in prs if pr['headRefName'] == a[a.index('--head')+1]]))
|
|
807
851
|
elif a[:2] == ['pr', 'create']:
|
|
808
852
|
d['creates'] = d.get('creates',0)+1
|
|
809
|
-
assert not d.get('pr'), 'duplicate PR'
|
|
810
853
|
head = a[a.index('--head')+1]; base = a[a.index('--base')+1]
|
|
811
|
-
|
|
854
|
+
assert not [pr for pr in prs if pr['headRefName'] == head], 'duplicate PR'
|
|
855
|
+
url = 'https://github.com/test/project/pull/%d' % (len(prs)+1)
|
|
856
|
+
prs.append({'number':len(prs)+1,'url':url,'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})
|
|
812
857
|
save()
|
|
813
|
-
print(
|
|
858
|
+
print(url)
|
|
814
859
|
if d.pop('interrupt_create',False): save(); sys.exit(1)
|
|
815
860
|
elif a[:2] == ['pr', 'view']:
|
|
816
|
-
print(json.dumps(
|
|
861
|
+
print(json.dumps(prs[int(a[2])-1]))
|
|
817
862
|
elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
|
|
818
863
|
d['disables'] = d.get('disables',0)+1
|
|
819
|
-
|
|
864
|
+
prs[int(a[2])-1]['autoMergeRequest'] = None
|
|
820
865
|
save()
|
|
821
866
|
elif a[:2] == ['pr', 'merge']:
|
|
822
867
|
assert '--admin' not in a and '--delete-branch' not in a
|
|
823
868
|
assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
|
|
869
|
+
pr = prs[int(a[2])-1]
|
|
824
870
|
sha = a[a.index('--match-head-commit')+1]
|
|
825
|
-
assert sha == remote('refs/heads/'+
|
|
871
|
+
assert sha == remote('refs/heads/'+pr['headRefName'])
|
|
826
872
|
d['merges'] = d.get('merges',0)+1
|
|
827
873
|
if not d.get('auto_allowed',True) and d.get('pending'):
|
|
828
874
|
save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
|
|
829
875
|
if d.get('pending'):
|
|
830
|
-
|
|
876
|
+
pr['autoMergeRequest'] = {'enabledAt':'now'}
|
|
831
877
|
else:
|
|
832
878
|
g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
|
|
833
879
|
base = remote('refs/heads/main')
|
|
834
|
-
|
|
835
|
-
|
|
880
|
+
# A three-way merge into the current base, as GitHub makes it; a conflicting PR does not merge.
|
|
881
|
+
merged = subprocess.run(g+['merge-tree','--write-tree',base,sha],capture_output=True,text=True,encoding="utf-8")
|
|
882
|
+
if merged.returncode:
|
|
883
|
+
save(); print('pull request is not mergeable: '+merged.stdout,file=sys.stderr); sys.exit(1)
|
|
884
|
+
parents = ['-p',base] if d.get('squash') else ['-p',base,'-p',sha]
|
|
885
|
+
merge = subprocess.check_output(g+['commit-tree',merged.stdout.split()[0],*parents,'-m','merge fixture'],text=True, encoding="utf-8").strip()
|
|
836
886
|
subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
|
|
837
|
-
|
|
887
|
+
pr.update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
|
|
838
888
|
save()
|
|
839
889
|
if d.pop('interrupt_merge',False): save(); sys.exit(1)
|
|
840
890
|
else:
|
|
@@ -854,7 +904,7 @@ import json, os, pathlib, subprocess, sys
|
|
|
854
904
|
a = sys.argv[1:]
|
|
855
905
|
p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text(encoding="utf-8"))
|
|
856
906
|
if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
|
|
857
|
-
subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['
|
|
907
|
+
subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['prs'][0]['headRefName'],d['race_sha']])
|
|
858
908
|
p.write_text(json.dumps(d), encoding="utf-8")
|
|
859
909
|
for operation in ('push', 'fetch', 'ls-remote'):
|
|
860
910
|
if operation in a and 'origin' in a:
|
|
@@ -1069,7 +1119,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1069
1119
|
return str(self.work / "product")
|
|
1070
1120
|
return readlink(path, *args, **kwargs)
|
|
1071
1121
|
with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(os, "readlink", target):
|
|
1072
|
-
with self.assertRaisesRegex(
|
|
1122
|
+
with self.assertRaisesRegex(WriterActive, "active process " + process.name):
|
|
1073
1123
|
stopped_writers(self.work)
|
|
1074
1124
|
|
|
1075
1125
|
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
@@ -1169,11 +1219,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1169
1219
|
with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
|
|
1170
1220
|
scratch_mounts()
|
|
1171
1221
|
|
|
1172
|
-
def accept(self
|
|
1173
|
-
if spec_expected is not None:
|
|
1174
|
-
(self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
|
|
1175
|
-
(self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
|
|
1176
|
-
self.g("add", spec_name, "spec.expected.json", work=self.task)
|
|
1222
|
+
def accept(self):
|
|
1177
1223
|
(self.task / "product").write_text("accepted\n", encoding="utf-8")
|
|
1178
1224
|
self.g("add", "product", work=self.task)
|
|
1179
1225
|
self.g("commit", "-m", "scoped task", work=self.task)
|
|
@@ -1184,32 +1230,6 @@ class CompletionTests(unittest.TestCase):
|
|
|
1184
1230
|
a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
|
|
1185
1231
|
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
|
|
1186
1232
|
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
|
|
1187
|
-
if pipeline:
|
|
1188
|
-
a = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
|
|
1189
|
-
archive = evidence / "runs" / a["run_id"]
|
|
1190
|
-
archive.mkdir(parents=True)
|
|
1191
|
-
self.archive = archive
|
|
1192
|
-
report = "<!-- devlyn:final-report run_id=fixture-run -->\nFixture completed.\n"
|
|
1193
|
-
(archive / "final-report.md").write_text(report, encoding="utf-8")
|
|
1194
|
-
phases = {n: {"started_at": "2026-09-10T00:00:00Z", "completed_at": "2026-09-10T00:00:01Z", "verdict": "PASS"}
|
|
1195
|
-
for n in ("plan", "implement", "build_gate", "cleanup", "verify", "final_report")}
|
|
1196
|
-
phases["cleanup"]["post_sha"] = self.sha
|
|
1197
|
-
phases["final_report"].update(output_sha256=hashlib.sha256(report.encode()).hexdigest(), artifacts={"log_file": ".devlyn/final-report.md"})
|
|
1198
|
-
criteria = "# Fixture acceptance\nProduct contains accepted bytes.\n"
|
|
1199
|
-
(archive / "criteria.generated.md").write_text(criteria, encoding="utf-8")
|
|
1200
|
-
source = {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md", "criteria_sha256": hashlib.sha256(criteria.encode()).hexdigest()}
|
|
1201
|
-
self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
|
|
1202
|
-
if spec_expected is not None:
|
|
1203
|
-
self.state["mode"] = "spec"
|
|
1204
|
-
self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
|
|
1205
|
-
(archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1206
|
-
(archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
|
|
1207
|
-
(archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
|
|
1208
|
-
if queue:
|
|
1209
|
-
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
1210
|
-
self.g("add", "queue.md", work=self.task)
|
|
1211
|
-
self.g("commit", "-m", "terminal queue", work=self.task)
|
|
1212
|
-
a["queue"] = {"commit": self.g("rev-parse", "HEAD", work=self.task), "file": "queue.md"}
|
|
1213
1233
|
self.acceptance = evidence / "acceptance.json"
|
|
1214
1234
|
self.acceptance.write_text(json.dumps(a), encoding="utf-8")
|
|
1215
1235
|
|
|
@@ -1250,7 +1270,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1250
1270
|
self.assertEqual(result["status"], "PR")
|
|
1251
1271
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1252
1272
|
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1253
|
-
self.assertEqual((d["pushs"], d.get("merges", 0), d["
|
|
1273
|
+
self.assertEqual((d["pushs"], d.get("merges", 0), d["prs"][0]["autoMergeRequest"]), (1, 0, None))
|
|
1254
1274
|
|
|
1255
1275
|
def test_pr_mode_disables_only_owned_auto_merge(self):
|
|
1256
1276
|
self.allocate(); self.accept()
|
|
@@ -1260,12 +1280,12 @@ class CompletionTests(unittest.TestCase):
|
|
|
1260
1280
|
result, _ = self.complete("--mode", "pr")
|
|
1261
1281
|
self.assertEqual(result["status"], "PR")
|
|
1262
1282
|
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1263
|
-
self.assertEqual((d["disables"], d["
|
|
1264
|
-
self.configure(
|
|
1283
|
+
self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, None))
|
|
1284
|
+
self.configure(prs=[dict(d["prs"][0], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"})])
|
|
1265
1285
|
_, r = self.complete("--mode", "pr", success=False)
|
|
1266
1286
|
self.assertIn("PR head repository differs", json.loads(r.stdout)["reason"])
|
|
1267
1287
|
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
1268
|
-
self.assertEqual((d["disables"], d["
|
|
1288
|
+
self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
|
|
1269
1289
|
|
|
1270
1290
|
def test_remote_rewrite_after_allocation_is_rejected(self):
|
|
1271
1291
|
self.allocate(); self.accept()
|
|
@@ -1387,6 +1407,22 @@ class CompletionTests(unittest.TestCase):
|
|
|
1387
1407
|
self.assertFalse(self.task.exists())
|
|
1388
1408
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1389
1409
|
|
|
1410
|
+
def test_unobservable_writers_settle_a_merged_delivery_and_retain_the_workspace(self):
|
|
1411
|
+
from unittest.mock import patch
|
|
1412
|
+
self.allocate(); self.accept()
|
|
1413
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1414
|
+
self.merge_pr()
|
|
1415
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
1416
|
+
with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
|
|
1417
|
+
result = complete(args)
|
|
1418
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
1419
|
+
self.assertIn("writer observation unsupported", result["workspace_cleanup"]["reason"])
|
|
1420
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1421
|
+
self.assertEqual((receipt["delivery"], receipt.get("status")), ("COMPLETE", None))
|
|
1422
|
+
self.assertTrue(self.task.exists())
|
|
1423
|
+
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1424
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1425
|
+
|
|
1390
1426
|
def test_release_is_refused_from_inside_the_task_tree(self):
|
|
1391
1427
|
self.allocate(); self.accept()
|
|
1392
1428
|
_, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
|
|
@@ -1403,8 +1439,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1403
1439
|
tree = self.run_cmd(bare + ["rev-parse", self.sha + "^{tree}"]).stdout.strip()
|
|
1404
1440
|
squash = self.run_cmd(bare + ["commit-tree", tree, "-p", base, "-m", "squash fixture"]).stdout.strip()
|
|
1405
1441
|
self.run_cmd(bare + ["update-ref", "refs/heads/main", squash, base])
|
|
1406
|
-
pr = json.loads(self.data.read_text(encoding="utf-8"))["
|
|
1407
|
-
self.configure(
|
|
1442
|
+
pr = json.loads(self.data.read_text(encoding="utf-8"))["prs"][0]
|
|
1443
|
+
self.configure(prs=[dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash})])
|
|
1408
1444
|
result, _ = self.complete()
|
|
1409
1445
|
self.assertEqual(result["status"], "COMPLETE")
|
|
1410
1446
|
self.assertFalse(self.task.exists())
|
|
@@ -1451,7 +1487,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1451
1487
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
1452
1488
|
# Reopen only the fixture PR to exercise the owner's pre-merge release.
|
|
1453
1489
|
d = json.loads(self.data.read_text())
|
|
1454
|
-
self.configure(
|
|
1490
|
+
self.configure(prs=[dict(d["prs"][0], state="OPEN", mergedAt=None, mergeCommit=None)])
|
|
1455
1491
|
self.receipt, self.task = receipt, task
|
|
1456
1492
|
result, _ = self.complete("--mode", "pr", "--writers-stopped")
|
|
1457
1493
|
self.assertEqual(result["status"], "PR")
|
|
@@ -1493,13 +1529,13 @@ class CompletionTests(unittest.TestCase):
|
|
|
1493
1529
|
saved.update(pr_number=1, writers_released=True)
|
|
1494
1530
|
unaccepted.write_text(json.dumps(saved))
|
|
1495
1531
|
unaccepted_before = unaccepted.read_bytes()
|
|
1496
|
-
pr = json.loads(self.data.read_text())["
|
|
1497
|
-
self.configure(
|
|
1532
|
+
pr = json.loads(self.data.read_text())["prs"][0]
|
|
1533
|
+
self.configure(prs=[dict(pr, state="CLOSED")])
|
|
1498
1534
|
result = self.allocate("closed")
|
|
1499
1535
|
self.assertEqual(result["reconciled"], [])
|
|
1500
1536
|
self.assertEqual(receipt.read_bytes(), before)
|
|
1501
1537
|
self.assertTrue(task.exists())
|
|
1502
|
-
self.configure(
|
|
1538
|
+
self.configure(prs=[pr])
|
|
1503
1539
|
self.merge_pr()
|
|
1504
1540
|
self.g("worktree", "lock", str(task))
|
|
1505
1541
|
result = self.allocate("locked")
|
|
@@ -1672,36 +1708,21 @@ class CompletionTests(unittest.TestCase):
|
|
|
1672
1708
|
self.assertFalse(self.task.exists())
|
|
1673
1709
|
|
|
1674
1710
|
def test_linked_custody_and_interrupted_removal(self):
|
|
1675
|
-
self.allocate(); self.accept(
|
|
1711
|
+
self.allocate(); self.accept()
|
|
1676
1712
|
self.configure(interrupt_remove=True)
|
|
1677
1713
|
result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
|
|
1678
1714
|
self.assertNotEqual(r.returncode, 0)
|
|
1679
1715
|
self.assertFalse(self.task.exists())
|
|
1680
1716
|
saved = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1681
1717
|
self.assertNotIn("cleanup_started", saved)
|
|
1682
|
-
recovered = self.receipt.parent / "custody" / ".devlyn/
|
|
1683
|
-
self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/
|
|
1718
|
+
recovered = self.receipt.parent / "custody" / ".devlyn/checks.txt"
|
|
1719
|
+
self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/checks.txt"]["sha256"])
|
|
1684
1720
|
result, _ = self.complete("--writers-stopped", acceptance=False)
|
|
1685
1721
|
self.assertEqual(result["status"], "COMPLETE")
|
|
1686
1722
|
self.assertEqual(self.g("show", saved["recovery_ref"]+":product"), "accepted")
|
|
1687
1723
|
|
|
1688
|
-
def
|
|
1689
|
-
self.allocate(); self.accept(
|
|
1690
|
-
original = json.loads(json.dumps(self.state))
|
|
1691
|
-
for case in ("failed", "verify-only", "blocked", "unfinished", "digest", "source"):
|
|
1692
|
-
state = json.loads(json.dumps(original))
|
|
1693
|
-
if case == "failed": state["phases"]["verify"]["verdict"] = "NEEDS_WORK"
|
|
1694
|
-
if case == "verify-only": state["mode"] = "verify-only"
|
|
1695
|
-
if case == "blocked": state["phases"]["implement"]["verdict"] = "BLOCKED"
|
|
1696
|
-
if case == "unfinished": state["phases"]["verify"]["completed_at"] = None
|
|
1697
|
-
if case == "digest": state["phases"]["final_report"]["output_sha256"] = "0"*64
|
|
1698
|
-
if case == "source": state["phases"]["cleanup"]["post_sha"] = self.g("rev-parse", "main")
|
|
1699
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
|
|
1700
|
-
with self.subTest(case=case):
|
|
1701
|
-
_, r = self.complete(success=False)
|
|
1702
|
-
self.assertNotEqual(r.returncode, 0)
|
|
1703
|
-
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1704
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(original), encoding="utf-8")
|
|
1724
|
+
def test_reject_invalid_policy_before_push(self):
|
|
1725
|
+
self.allocate(); self.accept()
|
|
1705
1726
|
for value in ("typo", "", " auto"):
|
|
1706
1727
|
self.g("config", "--local", "devlyn.completionMode", value)
|
|
1707
1728
|
_, r = self.complete("--mode", "pr", success=False)
|
|
@@ -1710,8 +1731,8 @@ class CompletionTests(unittest.TestCase):
|
|
|
1710
1731
|
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
1711
1732
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("creates", 0), 0)
|
|
1712
1733
|
|
|
1713
|
-
def
|
|
1714
|
-
self.allocate(); self.accept(
|
|
1734
|
+
def test_unverified_descendant_is_never_merged(self):
|
|
1735
|
+
self.allocate(); self.accept()
|
|
1715
1736
|
result, _ = self.complete("--mode", "pr")
|
|
1716
1737
|
self.assertEqual(result["status"], "PR")
|
|
1717
1738
|
(self.task / "product").write_text("unverified", encoding="utf-8")
|
|
@@ -1788,13 +1809,16 @@ class CompletionTests(unittest.TestCase):
|
|
|
1788
1809
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
1789
1810
|
|
|
1790
1811
|
def test_actual_foreign_writer_and_registration(self):
|
|
1812
|
+
# H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
|
|
1813
|
+
# retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
|
|
1814
|
+
# "active process <pid> uses task files", so the merged delivery never settled and a drain stopped there.
|
|
1791
1815
|
self.allocate(); self.accept()
|
|
1792
1816
|
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
|
|
1793
1817
|
try:
|
|
1794
|
-
|
|
1795
|
-
self.
|
|
1818
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
1819
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
1820
|
+
self.assertIn("active process", result["workspace_cleanup"]["reason"])
|
|
1796
1821
|
self.assertTrue(self.task.exists())
|
|
1797
|
-
self.assertIn("active process", json.loads(r.stdout)["reason"])
|
|
1798
1822
|
finally:
|
|
1799
1823
|
child.terminate(); child.wait(timeout=5)
|
|
1800
1824
|
# A foreign branch in the same registered tree never inherits ownership.
|
|
@@ -1829,45 +1853,52 @@ class CompletionTests(unittest.TestCase):
|
|
|
1829
1853
|
self.assertEqual(result["status"], "PR")
|
|
1830
1854
|
self.assertIn("disallows auto merge", result["merge_refused"])
|
|
1831
1855
|
self.assertTrue(self.task.exists())
|
|
1832
|
-
|
|
1833
|
-
self.
|
|
1834
|
-
|
|
1835
|
-
self.
|
|
1836
|
-
|
|
1837
|
-
def
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
self.
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
self.
|
|
1856
|
+
# Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
|
|
1857
|
+
result, _ = self.complete("--no-push", success=False)
|
|
1858
|
+
self.assertIn("fixture already has a pushed PR https://github.com/test/project/pull/1", result["reason"])
|
|
1859
|
+
self.assertNotIn("local_only", json.loads(self.receipt.read_text(encoding="utf-8")))
|
|
1860
|
+
|
|
1861
|
+
def test_pipeline_acceptance_is_refused_with_recovery_instruction(self):
|
|
1862
|
+
# Binding an archived resolve run needed the retired helpers. Whatever completion flags come with it,
|
|
1863
|
+
# it is refused before the receipt, a ref or the worktree changes, with the 4.1.0 command that finishes
|
|
1864
|
+
# it in the requested delivery mode; it never becomes direct acceptance.
|
|
1865
|
+
self.allocate(); self.accept()
|
|
1866
|
+
self.acceptance.write_text(json.dumps({"kind": "pipeline", "task": "fixture", "source_sha": self.sha,
|
|
1867
|
+
"run_id": "fixture-run"}), encoding="utf-8")
|
|
1868
|
+
def state():
|
|
1869
|
+
return (self.receipt.read_bytes(), self.g("show-ref"), self.g("rev-parse", "HEAD", work=self.task),
|
|
1870
|
+
self.g("status", "--porcelain", "--untracked-files=all", work=self.task),
|
|
1871
|
+
sorted(p.name for p in self.receipt.parent.iterdir() if p.name != "lock"))
|
|
1872
|
+
before = state()
|
|
1873
|
+
publish, local = "binds the pipeline acceptance before publishing", "ends as LOCAL_ONLY without binding"
|
|
1874
|
+
for flags, outcome in (((), publish), (("--mode", "pr"), publish), (("--mode", "auto", "--writers-stopped"), publish),
|
|
1875
|
+
(("--local-only",), local), (("--local-only", "--writers-stopped"), local)):
|
|
1876
|
+
with self.subTest(flags=flags):
|
|
1877
|
+
result, r = self.complete(*flags, success=False)
|
|
1878
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
1879
|
+
self.assertEqual(state(), before)
|
|
1880
|
+
self.assertIn("`npm pack devlyn-cli@4.1.0`", result["reason"])
|
|
1881
|
+
self.assertIn(shlex.join(["complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), *flags]) + "`",
|
|
1882
|
+
result["reason"])
|
|
1883
|
+
self.assertIn(outcome, result["reason"])
|
|
1884
|
+
result, r = self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance, success=False)
|
|
1885
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
1886
|
+
self.assertEqual(state(), before)
|
|
1887
|
+
self.assertIn(publish, result["reason"])
|
|
1888
|
+
self.assertNotIn(json.loads(before[0])["recovery_ref"], before[1])
|
|
1889
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
1890
|
+
|
|
1891
|
+
def test_bound_pipeline_receipt_resumes_delivery(self):
|
|
1892
|
+
# A receipt an earlier release bound to a resolve run keeps its custody; delivery resumes here.
|
|
1893
|
+
self.allocate(); self.accept()
|
|
1894
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
1895
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1896
|
+
receipt["acceptance"] = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
|
|
1897
|
+
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
1898
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto", acceptance=False)
|
|
1899
|
+
self.assertEqual((result["status"], result["acceptance"]), ("COMPLETE", "pipeline"))
|
|
1900
|
+
self.assertFalse(self.task.exists())
|
|
1901
|
+
self.assertEqual(self.g("show", receipt["recovery_ref"] + ":product"), "accepted")
|
|
1871
1902
|
|
|
1872
1903
|
def test_legacy_in_place_resume_does_not_overwrite_ignored_collision(self):
|
|
1873
1904
|
self.allocate_legacy(); self.accept()
|
|
@@ -1914,45 +1945,219 @@ class CompletionTests(unittest.TestCase):
|
|
|
1914
1945
|
self.assertTrue(self.task.exists())
|
|
1915
1946
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1916
1947
|
|
|
1917
|
-
def
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
self.assertNotEqual(r.returncode, 0)
|
|
1926
|
-
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1927
|
-
report.write_bytes(original)
|
|
1928
|
-
self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(original).hexdigest()
|
|
1929
|
-
(self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
1930
|
-
result, _ = self.complete("--mode", "pr")
|
|
1931
|
-
self.assertEqual(result["status"], "PR")
|
|
1948
|
+
def allocate_local(self, name, **source):
|
|
1949
|
+
args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project",
|
|
1950
|
+
"--base", "main", "--worktree", self.root / name]
|
|
1951
|
+
for key, value in source.items():
|
|
1952
|
+
args += ["--" + key.replace("_", "-"), value]
|
|
1953
|
+
result, _ = self.cli(*args)
|
|
1954
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
1955
|
+
return result
|
|
1932
1956
|
|
|
1933
|
-
def
|
|
1934
|
-
|
|
1935
|
-
self.
|
|
1936
|
-
|
|
1937
|
-
self.
|
|
1938
|
-
self.
|
|
1939
|
-
|
|
1957
|
+
def loop_result(self, verdict="ACCEPTED"):
|
|
1958
|
+
(self.task / "product").write_text(verdict.lower() + "\n", encoding="utf-8")
|
|
1959
|
+
self.g("add", "product", work=self.task)
|
|
1960
|
+
self.g("commit", "-m", "loop source", work=self.task)
|
|
1961
|
+
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
1962
|
+
stream = self.task / ".devlyn/loop/runs/1/cmd-0.stdout"
|
|
1963
|
+
stream.parent.mkdir(parents=True, exist_ok=True)
|
|
1964
|
+
stream.write_text("check output\n", encoding="utf-8")
|
|
1965
|
+
self.acceptance = self.task / ".devlyn/loop/acceptance.json"
|
|
1966
|
+
self.acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": self.sha,
|
|
1967
|
+
"verdict": verdict, "evidence": [".devlyn/loop/runs/1/cmd-0.stdout"]}), encoding="utf-8")
|
|
1968
|
+
return self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)[0]
|
|
1969
|
+
|
|
1970
|
+
def terminal(self):
|
|
1971
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
1972
|
+
self.g("add", "queue.md", work=self.task)
|
|
1973
|
+
self.g("commit", "-m", "terminal", work=self.task)
|
|
1974
|
+
commit = self.g("rev-parse", "HEAD", work=self.task)
|
|
1975
|
+
return commit, self.cli("attach", "--receipt", self.receipt, "--commit", commit, "--file", "queue.md")[0]
|
|
1976
|
+
|
|
1977
|
+
def test_local_chain_starts_from_accepted_source_not_terminal_commit(self):
|
|
1978
|
+
base = self.g("rev-parse", "main")
|
|
1979
|
+
other = self.root / "advance" # The remote advances; a local allocation never fetches it.
|
|
1980
|
+
self.run_cmd(["git", "clone", str(self.bare), str(other)])
|
|
1981
|
+
(other / "product").write_text("remote advance\n", encoding="utf-8")
|
|
1982
|
+
self.g("commit", "-am", "remote advance", work=other)
|
|
1983
|
+
self.g("push", "origin", "main", work=other)
|
|
1984
|
+
self.configure(pushs=0)
|
|
1985
|
+
result = self.allocate_local("first", local_base=base)
|
|
1986
|
+
receipt = json.loads(self.receipt.read_text())
|
|
1987
|
+
self.assertEqual((result["reconciled"], receipt["baseline"], receipt["local_only"]), ([], base, True))
|
|
1988
|
+
self.assertNotEqual(self.run_cmd(["git", "-C", str(self.work), "rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], success=False).returncode, 0)
|
|
1989
|
+
self.assertEqual(self.loop_result()["status"], "ACCEPTED")
|
|
1990
|
+
source = self.sha
|
|
1991
|
+
terminal, _ = self.terminal()
|
|
1992
|
+
receipt = json.loads(self.receipt.read_text())
|
|
1993
|
+
self.assertEqual((receipt["source_sha"], receipt["publish_sha"], self.g("rev-parse", receipt["recovery_ref"])), (source, terminal, terminal))
|
|
1994
|
+
first = self.receipt
|
|
1995
|
+
self.allocate_local("second", from_receipt=first)
|
|
1996
|
+
second = json.loads(self.receipt.read_text())
|
|
1997
|
+
self.assertEqual((second["baseline"], second["allocated_from"]["source_sha"], self.g("rev-parse", "HEAD", work=self.task)), (source, source, source))
|
|
1998
|
+
self.receipt = first
|
|
1999
|
+
result, _ = self.complete("--mode", "auto", acceptance=False)
|
|
2000
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
2001
|
+
self.assertEqual(json.loads(self.data.read_text()).get("pushs", 0), 0)
|
|
2002
|
+
|
|
2003
|
+
def test_local_allocation_refuses_unaccepted_failed_or_inexact_sources(self):
|
|
2004
|
+
self.allocate_local("pending", local_base=self.g("rev-parse", "main"))
|
|
2005
|
+
predecessor = self.receipt
|
|
2006
|
+
args = ["allocate", "--repo", self.work, "--task", "next", "--branch", "task/next", "--repository", "test/project",
|
|
2007
|
+
"--base", "main", "--worktree", self.root / "next"]
|
|
2008
|
+
for state in ("pending", "failed"):
|
|
2009
|
+
if state == "failed":
|
|
2010
|
+
self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
|
|
2011
|
+
result, _ = self.cli(*args, "--from-receipt", predecessor, success=False)
|
|
2012
|
+
self.assertIn("predecessor has no accepted result", result["reason"])
|
|
2013
|
+
result, _ = self.cli(*args, "--local-base", "main", success=False)
|
|
2014
|
+
self.assertIn("exact local commit", result["reason"])
|
|
2015
|
+
self.assertEqual(self.g("branch", "--list", "task/next"), "")
|
|
2016
|
+
self.assertFalse((self.root / "next").exists())
|
|
2017
|
+
|
|
2018
|
+
def test_local_allocation_needs_no_repository(self):
|
|
2019
|
+
"""E4. Prediction: without --repository, a remote allocation is refused naming it, with nothing created, while a
|
|
2020
|
+
local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
|
|
2021
|
+
LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
|
|
2022
|
+
args = ["allocate", "--repo", self.work, "--base", "main"]
|
|
2023
|
+
refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
|
|
2024
|
+
self.assertIn("--repository", refused["reason"])
|
|
2025
|
+
self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
|
|
2026
|
+
self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
|
|
2027
|
+
result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
|
|
2028
|
+
"--local-base", self.g("rev-parse", "HEAD"))
|
|
2029
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2030
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2031
|
+
self.accept()
|
|
2032
|
+
self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
|
|
2033
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
|
|
2034
|
+
|
|
2035
|
+
def test_local_allocation_needs_no_base(self):
|
|
2036
|
+
"""P4-2. Prediction: a local allocation with --local-base and no --base, the form agents wrote in 3 of 3 timed
|
|
2037
|
+
runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
|
|
2038
|
+
nothing created."""
|
|
2039
|
+
args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
|
|
2040
|
+
refused, _ = self.cli(*args, "--repository", "test/project", success=False)
|
|
2041
|
+
self.assertIn("--base", refused["reason"])
|
|
2042
|
+
self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
|
|
2043
|
+
result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
|
|
2044
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
2045
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2046
|
+
self.accept()
|
|
2047
|
+
self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
|
|
1940
2048
|
|
|
1941
|
-
def
|
|
1942
|
-
|
|
1943
|
-
|
|
2049
|
+
def test_allocation_canonicalizes_symlinked_and_dotted_worktree_paths(self):
|
|
2050
|
+
"""P4-2. Prediction: an absent worktree path reached through a symlinked directory (macOS /tmp) or written with
|
|
2051
|
+
`..` (`../work-x`, `$(pwd -P)/../work-x`) is ALLOCATED at its canonical path, and that path, now present, is
|
|
2052
|
+
refused through the link as not absent."""
|
|
2053
|
+
alias = self.root / "alias"
|
|
2054
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
2055
|
+
args = ["allocate", "--repo", self.work, "--base", "main", "--local-base", self.g("rev-parse", "HEAD")]
|
|
2056
|
+
for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
|
|
2057
|
+
result, _ = self.cli(*args, "--task", name, "--branch", "task/" + name, "--worktree", given, cwd=self.work)
|
|
2058
|
+
self.assertEqual((result["status"], result["worktree"]), ("ALLOCATED", str(self.root / name)))
|
|
2059
|
+
self.assertEqual(self.g("rev-parse", "--show-toplevel", work=self.root / name), str(self.root / name))
|
|
2060
|
+
refused, _ = self.cli(*args, "--task", "again", "--branch", "task/again", "--worktree", alias / "dotted", success=False)
|
|
2061
|
+
self.assertIn("linked worktree must be an absent path", refused["reason"])
|
|
2062
|
+
|
|
2063
|
+
def test_completion_accepts_the_path_form_the_worktree_was_allocated_with(self):
|
|
2064
|
+
"""Prediction: an acceptance named through the symlinked or `..` form the worktree was allocated with completes
|
|
2065
|
+
LOCAL_ONLY; before, allocate canonicalized the worktree but complete refused that form as outside it."""
|
|
2066
|
+
alias = self.root / "alias"
|
|
2067
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
2068
|
+
for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
|
|
2069
|
+
result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/" + name,
|
|
2070
|
+
"--worktree", given, "--local-base", self.g("rev-parse", "HEAD"), cwd=self.work)
|
|
2071
|
+
self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
|
|
2072
|
+
self.accept()
|
|
2073
|
+
self.acceptance = given / ".devlyn" / "acceptance.json"
|
|
2074
|
+
self.assertEqual(self.complete("--local-only", cwd=self.work)[0]["status"], "LOCAL_ONLY")
|
|
2075
|
+
|
|
2076
|
+
def test_remote_allocation_starts_from_an_exact_commit(self):
|
|
2077
|
+
# An ideate auto/pr task starts from the exact refreshed remote base the drain checked, never a second fetch.
|
|
2078
|
+
(self.work / "product").write_text("added locally\n", encoding="utf-8")
|
|
2079
|
+
self.g("commit", "-qam", "devlyn loop: add fixture")
|
|
2080
|
+
start = self.g("rev-parse", "HEAD")
|
|
2081
|
+
args = ["allocate", "--repo", self.work, "--task", "first", "--branch", "task/first", "--repository", "test/project",
|
|
2082
|
+
"--base", "main", "--worktree", self.root / "first"]
|
|
2083
|
+
for extra, reason in ((["--start", "main"], "--start must name an exact local commit"),
|
|
2084
|
+
(["--start", start, "--local-base", start], "use one of --local-base, --from-receipt or --start")):
|
|
2085
|
+
self.assertIn(reason, self.cli(*args, *extra, success=False)[0]["reason"])
|
|
2086
|
+
result, _ = self.cli(*args, "--start", start)
|
|
2087
|
+
receipt = json.loads(Path(result["receipt"]).read_text(encoding="utf-8"))
|
|
2088
|
+
self.assertEqual((receipt["baseline"], receipt.get("local_only"), self.g("rev-parse", "HEAD", work=Path(result["worktree"]))),
|
|
2089
|
+
(start, None, start))
|
|
2090
|
+
|
|
2091
|
+
def test_failed_loop_result_keeps_custody_and_recovery_but_never_publishes(self):
|
|
2092
|
+
self.allocate()
|
|
2093
|
+
self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
|
|
2094
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2095
|
+
self.assertEqual((receipt["product"], self.g("rev-parse", receipt["recovery_ref"])), ("FAILED", self.sha))
|
|
2096
|
+
self.assertTrue((self.receipt.parent / "custody/.devlyn/loop/runs/1/cmd-0.stdout").is_file())
|
|
2097
|
+
terminal, _ = self.terminal()
|
|
2098
|
+
self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), terminal)
|
|
2099
|
+
result, _ = self.complete("--mode", "auto", acceptance=False)
|
|
2100
|
+
self.assertEqual(result["status"], "FAILED")
|
|
2101
|
+
d = json.loads(self.data.read_text())
|
|
2102
|
+
self.assertEqual((d.get("pushs", 0), d.get("creates", 0)), (0, 0))
|
|
2103
|
+
|
|
2104
|
+
def test_bind_requires_the_source_checkout_only_for_a_publishable_result(self):
|
|
2105
|
+
for verdict in ("ACCEPTED", "FAILED"):
|
|
2106
|
+
with self.subTest(verdict=verdict):
|
|
2107
|
+
self.allocate(verdict.lower() + "-detached")
|
|
2108
|
+
(self.task / "product").write_text("source\n", encoding="utf-8")
|
|
2109
|
+
self.g("add", "product", work=self.task)
|
|
2110
|
+
self.g("commit", "-m", "loop source", work=self.task)
|
|
2111
|
+
sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
2112
|
+
self.g("checkout", "-q", "--detach", "HEAD~1", work=self.task) # The executor left HEAD at another commit.
|
|
2113
|
+
acceptance = self.task / ".devlyn/loop/acceptance.json"
|
|
2114
|
+
acceptance.parent.mkdir(parents=True, exist_ok=True)
|
|
2115
|
+
(self.task / ".devlyn/loop/out.txt").write_text("check output\n", encoding="utf-8")
|
|
2116
|
+
acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": sha,
|
|
2117
|
+
"verdict": verdict, "evidence": [".devlyn/loop/out.txt"]}), encoding="utf-8")
|
|
2118
|
+
result, _ = self.cli("accept", "--receipt", self.receipt, "--acceptance", acceptance, success=False)
|
|
2119
|
+
self.assertEqual(result["status"], "FAILED" if verdict == "FAILED" else "BLOCKED", result)
|
|
2120
|
+
|
|
2121
|
+
def test_attach_requires_the_terminal_checkout_only_for_a_publishable_result(self):
|
|
2122
|
+
for verdict in ("ACCEPTED", "FAILED"):
|
|
2123
|
+
with self.subTest(verdict=verdict):
|
|
2124
|
+
self.allocate(verdict.lower())
|
|
2125
|
+
self.loop_result(verdict)
|
|
2126
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
2127
|
+
self.g("add", "queue.md", work=self.task)
|
|
2128
|
+
self.g("commit", "-m", "terminal", work=self.task)
|
|
2129
|
+
terminal = self.g("rev-parse", "HEAD", work=self.task)
|
|
2130
|
+
self.g("checkout", "-q", "--detach", self.sha, work=self.task) # The task ref is terminal; HEAD stays at the source.
|
|
2131
|
+
result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md", success=False)
|
|
2132
|
+
self.assertEqual(result["status"], "ATTACHED" if verdict == "FAILED" else "BLOCKED", result)
|
|
2133
|
+
|
|
2134
|
+
def test_local_only_completion_binds_acceptance_custody(self):
|
|
2135
|
+
self.allocate(); self.accept()
|
|
2136
|
+
result, _ = self.complete("--local-only")
|
|
2137
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
2138
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2139
|
+
self.assertEqual((receipt["source_sha"], self.g("rev-parse", receipt["recovery_ref"])), (self.sha, self.sha))
|
|
2140
|
+
verify_files(self.receipt.parent / "custody", receipt["files"])
|
|
2141
|
+
|
|
2142
|
+
def test_attach_binds_only_a_queue_only_commit_and_publishes_it_with_the_source(self):
|
|
2143
|
+
self.allocate(); self.loop_result()
|
|
2144
|
+
source = self.sha
|
|
2145
|
+
(self.task / "product").write_text("unaccepted\n", encoding="utf-8")
|
|
2146
|
+
(self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
|
|
2147
|
+
self.g("add", "product", "queue.md", work=self.task)
|
|
2148
|
+
self.g("commit", "-m", "mixed", work=self.task)
|
|
2149
|
+
result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", self.g("rev-parse", "HEAD", work=self.task),
|
|
2150
|
+
"--file", "queue.md", success=False)
|
|
2151
|
+
self.assertIn("exactly the declared queue file", result["reason"])
|
|
2152
|
+
self.g("reset", "--hard", source, work=self.task)
|
|
2153
|
+
terminal, attached = self.terminal()
|
|
2154
|
+
self.assertEqual(attached["status"], "ATTACHED")
|
|
2155
|
+
self.assertEqual(self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md")[0]["status"], "ATTACHED")
|
|
2156
|
+
receipt = json.loads(self.receipt.read_text())
|
|
2157
|
+
self.assertEqual((receipt["source_sha"], receipt["publish_sha"]), (source, terminal))
|
|
2158
|
+
result, _ = self.complete("--mode", "pr", acceptance=False)
|
|
1944
2159
|
self.assertEqual(result["status"], "PR")
|
|
1945
|
-
|
|
1946
|
-
self.assertIn("spec.expected.json", receipt["files"])
|
|
1947
|
-
self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
|
|
1948
|
-
|
|
1949
|
-
def test_named_spec_missing_required_process_evidence(self):
|
|
1950
|
-
expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
|
|
1951
|
-
self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
|
|
1952
|
-
_, r = self.complete(success=False)
|
|
1953
|
-
self.assertNotEqual(r.returncode, 0)
|
|
1954
|
-
self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
|
|
1955
|
-
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
2160
|
+
self.assertEqual(json.loads(self.data.read_text())["prs"][0]["headRefOid"], terminal)
|
|
1956
2161
|
|
|
1957
2162
|
|
|
1958
2163
|
if __name__ == "__main__":
|