omp-conductor 0.3.2 → 0.3.4

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
@@ -312,25 +312,32 @@ Per tick, for the daemon's project:
312
312
  1. **Paused?** If the pause sentinel exists, the tick claims nothing and returns.
313
313
  Pause is checked first, so `omp-conductor pause` takes effect on the next tick
314
314
  without signalling the process.
315
- 2. **List the queue.** Open issues in `tracker.repo` labelled `queueLabel`.
316
- 3. **Filter and route.** An issue is eligible only if it carries the queue label
315
+ 2. **Settle the green PRs.** For every run in `pushed-green`, ask the tracker
316
+ what became of its PR. Merged the row becomes `merged`; closed without
317
+ merging → `failed`, with the PR named in the row's `lastError`. Still open, or
318
+ an answer that could not be obtained at all → the row is left exactly as it
319
+ is. Bounded by the number of green PRs awaiting a merge, which is a handful by
320
+ construction. This runs above admission so a row settled here frees its issue
321
+ in the same tick. See [what settles a green PR](#what-settles-a-green-pr).
322
+ 3. **List the queue.** Open issues in `tracker.repo` labelled `queueLabel`.
323
+ 4. **Filter and route.** An issue is eligible only if it carries the queue label
317
324
  and none of the three state labels (`inProgress`, `blocked`, `failed`). Eligible
318
325
  issues are partitioned into routable and unroutable.
319
- 4. **Escalate the unroutable** at Tier 1, quoting the repo labels actually seen and
326
+ 5. **Escalate the unroutable** at Tier 1, quoting the repo labels actually seen and
320
327
  the configured repo names. These are never dispatched.
321
- 5. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
328
+ 6. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
322
329
  daemon **pauses itself**, pages at Tier 2, and returns.
323
- 6. **Check capacity.** `maxConcurrentWorkers` minus *live* workers (runs in
330
+ 7. **Check capacity.** `maxConcurrentWorkers` minus *live* workers (runs in
324
331
  `claimed` or `running`) gives the free slots. A green PR awaiting a human
325
332
  merge occupies its issue but not a slot: its worker is finished, and counting
326
333
  it would let two green PRs stop the fleet. If no slot is free, the tick logs
327
334
  and returns.
328
- 7. **Admit issues** up to the free slots, skipping any issue that already has
335
+ 8. **Admit issues** up to the free slots, skipping any issue that already has
329
336
  an active run — including a green PR, so a second attempt can never land on a
330
337
  live PR. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
331
338
  instead of being admitted.
332
- 8. **Ask the tracker whether the work already exists.** For each candidate that
333
- survived step 7 — so at most one API call per free slot, never one per queued
339
+ 9. **Ask the tracker whether the work already exists.** For each candidate that
340
+ survived step 8 — so at most one API call per free slot, never one per queued
334
341
  issue — the daemon asks whether an **open** PR already closes the issue. If one
335
342
  does, the issue is skipped with its PR named in the log. Drafts count: a draft
336
343
  PR's branch still holds the only copy of the work. This is the guard the store
@@ -340,7 +347,7 @@ Per tick, for the daemon's project:
340
347
  retried next tick: the cost of holding is five minutes, the cost of admitting
341
348
  on an unknown is a burned attempt and a duplicate PR. Only that candidate is
342
349
  held, so a flaky API cannot stall the rest of the queue.
343
- 9. **Dispatch** the admitted issues concurrently.
350
+ 10. **Dispatch** the admitted issues concurrently.
344
351
 
345
352
  Then, per admitted issue:
346
353
 
@@ -368,6 +375,9 @@ Then, per admitted issue:
368
375
  | `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then **kept** as evidence | Tier 1 |
369
376
  | unexpected error | swapped to `agent:failed` | same | Tier 1 |
370
377
 
378
+ `pushed-green` is not the end of the row: a later tick settles it once the PR
379
+ resolves. See [what settles a green PR](#what-settles-a-green-pr).
380
+
371
381
  Label swaps add the new label before removing the old one: the reverse order
372
382
  leaves a window in which the issue carries no state label at all, which is
373
383
  exactly the shape eligibility reads as fresh work.
@@ -405,6 +415,34 @@ clean orphan has its label released so the next tick re-claims it. Orphaned
405
415
  attempts still count toward `maxAttemptsPerIssue`, so a crash loop escalates
406
416
  instead of redispatching forever.
407
417
 
418
+ ### What settles a green PR
419
+
420
+ `pushed-green` means the worker finished, pushed, and watched the checks go green.
421
+ What happens next is a human's decision, taken minutes to days later and never
422
+ announced to the daemon — so every tick asks the tracker about every green PR it
423
+ is still holding, and settles the ones that resolved:
424
+
425
+ | PR | Row becomes | Why |
426
+ | --- | --- | --- |
427
+ | merged | `merged` | The work landed. This is the state `merged` was reserved for. |
428
+ | closed without merging | `failed`, with the PR in `lastError` | A human read the work and said no. Leaving it `pushed-green` strands the issue forever behind a PR nobody will merge, and calling it `merged` is a lie about code that is not on the base branch. `failed` is true, and it releases the issue so a re-queue can be attempted again. |
429
+ | still open | unchanged | The normal steady state. Its issue must stay occupied, or a second attempt lands on the live PR. |
430
+ | could not be determined | unchanged | A flaky network, a revoked token, a deleted PR. An unknown answer never settles a row; the next tick asks again for free. |
431
+
432
+ The attempt counter is untouched either way, because both were real attempts. Run
433
+ rows are the only thing that changes: a merge normally closes the issue, and a
434
+ human who closed a PR is already looking at it, so what an issue's labels should
435
+ say next is the orchestrator's drain-duty judgement — the same division of labour
436
+ as a restart, above. One unreachable PR costs its own row and nothing else; the
437
+ rest of the sweep still settles.
438
+
439
+ Until this existed, nothing ever revisited a `pushed-green` row: the startup
440
+ reconciler only settles rows that held a process, and `merged` went unwritten. On
441
+ 2026-08-07 the reference fleet reported three active runs whose PRs were all
442
+ merged and whose issues were all closed, through two daemon restarts — and because
443
+ the active set *is* the busy set, those three issues were permanently unclaimable.
444
+ A status page that has stopped being evidence is worse than no status page.
445
+
408
446
  ### Branch names
409
447
 
410
448
  `<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
@@ -870,6 +908,11 @@ Any other path or method returns `404`. Note that `ok` reports only that the
870
908
  process is serving. It does not report that the fleet is doing work: read
871
909
  `paused` to tell those apart.
872
910
 
911
+ `activeRuns` counts occupied issues — live workers plus green PRs still awaiting a
912
+ merge — and each of those is settled by the tick once its PR resolves, so the
913
+ number goes back down on its own. A count that only ever grows is the bug this
914
+ used to have: read [what settles a green PR](#what-settles-a-green-pr).
915
+
873
916
  ## What a worker may and may not do
874
917
 
875
918
  Each worker gets one brief, one worktree, one branch, and no knowledge of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -66,14 +66,28 @@ by design — it is the guard that stops the next tick double-dispatching — bu
66
66
  nothing removes it, so a dead worker's issue sits "in progress" forever, occupying
67
67
  a slot that no longer exists. Compare the in-progress labels against the active
68
68
  runs `omp-conductor status` just showed you: **an in-progress issue with no
69
- matching active run is an orphan.**
69
+ matching active run is unattended — nobody is working under that label, and only
70
+ you can say why.**
70
71
 
71
- For an orphan, inspect what the dead worker left before touching the label. Read
72
+ For each, inspect what is actually there before touching the label. Read
72
73
  the issue itself (`gh issue view <n> --json labels` — the label-filtered *list*
73
74
  reads GitHub's eventually-consistent search index and lags label writes in both
74
75
  directions), then the worktree (`git status --porcelain`, `git log
75
- origin/main..HEAD`) and any PR. Four cases, checked in this order:
76
-
76
+ origin/main..HEAD`) and any PR. Not every one of these is a dead worker: the
77
+ conductor settles a green run's row once its PR resolves, so a resolved PR whose
78
+ issue nobody relabelled looks identical to an orphan from the outside. Six cases,
79
+ checked in this order:
80
+
81
+ - **A merged PR.** Nothing died — the work landed and the row already says
82
+ `merged`. Never release the label: the open-PR guard only objects to an *open*
83
+ PR, so a re-claim would re-implement a PR that is already on the base branch.
84
+ Close the issue if the merge satisfied it (a merge with no closing keyword
85
+ leaves it open), take the queue label off, and remove the in-progress label last.
86
+ - **A PR closed without merging.** A human read the work and said no; the row says
87
+ `failed`. Read the rejection before you touch anything — most of the time a
88
+ review comment is a spec change. Fold what it says into the issue, then release
89
+ the label so the next tick can attempt it again; the attempt counter still bounds
90
+ it. If the answer was "this should not be built", take it off the queue instead.
77
91
  - **An open PR that is green.** That worker finished; it just never got to report.
78
92
  This is the "already done" case above — handle it exactly the same way. Never
79
93
  release-and-re-claim it — a fresh worker would duplicate a finished run.
package/src/daemon.ts CHANGED
@@ -23,6 +23,7 @@ import { makeTracker } from "./tracker/github.ts";
23
23
  import type {
24
24
  Caps,
25
25
  Escalation,
26
+ PrState,
26
27
  ProjectConfig,
27
28
  ReadyIssue,
28
29
  RepoTarget,
@@ -629,6 +630,102 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
629
630
  }
630
631
  }
631
632
 
633
+ // ------------------------------------------------------------------- settlement
634
+
635
+ /** What a resolved PR turns its `pushed-green` row into. */
636
+ export interface Settlement {
637
+ state: "merged" | "failed";
638
+ /** The log line after `#<n> settled: `, and — for a rejection — the row's own
639
+ * `lastError`, because a `failed` row whose worker succeeded has to say so. */
640
+ reason: string;
641
+ }
642
+
643
+ /**
644
+ * What one `pushed-green` row becomes now its PR has an answer, or undefined to
645
+ * leave the row exactly as it is.
646
+ *
647
+ * A `pushed-green` row is the only one nothing ever revisited: the worker is
648
+ * finished, `reconcileOrphanedRuns` only settles rows that held a process, and
649
+ * `merged` went unwritten from day one. So they accumulated — three of them on
650
+ * the reference fleet on 2026-08-07, every PR merged and every issue closed,
651
+ * with `/healthz` still reporting three active runs and their issues
652
+ * permanently unclaimable, because the busy set *is* the active set (#18).
653
+ *
654
+ * The mapping, and why each answer is the only honest one:
655
+ *
656
+ * - `merged` — the work landed. That is what `merged` was reserved for.
657
+ * - `closed` — a human read the work and said no. Leaving it `pushed-green`
658
+ * strands the issue forever behind a PR nobody will ever merge, and calling it
659
+ * `merged` is simply a lie about work that does not exist on the base branch.
660
+ * `failed` is true — the attempt did not land — and it releases the busy guard,
661
+ * so an issue a human re-queues can be attempted again. The attempt counter is
662
+ * untouched either way: this row was a real attempt, and pretending otherwise
663
+ * would let a rejected issue cycle past `maxAttemptsPerIssue`.
664
+ * - `open`, and undefined — nothing changes. Undefined is "could not tell": a
665
+ * flaky network, a revoked token, a deleted PR. Settling on it would record a
666
+ * merge that never happened, and the next tick asks again for free. An
667
+ * ambiguous answer must never settle a row.
668
+ */
669
+ export function settlementFor(pr: PrState | undefined, prUrl: string): Settlement | undefined {
670
+ if (pr === "merged") return { state: "merged", reason: `${prUrl} merged` };
671
+ if (pr === "closed") return { state: "failed", reason: `${prUrl} closed without merging` };
672
+ return undefined;
673
+ }
674
+
675
+ /**
676
+ * Asks the tracker about every `pushed-green` PR and settles the ones that
677
+ * resolved.
678
+ *
679
+ * Effects at the call site, decision in {@link settlementFor} — the same split
680
+ * as `checkIntegrity`/`watchOrchestrator`. Exported like `admitCandidates`
681
+ * rather than kept private, because half of what has to hold is about the sweep
682
+ * and not the mapping: that a row without a PR costs no API call, and that one
683
+ * unreachable PR does not stop the others from settling.
684
+ *
685
+ * Tracker labels are deliberately not touched, exactly as
686
+ * {@link reconcileOrphanedRuns} does not touch them. A merge normally closes the
687
+ * issue, and a human who closed a PR is already looking at it; deciding what an
688
+ * issue's labels should say next is the orchestrator's drain duty, which reads
689
+ * these very rows through `omp-conductor status`.
690
+ */
691
+ export async function settlePushedGreen(
692
+ d: Pick<Deps, "project" | "tracker" | "store">,
693
+ ): Promise<void> {
694
+ const { project, tracker, store } = d;
695
+ // Filtered from the active set rather than asked for with a new query: active
696
+ // is live workers plus these, so the list is bounded by the worker cap plus
697
+ // the number of PRs awaiting a merge — a handful, by construction. A fleet
698
+ // where that is not a handful has a merge problem, not a dispatch one.
699
+ const pending = store.activeRuns(project.name).filter((r) => r.state === "pushed-green");
700
+
701
+ for (const run of pending) {
702
+ // Nothing to ask about. A green push means a PR, so this row should not
703
+ // exist; if one ever does, it must not buy a `gh` call every five minutes
704
+ // forever to be told nothing.
705
+ if (run.prUrl === undefined) continue;
706
+
707
+ let pr: PrState | undefined;
708
+ try {
709
+ pr = await tracker.prState(run.prUrl);
710
+ } catch (err) {
711
+ // Per row, like admission's held candidate. The GitHub adapter already
712
+ // answers undefined instead of throwing, so this catch is the port's
713
+ // contract rather than that adapter's behaviour — and a tracker that does
714
+ // throw must cost its own row, not the whole sweep.
715
+ log(`#${run.issue} not settled: PR state lookup failed (${errText(err)}) — retrying next tick`);
716
+ continue;
717
+ }
718
+
719
+ const settlement = settlementFor(pr, run.prUrl);
720
+ if (settlement === undefined) continue;
721
+
722
+ const patch: Partial<RunRecord> = { state: settlement.state, endedAt: Date.now() };
723
+ if (settlement.state === "failed") patch.lastError = settlement.reason;
724
+ store.updateRun(run.id, patch);
725
+ log(`#${run.issue} settled: ${settlement.reason}`);
726
+ }
727
+ }
728
+
632
729
  // -------------------------------------------------------------------- admission
633
730
 
634
731
  /** A candidate cleared for dispatch, with the attempt number it will run as. */
@@ -778,6 +875,13 @@ async function tick(d: Deps): Promise<void> {
778
875
  return;
779
876
  }
780
877
 
878
+ // Above admission on purpose: a row settled this tick frees its issue for
879
+ // this same tick, so a merge and a re-queue no longer cost five minutes each.
880
+ // Above the spend cap too, which returns early — settling is bookkeeping about
881
+ // work already paid for, and a fleet that halts itself is exactly when an
882
+ // operator reads `status` and needs it to be true.
883
+ await settlePushedGreen(d);
884
+
781
885
  // route() filters the queue through isEligible() itself, so anything already
782
886
  // carrying a state label is gone before it gets here.
783
887
  const { routed, unroutable } = route(await d.tracker.listReady(), project);
@@ -985,7 +1089,9 @@ export function armConductor(): void {
985
1089
  *
986
1090
  * `pushed-green` rows are deliberately left alone: they hold no process — they
987
1091
  * are finished work waiting on a human merge, and they must keep occupying the
988
- * issue so a second attempt cannot land on a live PR.
1092
+ * issue so a second attempt cannot land on a live PR. What eventually settles
1093
+ * them is {@link settlePushedGreen}, on the tick, by asking the tracker what
1094
+ * became of the PR — the one question a restart cannot answer by inference.
989
1095
  */
990
1096
  export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
991
1097
  // Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
package/src/escalate.ts CHANGED
@@ -233,22 +233,32 @@ async function sendTelegram(token: string, chatId: string, text: string): Promis
233
233
  throw new Error(`telegram sendMessage failed: ${redact(reason, token)}`);
234
234
  }
235
235
 
236
- const payload = redact(await res.text().catch(() => ""), token).slice(0, 400);
236
+ // The raw body is what gets parsed; `diagnostic` is only ever for humans.
237
+ // These were one variable once, and the bug that produced was expensive: the
238
+ // 400-char cap meant for a log line was applied first, so `JSON.parse` was
239
+ // handed a truncated object and threw. Telegram echoes the whole message back
240
+ // inside `result.text`, so every page long enough to matter overflowed and was
241
+ // reported as rejected *after being delivered* — with the dedup marker only
242
+ // written on success, that also re-sent the same page every tick. Redaction
243
+ // stays out of the parse for the same reason: it rewrites the very bytes the
244
+ // decision is read from.
245
+ const raw = await res.text().catch(() => "");
246
+ const diagnostic = redact(raw, token).slice(0, 400);
237
247
  if (!res.ok) {
238
- throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${payload}`);
248
+ throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${diagnostic}`);
239
249
  }
240
250
 
241
251
  // Telegram answers 200 with `{"ok":false}` for plenty of real failures
242
252
  // (kicked from the chat, bad chat_id), so the status alone proves nothing.
243
253
  let ok = false;
244
254
  try {
245
- const parsed: unknown = JSON.parse(payload);
255
+ const parsed: unknown = JSON.parse(raw);
246
256
  ok = typeof parsed === "object" && parsed !== null && "ok" in parsed && parsed.ok === true;
247
257
  } catch {
248
258
  ok = false;
249
259
  }
250
260
  if (!ok) {
251
- throw new Error(`telegram sendMessage rejected: ${payload}`);
261
+ throw new Error(`telegram sendMessage rejected: ${diagnostic}`);
252
262
  }
253
263
  }
