devlyn-cli 4.1.0 → 4.2.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 +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +68 -111
- package/bin/devlyn.js +265 -259
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/bin/skill-history.json +140 -0
- package/bin/skill-ownership.js +67 -0
- 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/node_modules/@inquirer/ansi/LICENSE +22 -0
- package/node_modules/@inquirer/ansi/README.md +121 -0
- package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
- package/node_modules/@inquirer/ansi/dist/index.js +21 -0
- package/node_modules/@inquirer/ansi/package.json +79 -0
- package/node_modules/@inquirer/checkbox/LICENSE +22 -0
- package/node_modules/@inquirer/checkbox/README.md +265 -0
- package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
- package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
- package/node_modules/@inquirer/checkbox/package.json +93 -0
- package/node_modules/@inquirer/core/LICENSE +22 -0
- package/node_modules/@inquirer/core/README.md +424 -0
- package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/index.js +12 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
- package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
- package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
- package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
- package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
- package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
- package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
- package/node_modules/@inquirer/core/package.json +98 -0
- package/node_modules/@inquirer/figures/LICENSE +22 -0
- package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
- package/node_modules/@inquirer/figures/dist/index.js +315 -0
- package/node_modules/@inquirer/figures/package.json +81 -0
- package/node_modules/@inquirer/select/LICENSE +22 -0
- package/node_modules/@inquirer/select/README.md +258 -0
- package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
- package/node_modules/@inquirer/select/dist/index.js +191 -0
- package/node_modules/@inquirer/select/package.json +93 -0
- package/node_modules/@inquirer/type/LICENSE +22 -0
- package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
- package/node_modules/@inquirer/type/dist/index.js +2 -0
- package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
- package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
- package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
- package/node_modules/@inquirer/type/dist/utils.js +16 -0
- package/node_modules/@inquirer/type/package.json +88 -0
- package/node_modules/cli-width/LICENSE +13 -0
- package/node_modules/cli-width/README.md +71 -0
- package/node_modules/cli-width/index.d.ts +13 -0
- package/node_modules/cli-width/index.js +49 -0
- package/node_modules/cli-width/package.json +40 -0
- package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
- package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
- package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
- package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
- package/node_modules/fast-string-truncated-width/license +21 -0
- package/node_modules/fast-string-truncated-width/package.json +35 -0
- package/node_modules/fast-string-truncated-width/readme.md +59 -0
- package/node_modules/fast-string-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-width/dist/index.js +14 -0
- package/node_modules/fast-string-width/license +21 -0
- package/node_modules/fast-string-width/package.json +34 -0
- package/node_modules/fast-string-width/readme.md +42 -0
- package/node_modules/fast-wrap-ansi/LICENSE +23 -0
- package/node_modules/fast-wrap-ansi/README.md +26 -0
- package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
- package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
- package/node_modules/fast-wrap-ansi/package.json +51 -0
- package/node_modules/mute-stream/LICENSE +15 -0
- package/node_modules/mute-stream/README.md +68 -0
- package/node_modules/mute-stream/lib/index.js +142 -0
- package/node_modules/mute-stream/package.json +54 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +74 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
- package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.js +279 -0
- package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/package.json +3 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
- package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
- package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.js +275 -0
- package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/package.json +3 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
- package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
- package/node_modules/signal-exit/package.json +106 -0
- package/package.json +17 -4
- 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
|
@@ -0,0 +1,1780 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ideate loop driver: package validation, the queue, locks, reconciliation and the serial drain.
|
|
3
|
+
|
|
4
|
+
`check` validates a loop package, `add` captures it with its queue rows (which may
|
|
5
|
+
replace one legacy row), `status` shows the reconciled queue and `drain -- <argv>` runs each
|
|
6
|
+
eligible task through allocation, committed inputs, one host-supplied executor
|
|
7
|
+
exchange, evidence-derived acceptance, the terminal transition and delivery.
|
|
8
|
+
Protocol and commands: ../references/loop.md; formats: ../references/package-format.md.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import datetime
|
|
15
|
+
import functools
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import re
|
|
21
|
+
import runpy
|
|
22
|
+
import shlex
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import unittest
|
|
28
|
+
|
|
29
|
+
SKILLS = Path(__file__).resolve().parents[2]
|
|
30
|
+
QUEUE = "docs/specs/queue.md" # The legacy queue: read for materialize, never written.
|
|
31
|
+
CAPTURES = "refs/devlyn/captures/"
|
|
32
|
+
SENTINEL = "<!-- devlyn:verification -->"
|
|
33
|
+
ID = r"[a-z0-9][a-z0-9-]{0,62}"
|
|
34
|
+
SHA_RE = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
35
|
+
JSON_FENCE_RE = re.compile(r"(?ms)^```json[ \t]*\n(.*?)\n```[ \t]*$")
|
|
36
|
+
ROW_RE = re.compile(r"- \[(?P<mark>[ xF])\] (?P<text>.*)")
|
|
37
|
+
ITEM_RE = re.compile(rf"(?P<identity>(?P<loop>{ID})\.(?P<task>{ID})) \[(?P<title>(?:[^\]\\\n]|\\.)*)\]"
|
|
38
|
+
r"\(docs/specs/(?P=loop)/(?P=task)/spec\.md\)(?P<rest>(?: — .*)?)")
|
|
39
|
+
REPLACES_RE = re.compile(r"<!-- replaces docs/specs/queue\.md row \(occurrence ([1-9][0-9]*)\): (- \[ \] .*) -->")
|
|
40
|
+
REQUIREMENT_RE = re.compile(r"[-*] (R[1-9][0-9]*): \S")
|
|
41
|
+
META_SECTIONS = ("Intent", "Constraints and exclusions", "Tasks", "Overall acceptance", "Execution policy",
|
|
42
|
+
"Decisions and assumptions")
|
|
43
|
+
TASK_SECTIONS = ("Context and goal", "Requirements", "Scope and constraints", "Out of scope", "Prerequisite inputs",
|
|
44
|
+
"Verification", "Deliverable and cleanup")
|
|
45
|
+
MANIFEST_KEYS = {"schema_version", "loop_id", "base_ref", "base_sha", "delivery", "tasks", "integration_task_id"}
|
|
46
|
+
FRONTMATTER_KEYS = {"id", "loop_id", "title", "kind", "review_requirements", "complexity"}
|
|
47
|
+
KINDS = {"feature", "spike", "prototype"}
|
|
48
|
+
UNSUPPORTED = {"process_evidence": "phase process obligations", "required_risk_probe_requirements": "risk-probe obligations",
|
|
49
|
+
"tier_a_waivers": "benchmark scope waivers", "spec_output_files": "benchmark scope surfaces"}
|
|
50
|
+
SETTLED = {"LOCAL_ONLY", "COMPLETE", "FAILED"}
|
|
51
|
+
OBLIGATIONS = [
|
|
52
|
+
"Work only in this worktree on its owned branch under the installed methodology; never edit the loop package or docs/specs/queue.md.",
|
|
53
|
+
"Commit any source changes on the owned branch; when none are needed, use inputs_sha without creating an empty commit. Leave no uncommitted or untracked source and remove residue the change created.",
|
|
54
|
+
"Run `runner` on the committed candidate; review records must bind engine, model, that source and both contract digests.",
|
|
55
|
+
"Write `submission` and exit; report a blocker instead of weakening acceptance.",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LoopError(Exception):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Waiting(LoopError):
|
|
64
|
+
"""The task cannot proceed now: it waits with this reason while independent work continues."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require(condition, message):
|
|
68
|
+
if not condition:
|
|
69
|
+
raise LoopError(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@functools.lru_cache(maxsize=None)
|
|
73
|
+
def shared(name):
|
|
74
|
+
return runpy.run_path(str(SKILLS / "_shared" / f"{name}.py"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@functools.lru_cache(maxsize=None)
|
|
78
|
+
def acceptance():
|
|
79
|
+
return runpy.run_path(str(Path(__file__).with_name("acceptance.py")))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def git_run(cwd, *args, data=None, env=None, ok=(0,)):
|
|
83
|
+
result = subprocess.run(["git", "-C", str(cwd), *args], input=data, capture_output=True, env=env)
|
|
84
|
+
require(result.returncode in ok, f"git {' '.join(args[:2])}: {result.stderr.decode('utf-8', 'replace').strip()}")
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def git(cwd, *args, **kwargs):
|
|
89
|
+
return git_run(cwd, *args, **kwargs).stdout.decode("utf-8").strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def show(cwd, rev, path):
|
|
93
|
+
result = git_run(cwd, "cat-file", "blob", f"{rev}:{path}", ok=(0, 128))
|
|
94
|
+
return result.stdout if result.returncode == 0 else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ancestor(cwd, old, new):
|
|
98
|
+
return git_run(cwd, "merge-base", "--is-ancestor", old, new, ok=(0, 1)).returncode == 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_text(path):
|
|
102
|
+
try:
|
|
103
|
+
return Path(path).read_bytes().decode("utf-8").replace("\r\n", "\n")
|
|
104
|
+
except (OSError, UnicodeError) as exc:
|
|
105
|
+
raise LoopError(f"{path}: {exc}") from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_json(path):
|
|
109
|
+
try:
|
|
110
|
+
return shared("expected-contract")["loads_strict_json"](read_text(path))
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise LoopError(f"{path}: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_json(path, value):
|
|
116
|
+
acceptance()["write_json"](path, value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def ref_value(cwd, ref):
|
|
120
|
+
return git_run(cwd, "rev-parse", "--verify", "--quiet", ref, ok=(0, 1)).stdout.decode("utf-8").strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def progress(identity, event):
|
|
124
|
+
print(f"devlyn-loop: {identity}: {event}", file=sys.stderr, flush=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def repository(path):
|
|
128
|
+
anchor = Path(git(Path(path), "rev-parse", "--show-toplevel")).resolve()
|
|
129
|
+
return anchor, Path(git(anchor, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@contextlib.contextmanager
|
|
133
|
+
def lock(common, name, *, blocking):
|
|
134
|
+
"""Native locks on the common Gitdir: drain.lock for a controller's lifetime, queue.lock for short mutations."""
|
|
135
|
+
directory = common / "devlyn-loops"
|
|
136
|
+
directory.mkdir(exist_ok=True)
|
|
137
|
+
with contextlib.ExitStack() as stack:
|
|
138
|
+
try:
|
|
139
|
+
stack.enter_context(shared("platform-support")["file_lock"](directory / name, blocking=blocking))
|
|
140
|
+
except BlockingIOError as exc:
|
|
141
|
+
raise LoopError(f"another drain is active for this repository ({directory / name})") from exc
|
|
142
|
+
yield
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def sections(text, path):
|
|
146
|
+
found, current, fence = {}, None, False
|
|
147
|
+
for line in text.split("\n"):
|
|
148
|
+
fence ^= line.startswith("```")
|
|
149
|
+
match = None if fence else re.fullmatch(r"## +(\S.*?)\s*", line)
|
|
150
|
+
if match:
|
|
151
|
+
current = match.group(1)
|
|
152
|
+
require(current not in found, f"{path}: duplicate section '## {current}'")
|
|
153
|
+
found[current] = []
|
|
154
|
+
elif current is not None:
|
|
155
|
+
found[current].append(line)
|
|
156
|
+
return {name: "\n".join(lines).strip() for name, lines in found.items()}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def require_sections(found, names, path):
|
|
160
|
+
missing = [name for name in names if not found.get(name)]
|
|
161
|
+
require(not missing, f"{path}: missing or empty section(s): " + ", ".join(f"'## {name}'" for name in missing))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def frontmatter(text, path):
|
|
165
|
+
end = text.find("\n---\n", 3)
|
|
166
|
+
require(text.startswith("---\n") and end > 0, f"{path}: must start with a --- frontmatter block closed by ---")
|
|
167
|
+
values = {}
|
|
168
|
+
for line in filter(str.strip, text[4:end].split("\n")):
|
|
169
|
+
key, separator, value = (part.strip() for part in line.partition(":"))
|
|
170
|
+
require(separator and re.fullmatch(r"[a-z_]+", key) and key not in values, f"{path}: invalid or duplicate frontmatter line {line!r}")
|
|
171
|
+
values[key] = ([item.strip().strip("\"'") for item in value[1:-1].split(",") if item.strip()]
|
|
172
|
+
if value.startswith("[") and value.endswith("]") else value.strip("\"'"))
|
|
173
|
+
return values, text[end + 5:]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def find_cycle(graph):
|
|
177
|
+
state, stack = {}, []
|
|
178
|
+
|
|
179
|
+
def visit(node):
|
|
180
|
+
state[node] = "open"
|
|
181
|
+
stack.append(node)
|
|
182
|
+
for dep in graph[node]:
|
|
183
|
+
if state.get(dep) == "open":
|
|
184
|
+
return stack[stack.index(dep):] + [dep]
|
|
185
|
+
if dep not in state and (cycle := visit(dep)):
|
|
186
|
+
return cycle
|
|
187
|
+
state[node] = "done"
|
|
188
|
+
stack.pop()
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
return next((cycle for node in graph if node not in state and (cycle := visit(node))), None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def closure(graph, node):
|
|
195
|
+
seen, todo = set(), list(graph[node])
|
|
196
|
+
while todo:
|
|
197
|
+
dep = todo.pop()
|
|
198
|
+
if dep not in seen:
|
|
199
|
+
seen.add(dep)
|
|
200
|
+
todo.extend(graph[dep])
|
|
201
|
+
return seen
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def check_manifest(anchor, manifest, loop_id, meta):
|
|
205
|
+
def fail(message):
|
|
206
|
+
raise LoopError(f"{meta}: manifest {message}")
|
|
207
|
+
|
|
208
|
+
if not isinstance(manifest, dict) or set(manifest) != MANIFEST_KEYS:
|
|
209
|
+
fail("keys must be exactly " + ", ".join(sorted(MANIFEST_KEYS)))
|
|
210
|
+
if manifest["schema_version"] != 1 or isinstance(manifest["schema_version"], bool):
|
|
211
|
+
fail("schema_version must be 1")
|
|
212
|
+
if manifest["loop_id"] != loop_id or not re.fullmatch(ID, loop_id):
|
|
213
|
+
fail(f"loop_id must equal its directory name and match {ID}")
|
|
214
|
+
base_ref, base_sha = manifest["base_ref"], manifest["base_sha"]
|
|
215
|
+
if not isinstance(base_ref, str) or not base_ref or git_run(anchor, "check-ref-format", "refs/heads/" + base_ref, ok=(0, 1)).returncode:
|
|
216
|
+
fail("base_ref must be a branch name")
|
|
217
|
+
if not isinstance(base_sha, str) or not SHA_RE.fullmatch(base_sha) or git_run(anchor, "cat-file", "-e", base_sha + "^{commit}", ok=(0, 1, 128)).returncode:
|
|
218
|
+
fail("base_sha must name an exact commit in this repository")
|
|
219
|
+
if manifest["delivery"] not in {"auto", "pr", "local-only"}:
|
|
220
|
+
fail("delivery must be auto, pr or local-only")
|
|
221
|
+
tasks = manifest["tasks"]
|
|
222
|
+
if not isinstance(tasks, list) or not tasks:
|
|
223
|
+
fail("tasks must be a non-empty list")
|
|
224
|
+
order = []
|
|
225
|
+
for entry in tasks:
|
|
226
|
+
if not isinstance(entry, dict) or set(entry) != {"id", "spec", "depends_on"}:
|
|
227
|
+
fail("tasks need exactly id, spec and depends_on")
|
|
228
|
+
task, deps = entry["id"], entry["depends_on"]
|
|
229
|
+
if not isinstance(task, str) or not re.fullmatch(ID, task) or task in order:
|
|
230
|
+
fail(f"task id {task!r} must be unique and match {ID}")
|
|
231
|
+
if entry["spec"] != f"{task}/spec.md":
|
|
232
|
+
fail(f"task {task} spec link must be {task}/spec.md")
|
|
233
|
+
if not isinstance(deps, list) or not all(isinstance(dep, str) for dep in deps) or len(set(deps)) != len(deps):
|
|
234
|
+
fail(f"task {task} depends_on must list distinct task ids")
|
|
235
|
+
order.append(task)
|
|
236
|
+
graph = {entry["id"]: entry["depends_on"] for entry in tasks}
|
|
237
|
+
for task, deps in graph.items():
|
|
238
|
+
if unknown := [dep for dep in deps if dep not in graph]:
|
|
239
|
+
fail(f"task {task} depends on unknown task(s): {', '.join(unknown)}")
|
|
240
|
+
if cycle := find_cycle(graph):
|
|
241
|
+
fail("dependency cycle: " + " -> ".join(cycle))
|
|
242
|
+
for task, deps in graph.items():
|
|
243
|
+
if late := [dep for dep in deps if order.index(dep) > order.index(task)]:
|
|
244
|
+
fail(f"task {task} must be listed after its dependencies: {', '.join(late)}")
|
|
245
|
+
final = manifest["integration_task_id"]
|
|
246
|
+
if final != order[-1] or set(order[:-1]) - closure(graph, final):
|
|
247
|
+
fail("integration_task_id must name the last task, which depends directly or transitively on every other task")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def package_bytes(anchor, rev, rel):
|
|
251
|
+
"""A package file's bytes in its loop's capture `rev`, or in the checkout when the loop has no record; None if absent."""
|
|
252
|
+
if rev is not None:
|
|
253
|
+
return show(anchor, rev, rel)
|
|
254
|
+
return (anchor / rel).read_bytes() if (anchor / rel).is_file() else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def package_text(anchor, rev, rel):
|
|
258
|
+
"""A package file's text and the label that names it in errors."""
|
|
259
|
+
label = anchor / rel if rev is None else f"{rev}:{rel}"
|
|
260
|
+
data = package_bytes(anchor, rev, rel)
|
|
261
|
+
require(data is not None, f"{label}: no such file")
|
|
262
|
+
try:
|
|
263
|
+
return data.decode("utf-8").replace("\r\n", "\n"), label
|
|
264
|
+
except UnicodeError as exc:
|
|
265
|
+
raise LoopError(f"{label}: {exc}") from exc
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def load_expected(text, path, requirements, review):
|
|
269
|
+
try:
|
|
270
|
+
data = shared("expected-contract")["loads_strict_json"](text)
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise LoopError(f"{path}: {exc}") from exc
|
|
273
|
+
|
|
274
|
+
def fail(message):
|
|
275
|
+
raise LoopError(f"{path}: {message}")
|
|
276
|
+
|
|
277
|
+
for key, label in UNSUPPORTED.items():
|
|
278
|
+
if isinstance(data, dict) and key in data:
|
|
279
|
+
fail(f"{key} ({label}) is unsupported by loop acceptance; translate it into verification_commands or review_requirements without weakening it")
|
|
280
|
+
if error := shared("expected-contract")["validate_expected_shape"](data):
|
|
281
|
+
fail(error)
|
|
282
|
+
commands = data.get("verification_commands", [])
|
|
283
|
+
for index, command in enumerate(commands):
|
|
284
|
+
refs = command.get("contract_refs", [])
|
|
285
|
+
if not refs or any(ref not in requirements for ref in refs):
|
|
286
|
+
fail(f"verification_commands[{index}].contract_refs must name requirement IDs of this task")
|
|
287
|
+
for index, pattern in enumerate(data.get("forbidden_patterns", [])):
|
|
288
|
+
try:
|
|
289
|
+
re.compile(pattern["pattern"])
|
|
290
|
+
except re.error as exc:
|
|
291
|
+
fail(f"forbidden_patterns[{index}] is not a valid regex: {exc}")
|
|
292
|
+
if data.get("pure_design") is True:
|
|
293
|
+
if commands or set(review) != set(requirements):
|
|
294
|
+
fail("pure_design permits no verification_commands and needs review_requirements covering every requirement")
|
|
295
|
+
elif not commands:
|
|
296
|
+
fail("runtime tasks need verification_commands; a pure_design task declares it with full review coverage")
|
|
297
|
+
covered = {ref for command in commands for ref in command["contract_refs"]} | set(review)
|
|
298
|
+
if uncovered := [req for req in requirements if req not in covered]:
|
|
299
|
+
fail("requirement(s) without command or review coverage: " + ", ".join(uncovered))
|
|
300
|
+
return data
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_task(anchor, manifest, entry, rev):
|
|
304
|
+
loop_id, task = manifest["loop_id"], entry["id"]
|
|
305
|
+
text, path = package_text(anchor, rev, f"docs/specs/{loop_id}/{task}/spec.md")
|
|
306
|
+
front, body = frontmatter(text, path)
|
|
307
|
+
|
|
308
|
+
def fail(message):
|
|
309
|
+
raise LoopError(f"{path}: {message}")
|
|
310
|
+
|
|
311
|
+
if "status" in front:
|
|
312
|
+
fail("status is queue state, not contract; remove it from the frontmatter")
|
|
313
|
+
if unknown := sorted(set(front) - FRONTMATTER_KEYS):
|
|
314
|
+
fail(f"unknown frontmatter key(s): {', '.join(unknown)}")
|
|
315
|
+
if missing := sorted(FRONTMATTER_KEYS - {"complexity"} - set(front)):
|
|
316
|
+
fail(f"missing frontmatter key(s): {', '.join(missing)}")
|
|
317
|
+
if (front["id"], front["loop_id"]) != (task, loop_id):
|
|
318
|
+
fail("frontmatter id and loop_id must match the manifest")
|
|
319
|
+
if not isinstance(front["title"], str) or not front["title"]:
|
|
320
|
+
fail("title must be a non-empty line")
|
|
321
|
+
if not isinstance(front["kind"], str) or front["kind"] not in KINDS:
|
|
322
|
+
fail("kind must be feature, spike or prototype")
|
|
323
|
+
review = front["review_requirements"]
|
|
324
|
+
if not isinstance(review, list):
|
|
325
|
+
fail("review_requirements must be a list such as [R2] or []")
|
|
326
|
+
found = sections(body, path)
|
|
327
|
+
require_sections(found, TASK_SECTIONS, path)
|
|
328
|
+
if not re.search(rf"(?m)^{re.escape(SENTINEL)}\n## +Verification[ \t]*$", body):
|
|
329
|
+
fail(f"'## Verification' must directly follow the {SENTINEL} sentinel line")
|
|
330
|
+
requirements = [m.group(1) for line in found["Requirements"].split("\n") if (m := REQUIREMENT_RE.match(line))]
|
|
331
|
+
if not requirements or len(set(requirements)) != len(requirements):
|
|
332
|
+
fail("Requirements need unique stable IDs written as '- R1: <obligation>'")
|
|
333
|
+
if unknown := [req for req in review if req not in requirements]:
|
|
334
|
+
fail(f"review_requirements name unknown requirement(s): {', '.join(unknown)}")
|
|
335
|
+
expected, expected_path = package_text(anchor, rev, f"docs/specs/{loop_id}/{task}/spec.expected.json")
|
|
336
|
+
return {"id": task, "title": front["title"], "kind": front["kind"], "requirements": requirements, "review": review,
|
|
337
|
+
"depends_on": entry["depends_on"], "expected": load_expected(expected, expected_path, requirements, review)}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_manifest(anchor, loop_id, rev=None):
|
|
341
|
+
"""The loop's meta.md text and its validated manifest, from its capture `rev` or, without a record, the checkout."""
|
|
342
|
+
text, meta = package_text(anchor, rev, f"docs/specs/{loop_id}/meta.md")
|
|
343
|
+
require_sections(sections(text, meta), META_SECTIONS, meta)
|
|
344
|
+
fences = JSON_FENCE_RE.findall(text)
|
|
345
|
+
require(len(fences) == 1, f"{meta}: needs exactly one fenced json manifest, found {len(fences)}")
|
|
346
|
+
try:
|
|
347
|
+
manifest = shared("expected-contract")["loads_strict_json"](fences[0])
|
|
348
|
+
except ValueError as exc:
|
|
349
|
+
raise LoopError(f"{meta}: manifest is not strict JSON: {exc}") from exc
|
|
350
|
+
check_manifest(anchor, manifest, loop_id, meta)
|
|
351
|
+
return text, manifest
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_package(anchor, loop_id, rev=None):
|
|
355
|
+
"""The manifest and each task's contract, or under `invalid` the task's validation error."""
|
|
356
|
+
text, manifest = load_manifest(anchor, loop_id, rev)
|
|
357
|
+
tasks, invalid = {}, {}
|
|
358
|
+
for entry in manifest["tasks"]:
|
|
359
|
+
try:
|
|
360
|
+
tasks[entry["id"]] = load_task(anchor, manifest, entry, rev)
|
|
361
|
+
except LoopError as exc:
|
|
362
|
+
invalid[entry["id"]] = str(exc)
|
|
363
|
+
return {"loop_id": loop_id, "meta_text": text, "manifest": manifest, "tasks": tasks, "invalid": invalid}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def package_of(anchor, meta):
|
|
367
|
+
meta = Path(meta).resolve()
|
|
368
|
+
loop_id = meta.parent.name
|
|
369
|
+
require(meta.name == "meta.md" and meta.parent.parent == anchor / "docs/specs", f"package must be <repo>/docs/specs/<loop-id>/meta.md: {meta}")
|
|
370
|
+
package = load_package(anchor, loop_id)
|
|
371
|
+
if package["invalid"]:
|
|
372
|
+
raise LoopError(next(iter(package["invalid"].values())))
|
|
373
|
+
return package
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def escape(title):
|
|
377
|
+
return title.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def row_line(identity, title):
|
|
381
|
+
loop, task = identity.split(".")
|
|
382
|
+
return f"- [ ] {identity} [{escape(title)}](docs/specs/{loop}/{task}/spec.md)"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def parse_queue(data, label=QUEUE):
|
|
386
|
+
try:
|
|
387
|
+
lines = data.decode("utf-8").split("\n")
|
|
388
|
+
except UnicodeError as exc:
|
|
389
|
+
raise LoopError(f"{label} is not UTF-8: {exc}") from exc
|
|
390
|
+
rows = []
|
|
391
|
+
for index, raw in enumerate(lines):
|
|
392
|
+
match = ROW_RE.fullmatch(raw.removesuffix("\r"))
|
|
393
|
+
if not match:
|
|
394
|
+
continue
|
|
395
|
+
row = {"index": index, "line": match.group(0), "mark": match["mark"], "text": match["text"].rstrip(), "identity": None}
|
|
396
|
+
# Only a row matching the full loop-row grammar is a loop row; any other row is a legacy row.
|
|
397
|
+
item = ITEM_RE.fullmatch(row["text"])
|
|
398
|
+
if item and bool(item["rest"]) == (match["mark"] == "F"):
|
|
399
|
+
row.update(identity=item["identity"], loop=item["loop"], task=item["task"], rest=item["rest"])
|
|
400
|
+
rows.append(row)
|
|
401
|
+
identities = [row["identity"] for row in rows if row["identity"]]
|
|
402
|
+
duplicates = sorted({identity for identity in identities if identities.count(identity) > 1})
|
|
403
|
+
require(not duplicates, f"{label}: duplicate queue identity: {', '.join(duplicates)}")
|
|
404
|
+
return rows
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def with_rows(data, lines):
|
|
408
|
+
"""`data` with each identity's row replaced by `lines[identity]`, every other byte unchanged."""
|
|
409
|
+
out = data.split(b"\n")
|
|
410
|
+
for row in parse_queue(data):
|
|
411
|
+
if row["identity"] in lines:
|
|
412
|
+
ending = b"\r" if out[row["index"]].endswith(b"\r") else b""
|
|
413
|
+
out[row["index"]] = lines[row["identity"]].encode("utf-8") + ending
|
|
414
|
+
return b"\n".join(out)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def transition(before, identity, mark, suffix=""):
|
|
418
|
+
"""The only legal transition: the identity's pending row becomes [x] or [F] with every other byte unchanged."""
|
|
419
|
+
row = next((row for row in parse_queue(before) if row["identity"] == identity), None)
|
|
420
|
+
require(row is not None and row["mark"] == " ", f"{identity} has no pending queue row to transition")
|
|
421
|
+
return with_rows(before, {identity: f"- [{mark}] {row['text']}{suffix}"})
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def loop_queue(loop):
|
|
425
|
+
return f"docs/specs/{loop}/queue.md"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def loop_rows(loop, package, target):
|
|
429
|
+
"""The queue file add generates for a loop: the legacy row it replaces, if any, then a row per task in manifest
|
|
430
|
+
order, each line one blank line from the next (package-format.md, Queue rows)."""
|
|
431
|
+
lines = [f"<!-- replaces {QUEUE} row (occurrence {target['occurrence']}): {target['line']} -->"] if target else []
|
|
432
|
+
lines += [row_line(f"{loop}.{task['id']}", task["title"]) for task in package["tasks"].values()]
|
|
433
|
+
return ("\n\n".join(lines) + "\n").encode("utf-8")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def parse_loop(data, loop, label):
|
|
437
|
+
"""A loop's queue file: its rows, each a task row of the loop, and the legacy row it replaces as (occurrence, line)."""
|
|
438
|
+
require(data is not None, f"{label}: no such file")
|
|
439
|
+
rows = parse_queue(data, label)
|
|
440
|
+
require(all(row["identity"] and row["loop"] == loop for row in rows), f"{label}: every row must be a task row of loop {loop}")
|
|
441
|
+
targets = [(int(m[1]), m[2]) for line in data.decode("utf-8").split("\n") if (m := REPLACES_RE.fullmatch(line.removesuffix("\r")))]
|
|
442
|
+
require(len(targets) <= 1, f"{label}: replaces more than one legacy row")
|
|
443
|
+
return rows, targets[0] if targets else None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def one_line(text):
|
|
447
|
+
text = " ".join(text.split())
|
|
448
|
+
return text if len(text) <= 200 else text[:199] + "…"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def branch_of(identity):
|
|
452
|
+
return "devlyn/" + identity.replace(".", "/")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def receipt_path(common, identity):
|
|
456
|
+
return common / "devlyn-completion" / hashlib.sha256(branch_of(identity).encode()).hexdigest()[:24] / "receipt.json"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def added_path(common, loop):
|
|
460
|
+
return common / "devlyn-loops" / loop / "added.json"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def records(common):
|
|
464
|
+
"""Each loop's add record, in add order."""
|
|
465
|
+
found = []
|
|
466
|
+
for path in (common / "devlyn-loops").glob("*/added.json"):
|
|
467
|
+
added = read_json(path)
|
|
468
|
+
require(isinstance(added, dict) and added.get("loop_id") == path.parent.name and type(added.get("sequence")) is int
|
|
469
|
+
and all(isinstance(added.get(key), str) for key in ("delivery", "commit", "origin", "branch")), f"{path}: malformed add record")
|
|
470
|
+
found.append(added)
|
|
471
|
+
return {added["loop_id"]: added for added in sorted(found, key=lambda added: added["sequence"])}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def queue_view(anchor, recorded):
|
|
475
|
+
"""The rows status and drain read (loop.md step 1): the legacy queue's raw rows in file order, a row that one loop
|
|
476
|
+
replaces shown as that loop's rows in its place; then the other recorded loops in add order; then the loops without a
|
|
477
|
+
record, as in a fresh clone, from the checkout's tracked queue files in loop-id order. A claim matching no row replaces
|
|
478
|
+
nothing; a row two loops claim is hidden, neither loop taking its place."""
|
|
479
|
+
legacy = anchor / QUEUE
|
|
480
|
+
raw = [row for row in parse_queue(legacy.read_bytes() if legacy.exists() else b"") if not row["identity"]]
|
|
481
|
+
loops = {loop: parse_loop(show(anchor, added["commit"], loop_queue(loop)), loop, f"{CAPTURES}{loop}:{loop_queue(loop)}")
|
|
482
|
+
for loop, added in recorded.items()}
|
|
483
|
+
for rel in sorted(filter(None, git(anchor, "ls-files", "-z", "--", ":(glob)docs/specs/*/queue.md").split("\0")), key=lambda rel: rel.split("/")[2]):
|
|
484
|
+
loop, data = rel.split("/")[2], (anchor / rel).read_bytes() if (anchor / rel).is_file() else None
|
|
485
|
+
# A file holding no task row of its directory's loop is not a loop's queue file.
|
|
486
|
+
if loop not in recorded and data and any(row.get("loop") == loop for row in parse_queue(data, anchor / rel)):
|
|
487
|
+
loops[loop] = parse_loop(data, loop, anchor / rel)
|
|
488
|
+
claims = {}
|
|
489
|
+
for loop, (_, target) in loops.items():
|
|
490
|
+
if target:
|
|
491
|
+
claims.setdefault(target, []).append(loop)
|
|
492
|
+
placed = {}
|
|
493
|
+
for (occurrence, line), owners in claims.items():
|
|
494
|
+
matches = [row["index"] for row in raw if row["line"] == line]
|
|
495
|
+
if len(matches) >= occurrence:
|
|
496
|
+
placed[matches[occurrence - 1]] = owners[0] if len(owners) == 1 else None
|
|
497
|
+
rows = []
|
|
498
|
+
for row in raw:
|
|
499
|
+
rows += [row] if row["index"] not in placed else loops[placed[row["index"]]][0] if placed[row["index"]] else []
|
|
500
|
+
return rows + [row for loop, (own, _) in loops.items() if loop not in placed.values() for row in own]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def package_state(cwd, commit, loop):
|
|
504
|
+
"""Each package file of the capture `commit` as the checkout `cwd` holds it: missing, equal (line endings aside) or
|
|
505
|
+
different."""
|
|
506
|
+
states = {}
|
|
507
|
+
for rel in filter(None, git(cwd, "ls-tree", "-r", "-z", "--name-only", commit).split("\0")):
|
|
508
|
+
if rel != loop_queue(loop):
|
|
509
|
+
data = (cwd / rel).read_bytes() if (cwd / rel).is_file() else None
|
|
510
|
+
states[rel] = ("missing" if data is None else "equal" if data.replace(b"\r\n", b"\n") == show(cwd, commit, rel).replace(b"\r\n", b"\n")
|
|
511
|
+
else "different")
|
|
512
|
+
return states
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def clean(common, recorded):
|
|
516
|
+
"""Remove each recorded loop's untracked package originals that equal its capture from the checkout that added it,
|
|
517
|
+
keeping and naming any other copy; returns what remains to report (package-format.md, Commands)."""
|
|
518
|
+
notes = []
|
|
519
|
+
for origin in dict.fromkeys(added["origin"] for added in recorded.values()):
|
|
520
|
+
root = Path(origin)
|
|
521
|
+
if not (root / "docs/specs").is_dir():
|
|
522
|
+
continue
|
|
523
|
+
untracked = set(filter(None, git(root, "ls-files", "--others", "-z", "--", "docs/specs").split("\0")))
|
|
524
|
+
for loop, added in recorded.items():
|
|
525
|
+
package = f"docs/specs/{loop}/"
|
|
526
|
+
if added["origin"] != origin or not any(rel.startswith(package) for rel in untracked):
|
|
527
|
+
continue
|
|
528
|
+
for rel, state in package_state(root, added["commit"], loop).items():
|
|
529
|
+
if rel in untracked and state == "different":
|
|
530
|
+
notes.append(f"{root / rel}: kept, it differs from the captured package {CAPTURES}{loop}")
|
|
531
|
+
elif rel in untracked and state == "equal":
|
|
532
|
+
try:
|
|
533
|
+
(root / rel).unlink()
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
notes.append(f"{root / rel}: incomplete cleanup: {exc}")
|
|
536
|
+
for directory, _, _ in os.walk(root / package, topdown=False):
|
|
537
|
+
with contextlib.suppress(OSError):
|
|
538
|
+
os.rmdir(directory)
|
|
539
|
+
return notes
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def task_complete(action, **values):
|
|
543
|
+
helper = shared("task-complete")
|
|
544
|
+
try:
|
|
545
|
+
return helper[action](argparse.Namespace(**values))
|
|
546
|
+
except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
|
|
547
|
+
raise LoopError(f"task-complete {action}: {exc}") from exc
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def derive_state(anchor, common, row, claims, rev):
|
|
551
|
+
identity = row["identity"]
|
|
552
|
+
state = {"kind": {" ": "pending", "x": "accepted", "F": "failed"}[row["mark"]], "receipt": None, "path": None}
|
|
553
|
+
require(len(claims) <= 1, f"conflicting receipts for {identity}: {', '.join(str(path) for path, _ in claims)}")
|
|
554
|
+
if not claims:
|
|
555
|
+
return state
|
|
556
|
+
path, receipt = claims[0]
|
|
557
|
+
require(receipt.get("branch") == branch_of(identity) and path == receipt_path(common, identity),
|
|
558
|
+
f"receipt {path} claims {identity} from branch {receipt.get('branch')!r}; conflicting ownership, inspect it")
|
|
559
|
+
if receipt.get("allocation") != "owned":
|
|
560
|
+
# Never adopted, and no executor ran: the task waits until the user removes what the allocation left.
|
|
561
|
+
state["invalid"] = (f"allocation did not finish ({path}); remove its worktree {receipt.get('worktree')} and branch "
|
|
562
|
+
f"{receipt['branch']} if present, delete {path.parent}, then drain again")
|
|
563
|
+
return state
|
|
564
|
+
result = None if not receipt.get("acceptance") else "failed" if receipt.get("product") == "FAILED" else "accepted"
|
|
565
|
+
require(row["mark"] == " " or result == state["kind"],
|
|
566
|
+
f"conflicting terminal state for {identity}: queue row [{row['mark']}] but receipt {path} is {result or 'unbound'}")
|
|
567
|
+
if result:
|
|
568
|
+
recovery = ref_value(anchor, receipt["recovery_ref"])
|
|
569
|
+
# Or an attachment interrupted after moving the recovery ref, before saving the receipt; drain completes it.
|
|
570
|
+
require(recovery == receipt["publish_sha"] or not receipt.get("queue") and bool(recovery)
|
|
571
|
+
and is_terminal(anchor, receipt, identity, recovery),
|
|
572
|
+
f"{identity}: recovery ref {receipt['recovery_ref']} is {recovery or 'missing'}, receipt binds {receipt['publish_sha']}")
|
|
573
|
+
if receipt.get("queue"):
|
|
574
|
+
marks = [r["mark"] for r in parse_queue(show(anchor, receipt["publish_sha"], receipt["queue"]["file"]) or b"") if r["identity"] == identity]
|
|
575
|
+
require(marks == ["x" if result == "accepted" else "F"], f"{identity}: terminal commit {receipt['publish_sha']} does not carry its {result} mark")
|
|
576
|
+
packet = path.parent / "packet.json"
|
|
577
|
+
if not result and packet.exists():
|
|
578
|
+
bound = read_json(packet)
|
|
579
|
+
for key in ("contract", "expected"):
|
|
580
|
+
rel = Path(bound[key]["path"]).relative_to(bound["worktree"]).as_posix()
|
|
581
|
+
current = package_bytes(anchor, rev, rel)
|
|
582
|
+
if current is None or hashlib.sha256(current).hexdigest() != bound[key]["sha256"]:
|
|
583
|
+
# Drain fails this task alone; the revision is planned as a new task.
|
|
584
|
+
state["inputs_changed"] = f"inputs-changed: {rel} changed after its inputs were committed; plan the revision as a new task"
|
|
585
|
+
break
|
|
586
|
+
state.update(kind=result or "active", path=path, receipt=receipt)
|
|
587
|
+
return state
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def view(anchor, common, local_only=False):
|
|
591
|
+
"""Reconcile the queue with every receipt and recovery ref before anything is selected."""
|
|
592
|
+
with lock(common, "queue.lock", blocking=True):
|
|
593
|
+
recorded = records(common)
|
|
594
|
+
cleanup = clean(common, recorded)
|
|
595
|
+
rows = queue_view(anchor, recorded)
|
|
596
|
+
claims, unreadable = {}, []
|
|
597
|
+
for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
|
|
598
|
+
try:
|
|
599
|
+
receipt = read_json(path)
|
|
600
|
+
claims.setdefault(receipt["task"], []).append((path, receipt))
|
|
601
|
+
except (LoopError, KeyError, TypeError) as exc:
|
|
602
|
+
unreadable.append(f"{path}: {exc}")
|
|
603
|
+
v = {"anchor": anchor, "common": common, "rows": rows, "records": recorded, "packages": {}, "manifests": {}, "states": {},
|
|
604
|
+
"unreadable": unreadable, "cleanup": cleanup, "local_only": local_only}
|
|
605
|
+
errors = {}
|
|
606
|
+
for row in v["rows"]:
|
|
607
|
+
if row["identity"]:
|
|
608
|
+
state = v["states"][row["identity"]] = derive_state(anchor, common, row, claims.get(row["identity"], []), capture(v, row["loop"]))
|
|
609
|
+
# A changed active task needs no current package text; a validation failure stops only its own task.
|
|
610
|
+
if state["kind"] == "pending" or state["kind"] == "active" and "inputs_changed" not in state:
|
|
611
|
+
loop = row["loop"]
|
|
612
|
+
if loop not in v["packages"] and loop not in errors:
|
|
613
|
+
try:
|
|
614
|
+
v["packages"][loop] = load_package(anchor, loop, capture(v, loop))
|
|
615
|
+
except LoopError as exc:
|
|
616
|
+
errors[loop] = str(exc)
|
|
617
|
+
package = v["packages"].get(loop)
|
|
618
|
+
if problem := errors.get(loop) or package["invalid"].get(row["task"]) or (
|
|
619
|
+
None if row["task"] in package["tasks"] else f"{row['identity']} is not a task of loop {loop}"):
|
|
620
|
+
state["invalid"] = problem
|
|
621
|
+
for row in v["rows"]:
|
|
622
|
+
state = v["states"].get(row["identity"])
|
|
623
|
+
for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"] if state and state["kind"] == "pending" and "invalid" not in state else []:
|
|
624
|
+
dep_state = v["states"].get(f"{row['loop']}.{dep}")
|
|
625
|
+
require(dep_state is not None, f"{row['identity']} depends on {row['loop']}.{dep}, which is not queued")
|
|
626
|
+
if dep_state["kind"] in {"failed", "blocked"}:
|
|
627
|
+
state.update(kind="blocked", blocker=f"{row['loop']}.{dep}", root=dep_state.get("root") or dep_state)
|
|
628
|
+
break
|
|
629
|
+
return v
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def capture(v, loop):
|
|
633
|
+
"""The commit holding the loop's captured package, or None for a loop with no record (its checkout's files)."""
|
|
634
|
+
return v["records"][loop]["commit"] if loop in v["records"] else None
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def published(v, loop):
|
|
638
|
+
"""Why --local-only leaves an auto/pr loop to a normal drain: a task of it already has a pushed PR (loop.md, Commands)."""
|
|
639
|
+
for identity, state in v["states"].items():
|
|
640
|
+
if identity.split(".")[0] == loop and state["receipt"] and state["receipt"].get("pushed"):
|
|
641
|
+
url = state["receipt"].get("pr_url") or state["receipt"]["branch"]
|
|
642
|
+
return f"{identity} already has a pushed PR {url}, so --local-only never runs loop {loop}; drain without --local-only"
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def is_local(v, loop):
|
|
647
|
+
added, package = v["records"].get(loop), v["packages"].get(loop)
|
|
648
|
+
delivery = added["delivery"] if added else package and package["manifest"]["delivery"]
|
|
649
|
+
return (v["local_only"] and not published(v, loop) or delivery == "local-only"
|
|
650
|
+
or any(s["receipt"] and s["receipt"].get("local_only") for i, s in v["states"].items() if i.split(".")[0] == loop))
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def delivered(receipt):
|
|
654
|
+
return receipt.get("delivery") == "COMPLETE" or bool(receipt.get("merge"))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def carrier(v, loop):
|
|
658
|
+
"""The auto/pr task whose undelivered, unfailed PR carries the loop's package, which its base lacked (loop.md step 2)."""
|
|
659
|
+
return None if is_local(v, loop) else next((
|
|
660
|
+
row["identity"] for row in v["rows"] if row.get("loop") == loop and (state := v["states"][row["identity"]])["receipt"]
|
|
661
|
+
and state["kind"] != "failed" and not delivered(state["receipt"]) and show(v["anchor"], state["receipt"]["baseline"], loop_queue(loop)) is None), None)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def waiting(v, row):
|
|
665
|
+
if invalid := v["states"][row["identity"]].get("invalid"):
|
|
666
|
+
return invalid
|
|
667
|
+
local = is_local(v, row["loop"])
|
|
668
|
+
if v["local_only"] and not local:
|
|
669
|
+
return published(v, row["loop"])
|
|
670
|
+
for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"]:
|
|
671
|
+
identity = f"{row['loop']}.{dep}"
|
|
672
|
+
state = v["states"][identity]
|
|
673
|
+
if state["kind"] != "accepted":
|
|
674
|
+
return f"waiting for {identity} ({state['kind']})"
|
|
675
|
+
if not state["receipt"]:
|
|
676
|
+
return f"prerequisite {identity} has no receipt-bound accepted source"
|
|
677
|
+
if not local and not delivered(state["receipt"]):
|
|
678
|
+
return f"awaiting delivery of {identity}"
|
|
679
|
+
if local:
|
|
680
|
+
return local_start(v, row["loop"])[1]
|
|
681
|
+
if plan := carrier(v, row["loop"]):
|
|
682
|
+
return f"awaiting delivery of {plan}, whose PR carries its loop's package"
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def next_task(v, attempted):
|
|
687
|
+
"""Earliest unsettled receipt first, then the earliest eligible pending row in physical order."""
|
|
688
|
+
for row in v["rows"]:
|
|
689
|
+
state = v["states"].get(row["identity"])
|
|
690
|
+
if state and state["receipt"] and row["identity"] not in attempted and "invalid" not in state and (
|
|
691
|
+
state["kind"] == "active" or not state["receipt"].get("queue") or state["receipt"].get("delivery") not in SETTLED):
|
|
692
|
+
return row
|
|
693
|
+
return next((row for row in v["rows"] if row["identity"] not in attempted and v["states"].get(row["identity"], {}).get("kind") == "pending"
|
|
694
|
+
and waiting(v, row) is None), None)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def annotate(v, refused):
|
|
698
|
+
"""Name each task's wait: a pending task's reason from waiting(), else why this drain did not advance it."""
|
|
699
|
+
for row in v["rows"]:
|
|
700
|
+
state = v["states"].get(row["identity"])
|
|
701
|
+
if state and (reason := state["kind"] == "pending" and waiting(v, row) or refused.get(row["identity"])):
|
|
702
|
+
state["waiting"] = reason
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def frontier(v, loop):
|
|
706
|
+
"""The loop's accepted task whose source contains every other accepted source. A local loop builds one history, though
|
|
707
|
+
not always in row order: a task whose unfinished allocation was removed starts again from a later row's source. Without
|
|
708
|
+
one, as after a fork by an older build, it is the last accepted row, and allocate's prerequisite check names the fork."""
|
|
709
|
+
accepted = [v["states"][row["identity"]] for row in v["rows"] if row.get("loop") == loop
|
|
710
|
+
and v["states"][row["identity"]]["kind"] == "accepted" and v["states"][row["identity"]]["receipt"]]
|
|
711
|
+
return next((tip for tip in reversed(accepted) if all(ancestor(v["anchor"], state["receipt"]["source_sha"], tip["receipt"]["source_sha"])
|
|
712
|
+
for state in accepted)), accepted[-1] if accepted else None)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def local_start(v, loop):
|
|
716
|
+
"""The commit a local loop's next task starts from, and why it waits instead (loop.md steps 2-3): none while a task of
|
|
717
|
+
the loop is active, so no two tasks start from one frontier; else its latest accepted source; else the start its
|
|
718
|
+
first allocation fixed, kept in that receipt; else the HEAD of the branch it was added on, which must descend from
|
|
719
|
+
the manifest base_sha."""
|
|
720
|
+
if active := next((identity for identity, state in v["states"].items() if identity.split(".")[0] == loop and state["kind"] == "active"), None):
|
|
721
|
+
return None, f"waiting for {active} (active), since a local loop runs one task at a time"
|
|
722
|
+
tip, added = frontier(v, loop), v["records"].get(loop)
|
|
723
|
+
fixed = next((state["receipt"]["baseline"] for identity, state in v["states"].items() if identity.split(".")[0] == loop
|
|
724
|
+
and state["receipt"] and not {"remote_url", "allocated_from"} & set(state["receipt"])), None)
|
|
725
|
+
remedy = "restore them to their bytes at that commit, or plan the remaining work as a new loop"
|
|
726
|
+
if tip or fixed:
|
|
727
|
+
start = tip["receipt"]["source_sha"] if tip else fixed
|
|
728
|
+
elif added is None:
|
|
729
|
+
return None, f"{loop} has no add record in this repository, so no branch to start it from; drain it where it was added"
|
|
730
|
+
else:
|
|
731
|
+
branch, base = added["branch"], manifest_of(v, loop)["base_sha"]
|
|
732
|
+
start, remedy = ref_value(v["anchor"], "refs/heads/" + branch), f"commit them on {branch}, or restore them to their bytes at that commit"
|
|
733
|
+
if not start or not ancestor(v["anchor"], base, start):
|
|
734
|
+
where = f"is at {start}, which does not descend from the manifest base_sha {base}" if start else "no longer exists"
|
|
735
|
+
return None, f"{branch}, the branch {loop} was added on, {where}; bring {base} into {branch}, or plan the work as a new loop"
|
|
736
|
+
if drift := package_drift(v, loop, start, branch):
|
|
737
|
+
return start, drift
|
|
738
|
+
if drift := instruction_drift(v["anchor"], start):
|
|
739
|
+
return start, f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run without them; {remedy}"
|
|
740
|
+
return start, None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def terminal_line(v, identity):
|
|
744
|
+
state = v["states"][identity]
|
|
745
|
+
row = next(row for row in v["rows"] if row["identity"] == identity)
|
|
746
|
+
if state["kind"] == "blocked":
|
|
747
|
+
root = state["root"]["receipt"]
|
|
748
|
+
return f"- [F] {row['text']} — blocked-prerequisite:{state['blocker']} (receipt {root['id'] if root else 'none'})"
|
|
749
|
+
if state["kind"] in {"accepted", "failed"} and state["receipt"] and state["receipt"].get("queue"):
|
|
750
|
+
terminal = parse_queue(show(v["anchor"], state["receipt"]["publish_sha"], state["receipt"]["queue"]["file"]) or b"")
|
|
751
|
+
return next(r["line"] for r in terminal if r["identity"] == identity)
|
|
752
|
+
return None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def build_tree(worktree, parent, files):
|
|
756
|
+
"""The tree of `parent` (or of nothing) with `files`, built in a temporary index so no checkout changes."""
|
|
757
|
+
with tempfile.TemporaryDirectory(prefix="devlyn-loop-index-") as temp:
|
|
758
|
+
env = {**os.environ, "GIT_INDEX_FILE": str(Path(temp) / "index")}
|
|
759
|
+
git(worktree, "read-tree", parent or "--empty", env=env)
|
|
760
|
+
for rel, data in files.items():
|
|
761
|
+
blob = git(worktree, "hash-object", "-w", "--stdin", data=data)
|
|
762
|
+
git(worktree, "update-index", "--add", "--cacheinfo", f"100644,{blob},{rel}", env=env)
|
|
763
|
+
return git(worktree, "write-tree", env=env)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def commit_files(worktree, branch, parent, files, message, *, checkout):
|
|
767
|
+
"""Commit `files` atop `parent` on `branch`; when `parent` already carries them there is no commit to make."""
|
|
768
|
+
tree = build_tree(worktree, parent, files)
|
|
769
|
+
if tree == git(worktree, "rev-parse", parent + "^{tree}"):
|
|
770
|
+
return parent
|
|
771
|
+
commit = git(worktree, "commit-tree", tree, "-p", parent, "-m", message)
|
|
772
|
+
git(worktree, "update-ref", "refs/heads/" + branch, commit, parent)
|
|
773
|
+
if checkout:
|
|
774
|
+
sync(worktree, branch, parent, commit)
|
|
775
|
+
return commit
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def sync(worktree, branch, parent, commit):
|
|
779
|
+
"""Move a checkout that was clean at `parent` to `commit`; refuses rather than overwrite local edits."""
|
|
780
|
+
if git_run(worktree, "symbolic-ref", "-q", "HEAD", ok=(0, 1)).stdout.decode("utf-8").strip() == "refs/heads/" + branch:
|
|
781
|
+
git(worktree, "read-tree", "-m", "-u", parent, commit)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def input_files(v, row, receipt, local):
|
|
785
|
+
"""The task's scoped inputs (loop.md step 4). A base lacking the loop's package gets the package directory from the
|
|
786
|
+
capture, its queue file's settled rows keeping their marks; otherwise a local task gets the base's queue file with the
|
|
787
|
+
loop's settled rows, and an auto/pr task nothing, so its PR changes only its own row."""
|
|
788
|
+
loop, rel = row["loop"], loop_queue(row["loop"])
|
|
789
|
+
data, files = show(Path(receipt["worktree"]), receipt["baseline"], rel), {}
|
|
790
|
+
if data is None:
|
|
791
|
+
package = [f"docs/specs/{loop}/meta.md", *(f"docs/specs/{loop}/{entry['id']}/{name}" for entry in v["packages"][loop]["manifest"]["tasks"]
|
|
792
|
+
for name in ("spec.md", "spec.expected.json")), rel]
|
|
793
|
+
files = {path: package_bytes(v["anchor"], capture(v, loop), path) for path in package}
|
|
794
|
+
require(None not in files.values(), f"{row['identity']}: the package of loop {loop} is incomplete in this checkout")
|
|
795
|
+
data = files[rel]
|
|
796
|
+
if local or files:
|
|
797
|
+
data = with_rows(data, {other["identity"]: line for other in v["rows"] if other.get("loop") == loop
|
|
798
|
+
and other["identity"] != row["identity"] and (line := terminal_line(v, other["identity"]))})
|
|
799
|
+
files[rel] = data
|
|
800
|
+
return files
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def require_merged(v, row, base):
|
|
804
|
+
"""auto/pr: every prerequisite's merge commit must be in the base, before allocation and before every execution."""
|
|
805
|
+
for dep in v["packages"][row["loop"]]["tasks"][row["task"]]["depends_on"]:
|
|
806
|
+
receipt = v["states"][f"{row['loop']}.{dep}"]["receipt"] or {}
|
|
807
|
+
merge = ((receipt.get("merge") or {}).get("mergeCommit") or {}).get("oid")
|
|
808
|
+
require(merge and ancestor(v["anchor"], merge, base), f"{row['identity']}: base {base} lacks the delivered prerequisite merge {merge}")
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def package_drift(v, loop, start, where):
|
|
812
|
+
"""Why a task of a recorded loop cannot start at `start` (loop.md step 3): it holds the loop's queue file, so the task
|
|
813
|
+
would bind its copy of the package, and that copy differs from the capture."""
|
|
814
|
+
added = v["records"].get(loop)
|
|
815
|
+
if added is None or show(v["anchor"], start, loop_queue(loop)) is None:
|
|
816
|
+
return None
|
|
817
|
+
files = [rel for rel in git(v["anchor"], "diff", "--name-only", "--no-renames", "--diff-filter=DMT", added["commit"], start, "--",
|
|
818
|
+
f"docs/specs/{loop}").splitlines() if rel != loop_queue(loop)]
|
|
819
|
+
return (f"{where} holds another version of loop {loop}'s package (differing from {CAPTURES}{loop}: {', '.join(files)}), so its tasks "
|
|
820
|
+
f"cannot run their captured contracts there; plan the work as a new loop, or restore those files on {where} to the captured "
|
|
821
|
+
"bytes") if files else None
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def instruction_drift(anchor, rev):
|
|
825
|
+
"""The installed instruction files whose content at `rev` differs from the anchor checkout's (both absent is equal): a
|
|
826
|
+
task starting at `rev` would run without them. Git compares them as `git status` does: a link by its target, line endings
|
|
827
|
+
under the checkout's conversion rules, and an untracked or ignored file as one still to commit."""
|
|
828
|
+
names = ("CLAUDE.md", "AGENTS.md")
|
|
829
|
+
differ = git(anchor, "diff", "--name-only", "--no-renames", rev, "--", *names).split("\n")
|
|
830
|
+
differ += git(anchor, "ls-files", "--others", "--", *names).split("\n")
|
|
831
|
+
return ", ".join(name for name in names if name in differ)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def evidence_ignored(anchor, common, start):
|
|
835
|
+
"""Whether a checkout of `start` ignores .devlyn/: its .gitignore files on that path, info/exclude, core.excludesFile."""
|
|
836
|
+
with tempfile.TemporaryDirectory(prefix="devlyn-loop-ignore-") as temp:
|
|
837
|
+
for rel in (".gitignore", ".devlyn/.gitignore", ".devlyn/loop/.gitignore"):
|
|
838
|
+
if (rules := show(anchor, start, rel)) is not None:
|
|
839
|
+
(Path(temp) / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
840
|
+
(Path(temp) / rel).write_bytes(rules)
|
|
841
|
+
return git_run(temp, "--git-dir", str(common), "--work-tree", temp, "check-ignore", "-q", "--no-index",
|
|
842
|
+
".devlyn/loop/evidence", ok=(0, 1)).returncode == 0
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def allocate(v, row, opts):
|
|
846
|
+
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, or its refreshed
|
|
847
|
+
remote base holds another version of the loop's package or lacks the checkout's instructions (waiting() checks a local
|
|
848
|
+
start before selection)."""
|
|
849
|
+
identity, loop, task = row["identity"], row["loop"], row["task"]
|
|
850
|
+
package = v["packages"][loop]
|
|
851
|
+
manifest = package["manifest"]
|
|
852
|
+
anchor, common = v["anchor"], v["common"]
|
|
853
|
+
deps = [v["states"][f"{loop}.{dep}"] for dep in package["tasks"][task]["depends_on"]]
|
|
854
|
+
values = {"repo": str(anchor), "task": identity, "branch": branch_of(identity), "repository": None,
|
|
855
|
+
"base": manifest["base_ref"], "remote": "origin", "worktree": str(opts.worktree_root / loop / task),
|
|
856
|
+
"local_base": None, "from_receipt": None, "start": None}
|
|
857
|
+
if is_local(v, loop):
|
|
858
|
+
tip, start = frontier(v, loop), local_start(v, loop)[0]
|
|
859
|
+
for dep in deps:
|
|
860
|
+
require(ancestor(anchor, dep["receipt"]["source_sha"], start),
|
|
861
|
+
f"{identity}: prerequisite source {dep['receipt']['source_sha']} is not in the accepted frontier {start}; plan an integration task")
|
|
862
|
+
values["from_receipt" if tip else "local_base"] = str(tip["path"]) if tip else start
|
|
863
|
+
else:
|
|
864
|
+
helper = shared("task-complete")
|
|
865
|
+
origin = git_run(anchor, "config", "--get", "remote.origin.url", ok=(0, 1)).stdout.decode("utf-8").strip()
|
|
866
|
+
try:
|
|
867
|
+
values["repository"] = helper["repository_from_url"](origin)
|
|
868
|
+
except helper["CompletionError"]:
|
|
869
|
+
return (f"{manifest['delivery']} delivery needs an origin remote of the form https://github.com/<owner>/<repo>, "
|
|
870
|
+
"git@github.com:<owner>/<repo> or ssh://git@github.com/<owner>/<repo>; set one, or drain with --local-only")
|
|
871
|
+
try:
|
|
872
|
+
start = helper["remote_base"]({"common_gitdir": str(common), "remote": "origin", "base": manifest["base_ref"]})
|
|
873
|
+
except helper["CompletionError"] as exc:
|
|
874
|
+
raise LoopError(f"{identity}: cannot refresh base {manifest['base_ref']}: {exc}") from exc
|
|
875
|
+
values["start"] = start
|
|
876
|
+
require_merged(v, row, start)
|
|
877
|
+
if drift := package_drift(v, loop, start, f"origin/{manifest['base_ref']}"):
|
|
878
|
+
return drift
|
|
879
|
+
require(evidence_ignored(anchor, common, start),
|
|
880
|
+
f"{identity}: .devlyn/ is not ignored in its start commit {start}, so loop evidence would dirty task source; commit a "
|
|
881
|
+
f"`.devlyn/` entry to .gitignore in the base the task starts from, or add `.devlyn/` to {common / 'info' / 'exclude'}")
|
|
882
|
+
if values["start"] and (drift := instruction_drift(anchor, start)):
|
|
883
|
+
return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
|
|
884
|
+
f"without them; commit them and push them to origin/{manifest['base_ref']}")
|
|
885
|
+
result = task_complete("allocate", **values)
|
|
886
|
+
progress(identity, f"allocated {result['worktree']}")
|
|
887
|
+
return None
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def ensure_packet(v, row, path, receipt):
|
|
891
|
+
"""Commit this task's scoped inputs on the owned branch, then write the executor's task packet."""
|
|
892
|
+
packet_path = path.parent / "packet.json"
|
|
893
|
+
if packet_path.exists():
|
|
894
|
+
return packet_path, read_json(packet_path)
|
|
895
|
+
identity, loop, task = row["identity"], row["loop"], row["task"]
|
|
896
|
+
worktree, baseline, branch = Path(receipt["worktree"]), receipt["baseline"], receipt["branch"]
|
|
897
|
+
local = bool(receipt.get("local_only"))
|
|
898
|
+
files = input_files(v, row, receipt, local)
|
|
899
|
+
head = git(worktree, "rev-parse", "refs/heads/" + branch)
|
|
900
|
+
if head == baseline:
|
|
901
|
+
inputs = commit_files(worktree, branch, baseline, files, f"devlyn loop: inputs for {identity}", checkout=True)
|
|
902
|
+
else:
|
|
903
|
+
require(git(worktree, "rev-parse", head + "^") == baseline and git(worktree, "rev-parse", head + "^{tree}") == build_tree(worktree, baseline, files),
|
|
904
|
+
f"{identity}: owned branch has commits that are not its scoped inputs; inspect {worktree}")
|
|
905
|
+
inputs = head
|
|
906
|
+
sync(worktree, branch, baseline, inputs)
|
|
907
|
+
progress(identity, f"inputs {inputs}")
|
|
908
|
+
package = v["packages"][loop]
|
|
909
|
+
spec = package["tasks"][task]
|
|
910
|
+
|
|
911
|
+
def identity_of(rel):
|
|
912
|
+
return {"path": str(worktree / rel), "sha256": hashlib.sha256(show(worktree, inputs, rel)).hexdigest()}
|
|
913
|
+
|
|
914
|
+
packet = {
|
|
915
|
+
"schema_version": 1, "task": identity, "title": spec["title"], "kind": spec["kind"],
|
|
916
|
+
"worktree": str(worktree), "branch": branch, "receipt": str(path), "scratch": str(path.parent / "scratch"),
|
|
917
|
+
"allocation_base": baseline, "inputs_sha": inputs,
|
|
918
|
+
"contract": identity_of(f"docs/specs/{loop}/{task}/spec.md"),
|
|
919
|
+
"expected": identity_of(f"docs/specs/{loop}/{task}/spec.expected.json"),
|
|
920
|
+
"meta": identity_of(f"docs/specs/{loop}/meta.md"),
|
|
921
|
+
"requirements": spec["requirements"], "review_requirements": spec["review"],
|
|
922
|
+
"shared_constraints": sections(package["meta_text"], "meta.md")["Constraints and exclusions"],
|
|
923
|
+
"dependencies": [{"task": f"{loop}.{dep}", "receipt": str(state["path"]), "source_sha": state["receipt"]["source_sha"]}
|
|
924
|
+
for dep in spec["depends_on"] if (state := v["states"][f"{loop}.{dep}"])["receipt"]],
|
|
925
|
+
"methodology": [identity_of(name) for name in ("CLAUDE.md", "AGENTS.md") if show(worktree, inputs, name) is not None],
|
|
926
|
+
"delivery": "local-only" if local else package["manifest"]["delivery"],
|
|
927
|
+
"evidence_dir": str(worktree / ".devlyn" / "loop"),
|
|
928
|
+
"submission": str(path.parent / "submission.json"),
|
|
929
|
+
"runner": [sys.executable, str(Path(__file__).with_name("acceptance.py")), "run", "--packet", str(packet_path)],
|
|
930
|
+
"obligations": OBLIGATIONS,
|
|
931
|
+
}
|
|
932
|
+
write_json(packet_path, packet)
|
|
933
|
+
return packet_path, packet
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def record(log, event):
|
|
937
|
+
"""Append one execution event durably, before the drain acts on it."""
|
|
938
|
+
with log.open("a", encoding="utf-8") as stream:
|
|
939
|
+
stream.write(f"{datetime.datetime.now(datetime.timezone.utc).isoformat()} {event}\n")
|
|
940
|
+
stream.flush()
|
|
941
|
+
os.fsync(stream.fileno())
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def ensure_submission(identity, packet_path, packet, executor):
|
|
945
|
+
"""Run the executor once unless its submission exists; returns a drain-recorded failure reason, else None. An
|
|
946
|
+
executor that exits without writing its submission raises Waiting: the task stays active and the next drain runs it
|
|
947
|
+
again.
|
|
948
|
+
|
|
949
|
+
An attempt's start is recorded before the spawn and its exit after the wait, so a start without an exit may have
|
|
950
|
+
left a live executor: drain waits until no process uses the worktree, then adopts its submission or runs the
|
|
951
|
+
executor again. Where writers cannot be observed the task fails as interrupted-unobservable."""
|
|
952
|
+
submission = Path(packet["submission"])
|
|
953
|
+
worktree = Path(packet["worktree"])
|
|
954
|
+
log = submission.with_name("executions.log")
|
|
955
|
+
events = log.read_text(encoding="utf-8").splitlines() if log.exists() else []
|
|
956
|
+
if events and events[-1].partition(" ")[2] == "start":
|
|
957
|
+
helper = shared("task-complete")
|
|
958
|
+
reported = False
|
|
959
|
+
while True:
|
|
960
|
+
try:
|
|
961
|
+
helper["stopped_writers"](worktree)
|
|
962
|
+
break
|
|
963
|
+
except helper["WritersUnobservable"] as exc:
|
|
964
|
+
return f"interrupted-unobservable: an interrupted executor may still be writing ({exc})"
|
|
965
|
+
except helper["WriterActive"] as exc:
|
|
966
|
+
if not reported:
|
|
967
|
+
progress(identity, f"waiting for an interrupted execution to stop ({exc})")
|
|
968
|
+
reported = True
|
|
969
|
+
time.sleep(2)
|
|
970
|
+
except helper["CompletionError"] as exc:
|
|
971
|
+
raise LoopError(f"{identity}: cannot establish that an interrupted execution stopped ({exc})") from exc
|
|
972
|
+
if submission.exists():
|
|
973
|
+
return None
|
|
974
|
+
argv = [part.replace("{packet}", str(packet_path)) for part in executor]
|
|
975
|
+
if any("{worktree_git_dir}" in part for part in argv): # Optional: a Codex executor lists it as a writable root.
|
|
976
|
+
git_dir = git(worktree, "rev-parse", "--path-format=absolute", "--git-dir")
|
|
977
|
+
argv = [part.replace("{worktree_git_dir}", git_dir) for part in argv]
|
|
978
|
+
output = Path(packet["evidence_dir"])
|
|
979
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
980
|
+
record(log, "start")
|
|
981
|
+
try:
|
|
982
|
+
# Files, never a pipe: a wrapper such as codex-monitored.sh refuses a piped stdout; stdin is empty, never the driver's.
|
|
983
|
+
with (output / "executor.stdout").open("ab") as stdout, (output / "executor.stderr").open("ab") as stderr:
|
|
984
|
+
child = subprocess.Popen(shared("platform-support")["native_argv"](argv, cwd=worktree), cwd=worktree, stdin=subprocess.DEVNULL,
|
|
985
|
+
stdout=stdout, stderr=stderr)
|
|
986
|
+
except OSError as exc:
|
|
987
|
+
record(log, f"not started: {one_line(str(exc))}")
|
|
988
|
+
raise LoopError(f"executor could not start: {exc}") from exc
|
|
989
|
+
progress(identity, f"executing; output in {output}")
|
|
990
|
+
code = child.wait()
|
|
991
|
+
record(log, f"exit {code} (pid {child.pid})")
|
|
992
|
+
if not submission.exists():
|
|
993
|
+
raise Waiting(f"executor exited {code} without a submission (see {output / 'executor.stderr'}); the next drain runs it again")
|
|
994
|
+
return None
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def terminal_queue(cwd, receipt, identity):
|
|
998
|
+
"""The loop queue file of this bound result's only legal terminal transition: its row becomes [x] or [F] — <reason>."""
|
|
999
|
+
failed = receipt.get("product") == "FAILED"
|
|
1000
|
+
suffix = f" — {one_line(receipt['acceptance']['reasons'][0])} (receipt {receipt['id']})" if failed else ""
|
|
1001
|
+
rel = loop_queue(identity.split(".")[0])
|
|
1002
|
+
return transition(show(cwd, receipt["acceptance"]["inputs_sha"], rel) or b"", identity, "F" if failed else "x", suffix)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def is_terminal(cwd, receipt, identity, commit):
|
|
1006
|
+
"""Whether `commit` is that transition: its sole parent is the bound source and its only change is the row."""
|
|
1007
|
+
source, rel = receipt["source_sha"], loop_queue(identity.split(".")[0])
|
|
1008
|
+
return (git(cwd, "rev-list", "--parents", "-n", "1", commit).split() == [commit, source]
|
|
1009
|
+
and git(cwd, "diff", "--name-only", source, commit) == rel and show(cwd, commit, rel) == terminal_queue(cwd, receipt, identity))
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def settle(v, row, receipt_file):
|
|
1013
|
+
"""Create (or adopt) the queue-only terminal commit atop the bound source, then attach it."""
|
|
1014
|
+
identity = row["identity"]
|
|
1015
|
+
receipt = read_json(receipt_file)
|
|
1016
|
+
worktree, source, branch = Path(receipt["worktree"]), receipt["source_sha"], receipt["branch"]
|
|
1017
|
+
failed = receipt.get("product") == "FAILED"
|
|
1018
|
+
with lock(v["common"], "queue.lock", blocking=True):
|
|
1019
|
+
head = git(worktree, "rev-parse", "refs/heads/" + branch)
|
|
1020
|
+
if head == source:
|
|
1021
|
+
commit = commit_files(worktree, branch, source, {loop_queue(row["loop"]): terminal_queue(worktree, receipt, identity)},
|
|
1022
|
+
f"devlyn loop: {identity} {'failed' if failed else 'accepted'}", checkout=not failed)
|
|
1023
|
+
else:
|
|
1024
|
+
require(is_terminal(worktree, receipt, identity, head),
|
|
1025
|
+
f"{identity}: owned branch moved past the bound source without its terminal transition; inspect {worktree}")
|
|
1026
|
+
commit = head
|
|
1027
|
+
if not failed:
|
|
1028
|
+
sync(worktree, branch, source, commit)
|
|
1029
|
+
progress(identity, f"terminal {commit}")
|
|
1030
|
+
task_complete("attach", receipt=str(receipt_file), commit=commit, file=loop_queue(row["loop"]))
|
|
1031
|
+
progress(identity, "attached")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def advance(v, row, opts):
|
|
1035
|
+
"""Run or resume one task from its durable state; never replays a bound result. Returns why it waits instead."""
|
|
1036
|
+
identity = row["identity"]
|
|
1037
|
+
path = receipt_path(v["common"], identity)
|
|
1038
|
+
local = is_local(v, row["loop"])
|
|
1039
|
+
if opts.local_only and not local:
|
|
1040
|
+
return published(v, row["loop"])
|
|
1041
|
+
if not path.exists() and (refused := allocate(v, row, opts)):
|
|
1042
|
+
return refused
|
|
1043
|
+
receipt = read_json(path)
|
|
1044
|
+
if not receipt.get("acceptance"):
|
|
1045
|
+
failure = v["states"][identity].get("inputs_changed")
|
|
1046
|
+
if not failure and not receipt.get("local_only"):
|
|
1047
|
+
require_merged(v, row, receipt["baseline"])
|
|
1048
|
+
packet_path, packet = ensure_packet(v, row, path, receipt)
|
|
1049
|
+
try:
|
|
1050
|
+
failure = failure or ensure_submission(identity, packet_path, packet, opts.executor)
|
|
1051
|
+
except Waiting as exc:
|
|
1052
|
+
return str(exc)
|
|
1053
|
+
try:
|
|
1054
|
+
result = acceptance()["accept"](packet_path, packet["submission"], failure)
|
|
1055
|
+
except acceptance()["AcceptanceError"] as exc:
|
|
1056
|
+
raise LoopError(f"{identity}: acceptance could not run: {exc}") from exc
|
|
1057
|
+
progress(identity, result["verdict"].lower() + "".join(f"; {reason}" for reason in result["reasons"][:3]))
|
|
1058
|
+
task_complete("accept", receipt=str(path), acceptance=str(Path(packet["worktree"]) / ".devlyn/loop/acceptance.json"))
|
|
1059
|
+
progress(identity, "bound")
|
|
1060
|
+
if not read_json(path).get("queue"):
|
|
1061
|
+
settle(v, row, path)
|
|
1062
|
+
receipt = read_json(path)
|
|
1063
|
+
if receipt.get("delivery") not in SETTLED:
|
|
1064
|
+
packet = read_json(path.parent / "packet.json")
|
|
1065
|
+
try:
|
|
1066
|
+
# The plan's mode seeds the first delivery call; a later per-task --mode persists in the receipt.
|
|
1067
|
+
result = task_complete("complete", receipt=str(path), acceptance=None,
|
|
1068
|
+
mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
|
|
1069
|
+
except LoopError as exc:
|
|
1070
|
+
return f"delivery blocked: {exc}"
|
|
1071
|
+
progress(identity, f"delivery {result['status']}")
|
|
1072
|
+
return None
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def summary(v, row):
|
|
1076
|
+
state = v["states"][row["identity"]]
|
|
1077
|
+
receipt = state["receipt"] or {}
|
|
1078
|
+
item = {"identity": row["identity"], "result": state["kind"], "receipt": str(state["path"]) if state["path"] else None}
|
|
1079
|
+
if state["kind"] == "failed":
|
|
1080
|
+
item["reason"] = row["rest"].lstrip(" —") if row["mark"] == "F" else (receipt.get("acceptance") or {}).get("reasons", ["failed"])[0]
|
|
1081
|
+
if state["kind"] == "blocked":
|
|
1082
|
+
item["reason"] = f"blocked-prerequisite:{state['blocker']}"
|
|
1083
|
+
if reason := state.get("waiting") or state.get("inputs_changed") or state.get("invalid"):
|
|
1084
|
+
item["reason"] = reason
|
|
1085
|
+
if receipt:
|
|
1086
|
+
acceptance_record = receipt.get("acceptance") or {}
|
|
1087
|
+
item.update(allocation_base=receipt.get("baseline"), **{"source" if state["kind"] == "accepted" else "candidate": receipt.get("source_sha")},
|
|
1088
|
+
terminal=(receipt.get("queue") or {}).get("commit"), delivery=receipt.get("delivery"), pr=receipt.get("pr_url"),
|
|
1089
|
+
resume=None if not receipt.get("acceptance") or receipt.get("delivery") in SETTLED
|
|
1090
|
+
else shlex.join([sys.executable, str(SKILLS / "_shared/task-complete.py"), "complete", "--receipt", str(state["path"])])
|
|
1091
|
+
if receipt.get("queue") else "drain again: the terminal commit is not attached yet",
|
|
1092
|
+
assumptions=acceptance_record.get("assumptions", []),
|
|
1093
|
+
questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
|
|
1094
|
+
worktree=receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None,
|
|
1095
|
+
cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
|
|
1096
|
+
branch=receipt.get("branch"), recovery_ref=receipt.get("recovery_ref"),
|
|
1097
|
+
custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
|
|
1098
|
+
scratch=(receipt.get("scratch_cleanup") or {}).get("status", "NOT_CLEANED"))
|
|
1099
|
+
return item
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def manifest_of(v, loop):
|
|
1103
|
+
"""The loop's manifest, read once per view."""
|
|
1104
|
+
if loop not in v["manifests"]:
|
|
1105
|
+
v["manifests"][loop] = v["packages"][loop]["manifest"] if loop in v["packages"] else load_manifest(v["anchor"], loop, capture(v, loop))[1]
|
|
1106
|
+
return v["manifests"][loop]
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def loop_acceptance(v, loop, items):
|
|
1110
|
+
"""Whole-loop acceptance over the manifest's complete task set: every task, the integration task last, needs
|
|
1111
|
+
receipt-backed acceptance. A manifest task missing from this queue joins `items` as not yet run."""
|
|
1112
|
+
try:
|
|
1113
|
+
manifest = manifest_of(v, loop)
|
|
1114
|
+
except LoopError as exc:
|
|
1115
|
+
return f"INCOMPLETE — manifest unreadable: {exc}"
|
|
1116
|
+
found, gaps = {item["identity"]: item for item in items}, []
|
|
1117
|
+
for identity in (f"{loop}.{entry['id']}" for entry in manifest["tasks"]):
|
|
1118
|
+
if identity not in found:
|
|
1119
|
+
items.append(found.setdefault(identity, {"identity": identity, "result": "not yet run"}))
|
|
1120
|
+
if found[identity]["result"] != "accepted":
|
|
1121
|
+
gaps.append(f"{identity} {found[identity]['result']}")
|
|
1122
|
+
elif not found[identity].get("receipt"):
|
|
1123
|
+
gaps.append(f"{identity} accepted without a receipt")
|
|
1124
|
+
return "INCOMPLETE — " + ", ".join(gaps) if gaps else "ACCEPTED"
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def bring_in(v, loop):
|
|
1128
|
+
"""The command that brings the loop's accepted work into the branch it was added on (loop.md step 11)."""
|
|
1129
|
+
tip = frontier(v, loop)
|
|
1130
|
+
if tip is None:
|
|
1131
|
+
return None
|
|
1132
|
+
receipt = tip["receipt"]
|
|
1133
|
+
if receipt.get("local_only"):
|
|
1134
|
+
# It fast-forwards when it can and merges otherwise, so the commands of several local loops apply in any order.
|
|
1135
|
+
return f"{v['records'][loop]['branch']}: git merge --ff {receipt['branch']}" if loop in v["records"] else None
|
|
1136
|
+
if not delivered(receipt):
|
|
1137
|
+
return None
|
|
1138
|
+
# The anchor holds no loop commit, so after merge, squash or rebase delivery this fast-forwards unless the branch has commits
|
|
1139
|
+
# of its own, and merges then; like the local form, the commands of several loops apply in any order.
|
|
1140
|
+
return f"{receipt['base']}: git merge --ff {receipt['remote']}/{receipt['base']}"
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def write_reports(v, status, reason):
|
|
1144
|
+
paths = []
|
|
1145
|
+
for loop in dict.fromkeys(row["loop"] for row in v["rows"] if row["identity"]):
|
|
1146
|
+
items = [summary(v, row) for row in v["rows"] if row.get("loop") == loop]
|
|
1147
|
+
lines = [f"# Drain report — {loop}", "", f"- Generated: {datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}",
|
|
1148
|
+
f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
|
|
1149
|
+
f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
|
|
1150
|
+
*([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
|
|
1151
|
+
try:
|
|
1152
|
+
meta, _ = load_manifest(v["anchor"], loop, capture(v, loop))
|
|
1153
|
+
decisions = sections(
|
|
1154
|
+
JSON_FENCE_RE.sub("", meta),
|
|
1155
|
+
v["anchor"] / f"docs/specs/{loop}/meta.md",
|
|
1156
|
+
)["Decisions and assumptions"] or "None recorded."
|
|
1157
|
+
except (LoopError, OSError) as exc:
|
|
1158
|
+
decisions = f"Unavailable: {exc}"
|
|
1159
|
+
lines += ["## Package decisions and assumptions", "", decisions, ""]
|
|
1160
|
+
labels = (("result", "Product"), ("reason", "Reason"), ("receipt", "Receipt"), ("custody", "Evidence custody"),
|
|
1161
|
+
("recovery_ref", "Recovery ref"), ("allocation_base", "Allocation base"), ("source", "Accepted source"), ("candidate", "Unaccepted source"),
|
|
1162
|
+
("terminal", "Terminal commit"), ("delivery", "Delivery"), ("pr", "PR"), ("resume", "Resume"),
|
|
1163
|
+
("assumptions", "Assumptions"), ("questions", "Unresolved questions"), ("worktree", "Retained worktree"),
|
|
1164
|
+
("cleanup", "Workspace cleanup"),
|
|
1165
|
+
("branch", "Branch"), ("scratch", "Scratch cleanup"))
|
|
1166
|
+
for item in items:
|
|
1167
|
+
lines += [f"## {item['identity']}", ""]
|
|
1168
|
+
lines += [f"- {label}: {'; '.join(value) if isinstance(value, list) else value}"
|
|
1169
|
+
for key, label in labels if (value := item.get(key)) not in (None, [], "")]
|
|
1170
|
+
lines.append("")
|
|
1171
|
+
path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
|
|
1172
|
+
acceptance()["atomic_write"](path, "\n".join(lines).encode("utf-8"))
|
|
1173
|
+
paths.append(str(path))
|
|
1174
|
+
return paths
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def counts(v):
|
|
1178
|
+
kinds = [state["kind"] for state in v["states"].values()]
|
|
1179
|
+
return {kind: kinds.count(kind) for kind in ("pending", "active", "accepted", "failed", "blocked")} | {
|
|
1180
|
+
"legacy_pending": sum(1 for row in v["rows"] if not row["identity"] and row["mark"] == " ")}
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def drain(args):
|
|
1184
|
+
anchor, common = repository(args.repo)
|
|
1185
|
+
require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
|
|
1186
|
+
args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn"
|
|
1187
|
+
with lock(common, "drain.lock", blocking=False):
|
|
1188
|
+
attempted, last, status, reason, refused = set(), None, "DRAINED", None, {}
|
|
1189
|
+
try:
|
|
1190
|
+
while True:
|
|
1191
|
+
last = view(anchor, common, args.local_only)
|
|
1192
|
+
row = next_task(last, attempted)
|
|
1193
|
+
if row is None:
|
|
1194
|
+
break
|
|
1195
|
+
attempted.add(row["identity"])
|
|
1196
|
+
if wait := advance(last, row, args):
|
|
1197
|
+
refused[row["identity"]] = wait
|
|
1198
|
+
if any(state["kind"] in {"pending", "active"} for state in last["states"].values()) or counts(last)["legacy_pending"] or refused:
|
|
1199
|
+
status = "WAITING"
|
|
1200
|
+
except (LoopError, OSError) as exc:
|
|
1201
|
+
status, reason = "BLOCKED", str(exc)
|
|
1202
|
+
with contextlib.suppress(LoopError, OSError):
|
|
1203
|
+
last = view(anchor, common, args.local_only)
|
|
1204
|
+
if last is None:
|
|
1205
|
+
return {"status": status, "reason": reason}
|
|
1206
|
+
annotate(last, refused)
|
|
1207
|
+
return {"status": status, "reason": reason, "reports": write_reports(last, status, reason), "counts": counts(last),
|
|
1208
|
+
"tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def status(args):
|
|
1212
|
+
anchor, common = repository(args.repo)
|
|
1213
|
+
v = view(anchor, common, args.local_only)
|
|
1214
|
+
row = next_task(v, set())
|
|
1215
|
+
annotate(v, {})
|
|
1216
|
+
tasks = [summary(v, r) for r in v["rows"] if r["identity"]]
|
|
1217
|
+
return {"status": "OK", "counts": counts(v), "next": row["identity"] if row else None,
|
|
1218
|
+
"blockers": [f"{t['identity']}: {t['reason']}" for t in tasks if t.get("reason") and t["result"] in {"pending", "active", "blocked"}]
|
|
1219
|
+
+ [f"legacy row {r['index'] + 1} needs planning: {r['text']}" for r in v["rows"] if not r["identity"] and r["mark"] == " "]
|
|
1220
|
+
+ [f"unreadable receipt {item}" for item in v["unreadable"]],
|
|
1221
|
+
"delivery": [t for t in tasks if t.get("resume")], "cleanup": v["cleanup"]}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def legacy_target(anchor, number):
|
|
1225
|
+
"""The pending legacy row on line `number` of the legacy queue, as add records it: its exact text and its occurrence
|
|
1226
|
+
among equal rows, line endings aside; None for any other line."""
|
|
1227
|
+
path = anchor / QUEUE
|
|
1228
|
+
rows = parse_queue(path.read_bytes() if path.exists() else b"")
|
|
1229
|
+
row = next((row for row in rows if row["index"] == number - 1 and row["identity"] is None and row["mark"] == " "), None)
|
|
1230
|
+
return row and {"line": row["line"], "occurrence": sum(other["line"] == row["line"] for other in rows if other["index"] <= row["index"])}
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def added_result(anchor, added, notes):
|
|
1234
|
+
loop, ref = added["loop_id"], added["capture"]
|
|
1235
|
+
rows, _ = parse_loop(show(anchor, added["commit"], loop_queue(loop)), loop, f"{ref}:{loop_queue(loop)}")
|
|
1236
|
+
return {"status": "ADDED", "tasks": [row["identity"] for row in rows], "capture": ref, "commit": added["commit"], "cleanup": notes,
|
|
1237
|
+
"message": f"The package is now held immutably at {ref}; inspect it with `git show {ref}:docs/specs/{loop}/meta.md`. "
|
|
1238
|
+
"A revision needs a new loop id."}
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def add(args):
|
|
1242
|
+
"""Capture the package and its queue rows under refs/devlyn/captures/<loop>, record the add, then remove the untracked
|
|
1243
|
+
originals equal to the capture (package-format.md, Commands); the branch, index and tracked files stay as they are."""
|
|
1244
|
+
meta = Path(args.package).resolve()
|
|
1245
|
+
anchor, common = repository(next(path for path in meta.parents if path.is_dir()))
|
|
1246
|
+
loop = meta.parent.name
|
|
1247
|
+
ref, package_dir = CAPTURES + loop, f"docs/specs/{loop}"
|
|
1248
|
+
require(meta.name == "meta.md" and meta.parent.parent == anchor / "docs/specs" and re.fullmatch(ID, loop),
|
|
1249
|
+
f"package must be <repo>/docs/specs/<loop-id>/meta.md: {meta}")
|
|
1250
|
+
with lock(common, "queue.lock", blocking=True):
|
|
1251
|
+
recorded = records(common)
|
|
1252
|
+
notes = clean(common, recorded)
|
|
1253
|
+
target = legacy_target(anchor, args.materialize) if args.materialize else None
|
|
1254
|
+
if loop in recorded:
|
|
1255
|
+
# This package's add, also one interrupted after its record or whose originals are gone, is reported as it is.
|
|
1256
|
+
added = recorded[loop]
|
|
1257
|
+
differ = [rel for rel, state in package_state(anchor, added["commit"], loop).items() if state == "different"]
|
|
1258
|
+
require(not differ and target == added.get("materialize"),
|
|
1259
|
+
f"loop id {loop} was already added with another package ({', '.join(differ) or '--materialize differs'}); it is held at "
|
|
1260
|
+
f"{ref}, so plan revised work as a new loop")
|
|
1261
|
+
return added_result(anchor, added, notes)
|
|
1262
|
+
interrupted = (f"{ref} holds the capture of an interrupted add of {loop} that differs from the checkout; inspect it with `git show "
|
|
1263
|
+
f"{ref}`, then restore that package or delete the ref with `git update-ref -d {ref}`, and add again")
|
|
1264
|
+
if captured := ref_value(anchor, ref):
|
|
1265
|
+
require(all(state == "equal" for state in package_state(anchor, captured, loop).values()), interrupted)
|
|
1266
|
+
package = package_of(anchor, meta)
|
|
1267
|
+
manifest = package["manifest"]
|
|
1268
|
+
local = manifest["delivery"] == "local-only"
|
|
1269
|
+
require(show(anchor, "HEAD", loop_queue(loop)) is None, f"loop id {loop} is already used by {loop_queue(loop)}; plan revised work as a new loop")
|
|
1270
|
+
if tracked := git(anchor, "diff", "--name-only", "--no-renames", "--diff-filter=MDT", "HEAD", "--", package_dir):
|
|
1271
|
+
raise LoopError(f"{package_dir} is committed with different content ({', '.join(tracked.splitlines())}); add never captures a "
|
|
1272
|
+
"revision of committed package files, so plan it as a new loop")
|
|
1273
|
+
branch = git_run(anchor, "symbolic-ref", "-q", "--short", "HEAD", ok=(0, 1)).stdout.decode("utf-8").strip()
|
|
1274
|
+
require(branch, f"add records the branch the loop is added on, but HEAD is detached in {anchor}")
|
|
1275
|
+
if drift := instruction_drift(anchor, head := git(anchor, "rev-parse", "HEAD")):
|
|
1276
|
+
# Tasks start from committed state: a local loop from this branch, auto/pr from the pushed base.
|
|
1277
|
+
raise LoopError(f"the installed instructions ({drift}) in this checkout differ from those in HEAD {head}, so the loop's tasks would "
|
|
1278
|
+
f"run without them; commit {'them' if local else 'and push them'}, then add the loop")
|
|
1279
|
+
if staged := git(anchor, "diff", "--cached", "--name-only", "--no-renames", "HEAD", "--", package_dir):
|
|
1280
|
+
raise LoopError(f"the index holds package changes HEAD lacks ({', '.join(staged.splitlines())}); add leaves the index as it is, "
|
|
1281
|
+
f"so unstage them with `git restore --staged -- {package_dir}` and add again")
|
|
1282
|
+
if args.materialize:
|
|
1283
|
+
pending = {row["index"] for row in queue_view(anchor, recorded) if row["identity"] is None and row["mark"] == " "}
|
|
1284
|
+
require(target and args.materialize - 1 in pending, f"{QUEUE} line {args.materialize} is not a pending legacy row")
|
|
1285
|
+
intent = " ".join(sections(package["meta_text"], "meta.md")["Intent"].split())
|
|
1286
|
+
require(" ".join(target["line"][6:].split()) in intent, "meta.md '## Intent' must reproduce the legacy row's intent verbatim")
|
|
1287
|
+
paths = [f"{package_dir}/meta.md", *(f"{package_dir}/{task}/{name}" for task in package["tasks"] for name in ("spec.md", "spec.expected.json"))]
|
|
1288
|
+
files = {rel: package_bytes(anchor, None, rel) for rel in paths} | {loop_queue(loop): loop_rows(loop, package, target)}
|
|
1289
|
+
tree = build_tree(anchor, None, files)
|
|
1290
|
+
if captured:
|
|
1291
|
+
require(tree == git(anchor, "rev-parse", captured + "^{tree}"), interrupted)
|
|
1292
|
+
else:
|
|
1293
|
+
# The ref keeps the commit, its tree and blobs from garbage collection once the originals are gone.
|
|
1294
|
+
captured = git(anchor, "commit-tree", tree, "-m", f"devlyn loop: capture {loop}")
|
|
1295
|
+
git(anchor, "update-ref", ref, captured, "0" * len(captured))
|
|
1296
|
+
added = {"schema_version": 1, "loop_id": loop, "delivery": manifest["delivery"], "capture": ref, "commit": captured, "tree": tree,
|
|
1297
|
+
"origin": str(anchor), "branch": branch, "sequence": max((other["sequence"] for other in recorded.values()), default=0) + 1,
|
|
1298
|
+
**({"materialize": target} if target else {})}
|
|
1299
|
+
write_json(added_path(common, loop), added)
|
|
1300
|
+
notes += clean(common, {loop: added})
|
|
1301
|
+
return added_result(anchor, added, notes)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def check(args):
|
|
1305
|
+
anchor, _ = repository(Path(args.package).resolve().parent)
|
|
1306
|
+
package = package_of(anchor, args.package)
|
|
1307
|
+
return {"status": "VALID", "loop_id": package["loop_id"], "tasks": [f"{package['loop_id']}.{task}" for task in package["tasks"]]}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
# Self-test fixtures: a valid two-task package, then one mutation per rejected shape.
|
|
1311
|
+
META = """# Loop {loop}
|
|
1312
|
+
|
|
1313
|
+
## Intent
|
|
1314
|
+
|
|
1315
|
+
{intent}
|
|
1316
|
+
|
|
1317
|
+
## Constraints and exclusions
|
|
1318
|
+
|
|
1319
|
+
Standard library only.
|
|
1320
|
+
|
|
1321
|
+
## Tasks
|
|
1322
|
+
|
|
1323
|
+
T1 builds the interface; T2 consumes it.
|
|
1324
|
+
|
|
1325
|
+
```json
|
|
1326
|
+
{manifest}
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
## Overall acceptance
|
|
1330
|
+
|
|
1331
|
+
R1 of t2 checks the assembled product.
|
|
1332
|
+
|
|
1333
|
+
## Execution policy
|
|
1334
|
+
|
|
1335
|
+
Installed methodology; autonomous question policy; delivery as declared.
|
|
1336
|
+
|
|
1337
|
+
## Decisions and assumptions
|
|
1338
|
+
|
|
1339
|
+
None beyond the task contracts.
|
|
1340
|
+
"""
|
|
1341
|
+
SPEC = """---
|
|
1342
|
+
id: {task}
|
|
1343
|
+
loop_id: {loop}
|
|
1344
|
+
title: {title}
|
|
1345
|
+
kind: feature
|
|
1346
|
+
review_requirements: [{review}]
|
|
1347
|
+
---
|
|
1348
|
+
# {title}
|
|
1349
|
+
|
|
1350
|
+
## Context and goal
|
|
1351
|
+
|
|
1352
|
+
Fixture.
|
|
1353
|
+
|
|
1354
|
+
## Requirements
|
|
1355
|
+
|
|
1356
|
+
- R1: {requirement}
|
|
1357
|
+
- R2: The code stays small.
|
|
1358
|
+
|
|
1359
|
+
## Scope and constraints
|
|
1360
|
+
|
|
1361
|
+
Only the declared files.
|
|
1362
|
+
|
|
1363
|
+
## Out of scope
|
|
1364
|
+
|
|
1365
|
+
Nothing else.
|
|
1366
|
+
|
|
1367
|
+
## Prerequisite inputs
|
|
1368
|
+
|
|
1369
|
+
{inputs}
|
|
1370
|
+
|
|
1371
|
+
<!-- devlyn:verification -->
|
|
1372
|
+
## Verification
|
|
1373
|
+
|
|
1374
|
+
The declared commands.
|
|
1375
|
+
|
|
1376
|
+
## Deliverable and cleanup
|
|
1377
|
+
|
|
1378
|
+
Keep the product; no residue.
|
|
1379
|
+
"""
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def write_package(anchor, loop, tasks, *, delivery="local-only", base=None, intent="Ship the fixture.", review="R2"):
|
|
1383
|
+
"""tasks: [(task_id, depends_on, title, verification_commands)] -> meta path."""
|
|
1384
|
+
base = base or git(anchor, "rev-parse", "HEAD")
|
|
1385
|
+
manifest = {"schema_version": 1, "loop_id": loop, "base_ref": "main", "base_sha": base, "delivery": delivery,
|
|
1386
|
+
"tasks": [{"id": task, "spec": f"{task}/spec.md", "depends_on": deps} for task, deps, _, _ in tasks],
|
|
1387
|
+
"integration_task_id": tasks[-1][0]}
|
|
1388
|
+
root = anchor / "docs/specs" / loop
|
|
1389
|
+
for task, deps, title, commands in tasks:
|
|
1390
|
+
(root / task).mkdir(parents=True, exist_ok=True)
|
|
1391
|
+
(root / task / "spec.md").write_text(SPEC.format(task=task, loop=loop, title=title, review=review, requirement=f"{title} works.",
|
|
1392
|
+
inputs=", ".join(deps) or "None."), encoding="utf-8")
|
|
1393
|
+
(root / task / "spec.expected.json").write_text(json.dumps({"verification_commands": commands}, indent=2), encoding="utf-8")
|
|
1394
|
+
(root / "meta.md").write_text(META.format(loop=loop, intent=intent, manifest=json.dumps(manifest, indent=2)), encoding="utf-8")
|
|
1395
|
+
return root / "meta.md"
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
class QueueTests(unittest.TestCase):
|
|
1399
|
+
def setUp(self):
|
|
1400
|
+
self.temp = tempfile.TemporaryDirectory(prefix="devlyn-queue ✓ ")
|
|
1401
|
+
self.addCleanup(self.temp.cleanup)
|
|
1402
|
+
self.root = Path(self.temp.name).resolve()
|
|
1403
|
+
self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
|
|
1404
|
+
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
1405
|
+
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"}
|
|
1406
|
+
self.anchor = self.root / "repo"
|
|
1407
|
+
subprocess.run(["git", "init", "-q", "--initial-branch=main", str(self.anchor)], check=True, env=self.env)
|
|
1408
|
+
(self.anchor / ".gitignore").write_text(".devlyn/\n", encoding="utf-8")
|
|
1409
|
+
subprocess.run(["git", "-C", str(self.anchor), "add", "."], check=True, env=self.env)
|
|
1410
|
+
subprocess.run(["git", "-C", str(self.anchor), "commit", "-qm", "base"], check=True, env=self.env)
|
|
1411
|
+
command = {"argv": [sys.executable, "-c", "pass"], "contract_refs": ["R1"]}
|
|
1412
|
+
self.tasks = [("t1", [], "Interface 인터페이스", [command]), ("t2", ["t1"], "Consumer [app]", [command])]
|
|
1413
|
+
self.meta = write_package(self.anchor, "inv", self.tasks)
|
|
1414
|
+
|
|
1415
|
+
def cli(self, *args, cwd=None, code=0):
|
|
1416
|
+
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), *map(str, args)], cwd=cwd or self.anchor,
|
|
1417
|
+
env=self.env, capture_output=True, text=True, encoding="utf-8")
|
|
1418
|
+
self.assertEqual(result.returncode, code, result.stdout + result.stderr)
|
|
1419
|
+
return json.loads(result.stdout)
|
|
1420
|
+
|
|
1421
|
+
def rejected(self, message, mutate):
|
|
1422
|
+
root = self.anchor / "docs/specs/inv"
|
|
1423
|
+
saved = {path: path.read_bytes() for path in root.rglob("*") if path.is_file()}
|
|
1424
|
+
mutate(root)
|
|
1425
|
+
try:
|
|
1426
|
+
with self.assertRaises(LoopError) as caught:
|
|
1427
|
+
package_of(self.anchor, self.meta)
|
|
1428
|
+
self.assertIn(message, str(caught.exception))
|
|
1429
|
+
finally:
|
|
1430
|
+
for path in root.rglob("*"):
|
|
1431
|
+
if path.is_file() and path not in saved:
|
|
1432
|
+
path.unlink()
|
|
1433
|
+
for path, data in saved.items():
|
|
1434
|
+
path.write_bytes(data)
|
|
1435
|
+
|
|
1436
|
+
def edit(self, rel, old, new):
|
|
1437
|
+
def mutate(root):
|
|
1438
|
+
path = root / rel
|
|
1439
|
+
text = path.read_text(encoding="utf-8")
|
|
1440
|
+
self.assertIn(old, text)
|
|
1441
|
+
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
|
1442
|
+
return mutate
|
|
1443
|
+
|
|
1444
|
+
def test_package_validation(self):
|
|
1445
|
+
self.assertEqual(self.cli("check", self.meta)["tasks"], ["inv.t1", "inv.t2"])
|
|
1446
|
+
manifest = json.loads(JSON_FENCE_RE.search((self.anchor / "docs/specs/inv/meta.md").read_text(encoding="utf-8")).group(1))
|
|
1447
|
+
dumped = json.dumps(manifest, indent=2)
|
|
1448
|
+
|
|
1449
|
+
def manifest_edit(change):
|
|
1450
|
+
data = json.loads(dumped)
|
|
1451
|
+
change(data)
|
|
1452
|
+
return self.edit("meta.md", dumped, json.dumps(data, indent=2))
|
|
1453
|
+
|
|
1454
|
+
cases = [
|
|
1455
|
+
("exactly one fenced json manifest", self.edit("meta.md", "## Overall acceptance", "```json\n{}\n```\n\n## Overall acceptance")),
|
|
1456
|
+
("manifest is not strict JSON", self.edit("meta.md", '"schema_version": 1', '"schema_version": 1, "schema_version": 1')),
|
|
1457
|
+
("keys must be exactly", manifest_edit(lambda m: m.pop("delivery"))),
|
|
1458
|
+
("must be unique", manifest_edit(lambda m: m["tasks"][1].update(id="t1", spec="t1/spec.md"))),
|
|
1459
|
+
("depends on unknown task(s): t9", manifest_edit(lambda m: m["tasks"][1].update(depends_on=["t9"]))),
|
|
1460
|
+
("dependency cycle: t1 -> t2 -> t1", manifest_edit(lambda m: m["tasks"][0].update(depends_on=["t2"]))),
|
|
1461
|
+
("spec link must be t2/spec.md", manifest_edit(lambda m: m["tasks"][1].update(spec="t1/spec.md"))),
|
|
1462
|
+
("integration_task_id must name the last task", manifest_edit(lambda m: m.update(integration_task_id="t1"))),
|
|
1463
|
+
("integration_task_id must name the last task", manifest_edit(lambda m: m["tasks"][1].update(depends_on=[]))),
|
|
1464
|
+
("base_sha must name an exact commit", manifest_edit(lambda m: m.update(base_sha="0" * 40))),
|
|
1465
|
+
("missing or empty section(s): '## Execution policy'", self.edit("meta.md", "## Execution policy", "## Policy")),
|
|
1466
|
+
("status is queue state", self.edit("t1/spec.md", "kind: feature", "kind: feature\nstatus: planned")),
|
|
1467
|
+
("kind must be feature, spike or prototype", self.edit("t1/spec.md", "kind: feature", "kind: [feature]")),
|
|
1468
|
+
("must directly follow", self.edit("t1/spec.md", "<!-- devlyn:verification -->\n", "")),
|
|
1469
|
+
("unique stable IDs", self.edit("t1/spec.md", "- R2: The code", "- R1: The code")),
|
|
1470
|
+
("review_requirements name unknown requirement(s): R7", self.edit("t1/spec.md", "review_requirements: [R2]", "review_requirements: [R7]")),
|
|
1471
|
+
("process_evidence (phase process obligations) is unsupported", self.edit("t1/spec.expected.json", '"verification_commands"', '"process_evidence": [], "verification_commands"')),
|
|
1472
|
+
("contract_refs must name requirement IDs", self.edit("t1/spec.expected.json", '"R1"', '"R9"')),
|
|
1473
|
+
("without command or review coverage: R2", self.edit("t1/spec.md", "review_requirements: [R2]", "review_requirements: []")),
|
|
1474
|
+
("pure_design permits no verification_commands", self.edit("t1/spec.expected.json", '"verification_commands"', '"pure_design": true, "verification_commands"')),
|
|
1475
|
+
]
|
|
1476
|
+
for message, mutate in cases:
|
|
1477
|
+
with self.subTest(message=message):
|
|
1478
|
+
self.rejected(message, mutate)
|
|
1479
|
+
|
|
1480
|
+
def test_rows_and_transitions(self):
|
|
1481
|
+
queue = (b"# Intent Queue\n\n" + f"- [x] old intent\n{row_line('a.t1', 'Same')}\n- [ ] legacy intent\n{row_line('a.t2', 'Same')}\n".encode())
|
|
1482
|
+
rows = parse_queue(queue)
|
|
1483
|
+
self.assertEqual([row["identity"] for row in rows], [None, "a.t1", None, "a.t2"])
|
|
1484
|
+
after = transition(queue, "a.t2", "F", " — failed: check (receipt 1)")
|
|
1485
|
+
self.assertEqual([r["mark"] for r in parse_queue(after)], ["x", " ", " ", "F"])
|
|
1486
|
+
self.assertEqual(after.replace(b"- [F] a.t2 [Same](docs/specs/a/t2/spec.md) \xe2\x80\x94 failed: check (receipt 1)", b""),
|
|
1487
|
+
queue.replace(row_line("a.t2", "Same").encode(), b""))
|
|
1488
|
+
for bad, message in ((lambda: transition(after, "a.t2", "x"), "no pending queue row"),
|
|
1489
|
+
(lambda: parse_queue(queue + row_line("a.t1", "Dup").encode()), "duplicate queue identity: a.t1")):
|
|
1490
|
+
with self.assertRaisesRegex(LoopError, re.escape(message)):
|
|
1491
|
+
bad()
|
|
1492
|
+
self.assertEqual(parse_queue(row_line("a.t1", "[x] tricky \\ title").encode())[0]["identity"], "a.t1")
|
|
1493
|
+
# A row that does not fully match the loop-row grammar is a legacy row; trailing whitespace is tolerated.
|
|
1494
|
+
legacy = [b"- [ ] package.json [bump lodash](https://github.com/x/y/issues/3)", b"- [ ] a.t3 [T](docs/specs/a/t9/spec.md)",
|
|
1495
|
+
b"- [x] a.t4 [T](docs/specs/a/t4/spec.md) trailing words", b"- [F] a.t5 [T](docs/specs/a/t5/spec.md)"]
|
|
1496
|
+
self.assertEqual([row["identity"] for row in parse_queue(b"\n".join(legacy))], [None] * 4)
|
|
1497
|
+
spaced = row_line("a.t1", "T").encode() + b" \t\n"
|
|
1498
|
+
self.assertEqual(parse_queue(spaced)[0]["identity"], "a.t1")
|
|
1499
|
+
self.assertEqual([(r["identity"], r["mark"]) for r in parse_queue(transition(spaced, "a.t1", "F", " — failed: x (receipt 1)"))], [("a.t1", "F")])
|
|
1500
|
+
|
|
1501
|
+
def test_add_captures_the_package_and_materializes_legacy_rows(self):
|
|
1502
|
+
"""Q, C. Prediction: add makes no commit and leaves the legacy queue, the index and every tracked file as they were;
|
|
1503
|
+
it points refs/devlyn/captures/inv at a commit holding exactly the package and docs/specs/inv/queue.md, its rows one
|
|
1504
|
+
blank line apart, records the add and removes the untracked originals; a retry reports that capture, also after the
|
|
1505
|
+
originals are gone; --materialize records the replaced legacy row's exact text and its occurrence among equal rows,
|
|
1506
|
+
line endings aside, in the loop's queue file, and status shows the loop's rows in that row's place; a different
|
|
1507
|
+
package under a used loop id is refused, also once committed. Before: add appended the rows to docs/specs/queue.md
|
|
1508
|
+
and committed them with the package."""
|
|
1509
|
+
package = {path.relative_to(self.anchor).as_posix(): path.read_bytes() for path in (self.anchor / "docs/specs/inv").rglob("*") if path.is_file()}
|
|
1510
|
+
legacy = b"# Intent Queue\n\n- [ ] Make the report weekly\r\n- [x] done\n- [ ] Make the report weekly\n"
|
|
1511
|
+
(self.anchor / QUEUE).write_bytes(legacy)
|
|
1512
|
+
git(self.anchor, "add", QUEUE, env=self.env)
|
|
1513
|
+
git(self.anchor, "commit", "-qm", "legacy queue", env=self.env)
|
|
1514
|
+
head, common, ref = git(self.anchor, "rev-parse", "HEAD"), Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir")), "refs/devlyn/captures/inv"
|
|
1515
|
+
added = self.cli("add", self.meta)
|
|
1516
|
+
rows = f"{row_line('inv.t1', 'Interface 인터페이스')}\n\n{row_line('inv.t2', 'Consumer [app]')}\n".encode()
|
|
1517
|
+
self.assertEqual((added["tasks"], added["capture"], added["commit"]), (["inv.t1", "inv.t2"], ref, git(self.anchor, "rev-parse", ref)))
|
|
1518
|
+
self.assertIn(f"git show {ref}:docs/specs/inv/meta.md", added["message"])
|
|
1519
|
+
self.assertEqual({rel: show(self.anchor, ref, rel) for rel in git(self.anchor, "ls-tree", "-r", "--name-only", ref).splitlines()},
|
|
1520
|
+
package | {"docs/specs/inv/queue.md": rows})
|
|
1521
|
+
self.assertEqual((git(self.anchor, "rev-parse", "HEAD"), git(self.anchor, "status", "--porcelain", "--untracked-files=all"),
|
|
1522
|
+
(self.anchor / QUEUE).read_bytes(), (self.anchor / "docs/specs/inv").exists()), (head, "", legacy, False))
|
|
1523
|
+
self.assertEqual(read_json(added_path(common, "inv")), {
|
|
1524
|
+
"schema_version": 1, "loop_id": "inv", "delivery": "local-only", "capture": ref, "commit": added["commit"],
|
|
1525
|
+
"tree": git(self.anchor, "rev-parse", ref + "^{tree}"), "origin": str(self.anchor), "branch": "main", "sequence": 1})
|
|
1526
|
+
self.assertEqual(self.cli("add", self.meta)["commit"], added["commit"]) # a retry, the originals gone
|
|
1527
|
+
self.assertEqual(transition(rows, "inv.t1", "x"), rows.replace(b"- [ ] inv.t1", b"- [x] inv.t1"))
|
|
1528
|
+
second = write_package(self.anchor, "rep", [("t1", [], "Weekly", self.tasks[0][3])], intent="Wrong intent.")
|
|
1529
|
+
self.assertIn("reproduce the legacy row's intent", self.cli("add", second, "--materialize", 5, code=1)["reason"])
|
|
1530
|
+
write_package(self.anchor, "rep", [("t1", [], "Weekly", self.tasks[0][3])], intent="User asked: make the report weekly.")
|
|
1531
|
+
self.assertIn("not a pending legacy row", self.cli("add", second, "--materialize", 4, code=1)["reason"])
|
|
1532
|
+
self.assertIn("reproduce", self.cli("add", second, "--materialize", 5, code=1)["reason"])
|
|
1533
|
+
write_package(self.anchor, "rep", [("t1", [], "Weekly", self.tasks[0][3])], intent="User asked: Make the report weekly.")
|
|
1534
|
+
self.cli("add", second, "--materialize", 5)
|
|
1535
|
+
self.assertEqual(show(self.anchor, "refs/devlyn/captures/rep", "docs/specs/rep/queue.md"),
|
|
1536
|
+
b"<!-- replaces docs/specs/queue.md row (occurrence 2): - [ ] Make the report weekly -->\n\n" + row_line("rep.t1", "Weekly").encode() + b"\n")
|
|
1537
|
+
status = self.cli("status")
|
|
1538
|
+
self.assertEqual((status["next"], (self.anchor / QUEUE).read_bytes()), ("rep.t1", legacy))
|
|
1539
|
+
self.assertEqual([blocker for blocker in status["blockers"] if blocker.startswith("legacy")], ["legacy row 3 needs planning: Make the report weekly"])
|
|
1540
|
+
write_package(self.anchor, "inv", [("t9", [], "Other", self.tasks[0][3])])
|
|
1541
|
+
for commit in (False, True): # Revised work under a used loop id is refused, also once the revision is committed.
|
|
1542
|
+
if commit:
|
|
1543
|
+
git(self.anchor, "add", "docs/specs/inv", env=self.env)
|
|
1544
|
+
git(self.anchor, "commit", "-qm", "revise inv", env=self.env)
|
|
1545
|
+
self.assertIn("loop id inv was already added with another package (docs/specs/inv/meta.md", self.cli("add", self.meta, code=1)["reason"])
|
|
1546
|
+
|
|
1547
|
+
def test_add_refuses_without_writing(self):
|
|
1548
|
+
"""C. Each refusal leaves HEAD, the index, the files, refs and records as they were: a revised package under a used
|
|
1549
|
+
loop id, a detached HEAD, package files committed with other content, a loop id a committed queue file already uses,
|
|
1550
|
+
and package changes in the index, which add asks to unstage rather than losing them (Prediction for the last;
|
|
1551
|
+
before: add committed the index's draft beside the checkout's text)."""
|
|
1552
|
+
self.cli("add", self.meta)
|
|
1553
|
+
common = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
|
1554
|
+
|
|
1555
|
+
def g(*args):
|
|
1556
|
+
subprocess.run(["git", "-C", str(self.anchor), *args], check=True, env=self.env, capture_output=True)
|
|
1557
|
+
|
|
1558
|
+
def edit_committed():
|
|
1559
|
+
g("add", "docs/specs/trk")
|
|
1560
|
+
g("commit", "-qm", "commit the package by hand")
|
|
1561
|
+
spec = self.anchor / "docs/specs/trk/t1/spec.md"
|
|
1562
|
+
spec.write_text(spec.read_text(encoding="utf-8").replace(" works.", " works for every caller."), encoding="utf-8")
|
|
1563
|
+
|
|
1564
|
+
def stage_draft():
|
|
1565
|
+
spec = self.anchor / "docs/specs/stg/t1/spec.md"
|
|
1566
|
+
final = spec.read_bytes()
|
|
1567
|
+
spec.write_bytes(final.replace(b"Fixture.", b"Draft."))
|
|
1568
|
+
g("add", "docs/specs/stg/t1/spec.md")
|
|
1569
|
+
spec.write_bytes(final)
|
|
1570
|
+
|
|
1571
|
+
def commit_queue():
|
|
1572
|
+
(self.anchor / "docs/specs/usd/queue.md").write_text(row_line("usd.t1", "Interface 인터페이스") + "\n", encoding="utf-8")
|
|
1573
|
+
g("add", "docs/specs/usd/queue.md")
|
|
1574
|
+
g("commit", "-qm", "a loop brought in from elsewhere")
|
|
1575
|
+
|
|
1576
|
+
def state():
|
|
1577
|
+
return (git(self.anchor, "rev-parse", "HEAD"), git(self.anchor, "ls-files", "-s"), git(self.anchor, "for-each-ref"),
|
|
1578
|
+
git(self.anchor, "status", "--porcelain", "--untracked-files=all"), sorted(path.name for path in (common / "devlyn-loops").iterdir()))
|
|
1579
|
+
for loop, tasks, prepare, message in (
|
|
1580
|
+
("inv", [("t9", [], "Other", self.tasks[0][3])], lambda: None, "loop id inv was already added with another package"),
|
|
1581
|
+
("dt", self.tasks[:1], lambda: g("checkout", "-q", "--detach"), "HEAD is detached"),
|
|
1582
|
+
("trk", self.tasks[:1], edit_committed, "docs/specs/trk is committed with different content (docs/specs/trk/t1/spec.md)"),
|
|
1583
|
+
("usd", self.tasks[:1], commit_queue, "loop id usd is already used by docs/specs/usd/queue.md"),
|
|
1584
|
+
("stg", self.tasks[:1], stage_draft, "the index holds package changes HEAD lacks (docs/specs/stg/t1/spec.md); add leaves "
|
|
1585
|
+
"the index as it is, so unstage them with `git restore --staged -- docs/specs/stg` and add again")):
|
|
1586
|
+
with self.subTest(loop=loop):
|
|
1587
|
+
meta = write_package(self.anchor, loop, tasks)
|
|
1588
|
+
prepare()
|
|
1589
|
+
before = state()
|
|
1590
|
+
self.assertIn(message, self.cli("add", meta, code=1)["reason"])
|
|
1591
|
+
self.assertEqual(state(), before)
|
|
1592
|
+
g("checkout", "-q", "main")
|
|
1593
|
+
self.assertIn("Draft.", git(self.anchor, "show", ":docs/specs/stg/t1/spec.md"))
|
|
1594
|
+
|
|
1595
|
+
def test_cleanup_reports_what_it_cannot_remove(self):
|
|
1596
|
+
"""C. Prediction: an original that cannot be unlinked stays and is reported as incomplete cleanup; the next cleanup
|
|
1597
|
+
removes it. Before: add committed the package and removed nothing."""
|
|
1598
|
+
from unittest import mock
|
|
1599
|
+
saved = {path: path.read_bytes() for path in (self.anchor / "docs/specs/inv").rglob("*") if path.is_file()}
|
|
1600
|
+
self.cli("add", self.meta)
|
|
1601
|
+
for path, data in saved.items(): # The originals as a crash before cleanup leaves them.
|
|
1602
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1603
|
+
path.write_bytes(data)
|
|
1604
|
+
common = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
|
1605
|
+
with mock.patch.object(Path, "unlink", side_effect=PermissionError("unlink denied")):
|
|
1606
|
+
notes = clean(common, records(common))
|
|
1607
|
+
self.assertEqual((sorted(notes), all(path.exists() for path in saved)), (sorted(f"{path}: incomplete cleanup: unlink denied" for path in saved), True))
|
|
1608
|
+
self.assertEqual((clean(common, records(common)), any(path.exists() for path in saved)), ([], False))
|
|
1609
|
+
|
|
1610
|
+
def test_a_legacy_row_is_replaced_only_by_its_one_exact_claim(self):
|
|
1611
|
+
"""Q. Prediction: a loop's queue file hides the legacy row whose exact text and occurrence it records, line endings
|
|
1612
|
+
aside, showing the loop's rows in its place; a claim on a missing occurrence replaces nothing, so no other row
|
|
1613
|
+
disappears; a row two loops claim, as two clones each materializing it leave it, is hidden with neither loop in its
|
|
1614
|
+
place, so materializing it once more is refused; loops without a record follow in loop-id order, two-b after two.
|
|
1615
|
+
Before: the first row equal to the recorded text was replaced, whatever its occurrence; two-b came first, its queue
|
|
1616
|
+
file's path sorting before two's since '-' sorts before '/'; the row two loops claim stayed pending, and every
|
|
1617
|
+
further --materialize of it was added."""
|
|
1618
|
+
(self.anchor / QUEUE).write_bytes(b"# Intent Queue\n\n- [ ] alpha\r\n- [ ] alpha\n- [ ] beta\n")
|
|
1619
|
+
for loop, occurrence, line in (("one", 2, "- [ ] alpha"), ("two", 1, "- [ ] beta"), ("two-b", 1, "- [ ] beta"), ("four", 3, "- [ ] alpha")):
|
|
1620
|
+
(self.anchor / "docs/specs" / loop).mkdir(parents=True)
|
|
1621
|
+
(self.anchor / loop_queue(loop)).write_text(f"<!-- replaces {QUEUE} row (occurrence {occurrence}): {line} -->\n\n{row_line(f'{loop}.t1', 'T')}\n",
|
|
1622
|
+
encoding="utf-8")
|
|
1623
|
+
git(self.anchor, "add", "docs/specs", env=self.env)
|
|
1624
|
+
self.assertEqual([(row["identity"], row["index"]) for row in queue_view(self.anchor, {})],
|
|
1625
|
+
[(None, 2), ("one.t1", 2), ("four.t1", 2), ("two.t1", 2), ("two-b.t1", 2)])
|
|
1626
|
+
again = write_package(self.anchor, "rep", self.tasks[:1], intent="User asked: beta.")
|
|
1627
|
+
self.assertIn(f"{QUEUE} line 5 is not a pending legacy row", self.cli("add", again, "--materialize", 5, code=1)["reason"])
|
|
1628
|
+
|
|
1629
|
+
def test_handwritten_marks_never_satisfy_dependencies(self):
|
|
1630
|
+
"""A loop without a record here, as in a fresh clone, is read from its tracked queue file, after the recorded loops
|
|
1631
|
+
and in loop-id order; a hand-written [x] there is no receipt-bound acceptance, so its dependent waits and whole-loop
|
|
1632
|
+
acceptance names the gap."""
|
|
1633
|
+
self.cli("add", self.meta)
|
|
1634
|
+
for loop in ("hw", "gw"):
|
|
1635
|
+
write_package(self.anchor, loop, self.tasks)
|
|
1636
|
+
rows = "\n\n".join(row_line(f"{loop}.{task}", title) for task, _, title, _ in self.tasks)
|
|
1637
|
+
(self.anchor / loop_queue(loop)).write_text(rows.replace(f"- [ ] {loop}.t1", f"- [x] {loop}.t1") + "\n", encoding="utf-8")
|
|
1638
|
+
git(self.anchor, "add", "docs/specs", env=self.env)
|
|
1639
|
+
git(self.anchor, "commit", "-qm", "loops brought in", env=self.env)
|
|
1640
|
+
status = self.cli("status")
|
|
1641
|
+
self.assertEqual((status["next"], status["blockers"]), ("inv.t1", [
|
|
1642
|
+
"inv.t2: waiting for inv.t1 (pending)", "gw.t2: prerequisite gw.t1 has no receipt-bound accepted source",
|
|
1643
|
+
"hw.t2: prerequisite hw.t1 has no receipt-bound accepted source"]))
|
|
1644
|
+
self.cli("drain", "--", sys.executable, "-c", "pass", "{packet}")
|
|
1645
|
+
report = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir")) / "devlyn-loops/hw/drain-report.md"
|
|
1646
|
+
self.assertIn("- Whole-loop acceptance: INCOMPLETE — hw.t1 accepted without a receipt, hw.t2 pending", report.read_text(encoding="utf-8"))
|
|
1647
|
+
|
|
1648
|
+
def test_common_gitdir_locks_span_worktrees(self):
|
|
1649
|
+
linked = self.root / "linked tree"
|
|
1650
|
+
subprocess.run(["git", "-C", str(self.anchor), "worktree", "add", "-q", "-b", "side", str(linked)], check=True, env=self.env)
|
|
1651
|
+
write_package(linked, "inv", self.tasks)
|
|
1652
|
+
common = Path(git(self.anchor, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
|
1653
|
+
holder = [sys.executable, "-c", "import pathlib, runpy, sys; lock = runpy.run_path(sys.argv[1])['file_lock'];\n"
|
|
1654
|
+
"with lock(pathlib.Path(sys.argv[2]), blocking=True):\n print('held', flush=True); sys.stdin.read()",
|
|
1655
|
+
str(SKILLS / "_shared/platform-support.py")]
|
|
1656
|
+
(common / "devlyn-loops").mkdir(exist_ok=True)
|
|
1657
|
+
for name, args, check in (("queue.lock", ["add", linked / "docs/specs/inv/meta.md"], "waits"),
|
|
1658
|
+
("drain.lock", ["drain", "--", "executor", "{packet}"], "refuses")):
|
|
1659
|
+
with subprocess.Popen(holder + [str(common / "devlyn-loops" / name)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) as held:
|
|
1660
|
+
self.assertEqual(held.stdout.readline().strip(), "held")
|
|
1661
|
+
with subprocess.Popen([sys.executable, str(Path(__file__).resolve()), *map(str, args)], cwd=linked, env=self.env,
|
|
1662
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") as waiter:
|
|
1663
|
+
if check == "waits":
|
|
1664
|
+
with self.assertRaises(subprocess.TimeoutExpired):
|
|
1665
|
+
waiter.wait(timeout=1.5)
|
|
1666
|
+
self.assertFalse(added_path(common, "inv").exists())
|
|
1667
|
+
held.stdin.close()
|
|
1668
|
+
self.assertEqual(waiter.wait(timeout=30), 0, waiter.stderr.read())
|
|
1669
|
+
else:
|
|
1670
|
+
out, _ = waiter.communicate(timeout=30)
|
|
1671
|
+
self.assertEqual(waiter.returncode, 1)
|
|
1672
|
+
self.assertIn("another drain is active", json.loads(out)["reason"])
|
|
1673
|
+
held.stdin.close()
|
|
1674
|
+
self.assertEqual((read_json(added_path(common, "inv"))["origin"], read_json(added_path(common, "inv"))["branch"]), (str(linked), "side"))
|
|
1675
|
+
|
|
1676
|
+
def test_a_crash_on_either_side_of_the_spawn_never_starts_a_second_executor(self):
|
|
1677
|
+
"""The attempt's start is durable before the spawn, so a resumed drain observes writers first: it waits while
|
|
1678
|
+
an executor lives and adopts its submission, runs the executor once when none did, and fails the task where
|
|
1679
|
+
writers cannot be observed. Prediction (H2): that one run exits 0 without writing a submission, so the task waits,
|
|
1680
|
+
naming the exit code and executor.stderr, and no submission exists. Before: the drain wrote a stand-in submission
|
|
1681
|
+
with a blocked-infrastructure blocker, which made the task [F]."""
|
|
1682
|
+
from unittest import mock
|
|
1683
|
+
helper = shared("task-complete")
|
|
1684
|
+
work, records = self.root / "work", self.root / "records"
|
|
1685
|
+
work.mkdir()
|
|
1686
|
+
records.mkdir()
|
|
1687
|
+
packet = {"worktree": str(work), "submission": str(records / "submission.json"), "inputs_sha": "0" * 40,
|
|
1688
|
+
"evidence_dir": str(work / ".devlyn/loop")}
|
|
1689
|
+
spawns, checks = [], []
|
|
1690
|
+
|
|
1691
|
+
class Crash(BaseException):
|
|
1692
|
+
"""The controller dies here."""
|
|
1693
|
+
|
|
1694
|
+
class Child:
|
|
1695
|
+
pid = 4242
|
|
1696
|
+
|
|
1697
|
+
def wait(self):
|
|
1698
|
+
return 0
|
|
1699
|
+
|
|
1700
|
+
def spawn(argv, **kwargs):
|
|
1701
|
+
spawns.append(argv)
|
|
1702
|
+
return Child()
|
|
1703
|
+
|
|
1704
|
+
def crash_after_spawn(argv, **kwargs):
|
|
1705
|
+
spawns.append(argv)
|
|
1706
|
+
raise Crash
|
|
1707
|
+
|
|
1708
|
+
def crash_before_spawn(argv, **kwargs):
|
|
1709
|
+
raise Crash
|
|
1710
|
+
|
|
1711
|
+
def drain(popen, observe=lambda count: None):
|
|
1712
|
+
def stopped_writers(path):
|
|
1713
|
+
checks.append(path)
|
|
1714
|
+
observe(len(checks))
|
|
1715
|
+
with mock.patch.dict(helper, {"stopped_writers": stopped_writers}), mock.patch("subprocess.Popen", popen), \
|
|
1716
|
+
mock.patch("time.sleep"):
|
|
1717
|
+
try:
|
|
1718
|
+
return ensure_submission("l.t", records / "packet.json", packet, ["executor", "{packet}"])
|
|
1719
|
+
except Waiting as exc:
|
|
1720
|
+
return str(exc)
|
|
1721
|
+
|
|
1722
|
+
def survivor(count):
|
|
1723
|
+
if count < 3:
|
|
1724
|
+
raise helper["WriterActive"]("active process 4242 uses task files")
|
|
1725
|
+
Path(packet["submission"]).write_text('{"by": "executor"}', encoding="utf-8")
|
|
1726
|
+
|
|
1727
|
+
def unobservable(count):
|
|
1728
|
+
raise helper["WritersUnobservable"]("writer observation unsupported on this platform; retain workspace")
|
|
1729
|
+
|
|
1730
|
+
stderr = Path(packet["evidence_dir"]) / "executor.stderr"
|
|
1731
|
+
for crash, observe, outcome in ((crash_after_spawn, survivor, (1, 3, None)),
|
|
1732
|
+
(crash_before_spawn, lambda count: None,
|
|
1733
|
+
(1, 1, f"executor exited 0 without a submission (see {stderr}); the next drain runs it again")),
|
|
1734
|
+
(crash_before_spawn, unobservable, (0, 1, "interrupted-unobservable"))):
|
|
1735
|
+
with self.subTest(crash=crash.__name__, observe=getattr(observe, "__name__", "nothing")):
|
|
1736
|
+
spawns.clear()
|
|
1737
|
+
checks.clear()
|
|
1738
|
+
for path in records.iterdir():
|
|
1739
|
+
path.unlink()
|
|
1740
|
+
with self.assertRaises(Crash):
|
|
1741
|
+
drain(crash)
|
|
1742
|
+
failure = drain(spawn, observe)
|
|
1743
|
+
self.assertEqual((len(spawns), len(checks), failure and failure.partition(": ")[0]), outcome)
|
|
1744
|
+
self.assertEqual(Path(packet["submission"]).exists(), crash is crash_after_spawn)
|
|
1745
|
+
if crash is crash_after_spawn:
|
|
1746
|
+
self.assertEqual(read_json(Path(packet["submission"])), {"by": "executor"})
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def main():
|
|
1750
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1751
|
+
parser.add_argument("--self-test", action="store_true")
|
|
1752
|
+
actions = parser.add_subparsers(dest="action")
|
|
1753
|
+
for name in ("check", "add"):
|
|
1754
|
+
actions.add_parser(name).add_argument("package", help="<repo>/docs/specs/<loop-id>/meta.md")
|
|
1755
|
+
actions.choices["add"].add_argument("--materialize", type=int, metavar="LINE", help="replace this pending legacy row")
|
|
1756
|
+
for name in ("status", "drain"):
|
|
1757
|
+
action = actions.add_parser(name)
|
|
1758
|
+
action.add_argument("--repo", default=".")
|
|
1759
|
+
action.add_argument("--local-only", "--no-push", action="store_true")
|
|
1760
|
+
actions.choices["drain"].add_argument("--worktree-root")
|
|
1761
|
+
actions.choices["drain"].add_argument("executor", nargs="+", help="host executor argv after --; {packet} becomes the packet path")
|
|
1762
|
+
args = parser.parse_args()
|
|
1763
|
+
if args.self_test:
|
|
1764
|
+
result = unittest.TextTestRunner(verbosity=1).run(unittest.defaultTestLoader.loadTestsFromTestCase(QueueTests))
|
|
1765
|
+
if result.wasSuccessful():
|
|
1766
|
+
print(f"queue self-test: PASS ({result.testsRun} tests)")
|
|
1767
|
+
return 0 if result.wasSuccessful() else 1
|
|
1768
|
+
if args.action is None:
|
|
1769
|
+
parser.error("check, add, status or drain is required")
|
|
1770
|
+
try:
|
|
1771
|
+
result = {"check": check, "add": add, "status": status, "drain": drain}[args.action](args)
|
|
1772
|
+
except (LoopError, OSError) as exc:
|
|
1773
|
+
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
1774
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
1775
|
+
return 1 if result["status"] == "BLOCKED" else 0
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
if __name__ == "__main__":
|
|
1779
|
+
shared("platform-support")["configure_utf8"]()
|
|
1780
|
+
sys.exit(main())
|