omp-conductor 0.7.1 → 0.8.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.
@@ -34,17 +34,6 @@ import type {
34
34
  Tracker,
35
35
  } from "../types.ts";
36
36
 
37
- /** The subset of `gh issue list --json` output this adapter reads. Fields the
38
- * API can return as null are typed as such so the mapping has to handle it. */
39
- interface GhIssue {
40
- number: number;
41
- title: string | null;
42
- body: string | null;
43
- labels: { name: string }[] | null;
44
- url: string;
45
- updatedAt: string;
46
- }
47
-
48
37
  /**
49
38
  * Pull requests that would close an issue, with the one field that decides it.
50
39
  *
@@ -172,24 +161,27 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
172
161
 
173
162
  /**
174
163
  * True when a label edit failed only because the requested end state already
175
- * holds. Adding a label is idempotent server-side, but removing one that is not
176
- * present is a 404, and a concurrent daemon restart can easily race into both.
164
+ * holds. Adding a label is idempotent server-side `gh api -X POST
165
+ * .../labels` answers 200 for an already-present label but removing one that
166
+ * is not present is a 404, and a concurrent daemon restart can easily race into
167
+ * both.
177
168
  *
178
- * The quoted form is gh's own, and it is why the two operations are classified
179
- * separately: `gh issue edit --remove-label x` on a label the *repository* does
180
- * not define exits 1 with `'x' not found` (2.97.0), which for a removal is the
181
- * end state already holding a label nobody defined cannot be on an issue
182
- * while for an add it is a genuine failure. Removing a label the repo defines
183
- * but the issue does not carry is already a silent success, so this path is
184
- * reached only by the undefined-label case, most often a state label an
185
- * operator declined to create at setup.
169
+ * The quoted forms are gh's own: `gh issue edit --remove-label x` on a label
170
+ * the *repository* does not define exits 1 with `'x' not found` (2.97.0), and
171
+ * `gh api -X DELETE .../labels/x` on an absent label fails with the API's
172
+ * `Label does not exist` / `HTTP 404`. For a removal both are the end state
173
+ * already holding a label nobody defined cannot be on an issue while for
174
+ * an add they are a genuine failure. Removing a label the repo defines but the
175
+ * issue does not carry is already a silent success, so this path is reached
176
+ * only by the undefined-label case, most often a state label an operator
177
+ * declined to create at setup.
186
178
  */
187
179
  function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
188
180
  if (!(err instanceof GhError)) return false;
189
181
  const stderr = err.stderr;
190
182
  return op === "add"
191
183
  ? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr)
192
- : /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found/i.test(
184
+ : /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found|HTTP 404|Not Found/i.test(
193
185
  stderr,
194
186
  );
195
187
  }
