@nickysagan/issue-orchestrator 0.1.1 → 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,220 +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.
112
- - **Active-container lease** — at startup the supervisor resolves its own exact
113
- 64-character Docker container ID (from `/proc/self/mountinfo`; the short
114
- `hostname` form is not accepted) and registers it with
115
- `PUT /managed-containers/<id>` on Sentinel. Registration is a lease, not an
116
- admission decision: Sentinel never allows or denies a start. Every later poll
117
- re-PUTs the same ID as its heartbeat, well inside the five-minute lease; a
118
- clean exit `DELETE`s it as best-effort cleanup, and an abrupt exit is handled
119
- by Sentinel's lease expiry. A failed heartbeat is logged and retried on the
120
- next poll. The supervisor keeps a local guard of two implementation workers
121
- plus one reviewer.
122
- - **Usage enforcement lives in Sentinel** — when Claude Code's five-hour or
123
- weekly window reaches 95%, Sentinel `docker pause`s every registered container,
124
- freezing this supervisor and every worker process in place, and unpauses only
125
- after a fresh valid reading puts both windows below 95%. This repository reads
126
- no usage telemetry, applies no thresholds, calls no Docker API, and never kills
127
- a worker because of usage.
128
- - **Stop** — the supervisor exits when nothing is queued, no worker or reviewer
129
- window is live, and every managed issue rests in `agent-blocked` or
130
- `user-merge-review`. Merge and post-merge cleanup monitoring are not yet
131
- 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.
132
94
 
133
95
  Live container pause/unpause check:
134
96
  [docs/smoke-checks/README.md](docs/smoke-checks/README.md).
135
97
 
136
- ## Managed review gate
137
-
138
- A managed PR receives an independent automated review and then either stops with
139
- documented blockers or becomes ready for the owner. The supervisor never
140
- approves and never merges.
141
-
142
- ### Labels
143
-
144
- At startup — after the repository is resolved and the GitHub App auth check
145
- passes, and before the first poll — the supervisor creates any of these labels
146
- that the repository is missing. Labels that already exist are never renamed,
147
- recoloured, or re-described, and no GitHub mutation ever happens at image-build
148
- time (the build has neither a target repository nor runtime credentials).
149
-
150
- | Label | Colour | Meaning |
151
- |---|---|---|
152
- | `agent-ready` | `0e8a16` | Ready for issue-orchestrator claim |
153
- | `agent-running` | `1d76db` | Managed by issue-orchestrator |
154
- | `agent-review` | `5319e7` | Waiting for or undergoing automated review |
155
- | `agent-blocked` | `d73a4a` | Automated workflow blocked; findings posted |
156
- | `user-merge-review` | `fbca04` | Automated review passed; waiting for owner review and merge |
157
-
158
- `agent-running` is the durable ownership marker: it is applied at claim and
159
- stays until post-merge cleanup (not yet implemented). Exactly one phase label —
160
- `agent-review`, `agent-blocked`, or `user-merge-review` — is active alongside
161
- it. Issue labels are canonical and the PR mirrors them; every poll repairs a
162
- mismatch, issuing an edit only when the sets actually differ.
98
+ ## Implementation-only handoff
163
99
 
164
- A PR with no linked `agent-running` issue is ignored entirely — manual
165
- `/github-issue` PRs and unrelated `agent/*` branches are never touched. More
166
- than one PR closing an issue, a branch that disagrees with its closing
167
- reference, or an `agent-review` issue with no PR all fail closed: an actionable
168
- comment plus `agent-blocked`.
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.
169
105
 
170
- ### Capacity
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.
171
109
 
