@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 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-4-8 "/github-issue <number>"
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. The full model name pins issue
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 not merged automatically. It first passes through the managed
20
- review gate below, which uses a third, separately reserved worker slot.
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
- Non-interactive print mode skips the workspace-trust prompt, and each process
62
- exits after its command finishes. A supervisor that cannot resolve its container
63
- ID or register its lease exits nonzero rather than run unenforced. Do not start
64
- Claude separately.
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. A second invocation in the same namespace for the same resolved
68
- `owner/repo` exits nonzero with an `already running` error before
69
- authentication, the container lease, tmux, or worker startup. Ownership is held by a
70
- kernel-owned Linux abstract Unix socket, so it is released on normal exit and
71
- automatically when the supervisor process dies. Devcontainers normally have
72
- distinct network namespaces and therefore independent guards; containers that
73
- share a network namespace also share this guard.
74
-
75
- Neither the image build nor devcontainer startup launches the supervisor or
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 running
91
- this autonomous command:
92
-
93
- ```bash
94
- ccode --print --permission-mode auto --model claude-opus-4-8 "/github-issue <n>"
95
- ```
96
-
97
- Non-interactive print mode skips the workspace-trust prompt; the process exits
98
- and its tmux window closes after the command finishes.
99
- - **Complete** a worker finishes by leaving its PR in draft and putting
100
- `agent-review` on the issue. Reaching any phase label is what marks
101
- implementation done: the supervisor closes the tmux window and frees the slot,
102
- and `agent-running` stays. An already-closed window is treated as closed; a
103
- failure at any later step is logged and retried by the next poll.
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
- ## Managed review gate
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
- | Label | Colour | Meaning |
153
- |---|---|---|
154
- | `agent-ready` | `0e8a16` | Ready for issue-orchestrator claim |
155
- | `agent-running` | `1d76db` | Managed by issue-orchestrator |
156
- | `agent-review` | `5319e7` | Waiting for or undergoing automated review |
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
- `agent-running` is the durable ownership marker: it is applied at claim and
161
- stays until post-merge cleanup (not yet implemented). Exactly one phase label
162
- `agent-review`, `agent-blocked`, or `user-merge-review` — is active alongside
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
- The reviewer is the producer and owns this format; this repository only consumes
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 App installation token (via `gh-app-token.sh`, overridable with
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 it runs an authenticated `gh repo view` smoke
248
- check and exits with a clear message if the App is not authenticated rather
249
- than churning on unauthenticated calls every poll.
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 plus one
254
- reviewer slot. Two knobs are environment-tunable:
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 below.
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 a GitHub issue end to end: select issue, open a
275
- draft PR immediately, self-resolve design decisions (logged to the PR),
276
- implement in an isolated worktree, verify against the issue, mark ready.
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
- Both are sourced from [`Sadotu/agent-skills`](https://github.com/Sadotu/agent-skills).
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
- Bump it in a normal pull request; when that lands on `main`,
286
- `.github/workflows/publish.yml` sees a version the registry does not carry,
287
- runs the tests, publishes, and pushes the matching `v<version>` tag. A merge
288
- that does not bump the version finds its version already published and exits
289
- without releasing anything.
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
- token is stored anywhere: npm exchanges the workflow's OIDC identity for a
294
- short-lived credential. Tagging is a second job, so the job holding an npm
295
- credential cannot write to this repository and the job that can push a tag
296
- holds no npm credential. A published version cannot be republished; bump and
297
- merge again.
298
-
299
- Pushing a `v*` tag by hand still works and takes the same path, with one extra
300
- guard: the tag must equal `v<version>` or the run fails.
301
-
302
- Trusted publishing can only be configured on a package that already exists, so
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
 
@@ -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 reserved implementation slots plus one reviewer slot that is never
24
- // borrowed for implementation. The pools are separate counters over separate
25
- // tmux window namespaces, so an orphan in one role can never eat the other's.
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
- const cmd = `bash -ic ${shellQuote(command)}${path
252
- ? ` 2>&1 | tee ${shellQuote(path)}`
253
- + `; printf '[orchestrator %s] %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)"`
254
- + ` ${shellQuote(completion)} >> ${shellQuote(path)}`
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-4-8 "/github-issue ${n}"`;
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-4-8 "/review-pr ${p}"`;
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 { ensureSession, listWorkerIssues, listReviewIssues, openWorker, openReviewer, closeWorker };
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 gh.listPrComments(pr.number);
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 && implLive === 0 && reviewLive === 0 && settled;
464
- return { done, implLive, reviewLive, started, reviewsStarted };
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 = runOnce,
854
+ runPoll = runImplementationOnlyOnce,
522
855
  checkSetupReady = isAgentSetupReady,
523
856
  sleepImpl = sleep,
524
857
  log = (message) => console.log(formatLogLine(message)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickysagan/issue-orchestrator",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A small, supervised GitHub issue queue that keeps autonomous coding workers alive.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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 current = phaseOf(issue.labels);
194
- if (current === phase) return;
195
- const delta = { add: [phase], remove: current ? [current] : [] };
196
- await gh.setIssueLabels(issue.number, delta);
197
- if (pr) await gh.setPrLabels(pr.number, delta);
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 }) {
@@ -1,9 +1,8 @@
1
1
  // Durable per-attempt worker logs.
2
2
  //
3
- // A tmux worker's output lives and dies with its pane, so a window that
4
- // vanishes takes the only evidence of why with it. Every launch reserves a
5
- // file here and streams its combined output into it, which lets the supervisor
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 tail already bounded and
158
- // redacted. `null` when there is nothing to show — including on any read
159
- // failure, because diagnostics must never break the poll that surfaces them.
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 path = join(dir, attempts.at(-1).name);
168
- return { path, tail: boundedTail(await readFile(path, "utf8")) };
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
  }