@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 +132 -109
- package/bin/supervisor.mjs +162 -17
- package/package.json +1 -1
- package/src/enforcementNotice.mjs +36 -0
- package/src/managedContainer.mjs +51 -4
- package/src/reviewGate.mjs +2 -1
package/README.md
CHANGED
|
@@ -1,184 +1,207 @@
|
|
|
1
1
|
# issue-orchestrator
|
|
2
2
|
|
|
3
|
-
A small, supervised issue queue.
|
|
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
|
|
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-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
+
Usage limits belong to
|
|
18
20
|
[Usage Sentinel](https://github.com/Sadotu/usage-sentinel), which pauses this
|
|
19
|
-
orchestrator's
|
|
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
|
|
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
|
|
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
|
|
31
|
+
### Installed usage
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
[npmjs](https://www.npmjs.com/package/@nickysagan/issue-orchestrator)
|
|
34
|
-
registry configuration or token
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
authentication, registers its
|
|
54
|
-
|
|
55
|
-
cannot resolve its container ID or
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
LLM
|
|
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
|
|
73
|
-
node bin/supervisor.mjs
|
|
74
|
-
tmux attach -t orchestrator # watch the workers
|
|
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
|
-
- **
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- **
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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`
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
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.
|
|
116
|
-
|
|
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
|
-
|
|
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` |
|
|
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
|
|
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 —
|
|
136
|
-
| `.agents/skills/` | Installed skills
|
|
137
|
-
| `.claude/skills` | Symlink to `.agents/skills` so Claude Code
|
|
138
|
-
| `CLAUDE.md` | Agent instructions and gotchas
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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.
|
|
154
|
-
it in a normal
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
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.
|
package/bin/supervisor.mjs
CHANGED
|
@@ -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
|
|
191
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
@@ -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
|
+
}
|
package/src/managedContainer.mjs
CHANGED
|
@@ -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
|
}
|
package/src/reviewGate.mjs
CHANGED
|
@@ -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
|
|
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 || []);
|