@nickysagan/issue-orchestrator 0.1.3 → 0.2.1

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
@@ -1,184 +1,207 @@
1
1
  # issue-orchestrator
2
2
 
3
- A small, supervised issue queue. A single Node supervisor picks up open issues
3
+ A small, supervised issue queue. One Node supervisor picks up open issues
4
4
  labeled `agent-ready` (ascending) and keeps at most **two** autonomous
5
- subscription-authenticated `ccode` implementation workers alive each in its own
5
+ subscription-authenticated `ccode` implementation workers alive, each in its own
6
6
  tmux window running:
7
7
 
8
8
  ```bash
9
- ccode --print --permission-mode auto --model claude-opus-5 "/github-issue <number>"
9
+ ccode --print --permission-mode auto --model claude-sonnet-5 --effort medium "/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.
12
+ The full model name pins workers to Sonnet 5 rather than the Claude Code default
13
+ or a moving alias, and `--effort medium` states the reasoning effort instead of
14
+ inheriting one. Together they are the production default for routine coding work;
15
+ an exceptional issue is run manually with Opus rather than escalated automatically.
16
+ Print mode skips the workspace-trust prompt, and each process exits — closing its
17
+ tmux window — when its command finishes.
16
18
 
17
- Usage limits are enforced by
19
+ Usage limits belong to
18
20
  [Usage Sentinel](https://github.com/Sadotu/usage-sentinel), which pauses this
19
- orchestrator's entire Docker container supervisor and workers together rather
20
- than this repository stopping workers itself.
21
+ orchestrator's whole Docker container; this repo never kills a worker for usage.
21
22
 
22
23
  A finished implementation PR is marked ready for the repository owner. The
23
- supervisor does not review, repair, approve, or merge it automatically.
24
+ supervisor does not review, repair, approve, or merge.
24
25
 
25
26
  ## Orchestrator
26
27
 
27
- Zero-dependency Node ≥20.8.0 on Linux (the supervisor guard uses a Linux abstract
28
- Unix socket). No build step.
28
+ Zero-dependency Node ≥20.8.0 on Linux (the supervisor guard uses a Linux
29
+ abstract Unix socket). No build step.
29
30
 
30
- ### Installed/current-repository usage
31
+ ### Installed usage
31
32
 
32
- The package is published publicly on
33
- [npmjs](https://www.npmjs.com/package/@nickysagan/issue-orchestrator), so no
34
- registry configuration or token is needed:
33
+ Published publicly on
34
+ [npmjs](https://www.npmjs.com/package/@nickysagan/issue-orchestrator) no
35
+ registry configuration or token needed. The scope names the package; the
36
+ installed command is `issue-orchestrator`.
35
37
 
36
38
  ```bash
37
39
  npm install -g @nickysagan/issue-orchestrator@latest
38
- ```
39
-
40
- The scope names the package; the installed command is `issue-orchestrator`.
41
-
42
- Run it from the target project repository, not from the package checkout:
43
-
44
- ```bash
45
- cd /path/to/target-project
40
+ cd /path/to/target-project # run from the target repo, not the package checkout
46
41
  issue-orchestrator
47
42
  ```
48
43
 
49
- The shared [`agent-devcontainer`](https://github.com/Sadotu/agent-devcontainer)
50
- also exposes that command through the literal alias `start work`.
44
+ [`agent-devcontainer`](https://github.com/Sadotu/agent-devcontainer) also exposes
45
+ that command as the alias `start work`.
51
46
 
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.
47
+ The supervisor resolves the working directory's `origin`, checks GitHub App
48
+ authentication, registers its container with Sentinel, and starts the workers
49
+ itself do not start Claude separately. It exits nonzero rather than run
50
+ unenforced if it cannot resolve its container ID or lease.
57
51
 
58
- Only one supervisor per repository per Linux network namespace may run at a
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.
52
+ One supervisor per repository per Linux network namespace: a second invocation
53
+ for the same `owner/repo` exits nonzero with `already running` before auth, the
54
+ lease, tmux, or workers. Ownership is a kernel-owned abstract Unix socket,
55
+ released on exit or process death. Devcontainers normally have distinct
56
+ namespaces, so their guards are independent.
65
57
 
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`.
58
+ Neither the image build nor devcontainer startup launches a supervisor or an
59
+ LLM; work begins only at `issue-orchestrator` / `start work`.
68
60
 
69
61
  ### Repository-checkout usage
70
62
 
71
63
  ```bash
72
- npm test # node --test over test/
73
- node bin/supervisor.mjs # start the supervisor loop
74
- tmux attach -t orchestrator # watch the workers directly
64
+ npm test # node --test over test/
65
+ node bin/supervisor.mjs # start the supervisor loop
66
+ tmux attach -t orchestrator # watch the workers
75
67
  ```
