Skip to content

Commit 0dfe393

Browse files
committed
docs: plan the auto-fix loop on failing PR checks
1 parent d117e31 commit 0dfe393

1 file changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
# Repository checks and the auto-fix loop
2+
3+
Status: design and delivery plan. Written 2026-08-19, after the invocation-ceiling
4+
fix (`e94f5d7c`) landed and was proven on our own production.
5+
6+
This document supersedes the polling proposal in
7+
[`2026-08-18-watch-external-ci-to-green.md`](./2026-08-18-watch-external-ci-to-green.md).
8+
That note assumed we would have to poll the provider pipeline. We do not: an
9+
event-driven trigger for failing CI already exists. What is left is smaller and
10+
different from what that note describes.
11+
12+
---
13+
14+
## 1. How it works today, in plain words
15+
16+
A workflow that writes code does its work inside a disposable sandbox. Before it
17+
pushes a branch and opens a pull request, it can run a list of shell commands in
18+
that sandbox. If they pass, the branch is published. If they fail, publication is
19+
blocked. That is the gate the product calls "Pre-PR checks".
20+
21+
Those commands do not live in the workflow. They live in one organization-wide
22+
configuration edited on a separate page (`/checks`), versioned like a document,
23+
with a history and a restore button. A workflow node called "Pre-PR checks" says
24+
only "run whatever is configured", plus one number: how many times an AI agent may
25+
try to repair a failure before the gate gives up.
26+
27+
That last part is the piece nobody can see. When a command fails, the node quietly
28+
starts an agent inside the same sandbox, hands it the failing output, lets it edit
29+
the code, then re-runs everything. Up to three times, by default. On the canvas
30+
this is one box with a subtitle reading "3 fix cycles". There is no second box, no
31+
edge, no progress. A user watching a run sees a node that says "running" for fifty
32+
minutes.
33+
34+
After the pull request is open, the story stops. The provider (GitHub Actions,
35+
GitLab CI) runs its own pipeline. Nothing in the product reacts to it. If that
36+
pipeline goes red, a human has to notice, copy the failure into a ticket comment,
37+
and move the ticket back into the AI column to start a completely new run.
38+
39+
That is the state a client is in right now: a merge request open, unit tests red,
40+
and the only available answer being "paste the log into the ticket by hand".
41+
42+
---
43+
44+
## 2. What we learned the hard way
45+
46+
Evidence, so none of this is re-litigated later.
47+
48+
**The gate used to die silently at 300 seconds.** Every check, every setup
49+
command, and every repair cycle ran inside a single `await` in a single durable
50+
step. Vercel kills one function invocation at 300 s. A tenant whose checks take
51+
longer lost the entire node, with no command output and no cause: the run reported
52+
`terminated`. Their real batch was 810 s. Fixed in `e94f5d7c` by launching batches
53+
detached and polling them across ticks. Proven on our production: run
54+
`wrun_01M0CVHRXFY98A7F7EYHK3X94S` kept the checks node alive for **3157 seconds in
55+
one attempt**, against a baseline (`wrun_01M0CGC9GEMEBC3THA2DNBECNJ`) that died at
56+
358631 ms.
57+
58+
**Green runs were checking nothing.** The gate reads a single global
59+
configuration. A tenant whose configuration lists two repositories, both with an
60+
empty setup phase, gets checks that either fail on the first command or, worse,
61+
exit 0 without doing anything. That tenant's configuration is now on version 12,
62+
which reads "No pre-PR checks configured. The gate is disabled."
63+
64+
**There is no setup phase in practice, so people paste one into the commands.**
65+
Reading one tenant's twelve configuration versions from the last 48 hours shows
66+
exactly this. Their `unify-frontend` entry is four bare commands with no install
67+
step at all:
68+
69+
```
70+
yarn lint:ci
71+
yarn check-upsolve-css
72+
yarn typecheck
73+
yarn test
74+
```
75+
76+
Their `arthur-scope` entry puts the install into check number one, so checks two
77+
through six run anyway and fail for a reason that has nothing to do with the code:
78+
79+
```
80+
cd scope/app_plane && uv sync --frozen && uv pip install -r ../lint_requirements.txt
81+
./scripts/openapi_client_utils.sh generate python && ./scripts/openapi_client_utils.sh install python
82+
cd scope/app_plane/app && uv run black . --check
83+
cd scope/app_plane/app && uv run python -m mypy . --strict --ignore-missing-imports ...
84+
python scripts/check_alembic_single_head.py
85+
cd scope/app_plane && ./local-dev/run_tests.sh -n 4
86+
```
87+
88+
`uv` does not exist in the sandbox image at all, and command five needs `python`
89+
on the path rather than `python3`. Meanwhile a third entry, for `arthur-engine`,
90+
shows someone rediscovering the setup phase by hand, in production, twice:
91+
92+
```
93+
curl -LsSf https://astral.sh/uv/install.sh | sh
94+
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh
95+
```
96+
97+
Twelve versions in two days is not a configuration problem. It is a missing
98+
product feature.
99+
100+
**The repair loop is expensive and blind.** Every cycle re-runs the entire batch,
101+
setup included, not just the command that failed (`pre-pr-checks.ts`,
102+
`runner.ts:206-244`). Three cycles of an 810 s batch is over 54 minutes against a
103+
100 minute run budget. Observed on our own fixture: three full cycles and 52
104+
minutes burned on `cd: genai-engine/ui: No such file or directory`. There was
105+
nothing for an agent to repair, and it could not tell.
106+
107+
**Nothing owns the pull request after it opens.** `failedChecks` exists only in
108+
the trigger and dispatch layer. A run started from a ticket never sees it.
109+
110+
---
111+
112+
## 3. What already exists (do not rebuild it)
113+
114+
This matters, because the obvious plan is bigger than the real one.
115+
116+
- **The trigger exists.** `trigger_pr_checks_failed`, registered at
117+
`block-registry.ts:414-448`, schema at `schema.ts:151-171`. It fires on a GitHub
118+
`check_run` completed with a failing conclusion and on a GitLab pipeline hook
119+
with `status: "failed"`. Both providers, signature-verified at the webhook route.
120+
- **Log retrieval exists.** `VCSAdapter.getCheckRunResults(prId)` returns entries
121+
with an optional `logs` field (`adapters/vcs/types.ts:86-113`). GitHub downloads
122+
the Actions job log; GitLab reads the job trace. It is already called from
123+
`fetch-pr-context.ts:176`.
124+
- **The whole workflow exists as a template.** `reviewFixAfterPrDefinition` in
125+
`templates.ts:169-260` wires exactly the shape we want:
126+
`trigger_pr_checks_failed` and `trigger_pr_review` into `prepare_workspace` into
127+
`fetch_pr_context` into `fix_agent` into `run_pre_pr_checks` into
128+
`finalize_workspace` into `post_pr_comment`.
129+
- **Pushing onto the open pull request exists.** `createOrFindPullRequest`
130+
(`repository-prs.ts:183-201`) finds the existing PR and returns it rather than
131+
opening a second one.
132+
- **Re-triggering after our own fix push is intended.** `workflow-push-suppression`
133+
only suppresses push-derived triggers as self-echo; a checks-failed event comes
134+
from the CI producer, so the loop closes naturally.
135+
- **The canvas can already draw a bounded repair loop.** `loop`, `branch` and a
136+
`generic_agent` with a writable workspace are enough (see Step 2).
137+
138+
So the event-driven loop is not a research project. Three concrete things stop it
139+
from working.
140+
141+
---
142+
143+
## 4. The three gaps, precisely
144+
145+
### Gap 1: the trigger is a no-op as shipped
146+
147+
`checkNames` defaults to `[]`, and an empty list makes `selectEligibleEvent` drop
148+
the event silently (`dispatch-trigger.ts:359`). It is an **allow-list of exact
149+
names**, not an ignore-list and not a pattern. The template hardcodes `["CI"]`,
150+
which matches no job name any real tenant uses.
151+
152+
Two consequences worth stating plainly: a user who adds this trigger and saves it
153+
gets a workflow that never fires and no explanation; and "ignore Meticulous" is
154+
not expressible as an ignore, only as an exhaustive list of everything that is not
155+
Meticulous.
156+
157+
### Gap 2: there is no per pull-request cap
158+
159+
`enforceTriggerRateLimit` is keyed on `{definitionId, nodeId}`
160+
(`trigger-rate-limit.ts:8-11`) over fixed UTC windows of a minute, hour, day or
161+
month. That is a global valve on a trigger node. It cannot express "at most two
162+
attempts on this pull request", and it has a bad failure mode for our purpose: one
163+
noisy repository exhausts the window for every other repository sharing the
164+
definition.
165+
166+
The loop needs a counter keyed on the pull request, persisted, that survives
167+
invocations and resets when the pull request's head moves for a reason that is not
168+
our own fix.
169+
170+
### Gap 3: the fix loop is invisible and unaddressable
171+
172+
The repair agent is a hidden `while` loop inside the checks node. It cannot be
173+
seen, re-ordered, given a different model, pointed at different instructions, or
174+
skipped for one repository. The only control is a number from 0 to 5.
175+
176+
---
177+
178+
## 5. The plan
179+
180+
### Step 0 (done): turn the hidden loop off by default
181+
182+
`MAX_PRE_PR_FIX_CYCLES` and the block default move from 3 to 0. The pre-PR gate
183+
goes back to being a gate: it runs commands and blocks publication. Repair moves
184+
to where the evidence is, after the pull request is open. A graph that wants the
185+
old behaviour can still author a number per node.
186+
187+
This also removes a real failure mode for free. `agent.ts` refuses to run repair
188+
when there is no pinned write-capable harness profile, which is what produced the
189+
client comment "The Pre-PR repair process could not be launched." With zero cycles
190+
that guard is never reached.
191+
192+
Verify: a run whose checks fail reports the failing command, exit code and output,
193+
and blocks publication, in one cycle, with no agent started.
194+
195+
### Step 1: reusable, named script sets per repository
196+
197+
Rename the concept. It is not "pre-PR checks", it is **repository checks**: a named
198+
set of commands attached to a repository, usable wherever the product runs
199+
commands.
200+
201+
Shape:
202+
203+
- Each repository gets named **phases** rather than one flat list. At minimum
204+
`setup` (install toolchains and dependencies, failure aborts the batch) and one
205+
or more named check groups (`lint`, `typecheck`, `test`, `build`), each a list of
206+
commands.
207+
- A check group is addressable by name, so a workflow node can say "run `lint` and
208+
`typecheck` for every changed repository" while the post-PR loop says "run
209+
`test`".
210+
- Setup phases become shareable, because the evidence above is three repositories
211+
pasting the same `uv` installer with three different sets of typos.
212+
213+
Constraints that must survive: the configuration stays versioned and append-only
214+
in `pre_pr_check_config_versions` (`schema.ts:834-840`), existing configurations
215+
keep working unchanged (an existing flat `commands` list reads as one unnamed
216+
group), and the root schema stays `.strict()`.
217+
218+
Open question for the product: does a check group belong to the organization
219+
(today's model) or to a workflow definition? Today one global configuration serves
220+
every definition, which is why one tenant's admin disabling the gate silently
221+
disabled it for everything.
222+
223+
### Step 2: make the fix agent a node on the canvas
224+
225+
Both of Filip's first two requirements are the same requirement: what happens
226+
should be visible where the workflow is drawn.
227+
228+
The good news, verified against the graph model rather than assumed: **the canvas
229+
can already express this. Nothing new has to be built to draw it.**
230+
231+
- `loop` is a control block with ports `continue` and `exhausted`, a bounded
232+
`maxAttempts` of 1 to 20, an `onExhaust` policy and a typed carry
233+
(`schema.ts:794-808`). A loop body may contain several nodes, and the graph
234+
validator requires `continue` to return to the loop while forbidding cycles
235+
anywhere else (`schema.ts:2797-2813`).
236+
- `branch` has ports `true` and `false` and reads an expression, so
237+
`steps.checks.output.ok` routes a failing batch (`workflow-graph.ts:78`).
238+
- `generic_agent` takes a free-text `prompt` from a binding
239+
(`block-registry.ts:717`) and, with `workspaceMode: read_write`, edits and
240+
commits in the same workspace. It is the only agent block that accepts arbitrary
241+
text; `fix_agent` has typed slots (`reviewFeedback`, `reviewResults`) and no slot
242+
for a failing command.
243+
244+
So the visible loop is authored, not coded:
245+
246+
```
247+
loop (maxAttempts: 2)
248+
-> run_pre_pr_checks
249+
-> branch on steps.checks.output.ok
250+
true -> finalize_workspace
251+
false -> generic_agent (workspaceMode: read_write,
252+
prompt bound to steps.checks.output.summary)
253+
-> back to loop.continue
254+
loop.exhausted -> post_pr_comment / update_ticket_status
255+
```
256+
257+
That is what Step 0 unlocks. With the internal repair off, the checks node reports
258+
and the graph decides. The user sees three boxes and two edges instead of one box
259+
with a subtitle, can point the repair node at a cheaper model, can change its
260+
instructions, and can delete it for one workflow without touching another.
261+
262+
Real work that remains, and it is small:
263+
264+
1. **A palette template for this shape**, so nobody has to wire five nodes by
265+
hand. The existing `reviewFixAfterPrDefinition` is the post-PR sibling of it.
266+
2. **Per-node live iteration display.** The canvas already shows a per-node run
267+
status with a glow and a pulsing dot (`flow-editor.tsx:271-384`), but the
268+
subtitle is static text from `nodeSummary` (`blocks.ts:64,179`) and inner
269+
iterations exist only in the trace. A loop that is on its second of two
270+
attempts should say so on the canvas.
271+
3. **Decide what the repair node actually receives.** Today the only bindable
272+
failure material is `summary`, a formatted string. If that turns out to be too
273+
thin, the checks output needs a structured `failures` array rather than a new
274+
block.
275+
276+
### Step 3: the post-PR auto-fix workflow
277+
278+
Deploy the existing template, then close the three gaps.
279+
280+
The loop: CI fails, the trigger fires, `fetch_pr_context` pulls the failing job
281+
names **and their logs**, the fix agent commits onto the same branch,
282+
`finalize_workspace` pushes to the open pull request, CI re-runs, and if it is
283+
still red the trigger fires again.
284+
285+
Work items, smallest first:
286+
287+
1. **Make `checkNames` usable.** Support an empty list meaning "any failing
288+
check", add an explicit ignore-list for the Meticulous case, and surface a
289+
validation warning in the editor when a saved trigger cannot ever fire.
290+
2. **Per pull-request attempt cap.** A new counter keyed on
291+
`(definitionId, provider, repoPath, prNumber)` with a configurable maximum,
292+
default 2. On exhaustion: stop, post one comment on the pull request saying the
293+
automatic fix gave up and why, and do not start a run. The counter resets when
294+
a human pushes to the branch.
295+
3. **Deploy and prove it**, on our own production first, on a repository whose CI
296+
we control and can make fail on demand.
297+
298+
Explicitly out of scope: polling the provider pipeline. The event-driven path
299+
makes it unnecessary.
300+
301+
---
302+
303+
## 6. Sequencing
304+
305+
The order is forced by what blocks what.
306+
307+
1. Step 0 ships with the next release. It is a default change and it removes a
308+
live failure mode for the client.
309+
2. Step 1 unblocks the client's two configuration-only repositories
310+
(`unify-frontend` and `arthur-scope` both need nothing but a setup phase) and
311+
should ship before anyone is asked to write another twelve config versions.
312+
3. Step 3 items 1 and 2 are independent of Step 1 and can run in parallel.
313+
4. Step 2 is mostly authoring, not engineering, and it becomes possible the moment
314+
Step 0 ships. Ship the template early; the live iteration display and the
315+
structured failures output can follow.
316+
317+
Nothing here is released to a client tenant before it has been reproduced and
318+
proven on our own production, on a fixture repository shaped like theirs.
319+
320+
---
321+
322+
## 7. Risks and open questions
323+
324+
- **Turning the hidden loop off makes some green runs red.** That is the point:
325+
a check that exits 0 having done nothing stops passing. It will still read as a
326+
regression to whoever is watching. It has to be communicated before the release,
327+
not after.
328+
- **A fix push re-runs CI, which costs the client money and minutes.** The per-PR
329+
cap is the only thing standing between a bad prompt and an expensive loop. It is
330+
a requirement, not a nicety.
331+
- **The fix agent will sometimes be wrong on the pull request.** Post-PR repair
332+
pushes commits a reviewer has to read. Whether the loop should push directly or
333+
propose is a product decision, not an engineering one.
334+
- **Sandbox environment variables do not exist.** The sandbox is created and
335+
commands are run without any `env` (`sandbox/manager.ts:90`,
336+
`pre-pr-checks/runner.ts:204`, `:273`), and the config schema has no field for a
337+
secret. Any repository whose install needs a token cannot be checked at all
338+
today except by pasting the token into the command text, which writes it to the
339+
database and the logs. This blocks one client repository outright and needs its
340+
own ticket.
341+
- **Which checks are gating.** Reading the provider's required-checks set is more
342+
honest than an allow-list maintained by hand, and it is not in this plan.

0 commit comments

Comments
 (0)