172
- Two reserved implementation slots plus one reviewer slot that is never borrowed
173
- for implementation. The pools are separate tmux window namespaces — `issue-<n>`
174
- and `review-<n>` — each counting only its own live windows. The oldest eligible
175
- `agent-review` PR gets a fresh reviewer window running:
176
-
177
- ```bash
178
- ccode --print --permission-mode auto --model claude-opus-4-8 "/review-pr <pr>"
179
- ```
180
-
181
- The reviewer slot frees as soon as that window exits. Waiting on CI holds no
182
- slot, because it is pure label state with no window.
183
-
184
- ### Review marker
185
-
186
- The reviewer (`Sadotu/agent-skills`, `review-pr`) records each pass in one
187
- immutable PR comment carrying a single-line marker:
188
-
189
- ```
190
- <!-- review-pr:v1 {"fingerprint":"<64-hex>","head":"<40-hex>","base":"<40-hex>","issueUpdatedAt":"<ISO>","prUpdatedAt":"<ISO>","issue":41,"pr":72,"pass":1,"verdict":"PASS"} -->
191
- ```
192
-
193
- The reviewer is the producer and owns this format; this repository only consumes
194
- it. Verdicts are exactly `PASS` and `BLOCKING`.
195
-
196
- A marker is applied only when all three hold:
197
-
198
- - **Authorship** — the comment has `viewerDidAuthor: true`, so the same GitHub
199
- App identity the supervisor authenticates as wrote it. Every fingerprint input
200
- is public, so without this check any commenter could forge a `PASS`.
201
- - **Identity** — its `issue` and `pr` are the managed pair being gated.
202
- - **Freshness** — its `fingerprint` equals the live fingerprint,
203
- `sha256(head 0x1e base 0x1e issue-body 0x1e pr-body)`. Bodies decide
204
- freshness, not `updatedAt`: a new comment or a label edit moves the timestamps
205
- while reviewing the same content, whereas any real change to the head, the
206
- base, the issue text, or the PR text invalidates the pass. `issueUpdatedAt`
207
- and `prUpdatedAt` are recorded for audit only.
208
-
209
- Comparing against live state rather than a snapshot captured at launch needs no
210
- persistence, so the same content is handled once even across a supervisor
211
- restart or a crash between the comment and the label transition. A marker that
212
- fails any check is discarded silently: nothing is posted, nothing transitions,
213
- and the PR simply queues a fresh pass. Parsing fails closed on an unsupported
214
- tag, malformed JSON, or a missing or ill-typed field. A trusted, applicable
215
- marker carrying an unsupported *verdict* is the one case that is not discarded —
216
- it blocks, so a contract break surfaces instead of looping.
217
-
218
- ### Transitions
219
-
220
- **`BLOCKING`** — the PR is kept or returned to draft and `agent-review` becomes
221
- `agent-blocked` on issue and PR. The reviewer's findings stand alone in its own
222
- comment; the supervisor adds nothing and stops. There is no automatic repair.
223
-
224
- **`PASS`** — the PR is marked ready for review first, so repositories whose CI
225
- triggers on `ready_for_review` start their checks. Checks are read on the
226
- following poll, never in the same one that opened the PR:
227
-
228
- | Checks | Outcome |
229
- |---|---|
230
- | None configured | Green → `user-merge-review` |
231
- | All complete and successful, neutral, or skipped | Green → `user-merge-review` |
232
- | Any still running | Keep `agent-review`; reviewer slot stays free |
233
- | Any failed | Return to draft, `agent-blocked`, post the failing check names and URLs |
234
- | Data unreadable | Transition nothing; retry next poll |
235
-
236
- Every check GitHub reports is treated as required. "No checks configured" and
237
- "could not read the checks" are deliberately distinct: only the former counts as
238
- green.
239
-
240
- ### GitHub authentication
110
+ ## GitHub authentication
241
111
 
242
112
  `gh` does not auto-consume the GitHub App credential, so the supervisor mints a