76
68
 
77
69
  ### How it works
78
70
 
79
- - **Claim** — the lowest-numbered `agent-ready` issue has its label swapped
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.
71
+ - **Admission** — before any claim, the primary worktree is checked read-only
72
+ with `git status --porcelain`. Pending changes, or an unreadable check, means
73
+ no claim and no worker that poll; nothing is stashed, reset, cleaned, or
74
+ committed. Live workers keep running.
75
+ - **Claim** — lowest-numbered `agent-ready` issue gets its label swapped to
76
+ `agent-running`, then a tmux window `issue-<n>` opens.
77
+ - **Complete** Phase 6 marks the PR ready. The next poll closes the worker
78
+ window, removes `agent-running`, and frees the slot.
79
+ - **Vanished** — a worker lost while its PR is still draft or absent releases
80
+ the claim and preserves the draft/worktree. No automatic restart.
81
+ - **Worker logs** `<git-common-dir>/issue-orchestrator/logs/issue-<n>.<attempt>.log`,
82
+ latest three attempts kept, secrets scrubbed from surfaced tails.
83
+ - **Worker usage** implementation workers run under Claude Code's streaming
84
+ JSON output, so each attempt log ends with the run's own usage record.
85
+ - **Pause notices** — the heartbeat carries Sentinel's enforcement state. A
86
+ pending pause prints one flushed line — triggering window, both observed
87
+ percentages, reset time — and is then acknowledged so Sentinel may pause; the
88
+ first heartbeat back to `running` prints one resume line. Ordinary refreshes
89
+ stay quiet, and a malformed payload is reported rather than guessed at.
92
90
  - **Stop** — the supervisor exits when nothing is queued and no implementation
93
- window is live.
91
+ or legacy window is live.
94
92
 
95
93
  Live container pause/unpause check:
96
94
  [docs/smoke-checks/README.md](docs/smoke-checks/README.md).
97
95
 
