devlyn-cli 2.10.1 → 3.0.1
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 +8 -9
- package/CLAUDE.md +17 -17
- package/README.md +33 -9
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
- package/bin/devlyn.js +9 -2
- package/config/skills/_shared/.ruff.toml +2 -0
- package/config/skills/_shared/adapters/claude.md +9 -1
- package/config/skills/_shared/adapters/codex.md +5 -0
- package/config/skills/_shared/adapters/grok.md +4 -2
- package/config/skills/_shared/archive_run.py +738 -56
- package/config/skills/_shared/codex-config.md +19 -4
- package/config/skills/_shared/codex-monitored.sh +81 -12
- package/config/skills/_shared/collect-codex-findings.py +311 -128
- package/config/skills/_shared/engine-preflight.md +8 -7
- package/config/skills/_shared/expected.schema.json +61 -0
- package/config/skills/_shared/finish-gate.py +12 -11
- package/config/skills/_shared/grok-anchor-guard.py +18 -1
- package/config/skills/_shared/invocation-receipt.py +1041 -0
- package/config/skills/_shared/judge-output-parser.py +200 -0
- package/config/skills/_shared/judge-role-evidence.py +273 -0
- package/config/skills/_shared/process-evidence.py +909 -0
- package/config/skills/_shared/resolve-bootstrap.py +776 -84
- package/config/skills/_shared/resolve-stop-hook.py +33 -1
- package/config/skills/_shared/role-config.py +552 -0
- package/config/skills/_shared/run-bounded.py +1 -1
- package/config/skills/_shared/spec-verify-check.py +749 -181
- package/config/skills/_shared/state-phase-write.py +1838 -58
- package/config/skills/_shared/terminal-claim-check.py +15 -0
- package/config/skills/_shared/verify-merge-findings.py +958 -51
- package/config/skills/devlyn:engines/SKILL.md +8 -16
- package/config/skills/devlyn:ideate/SKILL.md +2 -2
- package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
- package/config/skills/devlyn:queue/SKILL.md +7 -4
- package/config/skills/devlyn:resolve/SKILL.md +93 -35
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
- package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
- package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
- package/package.json +5 -1
- package/scripts/lint-skills.sh +314 -285
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
"""Deterministic PHASE-0 bootstrap for /devlyn:resolve."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import datetime
|
|
6
7
|
import hashlib
|
|
7
8
|
import importlib.util
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import pathlib
|
|
12
|
+
import runpy
|
|
11
13
|
import secrets
|
|
12
14
|
import stat
|
|
13
15
|
import subprocess
|
|
@@ -18,7 +20,7 @@ sys.dont_write_bytecode = True
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
VALUE_FLAGS = {
|
|
21
|
-
"--max-rounds", "--engine", "--spec", "--verify-only", "--goal-file", "--bypass",
|
|
23
|
+
"--max-rounds", "--engine", "--spec", "--verify-only", "--goal-file", "--bypass", "--role-config",
|
|
22
24
|
}
|
|
23
25
|
BOOL_FLAGS = {"--pair-verify", "--no-pair", "--risk-probes", "--no-risk-probes", "--perf"}
|
|
24
26
|
SINGLE_VALUE_FLAGS = VALUE_FLAGS - {"--bypass"}
|
|
@@ -44,8 +46,21 @@ def reject_json_constant(token: str) -> None:
|
|
|
44
46
|
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
50
|
+
result = {}
|
|
51
|
+
for key, value in pairs:
|
|
52
|
+
if key in result:
|
|
53
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
54
|
+
result[key] = value
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
47
58
|
def strict_json(text: str):
|
|
48
|
-
return json.loads(
|
|
59
|
+
return json.loads(
|
|
60
|
+
text,
|
|
61
|
+
parse_constant=reject_json_constant,
|
|
62
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
63
|
+
)
|
|
49
64
|
|
|
50
65
|
|
|
51
66
|
def json_bytes(value: object) -> bytes:
|
|
@@ -171,6 +186,8 @@ def parse_flags(argv: list[str]) -> dict:
|
|
|
171
186
|
"spec": values.get("--spec"),
|
|
172
187
|
"verify_ref": values.get("--verify-only"),
|
|
173
188
|
"goal_file": values.get("--goal-file"),
|
|
189
|
+
"role_config": values.get("--role-config"),
|
|
190
|
+
"no_pair": "--no-pair" in switches,
|
|
174
191
|
"inline_goal": " ".join(positional),
|
|
175
192
|
"pair_verify": "--pair-verify" in switches,
|
|
176
193
|
"bypasses": bypasses,
|
|
@@ -178,9 +195,32 @@ def parse_flags(argv: list[str]) -> dict:
|
|
|
178
195
|
|
|
179
196
|
|
|
180
197
|
def validate_shared_dir(shared_dir: pathlib.Path) -> None:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
198
|
+
for name in ("spec-verify-check.py", "archive_run.py", "process-evidence.py"):
|
|
199
|
+
required = shared_dir / name
|
|
200
|
+
if not required.is_file():
|
|
201
|
+
block("BLOCKED:shared-dir-unresolved", str(required))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def archive_prior_run(devlyn: pathlib.Path, shared_dir: pathlib.Path) -> None:
|
|
205
|
+
archive = runpy.run_path(shared_dir / "archive_run.py")
|
|
206
|
+
if not archive["has_owned_artifacts"](devlyn):
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
state = archive["read_state"](devlyn)
|
|
210
|
+
except (archive["ArchiveError"], OSError, UnicodeError, ValueError) as exc:
|
|
211
|
+
block("BLOCKED:prior-run-ownership-unverified", str(exc))
|
|
212
|
+
run_id = state["run_id"]
|
|
213
|
+
if not archive["is_completed"](state) or archive["has_bootstrap_residue"](devlyn):
|
|
214
|
+
block(
|
|
215
|
+
"BLOCKED:prior-run-unfinished",
|
|
216
|
+
f"Run {run_id} at {devlyn.parent} is unfinished or indeterminate. "
|
|
217
|
+
"Continue through its owning session or start in a distinct worktree.",
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
archive["move_artifacts"](devlyn, devlyn / "runs" / run_id)
|
|
221
|
+
archive["prune"](devlyn / "runs", keep=10)
|
|
222
|
+
except (archive["ArchiveError"], OSError, UnicodeError, ValueError) as exc:
|
|
223
|
+
block("BLOCKED:prior-run-archive-failed", str(exc))
|
|
184
224
|
|
|
185
225
|
|
|
186
226
|
def safe_goal_file(cwd: pathlib.Path, raw_path: str) -> bytes:
|
|
@@ -265,15 +305,30 @@ def init_spec_source(
|
|
|
265
305
|
}, staged_path.read_bytes() if staged_path.is_file() else None)
|
|
266
306
|
|
|
267
307
|
|
|
268
|
-
def git_text(cwd: pathlib.Path, *args: str
|
|
269
|
-
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True
|
|
270
|
-
text = proc.stdout.
|
|
271
|
-
if proc.returncode != 0 or
|
|
272
|
-
detail = (proc.stderr or proc.stdout).strip() or "git command failed"
|
|
308
|
+
def git_text(cwd: pathlib.Path, *args: str) -> str:
|
|
309
|
+
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True)
|
|
310
|
+
text = os.fsdecode(proc.stdout.removesuffix(b"\n"))
|
|
311
|
+
if proc.returncode != 0 or not text:
|
|
312
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
273
313
|
block("BLOCKED:invalid-flags", detail)
|
|
274
314
|
return text
|
|
275
315
|
|
|
276
316
|
|
|
317
|
+
def base_branch(cwd: pathlib.Path) -> str | None:
|
|
318
|
+
proc = subprocess.run(
|
|
319
|
+
["git", "symbolic-ref", "--short", "-q", "HEAD"],
|
|
320
|
+
cwd=cwd,
|
|
321
|
+
capture_output=True,
|
|
322
|
+
text=True,
|
|
323
|
+
)
|
|
324
|
+
if proc.returncode == 1 and not proc.stdout.strip() and not proc.stderr.strip():
|
|
325
|
+
return None
|
|
326
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
327
|
+
detail = (proc.stderr or proc.stdout).strip() or "git symbolic-ref failed"
|
|
328
|
+
block("BLOCKED:invalid-flags", detail)
|
|
329
|
+
return proc.stdout.strip()
|
|
330
|
+
|
|
331
|
+
|
|
277
332
|
def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
|
|
278
333
|
supplied = pathlib.Path(ref)
|
|
279
334
|
source = supplied if supplied.is_absolute() else cwd / supplied
|
|
@@ -287,6 +342,49 @@ def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
|
|
|
287
342
|
return raw
|
|
288
343
|
|
|
289
344
|
|
|
345
|
+
def require_clean_tracked_baseline(cwd: pathlib.Path) -> None:
|
|
346
|
+
changed: list[bytes] = []
|
|
347
|
+
for args in (("diff",), ("diff", "--cached")):
|
|
348
|
+
proc = subprocess.run(
|
|
349
|
+
["git", *args, "--no-renames", "--name-only", "-z"],
|
|
350
|
+
cwd=cwd,
|
|
351
|
+
capture_output=True,
|
|
352
|
+
)
|
|
353
|
+
if proc.returncode != 0:
|
|
354
|
+
block("BLOCKED:invalid-flags", os.fsdecode(proc.stderr or proc.stdout).strip())
|
|
355
|
+
changed.extend(path for path in proc.stdout.split(b"\0") if path)
|
|
356
|
+
if any(path != b".devlyn" and not path.startswith(b".devlyn/") for path in changed):
|
|
357
|
+
block(
|
|
358
|
+
"BLOCKED:worktree-dirty",
|
|
359
|
+
"Commit or stash tracked changes outside .devlyn before starting a full resolve.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@contextlib.contextmanager
|
|
364
|
+
def admission_lock(cwd: pathlib.Path):
|
|
365
|
+
lock_path = pathlib.Path(git_text(cwd, "rev-parse", "--absolute-git-dir")) / "devlyn-bootstrap.lock"
|
|
366
|
+
try:
|
|
367
|
+
import fcntl
|
|
368
|
+
handle = lock_path.open("a+b")
|
|
369
|
+
except (ImportError, OSError) as exc:
|
|
370
|
+
block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
|
|
371
|
+
# Closing releases flock on every exit; keep the inode for the next caller.
|
|
372
|
+
with handle:
|
|
373
|
+
try:
|
|
374
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
375
|
+
except BlockingIOError:
|
|
376
|
+
block(
|
|
377
|
+
"BLOCKED:bootstrap-contended",
|
|
378
|
+
f"Bootstrap admission is occupied at {cwd}. Retry from this root after admission finishes.",
|
|
379
|
+
)
|
|
380
|
+
except (OSError, AttributeError) as exc:
|
|
381
|
+
block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
|
|
382
|
+
for path in (cwd / ".devlyn", cwd / ".devlyn" / "runs"):
|
|
383
|
+
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
384
|
+
block("BLOCKED:devlyn-path-redirect", f"{cwd}: require an unredirected directory at {path}")
|
|
385
|
+
yield
|
|
386
|
+
|
|
387
|
+
|
|
290
388
|
def bootstrap(
|
|
291
389
|
argv: list[str],
|
|
292
390
|
cwd: pathlib.Path,
|
|
@@ -298,83 +396,508 @@ def bootstrap(
|
|
|
298
396
|
cwd = cwd.resolve()
|
|
299
397
|
validate_shared_dir(shared_dir)
|
|
300
398
|
parsed = parse_flags(argv)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
399
|
+
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"):
|
|
400
|
+
if name in os.environ:
|
|
401
|
+
block("BLOCKED:git-env-redirect", f"{cwd}: inherited {name} redirects Git; unset it before retrying.")
|
|
402
|
+
root = pathlib.Path(git_text(cwd, "rev-parse", "--show-toplevel")).resolve()
|
|
403
|
+
if cwd != root:
|
|
404
|
+
block("BLOCKED:worktree-root-required", f"{cwd} is not the worktree root {root}. Retry from {root}.")
|
|
405
|
+
if parsed["mode"] != "verify-only":
|
|
406
|
+
require_clean_tracked_baseline(cwd)
|
|
407
|
+
with admission_lock(cwd):
|
|
408
|
+
devlyn = cwd / ".devlyn"
|
|
409
|
+
outputs: dict[pathlib.Path, bytes | None] = {devlyn / "external-diff.patch": None}
|
|
410
|
+
if parsed["mode"] == "free-form":
|
|
411
|
+
raw_goal = (
|
|
412
|
+
safe_goal_file(cwd, parsed["goal_file"])
|
|
413
|
+
if parsed["goal_file"] is not None
|
|
414
|
+
else parsed["inline_goal"].encode("utf-8")
|
|
415
|
+
)
|
|
416
|
+
outputs[devlyn / "goal.raw.txt"] = raw_goal
|
|
417
|
+
source = {
|
|
418
|
+
"type": "generated",
|
|
419
|
+
"spec_path": None,
|
|
420
|
+
"spec_sha256": None,
|
|
421
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
422
|
+
"goal_sha256": sha256(raw_goal),
|
|
423
|
+
"criteria_path": ".devlyn/criteria.generated.md",
|
|
424
|
+
"criteria_sha256": None,
|
|
425
|
+
}
|
|
426
|
+
else:
|
|
427
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
428
|
+
source, staged_spec = init_spec_source(
|
|
429
|
+
cwd, pathlib.Path(tmp), shared_dir, parsed["spec"],
|
|
430
|
+
)
|
|
431
|
+
outputs[devlyn / "spec-verify.json"] = staged_spec
|
|
432
|
+
if parsed["mode"] == "verify-only":
|
|
433
|
+
outputs[devlyn / "external-diff.patch"] = capture_external_diff(cwd, parsed["verify_ref"])
|
|
434
|
+
|
|
435
|
+
role_input = None
|
|
436
|
+
if parsed["role_config"] is not None:
|
|
437
|
+
role_module = runpy.run_path(pathlib.Path(__file__).with_name("role-config.py"))
|
|
438
|
+
requested_path = pathlib.Path(parsed["role_config"])
|
|
439
|
+
if not requested_path.is_absolute():
|
|
440
|
+
requested_path = cwd / requested_path
|
|
441
|
+
try:
|
|
442
|
+
value, pin = role_module["read_config"](requested_path, run=True)
|
|
443
|
+
except ValueError as exc:
|
|
444
|
+
block("BLOCKED:invalid-engine-config", str(exc))
|
|
445
|
+
role_input = {**pin, "value": value}
|
|
446
|
+
|
|
447
|
+
engine = parsed["engine"] or default_engine
|
|
448
|
+
engine_source = "flag" if parsed["engine"] is not None else "default"
|
|
449
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
450
|
+
started_at = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
451
|
+
run_id = now.strftime("rs-%Y%m%dT%H%M%SZ-") + secrets.token_hex(6)
|
|
452
|
+
state = {
|
|
453
|
+
"version": "3.0",
|
|
454
|
+
"role_config_input": role_input,
|
|
455
|
+
"role_no_pair": parsed["no_pair"],
|
|
456
|
+
"run_id": run_id,
|
|
457
|
+
"started_at": started_at,
|
|
458
|
+
"session_id": os.environ.get("CLAUDE_CODE_SESSION_ID"),
|
|
459
|
+
"engine": engine,
|
|
460
|
+
"engine_source": engine_source,
|
|
461
|
+
"mode": parsed["mode"],
|
|
462
|
+
"pair_verify": parsed["pair_verify"],
|
|
463
|
+
"complexity": None,
|
|
464
|
+
"risk_profile": {
|
|
465
|
+
"high_risk": False,
|
|
466
|
+
"reasons": [],
|
|
467
|
+
"risk_probes_enabled": False,
|
|
468
|
+
"risk_probes_explicit": False,
|
|
469
|
+
"pair_default_enabled": True,
|
|
470
|
+
},
|
|
471
|
+
"risk_probes_digest": None,
|
|
472
|
+
"process_evidence": None,
|
|
473
|
+
"base_ref": {
|
|
474
|
+
"branch": base_branch(cwd),
|
|
475
|
+
"sha": git_text(cwd, "rev-parse", "HEAD"),
|
|
476
|
+
},
|
|
477
|
+
"rounds": {"max_rounds": parsed["max_rounds"], "global": 0},
|
|
478
|
+
"bypasses": parsed["bypasses"],
|
|
479
|
+
"implement_passed_sha": None,
|
|
480
|
+
"source": source,
|
|
481
|
+
"criteria": [],
|
|
482
|
+
"phases": {name: None for name in PHASE_NAMES},
|
|
483
|
+
"verify": {"coverage_failed": False, "pair_trigger": None},
|
|
318
484
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
485
|
+
state_raw = json_bytes(state)
|
|
486
|
+
outputs[devlyn / "pipeline.state.json"] = state_raw
|
|
487
|
+
archive_prior_run(devlyn, shared_dir)
|
|
488
|
+
atomic_write_batch(outputs, writer)
|
|
489
|
+
return {
|
|
490
|
+
"ok": True,
|
|
491
|
+
"run_id": run_id,
|
|
492
|
+
"mode": parsed["mode"],
|
|
493
|
+
"source": source,
|
|
494
|
+
"state_path": ".devlyn/pipeline.state.json",
|
|
495
|
+
"state_sha256": sha256(state_raw),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def pathname_self_test() -> None:
|
|
501
|
+
script = pathlib.Path(__file__).resolve()
|
|
502
|
+
env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1")
|
|
503
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
504
|
+
for name in ("plain", "trailing ", "trailing\t", "trailing\n", "trailing\r\n"):
|
|
505
|
+
repo = pathlib.Path(tmp).resolve() / name
|
|
506
|
+
repo.mkdir()
|
|
507
|
+
|
|
508
|
+
def git(*args):
|
|
509
|
+
return subprocess.run(["git", *args], cwd=repo, env=env,
|
|
510
|
+
check=True, capture_output=True).stdout
|
|
511
|
+
|
|
512
|
+
git("init", "-q")
|
|
513
|
+
git("-c", "user.name=Test", "-c", "user.email=test@example.com",
|
|
514
|
+
"commit", "--allow-empty", "-qm", "base")
|
|
515
|
+
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
516
|
+
raw = git("rev-parse", option)
|
|
517
|
+
print(f"pathname {name!r} {option}: {raw!r}", flush=True)
|
|
518
|
+
assert raw == os.fsencode(path) + b"\n", raw
|
|
519
|
+
command = [sys.executable, str(script), "pathname", "probe"]
|
|
520
|
+
proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
|
|
521
|
+
result = strict_json(proc.stdout)
|
|
522
|
+
assert proc.returncode == 0 and result["ok"] and not proc.stderr, (name, proc, result)
|
|
523
|
+
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
524
|
+
assert git_text(repo, "rev-parse", option) == str(path)
|
|
525
|
+
lock = repo / ".git" / "devlyn-bootstrap.lock"
|
|
526
|
+
inode = lock.stat().st_ino
|
|
527
|
+
devlyn = repo / ".devlyn"
|
|
528
|
+
before = {p.relative_to(devlyn): p.read_bytes() for p in devlyn.rglob("*") if p.is_file()}
|
|
529
|
+
assert (devlyn / "goal.raw.txt").read_bytes() == b"pathname probe"
|
|
530
|
+
proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
|
|
531
|
+
refused = strict_json(proc.stdout)
|
|
532
|
+
assert proc.returncode == 1 and not proc.stderr, (name, proc)
|
|
533
|
+
assert refused["blocked"] == "BLOCKED:prior-run-unfinished", refused
|
|
534
|
+
assert str(repo) in refused["detail"] and result["run_id"] in refused["detail"]
|
|
535
|
+
assert {p.relative_to(devlyn): p.read_bytes() for p in devlyn.rglob("*") if p.is_file()} == before
|
|
536
|
+
assert not (devlyn / "runs").exists() and lock.stat().st_ino == inode
|
|
537
|
+
print(f"PASS admission pathname {name!r}: exact root/Gitdir, stable lock, unfinished refusal")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def admission_self_test() -> None:
|
|
541
|
+
import time
|
|
542
|
+
from unittest.mock import patch
|
|
543
|
+
|
|
544
|
+
shared = pathlib.Path(__file__).resolve().parent
|
|
545
|
+
script = shared / "resolve-bootstrap.py"
|
|
546
|
+
archive = runpy.run_path(shared / "archive_run.py")
|
|
547
|
+
env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1",
|
|
548
|
+
CLAUDE_CODE_SESSION_ID="same-admission-session")
|
|
549
|
+
spec_raw = (
|
|
550
|
+
'# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n'
|
|
551
|
+
'{"verification_commands":[{"cmd":"printf ok","stdout_contains":["ok"]}]}\n```\n'
|
|
552
|
+
).encode()
|
|
553
|
+
modes = (["goal", "A"], ["--spec", "spec.md"],
|
|
554
|
+
["--verify-only", "external.patch", "--spec", "spec.md"])
|
|
555
|
+
|
|
556
|
+
def git(repo, *args):
|
|
557
|
+
return subprocess.run(["git", *args], cwd=repo, env=env, check=True, capture_output=True).stdout
|
|
558
|
+
|
|
559
|
+
def inputs(repo):
|
|
560
|
+
(repo / "spec.md").write_bytes(spec_raw)
|
|
561
|
+
(repo / "external.patch").write_bytes(b"external patch A\x00\n")
|
|
562
|
+
|
|
563
|
+
def init(repo):
|
|
564
|
+
repo.mkdir()
|
|
565
|
+
git(repo, "init", "-q")
|
|
566
|
+
git(repo, "config", "user.email", "test@example.com")
|
|
567
|
+
git(repo, "config", "user.name", "Test")
|
|
568
|
+
(repo / "app.py").write_text("print('base')\n")
|
|
569
|
+
git(repo, "add", "app.py")
|
|
570
|
+
git(repo, "commit", "-qm", "base")
|
|
571
|
+
inputs(repo)
|
|
572
|
+
|
|
573
|
+
def snapshot(path):
|
|
574
|
+
if not path.exists() and not path.is_symlink():
|
|
575
|
+
return {}
|
|
576
|
+
return {
|
|
577
|
+
str(p.relative_to(path)): (str(p.readlink()) if p.is_symlink()
|
|
578
|
+
else None if p.is_dir() else p.read_bytes())
|
|
579
|
+
for p in (path, *path.rglob("*"))
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
def cli(repo, argv, extra_env=None):
|
|
583
|
+
proc = subprocess.run([sys.executable, str(script), *argv], cwd=repo,
|
|
584
|
+
env=extra_env or env, capture_output=True, text=True, timeout=15)
|
|
585
|
+
assert proc.stderr == "", (proc.returncode, proc.stdout, proc.stderr)
|
|
586
|
+
result = strict_json(proc.stdout)
|
|
587
|
+
assert proc.returncode == (0 if result["ok"] else 1), result
|
|
588
|
+
return result
|
|
589
|
+
|
|
590
|
+
def refusal(repo, argv, reason, extra_env=None):
|
|
591
|
+
before = snapshot(repo / ".devlyn")
|
|
592
|
+
result = cli(repo, argv, extra_env)
|
|
593
|
+
assert result.get("blocked") == reason, result
|
|
594
|
+
assert snapshot(repo / ".devlyn") == before, result
|
|
595
|
+
return result
|
|
596
|
+
|
|
597
|
+
def wait_for(predicate):
|
|
598
|
+
deadline = time.monotonic() + 15
|
|
599
|
+
while not predicate():
|
|
600
|
+
assert time.monotonic() < deadline, "admission child synchronization timed out"
|
|
601
|
+
time.sleep(0.01)
|
|
602
|
+
|
|
603
|
+
# Test-only seam around the real bootstrap and atomic writer. Both children
|
|
604
|
+
# reach the pipe barrier before flock; only the winner reaches the writer.
|
|
605
|
+
child = r"""
|
|
606
|
+
import json, os, pathlib, runpy, sys, time
|
|
607
|
+
script, repo, signals, action, barrier, argv = sys.argv[1:]
|
|
608
|
+
m = runpy.run_path(script)
|
|
609
|
+
signals = pathlib.Path(signals)
|
|
610
|
+
def hold():
|
|
611
|
+
signals.with_suffix('.inside').touch()
|
|
612
|
+
deadline = time.monotonic() + 20
|
|
613
|
+
while not signals.with_suffix('.release').exists():
|
|
614
|
+
if time.monotonic() > deadline:
|
|
615
|
+
raise TimeoutError('held admission was not released')
|
|
616
|
+
time.sleep(0.01)
|
|
617
|
+
def writer(path, raw):
|
|
618
|
+
if action in ('hold', 'before'):
|
|
619
|
+
hold()
|
|
620
|
+
m['atomic_write'](path, raw)
|
|
621
|
+
if action == 'canonical':
|
|
622
|
+
hold()
|
|
623
|
+
if action.startswith('temp:'):
|
|
624
|
+
replace = pathlib.Path.replace
|
|
625
|
+
def held_replace(path, target):
|
|
626
|
+
if path.name.startswith(action[5:] + '.tmp.'):
|
|
627
|
+
hold()
|
|
628
|
+
return replace(path, target)
|
|
629
|
+
pathlib.Path.replace = held_replace
|
|
630
|
+
signals.with_suffix('.ready').touch()
|
|
631
|
+
if int(barrier) >= 0:
|
|
632
|
+
os.read(int(barrier), 1)
|
|
633
|
+
os.close(int(barrier))
|
|
634
|
+
try:
|
|
635
|
+
result = m['bootstrap'](json.loads(argv), pathlib.Path(repo), pathlib.Path(script).parent, writer=writer)
|
|
636
|
+
except m['BootstrapBlocked'] as exc:
|
|
637
|
+
print(json.dumps({'ok': False, 'blocked': exc.reason, 'detail': exc.detail}))
|
|
638
|
+
sys.exit(1)
|
|
639
|
+
print(json.dumps(result))
|
|
640
|
+
"""
|
|
641
|
+
|
|
642
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
643
|
+
root = pathlib.Path(tmp).resolve()
|
|
644
|
+
repo = root / "state"
|
|
645
|
+
init(repo)
|
|
646
|
+
first = cli(repo, modes[0])
|
|
647
|
+
devlyn = repo / ".devlyn"
|
|
648
|
+
state_path = devlyn / "pipeline.state.json"
|
|
649
|
+
initial = strict_json(state_path.read_text())
|
|
650
|
+
(devlyn / "engines.json").write_bytes(b'{"executor":"codex"}\n')
|
|
651
|
+
(devlyn / "config.json").write_bytes(b'{"setting":true}\n')
|
|
652
|
+
(devlyn / "unrelated.data").write_bytes(b"keep\n")
|
|
653
|
+
old_archive = devlyn / "runs" / "unfinished-old" / "pipeline.state.json"
|
|
654
|
+
old_archive.parent.mkdir(parents=True)
|
|
655
|
+
old_archive.write_bytes(b'{"run_id":"unfinished-old","phases":{}}\n')
|
|
656
|
+
for argv in modes:
|
|
657
|
+
result = refusal(repo, argv, "BLOCKED:prior-run-unfinished")
|
|
658
|
+
assert first["run_id"] in result["detail"] and str(repo) in result["detail"]
|
|
659
|
+
invalid = [None, [], "PASS", {}, {"verdict": "PASS"},
|
|
660
|
+
{"completed_at": "2026-09-06T00:00:00Z"}]
|
|
661
|
+
invalid += [{"verdict": v, "completed_at": "2026-09-06T00:00:00Z"}
|
|
662
|
+
for v in (None, [], {}, "UNKNOWN", "FAIL")]
|
|
663
|
+
invalid += [{"verdict": "PASS", "completed_at": t} for t in (
|
|
664
|
+
None, [], "", "2026-09-06T00:00:00", "2026-02-29T00:00:00Z",
|
|
665
|
+
"2026-09-06T24:00:00Z", "2026-09-06T00:00:00.1Z",
|
|
666
|
+
"2026-09-06T00:00:00+00:00",
|
|
667
|
+
)]
|
|
668
|
+
for final in invalid:
|
|
669
|
+
state = dict(initial, phases={"final_report": final, "verify": {"verdict": "PASS"}})
|
|
670
|
+
state_path.write_bytes(json_bytes(state))
|
|
671
|
+
refusal(repo, modes[0], "BLOCKED:prior-run-unfinished")
|
|
672
|
+
for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"):
|
|
673
|
+
for timestamp in ("2024-02-29T23:59:59Z", "2024-02-29T23:59:59.123Z"):
|
|
674
|
+
state = strict_json(state_path.read_text())
|
|
675
|
+
state["phases"]["final_report"] = {"verdict": verdict, "completed_at": timestamp}
|
|
676
|
+
state_path.write_bytes(json_bytes(state))
|
|
677
|
+
raw = state_path.read_bytes()
|
|
678
|
+
goal = (devlyn / "goal.raw.txt").read_bytes()
|
|
679
|
+
assert cli(repo, modes[0])["ok"]
|
|
680
|
+
archived = devlyn / "runs" / state["run_id"]
|
|
681
|
+
assert (archived / "pipeline.state.json").read_bytes() == raw
|
|
682
|
+
assert (archived / "goal.raw.txt").read_bytes() == goal
|
|
683
|
+
assert (devlyn / "engines.json").read_bytes() == b'{"executor":"codex"}\n'
|
|
684
|
+
assert (devlyn / "config.json").read_bytes() == b'{"setting":true}\n'
|
|
685
|
+
assert (devlyn / "unrelated.data").read_bytes() == b"keep\n"
|
|
686
|
+
assert old_archive.read_bytes() == b'{"run_id":"unfinished-old","phases":{}}\n'
|
|
687
|
+
for raw in (None, b"{", b"null", b'{"run_id":[]}', b'{"run_id":"../escape"}'):
|
|
688
|
+
if raw is None:
|
|
689
|
+
state_path.unlink()
|
|
690
|
+
else:
|
|
691
|
+
state_path.write_bytes(raw)
|
|
692
|
+
refusal(repo, modes[0], "BLOCKED:prior-run-ownership-unverified")
|
|
693
|
+
# Residue also blocks when the canonical report otherwise looks completed.
|
|
694
|
+
for pattern in archive["BOOTSTRAP_TEMP_PATTERNS"]:
|
|
695
|
+
state = dict(initial, phases={"final_report": {
|
|
696
|
+
"verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
|
|
697
|
+
}})
|
|
698
|
+
state_path.write_bytes(json_bytes(state))
|
|
699
|
+
residue = devlyn / pattern.replace("*", "interrupted")
|
|
700
|
+
residue.write_bytes(b"partial")
|
|
701
|
+
for argv in modes:
|
|
702
|
+
refusal(repo, argv, "BLOCKED:prior-run-unfinished")
|
|
703
|
+
residue.unlink()
|
|
704
|
+
print("PASS admission state: same-session/all-mode byte stability, 8 completed forms, ownership/residue refusal")
|
|
705
|
+
|
|
706
|
+
identity = root / "identity"
|
|
707
|
+
init(identity)
|
|
708
|
+
alias = root / "alias"
|
|
709
|
+
alias.symlink_to(identity, target_is_directory=True)
|
|
710
|
+
sub = identity / "sub"
|
|
711
|
+
sub.mkdir()
|
|
712
|
+
detail = refusal(sub, ["--goal-file", "relative.txt"], "BLOCKED:worktree-root-required")["detail"]
|
|
713
|
+
assert str(identity) in detail and "Retry" in detail
|
|
714
|
+
assert not (identity / ".devlyn").exists()
|
|
715
|
+
external = root / "external"
|
|
716
|
+
init(external)
|
|
717
|
+
external_before = snapshot(external)
|
|
718
|
+
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"):
|
|
719
|
+
for value in ("", str(external / ".git")):
|
|
720
|
+
for argv in modes:
|
|
721
|
+
result = refusal(identity, argv, "BLOCKED:git-env-redirect", dict(env, **{key: value}))
|
|
722
|
+
assert key in result["detail"] and str(identity) in result["detail"]
|
|
723
|
+
assert snapshot(external) == external_before
|
|
724
|
+
# Even a non-repository location must reject Git redirection before Git.
|
|
725
|
+
assert cli(root, modes[0], dict(env, GIT_DIR=""))["blocked"] == "BLOCKED:git-env-redirect"
|
|
726
|
+
for relative in (".devlyn", ".devlyn/runs"):
|
|
727
|
+
for dangling in (False, True):
|
|
728
|
+
target = root / ("missing-target" if dangling else "redirect-target")
|
|
729
|
+
if not dangling:
|
|
730
|
+
target.mkdir(exist_ok=True)
|
|
731
|
+
(target / "keep").write_bytes(b"untouched")
|
|
732
|
+
link = identity / relative
|
|
733
|
+
link.parent.mkdir(exist_ok=True)
|
|
734
|
+
link.symlink_to(target, target_is_directory=True)
|
|
735
|
+
before = snapshot(target)
|
|
736
|
+
result = refusal(identity, modes[0], "BLOCKED:devlyn-path-redirect")
|
|
737
|
+
assert str(link) in result["detail"] and link.is_symlink()
|
|
738
|
+
assert snapshot(target) == before
|
|
739
|
+
link.unlink()
|
|
740
|
+
print("PASS admission identity: physical-root guidance, Git redirects including empty, live/dangling directory links")
|
|
741
|
+
|
|
742
|
+
def launch(repo, signals, action, argv, barrier=-1):
|
|
743
|
+
return subprocess.Popen(
|
|
744
|
+
[sys.executable, "-c", child, str(script), str(repo), str(signals),
|
|
745
|
+
action, str(barrier), json.dumps(argv)],
|
|
746
|
+
cwd=repo, env=env, pass_fds=(() if barrier < 0 else (barrier,)),
|
|
747
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
323
748
|
)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
749
|
+
|
|
750
|
+
def receipt(proc):
|
|
751
|
+
stdout, stderr = proc.communicate(timeout=15)
|
|
752
|
+
assert stderr == "", (proc.returncode, stdout, stderr)
|
|
753
|
+
result = strict_json(stdout)
|
|
754
|
+
assert proc.returncode == (0 if result["ok"] else 1), result
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
for i, argv in enumerate(modes):
|
|
758
|
+
race = root / f"race-{i}"
|
|
759
|
+
init(race)
|
|
760
|
+
(race / "spec-b.md").write_bytes(spec_raw.replace(b"# Spec", b"# Other spec"))
|
|
761
|
+
(race / "external-b.patch").write_bytes(b"external patch B\n")
|
|
762
|
+
other = ["goal", "B"] if i == 0 else [s.replace("spec.md", "spec-b.md").replace(
|
|
763
|
+
"external.patch", "external-b.patch") for s in argv]
|
|
764
|
+
signals = [root / f"race-{i}-{label}" for label in ("A", "B")]
|
|
765
|
+
rfd, wfd = os.pipe()
|
|
766
|
+
procs = []
|
|
767
|
+
try:
|
|
768
|
+
procs = [launch(race, signal, "hold", args, rfd)
|
|
769
|
+
for signal, args in zip(signals, (argv, other))]
|
|
770
|
+
os.close(rfd)
|
|
771
|
+
wait_for(lambda: all(s.with_suffix(".ready").exists() for s in signals))
|
|
772
|
+
os.close(wfd)
|
|
773
|
+
wait_for(lambda: any(s.with_suffix(".inside").exists() for s in signals)
|
|
774
|
+
and any(p.poll() is not None for p in procs))
|
|
775
|
+
holders = [j for j, s in enumerate(signals) if s.with_suffix(".inside").exists()]
|
|
776
|
+
assert len(holders) == 1, holders
|
|
777
|
+
winner = holders[0]
|
|
778
|
+
loser = receipt(procs[1 - winner])
|
|
779
|
+
assert loser.get("blocked") == "BLOCKED:bootstrap-contended", loser
|
|
780
|
+
assert str(race) in loser["detail"] and "run_id" not in loser
|
|
781
|
+
assert snapshot(race / ".devlyn") == {}, "loser mutated owned/archive state"
|
|
782
|
+
signals[winner].with_suffix(".release").touch()
|
|
783
|
+
won = receipt(procs[winner])
|
|
784
|
+
assert won["ok"]
|
|
785
|
+
finally:
|
|
786
|
+
for proc in procs:
|
|
787
|
+
if proc.poll() is None:
|
|
788
|
+
proc.kill()
|
|
789
|
+
proc.communicate()
|
|
790
|
+
state_raw = (race / ".devlyn/pipeline.state.json").read_bytes()
|
|
791
|
+
state = strict_json(state_raw.decode())
|
|
792
|
+
assert state["run_id"] == won["run_id"] and sha256(state_raw) == won["state_sha256"]
|
|
793
|
+
expected_files = {"pipeline.state.json"}
|
|
794
|
+
if i == 0:
|
|
795
|
+
goal = (race / ".devlyn/goal.raw.txt").read_bytes()
|
|
796
|
+
assert goal == (b"goal A" if winner == 0 else b"goal B")
|
|
797
|
+
assert sha256(goal) == state["source"]["goal_sha256"]
|
|
798
|
+
expected_files.add("goal.raw.txt")
|
|
799
|
+
else:
|
|
800
|
+
source_path = "spec.md" if winner == 0 else "spec-b.md"
|
|
801
|
+
assert state["source"]["spec_path"] == source_path
|
|
802
|
+
assert state["source"]["spec_sha256"] == sha256((race / source_path).read_bytes())
|
|
803
|
+
expected_files.add("spec-verify.json")
|
|
804
|
+
if i == 2:
|
|
805
|
+
patch_path = "external.patch" if winner == 0 else "external-b.patch"
|
|
806
|
+
assert (race / ".devlyn/external-diff.patch").read_bytes() == (race / patch_path).read_bytes()
|
|
807
|
+
expected_files.add("external-diff.patch")
|
|
808
|
+
assert {p.name for p in (race / ".devlyn").iterdir()} == expected_files
|
|
809
|
+
for attempt in modes:
|
|
810
|
+
refusal(race, attempt, "BLOCKED:prior-run-unfinished")
|
|
811
|
+
print("PASS admission processes: pre-lock synchronized pair in all 3 modes, one winner, zero loser delta, unmixed outputs")
|
|
812
|
+
|
|
813
|
+
linked = root / "linked"
|
|
814
|
+
git(identity, "worktree", "add", "-qb", "independent", str(linked))
|
|
815
|
+
inputs(linked)
|
|
816
|
+
lock_path = pathlib.Path(git(identity, "rev-parse", "--absolute-git-dir").decode().strip()) / "devlyn-bootstrap.lock"
|
|
817
|
+
with admission_lock(identity):
|
|
818
|
+
inode = lock_path.stat().st_ino
|
|
819
|
+
assert cli(linked, modes[0])["ok"]
|
|
820
|
+
refusal(alias, modes[0], "BLOCKED:bootstrap-contended")
|
|
821
|
+
assert cli(alias, modes[0])["ok"]
|
|
822
|
+
assert lock_path.stat().st_ino == inode
|
|
823
|
+
assert pathlib.Path(git(linked, "rev-parse", "--absolute-git-dir").decode().strip()) != lock_path.parent
|
|
824
|
+
collision = subprocess.run(["git", "worktree", "add", str(root / "collision"), "independent"],
|
|
825
|
+
cwd=identity, env=env, capture_output=True)
|
|
826
|
+
assert collision.returncode != 0 and b"already" in collision.stderr
|
|
827
|
+
assert not (root / "collision").exists()
|
|
828
|
+
print("PASS admission worktrees: linked root initializes under other's lock; aliases exclude; branch collision retained")
|
|
829
|
+
|
|
830
|
+
failure = root / "failure"
|
|
831
|
+
init(failure)
|
|
832
|
+
def fail_writer(path, raw):
|
|
833
|
+
raise OSError("before-first-write")
|
|
834
|
+
try:
|
|
835
|
+
bootstrap(modes[0], failure, shared, writer=fail_writer)
|
|
836
|
+
except OSError as exc:
|
|
837
|
+
assert str(exc) == "before-first-write"
|
|
838
|
+
else:
|
|
839
|
+
raise AssertionError("writer exception was hidden")
|
|
840
|
+
assert not (failure / ".devlyn").exists()
|
|
841
|
+
failure_lock = failure / ".git/devlyn-bootstrap.lock"
|
|
842
|
+
failure_inode = failure_lock.stat().st_ino
|
|
843
|
+
import fcntl
|
|
844
|
+
for replacement in (patch.dict(sys.modules, {"fcntl": None}),
|
|
845
|
+
patch.object(fcntl, "flock", side_effect=OSError("unsupported locking"))):
|
|
846
|
+
with replacement:
|
|
847
|
+
try:
|
|
848
|
+
bootstrap(modes[0], failure, shared)
|
|
849
|
+
except BootstrapBlocked as exc:
|
|
850
|
+
assert exc.reason == "BLOCKED:bootstrap-lock-unavailable" and str(failure) in exc.detail
|
|
851
|
+
else:
|
|
852
|
+
raise AssertionError("unavailable locking was accepted")
|
|
853
|
+
assert not (failure / ".devlyn").exists()
|
|
854
|
+
assert failure_lock.stat().st_ino == failure_inode
|
|
855
|
+
denied = root / "lock-open-failure"
|
|
856
|
+
init(denied)
|
|
857
|
+
(denied / ".git/devlyn-bootstrap.lock").mkdir()
|
|
858
|
+
refusal(denied, modes[0], "BLOCKED:bootstrap-lock-unavailable")
|
|
859
|
+
assert cli(failure, modes[0])["ok"] and failure_lock.stat().st_ino == failure_inode
|
|
860
|
+
print("PASS admission failures: before-write exception/retry; explicit import/open/acquisition refusal; persistent inode")
|
|
861
|
+
|
|
862
|
+
deaths = [("before", modes[0], None), ("canonical", modes[0], "goal.raw.txt")]
|
|
863
|
+
deaths += [("temp:" + name, modes[mode], name + ".tmp.*") for name, mode in (
|
|
864
|
+
("goal.raw.txt", 0), ("spec-verify.json", 1), ("external-diff.patch", 2), ("pipeline.state.json", 0),
|
|
865
|
+
)]
|
|
866
|
+
for i, (action, argv, family) in enumerate(deaths):
|
|
867
|
+
dead = root / f"death-{i}"
|
|
868
|
+
init(dead)
|
|
869
|
+
signal = root / f"death-{i}"
|
|
870
|
+
proc = launch(dead, signal, action, argv)
|
|
871
|
+
try:
|
|
872
|
+
wait_for(lambda: signal.with_suffix(".inside").exists() or proc.poll() is not None)
|
|
873
|
+
assert signal.with_suffix(".inside").exists(), proc.communicate()
|
|
874
|
+
inode = (dead / ".git/devlyn-bootstrap.lock").stat().st_ino
|
|
875
|
+
proc.kill()
|
|
876
|
+
stdout, stderr = proc.communicate(timeout=15)
|
|
877
|
+
assert proc.returncode == -9 and stdout == "" and stderr == "", (stdout, stderr)
|
|
878
|
+
finally:
|
|
879
|
+
if proc.poll() is None:
|
|
880
|
+
proc.kill()
|
|
881
|
+
proc.communicate()
|
|
882
|
+
if family is None:
|
|
883
|
+
assert not (dead / ".devlyn").exists()
|
|
884
|
+
assert cli(dead, modes[1])["ok"]
|
|
885
|
+
else:
|
|
886
|
+
assert len(list((dead / ".devlyn").glob(family))) == 1
|
|
887
|
+
for attempt in modes:
|
|
888
|
+
refusal(dead, attempt, "BLOCKED:prior-run-ownership-unverified")
|
|
889
|
+
assert (dead / ".git/devlyn-bootstrap.lock").stat().st_ino == inode
|
|
890
|
+
print("PASS admission death: SIGKILL before write retries; canonical and all 4 real atomic temp families preserved across modes")
|
|
374
891
|
|
|
375
892
|
|
|
376
893
|
def self_test() -> int:
|
|
377
894
|
script_shared = pathlib.Path(__file__).resolve().parent
|
|
895
|
+
try:
|
|
896
|
+
strict_json('{"run_id":"a","run_id":"b"}')
|
|
897
|
+
except ValueError as exc:
|
|
898
|
+
assert "duplicate JSON key" in str(exc)
|
|
899
|
+
else:
|
|
900
|
+
raise AssertionError("duplicate bootstrap state key was accepted")
|
|
378
901
|
|
|
379
902
|
def init_repo(path: pathlib.Path) -> None:
|
|
380
903
|
path.mkdir()
|
|
@@ -385,8 +908,43 @@ def self_test() -> int:
|
|
|
385
908
|
subprocess.run(["git", "add", "app.py"], cwd=path, check=True)
|
|
386
909
|
subprocess.run(["git", "commit", "-qm", "base"], cwd=path, check=True)
|
|
387
910
|
|
|
911
|
+
def snapshot(path: pathlib.Path) -> dict[str, bytes]:
|
|
912
|
+
return {
|
|
913
|
+
str(file.relative_to(path)): file.read_bytes()
|
|
914
|
+
for file in path.rglob("*")
|
|
915
|
+
if file.is_file()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
def complete_prior(path: pathlib.Path) -> None:
|
|
919
|
+
state_path = path / ".devlyn" / "pipeline.state.json"
|
|
920
|
+
state = strict_json(state_path.read_text())
|
|
921
|
+
state["phases"]["final_report"] = {
|
|
922
|
+
"verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
|
|
923
|
+
}
|
|
924
|
+
state_path.write_bytes(json_bytes(state))
|
|
925
|
+
|
|
388
926
|
with tempfile.TemporaryDirectory() as tmp:
|
|
389
927
|
root = pathlib.Path(tmp)
|
|
928
|
+
role_work = root / "role-repo"
|
|
929
|
+
init_repo(role_work)
|
|
930
|
+
role_file = root / "roles.json"
|
|
931
|
+
for raw in (b"{", b'{"executor":"codex"}', b'{"roles":null}'):
|
|
932
|
+
role_file.write_bytes(raw)
|
|
933
|
+
try:
|
|
934
|
+
bootstrap(["--role-config", str(role_file), "fix app.py"], role_work, script_shared)
|
|
935
|
+
except BootstrapBlocked as exc:
|
|
936
|
+
assert exc.reason == "BLOCKED:invalid-engine-config"
|
|
937
|
+
else:
|
|
938
|
+
raise AssertionError("invalid per-run roles admitted")
|
|
939
|
+
assert not (role_work / ".devlyn/pipeline.state.json").exists()
|
|
940
|
+
role_raw = b'{"roles":{"worker":{"engine":"codex","model":"gpt-6-astra"}}}'
|
|
941
|
+
role_file.write_bytes(role_raw)
|
|
942
|
+
bootstrap(["--role-config", str(role_file), "--no-pair", "fix app.py"], role_work, script_shared)
|
|
943
|
+
staged_role = strict_json((role_work / ".devlyn/pipeline.state.json").read_text())
|
|
944
|
+
role_file.write_text("{}")
|
|
945
|
+
assert staged_role["role_config_input"]["sha256"] == sha256(role_raw)
|
|
946
|
+
assert staged_role["role_config_input"]["value"] == strict_json(role_raw.decode())
|
|
947
|
+
assert staged_role["role_no_pair"] is True
|
|
390
948
|
work = root / "repo"
|
|
391
949
|
init_repo(work)
|
|
392
950
|
session_key = "CLAUDE_CODE_SESSION_ID"
|
|
@@ -403,6 +961,8 @@ def self_test() -> int:
|
|
|
403
961
|
"engine": "claude",
|
|
404
962
|
"engine_source": "default",
|
|
405
963
|
"mode": "free-form",
|
|
964
|
+
"role_config_input": None,
|
|
965
|
+
"role_no_pair": False,
|
|
406
966
|
"pair_verify": False,
|
|
407
967
|
"complexity": None,
|
|
408
968
|
"risk_profile": {
|
|
@@ -413,8 +973,9 @@ def self_test() -> int:
|
|
|
413
973
|
"pair_default_enabled": True,
|
|
414
974
|
},
|
|
415
975
|
"risk_probes_digest": None,
|
|
976
|
+
"process_evidence": None,
|
|
416
977
|
"base_ref": {
|
|
417
|
-
"branch":
|
|
978
|
+
"branch": base_branch(work),
|
|
418
979
|
"sha": git_text(work, "rev-parse", "HEAD"),
|
|
419
980
|
},
|
|
420
981
|
"rounds": {"max_rounds": 4, "global": 0},
|
|
@@ -442,6 +1003,17 @@ def self_test() -> int:
|
|
|
442
1003
|
assert set(result) == {"ok", "run_id", "mode", "source", "state_path", "state_sha256"}
|
|
443
1004
|
print("PASS bootstrap self-test state-init byte contract: schema v3.0 exact, slim result")
|
|
444
1005
|
|
|
1006
|
+
detached = root / "detached-repo"
|
|
1007
|
+
init_repo(detached)
|
|
1008
|
+
subprocess.run(["git", "checkout", "--detach", "-q"], cwd=detached, check=True)
|
|
1009
|
+
detached_result = bootstrap(["fix", "app.py"], detached, script_shared)
|
|
1010
|
+
detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text())
|
|
1011
|
+
assert detached_result["ok"] is True
|
|
1012
|
+
assert detached_state["base_ref"]["branch"] is None
|
|
1013
|
+
assert detached_state["base_ref"]["sha"] == git_text(detached, "rev-parse", "HEAD")
|
|
1014
|
+
print("PASS bootstrap self-test detached HEAD: branch null with exact HEAD sha")
|
|
1015
|
+
|
|
1016
|
+
complete_prior(work)
|
|
445
1017
|
os.environ[session_key] = "session-self-test"
|
|
446
1018
|
bootstrap(["--engine", "raw-engine", "--pair-verify", "fix", "app.py"], work, script_shared)
|
|
447
1019
|
stamped = strict_json(state_path.read_text())
|
|
@@ -456,6 +1028,8 @@ def self_test() -> int:
|
|
|
456
1028
|
print("PASS bootstrap self-test session stamp: null-safe and env-present; engine flag passthrough")
|
|
457
1029
|
|
|
458
1030
|
invalid_flag_cases = [
|
|
1031
|
+
["--role-config"],
|
|
1032
|
+
["--role-config", "a", "--role-config", "b", "fix app.py"],
|
|
459
1033
|
["--pair-verify", "--no-pair", "fix", "app.py"],
|
|
460
1034
|
["--goal-file", "goal.txt", "--spec", "spec.md"],
|
|
461
1035
|
["--goal-file", "goal.txt", "--verify-only", "patch", "--spec", "spec.md"],
|
|
@@ -520,6 +1094,7 @@ def self_test() -> int:
|
|
|
520
1094
|
|
|
521
1095
|
goal_raw = "fix app.py\r\npreserve café bytes\n".encode()
|
|
522
1096
|
(work / "goal.txt").write_bytes(goal_raw)
|
|
1097
|
+
complete_prior(work)
|
|
523
1098
|
goal_result = bootstrap(["--goal-file", "goal.txt"], work, script_shared)
|
|
524
1099
|
assert goal_result["source"]["goal_sha256"] == sha256(goal_raw)
|
|
525
1100
|
assert (work / ".devlyn" / "goal.raw.txt").read_bytes() == goal_raw
|
|
@@ -563,24 +1138,139 @@ def self_test() -> int:
|
|
|
563
1138
|
"## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n"
|
|
564
1139
|
)
|
|
565
1140
|
spec_raw = spec_path.read_bytes()
|
|
1141
|
+
external_patch = work / ".devlyn" / "external-diff.patch"
|
|
1142
|
+
external_patch.write_bytes(b"stale spec patch\n")
|
|
1143
|
+
complete_prior(work)
|
|
566
1144
|
spec_result = bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
1145
|
+
assert not external_patch.exists()
|
|
567
1146
|
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
568
1147
|
assert staged["verification_commands"][0]["cmd"] == "printf ok"
|
|
569
1148
|
assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
|
|
1149
|
+
external_patch.write_bytes(b"stale free-form patch\n")
|
|
1150
|
+
complete_prior(work)
|
|
1151
|
+
bootstrap(["fresh", "goal"], work, script_shared)
|
|
1152
|
+
assert not external_patch.exists()
|
|
570
1153
|
(spec_dir / "spec.expected.json").write_text(json.dumps({
|
|
571
1154
|
"verification_commands": [{"cmd": "printf expected", "stdout_contains": ["expected"]}],
|
|
572
1155
|
}) + "\n")
|
|
1156
|
+
complete_prior(work)
|
|
573
1157
|
bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
574
1158
|
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
575
1159
|
assert staged["verification_commands"][0]["cmd"] == "printf expected"
|
|
576
1160
|
patch_raw = b"diff --git a/app.py b/app.py\nexact external bytes\x00\n"
|
|
577
1161
|
(work / "external.patch").write_bytes(patch_raw)
|
|
1162
|
+
(work / "app.py").write_text("print('dirty verify-only input')\n")
|
|
1163
|
+
complete_prior(work)
|
|
578
1164
|
verify_result = bootstrap([
|
|
579
1165
|
"--verify-only", "external.patch", "--spec", str(spec_path.relative_to(work)),
|
|
580
1166
|
], work, script_shared)
|
|
581
1167
|
assert verify_result["mode"] == "verify-only"
|
|
582
|
-
assert
|
|
583
|
-
|
|
1168
|
+
assert external_patch.read_bytes() == patch_raw
|
|
1169
|
+
subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
|
|
1170
|
+
print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
|
|
1171
|
+
|
|
1172
|
+
dirty_work = root / "dirty-repo"
|
|
1173
|
+
init_repo(dirty_work)
|
|
1174
|
+
bootstrap(["clean", "baseline"], dirty_work, script_shared)
|
|
1175
|
+
before_dirty = snapshot(dirty_work / ".devlyn")
|
|
1176
|
+
(dirty_work / "app.py").write_text("print('unstaged')\n")
|
|
1177
|
+
for label in ("unstaged", "staged"):
|
|
1178
|
+
if label == "staged":
|
|
1179
|
+
subprocess.run(["git", "add", "app.py"], cwd=dirty_work, check=True)
|
|
1180
|
+
try:
|
|
1181
|
+
bootstrap(["blocked", label], dirty_work, script_shared)
|
|
1182
|
+
except BootstrapBlocked as exc:
|
|
1183
|
+
assert exc.reason == "BLOCKED:worktree-dirty"
|
|
1184
|
+
assert "commit or stash" in exc.detail.lower()
|
|
1185
|
+
else:
|
|
1186
|
+
raise AssertionError(f"{label} tracked owner change accepted")
|
|
1187
|
+
assert snapshot(dirty_work / ".devlyn") == before_dirty
|
|
1188
|
+
subprocess.run(["git", "restore", "--staged", "app.py"], cwd=dirty_work, check=True)
|
|
1189
|
+
subprocess.run(["git", "restore", "app.py"], cwd=dirty_work, check=True)
|
|
1190
|
+
|
|
1191
|
+
devlyn_work = root / "devlyn-dirty-repo"
|
|
1192
|
+
init_repo(devlyn_work)
|
|
1193
|
+
(devlyn_work / ".devlyn").mkdir()
|
|
1194
|
+
owner_file = devlyn_work / ".devlyn" / "owner.txt"
|
|
1195
|
+
owner_file.write_text("base\n")
|
|
1196
|
+
subprocess.run(["git", "add", ".devlyn/owner.txt"], cwd=devlyn_work, check=True)
|
|
1197
|
+
subprocess.run(["git", "commit", "-qm", "track devlyn owner"], cwd=devlyn_work, check=True)
|
|
1198
|
+
owner_file.write_text("dirty allowed\n")
|
|
1199
|
+
assert bootstrap(["devlyn", "allowed"], devlyn_work, script_shared)["ok"] is True
|
|
1200
|
+
assert owner_file.read_text() == "dirty allowed\n"
|
|
1201
|
+
|
|
1202
|
+
untracked_work = root / "untracked-repo"
|
|
1203
|
+
init_repo(untracked_work)
|
|
1204
|
+
(untracked_work / "untracked.txt").write_text("allowed\n")
|
|
1205
|
+
assert bootstrap(["untracked", "allowed"], untracked_work, script_shared)["ok"] is True
|
|
1206
|
+
print("PASS bootstrap self-test honest baseline: dirty owner blocked; .devlyn/untracked allowed")
|
|
1207
|
+
|
|
1208
|
+
prior_work = root / "prior-run-repo"
|
|
1209
|
+
init_repo(prior_work)
|
|
1210
|
+
prior = bootstrap(["prior", "run"], prior_work, script_shared)
|
|
1211
|
+
prior_devlyn = prior_work / ".devlyn"
|
|
1212
|
+
for name in (
|
|
1213
|
+
"implement.task-context",
|
|
1214
|
+
"implement.prompt",
|
|
1215
|
+
"implement.stdout",
|
|
1216
|
+
"implement.stderr",
|
|
1217
|
+
"implement.events.jsonl",
|
|
1218
|
+
"implement.retry.1.stdout",
|
|
1219
|
+
"verify.primary.timeout.json",
|
|
1220
|
+
):
|
|
1221
|
+
(prior_devlyn / name).write_text(f"prior {name}\n", encoding="utf-8")
|
|
1222
|
+
(prior_devlyn / "engines.json").write_text('{"executor":"codex"}\n', encoding="utf-8")
|
|
1223
|
+
(prior_devlyn / "unrelated.data").write_text("preserve\n", encoding="utf-8")
|
|
1224
|
+
before = snapshot(prior_devlyn)
|
|
1225
|
+
try:
|
|
1226
|
+
bootstrap(["replacement", "run"], prior_work, script_shared)
|
|
1227
|
+
except BootstrapBlocked as exc:
|
|
1228
|
+
assert exc.reason == "BLOCKED:prior-run-unfinished"
|
|
1229
|
+
assert prior["run_id"] in exc.detail and str(prior_work.resolve()) in exc.detail
|
|
1230
|
+
else:
|
|
1231
|
+
raise AssertionError("unfinished prior run was replaced")
|
|
1232
|
+
assert snapshot(prior_devlyn) == before
|
|
1233
|
+
complete_prior(prior_work)
|
|
1234
|
+
replacement = bootstrap(["replacement", "run"], prior_work, script_shared)
|
|
1235
|
+
prior_archive = prior_devlyn / "runs" / prior["run_id"]
|
|
1236
|
+
assert replacement["run_id"] != prior["run_id"]
|
|
1237
|
+
for name in (
|
|
1238
|
+
"pipeline.state.json",
|
|
1239
|
+
"goal.raw.txt",
|
|
1240
|
+
"implement.task-context",
|
|
1241
|
+
"implement.prompt",
|
|
1242
|
+
"implement.stdout",
|
|
1243
|
+
"implement.stderr",
|
|
1244
|
+
"implement.events.jsonl",
|
|
1245
|
+
"implement.retry.1.stdout",
|
|
1246
|
+
"verify.primary.timeout.json",
|
|
1247
|
+
):
|
|
1248
|
+
assert (prior_archive / name).is_file(), name
|
|
1249
|
+
if name not in {"pipeline.state.json", "goal.raw.txt"}:
|
|
1250
|
+
assert not (prior_devlyn / name).exists(), name
|
|
1251
|
+
assert strict_json((prior_devlyn / "pipeline.state.json").read_text())["run_id"] == replacement["run_id"]
|
|
1252
|
+
assert (prior_archive / "goal.raw.txt").read_bytes() == b"prior run"
|
|
1253
|
+
assert (prior_devlyn / "goal.raw.txt").read_bytes() == b"replacement run"
|
|
1254
|
+
assert (prior_devlyn / "engines.json").read_text() == '{"executor":"codex"}\n'
|
|
1255
|
+
assert (prior_devlyn / "unrelated.data").read_text() == "preserve\n"
|
|
1256
|
+
|
|
1257
|
+
for label, state_raw in (("missing", None), ("malformed", b"{\n")):
|
|
1258
|
+
unauthenticated = root / f"unauthenticated-{label}"
|
|
1259
|
+
init_repo(unauthenticated)
|
|
1260
|
+
unauthenticated_devlyn = unauthenticated / ".devlyn"
|
|
1261
|
+
unauthenticated_devlyn.mkdir()
|
|
1262
|
+
(unauthenticated_devlyn / "implement.stdout").write_bytes(b"owned bytes\n")
|
|
1263
|
+
if state_raw is not None:
|
|
1264
|
+
(unauthenticated_devlyn / "pipeline.state.json").write_bytes(state_raw)
|
|
1265
|
+
before = snapshot(unauthenticated_devlyn)
|
|
1266
|
+
try:
|
|
1267
|
+
bootstrap(["must", "block"], unauthenticated, script_shared)
|
|
1268
|
+
except BootstrapBlocked as exc:
|
|
1269
|
+
assert exc.reason == "BLOCKED:prior-run-ownership-unverified", exc.reason
|
|
1270
|
+
else:
|
|
1271
|
+
raise AssertionError(f"{label} prior-run ownership was accepted")
|
|
1272
|
+
assert snapshot(unauthenticated_devlyn) == before
|
|
1273
|
+
print("PASS bootstrap self-test prior-run ownership: unfinished refused, completed archived; unauthenticated bytes stable")
|
|
584
1274
|
|
|
585
1275
|
malformed_work = root / "malformed-spec-repo"
|
|
586
1276
|
init_repo(malformed_work)
|
|
@@ -597,6 +1287,8 @@ def self_test() -> int:
|
|
|
597
1287
|
assert not (malformed_work / ".devlyn").exists()
|
|
598
1288
|
print("PASS bootstrap self-test spec validation failure: zero partial filesystem state")
|
|
599
1289
|
|
|
1290
|
+
pathname_self_test()
|
|
1291
|
+
admission_self_test()
|
|
600
1292
|
return 0
|
|
601
1293
|
|
|
602
1294
|
|