243
- short-lived App installation token (via `gh-app-token.sh`, overridable with
113
+ short-lived installation token (via `gh-app-token.sh`, overridable with
244
114
  `GH_APP_TOKEN_SCRIPT`) and injects it as `GH_TOKEN` for every `gh` call,
245
- re-minting each poll. At startup it runs an authenticated `gh repo view` smoke
246
- check and exits with a clear message if the App is not authenticated rather
247
- 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.
248
118
 
249
119
  ### Configuration
250
120
 
251
- The local concurrency guard is fixed at two implementation slots plus one
252
- reviewer slot. Two knobs are environment-tunable:
121
+ The local concurrency guard is fixed at two implementation slots. Two knobs
122
+ are environment-tunable:
253
123
 
254
124
  | Var | Default | Meaning |
255
125
  |-----|---------|---------|
256
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 |
257
127
  | `POLL_MS` | `60000` | Poll interval |
258
128
 
259
- Labels are created at startup — see the managed review gate below.
129
+ Labels are created at startup — see the managed review gate above.
260
130
 
261
131
  ## Repo contents
262
132
 
263
133
  | Path | Purpose |
264
134
  |------|---------|
265
135
  | `agents.toml` / `agents.lock` | [dotagents](https://github.com/Sadotu/agent-skills) manifest — declares which skills are installed and pins their source commits |
266
- | `.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 |
267
137
  | `.claude/skills` | Symlink to `.agents/skills` so Claude Code picks the skills up |
268
138
  | `CLAUDE.md` | Agent instructions and gotchas for working in this repo |
269
139
 
270
140
  ## Skills
271
141
 
272
- - **`github-issue`** — runs a GitHub issue end to end: select issue, open a
273
- draft PR immediately, self-resolve design decisions (logged to the PR),
274
- 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.
275
145
  - **`setup`** — connects the repo to the `container-coding-agent` GitHub App
276
146
  and verifies `git`/`gh` authenticate as the App.
277
147
 
278
- 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.
279
150
 
280
151
  ## Releasing
281
152
 
282
- `package.json` holds the version; the tag only confirms it. Bump it in a normal
283
- pull request, then tag the merge commit:
284
-
285
- ```bash
286
- git tag v0.2.0 && git push origin v0.2.0
287
- ```
288
-
289
- `.github/workflows/publish.yml` publishes to npmjs via
290
- [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm
291
- token is stored anywhere: npm exchanges the workflow's OIDC identity for a
292
- short-lived credential. It refuses to publish unless the tag equals
293
- `v<version>` and the tests pass. A published version cannot be republished;
294
- bump and tag again.
295
-
296
- Trusted publishing can only be configured on a package that already exists, so
297
- `0.1.0` was published by hand (`npm login && npm publish`) to bootstrap it.
298
- Every later release goes through the workflow.
299
-
300
- Package visibility is set on the package, not inherited from this private
301
- repository, and the setting appears only once a version exists. So after the
302
- **first** release, set it once under **Sadotu Packages
303
- issue-orchestrator → Package settings**.
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.
161
+
162
+ Publishing goes to npmjs via
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-orchestratorPackage settings**.
304
174
 
305
175
  ## Development environment
306
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,14 +272,31 @@ 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.
240
279
  // `tee` sees the pane's combined output, which is the whole point of the
241
280
  // log; an unreserved log leaves the command byte-identical to a plain launch.
242
- async function newWindow(role, number, name, command) {
243
- const path = await logs.prepare(role, number);
244
- const cmd = `bash -ic ${shellQuote(command)}${path ? ` 2>&1 | tee ${shellQuote(path)}` : ""}`;
281
+ //
282
+ // Once the pipeline ends, the same shell stamps `completion` into the log, so
283
+ // a reader can place the run in time without the file's mtime. It is written
284
+ // here rather than by the supervisor because only this shell knows the actual
285
+ // finish instant — the supervisor learns of it up to a poll later, while
286
+ // `tee` may still hold the file open. `date` matches `formatLogLine`'s ISO
287
+ // 8601 UTC shape, so both kinds of orchestrator line read alike.
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;
245
300
  return exec("tmux", ["new-window", "-t", session, "-n", name, cmd]);
246
301
  }
247
302
 
@@ -251,19 +306,42 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
251
306
  // window command via a non-interactive shell that never sources .bashrc,
252
307
  // so the alias would silently fail to resolve without `bash -ic`, which
253
308
  // forces alias expansion regardless of login/interactive invocation.
254
- const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/github-issue ${n}"`;
255
- return newWindow("issue", n, `issue-${n}`, command);
309
+ const command = `ccode --print --permission-mode auto --model claude-opus-5 "/github-issue ${n}"`;
310
+ // No PR to name: the worker opens one mid-run, long after this launch.
311
+ return newWindow("issue", n, `issue-${n}`, command, `Worker finished for #${n}`);
256
312
  }
257
313
 
258
314
  // The reviewer is a read-only pass over an existing PR; the window is named
259
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.
260
322
  function openReviewer(number, prNumber) {
261
323
  const i = issueNumber(number);
262
324
  const p = issueNumber(prNumber);
263
- const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/review-pr ${p}"`;
264
- return newWindow("review", i, `review-${i}`, command);
325
+ const command = `ccode --print --permission-mode auto --model claude-opus-5 "/review-pr ${i} ${p}"`;
326
+ return newWindow("review", i, `review-${i}`, command, `Reviewer finished for #${i} (PR #${p})`);
327
+ }
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
+ });
265
341
  }
266
342
 
343
+ const prepareRepair = (number) => logs.prepare("repair", issueNumber(number));
344
+
267
345
  // Suppress ONLY a confirmed "window absent" failure (idempotent close). Any
268
346
  // other tmux failure (e.g. server down) propagates so the caller does not
269
347
  // wrongly free the slot for a window that may still be alive.
@@ -278,7 +356,105 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
278
356
  }
279
357
  }
280
358
 
281
- 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
+ };
282
458
  }
283
459
 
284
460
  // ---- one poll cycle ---------------------------------------------------------