96
+ ### Inspecting one worker attempt's usage
97
+
98
+ An implementation worker streams its session into its attempt log as JSON
99
+ events. The run's own `result` event is the usage record:
100
+
101
+ ```bash
102
+ log="$(git rev-parse --git-common-dir)/issue-orchestrator/logs/issue-<n>.<attempt>.log"
103
+ grep -h '"type":"result"' "$log" |
104
+ jq '{session_id, num_turns, duration_ms, total_cost_usd, usage, modelUsage}'
105
+ ```
106
+
107
+ The issue and attempt numbers come from the path, the model from `modelUsage`
108
+ (and the log's opening `init` event), and completion from the log's final
109
+ `[orchestrator <timestamp>] Worker finished for #<n>` line. An interrupted
110
+ worker has no `result` event — its partial events are still in the file, and
111
+ `jq -cR 'fromjson? | select(.type)' "$log"` shows how far it got (the `-R
112
+ fromjson?` form skips the appended orchestrator stamp line, which is not JSON).
113
+
114
+ Reviewer and repair workers keep the plain-text output; only implementation
115
+ attempts carry a usage record.
116
+
98
117
  ## Implementation-only handoff
99
118
 
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.
119
+ `agent-running` is the ownership marker: applied at claim, removed only after the
120
+ PR is ready or a vanished/failed launch is released. Review labels in other repos
121
+ are left untouched, but no reviewer or repair worker is created and issues are
122
+ not transitioned through them.
105
123
 
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.
124
+ Manual and managed `/github-issue` runs both end at "PR ready". Human review and
125
+ merge start there; the supervisor exposes no approve or merge.
126
+
127
+ Legacy `review-<n>` and `repair-<n>` windows finish uninterrupted — they keep the
128
+ supervisor active, and repairs consume an implementation slot.
109
129
 
110
130
  ## GitHub authentication
111
131
 
112
132
  `gh` does not auto-consume the GitHub App credential, so the supervisor mints a
113
- short-lived installation token (via `gh-app-token.sh`, overridable with
133
+ short-lived installation token (`gh-app-token.sh`, overridable with
114
134
  `GH_APP_TOKEN_SCRIPT`) and injects it as `GH_TOKEN` for every `gh` call,
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.
135
+ re-minting each poll. A startup `gh repo view` smoke check exits with a clear
136
+ message when the App is not authenticated, instead of churning every poll.
118
137
 
119
138
  ### Configuration
120
139
 
121
- The local concurrency guard is fixed at two implementation slots. Two knobs
122
- are environment-tunable:
140
+ Concurrency is fixed at two implementation slots. Two environment knobs:
123
141
 
124
142
  | Var | Default | Meaning |
125
143
  |-----|---------|---------|
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 |
144
+ | `SENTINEL_URL` | `http://usage-sentinel:4317` | Sentinel base URL for the managed-container lease, on the shared container network; set explicitly (e.g. `http://host.docker.internal:4317`) only when Sentinel is exposed on the host |
127
145
  | `POLL_MS` | `60000` | Poll interval |
128
146
 
129
- Labels are created at startup — see the managed review gate above.
147
+ Labels are created at startup.
130
148
 
131
149
  ## Repo contents
132
150
 
133
151
  | Path | Purpose |
134
152
  |------|---------|
135
- | `agents.toml` / `agents.lock` | [dotagents](https://github.com/Sadotu/agent-skills) manifest — declares which skills are installed and pins their source commits |
136
- | `.agents/skills/` | Installed skills (`address-review`, `github-issue`, `review-pr`, `setup`) — managed artifacts, restored from the manifest, not committed |
137
- | `.claude/skills` | Symlink to `.agents/skills` so Claude Code picks the skills up |
138
- | `CLAUDE.md` | Agent instructions and gotchas for working in this repo |
153
+ | `agents.toml` / `agents.lock` | [dotagents](https://github.com/Sadotu/agent-skills) manifest — which skills are installed, pinned to source commits |
154
+ | `.agents/skills/` | Installed skills — managed artifacts, restored from the manifest, not committed |
155
+ | `.claude/skills` | Symlink to `.agents/skills` so Claude Code sees them |
156
+ | `CLAUDE.md` | Agent instructions and gotchas |
139
157
 
140
158
  ## Skills
141
159
 
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.
145
- - **`setup`** connects the repo to the `container-coding-agent` GitHub App
146
- and verifies `git`/`gh` authenticate as the App.
160
+ - **`github-issue`** — runs an issue end to end: draft PR, implementation in an
161
+ isolated worktree, verification, PR marked ready.
162
+ - **`setup`** — connects the repo to the `container-coding-agent` GitHub App and
163
+ verifies `git`/`gh` authenticate as the App.
164
+ - **`github-pr-cleanup`** cleans the worktree, branch and session artifacts of
165
+ one merged or closed pull request. Worktree Warden calls it automatically when
166
+ a pull request goes terminal, so cleanup happens outside this supervisor, which
167
+ ends at "PR ready". Installed here so the script exists in a checkout and can
168
+ be run by hand for a single pull request.
169
+
170
+ `review-pr` and `address-review` stay installed for manual use; the production
171
+ supervisor route does not invoke them.
147
172
 
148
- The standalone `review-pr` and `address-review` skills remain installed for
149
- manual use, but the production supervisor route does not invoke them.
173
+ Skills are pinned by `agents.lock` and do **not** update on their own — the
174
+ devcontainer installs `--frozen` whenever a lock exists. To take a new upstream
175
+ version, run `/opt/agent-devcontainer/dotagents-install.sh --upgrade "$PWD" /opt/agent-devcontainer`
176
+ and commit the rewritten lock.
150
177
 
151
178
  ## Releasing
152
179
 
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-orchestrator → Package settings**.
180
+ `package.json` holds the version, and merging the bump is the whole release.
181
+ Bump it in a normal PR; on `main`, `.github/workflows/publish.yml` sees a version
182
+ the registry lacks, runs the tests, publishes, and pushes the `v<version>` tag. A
183
+ merge without a bump exits without releasing. A hand-pushed `v*` tag takes the
184
+ same path with one extra guard: it must equal `v<version>`. Published versions
185
+ cannot be republished bump and merge again.
186
+
187
+ Publishing uses npmjs [trusted publishing](https://docs.npmjs.com/trusted-publishers),
188
+ so no npm token is stored anywhere. Tagging is a separate job, so the job holding
189
+ an npm credential cannot write to this repo and the job that can push a tag holds
190
+ no npm credential.
191
+
192
+ Two one-time bootstraps, both needing a package that already exists: `0.1.0` was
193
+ published by hand (`npm login && npm publish`) to enable trusted publishing, and
194
+ package visibility — set on the package, not inherited from this private repo —
195
+ must be set once under **Sadotu → Packages → issue-orchestrator → Package
196
+ settings**.
174
197
 
175
198
  ## Development environment
176
199
 
177
- Agents work on this repo from a shared, sandboxed devcontainer. That
178
- environment is **not part of this app** — it lives in its own repo:
200
+ Agents work from a shared, sandboxed devcontainer that is **not part of this
201
+ app** — it lives in
179
202
  [`Sadotu/agent-devcontainer`](https://github.com/Sadotu/agent-devcontainer)
180
203
  (image, setup scripts, security model, auth docs). A local `.devcontainer/`
181
- folder pointing at that image may exist in a checkout but is gitignored.
204
+ pointing at that image may exist in a checkout but is gitignored.
182
205
 
183
206
  GitHub access is via the scoped `container-coding-agent` GitHub App — never a
184
207
  personal token, never `gh auth login`. See `CLAUDE.md` for the auth wiring.
@@ -6,6 +6,7 @@ import { access, realpath } from "node:fs/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
8
  import { createManagedContainerClient, resolveContainerId as resolveOwnContainerId } from "../src/managedContainer.mjs";
9
+ import { describeEnforcementTransition } from "../src/enforcementNotice.mjs";
9
10
  import { BLOCKED, MERGE_REVIEW, READY, REVIEW, RUNNING, phaseOf } from "../src/labels.mjs";
10
11
  import { ensureLabels } from "../src/labels.mjs";
11
12
  import { selectApplicableMarker } from "../src/reviewMarker.mjs";
@@ -51,6 +52,38 @@ export async function isAgentSetupReady({ accessImpl = access } = {}) {
51
52
  }
52
53
  }
53
54
 
55
+ // Admission control for new launches. The pinned `github-issue` skill refuses
56
+ // to isolate issue work when the primary worktree is dirty, so claiming an
57
+ // issue in that state only produces a worker that exits before opening a PR.
58
+ // This runs the same predicate that guard runs — `git status --porcelain` —
59
+ // before anything is claimed.
60
+ //
61
+ // The primary worktree is resolved explicitly rather than assumed to be the
62
+ // caller's cwd: `git worktree list --porcelain` always reports it first, so a
63
+ // supervisor started from inside a linked worktree still inspects the tree
64
+ // isolation will actually branch from.
65
+ //
66
+ // Read-only by construction: it never stashes, resets, cleans, checks out, or
67
+ // commits, and it fails closed — an unreadable result is not a clean one.
68
+ export async function checkPrimaryWorktree({ exec }) {
69
+ try {
70
+ const { stdout: worktrees } = await exec("git", ["worktree", "list", "--porcelain"]);
71
+ const primary = String(worktrees).split("\n")
72
+ .find((line) => line.startsWith("worktree "))?.slice("worktree ".length).trim();
73
+ if (!primary) throw new Error("could not resolve the primary worktree");
74
+ const { stdout: status } = await exec("git", ["-C", primary, "status", "--porcelain"]);
75
+ const pending = String(status).split("\n").filter((line) => line.trim() !== "");
76
+ if (pending.length === 0) return { clean: true, reason: "clean", detail: "" };
77
+ return {
78
+ clean: false,
79
+ reason: "dirty",
80
+ detail: `${pending.length} pending change${pending.length === 1 ? "" : "s"} in ${primary}`,
81
+ };
82
+ } catch (err) {
83
+ return { clean: false, reason: "unreadable", detail: err.message };
84
+ }
85
+ }
86
+
54
87
  export async function acquireSupervisorOwnership(repo, {
55
88
  createServer = createNetServer,
56
89
  } = {}) {
@@ -187,8 +220,8 @@ export function createGitHub({ exec, repo }) {
187
220
 
188
221
  const claim = (n) => setIssueLabels(n, { add: [RUNNING], remove: [READY] });
189
222
  // A launch that fails before any work exists reverts to the queue. This is
190
- // the only path that removes `agent-running`, which is otherwise durable
191
- // until Phase 7 cleanup.
223
+ // the only path that returns the issue to `agent-ready`; every other removal
224
+ // of `agent-running` goes through `release` below.
192
225
  const restore = (n) => setIssueLabels(n, { add: [READY], remove: [RUNNING] });
193
226
  // Implementation-only completion releases the active claim after the PR is
194
227
  // ready for the repository owner. The label itself remains the queue's
@@ -306,7 +339,17 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
306
339
  // window command via a non-interactive shell that never sources .bashrc,
307
340
  // so the alias would silently fail to resolve without `bash -ic`, which
308
341
  // forces alias expansion regardless of login/interactive invocation.
309
- const command = `ccode --print --permission-mode auto --model claude-opus-5 "/github-issue ${n}"`;
342
+ //
343
+ // `stream-json` — not `json` — carries the run's session id and usage into
344
+ // the attempt log. `json` buffers the whole session and writes one object
345
+ // at exit, so a worker killed or lost mid-run would leave an *empty* log
346
+ // and destroy the diagnostic tail a vanished worker is reported with;
347
+ // streamed events land as they happen, exactly as text output does today,
348
+ // and the terminal `result` event still carries the full usage record.
349
+ // `--verbose` is mandatory: the CLI refuses the combination without it.
350
+ // Nothing here reads the result — measurement is passive, the log is the
351
+ // record.
352
+ const command = `ccode --print --permission-mode auto --model claude-sonnet-5 --effort medium --output-format stream-json --verbose "/github-issue ${n}"`;
310
353
  // No PR to name: the worker opens one mid-run, long after this launch.
311
354
  return newWindow("issue", n, `issue-${n}`, command, `Worker finished for #${n}`);
312
355
  }
@@ -363,14 +406,19 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
363
406
  }
364
407
 
365
408
  // Map open PRs to the implementation issue they close. GitHub's parsed closing
366
- // linkage is authoritative; the conventional branch prefix is a fallback.
409
+ // linkage is authoritative whenever the PR declares any, so a PR on
410
+ // `agent/<other>-*` that closes a different issue never maps to the branch
411
+ // number. The conventional branch prefix applies only to PRs that declare no
412
+ // closing reference at all. Two or more applicable PRs are reported as
413
+ // ambiguous rather than resolved to the first match: the caller must not act.
367
414
  export function implementationPrForIssue(prs, number) {
368
415
  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;
416
+ const applies = (pr) => (Array.isArray(pr.closingIssuesReferences) && pr.closingIssuesReferences.length > 0
417
+ ? pr.closingIssuesReferences.some((ref) => ref?.number === number)
418
+ : typeof pr.headRefName === "string" && pr.headRefName.startsWith(prefix));
419
+ const matches = (prs || []).filter(applies);
420
+ if (matches.length > 1) return { pr: null, ambiguous: true };
421
+ return { pr: matches[0] || null, ambiguous: false };
374
422
  }
375
423
 
376
424
  // Production poll route after the managed-review rollback. It deliberately
@@ -379,6 +427,7 @@ export function implementationPrForIssue(prs, number) {
379
427
  export async function runImplementationOnlyOnce({
380
428
  gh, tmux,
381
429
  checkSetupReady = async () => true,
430
+ checkPrimaryClean = async () => ({ clean: true, reason: "clean", detail: "" }),
382
431
  implementationSlots = IMPLEMENTATION_SLOTS,
383
432
  log,
384
433
  logs = NO_WORKER_LOGS,
@@ -386,13 +435,50 @@ export async function runImplementationOnlyOnce({
386
435
  }) {
387
436
  await tmux.ensureSession();
388
437
  const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
438
+ // A supervisor restarted into an `orchestrator` session left by the managed
439
+ // review release inherits live `review-N`/`repair-N` windows this route never
440
+ // opens. They are read before any mutation, alongside the implementation
441
+ // windows, so an inspection failure aborts the poll rather than running with
442
+ // unknown capacity.
389
443
  const liveIssues = await tmux.listWorkerIssues();
444
+ const liveReview = await tmux.listReviewIssues();
445
+ const liveRepair = await tmux.listRepairIssues();
390
446
  const prs = await gh.listOpenPrs();
391
- let liveCount = liveIssues.size;
447
+ // A legacy repair worker is a full `ccode` run sharing the implementation
448
+ // slots, exactly as it did before the rollback; a legacy reviewer had its own
449
+ // reserved slot and still consumes none.
450
+ let liveCount = liveIssues.size + liveRepair.size;
451
+
452
+ // Legacy windows are drained, never killed and never re-created: each may
453
+ // hold a worktree, a pushed branch, a draft PR and labels mid-transition, and
454
+ // the logic that knew how to finish or unwind that was removed with the
455
+ // managed review route. They exit on their own; until then they are visible
456
+ // here so nothing is launched over them and the supervisor cannot call itself
457
+ // idle. This line is the operator's only notice, so it names each one.
458
+ const legacyWindows = [
459
+ ...[...liveReview].sort((a, b) => a - b).map((n) => `review-${n}`),
460
+ ...[...liveRepair].sort((a, b) => a - b).map((n) => `repair-${n}`),
461
+ ];
462
+ if (legacyWindows.length > 0) {
463
+ log(`Legacy managed-review windows still live: ${legacyWindows.join(", ")}`
464
+ + " — draining; not managed, not restarted, and never killed by this supervisor");
465
+ }
392
466
 
393
467
  for (const issue of running) {
394
468
  try {
395
- const pr = implementationPrForIssue(prs, issue.number);
469
+ // Releasing the claim, or acting on a PR a legacy worker is still
470
+ // editing, is the race this reconciliation exists to avoid. Defer the
471
+ // whole issue; once its legacy window exits, the next poll reconciles it
472
+ // by the ordinary rules.
473
+ if (liveReview.has(issue.number) || liveRepair.has(issue.number)) {
474
+ log(`#${issue.number} has a live legacy window — deferring reconciliation to a later poll`);
475
+ continue;
476
+ }
477
+ const { pr, ambiguous } = implementationPrForIssue(prs, issue.number);
478
+ if (ambiguous) {
479
+ log(`#${issue.number}: multiple open PRs claim this issue — leaving worker and claim untouched`);
480
+ continue;
481
+ }
396
482
  if (pr && pr.isDraft === false) {
397
483
  if (liveIssues.has(issue.number)) {
398
484
  await tmux.closeWorker(issue.number);
@@ -418,11 +504,30 @@ export async function runImplementationOnlyOnce({
418
504
  if (ready.length > 0 && !await checkSetupReady()) {
419
505
  log("Agent setup incomplete — pausing worker launches");
420
506
  return {
421
- done: false, implLive: liveCount, reviewLive: 0,
507
+ done: false, implLive: liveCount, reviewLive: liveReview.size,
508
+ repairLive: liveRepair.size,
422
509
  started: 0, reviewsStarted: 0, repairsStarted: 0,
423
510
  };
424
511
  }
425
512
 
513
+ // Admission control. Isolation refuses a dirty primary worktree, so claiming
514
+ // an issue here would only burn a label transition on a worker that exits
515
+ // before opening a PR — which the next poll then misreports as vanished.
516
+ // Reconciliation above has already run, so live work keeps its slot, its
517
+ // claim and its lease; only new launches pause, and only for this poll.
518
+ if (ready.length > 0) {
519
+ const primary = await checkPrimaryClean();
520
+ if (!primary.clean) {
521
+ log(`Primary worktree ${primary.reason} (${primary.detail})`
522
+ + " — no issue claimed, worker launches paused this poll");
523
+ return {
524
+ done: false, implLive: liveCount, reviewLive: liveReview.size,
525
+ repairLive: liveRepair.size,
526
+ started: 0, reviewsStarted: 0, repairsStarted: 0,
527
+ };
528
+ }
529
+ }
530
+
426
531
  let started = 0;
427
532
  for (const number of ready) {
428
533
  if (liveCount >= implementationSlots) break;
@@ -448,9 +553,12 @@ export async function runImplementationOnlyOnce({
448
553
  }
449
554
 
450
555
  return {
451
- done: ready.length === 0 && liveCount === 0,
556
+ // `done` drops the Sentinel lease and stops the supervisor, so a live
557
+ // legacy worker of either kind must hold it false.
558
+ done: ready.length === 0 && liveCount === 0 && liveReview.size === 0,
452
559
  implLive: liveCount,
453
- reviewLive: 0,
560
+ reviewLive: liveReview.size,
561
+ repairLive: liveRepair.size,
454
562
  started,
455
563
  reviewsStarted: 0,
456
564
  repairsStarted: 0,
@@ -853,13 +961,48 @@ export async function main({
853
961
  createLeaseClient = createConfiguredLease,
854
962
  runPoll = runImplementationOnlyOnce,
855
963
  checkSetupReady = isAgentSetupReady,
964
+ checkPrimaryClean = () => checkPrimaryWorktree({ exec }),
856
965
  sleepImpl = sleep,
857
966
  log = (message) => console.log(formatLogLine(message)),
967
+ stdout = process.stdout,
858
968
  } = {}) {
859
969
  const repo = await resolve(exec);
860
970
  const releaseOwnership = await acquireOwnership(repo);
861
971
  const lease = createLeaseClient();
862
972
  let containerId;
973
+ // Docker freezes this process rather than restarting it, so the last observed
974
+ // enforcement state survives the pause in memory — nothing needs persisting.
975
+ let enforcementState = "running";
976
+
977
+ // Resolves only once the line has actually left the process. Workers run under
978
+ // `tmux … | tee`, where stdout is a pipe and `console.log` does not block, so
979
+ // an unflushed pause message could lose the race against Docker.
980
+ function logFlush(message) {
981
+ return new Promise((resolve, reject) => {
982
+ stdout.write(`${formatLogLine(message)}\n`, (err) => (err ? reject(err) : resolve()));
983
+ });
984
+ }
985
+
986
+ // Announce a transition, flush it, then acknowledge — in that order. The
987
+ // acknowledgment lets Sentinel pause this very container, so an unflushed
988
+ // message would race Docker and could be lost. A failed acknowledgment is
989
+ // visible but not fatal: Sentinel pauses anyway once its timeout elapses, so
990
+ // failing here would cost availability without adding enforcement.
991
+ //
992
+ // The parameter default covers a client that predates the enforcement field;
993
+ // the current one always parses and returns it.
994
+ async function notifyEnforcement(enforcement = { state: "running" }) {
995
+ const transition = describeEnforcementTransition(enforcementState, enforcement);
996
+ enforcementState = transition.state;
997
+ if (transition.message) await logFlush(transition.message);
998
+ if (!transition.acknowledge) return;
999
+ try {
1000
+ await lease.acknowledge(containerId);
1001
+ } catch (err) {
1002
+ log(`pause acknowledgment failed: ${err.message}`);
1003
+ }
1004
+ }
1005
+
863
1006
  try {
864
1007
  // One helper for the whole run: the launcher that reserves an attempt and
865
1008
  // the poll that later reads it must agree on where the logs live.
@@ -889,8 +1032,9 @@ export async function main({
889
1032
  // silently running unenforced.
890
1033
  try {
891
1034
  containerId = await resolveContainer();
892
- const { status } = await lease.register(containerId);
1035
+ const { status, enforcement } = await lease.register(containerId);
893
1036
  log(`Managed container lease ${status} for ${containerId}`);
1037
+ await notifyEnforcement(enforcement);
894
1038
  } catch (err) {
895
1039
  throw new Error(`managed-container registration failed: ${err.message}`);
896
1040
  }
@@ -902,7 +1046,8 @@ export async function main({
902
1046
  for (let firstPoll = true; ; firstPoll = false) {
903
1047
  if (!firstPoll) {
904
1048
  try {
905
- await lease.register(containerId);
1049
+ const { enforcement } = await lease.register(containerId);
1050
+ await notifyEnforcement(enforcement);
906
1051
  } catch (err) {
907
1052
  log(`lease heartbeat failed: ${err.message}`);
908
1053
  }
@@ -912,7 +1057,7 @@ export async function main({
912
1057
  const token = await mint(repo);
913
1058
  const gh = createGitHub({ exec: authExec(token), repo });
914
1059
  result = await runPoll({
915
- gh, tmux, checkSetupReady,
1060
+ gh, tmux, checkSetupReady, checkPrimaryClean,
916
1061
  implementationSlots: IMPLEMENTATION_SLOTS,
917
1062
  reviewerSlots: REVIEWER_SLOTS,
918
1063
  log, logs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickysagan/issue-orchestrator",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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,36 @@
1
+ // Sentinel's enforcement state is a lease field, not an admission decision.
2
+ // This module only decides what to say about a change in it, and whether the
3
+ // change still needs acknowledging; it reads no usage and gates nothing.
4
+ const WINDOW_NAMES = { short: "5-hour", long: "weekly" };
5
+
6
+ function pauseMessage(reason) {
7
+ const window = WINDOW_NAMES[reason.window];
8
+ const short = Math.round(reason.shortUsedPercent);
9
+ const long = Math.round(reason.longUsedPercent);
10
+ const resets = reason.resetsAt === null ? "" : ` Usage resets at ${reason.resetsAt}.`;
11
+ return (
12
+ `Sentinel usage threshold reached on the ${window} window ` +
13
+ `(5-hour ${short}%, weekly ${long}%); pausing this container until usage ` +
14
+ `becomes available.${resets}`
15
+ );
16
+ }
17
+
18
+ export function describeEnforcementTransition(previousState, enforcement) {
19
+ const state = enforcement.state;
20
+ // Acknowledgment is independent of the message: a pause announced on one
21
+ // heartbeat whose acknowledgment failed is retried on the next one without
22
+ // printing a second line.
23
+ const acknowledge = state === "pause_pending" && enforcement.acknowledged !== true;
24
+ if (state === previousState) return { state, message: null, acknowledge };
25
+ if (state === "running") {
26
+ return { state, message: "Sentinel unpaused this container; issue-orchestrator resumed.", acknowledge };
27
+ }
28
+ if (previousState === "running") {
29
+ // Announcing on any first departure from `running` covers a lease seen as
30
+ // `paused` outright — a Sentinel restart, or a pause whose persist raced —
31
+ // which would otherwise freeze the container in silence.
32
+ return { state, message: pauseMessage(enforcement.reason), acknowledge };
33
+ }
34
+ // pause_pending → paused: the same pause, already announced.
35
+ return { state, message: null, acknowledge };
36
+ }
@@ -19,6 +19,41 @@ export async function resolveContainerId({ readFileImpl = readFile } = {}) {
19
19
  throw new Error(`ambiguous Docker container IDs in /proc/self/mountinfo: ${[...ids].join(", ")}`);
20
20
  }
21
21
 
22
+ const WINDOWS = new Set(["short", "long"]);
23
+
24
+ function isPercent(value) {
25
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
26
+ }
27
+
28
+ function isReason(value) {
29
+ return (
30
+ typeof value === "object" && value !== null &&
31
+ WINDOWS.has(value.window) &&
32
+ isPercent(value.shortUsedPercent) &&
33
+ isPercent(value.longUsedPercent) &&
34
+ (value.resetsAt === null || typeof value.resetsAt === "string")
35
+ );
36
+ }
37
+
38
+ // Sentinel documents an absent field as `running`, so only a *present* payload
39
+ // can be malformed. A malformed one throws: this client never invents a state,
40
+ // because a wrong guess would either hide an imminent pause or announce one
41
+ // that is not coming.
42
+ function parseEnforcement(value) {
43
+ if (value === undefined || value === null) return { state: "running" };
44
+ if (typeof value !== "object") {
45
+ throw new Error("invalid managed-container enforcement: not an object");
46
+ }
47
+ if (value.state === "running") return { state: "running" };
48
+ if (value.state !== "pause_pending" && value.state !== "paused") {
49
+ throw new Error(`invalid managed-container enforcement: unknown state ${value.state}`);
50
+ }
51
+ if (!isReason(value.reason)) {
52
+ throw new Error("invalid managed-container enforcement: invalid reason");
53
+ }
54
+ return value;
55
+ }
56
+
22
57
  // Sentinel's `/managed-containers` API is a lease, not admission control: it
23
58
  // never allows or denies a start, so neither operation here returns anything a
24
59
  // caller could read as a decision. Any non-lease status is a local or transport
@@ -61,12 +96,12 @@ export function createManagedContainerClient({
61
96
  return new Error(`${prefix}: ${detail}`);
62
97
  }
63
98
 
64
- async function request(containerId, method) {
99
+ async function request(containerId, method, suffix = "") {
65
100
  if (typeof containerId !== "string" || !CONTAINER_ID.test(containerId)) {
66
101
  throw new Error(`invalid container ID: ${containerId}`);
67
102
  }
68
103
  const { signal, done } = startDeadline();
69
- const path = `/managed-containers/${encodeURIComponent(containerId)}`;
104
+ const path = `/managed-containers/${encodeURIComponent(containerId)}${suffix}`;
70
105
  let response;
71
106
  try {
72
107
  response = await withDeadline(fetchImpl(`${baseUrl}${path}`, { method, signal }), signal);
@@ -102,12 +137,24 @@ export function createManagedContainerClient({
102
137
  if (body?.status !== expected) {
103
138
  throw new Error(`invalid managed-container registration response: status ${body?.status}`);
104
139
  }
105
- return { status: expected };
140
+ return { status: expected, enforcement: parseEnforcement(body.enforcement) };
106
141
  } finally {
107
142
  done();
108
143
  }
109
144
  }
110
145
 
146
+ // Tells Sentinel the pre-pause message has been printed, so it may pause this
147
+ // container. `409 no pending pause` is not a failure: it only means Sentinel
148
+ // already resolved the transition — its acknowledgment timeout fired, or usage
149
+ // dropped back below the threshold.
150
+ async function acknowledge(containerId) {
151
+ const { response, done } = await request(containerId, "POST", "/acknowledge");
152
+ done();
153
+ if (response.status === 200) return { acknowledged: true };
154
+ if (response.status === 409) return { acknowledged: false };
155
+ throw new Error(`unexpected managed-container acknowledgment status ${response.status}`);
156
+ }
157
+
111
158
  async function unregister(containerId) {
112
159
  const { response, done } = await request(containerId, "DELETE");
113
160
  done();
@@ -116,5 +163,5 @@ export function createManagedContainerClient({
116
163
  }
117
164
  }
118
165
 
119
- return { register, unregister };
166
+ return { register, unregister, acknowledge };
120
167
  }
@@ -188,7 +188,8 @@ export function planLabelMirror(issueLabels, prLabels) {
188
188
  }
189
189
 
190
190
  // Swap the issue's phase label and mirror it onto the PR. `agent-running` is
191
- // never removed here — it is durable until Phase 7.
191
+ // never removed here — it survives every phase swap, and only the supervisor
192
+ // releases it, once the PR is ready for the owner.
192
193
  export async function setPhase({ gh, issue, pr, phase }) {
193
194
  const deltaFor = (labels) => {
194
195
  const current = new Set(labels || []);