@nickysagan/issue-orchestrator 0.1.3 → 0.2.0
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 +22 -2
- package/bin/supervisor.mjs +151 -16
- 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
|
@@ -76,6 +76,11 @@ tmux attach -t orchestrator # watch the workers directly
|
|
|
76
76
|
|
|
77
77
|
### How it works
|
|
78
78
|
|
|
79
|
+
- **Admission** — before anything is claimed, the primary worktree is checked
|
|
80
|
+
with a read-only `git status --porcelain`. If it has pending changes — or if
|
|
81
|
+
that check cannot be read — no issue is claimed and no worker starts for that
|
|
82
|
+
poll; nothing is stashed, reset, cleaned, or committed. Live workers keep
|
|
83
|
+
running and are reconciled as usual.
|
|
79
84
|
- **Claim** — the lowest-numbered `agent-ready` issue has its label swapped
|
|
80
85
|
`agent-ready` → `agent-running`, then a tmux window `issue-<n>` opens.
|
|
81
86
|
- **Complete** — Phase 6 marks the implementation PR ready for the owner. The
|
|
@@ -89,8 +94,14 @@ tmux attach -t orchestrator # watch the workers directly
|
|
|
89
94
|
the latest three attempts and scrubbing secrets from surfaced tails.
|
|
90
95
|
- **Usage enforcement** — Usage Sentinel owns pause/unpause thresholds. This
|
|
91
96
|
repository reads no usage telemetry and never kills a worker for usage.
|
|
97
|
+
- **Pause notices** — the managed-container heartbeat carries Sentinel's
|
|
98
|
+
enforcement state. A pending pause prints one flushed line naming the
|
|
99
|
+
triggering window, both observed percentages and the reset time, and is then
|
|
100
|
+
acknowledged so Sentinel may pause; the first heartbeat back to `running`
|
|
101
|
+
prints one resume line. Ordinary refreshes stay quiet, and a payload that is
|
|
102
|
+
present but malformed is reported rather than guessed at.
|
|
92
103
|
- **Stop** — the supervisor exits when nothing is queued and no implementation
|
|
93
|
-
window is live.
|
|
104
|
+
window and no legacy window (below) is live.
|
|
94
105
|
|
|
95
106
|
Live container pause/unpause check:
|
|
96
107
|
[docs/smoke-checks/README.md](docs/smoke-checks/README.md).
|
|
@@ -107,6 +118,9 @@ Manual and managed `/github-issue` runs both finish by marking the PR ready.
|
|
|
107
118
|
Human review and merge begin there. The supervisor exposes no approve or merge
|
|
108
119
|
operation.
|
|
109
120
|
|
|
121
|
+
Legacy `review-<n>` and `repair-<n>` windows are allowed to finish without being
|
|
122
|
+
restarted or killed; both keep the supervisor active, and repairs consume an implementation slot.
|
|
123
|
+
|
|
110
124
|
## GitHub authentication
|
|
111
125
|
|
|
112
126
|
`gh` does not auto-consume the GitHub App credential, so the supervisor mints a
|
|
@@ -133,7 +147,7 @@ Labels are created at startup — see the managed review gate above.
|
|
|
133
147
|
| Path | Purpose |
|
|
134
148
|
|------|---------|
|
|
135
149
|
| `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 |
|
|
150
|
+
| `.agents/skills/` | Installed skills (`address-review`, `github-issue`, `github-pr-cleanup`, `review-pr`, `setup`) — managed artifacts, restored from the manifest, not committed |
|
|
137
151
|
| `.claude/skills` | Symlink to `.agents/skills` so Claude Code picks the skills up |
|
|
138
152
|
| `CLAUDE.md` | Agent instructions and gotchas for working in this repo |
|
|
139
153
|
|
|
@@ -144,6 +158,12 @@ Labels are created at startup — see the managed review gate above.
|
|
|
144
158
|
owner.
|
|
145
159
|
- **`setup`** — connects the repo to the `container-coding-agent` GitHub App
|
|
146
160
|
and verifies `git`/`gh` authenticate as the App.
|
|
161
|
+
- **`github-pr-cleanup`** — cleans the worktree, branch and session artifacts of
|
|
162
|
+
one merged or closed pull request. Worktree Warden is the automatic caller: it
|
|
163
|
+
watches for terminal pull requests and runs the skill's cleanup script, so
|
|
164
|
+
cleanup happens outside this repository's supervisor, which ends at "PR ready
|
|
165
|
+
for the owner". The skill is installed here so that script exists in a
|
|
166
|
+
checkout, and so it can also be run by hand for a single pull request.
|
|
147
167
|
|
|
148
168
|
The standalone `review-pr` and `address-review` skills remain installed for
|
|
149
169
|
manual use, but the production supervisor route does not invoke them.
|
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
|
|
@@ -363,14 +396,19 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
|
363
396
|
}
|
|
364
397
|
|
|
365
398
|
// Map open PRs to the implementation issue they close. GitHub's parsed closing
|
|
366
|
-
// linkage is authoritative
|
|
399
|
+
// linkage is authoritative whenever the PR declares any, so a PR on
|
|
400
|
+
// `agent/<other>-*` that closes a different issue never maps to the branch
|
|
401
|
+
// number. The conventional branch prefix applies only to PRs that declare no
|
|
402
|
+
// closing reference at all. Two or more applicable PRs are reported as
|
|
403
|
+
// ambiguous rather than resolved to the first match: the caller must not act.
|
|
367
404
|
export function implementationPrForIssue(prs, number) {
|
|
368
405
|
const prefix = `agent/${number}-`;
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
406
|
+
const applies = (pr) => (Array.isArray(pr.closingIssuesReferences) && pr.closingIssuesReferences.length > 0
|
|
407
|
+
? pr.closingIssuesReferences.some((ref) => ref?.number === number)
|
|
408
|
+
: typeof pr.headRefName === "string" && pr.headRefName.startsWith(prefix));
|
|
409
|
+
const matches = (prs || []).filter(applies);
|
|
410
|
+
if (matches.length > 1) return { pr: null, ambiguous: true };
|
|
411
|
+
return { pr: matches[0] || null, ambiguous: false };
|
|
374
412
|
}
|
|
375
413
|
|
|
376
414
|
// Production poll route after the managed-review rollback. It deliberately
|
|
@@ -379,6 +417,7 @@ export function implementationPrForIssue(prs, number) {
|
|
|
379
417
|
export async function runImplementationOnlyOnce({
|
|
380
418
|
gh, tmux,
|
|
381
419
|
checkSetupReady = async () => true,
|
|
420
|
+
checkPrimaryClean = async () => ({ clean: true, reason: "clean", detail: "" }),
|
|
382
421
|
implementationSlots = IMPLEMENTATION_SLOTS,
|
|
383
422
|
log,
|
|
384
423
|
logs = NO_WORKER_LOGS,
|
|
@@ -386,13 +425,50 @@ export async function runImplementationOnlyOnce({
|
|
|
386
425
|
}) {
|
|
387
426
|
await tmux.ensureSession();
|
|
388
427
|
const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
|
|
428
|
+
// A supervisor restarted into an `orchestrator` session left by the managed
|
|
429
|
+
// review release inherits live `review-N`/`repair-N` windows this route never
|
|
430
|
+
// opens. They are read before any mutation, alongside the implementation
|
|
431
|
+
// windows, so an inspection failure aborts the poll rather than running with
|
|
432
|
+
// unknown capacity.
|
|
389
433
|
const liveIssues = await tmux.listWorkerIssues();
|
|
434
|
+
const liveReview = await tmux.listReviewIssues();
|
|
435
|
+
const liveRepair = await tmux.listRepairIssues();
|
|
390
436
|
const prs = await gh.listOpenPrs();
|
|
391
|
-
|
|
437
|
+
// A legacy repair worker is a full `ccode` run sharing the implementation
|
|
438
|
+
// slots, exactly as it did before the rollback; a legacy reviewer had its own
|
|
439
|
+
// reserved slot and still consumes none.
|
|
440
|
+
let liveCount = liveIssues.size + liveRepair.size;
|
|
441
|
+
|
|
442
|
+
// Legacy windows are drained, never killed and never re-created: each may
|
|
443
|
+
// hold a worktree, a pushed branch, a draft PR and labels mid-transition, and
|
|
444
|
+
// the logic that knew how to finish or unwind that was removed with the
|
|
445
|
+
// managed review route. They exit on their own; until then they are visible
|
|
446
|
+
// here so nothing is launched over them and the supervisor cannot call itself
|
|
447
|
+
// idle. This line is the operator's only notice, so it names each one.
|
|
448
|
+
const legacyWindows = [
|
|
449
|
+
...[...liveReview].sort((a, b) => a - b).map((n) => `review-${n}`),
|
|
450
|
+
...[...liveRepair].sort((a, b) => a - b).map((n) => `repair-${n}`),
|
|
451
|
+
];
|
|
452
|
+
if (legacyWindows.length > 0) {
|
|
453
|
+
log(`Legacy managed-review windows still live: ${legacyWindows.join(", ")}`
|
|
454
|
+
+ " — draining; not managed, not restarted, and never killed by this supervisor");
|
|
455
|
+
}
|
|
392
456
|
|
|
393
457
|
for (const issue of running) {
|
|
394
458
|
try {
|
|
395
|
-
|
|
459
|
+
// Releasing the claim, or acting on a PR a legacy worker is still
|
|
460
|
+
// editing, is the race this reconciliation exists to avoid. Defer the
|
|
461
|
+
// whole issue; once its legacy window exits, the next poll reconciles it
|
|
462
|
+
// by the ordinary rules.
|
|
463
|
+
if (liveReview.has(issue.number) || liveRepair.has(issue.number)) {
|
|
464
|
+
log(`#${issue.number} has a live legacy window — deferring reconciliation to a later poll`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const { pr, ambiguous } = implementationPrForIssue(prs, issue.number);
|
|
468
|
+
if (ambiguous) {
|
|
469
|
+
log(`#${issue.number}: multiple open PRs claim this issue — leaving worker and claim untouched`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
396
472
|
if (pr && pr.isDraft === false) {
|
|
397
473
|
if (liveIssues.has(issue.number)) {
|
|
398
474
|
await tmux.closeWorker(issue.number);
|
|
@@ -418,11 +494,30 @@ export async function runImplementationOnlyOnce({
|
|
|
418
494
|
if (ready.length > 0 && !await checkSetupReady()) {
|
|
419
495
|
log("Agent setup incomplete — pausing worker launches");
|
|
420
496
|
return {
|
|
421
|
-
done: false, implLive: liveCount, reviewLive:
|
|
497
|
+
done: false, implLive: liveCount, reviewLive: liveReview.size,
|
|
498
|
+
repairLive: liveRepair.size,
|
|
422
499
|
started: 0, reviewsStarted: 0, repairsStarted: 0,
|
|
423
500
|
};
|
|
424
501
|
}
|
|
425
502
|
|
|
503
|
+
// Admission control. Isolation refuses a dirty primary worktree, so claiming
|
|
504
|
+
// an issue here would only burn a label transition on a worker that exits
|
|
505
|
+
// before opening a PR — which the next poll then misreports as vanished.
|
|
506
|
+
// Reconciliation above has already run, so live work keeps its slot, its
|
|
507
|
+
// claim and its lease; only new launches pause, and only for this poll.
|
|
508
|
+
if (ready.length > 0) {
|
|
509
|
+
const primary = await checkPrimaryClean();
|
|
510
|
+
if (!primary.clean) {
|
|
511
|
+
log(`Primary worktree ${primary.reason} (${primary.detail})`
|
|
512
|
+
+ " — no issue claimed, worker launches paused this poll");
|
|
513
|
+
return {
|
|
514
|
+
done: false, implLive: liveCount, reviewLive: liveReview.size,
|
|
515
|
+
repairLive: liveRepair.size,
|
|
516
|
+
started: 0, reviewsStarted: 0, repairsStarted: 0,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
426
521
|
let started = 0;
|
|
427
522
|
for (const number of ready) {
|
|
428
523
|
if (liveCount >= implementationSlots) break;
|
|
@@ -448,9 +543,12 @@ export async function runImplementationOnlyOnce({
|
|
|
448
543
|
}
|
|
449
544
|
|
|
450
545
|
return {
|
|
451
|
-
done
|
|
546
|
+
// `done` drops the Sentinel lease and stops the supervisor, so a live
|
|
547
|
+
// legacy worker of either kind must hold it false.
|
|
548
|
+
done: ready.length === 0 && liveCount === 0 && liveReview.size === 0,
|
|
452
549
|
implLive: liveCount,
|
|
453
|
-
reviewLive:
|
|
550
|
+
reviewLive: liveReview.size,
|
|
551
|
+
repairLive: liveRepair.size,
|
|
454
552
|
started,
|
|
455
553
|
reviewsStarted: 0,
|
|
456
554
|
repairsStarted: 0,
|
|
@@ -853,13 +951,48 @@ export async function main({
|
|
|
853
951
|
createLeaseClient = createConfiguredLease,
|
|
854
952
|
runPoll = runImplementationOnlyOnce,
|
|
855
953
|
checkSetupReady = isAgentSetupReady,
|
|
954
|
+
checkPrimaryClean = () => checkPrimaryWorktree({ exec }),
|
|
856
955
|
sleepImpl = sleep,
|
|
857
956
|
log = (message) => console.log(formatLogLine(message)),
|
|
957
|
+
stdout = process.stdout,
|
|
858
958
|
} = {}) {
|
|
859
959
|
const repo = await resolve(exec);
|
|
860
960
|
const releaseOwnership = await acquireOwnership(repo);
|
|
861
961
|
const lease = createLeaseClient();
|
|
862
962
|
let containerId;
|
|
963
|
+
// Docker freezes this process rather than restarting it, so the last observed
|
|
964
|
+
// enforcement state survives the pause in memory — nothing needs persisting.
|
|
965
|
+
let enforcementState = "running";
|
|
966
|
+
|
|
967
|
+
// Resolves only once the line has actually left the process. Workers run under
|
|
968
|
+
// `tmux … | tee`, where stdout is a pipe and `console.log` does not block, so
|
|
969
|
+
// an unflushed pause message could lose the race against Docker.
|
|
970
|
+
function logFlush(message) {
|
|
971
|
+
return new Promise((resolve, reject) => {
|
|
972
|
+
stdout.write(`${formatLogLine(message)}\n`, (err) => (err ? reject(err) : resolve()));
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Announce a transition, flush it, then acknowledge — in that order. The
|
|
977
|
+
// acknowledgment lets Sentinel pause this very container, so an unflushed
|
|
978
|
+
// message would race Docker and could be lost. A failed acknowledgment is
|
|
979
|
+
// visible but not fatal: Sentinel pauses anyway once its timeout elapses, so
|
|
980
|
+
// failing here would cost availability without adding enforcement.
|
|
981
|
+
//
|
|
982
|
+
// The parameter default covers a client that predates the enforcement field;
|
|
983
|
+
// the current one always parses and returns it.
|
|
984
|
+
async function notifyEnforcement(enforcement = { state: "running" }) {
|
|
985
|
+
const transition = describeEnforcementTransition(enforcementState, enforcement);
|
|
986
|
+
enforcementState = transition.state;
|
|
987
|
+
if (transition.message) await logFlush(transition.message);
|
|
988
|
+
if (!transition.acknowledge) return;
|
|
989
|
+
try {
|
|
990
|
+
await lease.acknowledge(containerId);
|
|
991
|
+
} catch (err) {
|
|
992
|
+
log(`pause acknowledgment failed: ${err.message}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
863
996
|
try {
|
|
864
997
|
// One helper for the whole run: the launcher that reserves an attempt and
|
|
865
998
|
// the poll that later reads it must agree on where the logs live.
|
|
@@ -889,8 +1022,9 @@ export async function main({
|
|
|
889
1022
|
// silently running unenforced.
|
|
890
1023
|
try {
|
|
891
1024
|
containerId = await resolveContainer();
|
|
892
|
-
const { status } = await lease.register(containerId);
|
|
1025
|
+
const { status, enforcement } = await lease.register(containerId);
|
|
893
1026
|
log(`Managed container lease ${status} for ${containerId}`);
|
|
1027
|
+
await notifyEnforcement(enforcement);
|
|
894
1028
|
} catch (err) {
|
|
895
1029
|
throw new Error(`managed-container registration failed: ${err.message}`);
|
|
896
1030
|
}
|
|
@@ -902,7 +1036,8 @@ export async function main({
|
|
|
902
1036
|
for (let firstPoll = true; ; firstPoll = false) {
|
|
903
1037
|
if (!firstPoll) {
|
|
904
1038
|
try {
|
|
905
|
-
await lease.register(containerId);
|
|
1039
|
+
const { enforcement } = await lease.register(containerId);
|
|
1040
|
+
await notifyEnforcement(enforcement);
|
|
906
1041
|
} catch (err) {
|
|
907
1042
|
log(`lease heartbeat failed: ${err.message}`);
|
|
908
1043
|
}
|
|
@@ -912,7 +1047,7 @@ export async function main({
|
|
|
912
1047
|
const token = await mint(repo);
|
|
913
1048
|
const gh = createGitHub({ exec: authExec(token), repo });
|
|
914
1049
|
result = await runPoll({
|
|
915
|
-
gh, tmux, checkSetupReady,
|
|
1050
|
+
gh, tmux, checkSetupReady, checkPrimaryClean,
|
|
916
1051
|
implementationSlots: IMPLEMENTATION_SLOTS,
|
|
917
1052
|
reviewerSlots: REVIEWER_SLOTS,
|
|
918
1053
|
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 || []);
|