@jesdi/skills 0.0.44 → 0.0.45
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/package.json +1 -1
- package/skills/implement-spec/ARCHITECTURE.md +9 -4
- package/skills/implement-spec/README.md +8 -5
- package/skills/implement-spec/driver.py +80 -39
- package/skills/implement-spec/driver_final.py +6 -6
- package/skills/implement-spec/driver_legacy.py +92 -0
- package/skills/implement-spec/driver_merge.py +1 -5
- package/skills/implement-spec/driver_names.py +32 -0
- package/skills/implement-spec/driver_review.py +2 -2
- package/skills/implement-spec/driver_run.py +7 -2
- package/skills/implement-spec/driver_slots.py +13 -0
- package/skills/implement-spec/driver_state.py +12 -13
- package/skills/implement-spec/tests/test_agents.py +10 -10
- package/skills/implement-spec/tests/test_amendments.py +2 -2
- package/skills/implement-spec/tests/test_decisions.py +4 -4
- package/skills/implement-spec/tests/test_driver.py +183 -23
- package/skills/implement-spec/tests/test_final.py +5 -5
- package/skills/implement-spec/tests/test_fixloop.py +5 -5
- package/skills/implement-spec/tests/test_landing.py +26 -23
- package/skills/implement-spec/tests/test_legacy.py +139 -0
- package/skills/implement-spec/tests/test_stops.py +1 -1
- package/skills/implement-spec/tests/test_tidy.py +2 -2
- package/skills/implement-spec/tests/test_walk.py +10 -10
- package/skills-manifest.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""state.json, the resume contract: one lock, every write a read-modify-write, published by rename.
|
|
2
2
|
|
|
3
|
-
{"run": {revision, pr_branch, gates, stops, agents: [{ticket, role, entry, seconds, cost}], questions, start,
|
|
3
|
+
{"run": {revision, pr_branch, schema, gates, stops, agents: [{ticket, role, entry, seconds, cost}], questions, start,
|
|
4
4
|
landed: [[ticket, the PR tip its merge made]], final},
|
|
5
5
|
"tickets": {"NN": {phase, round, tip, test_writer_sha, locked, green_gates, slot, note, held}}}"""
|
|
6
6
|
from __future__ import annotations
|
|
@@ -13,8 +13,10 @@ import uuid
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import IO
|
|
15
15
|
|
|
16
|
-
from
|
|
16
|
+
from driver_names import Names
|
|
17
|
+
from driver_slots import ancestor, git, on_branch, path
|
|
17
18
|
|
|
19
|
+
SCHEMA = 2 # the version of this contract; 2: the names of driver_names. A run of an older driver has none.
|
|
18
20
|
PHASES = ("pending", "test-writer", "implement", "gate", "review", "fix", "merge", "merged", "blocked")
|
|
19
21
|
|
|
20
22
|
|
|
@@ -42,7 +44,7 @@ class State:
|
|
|
42
44
|
"""Create the file for a new run, or add the tickets a resumed run does not know yet."""
|
|
43
45
|
def change(state: dict) -> None:
|
|
44
46
|
state.setdefault("run", {
|
|
45
|
-
"revision": uuid.uuid4().hex, "pr_branch": pr_branch, "gates": 0, "stops": 0, "agents": [],
|
|
47
|
+
"revision": uuid.uuid4().hex, "pr_branch": pr_branch, "schema": SCHEMA, "gates": 0, "stops": 0, "agents": [],
|
|
46
48
|
"questions": [], "start": git(".", "rev-parse", f"refs/heads/{pr_branch}").stdout.strip(),
|
|
47
49
|
"landed": [], "final": _final()})
|
|
48
50
|
for key, value in _final().items(): # a run of an older driver lacks the newer keys
|
|
@@ -67,22 +69,19 @@ class State:
|
|
|
67
69
|
return self.update(lambda state: state["tickets"][number].update(fields))["tickets"][number]
|
|
68
70
|
|
|
69
71
|
|
|
70
|
-
def lock(path: Path) -> IO | None:
|
|
71
|
-
"""The
|
|
72
|
+
def lock(path: Path, shared: bool = False) -> IO | None:
|
|
73
|
+
"""The kernel lock on `path`, exclusive or `shared`, held while the returned file is open; None when another
|
|
74
|
+
process has it in a way that excludes this one."""
|
|
72
75
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
76
|
held = path.open("a")
|
|
74
77
|
try:
|
|
75
|
-
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
78
|
+
fcntl.flock(held, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
|
76
79
|
except BlockingIOError:
|
|
77
80
|
held.close()
|
|
78
81
|
return None
|
|
79
82
|
return held
|
|
80
83
|
|
|
81
84
|
|
|
82
|
-
def _ancestor(commit: str, of: str) -> bool:
|
|
83
|
-
return git(".", "merge-base", "--is-ancestor", commit, of).returncode == 0
|
|
84
|
-
|
|
85
|
-
|
|
86
85
|
def verified(entry: dict, branch: str, pr_branch: str) -> tuple[dict, str]:
|
|
87
86
|
"""(the fields to resume from, why) when git contradicts the entry; ({}, "") when it does not."""
|
|
88
87
|
phase, tip = entry["phase"], str(entry["tip"])
|
|
@@ -90,7 +89,7 @@ def verified(entry: dict, branch: str, pr_branch: str) -> tuple[dict, str]:
|
|
|
90
89
|
return {}, ""
|
|
91
90
|
# The branch of a merged ticket is deleted, so its commits are trusted only while the PR branch has them.
|
|
92
91
|
where, word = (pr_branch, "in") if phase == "merged" else (branch, "on")
|
|
93
|
-
if
|
|
92
|
+
if ancestor(tip, where):
|
|
94
93
|
return {}, ""
|
|
95
94
|
return {"phase": "implement", "tip": entry["test_writer_sha"]}, f"the recorded tip {tip[:7]} is not {word} {where}"
|
|
96
95
|
|
|
@@ -102,7 +101,7 @@ def kept(state: dict) -> dict[str, int]:
|
|
|
102
101
|
for number, entry in state.get("tickets", {}).items():
|
|
103
102
|
if (entry["phase"] in ("test-writer", "implement") or number in asking) and entry["slot"]:
|
|
104
103
|
slot = path(entry["slot"])
|
|
105
|
-
on =
|
|
106
|
-
if on ==
|
|
104
|
+
on = on_branch(slot)
|
|
105
|
+
if on == Names(state["run"]["pr_branch"]).ticket(number) and git(slot, "status", "--porcelain").stdout.strip():
|
|
107
106
|
found[number] = entry["slot"]
|
|
108
107
|
return found
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""Agent launch acceptance tests, through `sh run.sh` with a fake runner script or fake claude/codex on PATH.
|
|
2
2
|
|
|
3
3
|
Interface choices (ticket 02): `--runner <script>` is called as `<script> <role> <prompt> <report>` in the slot
|
|
4
|
-
directory. A plain run (no --dry-run) branches ticket/NN from the PR tip in slot 1, runs the test-writer (unless the
|
|
4
|
+
directory. A plain run (no --dry-run) branches ticket/feat%2Fcsv/NN from the PR tip in slot 1, runs the test-writer (unless the
|
|
5
5
|
Seam is `none — refactor`) then the implementer; from ticket 03 on an accepted ticket goes on to the driver's gate (`true` here) and a
|
|
6
|
-
merge into the PR branch, which deletes `ticket/NN`. Exit 0 when the ticket merges, 1 on BLOCKED.
|
|
6
|
+
merge into the PR branch, which deletes `ticket/feat%2Fcsv/NN`. Exit 0 when the ticket merges, 1 on BLOCKED.
|
|
7
7
|
`--only <role>` runs that one role on the first ticket and stops. `--spec <path>` names the spec for the prompts.
|
|
8
8
|
`--timeout <role>=<minutes>` (repeatable, fractions allowed) overrides a role timeout.
|
|
9
9
|
Ticket 08: the fixture has no `origin`, and a run that merges its ticket ends with the tidy and the final review, so
|
|
10
|
-
`ticket_commits()` reads the ticket's commits from `refs/implement/recovery`, the PR tip from before the tidy.
|
|
10
|
+
`ticket_commits()` reads the ticket's commits from `refs/implement-spec/feat%2Fcsv/recovery`, the PR tip from before the tidy.
|
|
11
11
|
"""
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
@@ -134,7 +134,7 @@ class World:
|
|
|
134
134
|
return path.read_text() if path.exists() else ""
|
|
135
135
|
|
|
136
136
|
def ticket_commits(self):
|
|
137
|
-
made = subprocess.run(["git", "rev-parse", "--verify", "-q", "refs/implement/recovery"], cwd=self.repo,
|
|
137
|
+
made = subprocess.run(["git", "rev-parse", "--verify", "-q", "refs/implement-spec/feat%2Fcsv/recovery"], cwd=self.repo,
|
|
138
138
|
capture_output=True, text=True).stdout.strip() or "feat/csv"
|
|
139
139
|
return git(self.repo, "rev-list", "--reverse", "--no-merges", f"{self.base}..{made}").split()
|
|
140
140
|
|
|
@@ -175,7 +175,7 @@ def test_runner_is_called_with_role_prompt_report_in_the_slot_directory(env):
|
|
|
175
175
|
assert role == "implementer" and call["prompt"].strip()
|
|
176
176
|
assert Path(call["cwd"], report).resolve().as_posix().endswith(".agent/implement/01/implementer-0.json")
|
|
177
177
|
assert Path(call["cwd"], report).exists()
|
|
178
|
-
assert call["cwd"] == str(w.repo.resolve()) and call["branch"] == "ticket/01"
|
|
178
|
+
assert call["cwd"] == str(w.repo.resolve()) and call["branch"] == "ticket/feat%2Fcsv/01"
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
def test_default_models_file_names_every_role():
|
|
@@ -231,7 +231,7 @@ def test_unknown_provider_or_effort_exits_3_naming_role_and_entry_before_any_age
|
|
|
231
231
|
proc = w.run({"test-writer": [tw()]})
|
|
232
232
|
assert proc.returncode == 3 and not w.calls("test-writer")
|
|
233
233
|
assert not launches(w.tmp, "claude") and not launches(w.tmp, "codex")
|
|
234
|
-
assert "ticket/01" not in git(w.repo, "branch", "--list")
|
|
234
|
+
assert "ticket/feat%2Fcsv/01" not in git(w.repo, "branch", "--list")
|
|
235
235
|
|
|
236
236
|
|
|
237
237
|
def test_implementer_prompt_carries_pointers_and_only_template_sentences(env):
|
|
@@ -284,7 +284,7 @@ def test_a_model_the_account_cannot_use_fails_the_preflight_with_exit_3_before_a
|
|
|
284
284
|
assert f"model entry openai/gpt-astra@medium: the codex CLI failed the preflight (exit 1): {REFUSED}" \
|
|
285
285
|
in proc.stdout.splitlines(), proc.stdout
|
|
286
286
|
assert not launches(w.tmp, "claude") and not launches(w.tmp, "codex")
|
|
287
|
-
assert "ticket/01" not in git(w.repo, "branch", "--list") and not w.ledger()
|
|
287
|
+
assert "ticket/feat%2Fcsv/01" not in git(w.repo, "branch", "--list") and not w.ledger()
|
|
288
288
|
|
|
289
289
|
|
|
290
290
|
def test_a_cli_that_fails_with_no_report_stops_the_run_with_exit_4_and_the_same_command_resumes(env):
|
|
@@ -368,16 +368,16 @@ def test_head_moved_to_the_pr_branch_resets_the_slot_and_reruns_once(env):
|
|
|
368
368
|
ok(w.run({"test-writer": [tw()], "implementer": [{"git": [["checkout", "feat/csv"]]}, impl()]}))
|
|
369
369
|
first, second = w.calls("implementer")
|
|
370
370
|
tests_sha = w.ticket_commits()[0]
|
|
371
|
-
assert (second["branch"], second["head"]) == ("ticket/01", tests_sha)
|
|
371
|
+
assert (second["branch"], second["head"]) == ("ticket/feat%2Fcsv/01", tests_sha)
|
|
372
372
|
assert any("implementer" in l and "feat/csv" in l for l in w.ledger().splitlines()), w.ledger()
|
|
373
373
|
|
|
374
374
|
|
|
375
375
|
def test_a_missing_ticket_branch_is_a_violation_named_in_the_ledger(env):
|
|
376
376
|
w = World(env)
|
|
377
|
-
gone = {"git": [["checkout", "--detach"], ["branch", "-D", "ticket/01"]]}
|
|
377
|
+
gone = {"git": [["checkout", "--detach"], ["branch", "-D", "ticket/feat%2Fcsv/01"]]}
|
|
378
378
|
proc = w.run({"test-writer": [tw()], "implementer": [gone, impl()]})
|
|
379
379
|
assert "Traceback" not in proc.stderr
|
|
380
|
-
assert any("ticket/01" in l and "branch" in l for l in w.ledger().splitlines()), w.ledger()
|
|
380
|
+
assert any("ticket/feat%2Fcsv/01" in l and "branch" in l for l in w.ledger().splitlines()), w.ledger()
|
|
381
381
|
|
|
382
382
|
|
|
383
383
|
def test_log_next_to_report_and_ledger_records_role_entry_duration_and_cost(env):
|
|
@@ -5,7 +5,7 @@ with and without its bare `origin`, and the rig of test_review with and without
|
|
|
5
5
|
|
|
6
6
|
Interface choices (ticket 08):
|
|
7
7
|
- with no `origin`, the tidied commits sit on the commit the PR branch had when the run started (nothing to rebase
|
|
8
|
-
onto), and `refs/implement/recovery` holds the PR tip from before the tidy, as with an `origin`.
|
|
8
|
+
onto), and `refs/implement-spec/feat%2Fcsv/recovery` holds the PR tip from before the tidy, as with an `origin`.
|
|
9
9
|
- the one ledger sentence about the missing remote names `origin` and says that the rebase and the push were skipped.
|
|
10
10
|
- a refused start (exit 3) prints its reason on stdout or stderr: `origin` for a push that has no remote; for the
|
|
11
11
|
correctness role one line that names `correctness` and `codex`, or `correctness` and each entry of its list.
|
|
@@ -39,7 +39,7 @@ def test_with_no_origin_the_branch_is_tidied_gated_and_reviewed_with_no_rebase_a
|
|
|
39
39
|
rows = rig.commits(rig.start)
|
|
40
40
|
assert [r["subject"] for r in rows] == ["feat(01): Export rows", "feat(02): Quote fields"]
|
|
41
41
|
assert ref(rig.repo, f"{rows[0]['sha']}~1") == rig.start, "no rebase: the ticket commits sit on the branch's start"
|
|
42
|
-
assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
|
|
42
|
+
assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement-spec/feat%2Fcsv/recovery^{tree}")
|
|
43
43
|
records = sorted(p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log"))
|
|
44
44
|
assert records == [f"gate-{tip[:7]}.log"], records
|
|
45
45
|
(gate,) = [g for g in rig.gates() if g["commit"] == tip]
|
|
@@ -99,7 +99,7 @@ def test_an_answers_file_of_an_earlier_run_is_not_applied_to_the_same_question_o
|
|
|
99
99
|
write_answers(rig, {Q1: "overrule-test"})
|
|
100
100
|
# The operator starts again: the run directory goes, the answers file is outside it and stays.
|
|
101
101
|
shutil.rmtree(rig.repo / ".agent/implement")
|
|
102
|
-
git(rig.repo, "branch", "-D", "ticket/01")
|
|
102
|
+
git(rig.repo, "branch", "-D", "ticket/feat%2Fcsv/01")
|
|
103
103
|
again = steps(blocked(), blocked(), impl("01"))
|
|
104
104
|
rig.run(again)
|
|
105
105
|
new = decisions(rig)
|
|
@@ -144,7 +144,7 @@ def test_a_stop_with_uncommitted_work_keeps_its_slot_and_takes_the_answer(tmp_pa
|
|
|
144
144
|
rig.run(steps(dirty, impl("01")), *UNATTENDED)
|
|
145
145
|
assert rig.code == 0, rig.ledger()
|
|
146
146
|
second = rig.calls("01", "implementer")[1]
|
|
147
|
-
assert second["status"] == "M demo/export.py" and second["branch"] == "ticket/01", "the work is in the tree of the rerun"
|
|
147
|
+
assert second["status"] == "M demo/export.py" and second["branch"] == "ticket/feat%2Fcsv/01", "the work is in the tree of the rerun"
|
|
148
148
|
prompt = (rig.folder() / "implementer-0.prompt.md").read_text()
|
|
149
149
|
assert "the locked tests stand as they are" in prompt and "uncommitted work" in prompt
|
|
150
150
|
assert "feat: 01 rows" in rig.pr_subjects() and not git(rig.repo, "status", "--porcelain")
|
|
@@ -221,7 +221,7 @@ def test_a_correction_that_commits_more_than_the_files_it_lists_is_rejected(tmp_
|
|
|
221
221
|
assert rig.code == 1, rig.ledger()
|
|
222
222
|
assert rig.led("Ticket 01: BLOCKED at the test-writer: a correction may change only the files it lists in "
|
|
223
223
|
"`locked`: its commits also change demo/export.py.")
|
|
224
|
-
assert rig.led(restored(OTHER)) and git(rig.repo, "show", f"ticket/01:{OTHER}") == OTHER_TEST
|
|
224
|
+
assert rig.led(restored(OTHER)) and git(rig.repo, "show", f"ticket/feat%2Fcsv/01:{OTHER}") == OTHER_TEST
|
|
225
225
|
assert len(rig.calls("01", "test-writer")) == 3 and len(rig.calls("01", "implementer")) == 1, "one rerun, no implementer"
|
|
226
226
|
assert "its commits also change demo/export.py" in rig.prompt("test-writer-0")
|
|
227
227
|
assert "feat: 01 rows" not in rig.pr_subjects() and rig.state()["tickets"]["01"]["held"] == {}
|
|
@@ -370,7 +370,7 @@ def test_unattended_writes_the_stage_file_and_prints_no_question(tmp_path, capsy
|
|
|
370
370
|
|
|
371
371
|
|
|
372
372
|
def test_unattended_names_the_blocked_tickets_and_needs_a_stage_path_to_write(tmp_path):
|
|
373
|
-
rig = Pushing(tmp_path, ONE, gate={"red": ["ticket/01"]})
|
|
373
|
+
rig = Pushing(tmp_path, ONE, gate={"red": ["ticket/feat%2Fcsv/01"]})
|
|
374
374
|
rig.run(plan(), "--unattended")
|
|
375
375
|
assert rig.code == 1 and not (rig.repo / ".agent/stage.json").exists()
|
|
376
376
|
rig.run(plan(), "--unattended", "--stage-json", ".agent/out/stage.json")
|
|
@@ -4,6 +4,7 @@ import fcntl
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
import sys
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -13,6 +14,7 @@ import pytest
|
|
|
13
14
|
import driver
|
|
14
15
|
import driver_agents
|
|
15
16
|
import driver_report
|
|
17
|
+
import driver_state
|
|
16
18
|
|
|
17
19
|
from conftest import GIT, ticket
|
|
18
20
|
|
|
@@ -33,7 +35,7 @@ def test_a_run_without_dry_run_needs_the_spec(repo, capsys):
|
|
|
33
35
|
repo(t01=ticket("01"))
|
|
34
36
|
assert driver.main(["--gate", "make check"]) == 3
|
|
35
37
|
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
|
|
38
|
+
assert "ticket/feat%2Fx/01" not in subprocess.run(["git", "branch"], capture_output=True, text=True).stdout
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def test_every_environment_and_ticket_error_is_listed_together(repo, capsys):
|
|
@@ -158,7 +160,7 @@ def ledger_of(tmp_path):
|
|
|
158
160
|
|
|
159
161
|
def walked(*short):
|
|
160
162
|
"""The PR tip as the tickets left it: the recovery point of the tidy when there is one, else the tip of feat/x."""
|
|
161
|
-
made = subprocess.run(["git", "rev-parse", *short, "-q", "--verify", "refs/implement/recovery"], capture_output=True, text=True)
|
|
163
|
+
made = subprocess.run(["git", "rev-parse", *short, "-q", "--verify", "refs/implement-spec/feat%2Fx/recovery"], capture_output=True, text=True)
|
|
162
164
|
return made.stdout.strip() or subprocess.run(["git", "rev-parse", *short, "feat/x"], capture_output=True, text=True).stdout.strip()
|
|
163
165
|
|
|
164
166
|
|
|
@@ -242,7 +244,7 @@ def test_a_moved_head_prompt_line_comes_from_its_template(repo, tmp_path, capsys
|
|
|
242
244
|
repo()
|
|
243
245
|
tip = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
|
244
246
|
agents(tmp_path, capsys, "git checkout -q --detach\n")
|
|
245
|
-
line = (driver_report.PROMPTS / "moved.md").read_text().replace("{branch}", "ticket/01").replace("{tip}", tip)
|
|
247
|
+
line = (driver_report.PROMPTS / "moved.md").read_text().replace("{branch}", "ticket/feat%2Fx/01").replace("{tip}", tip)
|
|
246
248
|
assert line in prompt_of(tmp_path) and prompt_of(tmp_path).rstrip().endswith("one ref store.")
|
|
247
249
|
|
|
248
250
|
|
|
@@ -307,10 +309,10 @@ def test_a_timeout_doubles_the_limit_and_a_second_one_blocks(repo, tmp_path, cap
|
|
|
307
309
|
|
|
308
310
|
|
|
309
311
|
@pytest.mark.parametrize("move, problem", [
|
|
310
|
-
("git checkout -q feat/x", "HEAD is on feat/x instead of ticket/01"),
|
|
311
|
-
("git checkout -q --detach", "HEAD is on a detached commit instead of ticket/01"),
|
|
312
|
-
("git checkout -q --detach; git branch -q -D ticket/01", "branch ticket/01 is missing"),
|
|
313
|
-
("git checkout -q -B ticket/01 HEAD~1", "ticket/01 no longer holds"),
|
|
312
|
+
("git checkout -q feat/x", "HEAD is on feat/x instead of ticket/feat%2Fx/01"),
|
|
313
|
+
("git checkout -q --detach", "HEAD is on a detached commit instead of ticket/feat%2Fx/01"),
|
|
314
|
+
("git checkout -q --detach; git branch -q -D ticket/feat%2Fx/01", "branch ticket/feat%2Fx/01 is missing"),
|
|
315
|
+
("git checkout -q -B ticket/feat%2Fx/01 HEAD~1", "ticket/feat%2Fx/01 no longer holds"),
|
|
314
316
|
])
|
|
315
317
|
def test_a_moved_head_resets_the_slot_and_reruns(repo, tmp_path, capsys, move, problem):
|
|
316
318
|
repo()
|
|
@@ -326,7 +328,7 @@ def test_a_moved_head_resets_the_slot_and_reruns(repo, tmp_path, capsys, move, p
|
|
|
326
328
|
|
|
327
329
|
def test_an_existing_ticket_branch_stops_the_run(repo, tmp_path, capsys):
|
|
328
330
|
repo()
|
|
329
|
-
subprocess.run(["git", "branch", "ticket/01"], check=True)
|
|
331
|
+
subprocess.run(["git", "branch", "ticket/feat%2Fx/01"], check=True)
|
|
330
332
|
assert agents(tmp_path, capsys, "exit 0\n") == (3, [])
|
|
331
333
|
|
|
332
334
|
|
|
@@ -357,7 +359,7 @@ echo "$role $nn" >> "${FILE:-work-$nn.txt}"; git add -A; git commit -q -m "$role
|
|
|
357
359
|
echo "$nn $PWD" >> "$0.calls"
|
|
358
360
|
"""
|
|
359
361
|
# Ticket 01 waits until ticket 02 has its branch, so both start from the same PR tip.
|
|
360
|
-
TOGETHER = """[ "$nn" != 01 ] || until git rev-parse -q --verify refs/heads/ticket/02 > /dev/null; do sleep 0.02; done\n"""
|
|
362
|
+
TOGETHER = """[ "$nn" != 01 ] || until git rev-parse -q --verify refs/heads/ticket/feat%2Fx/02 > /dev/null; do sleep 0.02; done\n"""
|
|
361
363
|
REFACTOR = {"seam": "none — refactor"}
|
|
362
364
|
# A fixer that cannot make the red gate green: the ticket blocks at fix round 1.
|
|
363
365
|
GIVE_UP = """[ $role != fixer ] || { printf '{"status": "BLOCKED", "commits": [], "files": [], "concerns": ["the gate stays red"], "green": [], "ticket": "01"}' > "$report"; exit 0; }\n"""
|
|
@@ -437,7 +439,7 @@ def test_a_conflict_agent_that_does_not_resolve_blocks_the_ticket_and_keeps_its_
|
|
|
437
439
|
assert f"BLOCKED at the merge: the conflict agent: report .agent/implement/{number}/conflict-0.json is not" in line
|
|
438
440
|
assert len([line for line in ledger if "conflict ran on" in line]) == 2 # the one rerun of a violation
|
|
439
441
|
assert state_of(tmp_path)["tickets"][number]["phase"] == "blocked"
|
|
440
|
-
assert f"ticket/{number}" in branches() and f"Ticket {number} is not merged: its phase is blocked." in out
|
|
442
|
+
assert f"ticket/feat%2Fx/{number}" in branches() and f"Ticket {number} is not merged: its phase is blocked." in out
|
|
441
443
|
for slot in (".", ".agent/worktrees/slot-2"): # the driver's open merge is gone
|
|
442
444
|
assert out_of("git", "-C", slot, "status", "--porcelain") == ""
|
|
443
445
|
assert out_of("git", "-C", slot, "rev-parse", "-q", "--verify", "MERGE_HEAD") == ""
|
|
@@ -450,7 +452,7 @@ def test_a_conflict_cli_that_fails_stops_the_run_and_leaves_no_open_merge(repo,
|
|
|
450
452
|
assert code == 4 and "with no report: ERROR: not signed in" in out, out
|
|
451
453
|
assert not [line for line in ledger if "BLOCKED" in line]
|
|
452
454
|
(number,) = [n for n, entry in state_of(tmp_path)["tickets"].items() if entry["phase"] == "merge"]
|
|
453
|
-
assert f"ticket/{number}" in branches()
|
|
455
|
+
assert f"ticket/feat%2Fx/{number}" in branches()
|
|
454
456
|
for slot in (".", ".agent/worktrees/slot-2"):
|
|
455
457
|
assert out_of("git", "-C", slot, "status", "--porcelain") == ""
|
|
456
458
|
assert out_of("git", "-C", slot, "rev-parse", "-q", "--verify", "MERGE_HEAD") == ""
|
|
@@ -459,7 +461,7 @@ def test_a_conflict_cli_that_fails_stops_the_run_and_leaves_no_open_merge(repo,
|
|
|
459
461
|
def test_a_red_gate_is_a_finding_and_the_ticket_it_blocks_never_starts(repo, tmp_path, capsys):
|
|
460
462
|
two(repo, second="01")
|
|
461
463
|
code, ledger, out = walk(tmp_path, capsys, GIVE_UP + WORK + REPORT, gate="echo CHECK-OUTPUT; exit 1")
|
|
462
|
-
tip = out_of("git", "rev-parse", "--short=7", "ticket/01")
|
|
464
|
+
tip = out_of("git", "rev-parse", "--short=7", "ticket/feat%2Fx/01")
|
|
463
465
|
assert code == 1 and ledger[-1] == GAVE_UP, out
|
|
464
466
|
assert ledger[1] == (f"Ticket 01: critical finding of kind code from the gate at "
|
|
465
467
|
f".agent/implement/01/gate-{tip}.log:0: the gate is red on {tip}.")
|
|
@@ -487,7 +489,7 @@ def test_a_tree_that_is_not_clean_is_a_red_gate_and_the_slot_keeps_the_work(repo
|
|
|
487
489
|
assert code == 1 and "from the gate at .agent/implement/01/gate-" in ledger[-3] and ledger[-1] == GAVE_UP, out
|
|
488
490
|
record = next((tmp_path / ".agent/implement/01").glob("gate-*.log")).read_text()
|
|
489
491
|
assert "status: not clean\n ?? junk.txt\n" in record and "exit: not run" in record and "\nNEVER\n" not in record
|
|
490
|
-
assert out_of("git", "symbolic-ref", "--short", "HEAD") == "ticket/01" and (tmp_path / "junk.txt").exists()
|
|
492
|
+
assert out_of("git", "symbolic-ref", "--short", "HEAD") == "ticket/feat%2Fx/01" and (tmp_path / "junk.txt").exists()
|
|
491
493
|
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
492
494
|
assert code == 3 and "uncommitted change: junk.txt" in out
|
|
493
495
|
|
|
@@ -524,7 +526,7 @@ def test_a_resumed_tip_that_is_not_on_the_branch_returns_the_ticket_to_implement
|
|
|
524
526
|
base = out_of("git", "rev-parse", "feat/x")
|
|
525
527
|
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
526
528
|
assert code == 0, out
|
|
527
|
-
assert "Ticket 01: returned to implement from the PR tip: the recorded tip 1111111 is not on ticket/01." in ledger
|
|
529
|
+
assert "Ticket 01: returned to implement from the PR tip: the recorded tip 1111111 is not on ticket/feat%2Fx/01." in ledger
|
|
528
530
|
assert out_of("git", "rev-parse", "feat/x~1") == base # the earlier, untrusted commit is not in the PR branch
|
|
529
531
|
|
|
530
532
|
|
|
@@ -537,10 +539,102 @@ def test_a_resumed_ticket_with_a_settled_gate_merges_without_a_gate_or_an_agent(
|
|
|
537
539
|
(tmp_path / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
538
540
|
# A gate on the ticket branch is still red, so a second gate for the settled commit would block the ticket. The
|
|
539
541
|
# one new gate is that of the end of run, on the PR branch.
|
|
540
|
-
code, ledger, out = walk(tmp_path, capsys, "exit 9\n", gate='[ "$(git rev-parse --abbrev-ref HEAD)" != ticket/01 ]')
|
|
542
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n", gate='[ "$(git rev-parse --abbrev-ref HEAD)" != ticket/feat%2Fx/01 ]')
|
|
541
543
|
assert code == 0 and walked() == tip and "| gates run | 2 |" in out
|
|
542
544
|
|
|
543
545
|
|
|
546
|
+
def as_older_driver(tmp_path, *numbers):
|
|
547
|
+
"""Make the run in tmp_path one that an older driver started: no `schema`, and the old names of its ticket
|
|
548
|
+
branches and of its recovery ref."""
|
|
549
|
+
state = state_of(tmp_path)
|
|
550
|
+
del state["run"]["schema"]
|
|
551
|
+
(tmp_path / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
552
|
+
for nn in numbers:
|
|
553
|
+
subprocess.run(["git", "branch", "-m", f"ticket/feat%2Fx/{nn}", f"ticket/{nn}"], check=True)
|
|
554
|
+
if recovery := out_of("git", "rev-parse", "-q", "--verify", "refs/implement-spec/feat%2Fx/recovery"):
|
|
555
|
+
subprocess.run(["git", "update-ref", "refs/implement/recovery", recovery], check=True)
|
|
556
|
+
subprocess.run(["git", "update-ref", "-d", "refs/implement-spec/feat%2Fx/recovery"], check=True)
|
|
557
|
+
return recovery
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_a_run_of_an_older_driver_resumes_on_its_renamed_ticket_branch(repo, tmp_path, capsys):
|
|
561
|
+
"""An older driver named the ticket branch `ticket/NN`. The resume renames it and trusts the recorded tip."""
|
|
562
|
+
entry = blocked_then_edited(repo, tmp_path, capsys, phase="merge")
|
|
563
|
+
state = state_of(tmp_path)
|
|
564
|
+
state["tickets"]["01"]["green_gates"] = [entry["tip"]]
|
|
565
|
+
(tmp_path / ".agent/implement/state.json").write_text(json.dumps(state))
|
|
566
|
+
as_older_driver(tmp_path, "01")
|
|
567
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n")
|
|
568
|
+
assert code == 0, out
|
|
569
|
+
assert "Ticket 01: renamed the branch ticket/01 of an older driver to ticket/feat%2Fx/01." in ledger
|
|
570
|
+
assert not any("returned to implement" in line for line in ledger) and walked() == entry["tip"]
|
|
571
|
+
assert branches() == ["feat/x"]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_a_run_of_an_older_driver_on_a_contradicted_ticket_branch_returns_the_ticket_to_implement(repo, tmp_path,
|
|
575
|
+
capsys):
|
|
576
|
+
"""Spec review: slot 1 is on `ticket/NN` of an older driver, and git contradicts the recorded tip, so the branch
|
|
577
|
+
keeps its old name. The resume accepts slot 1 there, as the older driver did, and starts the ticket again."""
|
|
578
|
+
blocked_then_edited(repo, tmp_path, capsys, phase="review", tip="1" * 40)
|
|
579
|
+
as_older_driver(tmp_path, "01")
|
|
580
|
+
subprocess.run(["git", "checkout", "-q", "ticket/01"], check=True)
|
|
581
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
582
|
+
assert code == 0, out
|
|
583
|
+
assert "Ticket 01: returned to implement from the PR tip: the recorded tip 1111111 is not on ticket/feat%2Fx/01." in ledger
|
|
584
|
+
assert not [line for line in ledger if "renamed" in line]
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def test_a_ticket_branch_of_an_older_driver_that_git_cannot_rename_stops_the_run(repo, tmp_path, capsys):
|
|
588
|
+
blocked_then_edited(repo, tmp_path, capsys)
|
|
589
|
+
as_older_driver(tmp_path, "01")
|
|
590
|
+
subprocess.run(["git", "branch", "ticket/feat%2Fx"], check=True) # a branch where the folder of the new name goes
|
|
591
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n")
|
|
592
|
+
assert code == 3 and out.startswith("implement-spec: cannot rename the branch ticket/01 of an older driver to "
|
|
593
|
+
"ticket/feat%2Fx/01: "), out
|
|
594
|
+
assert "ticket/01" in branches() and not [line for line in ledger if "renamed" in line]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_a_finished_run_of_an_older_driver_gets_the_new_name_of_its_recovery_ref(repo, tmp_path, capsys):
|
|
598
|
+
"""Spec review: the recovery ref moves through the driver, before the end of run reads it."""
|
|
599
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
600
|
+
assert walk(tmp_path, capsys, WORK + REPORT)[0] == 0
|
|
601
|
+
recovery = as_older_driver(tmp_path)
|
|
602
|
+
assert recovery
|
|
603
|
+
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
604
|
+
assert code == 0, out
|
|
605
|
+
assert ("Recovery point: moved refs/implement/recovery of an older driver to refs/implement-spec/feat%2Fx/recovery."
|
|
606
|
+
in (tmp_path / ".agent/implement/ledger.md").read_text().splitlines())
|
|
607
|
+
assert out_of("git", "rev-parse", "-q", "--verify", "refs/implement/recovery") == "" and walked() == recovery
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_no_older_driver_starts_while_a_newer_driver_runs(repo, tmp_path, capsys):
|
|
611
|
+
"""Spec review: the newer driver holds the lock of an older driver, shared, for its whole run. The agent tries to
|
|
612
|
+
take that lock as an older driver does, alone, and cannot."""
|
|
613
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
614
|
+
old_lock = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec.lock"
|
|
615
|
+
probe = tmp_path.parent / f"{tmp_path.name}-probe"
|
|
616
|
+
older = (f'"{sys.executable}" -c "import fcntl; fcntl.flock(open(\'{old_lock}\', \'a\'), '
|
|
617
|
+
f'fcntl.LOCK_EX | fcntl.LOCK_NB)" 2>/dev/null; echo $? > {probe}\n')
|
|
618
|
+
code, _, out = walk(tmp_path, capsys, older + WORK + REPORT)
|
|
619
|
+
assert code == 0 and probe.read_text() == "1\n", out
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@pytest.mark.parametrize("older_run", [False, True])
|
|
623
|
+
def test_a_newer_driver_waits_while_an_older_driver_runs(repo, tmp_path, capsys, older_run):
|
|
624
|
+
"""Correctness review: an older driver holds only `implement-spec.lock`, so a newer driver waits for that lock,
|
|
625
|
+
on a run of its own and on a run that an older driver started."""
|
|
626
|
+
blocked_then_edited(repo, tmp_path, capsys)
|
|
627
|
+
if older_run:
|
|
628
|
+
as_older_driver(tmp_path, "01")
|
|
629
|
+
old_lock = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec.lock"
|
|
630
|
+
with hold(old_lock):
|
|
631
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n")
|
|
632
|
+
assert code == 3 and out == ("implement-spec: a driver of an older version runs in this repository: it holds the "
|
|
633
|
+
f"lock {old_lock}. Wait until it ends, then run the driver again.\n")
|
|
634
|
+
assert ("ticket/01" if older_run else "ticket/feat%2Fx/01") in branches()
|
|
635
|
+
assert not [line for line in ledger if "renamed" in line] and state_of(tmp_path)["tickets"]["01"]["phase"] == "blocked"
|
|
636
|
+
|
|
637
|
+
|
|
544
638
|
def test_an_unknown_phase_fails_the_resume(repo, tmp_path, capsys):
|
|
545
639
|
blocked_then_edited(repo, tmp_path, capsys, phase="polish")
|
|
546
640
|
code, _, out = walk(tmp_path, capsys, "exit 9\n")
|
|
@@ -567,12 +661,23 @@ def test_a_first_run_on_a_detached_head_or_with_no_slot_is_refused(repo, tmp_pat
|
|
|
567
661
|
assert code == 3 and "the PR worktree is on a detached HEAD" in out
|
|
568
662
|
|
|
569
663
|
|
|
664
|
+
def test_a_first_run_in_a_slot_of_another_run_is_refused(repo, tmp_path, capsys):
|
|
665
|
+
"""Correctness review: a slot has no state.json of its own, so its ticket branch would name a lock of its own,
|
|
666
|
+
and two drivers would control one worktree."""
|
|
667
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
668
|
+
subprocess.run(["git", "checkout", "-q", "-b", "ticket/feat%2Fx/01"], check=True)
|
|
669
|
+
code, ledger, out = walk(tmp_path, capsys, "exit 9\n")
|
|
670
|
+
assert (code, ledger) == (3, []) and out == (
|
|
671
|
+
"the PR worktree is on the ticket branch ticket/feat%2Fx/01 of a run: check out the PR branch\n")
|
|
672
|
+
assert not (tmp_path / ".agent/implement").exists()
|
|
673
|
+
|
|
674
|
+
|
|
570
675
|
def test_a_ticket_branch_that_cannot_be_checked_out_blocks_the_ticket(repo, tmp_path, capsys):
|
|
571
676
|
"""A slot of an earlier run with more slots still holds the branch."""
|
|
572
677
|
blocked_then_edited(repo, tmp_path, capsys, phase="implement")
|
|
573
|
-
subprocess.run(["git", "worktree", "add", "-q", str(tmp_path.parent / f"{tmp_path.name}-other"), "ticket/01"], check=True)
|
|
678
|
+
subprocess.run(["git", "worktree", "add", "-q", str(tmp_path.parent / f"{tmp_path.name}-other"), "ticket/feat%2Fx/01"], check=True)
|
|
574
679
|
code, ledger, _ = walk(tmp_path, capsys, "exit 9\n")
|
|
575
|
-
assert code == 1 and "BLOCKED at the start: cannot check out ticket/01 in slot 1" in ledger[-1]
|
|
680
|
+
assert code == 1 and "BLOCKED at the start: cannot check out ticket/feat%2Fx/01 in slot 1" in ledger[-1]
|
|
576
681
|
|
|
577
682
|
|
|
578
683
|
def test_an_interrupt_kills_the_running_agents_and_stops_the_driver_at_once(repo, tmp_path, capsys, monkeypatch):
|
|
@@ -599,15 +704,62 @@ def test_an_interrupt_kills_the_running_agents_and_stops_the_driver_at_once(repo
|
|
|
599
704
|
assert "interrupted; run it again to resume" in capsys.readouterr().out
|
|
600
705
|
|
|
601
706
|
|
|
602
|
-
def
|
|
707
|
+
def hold(lock: Path):
|
|
708
|
+
"""The lock of another driver: it holds the kernel lock while it runs."""
|
|
709
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
710
|
+
held = lock.open("w")
|
|
711
|
+
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
712
|
+
return held
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def test_a_driver_on_another_pr_branch_does_not_stop_the_run(repo, tmp_path, capsys):
|
|
716
|
+
"""Runs on different PR branches share no state.json, no slot and no ref, so the lock of one stops no other."""
|
|
717
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
718
|
+
locks = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec"
|
|
719
|
+
with hold(locks / "feat%2Fother.lock"), hold(locks / "feat.lock"):
|
|
720
|
+
code, _, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
721
|
+
assert code == 0 and "1 of 1 tickets are merged into feat/x." in out
|
|
722
|
+
assert branches() == ["feat/x"] # the merge deleted ticket/feat%2Fx/01
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_a_driver_that_starts_while_the_first_starts_its_run_locks_the_branch_of_that_run(repo, monkeypatch):
|
|
726
|
+
"""Correctness review: right after the second driver reads HEAD, the first one writes state.json and puts slot 1
|
|
727
|
+
on a ticket branch. The PR branch that the second driver locks is still the one of that run."""
|
|
728
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
729
|
+
real = driver.driver_slots.on_branch
|
|
730
|
+
|
|
731
|
+
def first_driver_moves(slot):
|
|
732
|
+
here = real(slot)
|
|
733
|
+
driver.STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
driver.STATE.write_text(json.dumps({"run": {"pr_branch": "feat/x"}}))
|
|
735
|
+
subprocess.run(["git", "checkout", "-q", "-b", "ticket/feat%2Fx/01"], check=True)
|
|
736
|
+
return here
|
|
737
|
+
monkeypatch.setattr(driver.driver_slots, "on_branch", first_driver_moves)
|
|
738
|
+
assert driver._run_branch() == "feat/x"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def test_a_run_that_leaves_its_branch_while_the_driver_takes_the_lock_exits_3(repo, tmp_path, capsys, monkeypatch):
|
|
742
|
+
repo(t01=ticket("01", **REFACTOR))
|
|
743
|
+
real = driver_state.lock
|
|
744
|
+
|
|
745
|
+
def lock_while_another_run_starts(path, shared=False):
|
|
746
|
+
driver.STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
driver.STATE.write_text(json.dumps({"run": {"pr_branch": "feat/other", "schema": driver_state.SCHEMA}}))
|
|
748
|
+
return real(path, shared)
|
|
749
|
+
monkeypatch.setattr(driver_state, "lock", lock_while_another_run_starts)
|
|
750
|
+
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
751
|
+
assert (code, ledger) == (3, []) and out == (
|
|
752
|
+
"implement-spec: the run in this worktree left feat/x while the driver took its lock: run the driver again\n")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_a_second_driver_on_the_same_pr_branch_exits_3_and_changes_nothing(repo, tmp_path, capsys):
|
|
603
756
|
"""Final review, correctness 1: two drivers on one state.json lose updates and move the same refs. Ticket 09:
|
|
604
757
|
the lock is in the git common directory, so it is the same file in every worktree of the repository."""
|
|
605
758
|
repo(t01=ticket("01", **REFACTOR))
|
|
606
|
-
lock = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec.lock"
|
|
607
|
-
with lock
|
|
608
|
-
fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
759
|
+
lock = Path(out_of("git", "rev-parse", "--git-common-dir")).resolve() / "implement-spec/feat%2Fx.lock"
|
|
760
|
+
with hold(lock):
|
|
609
761
|
code, ledger, out = walk(tmp_path, capsys, WORK + REPORT)
|
|
610
|
-
assert (code, out) == (3, "implement-spec: another driver runs
|
|
762
|
+
assert (code, out) == (3, "implement-spec: another driver runs on feat/x: it holds the lock "
|
|
611
763
|
f"{lock}. Wait until it ends, then run the driver again.\n")
|
|
612
764
|
assert ledger == [] and not (tmp_path / ".agent/implement").exists() and branches() == ["feat/x"]
|
|
613
765
|
assert not Path(f"{tmp_path}-runner.calls").exists()
|
|
@@ -755,7 +907,11 @@ def test_a_merged_ticket_whose_commits_left_the_pr_branch_is_not_merged_on_a_rer
|
|
|
755
907
|
assert code == 1 and f"End of run: BLOCKED at feat/x does not hold the tidied tip {tip}" in out, out
|
|
756
908
|
|
|
757
909
|
|
|
758
|
-
|
|
910
|
+
@pytest.mark.parametrize("older_driver", [False, True])
|
|
911
|
+
def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_role(repo, tmp_path, capsys, monkeypatch,
|
|
912
|
+
older_driver):
|
|
913
|
+
"""With `older_driver`, the interrupted run is one whose driver named the ticket branches `ticket/NN` (spec
|
|
914
|
+
review): the resume renames each one, also in the slot that has it, and the slot keeps its work."""
|
|
759
915
|
two(repo)
|
|
760
916
|
mark = f"{tmp_path.parent}/{tmp_path.name}-started"
|
|
761
917
|
|
|
@@ -776,6 +932,8 @@ def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_r
|
|
|
776
932
|
walk(tmp_path, capsys, hang)
|
|
777
933
|
slot2 = tmp_path / ".agent/worktrees/slot-2"
|
|
778
934
|
assert (tmp_path / "wip-01.txt").exists() and (slot2 / "wip-02.txt").exists()
|
|
935
|
+
if older_driver:
|
|
936
|
+
as_older_driver(tmp_path, "01", "02")
|
|
779
937
|
resume = ('grep -q "was interrupted" "$prompt" || exit 9\nnn=$(basename "$(dirname "$report")")\n'
|
|
780
938
|
'[ "$(cat wip-$nn.txt)" = half ] || exit 8\n')
|
|
781
939
|
code, ledger, out = walk(tmp_path, capsys, resume + WORK + REPORT)
|
|
@@ -785,6 +943,8 @@ def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_r
|
|
|
785
943
|
assert out_of("git", "show", "feat/x:wip-01.txt") == "half" and out_of("git", "show", "feat/x:wip-02.txt") == "half"
|
|
786
944
|
assert [line for line in ledger if "Ticket 02: resumed in slot 2 with the work the interrupted implementer left" in line]
|
|
787
945
|
assert out_of("git", "status", "--porcelain") == "" and branches() == ["feat/x"]
|
|
946
|
+
renamed = [f"Ticket {nn}: renamed the branch ticket/{nn} of an older driver to ticket/feat%2Fx/{nn}." for nn in ("01", "02")]
|
|
947
|
+
assert [line for line in ledger if "renamed the branch" in line] == (renamed if older_driver else [])
|
|
788
948
|
|
|
789
949
|
|
|
790
950
|
def test_the_preflight_asks_each_used_entry_once_and_names_each_cli_that_fails(tmp_path, monkeypatch):
|
|
@@ -10,7 +10,7 @@ Interface choices (ticket 06):
|
|
|
10
10
|
the rebase and the push (ticket 08): test_amendments.py holds those tests.
|
|
11
11
|
- its agents report into `.agent/implement/final/`, so the fake runner keys them `final/final-reviewer`,
|
|
12
12
|
`final/fixer` and `final/conflict`; gate records there are `gate-<first 7 hex>.log`.
|
|
13
|
-
- recovery point: before the rebase the driver sets `refs/implement/recovery` to the PR tip and writes the ledger
|
|
13
|
+
- recovery point: before the rebase the driver sets `refs/implement-spec/feat%2Fcsv/recovery` to the PR tip and writes the ledger
|
|
14
14
|
sentence "Recovery point: ... <sha>". An ended run leaves the ref in place.
|
|
15
15
|
- a tidied commit has the subject `feat(NN): <ticket title>` and a body line `Ticket: <ticket file name>`.
|
|
16
16
|
- the final fixer's `commits` hold `{"sha", "ticket"}` objects, and the driver renames each fixer commit to
|
|
@@ -44,7 +44,7 @@ if step.get("hold"):
|
|
|
44
44
|
before = git("rev-parse", "feat/csv")
|
|
45
45
|
time.sleep(step["hold"])
|
|
46
46
|
rec["held"] = dict(before=before, after=git("rev-parse", "feat/csv"),
|
|
47
|
-
recovery=git("rev-parse", "--verify", "-q", "refs/implement/recovery"))
|
|
47
|
+
recovery=git("rev-parse", "--verify", "-q", "refs/implement-spec/feat%2Fcsv/recovery"))
|
|
48
48
|
for cmd in step.get("git", []):''', 1).replace(
|
|
49
49
|
'sha = ""\nif "commit" in step:',
|
|
50
50
|
'''sha = ""
|
|
@@ -142,7 +142,7 @@ class Final(Review):
|
|
|
142
142
|
return ref(self.repo, "feat/csv")
|
|
143
143
|
|
|
144
144
|
def recovery(self):
|
|
145
|
-
return ref(self.repo, "refs/implement/recovery")
|
|
145
|
+
return ref(self.repo, "refs/implement-spec/feat%2Fcsv/recovery")
|
|
146
146
|
|
|
147
147
|
def commits(self, base, tip="feat/csv"):
|
|
148
148
|
raw = git(self.repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%s%x1f%b%x1e", f"{base}..{tip}")
|
|
@@ -223,7 +223,7 @@ def test_seven_commits_in_merge_order_are_tidied_into_three_ticket_commits_with_
|
|
|
223
223
|
rows = rig.commits(rig.start)
|
|
224
224
|
assert [r["subject"] for r in rows] == [f"feat({nn}): {TITLES[nn]}" for nn in ("01", "03", "02")]
|
|
225
225
|
assert all(f"Ticket: {FILES[nn]}" in r["body"] for nn, r in zip(("01", "03", "02"), rows))
|
|
226
|
-
assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
|
|
226
|
+
assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement-spec/feat%2Fcsv/recovery^{tree}")
|
|
227
227
|
|
|
228
228
|
|
|
229
229
|
def test_one_gate_record_exists_for_the_tidied_tip_and_the_final_reviewer_is_pointed_at_it(story):
|
|
@@ -440,7 +440,7 @@ def test_unattended_with_everything_validated_pushes_once_opens_the_pr_and_write
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
def test_unattended_with_a_blocked_ticket_pushes_nothing_and_exits_1(tmp_path):
|
|
443
|
-
rig = Final(tmp_path, THREE, gate={"red": ["ticket/01"]})
|
|
443
|
+
rig = Final(tmp_path, THREE, gate={"red": ["ticket/feat%2Fcsv/01"]})
|
|
444
444
|
rig.run(plan(), *UNATTENDED)
|
|
445
445
|
assert rig.code == 1, rig.output()
|
|
446
446
|
assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv")
|