@jesdi/skills 0.0.44 → 0.0.46

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 (30) hide show
  1. package/package.json +1 -1
  2. package/skills/implement-spec/ARCHITECTURE.md +13 -6
  3. package/skills/implement-spec/README.md +9 -6
  4. package/skills/implement-spec/driver.py +82 -40
  5. package/skills/implement-spec/driver_final.py +9 -8
  6. package/skills/implement-spec/driver_fixloop.py +4 -1
  7. package/skills/implement-spec/driver_gate.py +5 -1
  8. package/skills/implement-spec/driver_legacy.py +92 -0
  9. package/skills/implement-spec/driver_merge.py +1 -5
  10. package/skills/implement-spec/driver_names.py +32 -0
  11. package/skills/implement-spec/driver_review.py +24 -2
  12. package/skills/implement-spec/driver_run.py +24 -13
  13. package/skills/implement-spec/driver_slots.py +13 -0
  14. package/skills/implement-spec/driver_state.py +12 -13
  15. package/skills/implement-spec/prompts/correctness.md +2 -0
  16. package/skills/implement-spec/prompts/final-reviewer.md +3 -1
  17. package/skills/implement-spec/prompts/pr-writer.md +1 -0
  18. package/skills/implement-spec/prompts/reviewer.md +2 -0
  19. package/skills/implement-spec/tests/test_agents.py +10 -10
  20. package/skills/implement-spec/tests/test_amendments.py +2 -2
  21. package/skills/implement-spec/tests/test_decisions.py +4 -4
  22. package/skills/implement-spec/tests/test_driver.py +183 -23
  23. package/skills/implement-spec/tests/test_final.py +5 -5
  24. package/skills/implement-spec/tests/test_fixloop.py +22 -5
  25. package/skills/implement-spec/tests/test_landing.py +26 -23
  26. package/skills/implement-spec/tests/test_legacy.py +139 -0
  27. package/skills/implement-spec/tests/test_stops.py +1 -1
  28. package/skills/implement-spec/tests/test_tidy.py +2 -2
  29. package/skills/implement-spec/tests/test_walk.py +10 -10
  30. package/skills-manifest.json +3 -3
@@ -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 test_a_second_driver_on_the_same_repository_exits_3_and_changes_nothing(repo, tmp_path, capsys):
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.open("w") as other: # the first driver: it holds the kernel lock while it runs
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 in this repository: it holds the lock "
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
- def test_a_rerun_after_an_interrupt_keeps_the_work_in_each_slot_and_reruns_the_role(repo, tmp_path, capsys, monkeypatch):
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")
@@ -426,7 +426,7 @@ def test_four_rounds_then_blocked_with_the_escalation_at_round_3(tmp_path):
426
426
  assert rig.ledger().count(f"Ticket 01 round 3: escalated to {FABLE}.") == 1
427
427
  assert [len(rig.led("fixer ran on", entry)) for entry in (OPUS, FABLE)] == [2, 2]
428
428
  tips = [rig.sha("01", "implementer")] + [call["sha"] for call in rig.calls("01", "fixer")]
429
- assert [gate["commit"] for gate in rig.gates() if gate["branch"] == "ticket/01"] == tips
429
+ assert [gate["commit"] for gate in rig.gates() if gate["branch"] == "ticket/feat%2Fcsv/01"] == tips
430
430
  assert rig.state()["tickets"]["01"]["tip"] == tips[-1] and not rig.calls("02")
431
431
  assert rig.state()["tickets"]["03"]["phase"] == "merged"
432
432
 
@@ -533,13 +533,13 @@ def test_a_timed_out_read_only_role_is_restored_only_when_no_other_agent_is_in_t
533
533
 
