@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.
Files changed (72) hide show
  1. package/README.md +12 -6
  2. package/package.json +2 -2
  3. package/skills/implement-spec/ARCHITECTURE.md +113 -0
  4. package/skills/implement-spec/README.md +214 -0
  5. package/skills/implement-spec/SKILL.md +18 -70
  6. package/skills/implement-spec/driver.py +580 -0
  7. package/skills/implement-spec/driver_agents.py +145 -0
  8. package/skills/implement-spec/driver_answers.py +79 -0
  9. package/skills/implement-spec/driver_decisions.py +152 -0
  10. package/skills/implement-spec/driver_final.py +505 -0
  11. package/skills/implement-spec/driver_fixloop.py +170 -0
  12. package/skills/implement-spec/driver_gate.py +47 -0
  13. package/skills/implement-spec/driver_graph.py +42 -0
  14. package/skills/implement-spec/driver_merge.py +105 -0
  15. package/skills/implement-spec/driver_report.py +115 -0
  16. package/skills/implement-spec/driver_review.py +126 -0
  17. package/skills/implement-spec/driver_review_page.py +51 -0
  18. package/skills/implement-spec/driver_run.py +262 -0
  19. package/skills/implement-spec/driver_slots.py +64 -0
  20. package/skills/implement-spec/driver_state.py +105 -0
  21. package/skills/implement-spec/driver_summary.py +40 -0
  22. package/skills/implement-spec/driver_tickets.py +132 -0
  23. package/skills/implement-spec/driver_tidy.py +57 -0
  24. package/skills/implement-spec/models.json +10 -0
  25. package/skills/implement-spec/prompts/conflict.md +32 -0
  26. package/skills/implement-spec/prompts/correction.md +4 -0
  27. package/skills/implement-spec/prompts/correctness.md +34 -0
  28. package/skills/implement-spec/prompts/final-fixer.md +36 -0
  29. package/skills/implement-spec/prompts/final-reviewer.md +36 -0
  30. package/skills/implement-spec/prompts/final-scoped.md +1 -0
  31. package/skills/implement-spec/prompts/fixer.md +35 -0
  32. package/skills/implement-spec/prompts/implementer.md +33 -0
  33. package/skills/implement-spec/prompts/interrupted.md +1 -0
  34. package/skills/implement-spec/prompts/moved.md +2 -0
  35. package/skills/implement-spec/prompts/overruled.md +5 -0
  36. package/skills/implement-spec/prompts/pr-body.md +19 -0
  37. package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
  38. package/skills/implement-spec/prompts/rereview.md +3 -0
  39. package/skills/implement-spec/prompts/rerun.md +2 -0
  40. package/skills/implement-spec/prompts/reviewer.md +34 -0
  41. package/skills/implement-spec/prompts/stands.md +2 -0
  42. package/skills/implement-spec/prompts/test-writer.md +34 -0
  43. package/skills/implement-spec/prompts/timeout-restored.md +2 -0
  44. package/skills/implement-spec/prompts/timeout-shared.md +2 -0
  45. package/skills/implement-spec/prompts/timeout.md +2 -0
  46. package/skills/implement-spec/run.sh +101 -0
  47. package/skills/implement-spec/tests/conftest.py +38 -0
  48. package/skills/implement-spec/tests/test_agents.py +358 -0
  49. package/skills/implement-spec/tests/test_amendments.py +117 -0
  50. package/skills/implement-spec/tests/test_cli.py +220 -0
  51. package/skills/implement-spec/tests/test_decisions.py +434 -0
  52. package/skills/implement-spec/tests/test_driver.py +767 -0
  53. package/skills/implement-spec/tests/test_final.py +442 -0
  54. package/skills/implement-spec/tests/test_fixloop.py +523 -0
  55. package/skills/implement-spec/tests/test_graph.py +32 -0
  56. package/skills/implement-spec/tests/test_landing.py +355 -0
  57. package/skills/implement-spec/tests/test_merge.py +144 -0
  58. package/skills/implement-spec/tests/test_models.py +47 -0
  59. package/skills/implement-spec/tests/test_report.py +162 -0
  60. package/skills/implement-spec/tests/test_review.py +338 -0
  61. package/skills/implement-spec/tests/test_review_page.py +67 -0
  62. package/skills/implement-spec/tests/test_slots.py +32 -0
  63. package/skills/implement-spec/tests/test_state.py +68 -0
  64. package/skills/implement-spec/tests/test_stops.py +272 -0
  65. package/skills/implement-spec/tests/test_summary.py +28 -0
  66. package/skills/implement-spec/tests/test_tickets.py +72 -0
  67. package/skills/implement-spec/tests/test_tidy.py +582 -0
  68. package/skills/implement-spec/tests/test_walk.py +457 -0
  69. package/skills/review-page/template.html +1 -1
  70. package/skills/to-tickets/SKILL.md +19 -2
  71. package/skills-manifest.json +7 -7
  72. 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"]