omp-conductor 0.3.1 → 0.3.3

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 `:`
@@ -769,23 +807,30 @@ prompt was never consumed, and the extension:
769
807
  Both escapes deliberately leave the session, because a loop that cannot drain
770
808
  its queue cannot report on itself — that is the whole failure.
771
809
 
772
- **Two things read it.** A marker nobody consumes is an artifact, not an alert:
773
-
774
- - **The daemon**, on its own five-minute tick, and *before* its pause check. The
775
- orchestrator is a different process and can be wedged while the fleet is
776
- deliberately paused precisely the state the dogfood fleet was in when this
777
- happened. One tier-2 page per stall, dated, re-armed when the marker clears,
778
- and latched only once the page is confirmed delivered, so an escalation
779
- channel that fails on the one tick that noticed does not buy permanent
780
- silence. It restarts nothing: a wedge lands mid-turn, and this process cannot
781
- tell a half-applied edit from an idle loop.
782
- - **herdr-conductor's recovery**, which treats marker-present as *not live*. Its
783
- liveness test agent listed AND a non-shell foreground process passes
784
- straight through a wedge, so without this a recovery run certifies a stuck
785
- session as healthy and returns "nothing to do".
786
-
787
- The daemon is what covers a wedge beginning *between* herdr's lifecycle events,
788
- since a session that stays alive and stops working emits none of them.
810
+ **The daemon reads it.** A marker nobody consumes is an artifact, not an alert,
811
+ so the dispatch daemon checks it on its own five-minute tick — and *before* its
812
+ pause check. The orchestrator is a different process and can be wedged while
813
+ the fleet is deliberately paused, which is precisely the state the dogfood
814
+ fleet was in when this happened. One tier-2 page per stall, keyed on the
815
+ marker's own timestamp so a second wedge the same day is not swallowed as a
816
+ repeat, re-armed when the marker clears, and latched only once the page is
817
+ confirmed delivered — an escalation channel that fails on the one tick that
818
+ noticed must not buy permanent silence.
819
+
820
+ It restarts nothing. A wedge lands mid-turn, and no other process can tell a
821
+ half-applied edit from an idle loop; the operator attaches, looks, and decides.
822
+
823
+ **herdr-conductor deliberately does not read it**, though its liveness test
824
+ (agent listed AND a non-shell foreground process) passes straight through a
825
+ wedge. That plugin only runs on `startup`, `pane.exited` and
826
+ `pane.agent_detected`, and a session that stays alive and stops working emits
827
+ none of them — so the check could never fire during the wedge itself. What it
828
+ *would* catch is the recovery afterwards: the marker survives a restart until
829
+ the new session consumes a tick, so every operator SIGTERM-and-resume would
830
+ page about the healthy session they just fixed. Telling those apart needs the
831
+ process start time against the marker's, and herdr's `pane process-info`
832
+ reports pids, not start times. The daemon gives up at most one tick of
833
+ coverage and never cries wolf.
789
834
 
790
835
  The first tick that actually sends clears the counter and deletes the marker,
791
836
  and it deletes one it did not write: recovery normally arrives as a fresh
@@ -863,6 +908,11 @@ Any other path or method returns `404`. Note that `ok` reports only that the
863
908
  process is serving. It does not report that the fleet is doing work: read
864
909
  `paused` to tell those apart.
865
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
+
866
916
  ## What a worker may and may not do
867
917
 
868
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.1",
3
+ "version": "0.3.3",
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,
@@ -151,10 +152,13 @@ async function watchOrchestrator(d: Deps): Promise<void> {
151
152
  tier: 2,
152
153
  project: d.project.name,
153
154
  issue: NO_ISSUE,
154
- // Dated like the other tier-2 summaries: the dedup key is the summary, and
155
- // a second wedge next month must not read as a repeat of this one.
155
+ // Keyed on the marker's own timestamp, not the date. The dedup ledger keys
156
+ // on this summary, and two wedges in one day is not a hypothetical the
157
+ // failure mode is a session that gets stuck, gets restarted, and gets stuck
158
+ // again on the same cause an hour later. A day-keyed summary would report
159
+ // the first and silently swallow every one after it.
156
160
  summary:
157
- `Orchestrator session wedged on ${new Date().toISOString().slice(0, 10)} — ` +
161
+ `Orchestrator session wedged (${verdict.since ?? `marker at ${marker}`}) — ` +
158
162
  `it has stopped reading its queue (${d.project.name})`,
159
163
  detail: [
160
164
  verdict.since ?? "Marker present with no readable timestamp.",
@@ -626,6 +630,102 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
626
630
  }
627
631
  }
628
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
+
629
729
  // -------------------------------------------------------------------- admission
630
730
 
631
731
  /** A candidate cleared for dispatch, with the attempt number it will run as. */
@@ -731,6 +831,15 @@ async function tick(d: Deps): Promise<void> {
731
831
  // attributable. A legitimate deploy never trips it, because installing a new
732
832
  // build and restarting the unit re-records the baseline from the new files;
733
833
  // only an edit *underneath* a live daemon diverges from it.
834
+ //
835
+ // Below the pause gate on purpose, unlike the stall watch above. The property
836
+ // being defended is that no work is dispatched under a package the operator
837
+ // did not install — and a paused fleet dispatches nothing, so nothing needs
838
+ // attributing yet. Tampering during a pause is not missed, only deferred: the
839
+ // baseline is boot's, so the first tick after `resume` compares against it and
840
+ // pauses again before claiming anything. Checking above the gate instead would
841
+ // page on every legitimate build an operator deploys into a parked fleet,
842
+ // which is exactly when they deploy them.
734
843
  const integrity = checkIntegrity(d.integrity, packageManifest());
735
844
  if (integrity.pause) {
736
845
  const shown = integrity.diff.slice(0, INTEGRITY_SAMPLE);
@@ -766,6 +875,13 @@ async function tick(d: Deps): Promise<void> {
766
875
  return;
767
876
  }
768
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
+
769
885
  // route() filters the queue through isEligible() itself, so anything already
770
886
  // carrying a state label is gone before it gets here.
771
887
  const { routed, unroutable } = route(await d.tracker.listReady(), project);
@@ -973,7 +1089,9 @@ export function armConductor(): void {
973
1089
  *
974
1090
  * `pushed-green` rows are deliberately left alone: they hold no process — they
975
1091
  * are finished work waiting on a human merge, and they must keep occupying the
976
- * 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.
977
1095
  */
978
1096
  export function reconcileOrphanedRuns(store: Store, project: string): RunRecord[] {
979
1097
  // Live runs only: `pushed-green` holds no process, so it cannot be orphaned by
@@ -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"