534
534
  DIRTY = [({"files": {"junk.txt": "x\n", "demo/export.py": "mutated\n"}},
535
535
  "the tree is not as the review found it: demo/export.py, junk.txt"),
536
- ({"git": [["checkout", "-q", "--detach"]]}, "the slot is no longer on ticket/01 at "),
537
- ({"commit": "sneak", "files": {"demo/export.py": "x = 2\n"}}, "the slot is no longer on ticket/01 at ")]
536
+ ({"git": [["checkout", "-q", "--detach"]]}, "the slot is no longer on ticket/feat%2Fcsv/01 at "),
537
+ ({"commit": "sneak", "files": {"demo/export.py": "x = 2\n"}}, "the slot is no longer on ticket/feat%2Fcsv/01 at ")]
538
538
 
539
539
 
540
540
  def slot_state(rig):
541
541
  git = lambda *args: subprocess.run(["git", "-C", str(rig.repo), *args], capture_output=True, text=True).stdout.strip()
542
- return git("status", "--porcelain"), git("rev-parse", "ticket/01")
542
+ return git("status", "--porcelain"), git("rev-parse", "ticket/feat%2Fcsv/01")
543
543
 
544
544
 
545
545
  @pytest.mark.parametrize("step, left", DIRTY)
@@ -550,7 +550,7 @@ def test_a_reviewer_that_changes_the_slot_is_run_once_more_on_the_restored_tip(t
550
550
  (line,) = rig.led("Ticket 01: a read-only role broke its rule:")
551
551
  assert left in line and line.endswith(f"the driver restored the slot to {tip[:7]} and runs the review again.")
552
552
  first, second = rig.calls("01", "reviewer")
553
- assert (second["branch"], second["head"], second["status"]) == ("ticket/01", tip, "")
553
+ assert (second["branch"], second["head"], second["status"]) == ("ticket/feat%2Fcsv/01", tip, "")
554
554
  assert rig.tip() == tip and not (rig.repo / "junk.txt").exists()
555
555
 
556
556
 
@@ -562,6 +562,23 @@ def test_a_second_review_that_changes_the_slot_blocks_the_ticket_with_the_slot_c
562
562
  assert slot_state(rig) == ("", rig.sha("01", "implementer")) and "feat: 01 rows" not in rig.pr_subjects()
563
563
 
564
564
 
565
+ def test_a_reviewer_that_writes_the_shared_git_config_gets_it_restored_and_runs_once_more(tmp_path):
566
+ # Issue #94: a reviewer ran git with GIT_DIR on the real git dir and left core.worktree on a deleted temp dir.
567
+ rig = Inside(tmp_path)
568
+ shared = rig.repo / ".git" / "config"
569
+ config = shared.read_bytes()
570
+ gone = {"git": [["config", "core.worktree", str(tmp_path / "gone")]]}
571
+ rig.run(plan(**{"01/reviewer": [{**review("01"), **gone}, review("01")]}))
572
+ assert rig.code == 0, rig.ledger()
573
+ assert shared.read_bytes() == config
574
+ (line,) = rig.led("Ticket 01: a read-only role broke its rule:")
575
+ assert f"the shared git config {shared.resolve()} changed" in line
576
+ assert line.endswith("the driver restored the shared git config and runs the review again.")
577
+ tip, prompt = rig.sha("01", "implementer"), rig.prompt("reviewer-0")
578
+ assert len(rig.calls("01", "reviewer")) == 2 and rig.tip() == tip
579
+ assert f".agent/implement/01/gate-{tip[:7]}.log" in prompt and "never write with `git config`" in prompt
580
+
581
+
565
582
  def test_a_reviewer_that_times_out_starts_again_from_the_clean_tip(tmp_path):
566
583
  hang = {"files": {"demo/export.py": "mutated\n", "junk.txt": "x\n"}, "sleep": 60}
567
584
  rig = Inside(tmp_path).run(plan(**{"01/reviewer": [hang, review("01")]}), "--timeout", "reviewer=0.03")
@@ -1,7 +1,7 @@
1
- """Ticket 09 acceptance tests: locked tests are checked on the merge result; one driver per repository.
1
+ """Ticket 09 acceptance tests: locked tests are checked on the merge result; one driver per PR branch.
2
2
 
3
3
  All through `sh run.sh --runner <script> --parallel 2` on the three-ticket fixture repository (the rigs of test_review
4
- and test_final), and through two worktrees of one fixture repository for the run lock. Tickets are ordered with signal
4
+ and test_final), and through two worktrees of one fixture repository, on two PR branches, for the run lock. Tickets are ordered with signal
5
5
  files and with `wait_pr_moves`, never with a sleep.