254
264
 
@@ -10,10 +10,10 @@
10
10
  * ~200-400ms) and failure classification by matching human-readable stderr
11
11
  * instead of reading a status code. Upgrade path when either bites: replace the
12
12
  * body of `gh()` with `fetch("https://api.github.com/...")` using a token from
13
- * `gh auth token`; the seven Tracker methods above it stay untouched.
13
+ * `gh auth token`; the eight Tracker methods above it stay untouched.
14
14
  */
15
15
 
16
- import type { ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
16
+ import type { PrState, ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
17
17
 
18
18
  /** The subset of `gh issue list --json` output this adapter reads. Fields the
19
19
  * API can return as null are typed as such so the mapping has to handle it. */
@@ -154,6 +154,41 @@ export function firstOpenCloser(raw: string): string | undefined {
154
154
  return nodes.find((n) => n !== null && n.state === "OPEN")?.url;
155
155
  }
156
156
 
157
+ /**
158
+ * A pull request URL this adapter is willing to hand to `gh`.
159
+ *
160
+ * Load-bearing, not defensive: `gh pr view` also accepts a bare number or a
161
+ * *branch name*, and resolves those against whatever repository the current
162
+ * directory belongs to. So a `prUrl` that is not a URL would not fail — it would
163
+ * be answered, confidently, about some other repository's pull request. A
164
+ * settle sweep that acted on that answer would mark the wrong run merged.
165
+ */
166
+ const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+/;
167
+
168
+ /**
169
+ * GitHub's PR state spelling mapped onto {@link PrState}.
170
+ *
171
+ * Split from the call so the mapping is pinned without a network round trip,
172
+ * and unrecognised text answers undefined rather than being coerced: this
173
+ * function's caller settles a run row on the answer, and every wrong answer
174
+ * here rewrites history for work that has no other record. `MERGED`, `CLOSED`
175
+ * and `OPEN` are the only three `gh pr view --json state` emits (verified on
176
+ * gh 2.97.0); anything else means the CLI changed under us, which is a reason
177
+ * to leave the row alone and let a human look.
178
+ */
179
+ export function prStateFrom(raw: string): PrState | undefined {
180
+ switch (raw.trim()) {
181
+ case "MERGED":
182
+ return "merged";
183
+ case "CLOSED":
184
+ return "closed";
185
+ case "OPEN":
186
+ return "open";
187
+ default:
188
+ return undefined;
189
+ }
190
+ }
191
+
157
192
  export function makeTracker(p: ProjectConfig): Tracker {
158
193
  const repo = p.tracker.repo;
159
194
 
@@ -248,5 +283,24 @@ export function makeTracker(p: ProjectConfig): Tracker {
248
283
 
249
284
  return firstOpenCloser(raw);
250
285
  },
286
+
287
+ async prState(url: string): Promise<PrState | undefined> {
288
+ // No `--repo`: a full URL is self-locating, and verified so on gh 2.97.0
289
+ // from a directory that is not a git repository at all — which is exactly
290
+ // where the daemon runs (its state directory), while the PR lives in one
291
+ // of the routed repos. Deriving `--repo` from the URL would only re-state
292
+ // what the URL already says.
293
+ if (!PR_URL.test(url)) return undefined;
294
+ try {
295
+ return prStateFrom(await gh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
296
+ } catch {
297
+ // Never throws, per the port's contract. A deleted PR, a revoked token
298
+ // and a flaky network all mean "could not tell", and the caller's whole
299
+ // job is to leave the row alone on that — so classifying them here would
300
+ // buy nothing but a way to get the classification wrong. The next tick
301
+ // asks again for free.
302
+ return undefined;
303
+ }
304
+ },
251
305
  };
252
306
  }
package/src/types.ts CHANGED
@@ -180,6 +180,15 @@ export interface ReadyIssue {
180
180
  updatedAt: string;
181
181
  }
182
182
 
183
+ /**
184
+ * How a pull request ended, in tracker-agnostic terms. Lowercase because the
185
+ * loop's vocabulary is lowercase; mapping GitHub's `MERGED`/`CLOSED`/`OPEN` onto
186
+ * it is the adapter's job.
187
+ *
188
+ * `closed` is closed *without* merging — a human looked at the work and said no.
189
+ */
190
+ export type PrState = "merged" | "closed" | "open";
191
+
183
192
  /**
184
193
  * Deliberately narrow so a Gitea or local-file tracker can drop in later.
185
194
  * Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
@@ -205,6 +214,19 @@ export interface Tracker {
205
214
  * only party that remembers across all of those.
206
215
  */
207
216
  openCloserFor(issue: number): Promise<string | undefined>;
217
+ /**
218
+ * The state of one specific pull request, or undefined when this adapter
219
+ * could not tell — a network failure, a deleted PR, a URL it cannot parse.
220
+ * Undefined never means "no".
221
+ *
222
+ * Deliberately separate from {@link Tracker.openCloserFor}, which answers a
223
+ * different question: that one asks "should this candidate be admitted", and
224
+ * answers undefined for merged, closed-unmerged, no PR at all and a deleted
225
+ * PR alike, because admission treats all four the same. This one asks "did
226
+ * *this* run's PR land", where those answers are opposites. Collapsing them
227
+ * is how a PR a human rejected gets recorded as merged.
228
+ */
229
+ prState(url: string): Promise<PrState | undefined>;
208
230
  }
209
231
 
210
232
  /**
@@ -216,6 +238,8 @@ export type RunState =
216
238
  | "claimed"
217
239
  | "running"
218
240
  | "pushed-green"
241
+ /** Its PR landed. Written by the tick's settle sweep, never by a worker: only
242
+ * the tracker knows, and it knows minutes to days after the run ended. */
219
243
  | "merged"
220
244
  | "blocked"
221
245
  | "failed"