@jesdi/skills 0.0.30 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +215 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +534 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +108 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +11 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/pr-writer.md +31 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +50 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +221 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +604 -0
- package/skills/implement-spec/tests/test_walk.py +459 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The review page of the open questions: decisions.json in, HTML out.
|
|
2
|
+
|
|
3
|
+
The page is the template of the review-page skill in questionnaire mode with its slots filled. Every text is escaped.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from html import escape
|
|
9
|
+
|
|
10
|
+
import driver_agents
|
|
11
|
+
|
|
12
|
+
TEMPLATE = driver_agents.SKILL.parent / "review-page" / "template.html"
|
|
13
|
+
SLOTS = ("header", "tickets", "questions", "corrections", "track")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _option(question: str, option: dict, recommended: bool) -> str:
|
|
17
|
+
name, chip = escape(option["id"]), ' <span class="chip rec">recommended</span>' * recommended
|
|
18
|
+
return (f'<label class="opt"><input type="radio" name="{question}" value="{name}"><span><span class="t">{name}'
|
|
19
|
+
f'{chip}</span><br><span class="d">{escape(option["description"])}</span></span></label>')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _question(q: dict) -> str:
|
|
23
|
+
name = escape(q["id"])
|
|
24
|
+
options = "\n".join(_option(name, option, option["id"] == q["recommended"]) for option in q["options"])
|
|
25
|
+
evidence = " · ".join(f"<code>{escape(pointer)}</code>" for pointer in q["evidence"])
|
|
26
|
+
return (f'<div class="q" data-q="{name}">\n'
|
|
27
|
+
f'<header><h3>{escape(q["title"])}</h3><span class="chip waiting">needs you</span></header>\n'
|
|
28
|
+
f'<p class="why">{escape(q["why"])}</p>\n<p class="why">Evidence: {evidence}</p>\n'
|
|
29
|
+
f'<div class="opts">\n{options}\n</div>\n'
|
|
30
|
+
f'<textarea id="note-{name}" placeholder="Note (optional)"></textarea>\n</div>')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _filled(page: str, pattern: str, new: str) -> str:
|
|
34
|
+
"""`page` with the one match of `pattern` replaced; ValueError when the template has not exactly one."""
|
|
35
|
+
page, count = re.subn(pattern, lambda match: new, page, flags=re.S)
|
|
36
|
+
if count != 1:
|
|
37
|
+
raise ValueError(f"{TEMPLATE} has {count} matches of {pattern}, not 1")
|
|
38
|
+
return page
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render(decisions: dict) -> str:
|
|
42
|
+
"""The page for decisions.json. Raises OSError with no template, ValueError when it has another shape."""
|
|
43
|
+
questions = decisions["questions"]
|
|
44
|
+
header = (f'<div class="eyebrow">implement-spec · decisions {escape(decisions["revision"][:12])}</div>\n'
|
|
45
|
+
f"<h1>{len(questions)} open question{'s' * (len(questions) != 1)} of the implementation</h1>\n"
|
|
46
|
+
'<p class="lede">Pick an option under each question, then send.</p>')
|
|
47
|
+
slots = {"header": header, "questions": "\n".join(["<h2>Open questions</h2>", *map(_question, questions)])}
|
|
48
|
+
page = _filled(TEMPLATE.read_text(), r'<body data-mode="[a-z]*">', '<body data-mode="questionnaire">')
|
|
49
|
+
for slot in SLOTS:
|
|
50
|
+
page = _filled(page, rf"(?<=<!-- slot: {slot} -->).*?(?=<!-- /slot -->)", f"\n{slots.get(slot, '')}\n")
|
|
51
|
+
return page
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""What the ticket threads and the end of run share: the Run, one agent with its one rerun, the gate, the ledger.
|
|
2
|
+
|
|
3
|
+
A step that cannot go on raises Blocked. The message says why, and `during` adds the step it happened in.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Iterator, NamedTuple, TypeVar
|
|
16
|
+
|
|
17
|
+
import driver_agents
|
|
18
|
+
import driver_decisions
|
|
19
|
+
import driver_gate
|
|
20
|
+
import driver_merge
|
|
21
|
+
import driver_report
|
|
22
|
+
import driver_review
|
|
23
|
+
import driver_slots
|
|
24
|
+
import driver_state
|
|
25
|
+
from driver_slots import git
|
|
26
|
+
|
|
27
|
+
OUT = Path(".agent/implement")
|
|
28
|
+
FINAL = "final" # the end of run has its reports and gate records under this name, as a ticket has under its number
|
|
29
|
+
_LEDGER = threading.Lock()
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Blocked(Exception):
|
|
34
|
+
"""A ticket, or the end of run, cannot go on. The message says why."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def during(step: str) -> Iterator[None]:
|
|
39
|
+
"""Put the step in front of a Blocked that leaves the block: `<step>: <why>`."""
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
except Blocked as blocked:
|
|
43
|
+
raise Blocked(f"{step}: {blocked}") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ledger(sentence: str) -> int:
|
|
47
|
+
"""Append one sentence to ledger.md and return its line number."""
|
|
48
|
+
with _LEDGER:
|
|
49
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with (OUT / "ledger.md").open("a+") as file:
|
|
51
|
+
file.write(" ".join(sentence.split()) + "\n")
|
|
52
|
+
file.seek(0)
|
|
53
|
+
return sum(1 for _ in file)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _owner(number: str) -> str:
|
|
57
|
+
return "End of run" if number == FINAL else f"Ticket {number}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _gate_record(state: dict, number: str) -> dict:
|
|
61
|
+
"""The record of state.json that holds the green gates of `number`."""
|
|
62
|
+
return state["run"]["final"] if number == FINAL else state["tickets"][number]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Ask(NamedTuple):
|
|
66
|
+
"""The questions a ticket thread wants the operator to answer when its ticket blocks."""
|
|
67
|
+
kind: str
|
|
68
|
+
subjects: list[str] # one question per subject
|
|
69
|
+
report: str # the evidence
|
|
70
|
+
fields: dict # what the ticket record gets with the stop
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Run:
|
|
75
|
+
args: argparse.Namespace
|
|
76
|
+
models: dict[str, list[str]]
|
|
77
|
+
state: driver_state.State
|
|
78
|
+
pr: str # the PR branch
|
|
79
|
+
kept: dict[str, int] # ticket number -> the slot that holds its uncommitted work from an earlier run
|
|
80
|
+
merging: threading.Lock = field(default_factory=threading.Lock) # held for one landing: merges are serial
|
|
81
|
+
asks: dict[str, Ask] = field(default_factory=dict) # ticket number -> its pending stop
|
|
82
|
+
|
|
83
|
+
def tip(self) -> str:
|
|
84
|
+
return git(".", "rev-parse", f"refs/heads/{self.pr}").stdout.strip()
|
|
85
|
+
|
|
86
|
+
def ask(self, number: str, kind: str, subjects: list[str], report: str, **fields) -> None:
|
|
87
|
+
self.asks[number] = Ask(kind, subjects, report, fields)
|
|
88
|
+
|
|
89
|
+
def agent(self, role: str, number: str, slot: str, values: dict[str, str], **options) -> dict:
|
|
90
|
+
"""Run one role and return its accepted report. `options` are those of `_attempts`."""
|
|
91
|
+
if role == "test-writer":
|
|
92
|
+
# Its report may change a locked file of a merged ticket only when it lists the file.
|
|
93
|
+
merged = driver_merge.protected(self.state.read()["tickets"], number)
|
|
94
|
+
options["check"] = {**(options.get("check") or {}), "merged": merged, "pr": self.tip()}
|
|
95
|
+
report = _attempts(self, role, number, slot, values, **options)
|
|
96
|
+
if subject := driver_decisions.contradicted(role, report):
|
|
97
|
+
# The implementer's commits stay: after the ruling the ticket goes on from them.
|
|
98
|
+
evidence = OUT / number / f"{role}-{options.get('fix_round', 0)}.json"
|
|
99
|
+
self.ask(number, "locked-test", [subject], str(evidence), tip=driver_slots.head(slot))
|
|
100
|
+
if report.get("status") in ("BLOCKED", "NEEDS_CONTEXT"):
|
|
101
|
+
concerns = "; ".join(report["concerns"]) or "no concern given"
|
|
102
|
+
raise Blocked(f"the {role} reported {report['status']}: {concerns}")
|
|
103
|
+
return report
|
|
104
|
+
|
|
105
|
+
def read_only(self, number: str, slot: str, branch: str, tip: str, review: Callable[[], T]) -> T:
|
|
106
|
+
"""Run `review` (read-only agents) and return its result. A review that changes the slot runs once more."""
|
|
107
|
+
for last in (False, True):
|
|
108
|
+
error = ""
|
|
109
|
+
try:
|
|
110
|
+
result = review()
|
|
111
|
+
except Blocked as blocked:
|
|
112
|
+
error = str(blocked)
|
|
113
|
+
left = _restore_if_changed(number, slot, branch, tip, runs_again=not (error or last))
|
|
114
|
+
if error or not left:
|
|
115
|
+
break
|
|
116
|
+
if error or left:
|
|
117
|
+
raise Blocked(error or left)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
def gate(self, number: str, slot: str, commit: str) -> None:
|
|
121
|
+
"""Gate `commit` in the slot, unless a green record names it already."""
|
|
122
|
+
if commit in _gate_record(self.state.read(), number)["green_gates"]:
|
|
123
|
+
return
|
|
124
|
+
at = driver_slots.head(slot)
|
|
125
|
+
if at != commit:
|
|
126
|
+
raise Blocked(f"the slot is at {at[:7]}, not at {commit[:7]}: the gate did not run")
|
|
127
|
+
green, record = driver_gate.run(self.args.gate, slot, OUT / number, self.pr)
|
|
128
|
+
|
|
129
|
+
def count(state: dict) -> None:
|
|
130
|
+
state["run"]["gates"] += 1
|
|
131
|
+
if green:
|
|
132
|
+
_gate_record(state, number)["green_gates"].append(commit)
|
|
133
|
+
|
|
134
|
+
self.state.update(count)
|
|
135
|
+
if not green:
|
|
136
|
+
raise Blocked(f"the gate is red on {commit[:7]}, record {record}")
|
|
137
|
+
ledger(f"{_owner(number)}: the gate is green on {commit[:7]}, record {record}.")
|
|
138
|
+
|
|
139
|
+
def restore_locked_files(self, number: str, slot: str) -> list[str]:
|
|
140
|
+
"""Before a test-writer correction: put each locked file the implementer changed back to its locked
|
|
141
|
+
version in the tree, so the correction's commit carries no change of the implementer. Returns the paths."""
|
|
142
|
+
tickets = self.state.read()["tickets"]
|
|
143
|
+
paths = (driver_review.restore_locked(slot, tickets[number])
|
|
144
|
+
+ driver_merge.restore(slot, self.tip(), driver_merge.protected(tickets, number)))
|
|
145
|
+
for path in paths:
|
|
146
|
+
ledger(f"Ticket {number}: {path} was not the locked version at the correction: the driver restored it in "
|
|
147
|
+
"the tree, so the correction starts from the locked version.")
|
|
148
|
+
return paths
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _restore_if_changed(number: str, slot: str, branch: str, tip: str, runs_again: bool) -> str:
|
|
152
|
+
"""After read-only agents: "" when the slot is as it was; else restore it and return what they left."""
|
|
153
|
+
left = driver_review.disturbed(slot, branch, tip)
|
|
154
|
+
if left:
|
|
155
|
+
driver_review.restore(slot, branch, tip)
|
|
156
|
+
where, what = ("slot 1", "the final review") if number == FINAL else ("the slot", "the review")
|
|
157
|
+
again = f" and runs {what} again" if runs_again else ""
|
|
158
|
+
ledger(f"{_owner(number)}: a read-only role broke its rule: {left}; the driver restored {where} to "
|
|
159
|
+
f"{tip[:7]}{again}.")
|
|
160
|
+
return left
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _reset_if_moved(slot: str, branch: str, tip: str) -> str:
|
|
164
|
+
"""After an agent: "" when the slot is on `branch` and still holds `tip`; else reset it and say what was wrong."""
|
|
165
|
+
head = git(slot, "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
|
|
166
|
+
if git(slot, "rev-parse", "-q", "--verify", f"refs/heads/{branch}").returncode:
|
|
167
|
+
problem = f"branch {branch} is missing"
|
|
168
|
+
elif head != branch:
|
|
169
|
+
problem = f"HEAD is on {head or 'a detached commit'} instead of {branch}"
|
|
170
|
+
elif git(slot, "merge-base", "--is-ancestor", tip, "HEAD").returncode:
|
|
171
|
+
problem = f"{branch} no longer holds {tip[:7]}"
|
|
172
|
+
else:
|
|
173
|
+
return ""
|
|
174
|
+
git(slot, "checkout", "-q", "-f", "-B", branch, tip)
|
|
175
|
+
return f"{problem}; the driver reset the slot to {tip[:7]}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _timeout_lines(role: str, minutes: float, context: dict, alone: Callable[[], bool]) -> str:
|
|
179
|
+
"""The lines the rerun prompt gets after a timeout. The tree stays, but a read-only role has no work to keep:
|
|
180
|
+
its slot is restored, unless another agent still works there."""
|
|
181
|
+
values = {"minutes": f"{minutes:g}", "tip": context["start"]}
|
|
182
|
+
if role not in driver_agents.READ_ONLY:
|
|
183
|
+
return driver_agents.render("timeout", values)
|
|
184
|
+
if alone():
|
|
185
|
+
driver_review.restore(context["repo"], context["branch"], context["start"])
|
|
186
|
+
return driver_agents.render("timeout-restored", values)
|
|
187
|
+
return driver_agents.render("timeout-shared", values)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _rejection(role: str, code: int | None, minutes: float, report: Path, context: dict, alone: Callable[[], bool],
|
|
191
|
+
detached: bool) -> tuple[str, str]:
|
|
192
|
+
"""(why the attempt is rejected, the lines the rerun prompt gets); ("", ...) when the report is accepted."""
|
|
193
|
+
branch, tip = context["branch"], context["start"]
|
|
194
|
+
# Read-only agents share a slot, and a rebase is on a detached HEAD: there the driver has no branch to reset.
|
|
195
|
+
owns_branch = role not in driver_agents.READ_ONLY and not detached
|
|
196
|
+
if owns_branch and (moved := _reset_if_moved(context["repo"], branch, tip)):
|
|
197
|
+
return moved, driver_agents.render("moved", {"branch": branch, "tip": tip})
|
|
198
|
+
if code is None:
|
|
199
|
+
return f"timed out after {minutes:g} minutes", _timeout_lines(role, minutes, context, alone)
|
|
200
|
+
error = "; ".join(driver_report.validate(context["template"], str(report), context))
|
|
201
|
+
return error, driver_agents.render("rerun", {"error": error})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _first_prompt(template: str, values: dict[str, str], notes: tuple | list, report: str) -> str:
|
|
205
|
+
"""The template of the role, then the lines of each `notes` template and the ending of the prompt again."""
|
|
206
|
+
text = driver_agents.render(template, {**values, "report": report})
|
|
207
|
+
if notes:
|
|
208
|
+
text += "".join(driver_agents.render(note, values) for note in notes) + driver_agents.tail(text, report)
|
|
209
|
+
return text
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _record_attempt(run: Run, role: str, number: str, entry: str, seconds: float, log: Path, error: str) -> None:
|
|
213
|
+
cost = driver_agents.cost(log)
|
|
214
|
+
used = {"ticket": number, "role": role, "entry": entry, "seconds": round(seconds, 1), "cost": float(cost or 0)}
|
|
215
|
+
run.state.update(lambda state: state["run"]["agents"].append(used))
|
|
216
|
+
spent = f", cost {cost} USD" if cost else ""
|
|
217
|
+
ledger(f"{_owner(number)}: {role} ran on {entry} for {seconds:.1f}s{spent}: {error or 'report accepted'}.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _command(run: Run, role: str, entry: str, prompt: str, report: str) -> list[str]:
|
|
221
|
+
if run.args.runner: # `--runner` is for tests
|
|
222
|
+
return [run.args.runner, role, prompt, report]
|
|
223
|
+
return driver_agents.command(role, entry, report)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _attempts(run: Run, role: str, number: str, slot: str, values: dict[str, str], notes: tuple | list = (),
|
|
227
|
+
entry: str = "", fix_round: int = 0, check: dict | None = None, template: str = "",
|
|
228
|
+
alone: Callable[[], bool] = lambda: True, detached: bool = False) -> dict:
|
|
229
|
+
"""Run one role in its slot, with at most one rerun, and return the accepted report.
|
|
230
|
+
|
|
231
|
+
notes templates whose lines the first prompt gets after the role's own
|
|
232
|
+
entry the model entry, when it is not the first of the role's list
|
|
233
|
+
fix_round the K of the files `<role>-<K>.json`, `.log` and `.prompt.md`
|
|
234
|
+
check more context for the report validation (driver_report.validate)
|
|
235
|
+
template the prompt and schema, when it is not the one named after the role
|
|
236
|
+
alone whether no other agent works in the slot now
|
|
237
|
+
detached the agent works on a detached HEAD
|
|
238
|
+
"""
|
|
239
|
+
folder, entry = OUT / number, entry or run.models[role][0]
|
|
240
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
report, log, prompt = (folder / f"{role}-{fix_round}{suffix}" for suffix in (".json", ".log", ".prompt.md"))
|
|
242
|
+
report_in_slot = os.path.relpath(report, slot) # the agent works in its slot
|
|
243
|
+
context = {"repo": slot, "branch": values["branch"], "start": values["start_sha"], "log": str(log), "round": 0,
|
|
244
|
+
"template": template or role, **(check or {})}
|
|
245
|
+
argv = _command(run, role, entry, os.path.relpath(prompt, slot), report_in_slot)
|
|
246
|
+
text = _first_prompt(context["template"], values, notes, report_in_slot)
|
|
247
|
+
minutes, error = run.args.timeout[role], ""
|
|
248
|
+
for _ in range(2):
|
|
249
|
+
prompt.write_text(text)
|
|
250
|
+
report.unlink(missing_ok=True)
|
|
251
|
+
start = time.monotonic()
|
|
252
|
+
code = driver_agents.launch(argv, prompt, log, minutes * 60, slot)
|
|
253
|
+
seconds = time.monotonic() - start
|
|
254
|
+
error, rerun_lines = _rejection(role, code, minutes, report, context, alone, detached)
|
|
255
|
+
_record_attempt(run, role, number, entry, seconds, log, error)
|
|
256
|
+
if not error:
|
|
257
|
+
return json.loads(report.read_text())
|
|
258
|
+
# The rerun prompt is the first prompt, the rejection, then the same ending again.
|
|
259
|
+
text += rerun_lines + driver_agents.tail(text, report_in_slot)
|
|
260
|
+
if code is None:
|
|
261
|
+
minutes *= 2
|
|
262
|
+
raise Blocked(error)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Slots: the worktrees agents run in. Slot 1 is the PR worktree, slots 2..N live under .agent/worktrees/.
|
|
2
|
+
|
|
3
|
+
Every path is relative to the PR worktree, which is the driver's working directory for the whole run.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def git(slot: str, *args: str) -> subprocess.CompletedProcess[str]:
|
|
12
|
+
# core.quotePath off: git prints a path with a non-ASCII name as it is, so it equals the path that a report names.
|
|
13
|
+
return subprocess.run(["git", "-C", slot, "-c", "core.quotePath=false", *args], capture_output=True, text=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def names(listing: str) -> list[str]:
|
|
17
|
+
"""The paths of a listing that git made with `-z`."""
|
|
18
|
+
return [name for name in listing.split("\0") if name]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def head(slot: str) -> str:
|
|
22
|
+
return git(slot, "rev-parse", "HEAD").stdout.strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def path(number: int) -> str:
|
|
26
|
+
return "." if number == 1 else f".agent/worktrees/slot-{number}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create(number: int, prepare: str | None, keep: bool = False) -> str:
|
|
30
|
+
"""Make and prepare slot 2..N once, or reuse it off any branch (`keep`: as it is). Returns "" or the error."""
|
|
31
|
+
slot = path(number)
|
|
32
|
+
git(".", "worktree", "prune") # a slot directory that was removed by hand
|
|
33
|
+
if Path(slot, ".git").exists():
|
|
34
|
+
if not (keep or release(slot)):
|
|
35
|
+
return f"slot {number}: {slot} has uncommitted changes: commit or discard them"
|
|
36
|
+
return ""
|
|
37
|
+
added = git(".", "worktree", "add", "-q", "--detach", slot)
|
|
38
|
+
if added.returncode:
|
|
39
|
+
return f"slot {number}: cannot create {slot}: {added.stderr.strip()}"
|
|
40
|
+
if not prepare:
|
|
41
|
+
return ""
|
|
42
|
+
done = subprocess.run(prepare, shell=True, cwd=slot, capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
43
|
+
if done.returncode:
|
|
44
|
+
# An unprepared slot must not look ready to the next run.
|
|
45
|
+
git(".", "worktree", "remove", "--force", slot)
|
|
46
|
+
return (f"slot {number}: --prepare `{prepare}` exited {done.returncode} in {slot}: "
|
|
47
|
+
f"{(done.stderr or done.stdout).strip()}")
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup(count: int, prepare: str | None, keep: set[int] = frozenset()) -> list[str]:
|
|
52
|
+
"""Slots 2..count, before any agent. The first failure stops the run: [] or [the error]."""
|
|
53
|
+
for number in range(2, count + 1):
|
|
54
|
+
if error := create(number, prepare, number in keep):
|
|
55
|
+
return [error]
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def release(slot: str) -> bool:
|
|
60
|
+
"""Take a clean slot off its branch. False: the slot has uncommitted work, which stays where it is."""
|
|
61
|
+
if git(slot, "status", "--porcelain").stdout.strip():
|
|
62
|
+
return False
|
|
63
|
+
git(slot, "checkout", "-q", "--detach")
|
|
64
|
+
return True
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""state.json, the resume contract: one lock, every write a read-modify-write, published by rename.
|
|
2
|
+
|
|
3
|
+
{"run": {revision, pr_branch, gates, stops, agents: [{ticket, role, entry, seconds, cost}], questions, start,
|
|
4
|
+
landed: [[ticket, the PR tip its merge made]], final},
|
|
5
|
+
"tickets": {"NN": {phase, round, tip, test_writer_sha, locked, green_gates, slot, note, held}}}"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
from driver_slots import git, path
|
|
17
|
+
|
|
18
|
+
PHASES = ("pending", "test-writer", "implement", "gate", "review", "fix", "merge", "merged", "blocked")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ticket() -> dict:
|
|
22
|
+
return {"phase": "pending", "round": 0, "tip": None, "test_writer_sha": None, "locked": [], "green_gates": [],
|
|
23
|
+
"slot": None, "note": "", "held": {}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _final() -> dict:
|
|
27
|
+
"""The end-of-run record before it starts. Every key is here, so a reader uses [] and a wrong name fails."""
|
|
28
|
+
return {"skipped": "", "recovery": None, "tidied": None, "onto": None, "base": None, "commits": {},
|
|
29
|
+
"reviewed": None, "round": 0, "open": [], "batch": None, "green_gates": [], "pushed": None, "body": None,
|
|
30
|
+
"pr": None}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class State:
|
|
34
|
+
def __init__(self, path: Path):
|
|
35
|
+
self.path = path
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
def read(self) -> dict:
|
|
39
|
+
return json.loads(self.path.read_text())
|
|
40
|
+
|
|
41
|
+
def start(self, pr_branch: str, numbers: list[str]) -> dict:
|
|
42
|
+
"""Create the file for a new run, or add the tickets a resumed run does not know yet."""
|
|
43
|
+
def change(state: dict) -> None:
|
|
44
|
+
state.setdefault("run", {
|
|
45
|
+
"revision": uuid.uuid4().hex, "pr_branch": pr_branch, "gates": 0, "stops": 0, "agents": [],
|
|
46
|
+
"questions": [], "start": git(".", "rev-parse", f"refs/heads/{pr_branch}").stdout.strip(),
|
|
47
|
+
"landed": [], "final": _final()})
|
|
48
|
+
for key, value in _final().items(): # a run of an older driver lacks the newer keys
|
|
49
|
+
state["run"]["final"].setdefault(key, value)
|
|
50
|
+
tickets = state.setdefault("tickets", {})
|
|
51
|
+
for number in numbers:
|
|
52
|
+
tickets.setdefault(number, _ticket())
|
|
53
|
+
return self.update(change)
|
|
54
|
+
|
|
55
|
+
def update(self, change) -> dict:
|
|
56
|
+
"""Apply `change(state)` to the file as it is now. A reader sees the old file or the new one, never a part."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
state = self.read() if self.path.exists() else {}
|
|
59
|
+
change(state)
|
|
60
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
scratch = self.path.with_suffix(".json.tmp")
|
|
62
|
+
scratch.write_text(json.dumps(state, indent=1) + "\n")
|
|
63
|
+
os.replace(scratch, self.path)
|
|
64
|
+
return state
|
|
65
|
+
|
|
66
|
+
def ticket(self, number: str, **fields) -> dict:
|
|
67
|
+
return self.update(lambda state: state["tickets"][number].update(fields))["tickets"][number]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def lock(path: Path) -> IO | None:
|
|
71
|
+
"""The exclusive kernel lock on `path`, held while the returned file is open; None when another process has it."""
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
held = path.open("a")
|
|
74
|
+
try:
|
|
75
|
+
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
76
|
+
except BlockingIOError:
|
|
77
|
+
held.close()
|
|
78
|
+
return None
|
|
79
|
+
return held
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ancestor(commit: str, of: str) -> bool:
|
|
83
|
+
return git(".", "merge-base", "--is-ancestor", commit, of).returncode == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verified(entry: dict, branch: str, pr_branch: str) -> tuple[dict, str]:
|
|
87
|
+
"""(the fields to resume from, why) when git contradicts the entry; ({}, "") when it does not."""
|
|
88
|
+
phase, tip = entry["phase"], str(entry["tip"])
|
|
89
|
+
if phase not in ("gate", "review", "fix", "merge", "merged"):
|
|
90
|
+
return {}, ""
|
|
91
|
+
# The branch of a merged ticket is deleted, so its commits are trusted only while the PR branch has them.
|
|
92
|
+
where, word = (pr_branch, "in") if phase == "merged" else (branch, "on")
|
|
93
|
+
if _ancestor(tip, where):
|
|
94
|
+
return {}, ""
|
|
95
|
+
return {"phase": "implement", "tip": entry["test_writer_sha"]}, f"the recorded tip {tip[:7]} is not {word} {where}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def kept(state: dict) -> dict[str, int]:
|
|
99
|
+
"""ticket number -> slot, per ticket stopped mid-role or at an open question, with uncommitted work in the slot."""
|
|
100
|
+
found = {}
|
|
101
|
+
asking = {question["ticket"] for question in state.get("run", {}).get("questions", []) if question["answer"] is None}
|
|
102
|
+
for number, entry in state.get("tickets", {}).items():
|
|
103
|
+
if (entry["phase"] in ("test-writer", "implement") or number in asking) and entry["slot"]:
|
|
104
|
+
slot = path(entry["slot"])
|
|
105
|
+
on = git(slot, "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
|
|
106
|
+
if on == f"ticket/{number}" and git(slot, "status", "--porcelain").stdout.strip():
|
|
107
|
+
found[number] = entry["slot"]
|
|
108
|
+
return found
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The summary of a run, from state.json: plain sentences and the measurement table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _rows(run: dict, tickets: dict) -> list[tuple[str, object]]:
|
|
8
|
+
agents = Counter((agent["role"], agent["entry"]) for agent in run["agents"])
|
|
9
|
+
seconds: dict[str, float] = defaultdict(float)
|
|
10
|
+
for agent in run["agents"]:
|
|
11
|
+
seconds[agent["role"]] += agent["seconds"]
|
|
12
|
+
rounds = ", ".join(f"{number}: {entry['round']}" for number, entry in tickets.items())
|
|
13
|
+
return [("gates run", run["gates"]),
|
|
14
|
+
*((f"agents, {role} on {entry}", number) for (role, entry), number in agents.items()),
|
|
15
|
+
*((f"wall time, {role}", f"{total:.1f} s") for role, total in seconds.items()),
|
|
16
|
+
("fix rounds per ticket", rounds),
|
|
17
|
+
("human stops", run["stops"]),
|
|
18
|
+
("cost", f"{sum(agent['cost'] for agent in run['agents']):.2f} USD")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _end(run: dict) -> list[str]:
|
|
22
|
+
"""What the end of run did, from its record."""
|
|
23
|
+
final = run["final"]
|
|
24
|
+
if final["skipped"]:
|
|
25
|
+
return [f"The end-of-run work (rebase, tidy, final review, push) did not run: {final['skipped']}."]
|
|
26
|
+
if final["pushed"]:
|
|
27
|
+
return [f"{run['pr_branch']} is pushed at {final['pushed'][:7]}; its PR is {final['pr'] or 'not open'}."]
|
|
28
|
+
if final["reviewed"]:
|
|
29
|
+
return [f"The final review covers {run['pr_branch']} up to {final['reviewed'][:7]}. Nothing is pushed."]
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render(state: dict) -> str:
|
|
34
|
+
run, tickets = state["run"], state["tickets"]
|
|
35
|
+
merged = sum(entry["phase"] == "merged" for entry in tickets.values())
|
|
36
|
+
lines = [f"{merged} of {len(tickets)} tickets are merged into {run['pr_branch']}."]
|
|
37
|
+
lines += [f"Ticket {number} is not merged: its phase is {entry['phase']}."
|
|
38
|
+
for number, entry in tickets.items() if entry["phase"] != "merged"] + _end(run)
|
|
39
|
+
table = ["| Measure | Value |", "|---|---|"] + [f"| {name} | {value} |" for name, value in _rows(run, tickets)]
|
|
40
|
+
return "\n".join(lines + ["", *table]) + "\n"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Ticket files in, task graph or error list out.
|
|
2
|
+
|
|
3
|
+
parse(path) -> (tickets, errors): tickets is empty when errors is not; each error starts with the file name.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_FILE = re.compile(r"(\d+)-.*\.md$")
|
|
13
|
+
_TITLE = re.compile(r"# \d+: *(\S.*)")
|
|
14
|
+
_FIELD = re.compile(r"\*\*(Blocked by|Seam|Touches|Risk|Suspicions):\*\* *(\S.*)")
|
|
15
|
+
_CRITERION = re.compile(r"- \[[ xX]\] *(\S.*)")
|
|
16
|
+
_NONE = re.compile(r"none\b", re.IGNORECASE) # "None (can start immediately)", "none — refactor"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Ticket:
|
|
21
|
+
number: str # as in the file name: "01"
|
|
22
|
+
file: str
|
|
23
|
+
title: str | None
|
|
24
|
+
blocked: str | None # the Blocked by text; blocked_by holds the resolved numbers
|
|
25
|
+
seam: str | None
|
|
26
|
+
touches: str | None
|
|
27
|
+
risk: str | None
|
|
28
|
+
suspicions: str | None
|
|
29
|
+
criteria: list[str]
|
|
30
|
+
lines: dict[str, int] # field name -> 1-based line
|
|
31
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def refactor(self) -> bool:
|
|
35
|
+
"""Seam `none — refactor`: no criterion and no test-writer."""
|
|
36
|
+
return bool(_NONE.match(self.seam or ""))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read(path: Path) -> Ticket:
|
|
40
|
+
found: dict[str, tuple[str, int]] = {}
|
|
41
|
+
criteria: list[str] = []
|
|
42
|
+
for number, line in enumerate(path.read_text().splitlines(), 1):
|
|
43
|
+
if m := _TITLE.match(line):
|
|
44
|
+
found.setdefault("title", (m.group(1).strip(), number))
|
|
45
|
+
elif m := _FIELD.match(line):
|
|
46
|
+
found.setdefault(m.group(1), (m.group(2).strip(), number))
|
|
47
|
+
elif m := _CRITERION.match(line):
|
|
48
|
+
criteria.append(m.group(1).strip())
|
|
49
|
+
text = {name: value for name, (value, _) in found.items()}
|
|
50
|
+
return Ticket(
|
|
51
|
+
number=_FILE.match(path.name).group(1), file=path.name, title=text.get("title"),
|
|
52
|
+
blocked=text.get("Blocked by"), seam=text.get("Seam"), touches=text.get("Touches"),
|
|
53
|
+
risk=text.get("Risk"), suspicions=text.get("Suspicions"), criteria=criteria,
|
|
54
|
+
lines={name: line for name, (_, line) in found.items()},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _missing(ticket: Ticket, alone: bool) -> list[str]:
|
|
59
|
+
required = (("title", ticket.title), ("Blocked by", ticket.blocked), ("Seam", ticket.seam))
|
|
60
|
+
errors = [f"{ticket.file}: missing {name}" for name, value in required if not value]
|
|
61
|
+
if not ticket.criteria and not ticket.refactor:
|
|
62
|
+
errors.append(f"{ticket.file}: missing criterion")
|
|
63
|
+
if not ticket.touches and not alone:
|
|
64
|
+
where = f" (expected after Seam, line {ticket.lines['Seam']})" if ticket.seam else ""
|
|
65
|
+
errors.append(f"{ticket.file}: missing Touches{where}")
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _blockers(ticket: Ticket, numbers: dict[int, str], titles: dict[str, int]) -> list[str]:
|
|
70
|
+
errors: list[str] = []
|
|
71
|
+
# ponytail: entries split on commas, so a title that holds a comma must be named by number.
|
|
72
|
+
for part in (p.strip() for p in ticket.blocked.split(",")):
|
|
73
|
+
m = re.match(r"\d+", part)
|
|
74
|
+
key = int(m.group()) if m else titles.get(part.lower())
|
|
75
|
+
if key in numbers:
|
|
76
|
+
ticket.blocked_by.append(numbers[key])
|
|
77
|
+
else:
|
|
78
|
+
name = m.group() if m else repr(part)
|
|
79
|
+
errors.append(f"{ticket.file}: Blocked by {name} names no ticket (line {ticket.lines['Blocked by']})")
|
|
80
|
+
return errors
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _link(tickets: list[Ticket]) -> list[str]:
|
|
84
|
+
"""Fill blocked_by from each Blocked by text: numbers, titles, or both."""
|
|
85
|
+
numbers = {int(t.number): t.number for t in tickets}
|
|
86
|
+
titles = {t.title.lower(): int(t.number) for t in tickets if t.title}
|
|
87
|
+
blocked = [t for t in tickets if t.blocked and not _NONE.match(t.blocked)]
|
|
88
|
+
return [e for t in blocked for e in _blockers(t, numbers, titles)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cycles(tickets: list[Ticket]) -> list[str]:
|
|
92
|
+
by = {t.number: t for t in tickets}
|
|
93
|
+
done: set[str] = set()
|
|
94
|
+
errors: list[str] = []
|
|
95
|
+
|
|
96
|
+
def visit(number: str, path: list[str]) -> None:
|
|
97
|
+
if number in path:
|
|
98
|
+
files = [by[n].file for n in path[path.index(number):]]
|
|
99
|
+
errors.append(f"{files[0]}: Blocked by cycle: {' -> '.join(files + files[:1])}")
|
|
100
|
+
elif number not in done:
|
|
101
|
+
for blocker in by[number].blocked_by:
|
|
102
|
+
visit(blocker, path + [number])
|
|
103
|
+
done.add(number)
|
|
104
|
+
|
|
105
|
+
for number in sorted(by):
|
|
106
|
+
visit(number, [])
|
|
107
|
+
return errors
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse(path) -> tuple[list[Ticket], list[str]]:
|
|
111
|
+
files = sorted(p for p in Path(path).glob("*.md") if _FILE.match(p.name))
|
|
112
|
+
if not files:
|
|
113
|
+
return [], [f"{path}: no ticket files (NN-name.md)"]
|
|
114
|
+
tickets = [_read(p) for p in files]
|
|
115
|
+
errors = [e for t in tickets for e in _missing(t, alone=len(tickets) == 1)]
|
|
116
|
+
errors += _link(tickets)
|
|
117
|
+
errors += _cycles(tickets)
|
|
118
|
+
return ([], errors) if errors else (tickets, [])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def max_frontier_width(tickets: list[Ticket]) -> int:
|
|
122
|
+
"""The most tickets ready at once when every ready ticket merges before the next start."""
|
|
123
|
+
by = {t.number: t for t in tickets}
|
|
124
|
+
depths: dict[str, int] = {}
|
|
125
|
+
|
|
126
|
+
def depth(number: str) -> int:
|
|
127
|
+
if number not in depths:
|
|
128
|
+
depths[number] = 1 + max((depth(b) for b in by[number].blocked_by), default=0)
|
|
129
|
+
return depths[number]
|
|
130
|
+
|
|
131
|
+
# ponytail: blocking edges only; Touches holds belong to the scheduler (ticket 03).
|
|
132
|
+
return max(Counter(depth(n) for n in by).values())
|