@@ -234,6 +226,22 @@ export function firstOpenCloser(raw: string): OpenCloser | undefined {
234
226
  */
235
227
  const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/;
236
228
 
229
+ /**
230
+ * Split a pull request URL into the owner, repo and number the REST API path
231
+ * spelling needs. The same shape {@link PR_URL} guards, with the parts
232
+ * captured; undefined for anything that guard would reject, so callers can use
233
+ * the two interchangeably.
234
+ */
235
+ export function prUrlParts(
236
+ url: string,
237
+ ): { owner: string; repo: string; number: number } | undefined {
238
+ const match = /^https?:\/\/[^\s/]+\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\/?$/.exec(url);
239
+ if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
240
+ return undefined;
241
+ }
242
+ return { owner: match[1], repo: match[2], number: Number.parseInt(match[3], 10) };
243
+ }
244
+
237
245
  /**
238
246
  * How much of a pull request's diff the settlement audit will hold in memory.
239
247
  *
@@ -372,6 +380,29 @@ export function issueStateFrom(raw: string): IssueState | undefined {
372
380
  }
373
381
  }
374
382
 
383
+ /**
384
+ * GitHub's REST pull-request shape (`{state, merged_at}`) mapped onto
385
+ * {@link PrState}. `merged_at` is what distinguishes a merged PR from a
386
+ * closed-unmerged one under REST — `state` alone answers "closed" for both, and
387
+ * a settle sweep that read only state would rewrite a merged row as
388
+ * closed-unmerged. Anything unrecognised is "could not tell", the same
389
+ * fail-closed answer {@link prStateFrom} gives.
390
+ */
391
+ export function prStateFromRest(raw: string): PrState | undefined {
392
+ let parsed: unknown;
393
+ try {
394
+ parsed = JSON.parse(raw) as unknown;
395
+ } catch {
396
+ return undefined;
397
+ }
398
+ if (parsed === null || typeof parsed !== "object") return undefined;
399
+ const row = parsed as { readonly [key: string]: unknown };
400
+ if (row["merged_at"] != null) return "merged";
401
+ if (row["state"] === "closed") return "closed";
402
+ if (row["state"] === "open") return "open";
403
+ return undefined;
404
+ }
405
+
375
406
  /**
376
407
  * Parent number from a raw GraphQL response, or undefined when the issue has
377
408
  * no parent. Throws when the issue or claimed parent is malformed — admission
@@ -438,6 +469,26 @@ export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown"
438
469
  }
439
470
  }
440
471
 
472
+ /**
473
+ * The REST mergeability spelling onto the same fail-open triple. `--jq
474
+ * .mergeable` prints the bare JSON token, so `"true"` and `"false"` are the
475
+ * only clean answers; `null` (printed verbatim by `--jq`) is GitHub still
476
+ * computing the merge, and everything else means the API changed under us.
477
+ */
478
+ export function mergeableFromRest(raw: string): "conflicting" | "clean" | "unknown" {
479
+ switch (raw.trim()) {
480
+ case "true":
481
+ return "clean";
482
+ case "false":
483
+ return "conflicting";
484
+ default:
485
+ // `null` is GitHub still computing the merge on a PR pushed seconds ago.
486
+ // Reading it as clean would let a conflict recovery fire on a PR nobody
487
+ // has assessed yet — the same reasoning as `UNKNOWN` above.
488
+ return "unknown";
489
+ }
490
+ }
491
+
441
492
  /**
442
493
  * Distinct workflow-run ids behind the non-success checks of a PR.
443
494
  *
@@ -483,6 +534,107 @@ export function labeledIssuesFrom(raw: string): { number: number; state: IssueSt
483
534
  return issues;
484
535
  }
485
536
 
537
+ /**
538
+ * Parse the REST `/repos/{owner}/{repo}/issues` array onto {@link ReadyIssue}.
539
+ *
540
+ * The endpoint returns pull requests as well as issues — every PR is an issue
541
+ * with a `pull_request` key — so that key is the filter that keeps a routed
542
+ * repo's PR backlog out of the ready queue (#188). The REST field spelling is
543
+ * snake_case (`html_url`, `updated_at`); nulls are tolerated the same way the
544
+ * mapping this replaces tolerated them, because the API answers null for any
545
+ * of these fields.
546
+ */
547
+ export function readyIssuesFromRest(raw: string): ReadyIssue[] {
548
+ let parsed: unknown;
549
+ try {
550
+ parsed = JSON.parse(raw) as unknown;
551
+ } catch {
552
+ return [];
553
+ }
554
+ if (!Array.isArray(parsed)) return [];
555
+ const issues: ReadyIssue[] = [];
556
+ for (const entry of parsed) {
557
+ if (entry === null || typeof entry !== "object") continue;
558
+ const row = entry as { readonly [key: string]: unknown };
559
+ // The endpoint mixes in pull requests; the queue must never claim one.
560
+ if (row["pull_request"] != null) continue;
561
+ const number = row["number"];
562
+ const title = row["title"];
563
+ const body = row["body"];
564
+ const labels = row["labels"];
565
+ const url = row["html_url"];
566
+ const updatedAt = row["updated_at"];
567
+ if (typeof number !== "number" || !Number.isInteger(number)) continue;
568
+ if (typeof url !== "string" || typeof updatedAt !== "string") continue;
569
+ const names: string[] = [];
570
+ if (Array.isArray(labels)) {
571
+ for (const label of labels) {
572
+ if (label === null || typeof label !== "object") continue;
573
+ const name = (label as { readonly name?: unknown }).name;
574
+ if (typeof name === "string") names.push(name);
575
+ }
576
+ }
577
+ issues.push({
578
+ number,
579
+ title: typeof title === "string" ? title : "",
580
+ body: typeof body === "string" ? body : "",
581
+ labels: names,
582
+ url,
583
+ updatedAt,
584
+ });
585
+ }
586
+ return issues;
587
+ }
588
+
589
+ /** The two GitHub API budgets this adapter spends: GraphQL for the
590
+ * closers/parent/rollup reads, core for everything else. `reset` is the REST
591
+ * spelling — epoch seconds. */
592
+ export interface RateLimitStatus {
593
+ graphql: { remaining: number; limit: number; reset: number };
594
+ core: { remaining: number; limit: number; reset: number };
595
+ }
596
+
597
+ function rateLimitBucket(
598
+ raw: unknown,
599
+ ): { remaining: number; limit: number; reset: number } | undefined {
600
+ if (raw === null || typeof raw !== "object") return undefined;
601
+ const row = raw as { readonly [key: string]: unknown };
602
+ const remaining = row["remaining"];
603
+ const limit = row["limit"];
604
+ const reset = row["reset"];
605
+ if (typeof remaining !== "number" || typeof limit !== "number" || typeof reset !== "number") {
606
+ return undefined;
607
+ }
608
+ return { remaining, limit, reset };
609
+ }
610
+
611
+ /**
612
+ * Read the GitHub API rate-limit budget for `status` (the tracker layer's
613
+ * counterpart to the decision probe: the layers differ, so the two stay
614
+ * independent).
615
+ *
616
+ * Any failure — a revoked token, a flaky network, a payload shape this build
617
+ * does not recognise — answers undefined: a broken `gh` must cost one status
618
+ * row, never the whole report.
619
+ */
620
+ export async function fetchRateLimit(
621
+ runGh: typeof gh = gh,
622
+ ): Promise<RateLimitStatus | undefined> {
623
+ try {
624
+ const parsed = JSON.parse(
625
+ await runGh(["api", "rate_limit", "--jq", "{graphql: .resources.graphql, core: .resources.core}"]),
626
+ ) as unknown;
627
+ if (parsed === null || typeof parsed !== "object") return undefined;
628
+ const row = parsed as { readonly [key: string]: unknown };
629
+ const graphql = rateLimitBucket(row["graphql"]);
630
+ const core = rateLimitBucket(row["core"]);
631
+ if (graphql === undefined || core === undefined) return undefined;
632
+ return { graphql, core };
633
+ } catch {
634
+ return undefined;
635
+ }
636
+ }
637
+
486
638
  export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
