@jesdi/skills 0.0.30 → 0.0.31
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/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +214 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +505 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +105 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +10 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +38 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +220 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +582 -0
- package/skills/implement-spec/tests/test_walk.py +457 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""In-process driver tests: the same seam as test_cli.py without the launcher, so coverage sees it."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import jsonschema
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import driver
|
|
14
|
+
import driver_agents
|
|
15
|
+
import driver_report
|
|
16
|
+
|
|
17
|
+
from conftest import GIT, ticket
|
|
18
|
+
|
|
19
|
+
ARGS = ["--gate", "make check", "--dry-run"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(capsys, *args):
|
|
23
|
+
code = driver.main([*ARGS, *args])
|
|
24
|
+
return code, capsys.readouterr().out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_valid_set_prints_the_widest_level(repo, capsys):
|
|
28
|
+
repo(t01=ticket("01"), t02=ticket("02", "01"), t03=ticket("03"), t04=ticket("04", "Ticket 02, 3"))
|
|
29
|
+
assert run(capsys) == (0, "max frontier width: 2\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_a_run_without_dry_run_needs_the_spec(repo, capsys):
|
|
33
|
+
repo(t01=ticket("01"))
|
|
34
|
+
assert driver.main(["--gate", "make check"]) == 3
|
|
35
|
+
assert capsys.readouterr().out == "--spec is required: the spec file the prompts point at\n"
|
|
36
|
+
assert "ticket/01" not in subprocess.run(["git", "branch"], capture_output=True, text=True).stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_every_environment_and_ticket_error_is_listed_together(repo, capsys):
|
|
40
|
+
root = repo(t01=ticket("01", "02"), t02=ticket("02", "01"), t03=ticket("03", "09, Nope", touches=""))
|
|
41
|
+
(root / ".gitignore").write_text("")
|
|
42
|
+
assert driver.main(["--dry-run"]) == 3
|
|
43
|
+
out = capsys.readouterr().out.splitlines()
|
|
44
|
+
assert out[0].startswith("--gate")
|
|
45
|
+
assert ".agent is not gitignored" in out
|
|
46
|
+
assert "uncommitted change: .gitignore" in out
|
|
47
|
+
assert "03-t.md: missing Touches (expected after Seam, line 5)" in out
|
|
48
|
+
assert "03-t.md: Blocked by 09 names no ticket (line 3)" in out
|
|
49
|
+
assert "03-t.md: Blocked by 'Nope' names no ticket (line 3)" in out
|
|
50
|
+
assert "01-t.md: Blocked by cycle: 01-t.md -> 02-t.md -> 01-t.md" in out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_ticket_with_no_fields_lists_each_missing_one(repo, capsys):
|
|
54
|
+
repo(t01="nothing here\n")
|
|
55
|
+
code, out = run(capsys)
|
|
56
|
+
assert code == 3
|
|
57
|
+
assert out.splitlines() == ["01-t.md: missing title", "01-t.md: missing Blocked by", "01-t.md: missing Seam",
|
|
58
|
+
"01-t.md: missing criterion"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_a_directory_without_tickets_is_an_error(repo, capsys):
|
|
62
|
+
assert run(capsys, "--tickets", "nowhere") == (3, "nowhere: no ticket files (NN-name.md)\n")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_outside_a_git_worktree_git_errors_are_reported(tmp_path, monkeypatch, capsys):
|
|
66
|
+
monkeypatch.chdir(tmp_path)
|
|
67
|
+
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent))
|
|
68
|
+
code, out = run(capsys)
|
|
69
|
+
assert code == 3 and "git status failed" in out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_models_file_checks_the_first_entry_of_each_role(repo, capsys, tmp_path, monkeypatch):
|
|
73
|
+
repo(t01=ticket("01"))
|
|
74
|
+
monkeypatch.setattr(driver_agents.shutil, "which", lambda cli: None if cli == "codex" else cli)
|
|
75
|
+
models = tmp_path.parent / f"{tmp_path.name}-models.json"
|
|
76
|
+
# The default reviewer and final-reviewer start on codex, so this file gives both a claude entry.
|
|
77
|
+
claude = {"reviewer": ["claude-opus-5@medium"], "final-reviewer": ["claude-opus-5@medium"]}
|
|
78
|
+
models.write_text(json.dumps({"implementer": ["openai/gpt-sol@medium"], **claude,
|
|
79
|
+
"correctness": ["openai/gpt-sol@medium"], "fixer": ["other/x@low"]}))
|
|
80
|
+
assert run(capsys, "--models", str(models)) == (
|
|
81
|
+
3, "implementer: codex is not on PATH (model entry openai/gpt-sol@medium)\n"
|
|
82
|
+
"fixer: model entry other/x@low names an unknown provider (known: anthropic, openai)\n"
|
|
83
|
+
"correctness: no entry of its list (openai/gpt-sol@medium) is on another provider than the implementer's "
|
|
84
|
+
"first entry openai/gpt-sol@medium; the correctness pass never runs on the implementer's provider\n")
|
|
85
|
+
# Requirement 2 names the first entry only: a later implementer entry whose CLI is missing is no error. For the
|
|
86
|
+
# correctness role it names the entry requirement 13 selects, which here is the second of its list.
|
|
87
|
+
models.write_text(json.dumps({"implementer": ["opus@medium", "openai/gpt-sol@medium"], **claude,
|
|
88
|
+
"correctness": ["opus@high", "openai/gpt-sol@low", "openai/gpt-astra@low"]}))
|
|
89
|
+
assert run(capsys, "--models", str(models)) == (
|
|
90
|
+
3, "correctness: codex is not on PATH (model entry openai/gpt-sol@low)\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("entry", ["claude-opus-5@turbo", "openai/gpt-sol", "openai/@low"])
|
|
94
|
+
def test_an_entry_needs_a_model_and_an_effort_of_its_provider(repo, capsys, tmp_path, entry):
|
|
95
|
+
repo(t01=ticket("01"))
|
|
96
|
+
models = tmp_path.parent / f"{tmp_path.name}-models.json"
|
|
97
|
+
models.write_text(json.dumps({"reviewer": ["claude-opus-5@low", entry]}))
|
|
98
|
+
code, out = run(capsys, "--models", str(models))
|
|
99
|
+
assert code == 3 and out.startswith(f"reviewer: model entry {entry} needs a model and an effort from ")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("content", ["{", "[]", '{"implementer": []}'])
|
|
103
|
+
def test_unreadable_models_file_is_an_error(repo, capsys, tmp_path, content):
|
|
104
|
+
repo(t01=ticket("01"))
|
|
105
|
+
models = tmp_path.parent / f"{tmp_path.name}-models.json"
|
|
106
|
+
models.write_text(content)
|
|
107
|
+
code, out = run(capsys, "--models", str(models))
|
|
108
|
+
assert code == 3 and out.startswith(f"--models {models}: ")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unknown_flag_exits_3_on_stdout(repo, capsys):
|
|
112
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
113
|
+
driver.main(["--nope"])
|
|
114
|
+
assert exit_info.value.code == 3
|
|
115
|
+
assert "implement-spec: unrecognized arguments: --nope" in capsys.readouterr().out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# In-process agent runs: a fake runner script, as in test_agents.py.
|
|
119
|
+
|
|
120
|
+
REPORT = """printf '{"status": "%s", "commits": ["%s"], "files": [], "concerns": [], %s}' \\
|
|
121
|
+
"${STATUS:-DONE}" "$(git rev-parse HEAD)" "$fields" > "$report"
|
|
122
|
+
"""
|
|
123
|
+
COMMIT = "echo $role >> work.txt; git add -A; git commit -q -m $role\n"
|
|
124
|
+
WRITER = """fields='"locked": ["work.txt"], "red": [{"test": "t", "line": "FAILED t"}]'; echo FAILED t\n"""
|
|
125
|
+
IMPLEMENTER = """fields='"green": ["1 passed"]'; echo '{"total_cost_usd": 0.25}'\n"""
|
|
126
|
+
# The reviewer of a plain run: it finds nothing and proves the locked file of WRITER.
|
|
127
|
+
CLEAN = """case $role in reviewer|correctness|final-reviewer) echo FAILED t
|
|
128
|
+
printf '{"status": "DONE", "commits": [], "files": [], "concerns": [], "findings": [], "mutations": [{"test": "work.txt::t", "file": "work.txt", "line": 1, "red": "FAILED t"}]}' > "$report"; exit 0;; esac
|
|
129
|
+
"""
|
|
130
|
+
ONCE = '[ -e "$0.again" ] || { touch "$0.again"; %s; }\n' # the first attempt only
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def agents(tmp_path, capsys, body, *args, seam="none — refactor"):
|
|
134
|
+
"""Run the driver on one ticket with a runner made of `body`; returns the exit code and the ledger lines."""
|
|
135
|
+
(tmp_path / ".agent/tickets").mkdir(parents=True, exist_ok=True)
|
|
136
|
+
(tmp_path / ".agent/tickets/01-t.md").write_text(ticket("01", seam=seam))
|
|
137
|
+
runner = tmp_path.parent / f"{tmp_path.name}-runner"
|
|
138
|
+
runner.write_text(f"#!/bin/sh\nrole=$1 prompt=$2 report=$3\n{'' if '--only' in args else CLEAN}{body}")
|
|
139
|
+
runner.chmod(0o755)
|
|
140
|
+
code = driver.main(["--gate", "true", "--spec", "spec.md", "--runner", str(runner), *args])
|
|
141
|
+
capsys.readouterr()
|
|
142
|
+
ledger = tmp_path / ".agent/implement/ledger.md"
|
|
143
|
+
return code, ledger_of(tmp_path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# The end of run follows the last merge and has its own tests (test_tidy.py): its sentences start with one of these.
|
|
147
|
+
END = ("End-of-run work skipped", "End of run:", "Recovery point:", "Tidy:", "Final review ")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ledger_of(tmp_path):
|
|
151
|
+
"""The ledger sentences of the tickets."""
|
|
152
|
+
ledger = tmp_path / ".agent/implement/ledger.md"
|
|
153
|
+
return [line for line in ledger.read_text().splitlines() if not line.startswith(END)] if ledger.exists() else []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def walked(*short):
|
|
157
|
+
"""The PR tip as the tickets left it: the recovery point of the tidy when there is one, else the tip of feat/x."""
|
|
158
|
+
made = subprocess.run(["git", "rev-parse", *short, "-q", "--verify", "refs/implement/recovery"], capture_output=True, text=True)
|
|
159
|
+
return made.stdout.strip() or subprocess.run(["git", "rev-parse", *short, "feat/x"], capture_output=True, text=True).stdout.strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def branches():
|
|
163
|
+
return subprocess.run(["git", "branch", "--format=%(refname:short)"], capture_output=True, text=True).stdout.split()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_accepted_reports_end_the_run_with_exit_0(repo, tmp_path, capsys):
|
|
167
|
+
repo()
|
|
168
|
+
# The implementer leaves the locked work.txt as the test-writer committed it.
|
|
169
|
+
commit = COMMIT.replace("work.txt", "$file")
|
|
170
|
+
body = (f'file=code.txt; [ $role != test-writer ] || file=work.txt\n{commit}case $role in test-writer) {WRITER};; '
|
|
171
|
+
f'*) {IMPLEMENTER}grep -q "Locked files: work.txt" $prompt || exit 9;; esac\n{REPORT}')
|
|
172
|
+
code, ledger = agents(tmp_path, capsys, body, seam="`f()`")
|
|
173
|
+
assert code == 0
|
|
174
|
+
tip = walked("--short=7")
|
|
175
|
+
assert [line.partition(" for ")[0] for line in ledger] == [
|
|
176
|
+
"Ticket 01: test-writer ran on claude-sonnet-5@medium", "Ticket 01: implementer ran on claude-sonnet-5@medium",
|
|
177
|
+
f"Ticket 01: the gate is green on {tip}, record .agent/implement/01/gate-{tip}.log.",
|
|
178
|
+
"Ticket 01: reviewer ran on openai/gpt-astra@medium",
|
|
179
|
+
f"Ticket 01: merged into feat/x as a fast-forward at {tip}."]
|
|
180
|
+
assert ledger[1].endswith(", cost 0.25 USD: report accepted.")
|
|
181
|
+
assert branches() == ["feat/x"] and "nothing to commit" in subprocess.run(
|
|
182
|
+
["git", "status"], capture_output=True, text=True).stdout
|
|
183
|
+
assert (tmp_path / ".agent/implement/01/implementer-0.log").read_text() == '{"total_cost_usd": 0.25}\n'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_a_violation_reruns_once_with_the_error_then_blocks(repo, tmp_path, capsys):
|
|
187
|
+
repo()
|
|
188
|
+
code, ledger = agents(tmp_path, capsys, f"STATUS=FINISHED\n{COMMIT}{IMPLEMENTER}{REPORT}")
|
|
189
|
+
assert code == 1 and len(ledger) == 3
|
|
190
|
+
assert ledger[2].startswith("Ticket 01: BLOCKED at the implementer: status: 'FINISHED' is not one of")
|
|
191
|
+
assert "The driver rejected the earlier attempt: status: 'FINISHED'" in (
|
|
192
|
+
tmp_path / ".agent/implement/01/implementer-0.prompt.md").read_text()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def prompt_of(tmp_path, role="implementer"):
|
|
196
|
+
return (tmp_path / f".agent/implement/01/{role}-0.prompt.md").read_text()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_an_agent_that_writes_no_report_is_rerun_once_then_blocks(repo, tmp_path, capsys):
|
|
200
|
+
repo()
|
|
201
|
+
code, ledger = agents(tmp_path, capsys, "exit 7\n", "--only", "reviewer")
|
|
202
|
+
assert code == 1 and len(ledger) == 3
|
|
203
|
+
assert ledger[2].startswith("Ticket 01: BLOCKED at the reviewer: report .agent/implement/01/reviewer-0.json is not")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize("extra", ['"locked": 5', '"locked": ["work.txt", "never-committed.txt"]', '"red": 5',
|
|
207
|
+
'"mutations": "x"'])
|
|
208
|
+
def test_a_field_of_another_role_is_ignored(repo, tmp_path, capsys, extra):
|
|
209
|
+
repo()
|
|
210
|
+
code, ledger = agents(tmp_path, capsys, f"{COMMIT}fields='\"green\": [], {extra}'\n{REPORT}")
|
|
211
|
+
assert code == 0 and len(ledger) == 4 and ledger[0].endswith("report accepted.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@pytest.mark.parametrize("status", ["BLOCKED", "NEEDS_CONTEXT"])
|
|
215
|
+
def test_a_report_that_says_blocked_blocks_the_ticket_without_a_rerun(repo, tmp_path, capsys, status):
|
|
216
|
+
repo()
|
|
217
|
+
body = f"""printf '{{"status": "{status}", "commits": [], "files": [], "concerns": ["test_x contradicts the spec"], "green": []}}' > "$report"\n"""
|
|
218
|
+
code, ledger = agents(tmp_path, capsys, body)
|
|
219
|
+
assert code == 1 and len(ledger) == 2
|
|
220
|
+
assert ledger[1] == f"Ticket 01: BLOCKED at the implementer: the implementer reported {status}: test_x contradicts the spec."
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_a_rerun_prompt_holds_only_template_lines_and_keeps_its_ending(repo, tmp_path, capsys):
|
|
224
|
+
repo()
|
|
225
|
+
agents(tmp_path, capsys, "exec sleep 30\n", "--timeout", "implementer=0.005")
|
|
226
|
+
prompt = prompt_of(tmp_path)
|
|
227
|
+
first = driver_agents.render("implementer", {
|
|
228
|
+
"spec": "spec.md", "ticket": ".agent/tickets/01-t.md", "seam": "none — refactor", "locked": "",
|
|
229
|
+
"start_sha": subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip(),
|
|
230
|
+
"report": ".agent/implement/01/implementer-0.json"})
|
|
231
|
+
ending = first[first.index("\nWrite one JSON report"):]
|
|
232
|
+
line = (driver_report.PROMPTS / "timeout.md").read_text().replace("{minutes}", "0.005")
|
|
233
|
+
assert "timed out" in line and "in the tree" in line
|
|
234
|
+
assert prompt == first + line + ending
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_a_moved_head_prompt_line_comes_from_its_template(repo, tmp_path, capsys):
|
|
238
|
+
repo()
|
|
239
|
+
tip = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
|
240
|
+
agents(tmp_path, capsys, "git checkout -q --detach\n")
|
|
241
|
+
line = (driver_report.PROMPTS / "moved.md").read_text().replace("{branch}", "ticket/01").replace("{tip}", tip)
|
|
242
|
+
assert line in prompt_of(tmp_path) and prompt_of(tmp_path).rstrip().endswith("one ref store.")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_a_process_the_agent_leaves_behind_is_stopped(repo, tmp_path, capsys):
|
|
246
|
+
repo()
|
|
247
|
+
code, _ = agents(tmp_path, capsys, f"( sleep 0.5; echo late > late.txt; echo LATE ) &\n{COMMIT}{IMPLEMENTER}{REPORT}")
|
|
248
|
+
assert code == 0
|
|
249
|
+
time.sleep(1)
|
|
250
|
+
assert not (tmp_path / "late.txt").exists()
|
|
251
|
+
assert "LATE" not in (tmp_path / ".agent/implement/01/implementer-0.log").read_text()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_the_process_group_of_an_agent_is_signalled_only_while_its_leader_is_not_reaped(tmp_path, monkeypatch):
|
|
255
|
+
"""Final review, correctness 4: when the leader is reaped, its id can be the group of another process."""
|
|
256
|
+
seen, real = [], os.killpg
|
|
257
|
+
|
|
258
|
+
def killpg(group, sig):
|
|
259
|
+
# WNOWAIT: look and do not reap. A leader that is reaped already is a ChildProcessError here.
|
|
260
|
+
ended = os.waitid(os.P_PID, group, os.WEXITED | os.WNOWAIT | os.WNOHANG) is not None
|
|
261
|
+
seen.append((ended, group in driver_agents.LIVE))
|
|
262
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
263
|
+
real(group, sig)
|
|
264
|
+
|
|
265
|
+
monkeypatch.setattr(driver_agents, "STOPPED", False)
|
|
266
|
+
monkeypatch.setattr(driver_agents.os, "killpg", killpg)
|
|
267
|
+
prompt, log = tmp_path / "prompt.md", tmp_path / "agent.log"
|
|
268
|
+
prompt.write_text("")
|
|
269
|
+
assert driver_agents.launch(["sh", "-c", "echo out; exit 7"], prompt, log, 30, str(tmp_path)) == 7
|
|
270
|
+
assert seen == [(True, True)] and log.read_text() == "out\n" # ended, not reaped, and stop() still knows it
|
|
271
|
+
begin = time.monotonic()
|
|
272
|
+
assert driver_agents.launch(["sh", "-c", "sleep 30"], prompt, log, 0.2, str(tmp_path)) is None
|
|
273
|
+
assert seen[1:] == [(False, True)] and 0.2 <= time.monotonic() - begin < 10
|
|
274
|
+
# After the launch the leader is reaped, so stop() has no group to signal.
|
|
275
|
+
assert not driver_agents.LIVE
|
|
276
|
+
driver_agents.stop()
|
|
277
|
+
assert len(seen) == 2 and driver_agents.STOPPED
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_concurrent_first_runs_of_the_launcher_all_succeed(tmp_path):
|
|
281
|
+
"""The loser of the race to publish the venv uses the winner's venv."""
|
|
282
|
+
run_sh = Path(driver.__file__).with_name("run.sh")
|
|
283
|
+
assert subprocess.run(["sh", str(run_sh), "--help"], capture_output=True).returncode == 0 # provision the cache
|
|
284
|
+
real = Path(os.environ.get("IMPLEMENT_SPEC_CACHE_DIR")
|
|
285
|
+
or Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "implement-spec")
|
|
286
|
+
cache = tmp_path / "cache"
|
|
287
|
+
cache.mkdir()
|
|
288
|
+
for part in ("uv", "python", "uv-cache"): # all but the venv, so each run builds it
|
|
289
|
+
(cache / part).symlink_to(real / part)
|
|
290
|
+
env = dict(os.environ, IMPLEMENT_SPEC_CACHE_DIR=str(cache))
|
|
291
|
+
procs = [subprocess.Popen(["sh", str(run_sh), "--help"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
292
|
+
text=True) for _ in range(6)]
|
|
293
|
+
assert [(p.wait(), p.stderr.read()) for p in procs] == [(0, "")] * 6
|
|
294
|
+
assert [p.name for p in (cache / "venv").iterdir()] == ["3.13.7-jsonschema-4.26.0"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_a_timeout_doubles_the_limit_and_a_second_one_blocks(repo, tmp_path, capsys):
|
|
298
|
+
repo()
|
|
299
|
+
code, ledger = agents(tmp_path, capsys, "exec sleep 30\n", "--timeout", "implementer=0.005")
|
|
300
|
+
assert code == 1
|
|
301
|
+
assert ledger[0].endswith(": timed out after 0.005 minutes.") and ledger[1].endswith(": timed out after 0.01 minutes.")
|
|
302
|
+
assert ledger[2].startswith("Ticket 01: BLOCKED at the implementer: timed out")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize("move, problem", [
|
|
306
|
+
("git checkout -q feat/x", "HEAD is on feat/x instead of ticket/01"),
|
|
307
|
+
("git checkout -q --detach", "HEAD is on a detached commit instead of ticket/01"),
|
|
308
|
+
("git checkout -q --detach; git branch -q -D ticket/01", "branch ticket/01 is missing"),
|
|
309
|
+
("git checkout -q -B ticket/01 HEAD~1", "ticket/01 no longer holds"),
|
|
310
|
+
])
|
|
311
|
+
def test_a_moved_head_resets_the_slot_and_reruns(repo, tmp_path, capsys, move, problem):
|
|
312
|
+
repo()
|
|
313
|
+
(tmp_path / "second.txt").write_text("a second commit, so the tip has a parent\n")
|
|
314
|
+
subprocess.run([*GIT, "add", "-A"], check=True)
|
|
315
|
+
subprocess.run([*GIT, "commit", "-q", "-m", "second"], check=True)
|
|
316
|
+
tip = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
|
317
|
+
code, ledger = agents(tmp_path, capsys, ONCE % f"{move}; exit 0" + COMMIT + IMPLEMENTER + REPORT)
|
|
318
|
+
assert code == 0
|
|
319
|
+
assert problem in ledger[0] and ledger[0].endswith(f"the driver reset the slot to {tip[:7]}.")
|
|
320
|
+
assert subprocess.run(["git", "rev-parse", "feat/x~1"], capture_output=True, text=True).stdout.strip() == tip
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_an_existing_ticket_branch_stops_the_run(repo, tmp_path, capsys):
|
|
324
|
+
repo()
|
|
325
|
+
subprocess.run(["git", "branch", "ticket/01"], check=True)
|
|
326
|
+
assert agents(tmp_path, capsys, "exit 0\n") == (3, [])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_a_timeout_for_an_unknown_role_is_a_usage_error(repo):
|
|
330
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
331
|
+
driver.main(["--timeout", "nobody=5"])
|
|
332
|
+
assert exit_info.value.code == 3
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_command_lines_per_provider():
|
|
336
|
+
assert driver_agents.command("implementer", "openai/gpt-sol@high", "r.json") == [
|
|
337
|
+
"codex", "exec", "--model", "gpt-sol", "-c", 'model_reasoning_effort="high"', "--sandbox", "workspace-write"]
|
|
338
|
+
assert driver_agents.command("reviewer", "openai/gpt-sol@high", "r.json")[-4:] == [
|
|
339
|
+
"--sandbox", "read-only", "-o", "r.json"]
|
|
340
|
+
assert driver_agents.command("reviewer", "opus@low", "r.json")[:6] == [
|
|
341
|
+
"claude", "-p", "--model", "opus", "--effort", "low"]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.parametrize("role", driver.ONLY)
|
|
345
|
+
def test_every_role_template_holds_a_valid_schema(role):
|
|
346
|
+
jsonschema.Draft202012Validator.check_schema(driver_report.schema(role))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# In-process walks: several tickets, slots, the gate, the merge and the resume.
|
|
350
|
+
|
|
351
|
+
WORK = """nn=$(basename "$(dirname "$report")"); fields='"green": ["1 passed"]'
|
|
352
|
+
echo "$role $nn" >> "${FILE:-work-$nn.txt}"; git add -A; git commit -q -m "$role $nn"
|
|
353
|
+
echo "$nn $PWD" >> "$0.calls"
|
|
354
|
+
"""
|
|
355
|
+
# Ticket 01 waits until ticket 02 has its branch, so both start from the same PR tip.
|
|
356
|
+
TOGETHER = """[ "$nn" != 01 ] || until git rev-parse -q --verify refs/heads/ticket/02 > /dev/null; do sleep 0.02; done\n"""
|
|
357
|
+
REFACTOR = {"seam": "none — refactor"}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def walk(tmp_path, capsys, body, *args, gate="true"):
|
|
361
|
+
"""Run the driver on the ticket set of the repository; returns the exit code, the ledger lines and stdout."""
|
|
362
|
+
runner = tmp_path.parent / f"{tmp_path.name}-runner"
|
|
363
|
+
runner.write_text(f"#!/bin/sh\nrole=$1 prompt=$2 report=$3\n{CLEAN}{body}")
|
|
364
|
+
runner.chmod(0o755)
|
|
365
|
+
code = driver.main(["--gate", gate, "--spec", "spec.md", "--runner", str(runner), *args])
|
|
366
|
+
return code, ledger_of(tmp_path), capsys.readouterr().out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def out_of(*command):
|
|
370
|
+
return subprocess.run(command, capture_output=True, text=True).stdout.strip()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def state_of(tmp_path):
|
|
374
|
+
return json.loads((tmp_path / ".agent/implement/state.json").read_text())
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def two(repo, second="None", touches="`demo/y.py: g`"):
|
|
378
|
+
return repo(t01=ticket("01", **REFACTOR), t02=ticket("02", second, touches=touches, **REFACTOR))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_two_tickets_run_in_two_slots_and_the_second_merge_is_a_gated_merge_commit(repo, tmp_path, capsys):
|
|
382
|
+
two(repo)
|
|
383
|
+
log = tmp_path.parent / f"{tmp_path.name}-prepare.log"
|
|
384
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, "--prepare", f"pwd >> {log}")
|
|
385
|
+
assert code == 0, out
|
|
386
|
+
calls = dict(line.split() for line in Path(f"{tmp_path}-runner.calls").read_text().splitlines())
|
|
387
|
+
assert calls == {"01": str(tmp_path.resolve()), "02": str((tmp_path / ".agent/worktrees/slot-2").resolve())}
|
|
388
|
+
assert len(out_of("git", "rev-list", "--parents", "-n1", walked()).split()) == 3 # a merge commit
|
|
389
|
+
# Each ticket tip and the merge commit; the fourth gate is that of the end of run, on the tidied tip.
|
|
390
|
+
assert len([line for line in ledger if "the gate is green" in line]) == 3 and "| gates run | 4 |" in out
|
|
391
|
+
assert [line for line in ledger if "merged into feat/x as a merge commit" in line]
|
|
392
|
+
assert {entry["phase"] for entry in state_of(tmp_path)["tickets"].values()} == {"merged"}
|
|
393
|
+
assert branches() == ["feat/x"] and out_of("git", "status", "--porcelain") == ""
|
|
394
|
+
assert (tmp_path / "work-01.txt").exists() and (tmp_path / "work-02.txt").exists()
|
|
395
|
+
assert out.startswith("max frontier width: 2\n" + (tmp_path / ".agent/implement/summary.md").read_text())
|
|
396
|
+
# A rerun finds every ticket merged: no agent, no gate, no second prepare.
|
|
397
|
+
code, again, _ = walk(tmp_path, capsys, "exit 9\n", "--prepare", f"pwd >> {log}")
|
|
398
|
+
assert (code, again) == (0, ledger) and len(log.read_text().splitlines()) == 1
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# The conflict agent of a merge that conflicts: it commits the open merge as it is, or it does nothing.
|
|
402
|
+
RESOLVE = """if [ $role = conflict ]; then git add -A; git commit -q -m resolved
|
|
403
|
+
printf '{"status": "DONE", "commits": ["%s"], "files": ["work.txt"], "concerns": []}' "$(git rev-parse HEAD)" > "$report"
|
|
404
|
+
exit 0; fi
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_a_merge_conflict_goes_to_the_conflict_agent_once_and_its_commit_is_gated(repo, tmp_path, capsys):
|
|
409
|
+
two(repo)
|
|
410
|
+
code, ledger, out = walk(tmp_path, capsys, RESOLVE + "FILE=work.txt\n" + WORK + TOGETHER + REPORT)
|
|
411
|
+
assert code == 0, out
|
|
412
|
+
(line,) = [line for line in ledger if "the conflict agent resolved the merge conflict in work.txt at " in line]
|
|
413
|
+
number, tip = line[7:9], walked()
|
|
414
|
+
assert line.endswith(f" at {tip[:7]}.") and len(out_of("git", "rev-list", "--parents", "-n1", tip).split()) == 3
|
|
415
|
+
assert f"Ticket {number}: the gate is green on {tip[:7]}, record .agent/implement/{number}/gate-{tip[:7]}.log." in ledger
|
|
416
|
+
assert "| gates run | 4 |" in out and "| agents, conflict on claude-opus-5@medium | 1 |" in out
|
|
417
|
+
assert dict(state_of(tmp_path)["run"]["landed"])[number] == tip
|
|
418
|
+
prompt = (tmp_path / f".agent/implement/{number}/conflict-0.prompt.md").read_text()
|
|
419
|
+
assert "Files in conflict: work.txt\n" in prompt and prompt.rstrip().endswith("one ref store.")
|
|
420
|
+
assert branches() == ["feat/x"] and out_of("git", "status", "--porcelain") == ""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_a_conflict_agent_that_does_not_resolve_blocks_the_ticket_and_keeps_its_branch(repo, tmp_path, capsys):
|
|
424
|
+
two(repo)
|
|
425
|
+
stuck = '[ $role != conflict ] || exit 7\n'
|
|
426
|
+
code, ledger, out = walk(tmp_path, capsys, stuck + "FILE=work.txt\n" + WORK + TOGETHER + REPORT)
|
|
427
|
+
assert code == 1, out
|
|
428
|
+
(line,) = [line for line in ledger if "BLOCKED" in line]
|
|
429
|
+
number = line[7:9]
|
|
430
|
+
assert f"BLOCKED at the merge: the conflict agent: report .agent/implement/{number}/conflict-0.json is not" in line
|
|
431
|
+
assert len([line for line in ledger if "conflict ran on" in line]) == 2 # the one rerun of a violation
|
|
432
|
+
assert state_of(tmp_path)["tickets"][number]["phase"] == "blocked"
|
|
433
|
+
assert f"ticket/{number}" in branches() and f"Ticket {number} is not merged: its phase is blocked." in out
|
|
434
|
+
for slot in (".", ".agent/worktrees/slot-2"): # the driver's open merge is gone
|
|
435
|
+
assert out_of("git", "-C", slot, "status", "--porcelain") == ""
|
|
436
|
+
assert out_of("git", "-C", slot, "rev-parse", "-q", "--verify", "MERGE_HEAD") == ""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_a_red_gate_blocks_the_ticket_and_the_ticket_it_blocks_never_starts(repo, tmp_path, capsys):
|
|
440
|
+
two(repo, second="01")
|
|
441
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT, gate="echo CHECK-OUTPUT; exit 1")
|
|
442
|
+
tip = out_of("git", "rev-parse", "--short=7", "ticket/01")
|
|
443
|
+
assert code == 1 and len(ledger) == 2, out
|
|
444
|
+
assert ledger[1] == (f"Ticket 01: BLOCKED at the gate: the gate is red on {tip}, "
|
|
445
|
+
f"record .agent/implement/01/gate-{tip}.log.")
|
|
446
|
+
record = (tmp_path / f".agent/implement/01/gate-{tip}.log").read_text()
|
|
447
|
+
assert "status: clean\n" in record and "CHECK-OUTPUT\n\nexit: 1\nafter: clean\nend: " in record
|
|
448
|
+
assert "Ticket 02 is not merged: its phase is pending." in out and "| gates run | 1 |" in out
|
|
449
|
+
assert out_of("git", "symbolic-ref", "--short", "HEAD") == "feat/x"
|
|
450
|
+
assert walk(tmp_path, capsys, "exit 9\n")[0] == 1 # a BLOCKED ticket stays BLOCKED on a rerun
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_a_red_merge_gate_blocks_the_ticket_and_the_pr_branch_does_not_move(repo, tmp_path, capsys):
|
|
454
|
+
two(repo)
|
|
455
|
+
gate = 'test "$(git rev-parse --abbrev-ref HEAD)" != HEAD' # red on a detached commit: the merge commit
|
|
456
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=gate)
|
|
457
|
+
assert code == 1, out
|
|
458
|
+
(line,) = [line for line in ledger if "BLOCKED" in line]
|
|
459
|
+
assert "BLOCKED at the merge: the gate is red on " in line
|
|
460
|
+
assert len(out_of("git", "rev-list", "--parents", "-n1", "feat/x").split()) == 2 # no merge commit landed
|
|
461
|
+
assert len(branches()) == 2 and out_of("git", "status", "--porcelain") == ""
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_a_tree_that_is_not_clean_is_a_red_gate_and_the_slot_keeps_the_work(repo, tmp_path, capsys):
|
|
465
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
466
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT + "echo left > junk.txt\n", gate="echo NEVER")
|
|
467
|
+
assert code == 1 and "BLOCKED at the gate" in ledger[-1], out
|
|
468
|
+
record = next((tmp_path / ".agent/implement/01").glob("gate-*.log")).read_text()
|
|
469
|
+
assert "status: not clean\n ?? junk.txt\n" in record and "exit: not run" in record and "\nNEVER\n" not in record
|
|
470
|
+
assert out_of("git", "symbolic-ref", "--short", "HEAD") == "ticket/01" and (tmp_path / "junk.txt").exists()
|
|
471
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
472
|
+
assert code == 3 and "uncommitted change: junk.txt" in out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_a_failing_prepare_stops_the_run_and_removes_the_slot(repo, tmp_path, capsys):
|
|
476
|
+
two(repo)
|
|
477
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n", "--prepare", "echo no-deps >&2; false")
|
|
478
|
+
assert (code, ledger) == (3, [])
|
|
479
|
+
assert "slot 2: --prepare `echo no-deps >&2; false` exited 1 in .agent/worktrees/slot-2: no-deps" in out
|
|
480
|
+
assert not (tmp_path / ".agent/worktrees/slot-2").exists() and branches() == ["feat/x"]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_a_touches_overlap_holds_the_second_ticket_and_the_ledger_says_why(repo, tmp_path, capsys):
|
|
484
|
+
two(repo, touches="`demo/x.py`")
|
|
485
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
486
|
+
assert code == 0, out
|
|
487
|
+
assert ledger[0] == "Ticket 02: held while 01 runs: both touch demo/x.py: f."
|
|
488
|
+
assert ledger.count(ledger[0]) == 1 and len(out_of("git", "rev-list", "--merges", "feat/x").split()) == 0
|
|
489
|
+
assert not (tmp_path / ".agent/worktrees/slot-2/work-02.txt").exists() # 02 ran in slot 1, after 01
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def blocked_then_edited(repo, tmp_path, capsys, **fields):
|
|
493
|
+
"""A one-ticket run that a red gate blocks, then state.json edited as the resume tests need."""
|
|
494
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
495
|
+
assert walk(tmp_path, capsys, WORK + REPORT, gate="false")[0] == 1
|
|
496
|
+
state = state_of(tmp_path)
|
|
497
|
+
state["tickets"]["01"].update(fields)
|
|
498
|
+
(tmp_path / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
499
|
+
return state["tickets"]["01"]
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_a_resumed_tip_that_is_not_on_the_branch_returns_the_ticket_to_implement(repo, tmp_path, capsys):
|
|
503
|
+
blocked_then_edited(repo, tmp_path, capsys, phase="review", tip="1" * 40)
|
|
504
|
+
base = out_of("git", "rev-parse", "feat/x")
|
|
505
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
506
|
+
assert code == 0, out
|
|
507
|
+
assert "Ticket 01: returned to implement from the PR tip: the recorded tip 1111111 is not on ticket/01." in ledger
|
|
508
|
+
assert out_of("git", "rev-parse", "feat/x~1") == base # the earlier, untrusted commit is not in the PR branch
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_a_resumed_ticket_with_a_settled_gate_merges_without_a_gate_or_an_agent(repo, tmp_path, capsys):
|
|
512
|
+
tip = None
|
|
513
|
+
entry = blocked_then_edited(repo, tmp_path, capsys, phase="merge")
|
|
514
|
+
tip = entry["tip"]
|
|
515
|
+
state = state_of(tmp_path)
|
|
516
|
+
state["tickets"]["01"]["green_gates"] = [tip]
|
|
517
|
+
(tmp_path / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
518
|
+
# A gate on the ticket branch is still red, so a second gate for the settled commit would block the ticket. The
|
|
519
|
+
# one new gate is that of the end of run, on the PR branch.
|
|
520
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n", gate='[ "$(git rev-parse --abbrev-ref HEAD)" != ticket/01 ]')
|
|
521
|
+
assert code == 0 and walked() == tip and "| gates run | 2 |" in out
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_an_unknown_phase_fails_the_resume(repo, tmp_path, capsys):
|
|
525
|
+
blocked_then_edited(repo, tmp_path, capsys, phase="polish")
|
|
526
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
527
|
+
assert code == 3 and ".agent/implement/state.json: ticket 01 has the unknown phase 'polish'" in out
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_a_rerun_on_another_branch_is_refused_and_a_detached_rerun_uses_the_recorded_branch(repo, tmp_path, capsys):
|
|
531
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
532
|
+
assert walk(tmp_path, capsys, WORK + REPORT)[0] == 0
|
|
533
|
+
subprocess.run(["git", "checkout", "-q", "-b", "other"], check=True)
|
|
534
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
535
|
+
assert code == 3 and "records a run on feat/x, not on other" in out
|
|
536
|
+
subprocess.run(["git", "checkout", "-q", "--detach"], check=True) # as an interrupted run leaves slot 1
|
|
537
|
+
assert walk(tmp_path, capsys, "exit 9\n")[0] == 0
|
|
538
|
+
assert out_of("git", "symbolic-ref", "--short", "HEAD") == "feat/x"
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def test_a_first_run_on_a_detached_head_or_with_no_slot_is_refused(repo, tmp_path, capsys):
|
|
542
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
543
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n", "--parallel", "0")
|
|
544
|
+
assert code == 3 and "--parallel must be 1 or more" in out
|
|
545
|
+
subprocess.run(["git", "checkout", "-q", "--detach"], check=True)
|
|
546
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
547
|
+
assert code == 3 and "the PR worktree is on a detached HEAD" in out
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def test_a_ticket_branch_that_cannot_be_checked_out_blocks_the_ticket(repo, tmp_path, capsys):
|
|
551
|
+
"""A slot of an earlier run with more slots still holds the branch."""
|
|
552
|
+
blocked_then_edited(repo, tmp_path, capsys, phase="implement")
|
|
553
|
+
subprocess.run(["git", "worktree", "add", "-q", str(tmp_path.parent / f"{tmp_path.name}-other"), "ticket/01"], check=True)
|
|
554
|
+
code, ledger, _ = walk(tmp_path, capsys, "exit 9\n")
|
|
555
|
+
assert code == 1 and "BLOCKED at the start: cannot check out ticket/01 in slot 1" in ledger[-1]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_an_interrupt_kills_the_running_agents_and_stops_the_driver_at_once(repo, tmp_path, capsys, monkeypatch):
|
|
559
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
560
|
+
started = tmp_path.parent / f"{tmp_path.name}-started"
|
|
561
|
+
|
|
562
|
+
def interrupt(*_, **__):
|
|
563
|
+
while not (started.exists() and driver_agents.LIVE):
|
|
564
|
+
time.sleep(0.01)
|
|
565
|
+
raise KeyboardInterrupt
|
|
566
|
+
|
|
567
|
+
def leave(code):
|
|
568
|
+
raise SystemExit(code)
|
|
569
|
+
|
|
570
|
+
monkeypatch.setattr(driver_agents, "STOPPED", False) # restored after the test: stop() sets it for the process
|
|
571
|
+
monkeypatch.setattr(driver, "wait", interrupt)
|
|
572
|
+
monkeypatch.setattr(driver.os, "_exit", leave)
|
|
573
|
+
begin = time.monotonic()
|
|
574
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
575
|
+
walk(tmp_path, capsys, f"touch {started}; sleep 30; echo late > late.txt\n")
|
|
576
|
+
assert exit_info.value.code == 130 and time.monotonic() - begin < 20
|
|
577
|
+
assert driver_agents.STOPPED and not driver_agents.LIVE and not (tmp_path / "late.txt").exists()
|
|
578
|
+
assert state_of(tmp_path)["tickets"]["01"]["phase"] == "implement" # not BLOCKED: a rerun starts it again
|
|
579
|
+
assert "interrupted; run it again to resume" in capsys.readouterr().out
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def test_a_second_driver_on_the_same_repository_exits_3_and_changes_nothing(repo, tmp_path, capsys):
|
|
583
|
+
"""Final review, correctness 1: two drivers on one state.json lose updates and move the same refs. Ticket 09:
|
|
584
|
+
the lock is in the git common directory, so it is the same file in every worktree of the repository."""
|
|
585
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
586
|
+
lock = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec.lock"
|
|
587
|
+
with lock.open("w") as other: # the first driver: it holds the kernel lock while it runs
|
|
588
|
+
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
589
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
590
|
+
assert (code, out) == (3, "implement-spec: another driver runs in this repository: it holds the lock "
|
|
591
|
+
f"{lock}. Wait until it ends, then run the driver again.\n")
|
|
592
|
+
assert ledger == [] and not (tmp_path / ".agent/implement").exists() and branches() == ["feat/x"]
|
|
593
|
+
assert not Path(f"{tmp_path}-runner.calls").exists()
|
|
594
|
+
assert run(capsys) == (0, "max frontier width: 1\n") # --dry-run changes nothing, so it needs no lock
|
|
595
|
+
# The lock ends with its holder, also one that was killed: no file is left to remove by hand.
|
|
596
|
+
code, _, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
597
|
+
assert code == 0 and "1 of 1 tickets are merged into feat/x." in out
|
|
598
|
+
assert walk(tmp_path, capsys, "exit 9\n")[0] == 0 # the run let its lock go when it ended
|
|
599
|
+
assert out_of("git", "status", "--porcelain") == ""
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def test_a_wrong_call_takes_no_lock_and_makes_no_file(repo, tmp_path, capsys):
|
|
603
|
+
repo(t01="nothing here\n")
|
|
604
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
605
|
+
assert code == 3 and out.startswith("01-t.md: missing title\n") and not (tmp_path / ".agent/implement").exists()
|
|
606
|
+
assert not (tmp_path / ".git/implement-spec.lock").exists()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_a_run_outside_a_repository_takes_no_lock_and_makes_no_file(tmp_path, monkeypatch, capsys):
|
|
610
|
+
monkeypatch.chdir(tmp_path)
|
|
611
|
+
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent))
|
|
612
|
+
(tmp_path / ".agent/implement").mkdir(parents=True) # what an earlier run left does not make the call valid
|
|
613
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
614
|
+
assert code == 3 and "git status failed" in out and [path.name for path in tmp_path.iterdir()] == [".agent"]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def test_a_pr_branch_that_another_hand_moves_during_the_merge_gate_starts_the_landing_again(repo, tmp_path, capsys):
|
|
618
|
+
"""Ticket 09: the locked-file check and the ref move are one step. The ref moves only from the tip the check
|
|
619
|
+
read; from a tip that moved, the merge, the check and the gate are made again."""
|
|
620
|
+
two(repo)
|
|
621
|
+
mark = tmp_path.parent / f"{tmp_path.name}-moved"
|
|
622
|
+
# The gate of the first merge commit puts one more commit on the PR branch, as another hand would.
|
|
623
|
+
gate = (f'[ -e {mark} ] || [ -z "$(git rev-parse -q --verify HEAD^2)" ] || {{ touch {mark}; git update-ref '
|
|
624
|
+
'refs/heads/feat/x "$(git commit-tree "feat/x^{tree}" -p feat/x -m other)"; }')
|
|
625
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=gate)
|
|
626
|
+
assert code == 0 and mark.exists(), out
|
|
627
|
+
subjects = out_of("git", "log", "--format=%s", walked()).splitlines()
|
|
628
|
+
assert "other" in subjects and {"implementer 01", "implementer 02"} <= set(subjects)
|
|
629
|
+
assert len([line for line in ledger if "merged into feat/x as a merge commit" in line]) == 1
|
|
630
|
+
assert "| gates run | 5 |" in out # two ticket tips, the stale merge commit, the new one, the end of run
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# Ticket 09, review round 1 (m1, Codex 6): the restart has a bound, and only a stale ref starts it.
|
|
634
|
+
MERGE_GATE = '[ -z "$(git rev-parse -q --verify HEAD^2)" ] || ' # the rest of the line runs for a merge commit only
|
|
635
|
+
MOVE = 'git update-ref refs/heads/feat/x "$(git commit-tree "feat/x^{tree}" -p feat/x -m other)"'
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def merge_blocked(ledger):
|
|
639
|
+
(line,) = [line for line in ledger if "BLOCKED" in line]
|
|
640
|
+
return line
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def test_a_pr_branch_that_moves_at_each_merge_gate_blocks_the_ticket_after_the_second_try(repo, tmp_path, capsys):
|
|
644
|
+
two(repo)
|
|
645
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=MERGE_GATE + MOVE)
|
|
646
|
+
assert code == 1, out
|
|
647
|
+
assert "BLOCKED at the merge: " in merge_blocked(ledger) and "feat/x" in merge_blocked(ledger)
|
|
648
|
+
assert "| gates run | 4 |" in out # two ticket tips and two merge commits: the landing is tried two times
|
|
649
|
+
assert out_of("git", "log", "--format=%s", "feat/x").splitlines().count("other") == 2
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def test_a_red_merge_gate_does_not_start_the_landing_again_when_the_pr_branch_moved(repo, tmp_path, capsys):
|
|
653
|
+
two(repo)
|
|
654
|
+
mark = tmp_path.parent / f"{tmp_path.name}-red"
|
|
655
|
+
gate = MERGE_GATE + f'[ -e {mark} ] || {{ touch {mark}; {MOVE}; exit 1; }}'
|
|
656
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=gate)
|
|
657
|
+
assert code == 1 and "BLOCKED at the merge: the gate is red on " in merge_blocked(ledger), out
|
|
658
|
+
assert "| gates run | 3 |" in out
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_a_conflict_on_the_second_try_blocks_the_ticket_with_no_conflict_agent(repo, tmp_path, capsys):
|
|
662
|
+
"""Requirement 11: the conflict agent runs once for each merge. The second try runs no agent."""
|
|
663
|
+
two(repo)
|
|
664
|
+
mark = tmp_path.parent / f"{tmp_path.name}-foreign"
|
|
665
|
+
# Another hand commits both work files to the PR branch: the one of the ticket in flight is an add/add conflict.
|
|
666
|
+
foreign = ('( export GIT_INDEX_FILE="$(git rev-parse --git-dir)/foreign-index"; git read-tree feat/x; '
|
|
667
|
+
'blob=$(echo foreign | git hash-object -w --stdin); for nn in 01 02; do '
|
|
668
|
+
'git update-index --add --cacheinfo 100644,$blob,work-$nn.txt; done; git update-ref refs/heads/feat/x '
|
|
669
|
+
'"$(git commit-tree "$(git write-tree)" -p feat/x -m other)"; rm "$GIT_INDEX_FILE" )')
|
|
670
|
+
gate = MERGE_GATE + f'[ -e {mark} ] || {{ touch {mark}; {foreign}; }}'
|
|
671
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=gate)
|
|
672
|
+
assert code == 1 and mark.exists(), out
|
|
673
|
+
assert "BLOCKED at the merge: the merge into the PR branch conflicts in work-0" in merge_blocked(ledger)
|
|
674
|
+
assert not [line for line in ledger if "conflict ran on" in line] and "| gates run | 3 |" in out
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_the_landing_check_runs_while_the_ticket_holds_the_merge_lock(repo, tmp_path, capsys, monkeypatch):
|
|
678
|
+
"""Review round 1, m2: the check and the ref move are one step only under the lock that makes merges serial."""
|
|
679
|
+
two(repo)
|
|
680
|
+
runs, held, check = [], [], driver.driver_merge.refused
|
|
681
|
+
|
|
682
|
+
class Seen(driver.Run):
|
|
683
|
+
def __init__(self, *args, **kwargs):
|
|
684
|
+
super().__init__(*args, **kwargs)
|
|
685
|
+
runs.append(self)
|
|
686
|
+
|
|
687
|
+
def refused(*args):
|
|
688
|
+
held.append(runs[0].merging.locked())
|
|
689
|
+
return check(*args)
|
|
690
|
+
|
|
691
|
+
monkeypatch.setattr(driver, "Run", Seen)
|
|
692
|
+
monkeypatch.setattr(driver.driver_merge, "refused", refused)
|
|
693
|
+
code, _, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT)
|
|
694
|
+
assert code == 0 and held == [True, True], out
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# Fix round 1 of ticket 03.
|
|
698
|
+
|
|
699
|
+
def test_a_merge_commit_the_slot_cannot_check_out_is_not_gated_and_does_not_land(repo, tmp_path, capsys):
|
|
700
|
+
two(repo)
|
|
701
|
+
gate = 'touch "$(git rev-parse --git-dir)/index.lock"' # after its gate, the slot can check out nothing
|
|
702
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + TOGETHER + REPORT, gate=gate)
|
|
703
|
+
assert code == 1, out
|
|
704
|
+
(line,) = [line for line in ledger if "BLOCKED" in line]
|
|
705
|
+
assert "BLOCKED at the merge: the slot is at " in line and "the gate did not run" in line
|
|
706
|
+
assert len(out_of("git", "rev-list", "--parents", "-n1", "feat/x").split()) == 2 # no merge commit landed
|
|
707
|
+
assert "| gates run | 2 |" in out
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@pytest.mark.parametrize("gate, after", [
|
|
711
|
+
("echo patched >> work-01.txt", "after: not clean\n M work-01.txt\n"),
|
|
712
|
+
("git commit -q --allow-empty -m by-the-gate", "after: HEAD moved to "),
|
|
713
|
+
])
|
|
714
|
+
def test_a_gate_that_changes_the_tree_or_moves_head_is_red(repo, tmp_path, capsys, gate, after):
|
|
715
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
716
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT, gate=gate)
|
|
717
|
+
assert code == 1 and "BLOCKED at the gate: the gate is red on " in ledger[-1], out
|
|
718
|
+
record = next((tmp_path / ".agent/implement/01").glob("gate-*.log")).read_text()
|
|
719
|
+
assert "\nexit: 0\n" in record and after in record
|
|
720
|
+
assert state_of(tmp_path)["tickets"]["01"]["green_gates"] == []
|
|
721
|
+
assert "by-the-gate" not in out_of("git", "log", "--format=%s", "feat/x")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def test_a_merged_ticket_whose_commits_left_the_pr_branch_is_not_merged_on_a_rerun(repo, tmp_path, capsys):
|
|
725
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
726
|
+
base = out_of("git", "rev-parse", "HEAD")
|
|
727
|
+
assert walk(tmp_path, capsys, WORK + REPORT)[0] == 0
|
|
728
|
+
tip = out_of("git", "rev-parse", "--short=7", "HEAD")
|
|
729
|
+
subprocess.run(["git", "reset", "-q", "--hard", base], check=True) # the operator dropped the ticket's commits
|
|
730
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
731
|
+
assert f"Ticket 01: returned to implement from the PR tip: the recorded tip {tip} is not in feat/x." in ledger
|
|
732
|
+
assert ledger[-1].startswith("Ticket 01: merged into feat/x as a fast-forward at ")
|
|
733
|
+
assert (tmp_path / "work-01.txt").exists() and out_of("git", "rev-parse", "feat/x~1") == base
|
|
734
|
+
# The end of run tidied the branch in the first run, and that tip left the branch too: it stops (test_tidy.py).
|
|
735
|
+
assert code == 1 and f"End of run: BLOCKED at feat/x does not hold the tidied tip {tip}" in out, out
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_role(repo, tmp_path, capsys, monkeypatch):
|
|
739
|
+
two(repo)
|
|
740
|
+
mark = f"{tmp_path.parent}/{tmp_path.name}-started"
|
|
741
|
+
|
|
742
|
+
def interrupt(*_, **__):
|
|
743
|
+
while not (Path(mark + "-01").exists() and Path(mark + "-02").exists() and len(driver_agents.LIVE) == 2):
|
|
744
|
+
time.sleep(0.01)
|
|
745
|
+
raise KeyboardInterrupt
|
|
746
|
+
|
|
747
|
+
def leave(code):
|
|
748
|
+
raise SystemExit(code)
|
|
749
|
+
|
|
750
|
+
hang = f'nn=$(basename "$(dirname "$report")"); echo half > wip-$nn.txt; touch {mark}-$nn; sleep 30\n'
|
|
751
|
+
with monkeypatch.context() as patch:
|
|
752
|
+
patch.setattr(driver_agents, "STOPPED", False)
|
|
753
|
+
patch.setattr(driver, "wait", interrupt)
|
|
754
|
+
patch.setattr(driver.os, "_exit", leave)
|
|
755
|
+
with pytest.raises(SystemExit):
|
|
756
|
+
walk(tmp_path, capsys, hang)
|
|
757
|
+
slot2 = tmp_path / ".agent/worktrees/slot-2"
|
|
758
|
+
assert (tmp_path / "wip-01.txt").exists() and (slot2 / "wip-02.txt").exists()
|
|
759
|
+
resume = ('grep -q "was interrupted" "$prompt" || exit 9\nnn=$(basename "$(dirname "$report")")\n'
|
|
760
|
+
'[ "$(cat wip-$nn.txt)" = half ] || exit 8\n')
|
|
761
|
+
code, ledger, out = walk(tmp_path, capsys, resume + WORK + REPORT)
|
|
762
|
+
assert code == 0, out + "\n".join(ledger)
|
|
763
|
+
calls = dict(line.split() for line in Path(f"{tmp_path}-runner.calls").read_text().splitlines())
|
|
764
|
+
assert calls == {"01": str(tmp_path.resolve()), "02": str(slot2.resolve())}
|
|
765
|
+
assert out_of("git", "show", "feat/x:wip-01.txt") == "half" and out_of("git", "show", "feat/x:wip-02.txt") == "half"
|
|
766
|
+
assert [line for line in ledger if "Ticket 02: resumed in slot 2 with the work the interrupted implementer left" in line]
|
|
767
|
+
assert out_of("git", "status", "--porcelain") == "" and branches() == ["feat/x"]
|