@nickysagan/issue-orchestrator 0.1.2 → 0.1.3
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 +80 -216
- package/bin/supervisor.mjs +357 -24
- package/package.json +1 -1
- package/src/repairGate.mjs +57 -0
- package/src/repairMarker.mjs +248 -0
- package/src/reviewGate.mjs +16 -7
- package/src/workerLogs.mjs +14 -10
package/README.md
CHANGED
|
@@ -6,18 +6,21 @@ subscription-authenticated `ccode` implementation workers alive — each in its
|
|
|
6
6
|
tmux window running:
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
ccode --print --permission-mode auto --model claude-opus-
|
|
9
|
+
ccode --print --permission-mode auto --model claude-opus-5 "/github-issue <number>"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
The full model name pins implementation workers to Opus 5 rather than the
|
|
13
|
+
Claude Code default or the moving `opus` alias. Non-interactive print mode
|
|
14
|
+
skips the workspace-trust prompt, and each process exits — closing its tmux
|
|
15
|
+
window — once its command finishes.
|
|
16
|
+
|
|
12
17
|
Usage limits are enforced by
|
|
13
18
|
[Usage Sentinel](https://github.com/Sadotu/usage-sentinel), which pauses this
|
|
14
19
|
orchestrator's entire Docker container — supervisor and workers together — rather
|
|
15
|
-
than this repository stopping workers itself.
|
|
16
|
-
workers to Opus 4.8 instead of the Claude Code default or the moving `opus`
|
|
17
|
-
alias.
|
|
20
|
+
than this repository stopping workers itself.
|
|
18
21
|
|
|
19
|
-
A finished PR is
|
|
20
|
-
|
|
22
|
+
A finished implementation PR is marked ready for the repository owner. The
|
|
23
|
+
supervisor does not review, repair, approve, or merge it automatically.
|
|
21
24
|
|
|
22
25
|
## Orchestrator
|
|
23
26
|
|
|
@@ -44,37 +47,24 @@ issue-orchestrator
|
|
|
44
47
|
```
|
|
45
48
|
|
|
46
49
|
The shared [`agent-devcontainer`](https://github.com/Sadotu/agent-devcontainer)
|
|
47
|
-
also exposes that command through the literal alias
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
start work
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
The supervisor resolves the `origin` of that current working directory,
|
|
54
|
-
checks GitHub App authentication, registers its own container with Sentinel, and
|
|
55
|
-
starts autonomous workers itself:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
ccode --print --permission-mode auto --model claude-opus-4-8 "/github-issue <number>"
|
|
59
|
-
```
|
|
50
|
+
also exposes that command through the literal alias `start work`.
|
|
60
51
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
The supervisor resolves the `origin` of that working directory, checks GitHub App
|
|
53
|
+
authentication, registers its own container with Sentinel, and starts the
|
|
54
|
+
autonomous workers itself. Do not start Claude separately. A supervisor that
|
|
55
|
+
cannot resolve its container ID or register its lease exits nonzero rather than
|
|
56
|
+
run unenforced.
|
|
65
57
|
|
|
66
58
|
Only one supervisor per repository per Linux network namespace may run at a
|
|
67
|
-
time
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
an LLM. Work begins only when a user explicitly runs `issue-orchestrator` or
|
|
77
|
-
its `start work` alias.
|
|
59
|
+
time; a second invocation for the same resolved `owner/repo` exits nonzero with
|
|
60
|
+
`already running` before authentication, the lease, tmux, or worker startup.
|
|
61
|
+
Ownership is a kernel-owned Linux abstract Unix socket, released on normal exit
|
|
62
|
+
and automatically when the process dies. Devcontainers normally have distinct
|
|
63
|
+
network namespaces and therefore independent guards; containers sharing a
|
|
64
|
+
namespace share this guard.
|
|
65
|
+
|
|
66
|
+
Neither the image build nor devcontainer startup launches the supervisor or an
|
|
67
|
+
LLM. Work begins only when a user runs `issue-orchestrator` or `start work`.
|
|
78
68
|
|
|
79
69
|
### Repository-checkout usage
|
|
80
70
|
|
|
@@ -87,226 +77,100 @@ tmux attach -t orchestrator # watch the workers directly
|
|
|
87
77
|
### How it works
|
|
88
78
|
|
|
89
79
|
- **Claim** — the lowest-numbered `agent-ready` issue has its label swapped
|
|
90
|
-
`agent-ready` → `agent-running`, then a tmux window `issue-<n>` opens
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
- **Vanished** — if a worker window disappears before the issue reaches a phase
|
|
105
|
-
label, the workflow died. There is no repair worker, so the supervisor fails
|
|
106
|
-
closed: it comments on the issue — including the vanished worker's log path
|
|
107
|
-
and the tail of its output — and sets `agent-blocked`. The issue is never
|
|
108
|
-
restarted automatically, because its draft PR or worktree may still exist.
|
|
109
|
-
- **Worker logs** — every launch `tee`s the window's combined output to
|
|
110
|
-
`<git-common-dir>/issue-orchestrator/logs/<issue|review>-<n>.<attempt>.log`,
|
|
111
|
-
keeping the latest three attempts. Surfaced tails are scrubbed of secrets. A
|
|
112
|
-
run that reaches its end stamps a final `[orchestrator <ISO>] ... finished ...`
|
|
113
|
-
line into its log, so a tail can be placed in time without the file's mtime.
|
|
114
|
-
- **Active-container lease** — at startup the supervisor resolves its own exact
|
|
115
|
-
64-character Docker container ID (from `/proc/self/mountinfo`; the short
|
|
116
|
-
`hostname` form is not accepted) and registers it with
|
|
117
|
-
`PUT /managed-containers/<id>` on Sentinel. Registration is a lease, not an
|
|
118
|
-
admission decision: Sentinel never allows or denies a start. Every later poll
|
|
119
|
-
re-PUTs the same ID as its heartbeat, well inside the five-minute lease; a
|
|
120
|
-
clean exit `DELETE`s it as best-effort cleanup, and an abrupt exit is handled
|
|
121
|
-
by Sentinel's lease expiry. A failed heartbeat is logged and retried on the
|
|
122
|
-
next poll. The supervisor keeps a local guard of two implementation workers
|
|
123
|
-
plus one reviewer.
|
|
124
|
-
- **Usage enforcement lives in Sentinel** — when Claude Code's five-hour or
|
|
125
|
-
weekly window reaches 95%, Sentinel `docker pause`s every registered container,
|
|
126
|
-
freezing this supervisor and every worker process in place, and unpauses only
|
|
127
|
-
after a fresh valid reading puts both windows below 95%. This repository reads
|
|
128
|
-
no usage telemetry, applies no thresholds, calls no Docker API, and never kills
|
|
129
|
-
a worker because of usage.
|
|
130
|
-
- **Stop** — the supervisor exits when nothing is queued, no worker or reviewer
|
|
131
|
-
window is live, and every managed issue rests in `agent-blocked` or
|
|
132
|
-
`user-merge-review`. Merge and post-merge cleanup monitoring are not yet
|
|
133
|
-
implemented.
|
|
80
|
+
`agent-ready` → `agent-running`, then a tmux window `issue-<n>` opens.
|
|
81
|
+
- **Complete** — Phase 6 marks the implementation PR ready for the owner. The
|
|
82
|
+
next poll closes any remaining worker window, removes `agent-running` from
|
|
83
|
+
the issue, and frees the slot.
|
|
84
|
+
- **Vanished** — if a worker disappears while its PR is still draft or absent,
|
|
85
|
+
the claim is released and the draft/worktree is preserved. It is not
|
|
86
|
+
restarted automatically.
|
|
87
|
+
- **Worker logs** — every implementation launch writes combined output to
|
|
88
|
+
`<git-common-dir>/issue-orchestrator/logs/issue-<n>.<attempt>.log`, keeping
|
|
89
|
+
the latest three attempts and scrubbing secrets from surfaced tails.
|
|
90
|
+
- **Usage enforcement** — Usage Sentinel owns pause/unpause thresholds. This
|
|
91
|
+
repository reads no usage telemetry and never kills a worker for usage.
|
|
92
|
+
- **Stop** — the supervisor exits when nothing is queued and no implementation
|
|
93
|
+
window is live.
|
|
134
94
|
|
|
135
95
|
Live container pause/unpause check:
|
|
136
96
|
[docs/smoke-checks/README.md](docs/smoke-checks/README.md).
|
|
137
97
|
|
|
138
|
-
##
|
|
139
|
-
|
|
140
|
-
A managed PR receives an independent automated review and then either stops with
|
|
141
|
-
documented blockers or becomes ready for the owner. The supervisor never
|
|
142
|
-
approves and never merges.
|
|
143
|
-
|
|
144
|
-
### Labels
|
|
145
|
-
|
|
146
|
-
At startup — after the repository is resolved and the GitHub App auth check
|
|
147
|
-
passes, and before the first poll — the supervisor creates any of these labels
|
|
148
|
-
that the repository is missing. Labels that already exist are never renamed,
|
|
149
|
-
recoloured, or re-described, and no GitHub mutation ever happens at image-build
|
|
150
|
-
time (the build has neither a target repository nor runtime credentials).
|
|
98
|
+
## Implementation-only handoff
|
|
151
99
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
| `agent-blocked` | `d73a4a` | Automated workflow blocked; findings posted |
|
|
158
|
-
| `user-merge-review` | `fbca04` | Automated review passed; waiting for owner review and merge |
|
|
100
|
+
`agent-running` remains the active ownership marker: it is applied when an
|
|
101
|
+
issue is claimed and removed only after the implementation PR is ready for the
|
|
102
|
+
owner, or when a vanished/failed launch is safely released. Existing review
|
|
103
|
+
labels are left untouched in repositories, but the supervisor does not create a
|
|
104
|
+
reviewer or repair worker and does not transition issues through them.
|
|
159
105
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
it. Issue labels are canonical and the PR mirrors them; every poll repairs a
|
|
164
|
-
mismatch, issuing an edit only when the sets actually differ.
|
|
165
|
-
|
|
166
|
-
A PR with no linked `agent-running` issue is ignored entirely — manual
|
|
167
|
-
`/github-issue` PRs and unrelated `agent/*` branches are never touched. More
|
|
168
|
-
than one PR closing an issue, a branch that disagrees with its closing
|
|
169
|
-
reference, or an `agent-review` issue with no PR all fail closed: an actionable
|
|
170
|
-
comment plus `agent-blocked`.
|
|
171
|
-
|
|
172
|
-
### Capacity
|
|
173
|
-
|
|
174
|
-
Two reserved implementation slots plus one reviewer slot that is never borrowed
|
|
175
|
-
for implementation. The pools are separate tmux window namespaces — `issue-<n>`
|
|
176
|
-
and `review-<n>` — each counting only its own live windows. The oldest eligible
|
|
177
|
-
`agent-review` PR gets a fresh reviewer window running:
|
|
178
|
-
|
|
179
|
-
```bash
|
|
180
|
-
ccode --print --permission-mode auto --model claude-opus-4-8 "/review-pr <pr>"
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
The reviewer slot frees as soon as that window exits. Waiting on CI holds no
|
|
184
|
-
slot, because it is pure label state with no window.
|
|
185
|
-
|
|
186
|
-
### Review marker
|
|
187
|
-
|
|
188
|
-
The reviewer (`Sadotu/agent-skills`, `review-pr`) records each pass in one
|
|
189
|
-
immutable PR comment carrying a single-line marker:
|
|
190
|
-
|
|
191
|
-
```
|
|
192
|
-
<!-- review-pr:v1 {"fingerprint":"<64-hex>","head":"<40-hex>","base":"<40-hex>","issueUpdatedAt":"<ISO>","prUpdatedAt":"<ISO>","issue":41,"pr":72,"pass":1,"verdict":"PASS"} -->
|
|
193
|
-
```
|
|
106
|
+
Manual and managed `/github-issue` runs both finish by marking the PR ready.
|
|
107
|
+
Human review and merge begin there. The supervisor exposes no approve or merge
|
|
108
|
+
operation.
|
|
194
109
|
|
|
195
|
-
|
|
196
|
-
it. Verdicts are exactly `PASS` and `BLOCKING`.
|
|
197
|
-
|
|
198
|
-
A marker is applied only when all three hold:
|
|
199
|
-
|
|
200
|
-
- **Authorship** — the comment has `viewerDidAuthor: true`, so the same GitHub
|
|
201
|
-
App identity the supervisor authenticates as wrote it. Every fingerprint input
|
|
202
|
-
is public, so without this check any commenter could forge a `PASS`.
|
|
203
|
-
- **Identity** — its `issue` and `pr` are the managed pair being gated.
|
|
204
|
-
- **Freshness** — its `fingerprint` equals the live fingerprint,
|
|
205
|
-
`sha256(head 0x1e base 0x1e issue-body 0x1e pr-body)`. Bodies decide
|
|
206
|
-
freshness, not `updatedAt`: a new comment or a label edit moves the timestamps
|
|
207
|
-
while reviewing the same content, whereas any real change to the head, the
|
|
208
|
-
base, the issue text, or the PR text invalidates the pass. `issueUpdatedAt`
|
|
209
|
-
and `prUpdatedAt` are recorded for audit only.
|
|
210
|
-
|
|
211
|
-
Comparing against live state rather than a snapshot captured at launch needs no
|
|
212
|
-
persistence, so the same content is handled once even across a supervisor
|
|
213
|
-
restart or a crash between the comment and the label transition. A marker that
|
|
214
|
-
fails any check is discarded silently: nothing is posted, nothing transitions,
|
|
215
|
-
and the PR simply queues a fresh pass. Parsing fails closed on an unsupported
|
|
216
|
-
tag, malformed JSON, or a missing or ill-typed field. A trusted, applicable
|
|
217
|
-
marker carrying an unsupported *verdict* is the one case that is not discarded —
|
|
218
|
-
it blocks, so a contract break surfaces instead of looping.
|
|
219
|
-
|
|
220
|
-
### Transitions
|
|
221
|
-
|
|
222
|
-
**`BLOCKING`** — the PR is kept or returned to draft and `agent-review` becomes
|
|
223
|
-
`agent-blocked` on issue and PR. The reviewer's findings stand alone in its own
|
|
224
|
-
comment; the supervisor adds nothing and stops. There is no automatic repair.
|
|
225
|
-
|
|
226
|
-
**`PASS`** — the PR is marked ready for review first, so repositories whose CI
|
|
227
|
-
triggers on `ready_for_review` start their checks. Checks are read on the
|
|
228
|
-
following poll, never in the same one that opened the PR:
|
|
229
|
-
|
|
230
|
-
| Checks | Outcome |
|
|
231
|
-
|---|---|
|
|
232
|
-
| None configured | Green → `user-merge-review` |
|
|
233
|
-
| All complete and successful, neutral, or skipped | Green → `user-merge-review` |
|
|
234
|
-
| Any still running | Keep `agent-review`; reviewer slot stays free |
|
|
235
|
-
| Any failed | Return to draft, `agent-blocked`, post the failing check names and URLs |
|
|
236
|
-
| Data unreadable | Transition nothing; retry next poll |
|
|
237
|
-
|
|
238
|
-
Every check GitHub reports is treated as required. "No checks configured" and
|
|
239
|
-
"could not read the checks" are deliberately distinct: only the former counts as
|
|
240
|
-
green.
|
|
241
|
-
|
|
242
|
-
### GitHub authentication
|
|
110
|
+
## GitHub authentication
|
|
243
111
|
|
|
244
112
|
`gh` does not auto-consume the GitHub App credential, so the supervisor mints a
|
|
245
|
-
short-lived
|
|
113
|
+
short-lived installation token (via `gh-app-token.sh`, overridable with
|
|
246
114
|
`GH_APP_TOKEN_SCRIPT`) and injects it as `GH_TOKEN` for every `gh` call,
|
|
247
|
-
re-minting each poll. At startup
|
|
248
|
-
|
|
249
|
-
|
|
115
|
+
re-minting each poll. At startup an authenticated `gh repo view` smoke check
|
|
116
|
+
exits with a clear message if the App is not authenticated, rather than churning
|
|
117
|
+
on unauthenticated calls every poll.
|
|
250
118
|
|
|
251
119
|
### Configuration
|
|
252
120
|
|
|
253
|
-
The local concurrency guard is fixed at two implementation slots
|
|
254
|
-
|
|
121
|
+
The local concurrency guard is fixed at two implementation slots. Two knobs
|
|
122
|
+
are environment-tunable:
|
|
255
123
|
|
|
256
124
|
| Var | Default | Meaning |
|
|
257
125
|
|-----|---------|---------|
|
|
258
126
|
| `SENTINEL_URL` | `http://usage-sentinel:4317` | Usage Sentinel base URL for the managed-container lease, on the shared container network; set this explicitly (for example, `http://host.docker.internal:4317`) only when Sentinel is exposed on the host |
|
|
259
127
|
| `POLL_MS` | `60000` | Poll interval |
|
|
260
128
|
|
|
261
|
-
Labels are created at startup — see the managed review gate
|
|
129
|
+
Labels are created at startup — see the managed review gate above.
|
|
262
130
|
|
|
263
131
|
## Repo contents
|
|
264
132
|
|
|
265
133
|
| Path | Purpose |
|
|
266
134
|
|------|---------|
|
|
267
135
|
| `agents.toml` / `agents.lock` | [dotagents](https://github.com/Sadotu/agent-skills) manifest — declares which skills are installed and pins their source commits |
|
|
268
|
-
| `.agents/skills/` | Installed skills (`github-issue`, `setup`) — managed artifacts, restored from the manifest, not committed |
|
|
136
|
+
| `.agents/skills/` | Installed skills (`address-review`, `github-issue`, `review-pr`, `setup`) — managed artifacts, restored from the manifest, not committed |
|
|
269
137
|
| `.claude/skills` | Symlink to `.agents/skills` so Claude Code picks the skills up |
|
|
270
138
|
| `CLAUDE.md` | Agent instructions and gotchas for working in this repo |
|
|
271
139
|
|
|
272
140
|
## Skills
|
|
273
141
|
|
|
274
|
-
- **`github-issue`** — runs
|
|
275
|
-
|
|
276
|
-
|
|
142
|
+
- **`github-issue`** — runs an issue end to end, opens a draft PR, implements
|
|
143
|
+
in an isolated worktree, verifies the result, and marks the PR ready for the
|
|
144
|
+
owner.
|
|
277
145
|
- **`setup`** — connects the repo to the `container-coding-agent` GitHub App
|
|
278
146
|
and verifies `git`/`gh` authenticate as the App.
|
|
279
147
|
|
|
280
|
-
|
|
148
|
+
The standalone `review-pr` and `address-review` skills remain installed for
|
|
149
|
+
manual use, but the production supervisor route does not invoke them.
|
|
281
150
|
|
|
282
151
|
## Releasing
|
|
283
152
|
|
|
284
|
-
`package.json` holds the version, and merging the bump is the whole release.
|
|
285
|
-
|
|
286
|
-
`.github/workflows/publish.yml` sees a version the registry does not carry,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
153
|
+
`package.json` holds the version, and merging the bump is the whole release. Bump
|
|
154
|
+
it in a normal pull request; when that lands on `main`,
|
|
155
|
+
`.github/workflows/publish.yml` sees a version the registry does not carry, runs
|
|
156
|
+
the tests, publishes, and pushes the matching `v<version>` tag. A merge without a
|
|
157
|
+
bump finds its version already published and exits without releasing. Pushing a
|
|
158
|
+
`v*` tag by hand takes the same path, with one extra guard: the tag must equal
|
|
159
|
+
`v<version>` or the run fails. A published version cannot be republished; bump
|
|
160
|
+
and merge again.
|
|
290
161
|
|
|
291
162
|
Publishing goes to npmjs via
|
|
292
|
-
[trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
`0.1.0` was published by hand (`npm login && npm publish`) to bootstrap it.
|
|
304
|
-
Every later release goes through the workflow.
|
|
305
|
-
|
|
306
|
-
Package visibility is set on the package, not inherited from this private
|
|
307
|
-
repository, and the setting appears only once a version exists. So after the
|
|
308
|
-
**first** release, set it once under **Sadotu → Packages →
|
|
309
|
-
issue-orchestrator → Package settings**.
|
|
163
|
+
[trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token
|
|
164
|
+
is stored anywhere — npm exchanges the workflow's OIDC identity for a short-lived
|
|
165
|
+
credential. Tagging is a separate job, so the job holding an npm credential
|
|
166
|
+
cannot write to this repository and the job that can push a tag holds no npm
|
|
167
|
+
credential.
|
|
168
|
+
|
|
169
|
+
Two one-time bootstraps, both because they need a package that already exists:
|
|
170
|
+
`0.1.0` was published by hand (`npm login && npm publish`) to enable trusted
|
|
171
|
+
publishing, and package visibility — set on the package, not inherited from this
|
|
172
|
+
private repository — must be set once after the first release under **Sadotu →
|
|
173
|
+
Packages → issue-orchestrator → Package settings**.
|
|
310
174
|
|
|
311
175
|
## Development environment
|
|
312
176
|
|
package/bin/supervisor.mjs
CHANGED
|
@@ -9,9 +9,15 @@ import { createManagedContainerClient, resolveContainerId as resolveOwnContainer
|
|
|
9
9
|
import { BLOCKED, MERGE_REVIEW, READY, REVIEW, RUNNING, phaseOf } from "../src/labels.mjs";
|
|
10
10
|
import { ensureLabels } from "../src/labels.mjs";
|
|
11
11
|
import { selectApplicableMarker } from "../src/reviewMarker.mjs";
|
|
12
|
+
import {
|
|
13
|
+
formatRepairAttemptComment, formatRepairBudgetComment, formatRepairFailureComment,
|
|
14
|
+
formatInvalidRepairHistoryComment, hasInvalidRepairHistoryComment,
|
|
15
|
+
hasRepairBudgetComment, hasRepairFailure,
|
|
16
|
+
} from "../src/repairMarker.mjs";
|
|
17
|
+
import { exactPhase, planRepair } from "../src/repairGate.mjs";
|
|
12
18
|
import { createWorkerLogs } from "../src/workerLogs.mjs";
|
|
13
19
|
import {
|
|
14
|
-
applyReviewPlan, classifyChecks, failClosed, mirrorLabels, planVerdict, resolveManagedPrs,
|
|
20
|
+
applyReviewPlan, classifyChecks, failClosed, mirrorLabels, planVerdict, resolveManagedPrs, setPhase,
|
|
15
21
|
} from "../src/reviewGate.mjs";
|
|
16
22
|
|
|
17
23
|
const run = promisify(execFile);
|
|
@@ -20,11 +26,17 @@ const run = promisify(execFile);
|
|
|
20
26
|
// Fixed defaults per the issue's simplicity constraints. Only the Sentinel URL
|
|
21
27
|
// and the poll interval are environment-tunable.
|
|
22
28
|
//
|
|
23
|
-
// Two
|
|
24
|
-
// borrowed
|
|
25
|
-
//
|
|
29
|
+
// Two shared implementation/repair slots plus one reviewer slot that is never
|
|
30
|
+
// borrowed. Issue and repair windows share one counter; review windows remain
|
|
31
|
+
// independent.
|
|
26
32
|
const IMPLEMENTATION_SLOTS = 2;
|
|
27
33
|
const REVIEWER_SLOTS = 1;
|
|
34
|
+
// A reviewer that exits without gating its PR is relaunched, because the usual
|
|
35
|
+
// cause is transient. It is not relaunched forever: after this many launches
|
|
36
|
+
// the issue fails closed, rather than burning a `ccode` run and the single
|
|
37
|
+
// reviewer slot every poll on a reviewer that cannot start.
|
|
38
|
+
const REVIEWER_ATTEMPTS = 3;
|
|
39
|
+
const REPAIR_ATTEMPTS = 2;
|
|
28
40
|
const POLL_MS = Number(process.env.POLL_MS || 60000);
|
|
29
41
|
const TOKEN_SCRIPT = process.env.GH_APP_TOKEN_SCRIPT || "/opt/agent-devcontainer/gh-app-token.sh";
|
|
30
42
|
const SESSION = "orchestrator";
|
|
@@ -126,6 +138,14 @@ export function createGitHub({ exec, repo }) {
|
|
|
126
138
|
.sort((a, b) => a.number - b.number);
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
async function getIssue(number) {
|
|
142
|
+
const { stdout } = await exec("gh", [
|
|
143
|
+
"issue", "view", String(number), ...R, "--json", "number,labels,updatedAt,body",
|
|
144
|
+
]);
|
|
145
|
+
const issue = JSON.parse(stdout || "null");
|
|
146
|
+
return issue && { ...issue, labels: names(issue.labels) };
|
|
147
|
+
}
|
|
148
|
+
|
|
129
149
|
// One call per poll carries everything the review gate needs: the marker
|
|
130
150
|
// fingerprint inputs (SHAs and body), mirror labels, and the check rollup.
|
|
131
151
|
async function listOpenPrs() {
|
|
@@ -135,6 +155,12 @@ export function createGitHub({ exec, repo }) {
|
|
|
135
155
|
return JSON.parse(stdout || "[]").map((pr) => ({ ...pr, labels: names(pr.labels) }));
|
|
136
156
|
}
|
|
137
157
|
|
|
158
|
+
async function getPr(number) {
|
|
159
|
+
const { stdout } = await exec("gh", ["pr", "view", String(number), ...R, "--json", PR_FIELDS]);
|
|
160
|
+
const pr = JSON.parse(stdout || "null");
|
|
161
|
+
return pr && { ...pr, labels: names(pr.labels) };
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
async function listPrComments(number) {
|
|
139
165
|
const { stdout } = await exec("gh", ["pr", "view", String(number), ...R, "--json", "comments"]);
|
|
140
166
|
return JSON.parse(stdout || "{}").comments || [];
|
|
@@ -164,6 +190,10 @@ export function createGitHub({ exec, repo }) {
|
|
|
164
190
|
// the only path that removes `agent-running`, which is otherwise durable
|
|
165
191
|
// until Phase 7 cleanup.
|
|
166
192
|
const restore = (n) => setIssueLabels(n, { add: [READY], remove: [RUNNING] });
|
|
193
|
+
// Implementation-only completion releases the active claim after the PR is
|
|
194
|
+
// ready for the repository owner. The label itself remains the queue's
|
|
195
|
+
// durable "worker owns this issue" marker while work is in progress.
|
|
196
|
+
const release = (n) => setIssueLabels(n, { remove: [RUNNING] });
|
|
167
197
|
|
|
168
198
|
const markPrReady = (n) => exec("gh", ["pr", "ready", String(n), ...R]);
|
|
169
199
|
const markPrDraft = (n) => exec("gh", ["pr", "ready", String(n), ...R, "--undo"]);
|
|
@@ -181,8 +211,8 @@ export function createGitHub({ exec, repo }) {
|
|
|
181
211
|
|
|
182
212
|
// Deliberately no approve and no merge: the supervisor never does either.
|
|
183
213
|
return {
|
|
184
|
-
listReadyIssues, listRunningIssues, listOpenPrs, listPrComments,
|
|
185
|
-
claim, restore, setIssueLabels, setPrLabels, markPrReady, markPrDraft,
|
|
214
|
+
listReadyIssues, listRunningIssues, getIssue, listOpenPrs, getPr, listPrComments,
|
|
215
|
+
claim, restore, release, setIssueLabels, setPrLabels, markPrReady, markPrDraft,
|
|
186
216
|
commentIssue, commentPr, listLabels, createLabel,
|
|
187
217
|
};
|
|
188
218
|
}
|
|
@@ -204,6 +234,14 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
204
234
|
return n;
|
|
205
235
|
}
|
|
206
236
|
|
|
237
|
+
function reviewFingerprint(value) {
|
|
238
|
+
const fingerprint = String(value ?? "");
|
|
239
|
+
if (!/^[0-9a-f]{64}$/.test(fingerprint)) {
|
|
240
|
+
throw new Error(`invalid review fingerprint: ${value}`);
|
|
241
|
+
}
|
|
242
|
+
return fingerprint;
|
|
243
|
+
}
|
|
244
|
+
|
|
207
245
|
// tmux passes its command to /bin/sh, which then invokes interactive bash so
|
|
208
246
|
// the subscription-authenticated `ccode` alias is available. Quote every
|
|
209
247
|
// model-derived or recorded value before it crosses either shell boundary.
|
|
@@ -225,7 +263,7 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
225
263
|
const set = new Set();
|
|
226
264
|
for (const line of stdout.split("\n")) {
|
|
227
265
|
const m = line.trim().match(pattern);
|
|
228
|
-
if (m) set.add(Number(m[1]));
|
|
266
|
+
if (m && Number(m[1]) > 0) set.add(Number(m[1]));
|
|
229
267
|
}
|
|
230
268
|
return set;
|
|
231
269
|
}
|
|
@@ -234,6 +272,7 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
234
272
|
// Reviewer windows are a separate namespace so the reserved reviewer slot can
|
|
235
273
|
// never be consumed by — or borrowed for — implementation.
|
|
236
274
|
const listReviewIssues = () => listWindows("review");
|
|
275
|
+
const listRepairIssues = () => listWindows("repair");
|
|
237
276
|
|
|
238
277
|
// Callers validate their numbers before calling this, so an invalid number
|
|
239
278
|
// still throws synchronously rather than rejecting a returned promise.
|
|
@@ -246,13 +285,18 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
246
285
|
// finish instant — the supervisor learns of it up to a poll later, while
|
|
247
286
|
// `tee` may still hold the file open. `date` matches `formatLogLine`'s ISO
|
|
248
287
|
// 8601 UTC shape, so both kinds of orchestrator line read alike.
|
|
249
|
-
async function newWindow(role, number, name, command, completion) {
|
|
250
|
-
const path = await logs.prepare(role, number);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
288
|
+
async function newWindow(role, number, name, command, completion, options = {}) {
|
|
289
|
+
const path = options.pathReserved ? options.path : await logs.prepare(role, number);
|
|
290
|
+
if (!path) {
|
|
291
|
+
return exec("tmux", ["new-window", "-t", session, "-n", name, `bash -ic ${shellQuote(command)}`]);
|
|
292
|
+
}
|
|
293
|
+
const pipeline = `bash -ic ${shellQuote(command)} 2>&1 | tee ${shellQuote(path)}`;
|
|
294
|
+
const status = options.preserveExitStatus ? `; status=\${PIPESTATUS[0]}` : "";
|
|
295
|
+
const stamp = options.preserveExitStatus
|
|
296
|
+
? `; printf '[orchestrator %s] %s (exit status %s)\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" ${shellQuote(completion)} "$status" >> ${shellQuote(path)}; exit "$status"`
|
|
297
|
+
: `; printf '[orchestrator %s] %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" ${shellQuote(completion)} >> ${shellQuote(path)}`;
|
|
298
|
+
const script = `${pipeline}${status}${stamp}`;
|
|
299
|
+
const cmd = options.preserveExitStatus ? `bash -c ${shellQuote(script)}` : script;
|
|
256
300
|
return exec("tmux", ["new-window", "-t", session, "-n", name, cmd]);
|
|
257
301
|
}
|
|
258
302
|
|
|
@@ -262,20 +306,42 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
262
306
|
// window command via a non-interactive shell that never sources .bashrc,
|
|
263
307
|
// so the alias would silently fail to resolve without `bash -ic`, which
|
|
264
308
|
// forces alias expansion regardless of login/interactive invocation.
|
|
265
|
-
const command = `ccode --print --permission-mode auto --model claude-opus-
|
|
309
|
+
const command = `ccode --print --permission-mode auto --model claude-opus-5 "/github-issue ${n}"`;
|
|
266
310
|
// No PR to name: the worker opens one mid-run, long after this launch.
|
|
267
311
|
return newWindow("issue", n, `issue-${n}`, command, `Worker finished for #${n}`);
|
|
268
312
|
}
|
|
269
313
|
|
|
270
314
|
// The reviewer is a read-only pass over an existing PR; the window is named
|
|
271
315
|
// for the issue so capacity accounting lines up with the managed issue set.
|
|
316
|
+
//
|
|
317
|
+
// `review-pr` reviews a linked *pair* and takes the issue first, then the PR
|
|
318
|
+
// — the order its SKILL.md and both consumer specs document, which is the
|
|
319
|
+
// reverse of the order its own snapshot/publish helper scripts take. The
|
|
320
|
+
// reviewer runs under `--print`, so a missing argument cannot be asked for:
|
|
321
|
+
// it would simply never resolve its inputs and never publish a pass.
|
|
272
322
|
function openReviewer(number, prNumber) {
|
|
273
323
|
const i = issueNumber(number);
|
|
274
324
|
const p = issueNumber(prNumber);
|
|
275
|
-
const command = `ccode --print --permission-mode auto --model claude-opus-
|
|
325
|
+
const command = `ccode --print --permission-mode auto --model claude-opus-5 "/review-pr ${i} ${p}"`;
|
|
276
326
|
return newWindow("review", i, `review-${i}`, command, `Reviewer finished for #${i} (PR #${p})`);
|
|
277
327
|
}
|
|
278
328
|
|
|
329
|
+
function openRepair(number, prNumber, fingerprint, reservedPath) {
|
|
330
|
+
const i = issueNumber(number);
|
|
331
|
+
const p = issueNumber(prNumber);
|
|
332
|
+
const f = reviewFingerprint(fingerprint);
|
|
333
|
+
if (reservedPath === undefined) {
|
|
334
|
+
throw new Error("reserved repair log path must be provided explicitly");
|
|
335
|
+
}
|
|
336
|
+
const command = `ccode --print --permission-mode auto --model claude-opus-5 "/address-review ${i} ${p} ${f}"`;
|
|
337
|
+
|
|
338
|
+
return newWindow("repair", i, `repair-${i}`, command, `Repair finished for #${i} (PR #${p})`, {
|
|
339
|
+
pathReserved: true, path: reservedPath, preserveExitStatus: true,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const prepareRepair = (number) => logs.prepare("repair", issueNumber(number));
|
|
344
|
+
|
|
279
345
|
// Suppress ONLY a confirmed "window absent" failure (idempotent close). Any
|
|
280
346
|
// other tmux failure (e.g. server down) propagates so the caller does not
|
|
281
347
|
// wrongly free the slot for a window that may still be alive.
|
|
@@ -290,7 +356,105 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
290
356
|
}
|
|
291
357
|
}
|
|
292
358
|
|
|
293
|
-
return {
|
|
359
|
+
return {
|
|
360
|
+
ensureSession, listWorkerIssues, listReviewIssues, listRepairIssues,
|
|
361
|
+
openWorker, openReviewer, prepareRepair, openRepair, closeWorker,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Map open PRs to the implementation issue they close. GitHub's parsed closing
|
|
366
|
+
// linkage is authoritative; the conventional branch prefix is a fallback.
|
|
367
|
+
export function implementationPrForIssue(prs, number) {
|
|
368
|
+
const prefix = `agent/${number}-`;
|
|
369
|
+
const closesIssue = (pr) => Array.isArray(pr.closingIssuesReferences)
|
|
370
|
+
&& pr.closingIssuesReferences.some((ref) => ref?.number === number);
|
|
371
|
+
const matchesBranch = (pr) => typeof pr.headRefName === "string"
|
|
372
|
+
&& pr.headRefName.startsWith(prefix);
|
|
373
|
+
return (prs || []).find((pr) => closesIssue(pr) || matchesBranch(pr)) || null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Production poll route after the managed-review rollback. It deliberately
|
|
377
|
+
// knows only queue, implementation worker and owner-ready PR states. Review,
|
|
378
|
+
// repair, approval and merge remain outside the supervisor.
|
|
379
|
+
export async function runImplementationOnlyOnce({
|
|
380
|
+
gh, tmux,
|
|
381
|
+
checkSetupReady = async () => true,
|
|
382
|
+
implementationSlots = IMPLEMENTATION_SLOTS,
|
|
383
|
+
log,
|
|
384
|
+
logs = NO_WORKER_LOGS,
|
|
385
|
+
runningIssues,
|
|
386
|
+
}) {
|
|
387
|
+
await tmux.ensureSession();
|
|
388
|
+
const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
|
|
389
|
+
const liveIssues = await tmux.listWorkerIssues();
|
|
390
|
+
const prs = await gh.listOpenPrs();
|
|
391
|
+
let liveCount = liveIssues.size;
|
|
392
|
+
|
|
393
|
+
for (const issue of running) {
|
|
394
|
+
try {
|
|
395
|
+
const pr = implementationPrForIssue(prs, issue.number);
|
|
396
|
+
if (pr && pr.isDraft === false) {
|
|
397
|
+
if (liveIssues.has(issue.number)) {
|
|
398
|
+
await tmux.closeWorker(issue.number);
|
|
399
|
+
liveIssues.delete(issue.number);
|
|
400
|
+
liveCount -= 1;
|
|
401
|
+
}
|
|
402
|
+
await gh.release(issue.number);
|
|
403
|
+
log(`Completed #${issue.number} (PR ready for owner) — agent-running released`);
|
|
404
|
+
} else if (!liveIssues.has(issue.number)) {
|
|
405
|
+
const diagnostics = await logs.diagnostics("issue", issue.number);
|
|
406
|
+
const detail = diagnostics
|
|
407
|
+
? `; log: ${diagnostics.path}\n${diagnostics.tail}`
|
|
408
|
+
: "";
|
|
409
|
+
await gh.release(issue.number);
|
|
410
|
+
log(`Worker for #${issue.number} vanished; PR still draft (${pr ? pr.url : "no PR"}) — released, not restarted${detail}`);
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
log(`reconcile error for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const ready = await gh.listReadyIssues();
|
|
418
|
+
if (ready.length > 0 && !await checkSetupReady()) {
|
|
419
|
+
log("Agent setup incomplete — pausing worker launches");
|
|
420
|
+
return {
|
|
421
|
+
done: false, implLive: liveCount, reviewLive: 0,
|
|
422
|
+
started: 0, reviewsStarted: 0, repairsStarted: 0,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let started = 0;
|
|
427
|
+
for (const number of ready) {
|
|
428
|
+
if (liveCount >= implementationSlots) break;
|
|
429
|
+
let claimed = false;
|
|
430
|
+
try {
|
|
431
|
+
await gh.claim(number);
|
|
432
|
+
claimed = true;
|
|
433
|
+
await tmux.openWorker(number);
|
|
434
|
+
liveCount += 1;
|
|
435
|
+
started += 1;
|
|
436
|
+
log(`Started worker for #${number}`);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
const cleanup = [];
|
|
439
|
+
if (claimed) {
|
|
440
|
+
try {
|
|
441
|
+
await gh.restore(number);
|
|
442
|
+
} catch (cleanupError) {
|
|
443
|
+
cleanup.push(`restore: ${cleanupError.message}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
log(`failed to start #${number} (leaving for next poll): ${err.message}${cleanup.length ? `; cleanup failed: ${cleanup.join(", ")}` : ""}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
done: ready.length === 0 && liveCount === 0,
|
|
452
|
+
implLive: liveCount,
|
|
453
|
+
reviewLive: 0,
|
|
454
|
+
started,
|
|
455
|
+
reviewsStarted: 0,
|
|
456
|
+
repairsStarted: 0,
|
|
457
|
+
};
|
|
294
458
|
}
|
|
295
459
|
|
|
296
460
|
// ---- one poll cycle ---------------------------------------------------------
|
|
@@ -305,6 +469,7 @@ export async function runOnce({
|
|
|
305
469
|
checkSetupReady = async () => true,
|
|
306
470
|
implementationSlots = IMPLEMENTATION_SLOTS,
|
|
307
471
|
reviewerSlots = REVIEWER_SLOTS,
|
|
472
|
+
reviewerAttempts = REVIEWER_ATTEMPTS,
|
|
308
473
|
log,
|
|
309
474
|
logs = NO_WORKER_LOGS,
|
|
310
475
|
runningIssues,
|
|
@@ -314,15 +479,21 @@ export async function runOnce({
|
|
|
314
479
|
const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
|
|
315
480
|
const liveImpl = await tmux.listWorkerIssues();
|
|
316
481
|
const liveReview = await tmux.listReviewIssues();
|
|
482
|
+
const liveRepair = await tmux.listRepairIssues();
|
|
317
483
|
const prs = await gh.listOpenPrs();
|
|
318
484
|
|
|
319
|
-
let implLive = liveImpl.size;
|
|
485
|
+
let implLive = liveImpl.size + liveRepair.size;
|
|
320
486
|
let reviewLive = liveReview.size;
|
|
321
487
|
|
|
322
488
|
// Phase state as this poll last knew it, updated in place by each transition
|
|
323
489
|
// so the exit condition below reflects the work just done.
|
|
324
490
|
const phases = new Map(running.map((issue) => [issue.number, phaseOf(issue.labels)]));
|
|
325
491
|
const { managed, problems } = resolveManagedPrs(running, prs);
|
|
492
|
+
const commentsByPr = new Map();
|
|
493
|
+
const prComments = async (number) => {
|
|
494
|
+
if (!commentsByPr.has(number)) commentsByPr.set(number, gh.listPrComments(number));
|
|
495
|
+
return commentsByPr.get(number);
|
|
496
|
+
};
|
|
326
497
|
|
|
327
498
|
// --- linkage ambiguity fails closed ---------------------------------------
|
|
328
499
|
for (const problem of problems) {
|
|
@@ -370,15 +541,89 @@ export async function runOnce({
|
|
|
370
541
|
}
|
|
371
542
|
}
|
|
372
543
|
|
|
544
|
+
// --- blocked repair reconciliation and eligibility ------------------------
|
|
545
|
+
// First reconcile durable reservations and collect complete candidates.
|
|
546
|
+
// Launching happens only after every managed item has been inspected.
|
|
547
|
+
const eligibleRepairs = [];
|
|
548
|
+
const liveRepairPairs = new Set();
|
|
549
|
+
const reconciledRepairPairs = new Set();
|
|
550
|
+
for (const { issue, pr } of managed) {
|
|
551
|
+
try {
|
|
552
|
+
if (![BLOCKED, REVIEW].includes(phases.get(issue.number))) continue;
|
|
553
|
+
const comments = await prComments(pr.number);
|
|
554
|
+
const identity = { issue: issue.number, pr: pr.number };
|
|
555
|
+
const plan = planRepair({
|
|
556
|
+
issue, pr, comments, phase: phases.get(issue.number),
|
|
557
|
+
liveRepair: liveRepair.has(issue.number), maxAttempts: REPAIR_ATTEMPTS,
|
|
558
|
+
});
|
|
559
|
+
if (plan.action === "invalid-history") {
|
|
560
|
+
log(`#${issue.number}: invalid repair history — leaving blocked for manual intervention`);
|
|
561
|
+
const sourceFingerprint = plan.latestReview?.fingerprint;
|
|
562
|
+
if (sourceFingerprint && !hasInvalidRepairHistoryComment(comments, identity, sourceFingerprint)) {
|
|
563
|
+
await gh.commentPr(pr.number, formatInvalidRepairHistoryComment({ ...identity, sourceFingerprint }));
|
|
564
|
+
}
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (plan.action === "live") {
|
|
568
|
+
liveRepairPairs.add(issue.number);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (plan.action === "review") {
|
|
572
|
+
await setPhase({ gh, issue, pr, phase: REVIEW });
|
|
573
|
+
phases.set(issue.number, REVIEW);
|
|
574
|
+
reconciledRepairPairs.add(issue.number);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (plan.action === "failed") {
|
|
578
|
+
const { reservation } = plan;
|
|
579
|
+
await setPhase({ gh, issue, pr, phase: BLOCKED });
|
|
580
|
+
phases.set(issue.number, BLOCKED);
|
|
581
|
+
reconciledRepairPairs.add(issue.number);
|
|
582
|
+
if (!hasRepairFailure(comments, identity, reservation.attempt, reservation.sourceFingerprint)) {
|
|
583
|
+
const diagnostics = await logs.diagnostics("repair", issue.number);
|
|
584
|
+
const correlated = reservation.log && diagnostics?.path === reservation.log ? diagnostics : null;
|
|
585
|
+
const exitMatch = String(correlated?.tail ?? "").match(/exit status\s+(\d+)/i);
|
|
586
|
+
await gh.commentPr(pr.number, formatRepairFailureComment({
|
|
587
|
+
...identity,
|
|
588
|
+
attempt: reservation.attempt,
|
|
589
|
+
sourceFingerprint: reservation.sourceFingerprint,
|
|
590
|
+
logUrl: correlated?.path ?? null,
|
|
591
|
+
exitCode: exitMatch ? Number(exitMatch[1]) : "unavailable",
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (plan.action === "budget") {
|
|
597
|
+
if (!hasRepairBudgetComment(comments, identity, plan.marker.fingerprint)) {
|
|
598
|
+
await gh.commentPr(pr.number, formatRepairBudgetComment({
|
|
599
|
+
...identity,
|
|
600
|
+
maxAttempts: REPAIR_ATTEMPTS,
|
|
601
|
+
sourceFingerprint: plan.marker.fingerprint,
|
|
602
|
+
blockingReviewUrls: plan.history.blockingPasses.map(({ url }) => url),
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (plan.action !== "eligible") continue;
|
|
608
|
+
eligibleRepairs.push({
|
|
609
|
+
issue, pr, attempt: plan.attempt, sourceFingerprint: plan.sourceFingerprint,
|
|
610
|
+
blockingCreatedAt: plan.blockingCreatedAt,
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
log(`repair reconciliation error for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
373
617
|
// --- review reconciliation -------------------------------------------------
|
|
374
618
|
const needsReview = [];
|
|
375
619
|
for (const { issue, pr } of managed) {
|
|
376
620
|
try {
|
|
621
|
+
if (liveRepairPairs.has(issue.number) || reconciledRepairPairs.has(issue.number)) continue;
|
|
377
622
|
await mirrorLabels({ gh, issue, pr });
|
|
378
623
|
if (phases.get(issue.number) !== REVIEW) continue;
|
|
379
624
|
|
|
380
625
|
const checkedAt = now();
|
|
381
|
-
const comments = await
|
|
626
|
+
const comments = await prComments(pr.number);
|
|
382
627
|
const marker = selectApplicableMarker(comments, {
|
|
383
628
|
issue: issue.number, pr: pr.number,
|
|
384
629
|
head: pr.headRefOid, base: pr.baseRefOid,
|
|
@@ -403,10 +648,10 @@ export async function runOnce({
|
|
|
403
648
|
}
|
|
404
649
|
|
|
405
650
|
const ready = await gh.listReadyIssues();
|
|
406
|
-
const wantsLaunch = ready.length > 0 || needsReview.length > 0;
|
|
651
|
+
const wantsLaunch = ready.length > 0 || needsReview.length > 0 || eligibleRepairs.length > 0;
|
|
407
652
|
if (wantsLaunch && !await checkSetupReady()) {
|
|
408
653
|
log("Agent setup incomplete — pausing worker and reviewer launches");
|
|
409
|
-
return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0 };
|
|
654
|
+
return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0, repairsStarted: 0 };
|
|
410
655
|
}
|
|
411
656
|
|
|
412
657
|
// --- reviewer launches (oldest PR first) -----------------------------------
|
|
@@ -424,6 +669,23 @@ export async function runOnce({
|
|
|
424
669
|
// eventually displace it and the relaunch itself explains nothing.
|
|
425
670
|
const diagnostics = await logs.diagnostics("review", issue.number);
|
|
426
671
|
if (diagnostics) {
|
|
672
|
+
// The reviewer log's attempt number is the durable launch count, so a
|
|
673
|
+
// reviewer that never gates its PR is bounded instead of relaunched
|
|
674
|
+
// every poll forever. A missing count (no log at all, or a helper that
|
|
675
|
+
// reports none) keeps the unbounded behaviour: logs are best effort and
|
|
676
|
+
// must never be what blocks an issue.
|
|
677
|
+
if (Number.isInteger(diagnostics.attempt) && diagnostics.attempt >= reviewerAttempts) {
|
|
678
|
+
await failClosed({
|
|
679
|
+
gh, issue, pr, log,
|
|
680
|
+
message: `The reviewer for #${issue.number} exited without gating PR #${pr.number}`
|
|
681
|
+
+ ` after ${diagnostics.attempt} attempts. Its linked PR is ${pr.url}.`
|
|
682
|
+
+ `\n\nReviewer log: \`${diagnostics.path}\`\n\n\`\`\`\n${diagnostics.tail}\n\`\`\``,
|
|
683
|
+
});
|
|
684
|
+
phases.set(issue.number, BLOCKED);
|
|
685
|
+
// The slot is deliberately not consumed: another PR can use it in
|
|
686
|
+
// this same poll.
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
427
689
|
log(`A previous reviewer for #${issue.number} (${pr.url}) left ${diagnostics.path}:\n${diagnostics.tail}`);
|
|
428
690
|
}
|
|
429
691
|
await tmux.openReviewer(issue.number, pr.number);
|
|
@@ -435,6 +697,76 @@ export async function runOnce({
|
|
|
435
697
|
}
|
|
436
698
|
}
|
|
437
699
|
|
|
700
|
+
// --- repair launches (oldest blocking review first) -----------------------
|
|
701
|
+
eligibleRepairs.sort((a, b) =>
|
|
702
|
+
String(a.blockingCreatedAt ?? "").localeCompare(String(b.blockingCreatedAt ?? ""))
|
|
703
|
+
|| a.issue.number - b.issue.number);
|
|
704
|
+
let repairsStarted = 0;
|
|
705
|
+
for (const repair of eligibleRepairs) {
|
|
706
|
+
if (implLive >= implementationSlots) break;
|
|
707
|
+
const { issue, pr, attempt, sourceFingerprint } = repair;
|
|
708
|
+
let currentIssue;
|
|
709
|
+
let currentPr;
|
|
710
|
+
try {
|
|
711
|
+
currentIssue = await gh.getIssue(issue.number);
|
|
712
|
+
currentPr = await gh.getPr(pr.number);
|
|
713
|
+
const comments = await gh.listPrComments(pr.number);
|
|
714
|
+
const freshPlan = planRepair({
|
|
715
|
+
issue: currentIssue, pr: currentPr, comments, phase: BLOCKED,
|
|
716
|
+
liveRepair: false, maxAttempts: REPAIR_ATTEMPTS,
|
|
717
|
+
});
|
|
718
|
+
if (freshPlan.action === "invalid-history") {
|
|
719
|
+
const identity = { issue: issue.number, pr: pr.number };
|
|
720
|
+
const fingerprint = freshPlan.latestReview?.fingerprint;
|
|
721
|
+
if (fingerprint && !hasInvalidRepairHistoryComment(comments, identity, fingerprint)) {
|
|
722
|
+
await gh.commentPr(pr.number, formatInvalidRepairHistoryComment({ ...identity, sourceFingerprint: fingerprint }));
|
|
723
|
+
}
|
|
724
|
+
log(`#${issue.number}: repair history became invalid during launch revalidation`);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const linked = currentPr?.closingIssuesReferences?.some(({ number }) => number === issue.number);
|
|
728
|
+
const valid = currentIssue?.number === issue.number && currentPr?.number === pr.number && linked
|
|
729
|
+
&& freshPlan.action === "eligible" && freshPlan.attempt === attempt
|
|
730
|
+
&& freshPlan.sourceFingerprint === sourceFingerprint;
|
|
731
|
+
if (!valid) {
|
|
732
|
+
if (freshPlan.action === "review"
|
|
733
|
+
&& ["stale-review-snapshot", "reserved-head-changed"].includes(freshPlan.reason)
|
|
734
|
+
&& currentIssue && currentPr
|
|
735
|
+
&& exactPhase(currentIssue.labels, BLOCKED) && exactPhase(currentPr.labels, BLOCKED)) {
|
|
736
|
+
await setPhase({ gh, issue: currentIssue, pr: currentPr, phase: REVIEW });
|
|
737
|
+
phases.set(issue.number, REVIEW);
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
} catch (err) {
|
|
742
|
+
log(`failed to revalidate repair for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const logPath = await tmux.prepareRepair(issue.number);
|
|
746
|
+
try {
|
|
747
|
+
await gh.commentPr(pr.number, formatRepairAttemptComment({
|
|
748
|
+
issue: issue.number,
|
|
749
|
+
pr: pr.number,
|
|
750
|
+
attempt,
|
|
751
|
+
maxAttempts: REPAIR_ATTEMPTS,
|
|
752
|
+
sourceFingerprint,
|
|
753
|
+
head: currentPr.headRefOid,
|
|
754
|
+
log: logPath,
|
|
755
|
+
}));
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log(`failed to reserve repair for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
await tmux.openRepair(issue.number, pr.number, sourceFingerprint, logPath);
|
|
762
|
+
implLive += 1;
|
|
763
|
+
repairsStarted += 1;
|
|
764
|
+
log(`Started repair ${attempt} for #${issue.number} (PR #${pr.number})`);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
log(`failed to start repair for #${issue.number} (reservation remains for reconciliation): ${err.message}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
438
770
|
// --- implementation launches ----------------------------------------------
|
|
439
771
|
let started = 0;
|
|
440
772
|
for (const n of ready) {
|
|
@@ -460,8 +792,9 @@ export async function runOnce({
|
|
|
460
792
|
// nothing is queued or live. Merge and cleanup monitoring belong to later
|
|
461
793
|
// groups.
|
|
462
794
|
const settled = [...phases.values()].every((phase) => phase === BLOCKED || phase === MERGE_REVIEW);
|
|
463
|
-
const done = ready.length === 0 &&
|
|
464
|
-
|
|
795
|
+
const done = ready.length === 0 && eligibleRepairs.length === 0
|
|
796
|
+
&& implLive === 0 && reviewLive === 0 && settled;
|
|
797
|
+
return { done, implLive, reviewLive, started, reviewsStarted, repairsStarted };
|
|
465
798
|
}
|
|
466
799
|
|
|
467
800
|
// ---- real-environment wiring ------------------------------------------------
|
|
@@ -518,7 +851,7 @@ export async function main({
|
|
|
518
851
|
bootstrapLabels = bootstrapRepoLabels,
|
|
519
852
|
resolveContainerId: resolveContainer = resolveOwnContainerId,
|
|
520
853
|
createLeaseClient = createConfiguredLease,
|
|
521
|
-
runPoll =
|
|
854
|
+
runPoll = runImplementationOnlyOnce,
|
|
522
855
|
checkSetupReady = isAgentSetupReady,
|
|
523
856
|
sleepImpl = sleep,
|
|
524
857
|
log = (message) => console.log(formatLogLine(message)),
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BLOCKED, PHASE_LABELS, REVIEW, RUNNING } from "./labels.mjs";
|
|
2
|
+
import { selectApplicableMarker } from "./reviewMarker.mjs";
|
|
3
|
+
import {
|
|
4
|
+
selectLatestTrustedReview, selectLatestTrustedReviewContext,
|
|
5
|
+
selectRepairHistory, selectRepairReservation,
|
|
6
|
+
} from "./repairMarker.mjs";
|
|
7
|
+
|
|
8
|
+
export function exactPhase(labels, phase) {
|
|
9
|
+
const set = new Set(labels || []);
|
|
10
|
+
return set.has(RUNNING) && set.has(phase)
|
|
11
|
+
&& PHASE_LABELS.filter((label) => set.has(label)).length === 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function planRepair({ issue, pr, comments, phase, liveRepair, maxAttempts }) {
|
|
15
|
+
if (![BLOCKED, REVIEW].includes(phase)) return { action: "skip" };
|
|
16
|
+
if (!liveRepair && exactPhase(issue.labels, REVIEW) && exactPhase(pr.labels, REVIEW)) return { action: "skip" };
|
|
17
|
+
|
|
18
|
+
const identity = { issue: issue.number, pr: pr.number };
|
|
19
|
+
const history = selectRepairHistory(comments, identity);
|
|
20
|
+
const latestReview = selectLatestTrustedReview(comments, identity);
|
|
21
|
+
const latestContext = selectLatestTrustedReviewContext(comments, identity);
|
|
22
|
+
if (!history.valid) return { action: "invalid-history", history, latestReview };
|
|
23
|
+
const reservation = latestReview?.verdict === "BLOCKING"
|
|
24
|
+
? selectRepairReservation(comments, identity, latestReview.fingerprint)
|
|
25
|
+
: null;
|
|
26
|
+
if (reservation) {
|
|
27
|
+
if (liveRepair) return { action: "live", reservation };
|
|
28
|
+
if (reservation.head !== pr.headRefOid) {
|
|
29
|
+
return { action: "review", reason: "reserved-head-changed", reservation };
|
|
30
|
+
}
|
|
31
|
+
return { action: "failed", reservation };
|
|
32
|
+
}
|
|
33
|
+
if (phase !== BLOCKED || !exactPhase(issue.labels, BLOCKED) || !exactPhase(pr.labels, BLOCKED)) {
|
|
34
|
+
return { action: "skip" };
|
|
35
|
+
}
|
|
36
|
+
const marker = selectApplicableMarker(comments, {
|
|
37
|
+
...identity, head: pr.headRefOid, base: pr.baseRefOid,
|
|
38
|
+
issueBody: issue.body, prBody: pr.body,
|
|
39
|
+
});
|
|
40
|
+
const applicable = marker && latestReview
|
|
41
|
+
&& marker.fingerprint === latestReview.fingerprint && marker.pass === latestReview.pass;
|
|
42
|
+
if (!applicable || marker.verdict !== "BLOCKING") {
|
|
43
|
+
return latestReview?.verdict === "BLOCKING"
|
|
44
|
+
? { action: "review", reason: "stale-review-snapshot" }
|
|
45
|
+
: { action: "skip" };
|
|
46
|
+
}
|
|
47
|
+
if (!pr.isDraft) return { action: "skip" };
|
|
48
|
+
if (history.attempts.length >= maxAttempts) {
|
|
49
|
+
return { action: "budget", marker, history };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
action: "eligible", marker, history,
|
|
53
|
+
attempt: history.attempts.length + 1,
|
|
54
|
+
sourceFingerprint: marker.fingerprint,
|
|
55
|
+
blockingCreatedAt: latestContext?.createdAt,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { parseReviewMarkers } from "./reviewMarker.mjs";
|
|
2
|
+
|
|
3
|
+
export const REPAIR_MARKER_TAG = "issue-orchestrator:repair-attempt v1";
|
|
4
|
+
|
|
5
|
+
const ATTEMPT_PATTERN = /^<!-- issue-orchestrator:repair-attempt v1 (\{.*\}) -->$/m;
|
|
6
|
+
const ATTEMPT_FIELDS = ["issue", "pr", "attempt", "sourceFingerprint", "head", "log"];
|
|
7
|
+
const FAILURE_FIELDS = ["issue", "pr", "attempt", "sourceFingerprint"];
|
|
8
|
+
const BUDGET_FIELDS = ["issue", "pr", "sourceFingerprint"];
|
|
9
|
+
const INVALID_HISTORY_FIELDS = ["issue", "pr", "sourceFingerprint"];
|
|
10
|
+
const FAILURE_PATTERN = /^<!-- issue-orchestrator:repair-failure v1 (\{.*\}) -->$/m;
|
|
11
|
+
const BUDGET_PATTERN = /^<!-- issue-orchestrator:repair-budget-exhausted v1 (\{.*\}) -->$/m;
|
|
12
|
+
const INVALID_HISTORY_PATTERN = /^<!-- issue-orchestrator:repair-history-invalid v1 (\{.*\}) -->$/m;
|
|
13
|
+
|
|
14
|
+
function positiveInteger(value) {
|
|
15
|
+
return Number.isInteger(value) && value > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validIdentity(value) {
|
|
19
|
+
return value && positiveInteger(value.issue) && positiveInteger(value.pr);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fingerprint = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
23
|
+
const commit = (value) => typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
24
|
+
|
|
25
|
+
function exactPayload(value, fields) {
|
|
26
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
27
|
+
&& Object.keys(value).length === fields.length
|
|
28
|
+
&& fields.every((field) => Object.hasOwn(value, field));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function attemptPayload(value) {
|
|
32
|
+
if (!exactPayload(value, ATTEMPT_FIELDS)) return null;
|
|
33
|
+
if (!positiveInteger(value.issue) || !positiveInteger(value.pr) || !positiveInteger(value.attempt)) return null;
|
|
34
|
+
if (!fingerprint(value.sourceFingerprint) || !commit(value.head)) return null;
|
|
35
|
+
if (value.log !== null && (typeof value.log !== "string" || value.log.length === 0)) return null;
|
|
36
|
+
return Object.fromEntries(ATTEMPT_FIELDS.map((field) => [field, value[field]]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseRepairAttempt(body) {
|
|
40
|
+
const match = ATTEMPT_PATTERN.exec(String(body ?? ""));
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
try {
|
|
43
|
+
return attemptPayload(JSON.parse(match[1]));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trustedAttempt(comment, identity) {
|
|
50
|
+
if (comment?.viewerDidAuthor !== true) return null;
|
|
51
|
+
const marker = parseRepairAttempt(comment.body);
|
|
52
|
+
if (!marker || marker.issue !== identity.issue || marker.pr !== identity.pr) return null;
|
|
53
|
+
return marker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function selectRepairReservation(comments, identity, sourceFingerprint) {
|
|
57
|
+
if (!validIdentity(identity) || typeof sourceFingerprint !== "string" || sourceFingerprint.length === 0) return null;
|
|
58
|
+
let found = null;
|
|
59
|
+
for (const comment of comments || []) {
|
|
60
|
+
const marker = trustedAttempt(comment, identity);
|
|
61
|
+
if (marker?.sourceFingerprint === sourceFingerprint) found = marker;
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function trustedReviews(comment, identity) {
|
|
67
|
+
if (comment?.viewerDidAuthor !== true) return [];
|
|
68
|
+
return parseReviewMarkers(comment.body).filter((marker) => (
|
|
69
|
+
marker.issue === identity.issue && marker.pr === identity.pr
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function selectLatestTrustedReview(comments, identity) {
|
|
74
|
+
if (!validIdentity(identity)) return null;
|
|
75
|
+
let found = null;
|
|
76
|
+
for (const comment of comments || []) {
|
|
77
|
+
for (const marker of trustedReviews(comment, identity)) found = marker;
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function selectLatestTrustedReviewContext(comments, identity) {
|
|
83
|
+
if (!validIdentity(identity)) return null;
|
|
84
|
+
let found = null;
|
|
85
|
+
for (const comment of comments || []) {
|
|
86
|
+
for (const marker of trustedReviews(comment, identity)) {
|
|
87
|
+
found = { marker, createdAt: comment.createdAt, url: comment.url };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function selectRepairHistory(comments, identity) {
|
|
94
|
+
if (!validIdentity(identity)) return { valid: false, attempts: [], blockingPasses: [] };
|
|
95
|
+
const all = Array.from(comments || []);
|
|
96
|
+
let start = 0;
|
|
97
|
+
for (let index = 0; index < all.length; index += 1) {
|
|
98
|
+
if (trustedReviews(all[index], identity).some(({ verdict }) => verdict === "PASS")) start = index + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const attempts = [];
|
|
102
|
+
const blockingPasses = [];
|
|
103
|
+
const blockingSources = new Set();
|
|
104
|
+
const attemptedSources = new Set();
|
|
105
|
+
let valid = true;
|
|
106
|
+
for (const comment of all.slice(start)) {
|
|
107
|
+
for (const marker of trustedReviews(comment, identity)) {
|
|
108
|
+
if (marker.verdict === "BLOCKING") {
|
|
109
|
+
blockingPasses.push({ fingerprint: marker.fingerprint, pass: marker.pass, url: comment.url });
|
|
110
|
+
blockingSources.add(`${marker.fingerprint}\0${marker.head}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const marker = trustedAttempt(comment, identity);
|
|
114
|
+
if (!marker) continue;
|
|
115
|
+
if (!blockingSources.has(`${marker.sourceFingerprint}\0${marker.head}`)) continue;
|
|
116
|
+
if (attemptedSources.has(marker.sourceFingerprint)) {
|
|
117
|
+
valid = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (marker.attempt !== attempts.length + 1) {
|
|
121
|
+
valid = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
attempts.push(marker);
|
|
125
|
+
attemptedSources.add(marker.sourceFingerprint);
|
|
126
|
+
}
|
|
127
|
+
return { valid, attempts, blockingPasses };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasTrustedMarker(comments, identity, pattern, fields, validPayload, matches) {
|
|
131
|
+
for (const comment of comments || []) {
|
|
132
|
+
if (comment?.viewerDidAuthor !== true) continue;
|
|
133
|
+
const match = pattern.exec(String(comment.body ?? ""));
|
|
134
|
+
if (!match) continue;
|
|
135
|
+
try {
|
|
136
|
+
const marker = JSON.parse(match[1]);
|
|
137
|
+
if (exactPayload(marker, fields) && validPayload(marker)
|
|
138
|
+
&& marker.issue === identity.issue && marker.pr === identity.pr
|
|
139
|
+
&& matches(marker)) return true;
|
|
140
|
+
} catch {
|
|
141
|
+
// Malformed comments are untrusted state, never durable evidence.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function hasRepairFailure(comments, identity, attempt, sourceFingerprint) {
|
|
148
|
+
if (!validIdentity(identity) || !positiveInteger(attempt) || !fingerprint(sourceFingerprint)) return false;
|
|
149
|
+
return hasTrustedMarker(
|
|
150
|
+
comments, identity, FAILURE_PATTERN, FAILURE_FIELDS,
|
|
151
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
152
|
+
&& positiveInteger(marker.attempt) && fingerprint(marker.sourceFingerprint),
|
|
153
|
+
(marker) => marker.attempt === attempt && marker.sourceFingerprint === sourceFingerprint,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasRepairBudgetComment(comments, identity, sourceFingerprint) {
|
|
158
|
+
if (!validIdentity(identity) || !fingerprint(sourceFingerprint)) return false;
|
|
159
|
+
return hasTrustedMarker(
|
|
160
|
+
comments, identity, BUDGET_PATTERN, BUDGET_FIELDS,
|
|
161
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
162
|
+
&& fingerprint(marker.sourceFingerprint),
|
|
163
|
+
(marker) => marker.sourceFingerprint === sourceFingerprint,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hasInvalidRepairHistoryComment(comments, identity, sourceFingerprint) {
|
|
168
|
+
if (!validIdentity(identity) || !fingerprint(sourceFingerprint)) return false;
|
|
169
|
+
return hasTrustedMarker(
|
|
170
|
+
comments, identity, INVALID_HISTORY_PATTERN, INVALID_HISTORY_FIELDS,
|
|
171
|
+
(marker) => positiveInteger(marker.issue) && positiveInteger(marker.pr)
|
|
172
|
+
&& fingerprint(marker.sourceFingerprint),
|
|
173
|
+
(marker) => marker.sourceFingerprint === sourceFingerprint,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requireCommentInput(input, { attempt = false } = {}) {
|
|
178
|
+
if (!validIdentity(input)) throw new TypeError("issue and pr must be positive integers");
|
|
179
|
+
if (!fingerprint(input.sourceFingerprint)) {
|
|
180
|
+
throw new TypeError("sourceFingerprint must be 64 lowercase hex characters");
|
|
181
|
+
}
|
|
182
|
+
if (attempt && !positiveInteger(input.attempt)) throw new TypeError("attempt must be a positive integer");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hidden(tag, payload) {
|
|
186
|
+
return `<!-- ${tag} ${JSON.stringify(payload)} -->`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatRepairAttemptComment(input) {
|
|
190
|
+
requireCommentInput(input, { attempt: true });
|
|
191
|
+
if (!commit(input.head)) throw new TypeError("head must be 40 hex characters");
|
|
192
|
+
if (input.log !== undefined && input.log !== null
|
|
193
|
+
&& (typeof input.log !== "string" || input.log.length === 0)) {
|
|
194
|
+
throw new TypeError("log must be a nonempty string or null");
|
|
195
|
+
}
|
|
196
|
+
if (!positiveInteger(input.maxAttempts)) throw new TypeError("maxAttempts must be a positive integer");
|
|
197
|
+
const marker = {
|
|
198
|
+
issue: input.issue, pr: input.pr, attempt: input.attempt,
|
|
199
|
+
sourceFingerprint: input.sourceFingerprint, head: input.head,
|
|
200
|
+
log: input.log ?? null,
|
|
201
|
+
};
|
|
202
|
+
return [
|
|
203
|
+
`Automatic repair attempt ${input.attempt} of ${input.maxAttempts} reserved for review source \`${input.sourceFingerprint}\`.`,
|
|
204
|
+
"",
|
|
205
|
+
hidden(REPAIR_MARKER_TAG, marker),
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formatRepairFailureComment(input) {
|
|
210
|
+
requireCommentInput(input, { attempt: true });
|
|
211
|
+
const details = [];
|
|
212
|
+
if (input.exitCode !== undefined) details.push(`Exit code ${input.exitCode}.`);
|
|
213
|
+
if (typeof input.logUrl === "string" && input.logUrl.length > 0) details.push(`Logs: ${input.logUrl}`);
|
|
214
|
+
else details.push("Log location unavailable.");
|
|
215
|
+
return [
|
|
216
|
+
`Automatic repair attempt ${input.attempt} failed for review source \`${input.sourceFingerprint}\`.`,
|
|
217
|
+
...details,
|
|
218
|
+
"",
|
|
219
|
+
hidden("issue-orchestrator:repair-failure v1", {
|
|
220
|
+
issue: input.issue, pr: input.pr, attempt: input.attempt, sourceFingerprint: input.sourceFingerprint,
|
|
221
|
+
}),
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function formatRepairBudgetComment(input) {
|
|
226
|
+
requireCommentInput(input);
|
|
227
|
+
if (!positiveInteger(input.maxAttempts)) throw new TypeError("maxAttempts must be a positive integer");
|
|
228
|
+
const urls = Array.from(input.blockingReviewUrls || []).filter((url) => typeof url === "string" && url.length > 0);
|
|
229
|
+
return [
|
|
230
|
+
`${input.maxAttempts} automatic repair attempts have been exhausted for review source \`${input.sourceFingerprint}\`.`,
|
|
231
|
+
...(urls.length > 0 ? ["", "Blocking review passes:", ...urls.map((url) => `- ${url}`)] : []),
|
|
232
|
+
"",
|
|
233
|
+
hidden("issue-orchestrator:repair-budget-exhausted v1", {
|
|
234
|
+
issue: input.issue, pr: input.pr, sourceFingerprint: input.sourceFingerprint,
|
|
235
|
+
}),
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatInvalidRepairHistoryComment(input) {
|
|
240
|
+
requireCommentInput(input);
|
|
241
|
+
return [
|
|
242
|
+
`Invalid automatic repair history detected for review source \`${input.sourceFingerprint}\`. Manual intervention is required.`,
|
|
243
|
+
"",
|
|
244
|
+
hidden("issue-orchestrator:repair-history-invalid v1", {
|
|
245
|
+
issue: input.issue, pr: input.pr, sourceFingerprint: input.sourceFingerprint,
|
|
246
|
+
}),
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
package/src/reviewGate.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BLOCKED, MANAGED_LABELS, MERGE_REVIEW, REVIEW, phaseOf } from "./labels.mjs";
|
|
1
|
+
import { BLOCKED, MANAGED_LABELS, MERGE_REVIEW, PHASE_LABELS, REVIEW, phaseOf } from "./labels.mjs";
|
|
2
2
|
|
|
3
3
|
const BRANCH_PREFIX = /^agent\/(\d+)-/;
|
|
4
4
|
|
|
@@ -189,12 +189,21 @@ export function planLabelMirror(issueLabels, prLabels) {
|
|
|
189
189
|
|
|
190
190
|
// Swap the issue's phase label and mirror it onto the PR. `agent-running` is
|
|
191
191
|
// never removed here — it is durable until Phase 7.
|
|
192
|
-
async function setPhase({ gh, issue, pr, phase }) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
export async function setPhase({ gh, issue, pr, phase }) {
|
|
193
|
+
const deltaFor = (labels) => {
|
|
194
|
+
const current = new Set(labels || []);
|
|
195
|
+
return {
|
|
196
|
+
add: current.has(phase) ? [] : [phase],
|
|
197
|
+
remove: PHASE_LABELS.filter((label) => label !== phase && current.has(label)),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const issueDelta = deltaFor(issue.labels);
|
|
201
|
+
if (issueDelta.add.length || issueDelta.remove.length) await gh.setIssueLabels(issue.number, issueDelta);
|
|
202
|
+
if (pr) {
|
|
203
|
+
if (!Array.isArray(pr.labels)) throw new Error("PR labels must be provided");
|
|
204
|
+
const prDelta = deltaFor(pr.labels);
|
|
205
|
+
if (prDelta.add.length || prDelta.remove.length) await gh.setPrLabels(pr.number, prDelta);
|
|
206
|
+
}
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
export async function mirrorLabels({ gh, issue, pr }) {
|
package/src/workerLogs.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Durable per-attempt worker logs.
|
|
2
2
|
//
|
|
3
|
-
// A tmux worker's output
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// surface a bounded, redacted tail when it later notices the worker is gone.
|
|
3
|
+
// A tmux worker's pane output disappears with its window. Every launch reserves
|
|
4
|
+
// a file here and streams its combined output into it, preserving evidence the
|
|
5
|
+
// supervisor can surface as a bounded, redacted tail after the worker is gone.
|
|
7
6
|
//
|
|
8
7
|
// Everything is best effort: a log that cannot be written must never cost a
|
|
9
8
|
// worker, so every operation degrades to `null` rather than throwing.
|
|
@@ -26,7 +25,7 @@ export const TAIL_CHARS = 4000;
|
|
|
26
25
|
// Roles are an allowlist, not a passthrough: a later repair worker adds one
|
|
27
26
|
// string here and needs no other change, while no caller-supplied value can
|
|
28
27
|
// ever reach the path.
|
|
29
|
-
export const WORKER_ROLES = new Set(["issue", "review"]);
|
|
28
|
+
export const WORKER_ROLES = new Set(["issue", "review", "repair"]);
|
|
30
29
|
|
|
31
30
|
const REDACTIONS = [
|
|
32
31
|
// Whole PEM blocks first, before any inner rule can fragment them. The body
|
|
@@ -154,9 +153,10 @@ export function createWorkerLogs({
|
|
|
154
153
|
return join(dir, `${id.role}-${id.number}.${attempt}.log`);
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
// The newest recorded attempt for a worker, with its
|
|
158
|
-
// redacted. `null` when there is nothing to show —
|
|
159
|
-
// failure, because diagnostics must never break the
|
|
156
|
+
// The newest recorded attempt for a worker, with its attempt number and its
|
|
157
|
+
// tail already bounded and redacted. `null` when there is nothing to show —
|
|
158
|
+
// including on any read failure, because diagnostics must never break the
|
|
159
|
+
// poll that surfaces them.
|
|
160
160
|
async function diagnostics(role, number) {
|
|
161
161
|
const id = identity(role, number);
|
|
162
162
|
if (!id) return null;
|
|
@@ -164,8 +164,12 @@ export function createWorkerLogs({
|
|
|
164
164
|
const dir = await logDir();
|
|
165
165
|
const attempts = await existingAttempts(dir, id.role, id.number);
|
|
166
166
|
if (attempts.length === 0) return null;
|
|
167
|
-
const
|
|
168
|
-
|
|
167
|
+
const newest = attempts.at(-1);
|
|
168
|
+
const path = join(dir, newest.name);
|
|
169
|
+
// The attempt number is this worker's durable launch count: retention
|
|
170
|
+
// prunes the *oldest* files while `prepare` numbers from the highest
|
|
171
|
+
// surviving one, so it keeps rising past `KEEP_ATTEMPTS`.
|
|
172
|
+
return { path, attempt: newest.attempt, tail: boundedTail(await readFile(path, "utf8")) };
|
|
169
173
|
} catch {
|
|
170
174
|
return null;
|
|
171
175
|
}
|