6
6
 
7
7
  Interface choices (ticket 09):
@@ -17,9 +17,8 @@ Interface choices (ticket 09):
17
17
  commit stays open until the reviewer of the other ticket ended, plus a bounded time for that ticket to reach its
18
18
  landing. Either order of the two landings is correct, and so is a driver that gives the stale merge commit up;
19
19
  a landing after the lock with the weakened file is not, and a landing that is lost is not.
20
- - the lock sentence is the whole stdout of the second driver, one line, and it names the lock by a path that exists
21
- when it is resolved in the second worktree (an absolute path, or a path relative to that worktree).
22
- - "the second one starts" and "the lock is released": a driver in the other worktree runs its one ticket to exit 0.
20
+ - a driver on another PR branch in another worktree runs its one ticket to exit 0 while the first driver runs, and
21
+ changes no file and no ref of the first run. The lock of the same PR branch is in test_driver.
23
22
  - "never reported as an uncommitted change": no line of `git status --short` in either worktree has `lock` in it.
24
23
  """
25
24
  import json
@@ -30,6 +29,8 @@ import time
30
29
 
31
30
  import pytest
32
31
 
32
+ from driver_names import Names
33
+
33
34
  from test_cli import RUN, git, models_file, snapshot, three, ticket
34
35
  from test_final import Final
35
36
  from test_review import HOOKED, Review, cli
@@ -111,14 +112,14 @@ def test_an_implementer_that_weakens_the_locked_test_of_a_merged_ticket_lands_on
111
112
  # ---------------------------------------------------------------- a ticket branch that takes the PR branch in
112
113
 
113
114
  def takes_in(*more):
114
- """03's implementer in slot 2: it waits until the PR branch moved, merges it into ticket/03, then commits."""
115
+ """03's implementer in slot 2: it waits until the PR branch moved, merges it into ticket/feat%2Fcsv/03, then commits."""
115
116
  return {**impl("03", "demo/cli.py"), "wait_pr_moves": True, "git": [["merge", "-q", "--no-edit", "feat/csv"], *more]}
116
117
 
117
118
 
118
119
  def test_a_ticket_that_merged_the_pr_branch_into_itself_lands_with_the_tests_of_the_merged_ticket(tmp_path):
119
120
  rig = Review(tmp_path, three()).run(plan(**{"03/implementer": [takes_in()], "03/fixer": NO_REPORT}))
120
121
  (call,) = rig.calls("03", "implementer")
121
- assert call["cwd"] == str(rig.slot2) and holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/03"
122
+ assert call["cwd"] == str(rig.slot2) and holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/feat%2Fcsv/03"
122
123
  assert not rig.led("locked file changed"), rig.ledger()
123
124
  assert rig.code == 0, rig.output()
124
125
  assert phase(rig, "03") == "merged" and on_pr(rig, T01) == LOCKED_01 and on_pr(rig, T03) == LOCKED_03
@@ -128,7 +129,7 @@ def test_a_ticket_that_merged_the_pr_branch_and_deleted_a_locked_test_of_the_mer
128
129
  rig = Review(tmp_path, three())
129
130
  rig.run(plan(**{"03/implementer": [takes_in(["rm", "-q", T01])], "03/fixer": NO_REPORT}))
130
131
  (call,) = rig.calls("03", "implementer")
131
- assert holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/03"
132
+ assert holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/feat%2Fcsv/03"
132
133
  assert rig.code == 0 and restored(rig, "03", T01), rig.output()
133
134
  assert phase(rig, "01") == "merged" and on_pr(rig, T01) == LOCKED_01