@@ -293,6 +469,7 @@ export async function runOnce({
293
469
  checkSetupReady = async () => true,
294
470
  implementationSlots = IMPLEMENTATION_SLOTS,
295
471
  reviewerSlots = REVIEWER_SLOTS,
472
+ reviewerAttempts = REVIEWER_ATTEMPTS,
296
473
  log,
297
474
  logs = NO_WORKER_LOGS,
298
475
  runningIssues,
@@ -302,15 +479,21 @@ export async function runOnce({
302
479
  const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
303
480
  const liveImpl = await tmux.listWorkerIssues();
304
481
  const liveReview = await tmux.listReviewIssues();
482
+ const liveRepair = await tmux.listRepairIssues();
305
483
  const prs = await gh.listOpenPrs();
306
484
 
307
- let implLive = liveImpl.size;
485
+ let implLive = liveImpl.size + liveRepair.size;
308
486
  let reviewLive = liveReview.size;
309
487
 
310
488
  // Phase state as this poll last knew it, updated in place by each transition
311
489
  // so the exit condition below reflects the work just done.
312
490
  const phases = new Map(running.map((issue) => [issue.number, phaseOf(issue.labels)]));
313
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
+ };
314
497
 
315
498
  // --- linkage ambiguity fails closed ---------------------------------------
316
499
  for (const problem of problems) {
@@ -358,15 +541,89 @@ export async function runOnce({
358
541
  }
359
542
  }
360
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
+
361
617
  // --- review reconciliation -------------------------------------------------
362
618
  const needsReview = [];
363
619
  for (const { issue, pr } of managed) {
364
620
  try {
621
+ if (liveRepairPairs.has(issue.number) || reconciledRepairPairs.has(issue.number)) continue;
365
622
  await mirrorLabels({ gh, issue, pr });
366
623
  if (phases.get(issue.number) !== REVIEW) continue;
367
624
 
368
625
  const checkedAt = now();
369
- const comments = await gh.listPrComments(pr.number);
626
+ const comments = await prComments(pr.number);
370
627
  const marker = selectApplicableMarker(comments, {
371
628
  issue: issue.number, pr: pr.number,
372
629
  head: pr.headRefOid, base: pr.baseRefOid,
@@ -391,10 +648,10 @@ export async function runOnce({
391
648
  }
392
649
 
393
650
  const ready = await gh.listReadyIssues();
394
- const wantsLaunch = ready.length > 0 || needsReview.length > 0;
651
+ const wantsLaunch = ready.length > 0 || needsReview.length > 0 || eligibleRepairs.length > 0;
395
652
  if (wantsLaunch && !await checkSetupReady()) {
396
653
  log("Agent setup incomplete — pausing worker and reviewer launches");
397
- return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0 };
654
+ return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0, repairsStarted: 0 };
398
655
  }
399
656
 
400
657
  // --- reviewer launches (oldest PR first) -----------------------------------
@@ -412,6 +669,23 @@ export async function runOnce({
412
669
  // eventually displace it and the relaunch itself explains nothing.
413
670
  const diagnostics = await logs.diagnostics("review", issue.number);
414
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
+ }
415
689
  log(`A previous reviewer for #${issue.number} (${pr.url}) left ${diagnostics.path}:\n${diagnostics.tail}`);
416
690
  }
417
691
  await tmux.openReviewer(issue.number, pr.number);
@@ -423,6 +697,76 @@ export async function runOnce({
423
697
  }
424
698
  }
425
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
+
426
770
  // --- implementation launches ----------------------------------------------
427
771
  let started = 0;
428
772
  for (const n of ready) {
@@ -448,8 +792,9 @@ export async function runOnce({
448
792
  // nothing is queued or live. Merge and cleanup monitoring belong to later
449
793
  // groups.
450
794
  const settled = [...phases.values()].every((phase) => phase === BLOCKED || phase === MERGE_REVIEW);
451
- const done = ready.length === 0 && implLive === 0 && reviewLive === 0 && settled;
452
- 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 };
453
798
  }
454
799
 
455
800
  // ---- real-environment wiring ------------------------------------------------
@@ -506,7 +851,7 @@ export async function main({
506
851
  bootstrapLabels = bootstrapRepoLabels,
507
852
  resolveContainerId: resolveContainer = resolveOwnContainerId,
508
853
  createLeaseClient = createConfiguredLease,
509
- runPoll = runOnce,
854
+ runPoll = runImplementationOnlyOnce,
510
855
  checkSetupReady = isAgentSetupReady,
511
856
  sleepImpl = sleep,
512
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.1",
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
  }