@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 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.
@@ -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
@@ -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; the conventional branch prefix is a fallback.
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 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;
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
- let liveCount = liveIssues.size;
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
- const pr = implementationPrForIssue(prs, issue.number);
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: 0,
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: ready.length === 0 && liveCount === 0,
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: 0,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickysagan/issue-orchestrator",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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 || []);