134
135
 
@@ -297,44 +298,46 @@ def until(done, seconds=30):
297
298
  time.sleep(0.02)
298
299
 
299
300
 
300
- def world(*rigs):
301
- """Every file of each worktree, and every ref and worktree HEAD of the one repository."""
302
- return ([snapshot(r.repo) for r in rigs], git(rigs[0].repo, "for-each-ref"), git(rigs[0].repo, "worktree", "list"))
303
-
304
-
305
301
  def no_lock_is_dirty(*rigs):
306
302
  for rig in rigs:
307
303
  status = git(rig.repo, "status", "--short")
308
304
  assert "lock" not in status, status
309
305
 
310
306
 
311
- def test_a_second_driver_in_another_worktree_exits_3_naming_the_lock_and_starts_when_the_first_ended(tmp_path):
307
+ def first_run_refs(rig):
308
+ """The refs of the run on feat/csv: its PR branch, its ticket branches and its recovery ref."""
309
+ names = Names("feat/csv")
310
+ refs = git(rig.repo, "for-each-ref", "--format=%(refname) %(objectname)").splitlines()
311
+ return [ref for ref in refs if ref.split()[0] in ("refs/heads/feat/csv", names.recovery)
312
+ or ref.startswith(f"refs/heads/{names.ticket('')}")]
313
+
314
+
315
+ def test_a_driver_on_another_pr_branch_in_another_worktree_runs_while_the_first_runs(tmp_path):
316
+ """Runs on different PR branches share no state.json, no slot and no ref: the second one ends while the first
317
+ holds both its slots, and leaves each file and each ref of the first as it was."""
312
318
  first, second = pair(tmp_path)
313
319
  held = {f"{nn}/test-writer": [{**tw(nn), "wait": ["go"]}] for nn in ("01", "03")}
314
320
  proc = start(first, plan(**held))
315
321
  try:
316
322
  until(lambda: len(first.calls(role="test-writer")) == 2) # the first driver holds both its slots in a role
317
- before = world(first, second)
323
+ before = (snapshot(first.repo), first_run_refs(first))
324
+ assert [ref for ref in before[1] if "/ticket/" in ref], before[1] # the first run has its ticket branches
318
325
  second.run(OTHER)
319
- assert second.code == 3, second.output()
320
- (sentence,) = second.out.splitlines()
321
- named = [word.rstrip(".,;:") for word in sentence.split() if "/" in word or word.rstrip(".,;:").endswith("lock")]
322
- assert "lock" in sentence and any((second.repo / word).is_file() for word in named), sentence
323
- assert world(first, second) == before and not second.calls()
326
+ assert second.code == 0 and second.calls("04", "implementer"), second.output()
327
+ assert (snapshot(first.repo), first_run_refs(first)) == before
328
+ assert not git(second.repo, "branch", "--list", "ticket/feat%2Fother/*") # the merge deleted ticket 04's branch
324
329
  no_lock_is_dirty(first, second)
325
330
  (first.rig / "sig/go").touch()
326
331
  out, _ = proc.communicate(timeout=120)
327
332
  finally:
328
333
  ended(proc)
329
334
  assert proc.returncode == 0, out
330
- second.run(OTHER)
331
- assert second.code == 0 and second.calls("04", "implementer"), second.output()
332
335
  no_lock_is_dirty(first, second)
333
336
 
334
337
 
335
338
  ENDS = {
336
339
  0: (plan(), {}, ()),
337
- 1: (plan(), {"red": ["ticket/01"]}, ()),
340
+ 1: (plan(), {"red": ["ticket/feat%2Fcsv/01"]}, ()),
338
341
  2: (plan(**{"01/implementer": [contradicted()]}), {}, ()),
339
342
  3: (plan(), {}, ("--prepare", "false")), # slot 2 cannot be made: the run has started and holds the lock
340
343
  130: (plan(**{"01/test-writer": [{**tw("01"), "wait": ["never"]}]}), {}, ()),