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.
- package/README.md +16 -10
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +73 -24
- package/src/config.ts +11 -0
- package/src/daemon.ts +73 -8
- package/src/decisions.ts +67 -7
- package/src/fleet.ts +39 -6
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/setup.ts +1 -1
- package/src/tracker/github.ts +234 -73
- package/src/types.ts +10 -0
- package/src/unblock.ts +98 -13
- package/src/upgrade.ts +29 -3
- package/src/worktree.ts +23 -4
package/src/tracker/github.ts
CHANGED
|
@@ -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
|
|
176
|
-
*
|
|
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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
"
|
|
495
|
-
|
|
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
|
-
|
|
512
|
-
//
|
|
513
|
-
// the normal steady state, not an error.
|
|
514
|
-
|
|
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
|
-
|
|
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([
|
|
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(["
|
|
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`:
|
|
615
|
-
// from
|
|
616
|
-
//
|
|
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
|
|
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
|
|
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
|
-
"
|
|
741
|
-
|
|
742
|
-
"--
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
?
|
|
222
|
-
|
|
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
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
}
|