@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.
- package/package.json +1 -1
- package/skills/implement-spec/ARCHITECTURE.md +13 -6
- package/skills/implement-spec/README.md +9 -6
- package/skills/implement-spec/driver.py +82 -40
- package/skills/implement-spec/driver_final.py +9 -8
- package/skills/implement-spec/driver_fixloop.py +4 -1
- package/skills/implement-spec/driver_gate.py +5 -1
- 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 +24 -2
- package/skills/implement-spec/driver_run.py +24 -13
- package/skills/implement-spec/driver_slots.py +13 -0
- package/skills/implement-spec/driver_state.py +12 -13
- package/skills/implement-spec/prompts/correctness.md +2 -0
- package/skills/implement-spec/prompts/final-reviewer.md +3 -1
- package/skills/implement-spec/prompts/pr-writer.md +1 -0
- package/skills/implement-spec/prompts/reviewer.md +2 -0
- 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 +22 -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
package/package.json
CHANGED
|
@@ -21,7 +21,9 @@ A comment in the code is for a line that looks wrong or removable without it.
|
|
|
21
21
|
| `driver_merge.py` | Merges by git plumbing, the locked-file check of a landing, the move of the PR ref |
|
|
22
22
|
| `driver_gate.py` | One run of the gate command and its record |
|
|
23
23
|
| `driver_slots.py` | The worktrees that tickets run in, and the `git` helper |
|
|
24
|
-
| `driver_state.py` | `state.json` (the resume contract) and the lock
|
|
24
|
+
| `driver_state.py` | `state.json` (the resume contract) and the kernel lock |
|
|
25
|
+
| `driver_names.py` | The names of one run: its ticket branches, its recovery ref and its lock file |
|
|
26
|
+
| `driver_legacy.py` | The take-over of a run that an older driver started; delete it when no such run is left |
|
|
25
27
|
| `driver_decisions.py`, `driver_answers.py`, `driver_review_page.py` | Operator questions, their answers, the page that shows them |
|
|
26
28
|
| `driver_tidy.py`, `driver_summary.py` | The history with one commit per ticket; `summary.md` |
|
|
27
29
|
|
|
@@ -89,15 +91,17 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
89
91
|
- The gate runs only on a commit the driver is about to trust, and one green record settles that commit.
|
|
90
92
|
A gate on a dirty tree, or one that leaves the tree changed or HEAD moved, is red.
|
|
91
93
|
- The end of run rewrites the branch one time, before the first final review, and never after a review. A history
|
|
92
|
-
that origin has is never rewritten. `refs/implement
|
|
94
|
+
that origin has is never rewritten. `refs/implement-spec/<key>/recovery` holds the tip before the rewrite.
|
|
93
95
|
- A rewrite never changes a file: the driver compares trees (`driver_tidy.same_tree`) before it moves the branch.
|
|
94
96
|
- The push sends the reviewed commit, not the branch name, with no force.
|
|
95
97
|
|
|
96
98
|
### Work is never dropped
|
|
97
99
|
|
|
98
100
|
- A role that commits keeps its tree after a timeout or an interrupt; its rerun continues from it.
|
|
99
|
-
- A read-only role (reviewer, correctness, final-reviewer, pr-writer) has no work to keep. When it changes its slot
|
|
100
|
-
driver restores
|
|
101
|
+
- A read-only role (reviewer, correctness, final-reviewer, pr-writer) has no work to keep. When it changes its slot or
|
|
102
|
+
the shared git config, the driver restores them and runs the review one more time (`Run.read_only`). The config is
|
|
103
|
+
a byte copy taken before the role (`driver_review.shared_config`): every worktree reads that one file, so a key a
|
|
104
|
+
role writes there, such as a `core.worktree` on a temp directory, breaks git in all of them.
|
|
101
105
|
- A slot with uncommitted work of an interrupted or parked ticket is kept for that ticket (`driver_state.kept`).
|
|
102
106
|
- A blocked ticket keeps its slot when the slot is not clean.
|
|
103
107
|
|
|
@@ -110,8 +114,11 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
110
114
|
`finish_interrupted_move` completes it at the next start.
|
|
111
115
|
- At a resume, git must confirm each recorded tip (`driver_state.verified`). A ticket that git contradicts goes
|
|
112
116
|
back to `implement` from its test-writer commit.
|
|
113
|
-
- The lock of one driver per
|
|
114
|
-
it when the process ends, so it is never stale.
|
|
117
|
+
- The lock of one driver per PR branch is a kernel lock on a file in the git common directory. The kernel frees
|
|
118
|
+
it when the process ends, so it is never stale. Every ref that a run writes has the PR branch in its name
|
|
119
|
+
as one escaped name component. `driver_names.Names` (`Run.names`) makes every name of a run, so runs on
|
|
120
|
+
different PR branches share no ref. `driver_legacy.resume` takes over a run that an older driver started, once,
|
|
121
|
+
after the lock and before any check reads a ref.
|
|
115
122
|
|
|
116
123
|
### Agents
|
|
117
124
|
|
|
@@ -23,7 +23,9 @@ Before any agent starts, the driver checks these. Each failed check prints one l
|
|
|
23
23
|
|
|
24
24
|
A run that stopped is continued with the same command. `state.json` is the only input of a resume.
|
|
25
25
|
|
|
26
|
-
One driver runs
|
|
26
|
+
One driver runs on a PR branch at a time. The driver holds a kernel file lock on `implement-spec/<key>.lock` in the git common directory (`git rev-parse --git-common-dir`) for the whole run. `<key>` is the PR branch as one name component: `%` becomes `%25` and `/` becomes `%2F`, so `feat/x` gives `feat%2Fx`. Each worktree of the repository shares that directory, so a second driver on the same PR branch, in the same worktree or in another one, ends with exit 3 and names the lock file. Drivers on different PR branches run at the same time: each ticket branch is `ticket/<key>/<NN>` and the recovery ref is `refs/implement-spec/<key>/recovery`, so two runs share no ref. Git refs are a tree, and one component per PR branch keeps the refs of one run from being a folder of the refs of another. The file is not in a worktree, so `git status` never shows it. The lock ends with its process, so a driver that was killed leaves no lock behind. `--dry-run` takes no lock. A wrong call in a worktree that has no `.agent/implement/` takes no lock and makes no file; where an earlier run left that folder, the driver takes the lock first, then makes the checks.
|
|
27
|
+
|
|
28
|
+
A driver before this change named the ticket branches `ticket/<NN>` and the recovery ref `refs/implement/recovery`. An older driver holds `implement-spec.lock` in the git common directory alone for its whole run. Each newer driver also holds that file, shared, for its whole run: newer drivers do not stop each other, but while an older driver runs in the repository, a newer one ends with exit 3 and names that lock, and while a newer one runs, no older driver starts. A run that the newer driver started has `schema` 2 in `state.json`. When the newer driver resumes a run with no `schema`, it renames each `ticket/<NN>` that holds the start commit of the run and the tip that the run recorded, and moves `refs/implement/recovery` when it holds the recovery point of the run. It writes one ledger sentence for each, and then `schema`. A ref that git cannot rename ends the run with exit 3 and git's error, and nothing is lost.
|
|
27
29
|
|
|
28
30
|
## Flags
|
|
29
31
|
|
|
@@ -56,7 +58,7 @@ Default timeouts in minutes: explorer 30, test-writer 30, implementer 90, review
|
|
|
56
58
|
| 0 | Done: every ticket is merged and no question is open. Also the end of a valid `--dry-run`. In local mode without `--push`, nothing is pushed. |
|
|
57
59
|
| 1 | A ticket is BLOCKED, or the end of run is BLOCKED. The other tickets ran as far as they could. |
|
|
58
60
|
| 2 | Parked: a question of kind `locked-test` or `final-finding` is open. Exit 2 wins over exit 1. |
|
|
59
|
-
| 3 | Invalid input or a failed precondition: a usage error, a ticket error, a model error, an uncommitted change, a slot that cannot be made, another driver that runs
|
|
61
|
+
| 3 | Invalid input or a failed precondition: a usage error, a ticket error, a model error, an uncommitted change, a slot that cannot be made, another driver that runs on the same PR branch. No agent starts. The one exception: a review page that cannot be written gives exit 3 at the end of a run, after agents ran. |
|
|
60
62
|
| 4 | Stopped: the CLI of an agent failed two times and wrote no report (for example, the account cannot use the model). The driver starts no other agent and lets the running agents end. No ticket is BLOCKED by it: fix the CLI or the model entry, then run the same command again to resume. |
|
|
61
63
|
| 130 | The operator interrupted the run. The driver stops each agent. The work stays in the slots for the next run. |
|
|
62
64
|
|
|
@@ -75,7 +77,7 @@ Everything is under `.agent/`, which is never committed.
|
|
|
75
77
|
| Path | Content |
|
|
76
78
|
|---|---|
|
|
77
79
|
| `.agent/implement/state.json` | The resume contract: the run record and one record for each ticket (`driver_state.py`). |
|
|
78
|
-
| `<git common directory>/implement-spec
|
|
80
|
+
| `<git common directory>/implement-spec/<key>.lock` | An empty file, not in the worktree. The driver that runs holds a kernel file lock on it (`driver_state.py` `lock`). Do not remove it during a run. |
|
|
79
81
|
| `.agent/implement/ledger.md` | One plain sentence for each event. Only the driver writes it, and it reads no fact from it. |
|
|
80
82
|
| `.agent/implement/summary.md` | The result in sentences, and the measurement table: gates, agents, wall time, fix rounds, human stops, cost. |
|
|
81
83
|
| `.agent/implement/decisions.json` | The open questions and their `revision`. Written only after a stop. |
|
|
@@ -88,7 +90,8 @@ Everything is under `.agent/`, which is never committed.
|
|
|
88
90
|
| `.agent/implement-review.html` | The page with the open questions. |
|
|
89
91
|
| `.agent/implement-answers.json` | The answers: `{"v": 1, "stage": "implement", "submitted": …, "revision": …, "answers": {…}}`. |
|
|
90
92
|
| `.agent/worktrees/slot-K/` | Slots 2..N. Slot 1 is the PR worktree. |
|
|
91
|
-
| `refs/implement
|
|
93
|
+
| `refs/implement-spec/<key>/recovery` | A git ref: the PR tip before the tidy. |
|
|
94
|
+
| `ticket/<key>/<NN>` | The branch of one ticket. The driver deletes it when the ticket is merged. |
|
|
92
95
|
|
|
93
96
|
## Roles of one ticket
|
|
94
97
|
|
|
@@ -97,7 +100,7 @@ A ticket is ready when each ticket in its **Blocked by** line is merged and no r
|
|
|
97
100
|
1. **test-writer** (`prompts/test-writer.md`). It writes one black-box test for each criterion through the Seam and commits them red. Those files are locked. A ticket with Seam `none — refactor` has no test-writer.
|
|
98
101
|
2. **implementer** (`prompts/implementer.md`). It starts from that commit and makes the locked tests green. It makes the gate (`--gate`) green on its final commit before it reports DONE or DONE_WITH_CONCERNS.
|
|
99
102
|
3. **Gate.** The driver runs `--gate` on the ticket tip.
|
|
100
|
-
4. **Review.** The driver compares each locked file of the ticket with the test-writer commit. A file that differs is a critical finding `locked file changed: <path>`. A locked file of a merged ticket that the ticket changed since its merge base with the PR branch is a critical `fixture` finding `the ticket changed the locked file of a merged ticket: <path>`, so it goes to a correction (see **Before a correction**). Then the **reviewer** (`prompts/reviewer.md`) runs, and for a ticket with a **Risk** line the **correctness** pass (`prompts/correctness.md`) runs at the same time in the same slot.
|
|
103
|
+
4. **Review.** The driver compares each locked file of the ticket with the test-writer commit. A file that differs is a critical finding `locked file changed: <path>`. A locked file of a merged ticket that the ticket changed since its merge base with the PR branch is a critical `fixture` finding `the ticket changed the locked file of a merged ticket: <path>`, so it goes to a correction (see **Before a correction**). Then the **reviewer** (`prompts/reviewer.md`) runs, and for a ticket with a **Risk** line the **correctness** pass (`prompts/correctness.md`) runs at the same time in the same slot. A read-only role (reviewer, correctness, final-reviewer, pr-writer) leaves its slot and the git config as it found them: all worktrees share one config, and one key written there, such as a `core.worktree` on a temp directory, breaks git in each of them. To run a check off the slot, a reviewer reads the driver's gate record or makes its own `git worktree add --detach` copy and removes it. After read-only roles, the driver checks the slot and the shared config (`git rev-parse --git-common-dir`, then `config`) against what they were before. When one of them changed, the driver restores it, writes `a read-only role broke its rule: ...` to the ledger, and runs the review one more time; a second change blocks the ticket.
|
|
101
104
|
5. **Merge.** The driver merges the reviewed tip into the PR branch with git plumbing, then deletes the ticket branch. A conflict goes to the **conflict** agent (`prompts/conflict.md`) once. Its resolution may change no locked file of the ticket. A second conflict blocks the ticket.
|
|
102
105
|
**The landing check.** Merges are serial. Before the PR branch moves, the driver compares the tree that is about to land (the ticket tip of a fast-forward, or the merge commit) with the PR tip: each locked file of each merged ticket must be the same tree entry in both: the same blob with the same mode, so a file that became a symlink is a difference. A file that only one of them has is a difference. A file that the landing ticket lists in its own `locked` is not checked: the test-writer of a later ticket can add a test to a file that an earlier ticket locked, and must list that file. A test-writer report that changes such a file and does not list it is rejected, with the usual one rerun. The review finds such a change first; the landing check catches a change that no review saw, for example of a ticket that merged after the last review. A difference refuses the landing: the ticket is BLOCKED with `locked file changed: <path>` in the ledger, the PR branch does not move, and no fix round runs. The PR branch moves only from the tip that the check read; when another hand moved the branch in between, the driver makes the merge, the check and the gate again, one time. Only that case starts the landing again: a refused landing and a red merge gate block the ticket. The second try runs no conflict agent, so a conflict there blocks the ticket, and so does a branch that moved again.
|
|
103
106
|
**Before a correction.** A test-writer that corrects locked tests (after `overrule-test`, or for a `fixture` finding or a `test` finding on a locked file) starts from the locked versions: the driver puts back each locked file of the ticket and each locked file of a merged ticket that the ticket changed, commits them in one commit of its own, and the ledger names each file. A file of a merged ticket goes back to the version of the PR branch. The commit keeps the tree of the next fixer and of the gate clean, whatever files the correction commits. The correction's prompt names the restored files: when a test in one of them fails only because the ticket changed an interface that the test uses, the correction updates the file to the new interface, keeps what each test asserts, and lists the file in `locked`.
|
|
@@ -131,7 +134,7 @@ A red gate on the commit of the implementer or of a fixer is a critical finding
|
|
|
131
134
|
|
|
132
135
|
The end of run starts only when every ticket is merged and every slot is idle. Its agents work in slot 1.
|
|
133
136
|
|
|
134
|
-
1. **Tidy.** The driver saves the PR tip in `refs/implement
|
|
137
|
+
1. **Tidy.** The driver saves the PR tip in `refs/implement-spec/<key>/recovery`. It then makes one commit for each ticket, in merge order, named `feat(NN): <title>`. Each commit holds the tests, the implementation and the fixes of its ticket. The tree of the new tip must equal the tree of the old tip, or the branch stays as it was.
|
|
135
138
|
2. **Rebase** onto the fetched tip of the default branch of `origin`. A conflict goes to the conflict agent (`prompts/rebase-conflict.md`) once, and the rebased tip is gated.
|
|
136
139
|
3. **Gate**, then the **final-reviewer** (`prompts/final-reviewer.md`) on the whole range, with the method of the `review-diff` skill. It gets the minor findings of the tickets and the rulings of the operator.
|
|
137
140
|
4. **Fix batch.** Critical and important findings go to one fixer (`prompts/final-fixer.md`). The driver names each of its commits `fix(NN): …` from the ticket in the report. Then the gate, then one scoped review of those commits.
|
|
@@ -14,8 +14,10 @@ from __future__ import annotations
|
|
|
14
14
|
import argparse
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
|
+
import re
|
|
17
18
|
import sys
|
|
18
19
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
20
|
+
from contextlib import ExitStack
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
|
|
@@ -24,7 +26,9 @@ import driver_answers
|
|
|
24
26
|
import driver_decisions
|
|
25
27
|
import driver_final
|
|
26
28
|
import driver_fixloop
|
|
29
|
+
import driver_gate
|
|
27
30
|
import driver_graph
|
|
31
|
+
import driver_legacy
|
|
28
32
|
import driver_merge
|
|
29
33
|
import driver_review
|
|
30
34
|
import driver_review_page
|
|
@@ -32,6 +36,7 @@ import driver_slots
|
|
|
32
36
|
import driver_state
|
|
33
37
|
import driver_summary
|
|
34
38
|
import driver_tickets
|
|
39
|
+
from driver_names import Names
|
|
35
40
|
from driver_run import FINAL, OUT, Blocked, Halted, Run, during, ledger
|
|
36
41
|
from driver_slots import git
|
|
37
42
|
from driver_tickets import Ticket
|
|
@@ -59,7 +64,7 @@ def _roles_left(ticket: Ticket, phase: str, only: str | None) -> list[str]:
|
|
|
59
64
|
|
|
60
65
|
def _check_out_ticket(ticket: Ticket, slot_number: int, entry: dict, run: Run) -> str:
|
|
61
66
|
"""Put the ticket branch in the slot and return the commit its next role starts from."""
|
|
62
|
-
slot, branch = driver_slots.path(slot_number),
|
|
67
|
+
slot, branch = driver_slots.path(slot_number), run.names.ticket(ticket.number)
|
|
63
68
|
if run.kept.get(ticket.number) == slot_number:
|
|
64
69
|
# The slot holds the uncommitted work of an interrupted role: the branch and the tree stay as they are.
|
|
65
70
|
return entry["tip"] or git(slot, "merge-base", "HEAD", run.tip()).stdout.strip()
|
|
@@ -80,7 +85,8 @@ def _prompt_values(ticket: Ticket, slot: str, run: Run) -> dict[str, str]:
|
|
|
80
85
|
"""The pointers every prompt of the ticket gets. An agent works in its slot, so paths are relative to it."""
|
|
81
86
|
folder = OUT / ticket.number
|
|
82
87
|
return {"spec": os.path.relpath(run.args.spec, slot), "seam": ticket.seam, "pr_branch": run.pr,
|
|
83
|
-
"branch":
|
|
88
|
+
"branch": run.names.ticket(ticket.number),
|
|
89
|
+
"ticket": os.path.relpath(Path(run.args.tickets) / ticket.file, slot),
|
|
84
90
|
"correctness": str(driver_review.CORRECTNESS), "gate": run.args.gate,
|
|
85
91
|
"blocked_report": os.path.relpath(_blocked_report(ticket.number, run), slot),
|
|
86
92
|
"writer_report": os.path.relpath(folder / "test-writer-0.json", slot)}
|
|
@@ -104,7 +110,7 @@ def _run_roles(ticket: Ticket, slot_number: int, run: Run) -> None:
|
|
|
104
110
|
values = {**_prompt_values(ticket, slot, run), "locked": " ".join(entry["locked"])}
|
|
105
111
|
for role in roles:
|
|
106
112
|
run.state.ticket(number, phase=DURING.get(role, "review"))
|
|
107
|
-
values
|
|
113
|
+
values.update(start_sha=start, gate_record=os.path.relpath(driver_gate.record_path(OUT / number, start), slot))
|
|
108
114
|
# A test-writer on a ticket that has locked tests is the correction after a ruling.
|
|
109
115
|
check = {"correction": role == "test-writer" and bool(entry["test_writer_sha"])}
|
|
110
116
|
if check["correction"] and (restored := run.restore_locked_files(number, slot)):
|
|
@@ -152,7 +158,7 @@ def _resolve_conflict(ticket: Ticket, slot: str, run: Run, tip: str, files: list
|
|
|
152
158
|
|
|
153
159
|
def _merge_commit(ticket: Ticket, slot: str, run: Run, tip: str, may_resolve: bool) -> _Landing:
|
|
154
160
|
"""Merge the ticket tip with the PR tip into a commit that no ref holds yet."""
|
|
155
|
-
message = f"Merge
|
|
161
|
+
message = f"Merge {run.names.ticket(ticket.number)} into {run.pr}"
|
|
156
162
|
pr_tip = run.tip()
|
|
157
163
|
commit, error = driver_merge.commit(pr_tip, tip, message)
|
|
158
164
|
if not error:
|
|
@@ -171,7 +177,8 @@ def _merge_commit(ticket: Ticket, slot: str, run: Run, tip: str, may_resolve: bo
|
|
|
171
177
|
def _checked_landing(ticket: Ticket, slot: str, run: Run, may_resolve: bool) -> _Landing:
|
|
172
178
|
"""The commit to land, with the locked-file check passed and the gate green. Call it under the merge lock."""
|
|
173
179
|
number = ticket.number
|
|
174
|
-
|
|
180
|
+
# An earlier try left the slot on its merge commit.
|
|
181
|
+
git(slot, "checkout", "-q", run.names.ticket(number))
|
|
175
182
|
tip = run.state.ticket(number, phase="merge")["tip"]
|
|
176
183
|
landing = _merge_commit(ticket, slot, run, tip, may_resolve)
|
|
177
184
|
refusal = driver_merge.refused(run.state.read()["tickets"], number, landing.pr_tip, landing.commit)
|
|
@@ -208,7 +215,7 @@ def _record_landing(state: dict, number: str, pr_tip: str) -> None:
|
|
|
208
215
|
|
|
209
216
|
def _merge(ticket: Ticket, slot: str, run: Run) -> None:
|
|
210
217
|
"""Land the reviewed ticket tip on the PR branch, then delete the ticket branch."""
|
|
211
|
-
number, branch = ticket.number,
|
|
218
|
+
number, branch = ticket.number, run.names.ticket(ticket.number)
|
|
212
219
|
# The merge gate and the conflict agent run under the lock: the commit the gate saw is the one that lands.
|
|
213
220
|
with run.merging, during("the merge"):
|
|
214
221
|
landing = _land(ticket, slot, run)
|
|
@@ -348,27 +355,16 @@ def _work(tickets: list[Ticket], slots: int, run: Run) -> tuple[bool, list[str]]
|
|
|
348
355
|
|
|
349
356
|
# ---------------------------------------------------------------- before the first agent: resume and answers
|
|
350
357
|
|
|
351
|
-
def _pr_branch(state: driver_state.State) -> tuple[str, str]:
|
|
352
|
-
"""(the PR branch, "") or ("", why the run cannot start)."""
|
|
353
|
-
here = git(".", "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
|
|
354
|
-
# An interrupted run leaves slot 1 on a ticket branch or detached, so the record names the PR branch.
|
|
355
|
-
pr = state.read()["run"]["pr_branch"] if state.path.exists() else here
|
|
356
|
-
if not pr:
|
|
357
|
-
return "", "the PR worktree is on a detached HEAD: check out the PR branch"
|
|
358
|
-
if here not in ("", pr) and not here.startswith("ticket/"):
|
|
359
|
-
return "", f"{state.path} records a run on {pr}, not on {here}: check out {pr}, or remove {OUT}"
|
|
360
|
-
return pr, ""
|
|
361
|
-
|
|
362
|
-
|
|
363
358
|
def _resume_error(number: str, state: driver_state.State) -> str:
|
|
364
359
|
"""Check the record of one ticket against git. A ticket that git contradicts goes back to its last verified
|
|
365
360
|
phase; "" or the error that stops the run."""
|
|
366
|
-
|
|
361
|
+
run, entry = state.read()["run"], state.read()["tickets"][number]
|
|
362
|
+
branch = Names(run["pr_branch"]).ticket(number)
|
|
367
363
|
if entry["phase"] not in driver_state.PHASES:
|
|
368
364
|
return f"{state.path}: ticket {number} has the unknown phase {entry['phase']!r}"
|
|
369
|
-
if entry["phase"] == "pending" and
|
|
365
|
+
if entry["phase"] == "pending" and driver_slots.branch_exists(branch):
|
|
370
366
|
return f"branch {branch} exists and {state.path} does not record it: delete or rename the branch"
|
|
371
|
-
fields, why = driver_state.verified(entry, branch,
|
|
367
|
+
fields, why = driver_state.verified(entry, branch, run["pr_branch"])
|
|
372
368
|
if fields:
|
|
373
369
|
state.ticket(number, **fields)
|
|
374
370
|
start = fields["tip"][:7] if fields["tip"] else "the PR tip"
|
|
@@ -376,15 +372,12 @@ def _resume_error(number: str, state: driver_state.State) -> str:
|
|
|
376
372
|
return ""
|
|
377
373
|
|
|
378
374
|
|
|
379
|
-
def _resume(tickets: list[Ticket], state: driver_state.State, slots: int, args, kept: dict) ->
|
|
380
|
-
"""Start or resume state.json and make the slots:
|
|
381
|
-
pr, error = _pr_branch(state)
|
|
382
|
-
if error:
|
|
383
|
-
return "", [error]
|
|
375
|
+
def _resume(tickets: list[Ticket], state: driver_state.State, pr: str, slots: int, args, kept: dict) -> list[str]:
|
|
376
|
+
"""Start or resume state.json on the PR branch `pr` and make the slots: the errors that stop the run."""
|
|
384
377
|
state.start(pr, [ticket.number for ticket in tickets])
|
|
385
378
|
driver_final.finish_interrupted_move(state, pr)
|
|
386
379
|
errors = [error for error in (_resume_error(ticket.number, state) for ticket in tickets) if error]
|
|
387
|
-
return
|
|
380
|
+
return errors or driver_slots.setup(slots, args.prepare, set(kept.values()))
|
|
388
381
|
|
|
389
382
|
|
|
390
383
|
def _publish_questions(run: dict) -> str:
|
|
@@ -474,10 +467,10 @@ def _exit_code(final: dict, args, end_blocked: bool) -> int:
|
|
|
474
467
|
return code
|
|
475
468
|
|
|
476
469
|
|
|
477
|
-
def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[str, int]) -> int:
|
|
470
|
+
def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[str, int], pr: str) -> int:
|
|
478
471
|
state = driver_state.State(STATE)
|
|
479
472
|
slots = 1 if args.only else min(args.parallel, len(tickets))
|
|
480
|
-
|
|
473
|
+
errors = _resume(tickets, state, pr, slots, args, kept)
|
|
481
474
|
errors = errors or _apply_answers(state, args)
|
|
482
475
|
for error in errors:
|
|
483
476
|
print(error)
|
|
@@ -564,7 +557,7 @@ def _validated(args: argparse.Namespace) -> tuple[list[Ticket], dict[str, list[s
|
|
|
564
557
|
errors.append("--spec is required: the spec file the prompts point at")
|
|
565
558
|
if args.parallel < 1:
|
|
566
559
|
errors.append("--parallel must be 1 or more")
|
|
567
|
-
kept = driver_state.kept(
|
|
560
|
+
kept = driver_state.kept(driver_state.State(STATE).read()) if STATE.exists() else {}
|
|
568
561
|
errors += _worktree_errors(1 in kept.values())
|
|
569
562
|
models, model_errors = driver_agents.load_models(args.models)
|
|
570
563
|
tickets, ticket_errors = driver_tickets.parse(args.tickets)
|
|
@@ -579,16 +572,61 @@ def _print_width(tickets: list[Ticket]) -> None:
|
|
|
579
572
|
print(f"max frontier width: {driver_tickets.max_frontier_width(tickets)}")
|
|
580
573
|
|
|
581
574
|
|
|
582
|
-
def
|
|
583
|
-
"""The
|
|
584
|
-
|
|
575
|
+
def _run_branch() -> str:
|
|
576
|
+
"""The PR branch the run in this worktree is on: the one state.json records, else the one checked out."""
|
|
577
|
+
# HEAD first: a driver writes state.json before it moves HEAD, so a HEAD that a run moved has a state.json.
|
|
578
|
+
here = driver_slots.on_branch(".")
|
|
579
|
+
return driver_state.State(STATE).read()["run"]["pr_branch"] if STATE.exists() else here
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _start_errors(pr: str) -> list[str]:
|
|
583
|
+
"""Under the lock of `pr`, before any check reads a ref: the errors that stop the run, or [].
|
|
584
|
+
|
|
585
|
+
The run of this worktree must still be on `pr`, a run that an older driver started gets its new names first,
|
|
586
|
+
and slot 1 must be on `pr` or where an interrupted run left it: on one of its ticket branches, or detached."""
|
|
587
|
+
if _run_branch() != pr:
|
|
588
|
+
return [f"the run in this worktree left {pr} while the driver took its lock: run the driver again"]
|
|
589
|
+
if errors := driver_legacy.resume(driver_state.State(STATE)):
|
|
590
|
+
return errors
|
|
591
|
+
here = driver_slots.on_branch(".")
|
|
592
|
+
# An older driver named its ticket branches `ticket/NN`, and a branch that the resume does not rename stays so.
|
|
593
|
+
if here in ("", pr) or here.startswith(Names(pr).ticket("")) or re.fullmatch(r"ticket/\d+", here):
|
|
594
|
+
return []
|
|
595
|
+
return [f"{STATE} records a run on {pr}, not on {here}: check out {pr}, or remove {OUT}"]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _common_dir_and_branch(args: argparse.Namespace) -> tuple[Path, str] | None:
|
|
599
|
+
"""(the git common directory, which every worktree shares and `git status` never reports, and the PR branch of
|
|
600
|
+
the run): what names the lock. None, with each error printed, when the call is not valid."""
|
|
585
601
|
common = git(".", "rev-parse", "--git-common-dir")
|
|
586
602
|
# A first call is validated before the lock, so a wrong call makes no file. After an earlier run the lock comes
|
|
587
603
|
# first: the driver that holds it changes what the validation reads.
|
|
588
604
|
first_call = common.returncode or not OUT.exists()
|
|
589
605
|
if first_call and not _validated(args):
|
|
590
606
|
return None
|
|
591
|
-
|
|
607
|
+
if not (pr := _run_branch()):
|
|
608
|
+
print("the PR worktree is on a detached HEAD: check out the PR branch")
|
|
609
|
+
return None
|
|
610
|
+
if pr.startswith("ticket/"): # a slot of another run: its driver owns the worktree and the branch
|
|
611
|
+
print(f"the PR worktree is on the ticket branch {pr} of a run: check out the PR branch")
|
|
612
|
+
return None
|
|
613
|
+
return Path(common.stdout.strip()).resolve(), pr
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _hold(common: Path, pr: str) -> tuple[ExitStack | None, str]:
|
|
617
|
+
"""Take the locks of a run on `pr`: (the stack that holds them until it closes, "") or (None, why another
|
|
618
|
+
driver stops this one)."""
|
|
619
|
+
with ExitStack() as stack:
|
|
620
|
+
lock_file = Names(pr).lock_file(common)
|
|
621
|
+
if not (held := driver_state.lock(lock_file)):
|
|
622
|
+
return None, (f"another driver runs on {pr}: it holds the lock {lock_file}. Wait until it ends, then "
|
|
623
|
+
"run the driver again.")
|
|
624
|
+
stack.enter_context(held)
|
|
625
|
+
older, error = driver_legacy.lock(common)
|
|
626
|
+
if not older:
|
|
627
|
+
return None, error
|
|
628
|
+
stack.enter_context(older)
|
|
629
|
+
return stack.pop_all(), ""
|
|
592
630
|
|
|
593
631
|
|
|
594
632
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -597,15 +635,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
597
635
|
if valid := _validated(args):
|
|
598
636
|
_print_width(valid[0])
|
|
599
637
|
return 0 if valid else 3
|
|
600
|
-
if not (
|
|
638
|
+
if not (locking := _common_dir_and_branch(args)):
|
|
601
639
|
return 3
|
|
602
|
-
|
|
640
|
+
common, pr = locking
|
|
641
|
+
held, error = _hold(common, pr)
|
|
603
642
|
if not held:
|
|
604
|
-
print(f"implement-spec:
|
|
605
|
-
"ends, then run the driver again.")
|
|
643
|
+
print(f"implement-spec: {error}")
|
|
606
644
|
return 3
|
|
607
645
|
with held:
|
|
608
|
-
# Only what is read under the lock is trusted: no other driver changes state.json, the slots
|
|
646
|
+
# Only what is read under the lock is trusted: no other driver changes state.json, the slots, the PR branch
|
|
647
|
+
# or its ticket branches. Drivers on other PR branches touch none of them.
|
|
648
|
+
if errors := _start_errors(pr):
|
|
649
|
+
print("".join(f"implement-spec: {error}\n" for error in errors), end="")
|
|
650
|
+
return 3
|
|
609
651
|
if not (valid := _validated(args)):
|
|
610
652
|
return 3
|
|
611
653
|
tickets, models, kept = valid
|
|
@@ -614,7 +656,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
614
656
|
print("".join(error + "\n" for error in errors), end="")
|
|
615
657
|
return 3
|
|
616
658
|
_print_width(tickets)
|
|
617
|
-
return _run(tickets, models, args, kept)
|
|
659
|
+
return _run(tickets, models, args, kept, pr)
|
|
618
660
|
|
|
619
661
|
|
|
620
662
|
if __name__ == "__main__":
|
|
@@ -4,7 +4,7 @@ commits with a scoped review, then stop before the push, or push and open the PR
|
|
|
4
4
|
|
|
5
5
|
`run.final` of state.json is the resume contract (`driver_state._final` makes it with every key):
|
|
6
6
|
skipped why the end-of-run work did not start
|
|
7
|
-
recovery the PR tip before the rewrite;
|
|
7
|
+
recovery the PR tip before the rewrite; Run.names names its ref. None when origin had the history already
|
|
8
8
|
tidied the tip the rewrite made, or the tip as the tickets left it when the history is kept
|
|
9
9
|
base, onto the default branch of origin and its tip under the history; with no origin: "" and the start of the run
|
|
10
10
|
commits {ticket: its commit}
|
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
|
|
27
27
|
import driver_agents
|
|
28
28
|
import driver_decisions
|
|
29
|
+
import driver_gate
|
|
29
30
|
import driver_merge
|
|
30
31
|
import driver_review
|
|
31
32
|
import driver_tidy
|
|
@@ -34,7 +35,6 @@ from driver_slots import git, head
|
|
|
34
35
|
from driver_state import State
|
|
35
36
|
from driver_tickets import Ticket
|
|
36
37
|
|
|
37
|
-
RECOVERY = "refs/implement/recovery"
|
|
38
38
|
KIND = "final-finding"
|
|
39
39
|
ROUNDS = 2 # the full review and the scoped one after the fix batch
|
|
40
40
|
# The final review runs through the review-diff skill, which is installed next to this one.
|
|
@@ -170,7 +170,8 @@ def _resolve_rebase_conflict(run: Run, env: dict, base: str, onto: str) -> None:
|
|
|
170
170
|
raise Blocked(f"the conflict agent: {error}" if error else
|
|
171
171
|
f"the rebase onto {base} conflicts a second time, in {', '.join(again)}")
|
|
172
172
|
# The resolution may not change a locked test: each one is as the recovery point has it.
|
|
173
|
-
|
|
173
|
+
before = git(".", "rev-parse", run.names.recovery).stdout.strip()
|
|
174
|
+
changed = driver_review.locked_changes(".", before, _locked_files(run))
|
|
174
175
|
if error := error or "; ".join(finding["title"] for finding in changed):
|
|
175
176
|
raise Blocked(f"the conflict agent: {error}")
|
|
176
177
|
ledger(f"Rebase: the conflict agent resolved the conflict with {base} in {', '.join(files)} at {head('.')[:7]}.")
|
|
@@ -249,7 +250,7 @@ def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
|
|
|
249
250
|
final = run.state.read()["run"]["final"]
|
|
250
251
|
if final["tidied"]:
|
|
251
252
|
if not driver_merge.ancestor(final["tidied"], run.tip()):
|
|
252
|
-
raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {
|
|
253
|
+
raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {run.names.recovery} holds the "
|
|
253
254
|
"history before the tidy")
|
|
254
255
|
return
|
|
255
256
|
base, onto = _base(run)
|
|
@@ -258,9 +259,9 @@ def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
|
|
|
258
259
|
_keep_published_history(run, old, base, onto)
|
|
259
260
|
return
|
|
260
261
|
tree = git(".", "rev-parse", f"{old}^{{tree}}").stdout.strip()
|
|
261
|
-
git(".", "update-ref", "-m", "implement-spec: recovery point",
|
|
262
|
+
git(".", "update-ref", "-m", "implement-spec: recovery point", run.names.recovery, old)
|
|
262
263
|
ledger(f"Recovery point: {run.pr} was at {old} with the tree {tree} before the rebase and the tidy; "
|
|
263
|
-
f"{
|
|
264
|
+
f"{run.names.recovery} holds that commit.")
|
|
264
265
|
git(".", "checkout", "-q", "--detach") # slot 1 is off the branch while the history is made
|
|
265
266
|
try:
|
|
266
267
|
commits = _new_history(run, tickets, old, base, onto)
|
|
@@ -325,7 +326,7 @@ def _review(run: Run, final: dict, tip: str) -> None:
|
|
|
325
326
|
"""One round of the final review on `tip`: the full review, or after it the scoped one."""
|
|
326
327
|
done, folder = final["round"], OUT / FINAL
|
|
327
328
|
values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['reviewed'] or final['onto']}..{tip}",
|
|
328
|
-
"gate": str(folder
|
|
329
|
+
"gate": str(driver_gate.record_path(folder, tip)), "review_diff": str(REVIEW_DIFF),
|
|
329
330
|
"ledger": str(OUT / "ledger.md"), "minors": _minor_findings(), "reviewed": str(final["reviewed"]),
|
|
330
331
|
"rulings": "\n".join(driver_decisions.rulings(run.state.read()["run"]["questions"])),
|
|
331
332
|
"findings": str(folder / f"findings-{done - 1}.json")}
|
|
@@ -441,7 +442,7 @@ def _write_body(run: Run, final: dict, tickets: list[Ticket]) -> Path:
|
|
|
441
442
|
raise Blocked("the pr skill is missing: the PR body follows it; install it with "
|
|
442
443
|
"`npx skills add mattpocock/skills --skill pr`")
|
|
443
444
|
values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['onto']}..{tip}",
|
|
444
|
-
"gate": str(OUT / FINAL
|
|
445
|
+
"gate": str(driver_gate.record_path(OUT / FINAL, tip)), "pr_skill": str(method), "facts": facts}
|
|
445
446
|
with during("the PR body"):
|
|
446
447
|
report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent("pr-writer", FINAL, ".", values))
|
|
447
448
|
body.write_text(f"{report['body'].strip()}\n\n{facts}")
|
|
@@ -11,6 +11,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
12
|
|
|
13
13
|
import driver_agents
|
|
14
|
+
import driver_gate
|
|
14
15
|
import driver_merge
|
|
15
16
|
import driver_review
|
|
16
17
|
import driver_slots
|
|
@@ -39,9 +40,11 @@ class Job:
|
|
|
39
40
|
"""The values of a prompt; `review` is the round whose findings the agent reads."""
|
|
40
41
|
entry = self.run.state.read()["tickets"][self.ticket.number]
|
|
41
42
|
findings = os.path.relpath(OUT / self.ticket.number / f"findings-{review}.json", self.slot)
|
|
43
|
+
gate = os.path.relpath(driver_gate.record_path(OUT / self.ticket.number, self.tip), self.slot)
|
|
42
44
|
return {**self.values, "start_sha": self.tip, "from_sha": self.previous, "round": str(self.round),
|
|
43
45
|
"locked": " ".join(entry["locked"]), "test_writer_sha": str(entry["test_writer_sha"] or ""),
|
|
44
|
-
"findings": findings, "
|
|
46
|
+
"findings": findings, "gate_record": gate,
|
|
47
|
+
"proofs": " ".join(f"{file}:{line}" for file, line in sorted(self.again))}
|
|
45
48
|
|
|
46
49
|
|
|
47
50
|
def entry(models: dict[str, list[str]], round: int) -> str:
|
|
@@ -25,12 +25,16 @@ def _after(slot: str, commit: str) -> str:
|
|
|
25
25
|
return "not clean\n" + "".join(f" {line}\n" for line in status.splitlines()).rstrip("\n") if status else "clean"
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def record_path(folder: Path, commit: str) -> Path:
|
|
29
|
+
return folder / f"gate-{commit[:7]}.log"
|
|
30
|
+
|
|
31
|
+
|
|
28
32
|
def run(command: str, slot: str, folder: Path, pr_branch: str) -> tuple[bool, Path]:
|
|
29
33
|
"""Gate the HEAD of `slot`. Returns (green, the record)."""
|
|
30
34
|
commit = head(slot)
|
|
31
35
|
status = git(slot, "status", "--short").stdout
|
|
32
36
|
folder.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
record = folder
|
|
37
|
+
record = record_path(folder, commit)
|
|
34
38
|
# The cache of compiled Python stays out of the slot, so the gate leaves the tree clean.
|
|
35
39
|
env = dict(os.environ, IMPLEMENT_SPEC_BASE=pr_branch, PYTHONPYCACHEPREFIX=str(folder.parent.resolve() / "pycache"))
|
|
36
40
|
with record.open("w") as out:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A run that an older driver started: one lock per repository, ticket branches `ticket/NN` and the recovery ref
|
|
2
|
+
`refs/implement/recovery`.
|
|
3
|
+
|
|
4
|
+
Every run of a newer driver holds `lock` (driver._hold), so a newer and an older driver never run in one repository
|
|
5
|
+
at the same time. The driver calls `resume` once, after the locks and before any check reads a ref: a run with no
|
|
6
|
+
`schema` in state.json gets the new names of its refs.
|
|
7
|
+
ponytail: delete this module, tests/test_legacy.py and its one line in driver._hold and in driver._start_errors
|
|
8
|
+
once no older driver is left.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO
|
|
14
|
+
|
|
15
|
+
import driver_slots
|
|
16
|
+
import driver_state
|
|
17
|
+
from driver_names import Names
|
|
18
|
+
from driver_run import ledger
|
|
19
|
+
from driver_slots import git
|
|
20
|
+
|
|
21
|
+
LOCK = "implement-spec.lock" # in the git common directory: an older driver holds it for its whole run
|
|
22
|
+
RECOVERY = "refs/implement/recovery"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _checked_out_elsewhere(branch: str) -> bool:
|
|
26
|
+
"""True when a worktree that is not this one nor one of its slots has `branch` checked out."""
|
|
27
|
+
here = Path.cwd().resolve()
|
|
28
|
+
for block in git(".", "worktree", "list", "--porcelain").stdout.split("\n\n"):
|
|
29
|
+
lines = block.splitlines()
|
|
30
|
+
if lines and f"branch refs/heads/{branch}" in lines:
|
|
31
|
+
where = Path(lines[0].removeprefix("worktree ")).resolve()
|
|
32
|
+
return where != here and here / ".agent" / "worktrees" not in where.parents
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _this_runs(entry: dict, legacy: str, start: str) -> bool:
|
|
37
|
+
"""A `ticket/NN` is this run's when it holds the commit the run started from and the tip the run recorded, and
|
|
38
|
+
no worktree outside this run has it checked out."""
|
|
39
|
+
if not driver_slots.ancestor(start, legacy) or _checked_out_elsewhere(legacy):
|
|
40
|
+
return False
|
|
41
|
+
return not entry["tip"] or driver_slots.ancestor(entry["tip"], legacy)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _branch(entry: dict, number: str, names: Names, start: str) -> str:
|
|
45
|
+
"""Rename one `ticket/NN` of this run: "" or the error."""
|
|
46
|
+
legacy, new = f"ticket/{number}", names.ticket(number)
|
|
47
|
+
if entry["phase"] in ("pending", "merged") or not driver_slots.branch_exists(legacy):
|
|
48
|
+
return ""
|
|
49
|
+
if driver_slots.branch_exists(new) or not _this_runs(entry, legacy, start):
|
|
50
|
+
return ""
|
|
51
|
+
moved = git(".", "branch", "-m", legacy, new) # git also moves the HEAD of a slot that has the branch
|
|
52
|
+
if moved.returncode:
|
|
53
|
+
return f"cannot rename the branch {legacy} of an older driver to {new}: {moved.stderr.strip()}"
|
|
54
|
+
ledger(f"Ticket {number}: renamed the branch {legacy} of an older driver to {new}.")
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _recovery(run: dict, names: Names) -> str:
|
|
59
|
+
"""Move `refs/implement/recovery` to the name of this run when it holds the recovery point that this run
|
|
60
|
+
recorded: "" or the error."""
|
|
61
|
+
recorded = run.get("final", {}).get("recovery")
|
|
62
|
+
if not recorded or git(".", "rev-parse", "-q", "--verify", RECOVERY).stdout.strip() != recorded:
|
|
63
|
+
return ""
|
|
64
|
+
# The old ref goes only when the new one holds the commit: a move that stopped half way is finished next time.
|
|
65
|
+
made = git(".", "update-ref", "-m", "implement-spec: rename", names.recovery, recorded)
|
|
66
|
+
if made.returncode:
|
|
67
|
+
return f"cannot move {RECOVERY} of an older driver to {names.recovery}: {made.stderr.strip()}"
|
|
68
|
+
git(".", "update-ref", "-d", RECOVERY, recorded)
|
|
69
|
+
ledger(f"Recovery point: moved {RECOVERY} of an older driver to {names.recovery}.")
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def lock(common_dir: Path) -> tuple[IO | None, str]:
|
|
74
|
+
"""The lock of an older driver, shared: (the held file, "") or (None, the error) while an older driver runs in
|
|
75
|
+
the repository. Newer drivers hold it together, so they do not stop each other; an older driver holds it alone,
|
|
76
|
+
so it never runs beside one."""
|
|
77
|
+
if held := driver_state.lock(common_dir / LOCK, shared=True):
|
|
78
|
+
return held, ""
|
|
79
|
+
return None, (f"a driver of an older version runs in this repository: it holds the lock {common_dir / LOCK}. "
|
|
80
|
+
"Wait until it ends, then run the driver again.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resume(state: driver_state.State) -> list[str]:
|
|
84
|
+
"""Give each ref of a run that an older driver started its new name, then mark the run: the errors, or []."""
|
|
85
|
+
if not state.path.exists() or state.read()["run"].get("schema", 1) >= driver_state.SCHEMA:
|
|
86
|
+
return []
|
|
87
|
+
run, names = state.read()["run"], Names(state.read()["run"]["pr_branch"])
|
|
88
|
+
moves = [_branch(entry, number, names, run["start"]) for number, entry in state.read()["tickets"].items()]
|
|
89
|
+
if errors := [error for error in [*moves, _recovery(run, names)] if error]:
|
|
90
|
+
return errors
|
|
91
|
+
state.update(lambda changed: changed["run"].update(schema=driver_state.SCHEMA))
|
|
92
|
+
return []
|
|
@@ -8,11 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import os
|
|
9
9
|
import subprocess
|
|
10
10
|
|
|
11
|
-
from driver_slots import git, names
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def ancestor(commit: str, of: str) -> bool:
|
|
15
|
-
return git(".", "merge-base", "--is-ancestor", commit, of).returncode == 0
|
|
11
|
+
from driver_slots import ancestor, git, names
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
def identity(tip: str, slot: str = ".") -> dict[str, str]:
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The names of one run: its ticket branches, its recovery ref and its lock file.
|
|
2
|
+
|
|
3
|
+
Each name carries the PR branch as one name component, so runs on other PR branches share none of them. One
|
|
4
|
+
component, because git refs are a tree: `ticket/feat/01` and `ticket/feat/01/02` cannot both exist. `%` and `/` are
|
|
5
|
+
escaped, so two PR branches never give the same component: `feat/x` gives `feat%2Fx`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Names:
|
|
15
|
+
pr: str # the PR branch
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def _key(self) -> str:
|
|
19
|
+
return self.pr.replace("%", "%25").replace("/", "%2F")
|
|
20
|
+
|
|
21
|
+
def ticket(self, number: str) -> str:
|
|
22
|
+
"""The branch of ticket `number`."""
|
|
23
|
+
return f"ticket/{self._key}/{number}"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def recovery(self) -> str:
|
|
27
|
+
"""The ref that holds the PR tip before the tidy."""
|
|
28
|
+
return f"refs/implement-spec/{self._key}/recovery"
|
|
29
|
+
|
|
30
|
+
def lock_file(self, common_dir: Path) -> Path:
|
|
31
|
+
"""The file one driver per PR branch locks, in the git common directory that every worktree shares."""
|
|
32
|
+
return common_dir / "implement-spec" / f"{self._key}.lock"
|