487
639
  const repo = p.tracker.repo;
488
640
 
@@ -490,43 +642,35 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
490
642
  // `checkConclusions` instead of re-implementing the same `gh` call.
491
643
  const tracker: Tracker = {
492
644
  async listReady(): Promise<ReadyIssue[]> {
645
+ // REST, not `issue list --json`: the ready-queue read is a hot-path poll
646
+ // on every dispatch pass, and GraphQL budget is the scarce one (#188).
647
+ // ponytail: one page is the cap. A queue deeper than 100 ready issues
648
+ // truncates silently; upgrade path is `--paginate`, but a backlog that
649
+ // size is a staffing problem before it is a paging one.
493
650
  const raw = await runGh([
494
- "issue",
495
- "list",
496
- "--repo",
497
- repo,
498
- "--state",
499
- "open",
500
- "--label",
501
- p.queueLabel,
502
- // ponytail: one page is the cap. A queue deeper than 100 ready issues
503
- // truncates silently; upgrade path is `--paginate` via the API, but a
504
- // backlog that size is a staffing problem before it is a paging one.
505
- "--limit",
506
- "100",
507
- "--json",
508
- "number,title,body,labels,url,updatedAt",
651
+ "api",
652
+ `repos/${repo}/issues?state=open&labels=${encodeURIComponent(p.queueLabel)}&per_page=100`,
509
653
  ]);
510
654
 
511
- const text = raw.trim();
512
- // `gh` prints nothing at all in some no-match paths; an empty queue is
513
- // the normal steady state, not an error.
514
- if (!text) return [];
515
-
516
- const issues = JSON.parse(text) as GhIssue[];
517
- return issues.map((issue) => ({
518
- number: issue.number,
519
- title: issue.title ?? "",
520
- body: issue.body ?? "",
521
- labels: (issue.labels ?? []).map((label) => label.name),
522
- url: issue.url,
523
- updatedAt: issue.updatedAt,
524
- }));
655
+ // The REST endpoint always answers a JSON array — an empty queue is `[]`,
656
+ // not empty text but an empty body still parses to no issues here, and
657
+ // the empty queue is the normal steady state, not an error.
658
+ return readyIssuesFromRest(raw);
525
659
  },
526
660
 
527
661
  async addLabel(issue: number, label: string): Promise<void> {
528
662
  try {
529
- await runGh(["issue", "edit", String(issue), "--repo", repo, "--add-label", label]);
663
+ // REST POST is natively idempotent: re-adding a label the issue already
664
+ // carries answers 200 with the label, so the noop classification below
665
+ // is reachable only on a genuinely odd failure — kept for parity.
666
+ await runGh([
667
+ "api",
668
+ "-X",
669
+ "POST",
670
+ `repos/${repo}/issues/${issue}/labels`,
671
+ "-f",
672
+ `labels[]=${label}`,
673
+ ]);
530
674
  } catch (err) {
531
675
  if (!isLabelNoop(err, "add")) throw err;
532
676
  }
@@ -534,7 +678,12 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
534
678
 
535
679
  async removeLabel(issue: number, label: string): Promise<void> {
536
680
  try {
537
- await runGh(["issue", "edit", String(issue), "--repo", repo, "--remove-label", label]);
681
+ await runGh([
682
+ "api",
683
+ "-X",
684
+ "DELETE",
685
+ `repos/${repo}/issues/${issue}/labels/${encodeURIComponent(label)}`,
686
+ ]);
538
687
  } catch (err) {
539
688
  if (!isLabelNoop(err, "remove")) throw err;
540
689
  }
@@ -602,8 +751,10 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
602
751
 
603
752
  async issueState(issue: number): Promise<IssueState | undefined> {
604
753
  try {
754
+ // REST answers lowercase `open`/`closed`; the shared parser expects the
755
+ // CLI's uppercase spelling, so the answer is normalised before it.
605
756
  return issueStateFrom(
606
- await runGh(["issue", "view", String(issue), "--repo", repo, "--json", "state", "--jq", ".state"]),
757
+ (await runGh(["api", `repos/${repo}/issues/${issue}`, "--jq", ".state"])).trim().toUpperCase(),
607
758
  );
608
759
  } catch {
609
760
  return undefined;
@@ -611,14 +762,21 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
611
762
  },
612
763
 
613
764
  async prState(url: string): Promise<PrState | undefined> {
614
- // No `--repo`: a full URL is self-locating, and verified so on gh 2.97.0
615
- // from a directory that is not a git repository at all which is exactly
616
- // where the daemon runs (its state directory), while the PR lives in one
617
- // of the routed repos. Deriving `--repo` from the URL would only re-state
618
- // what the URL already says.
765
+ // No `--repo`: the full URL names the repository, and the daemon runs
766
+ // from its own state directory rather than a checkout so the REST path
767
+ // halves come out of the URL itself, never out of the current directory.
619
768
  if (!PR_URL.test(url)) return undefined;
769
+ const parts = prUrlParts(url);
770
+ if (parts === undefined) return undefined;
620
771
  try {
621
- return prStateFrom(await runGh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
772
+ return prStateFromRest(
773
+ await runGh([
774
+ "api",
775
+ `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
776
+ "--jq",
777
+ "{state, merged_at}",
778
+ ]),
779
+ );
622
780
  } catch {
623
781
  // Never throws, per the port's contract. A deleted PR, a revoked token
624
782
  // and a flaky network all mean "could not tell", and the caller's whole
@@ -713,8 +871,17 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
713
871
 
714
872
  async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
715
873
  if (!PR_URL.test(prUrl)) return "unknown";
874
+ const parts = prUrlParts(prUrl);
875
+ if (parts === undefined) return "unknown";
716
876
  try {
717
- return mergeableFrom(await runGh(["pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"]));
877
+ return mergeableFromRest(
878
+ await runGh([
879
+ "api",
880
+ `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
881
+ "--jq",
882
+ ".mergeable",
883
+ ]),
884
+ );
718
885
  } catch {
719
886
  return "unknown";
720
887
  }
@@ -737,18 +904,12 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
737
904
  try {
738
905
  return labeledIssuesFrom(
739
906
  await runGh([
740
- "issue",
741
- "list",
742
- "--repo",
743
- repo,
744
- "--label",
745
- label,
746
- "--state",
747
- "all",
748
- "--json",
749
- "number,state",
750
- "--limit",
751
- String(limit),
907
+ "api",
908
+ `repos/${repo}/issues?state=all&labels=${encodeURIComponent(label)}&per_page=${limit}`,
909
+ "--jq",
910
+ // The endpoint mixes pull requests into the issue list; the
911
+ // reconcile must not remove labels off PRs.
912
+ "[.[] | select(.pull_request == null) | {number, state}]",
752
913
  ]),
753
914
  );
754
915
  } catch {
package/src/types.ts CHANGED
@@ -42,6 +42,10 @@ export interface Caps {
42
42
  /** Parallel omp sessions. Two by default: on a small self-hosted runner pool
43
43
  * a third worker would starve its own PR checks. */
44
44
  maxConcurrentWorkers: number;
45
+ /** Max live workers per repository. 1 by default: the mirror, branch-protection
46
+ * staleness and shared CI egress are all per-repo collision domains (#186), so
47
+ * extra slots should land on other repos. */
48
+ maxConcurrentWorkersPerRepo: number;
45
49
  /**
46
50
  * Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock
47
51
  * still apply). `0` is a hard stop — deliberate, not "unset".
@@ -479,6 +483,10 @@ export interface ProjectConfig {
479
483
  tracker: { kind: "github"; repo: string };
480
484
  /** The one label that means "a human has signed this off as agent-ready". */
481
485
  queueLabel: string;
486
+ /** Routable-candidate count below which the tick prompt tells the orchestrator
487
+ * to groom the queue. Optional; defaults to {@link DEFAULT_GROOM_BELOW} in
488
+ * orchestrator-tick.ts. */
489
+ groomBelow?: number;
482
490
  /** Labels the dispatcher writes back so the tracker alone shows live state
483
491
  * to a human who never opens the daemon's logs. */
484
492
  stateLabels: { inProgress: string; blocked: string; failed: string };
@@ -923,6 +931,7 @@ export type AdmissionHoldReason =
923
931
  | "continuations"
924
932
  | "parent-lookup-error"
925
933
  | "sibling-active"
934
+ | "repo-active"
926
935
  | "open-pr-lookup-error"
927
936
  | "open-pr"
928
937
  | "unsalvaged-wip"
@@ -1311,6 +1320,7 @@ export interface Escalation {
1311
1320
  */
1312
1321
  export const DEFAULT_CAPS: Caps = {
1313
1322
  maxConcurrentWorkers: 2,
1323
+ maxConcurrentWorkersPerRepo: 1,
1314
1324
  dailySpendUsd: 25,
1315
1325
  // Off unless an operator names a window. A default threshold would need a
1316
1326
  // default window id, and guessing which allowance a fleet lives on is how a
package/src/unblock.ts CHANGED
@@ -15,7 +15,10 @@
15
15
  * dispatcher writes labels with, and the brief's rule stays absolute. That
16
16
  * absoluteness is worth more than the exception it replaces: orphan detection
17
17
  * is only trustworthy while every state label on the tracker was written by
18
- * this package.
18
+ * this package. The re-queue is the same port and the same principle: the
19
+ * queue label goes back on by default (#184) so an answered block is
20
+ * dispatchable again, and it stays off under `--no-requeue` or while any run
21
+ * for the issue is still active.
19
22
  *
20
23
  * Nothing here writes to the store, and that is a decision rather than an
21
24
  * omission. `RunState` describes what a worker process did; an answer is the
@@ -44,11 +47,24 @@ export interface UnblockOutcome {
44
47
  * to "will the dispatcher hold this issue as issue-active?" that #178
45
48
  * found this verb guessing at. */
46
49
  active: boolean;
50
+ /** Set when a live worker (a claimed/running run) is on the issue. The
51
+ * dispatcher holds a worker-backed run as issue-active unconditionally,
52
+ * while worker-free pushed occupancy can bypass it (#175) — so this is
53
+ * the signal the queue label and the in-flight wording follow. */
54
+ live?: true;
47
55
  /** Set when nothing was cleared because the newest attempt's work exists
48
56
  * only in its worktree. Carries the salvage failure verbatim. */
49
57
  refused?: string;
50
58
  /** Set when `--force` recorded an operator's acceptance of that loss. */
51
59
  forced?: true;
60
+ /** Set when the queue label was re-added (the default); absent on
61
+ * --no-requeue, on the refusal path, and when a live worker (claimed or
62
+ * running) is still on the issue. A pushed-green/pushed-pending occupancy
63
+ * is worker-free, so the label is still restored there (#175 bypasses it;
64
+ * `isEligible` needs the label once the PR resolves). */
65
+ requeued?: true;
66
+ /** Set when `--no-requeue` skipped the queue-label re-add. */
67
+ requeueSkipped?: true;
52
68
  }
53
69
 
54
70
  /**
@@ -90,13 +106,24 @@ export async function unblockIssue(
90
106
  tracker: Tracker,
91
107
  store: Store,
92
108
  issue: number,
93
- opts: { force?: boolean } = {},
109
+ opts: { force?: boolean; requeue?: boolean } = {},
94
110
  ): Promise<UnblockOutcome> {
111
+ const requeue = opts.requeue !== false;
95
112
  // Read before any label is touched: terminality is the whole of the argument
96
113
  // for clearing in-progress, so the row that carries it decides the set.
97
114
  const latest = store.latestRun(project.name, issue);
98
115
  const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
116
+ // Two different occupancies, and the label decisions each follow its own:
117
+ // `active` (ACTIVE_STATES) is what *occupies the issue* — a pushed-green or
118
+ // pushed-pending run has no worker process but its PR is live, so dispatch
119
+ // must not start a fresh attempt behind it. `live` (LIVE_STATES) is what
120
+ // *holds a worker process* (claimed/running). The queue label follows
121
+ // `live`: the dispatcher only holds a claim as issue-active for a
122
+ // worker-backed run (#175 bypasses worker-free pushed-green rows), so an
123
+ // issue whose occupancy is pushed-only is genuinely re-queueable and
124
+ // `isEligible` requires the label once that PR resolves closed-unmerged.
99
125
  const active = store.activeRuns(project.name).some((r) => r.issue === issue);
126
+ const live = store.liveRuns(project.name).some((r) => r.issue === issue);
100
127
  const counts = {
101
128
  attemptsUsed: store.attemptsFor(project.name, issue),
102
129
  failuresUsed: store.failuresFor(project.name, issue),
@@ -132,11 +159,37 @@ export async function unblockIssue(
132
159
  cleared.push(label);
133
160
  }
134
161
 
162
+ // The re-queue half of the verb. Clearing the state labels makes the issue
163
+ // *eligible*, but the dispatcher never sees an issue that does not carry the
164
+ // queue label — without this the unblock ends in the same alive-but-inert
165
+ // state it exists to end (#184). The label goes back on by default;
166
+ // `--no-requeue` is the "about to close it" case and leaves dispatch alone.
167
+ // Never under a *live worker*: the dispatcher holds a worker-backed run as
168
+ // issue-active anyway, and restoring the label under one is exactly the
169
+ // #178 inverted state. A pushed-green/pushed-pending run is worker-free —
170
+ // its PR is live but no process writes to its branch — so a pushed
171
+ // continuation still gets the label: the dispatcher bypasses worker-free
172
+ // pushed-green rows (#175), and `isEligible` requires the queue label once
173
+ // the PR resolves closed-unmerged.
174
+ let requeued: true | undefined;
175
+ let requeueSkipped: true | undefined;
176
+ if (!live) {
177
+ if (requeue) {
178
+ await tracker.addLabel(issue, project.queueLabel);
179
+ requeued = true;
180
+ } else {
181
+ requeueSkipped = true;
182
+ }
183
+ }
184
+
135
185
  return {
136
186
  cleared,
137
187
  ...counts,
188
+ ...(live ? { live: true as const } : {}),
138
189
  ...(latest === undefined ? {} : { latest }),
139
190
  ...(held === undefined ? {} : { forced: true as const }),
191
+ ...(requeued === undefined ? {} : { requeued }),
192
+ ...(requeueSkipped === undefined ? {} : { requeueSkipped }),
140
193
  };
141
194
  }
142
195
 
@@ -191,9 +244,15 @@ export function formatUnblock(
191
244
  }
192
245
 
193
246
  if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
247
+ // The newest run is live, so `active` is necessarily true and the queue
248
+ // label was NOT re-added (unblockIssue only re-queues when no run for the
249
+ // issue is active). Say that, like the sibling-active branch below does —
250
+ // the label outcome is part of the contract (#184), and a live newest run
251
+ // reaches this branch instead of `else if (o.active)`.
194
252
  lines.push(
195
253
  ` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
196
254
  `"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
255
+ ` queue "${project.queueLabel}" not restored — a run is still active; re-run unblock once it settles`,
197
256
  );
198
257
  } else if (o.failuresUsed >= caps.maxAttemptsPerIssue) {
199
258
  lines.push(
@@ -218,25 +277,51 @@ export function formatUnblock(
218
277
  // instead of promising a re-claim the next tick withholds.
219
278
  lines.push(
220
279
  latest.prUrl === undefined
221
- ? ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
222
- `(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
280
+ ? o.requeued === true
281
+ ? ` next tick eligible again — "${project.queueLabel}" restored (no-op if it was already present; ` +
282
+ "the dispatcher still applies its open-PR check at claim time)"
283
+ : ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
284
+ `(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
223
285
  : ` next tick eligible as a continuation of ${latest.prUrl} — the pushed run stays active until ` +
224
286
  "that PR resolves; dispatch continues on its branch",
225
287
  );
226
288
  } else if (o.active) {
227
- // A run other than the newest is still active (e.g. a live sibling under a
228
- // terminal newest row), so the dispatcher will hold the issue as
229
- // issue-active whatever the labels now say. #178's misleading case.
230
- lines.push(
231
- ` in flight a run for this issue is still active, so the dispatcher holds the issue until it settles — ` +
232
- `nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
233
- );
289
+ // A run other than the newest still occupies the issue. Two shapes hide
290
+ // under `active`, and they demand opposite reports: a *live worker*
291
+ // (claimed/running) holds the issue unconditionally #178's misleading
292
+ // case — while worker-free pushed runs (pushed-green/pushed-pending) are
293
+ // the continuation shape, because #175 lets the dispatcher admit
294
+ // pushed-green rows whose workers are gone. The queue label follows the
295
+ // same split: off under a live worker, restored under pushed-only
296
+ // occupancy.
297
+ if (o.live === true) {
298
+ lines.push(
299
+ ` in flight a live worker is still on this issue, so the dispatcher holds it until that run settles — ` +
300
+ `nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
301
+ ` queue "${project.queueLabel}" not restored — a live worker is still on this issue; re-run unblock once it settles`,
302
+ );
303
+ } else {
304
+ lines.push(
305
+ ` next tick no live worker is on this issue — its active run(s) are worker-free pushes; ` +
306
+ "dispatch continues their branch once its holds and open-PR check clear",
307
+ );
308
+ if (o.requeued === true) {
309
+ lines.push(` queue "${project.queueLabel}" restored (no-op if it was already present)`);
310
+ }
311
+ }
234
312
  } else {
235
313
  lines.push(
236
- ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
237
- "(the dispatcher still applies its open-PR check at claim time)",
314
+ o.requeued === true
315
+ ? ` next tick eligible again "${project.queueLabel}" restored (no-op if it was already present; ` +
316
+ "the dispatcher still applies its open-PR check at claim time)"
317
+ : ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
318
+ "(the dispatcher still applies its open-PR check at claim time)",
238
319
  );
239
320
  }
240
321
 
322
+ if (o.requeueSkipped === true) {
323
+ lines.push(` queue "${project.queueLabel}" left untouched (--no-requeue)`);
324
+ }
325
+
241
326
  return lines.join("\n");
242
327
  }