@agent-relay/factory 0.1.76 → 0.1.79

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.
@@ -167,6 +167,31 @@ const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
167
167
  * multi-hour run holds the slot honestly), so what is bounded is the *rate*.
168
168
  */
169
169
  const DISPATCH_LIFECYCLE_RETRY_MAX_MS = 30_000;
170
+ /**
171
+ * How many times a work unit's RELEASE may fail before it is dead-lettered.
172
+ *
173
+ * #303 bounded the rate of the capacity-wait re-arm and deliberately left its
174
+ * count unbounded, because waiting for capacity is legitimate: the slot will
175
+ * free eventually and abandoning the wait would drop real work. Release is the
176
+ * opposite shape. It is the last step of a work unit that is already finished
177
+ * — the issue is closed, the writeback is acknowledged, the batch slot is
178
+ * gone — so a release that has failed ten times is not waiting for anything.
179
+ * It is failing, and re-arming it at 1 Hz forever buys nothing.
180
+ *
181
+ * What that costs when it happens: `#finishDurableRelease` does not throw on a
182
+ * failed release, it returns `false` and re-arms itself, so the loop runs
183
+ * through the *success* path of every `.catch()` that might otherwise have
184
+ * bounded it. Each pass calls `#terminationRoots` once per agent, and
185
+ * `#writeInFlightRegistry` calls it once per agent again — each one a process
186
+ * table scan — plus a durable read and write. Three agents is order ten scans
187
+ * and several state operations per second, indefinitely, which is the same
188
+ * serialized-store spin #303 measured at 1477 GETs in 111 s.
189
+ *
190
+ * Ten attempts at the 1 Hz floor is ~10 s of genuine retry, which covers the
191
+ * transient failures release retries exist for (a brief control-plane blip, a
192
+ * lease handover) without covering a permanent one.
193
+ */
194
+ const DISPATCH_LIFECYCLE_MAX_RELEASE_ATTEMPTS = 10;
170
195
  /** Rate limit for the capacity-wait warning once the backoff has capped. */
171
196
  const DISPATCH_LIFECYCLE_CAPACITY_WAIT_LOG_MS = 60_000;
172
197
  const DISPATCH_WRITEBACK_MAX_ATTEMPTS = 3;
@@ -424,6 +449,7 @@ export class FactoryLoop {
424
449
  #babysitterWakeUnreachableEscalateMs;
425
450
  #babysitterWakeUnreachableRetryMs;
426
451
  #startupAgentExitDrainTimeoutMs;
452
+ #dispatchLifecycleRetryMs;
427
453
  #state;
428
454
  #workspaceId;
429
455
  #relayflows;
@@ -480,6 +506,10 @@ export class FactoryLoop {
480
506
  // owner/repo#number identity. GitHub-native records outrank Linear mirrors.
481
507
  #dependencyIssues = new Map();
482
508
  #terminalDependencyIdentities = new Set();
509
+ /** Sweep-scoped memo of `#dependencyIsTerminalOrMerged`'s mount walk, including
510
+ * the negative answer that `#terminalDependencyIdentities` cannot hold. Cleared
511
+ * with it at the top of every sweep. */
512
+ #dependencyPrProbes = new Map();
483
513
  #dependencyParkNotices = new Map();
484
514
  #dependencyGithubPathsByIdentity;
485
515
  #dependencyLinearTreeLoaded = false;
@@ -496,6 +526,16 @@ export class FactoryLoop {
496
526
  #dispatchTerminalWaiters = new Map();
497
527
  #dispatchLifecycleRetryTimers = new Map();
498
528
  #dispatchLifecycleDrives = new Set();
529
+ /**
530
+ * Failed release attempts per work unit, and the units that ran out.
531
+ *
532
+ * Keyed by `dispatchLifecycleKey`, so it follows the work unit rather than
533
+ * any one agent, surface or dispatcher — the same identity rule the AR-448
534
+ * duplicate established for claims. Counted only for release re-arms; a
535
+ * capacity or ownership wait is not a failure and must not spend the budget.
536
+ */
537
+ #dispatchLifecycleReleaseAttempts = new Map();
538
+ #dispatchLifecycleReleaseAbandoned = new Set();
499
539
  #abandonedDispatchReasons = new Map();
500
540
  /**
501
541
  * Live batch-capacity waits, keyed by issue (#303).
@@ -833,7 +873,8 @@ export class FactoryLoop {
833
873
  this.#customProbePrResolver = Boolean(ports.probePrResolver);
834
874
  this.#hasProbePrGhRunner = Boolean(ports.probePrGhRunner);
835
875
  this.#probePrGhRunner = ports.probePrGhRunner ?? failClosedGhRunner;
836
- this.#probePrResolver = ports.probePrResolver ?? ((issue) => this.#resolveIssuePr(issue));
876
+ this.#probePrResolver = ports.probePrResolver ??
877
+ ((issue) => this.#resolveIssuePr(issue, { repo: this.#probeRepoForIssue(issue) }));
837
878
  this.#logger = normalizeLogger(ports.logger ?? console);
838
879
  this.#clock = ports.clock ?? realClock;
839
880
  this.#fleetControlPlane = new FleetControlPlaneCircuit({
@@ -854,6 +895,7 @@ export class FactoryLoop {
854
895
  this.#babysitterWakeUnreachableEscalateMs = ports.babysitterWakeUnreachableEscalateMs ?? BABYSITTER_WAKE_UNREACHABLE_ESCALATE_MS;
855
896
  this.#babysitterWakeUnreachableRetryMs = ports.babysitterWakeUnreachableRetryMs ?? BABYSITTER_WAKE_UNREACHABLE_RETRY_MS;
856
897
  this.#startupAgentExitDrainTimeoutMs = ports.startupAgentExitDrainTimeoutMs ?? STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS;
898
+ this.#dispatchLifecycleRetryMs = ports.dispatchLifecycleRetryMs ?? DISPATCH_LIFECYCLE_RETRY_MS;
857
899
  this.#workspaceId = config.workspaceId ?? 'default';
858
900
  this.#relayflows = ports.relayflows;
859
901
  this.#worktrees = ports.worktrees;
@@ -2355,7 +2397,11 @@ export class FactoryLoop {
2355
2397
  }
2356
2398
  if (pr.draft) {
2357
2399
  this.#increment('completionSweepDraftPr');
2358
- this.#probePrGhBackoffUntilMs.set(issueStateKey(issueRef(issue)), this.#clock.now() + PROBE_PR_GH_BACKOFF_MS);
2400
+ // Must match the key `#completionPrForIssue` resolves under —
2401
+ // same options, same repo scope — or this backoff is written
2402
+ // under a name nothing reads and the draft PR is re-fetched from
2403
+ // gh on every pass.
2404
+ this.#probePrGhBackoffUntilMs.set(this.#probePrCacheKey(issue, { repo: this.#probeRepoForIssue(issue) }), this.#clock.now() + PROBE_PR_GH_BACKOFF_MS);
2359
2405
  return undefined;
2360
2406
  }
2361
2407
  if (record.decision.implementers.length > 1 && !await this.#allImplementersHaveCompletionPr(record)) {
@@ -2596,6 +2642,7 @@ export class FactoryLoop {
2596
2642
  }
2597
2643
  return this.#resolveIssuePr(issue, {
2598
2644
  titleMarker: FACTORY_E2E_MARKER,
2645
+ repo: this.#probeRepoForIssue(issue),
2599
2646
  });
2600
2647
  }
2601
2648
  async #openPrForIssue(issue) {
@@ -2605,18 +2652,82 @@ export class FactoryLoop {
2605
2652
  return this.#resolveIssuePr(issue, {
2606
2653
  titleMarker: FACTORY_E2E_MARKER,
2607
2654
  openOnly: true,
2655
+ repo: this.#probeRepoForIssue(issue),
2608
2656
  });
2609
2657
  }
2610
- async #resolveIssuePr(issue, opts = {}) {
2658
+ /**
2659
+ * Which repository a probe walk may scope itself to.
2660
+ *
2661
+ * `resolveIssuePrFromMount` has always accepted `opts.repo`, but
2662
+ * `#resolveIssuePr` had no way to express one, so every probe crawled the PR
2663
+ * tree of EVERY configured repository — 21 of them in the live workspace — to
2664
+ * find a PR that can only ever live in one. This is the same routing answer
2665
+ * `#dependencyIsTerminalOrMerged` already uses for its own probe, so the two
2666
+ * probes now scope identically.
2667
+ *
2668
+ * `undefined` means the routing is genuinely ambiguous (a decision spanning
2669
+ * several routes, an issue whose labels match no single repo). The walk then
2670
+ * stays unscoped, exactly as before: narrowing on a guess would silently fail
2671
+ * to find a PR that is really there, which is worse than a slow walk.
2672
+ *
2673
+ * `allowDefault: false` is the whole point of this wrapper. Routing precedence
2674
+ * is byLabel, byProject, keywordRules, default, and `dependencyRepoForIssue`
2675
+ * can see neither the triage decision nor `keywordRules`. Left to its own
2676
+ * fallback it answers `repos.default` for every keyword-routed issue, while
2677
+ * dispatch opened that PR in the keyword-selected repository — so the probe
2678
+ * would walk one repository, confidently, and find nothing. Reporting "no PR"
2679
+ * for an issue that has one is a correctness bug; the unscoped walk it falls
2680
+ * back to instead is merely slow, and the dedupe and cache in this same change
2681
+ * already blunt that cost.
2682
+ */
2683
+ #probeRepoForIssue(issue) {
2684
+ return dependencyRepoForIssue(issue, undefined, this.#config, { allowDefault: false });
2685
+ }
2686
+ /**
2687
+ * The key for BOTH `#probePrResolvedCache` and `#probePrGhBackoffUntilMs`.
2688
+ *
2689
+ * Shared rather than inlined because those two maps are written from more than
2690
+ * one place: `#resolveIssuePr` writes both, and the completion sweep writes a
2691
+ * draft-PR backoff directly. Those writes must agree on the key or the backoff
2692
+ * is set under a name nothing reads, which silently costs a gh call per pass.
2693
+ *
2694
+ * `repo` is part of the key because it narrows which pull requests the
2695
+ * resolution can even see — an issue whose route changes must not be served
2696
+ * the previous repository's PR, or held off gh by the previous repository's
2697
+ * negative backoff, because the completion path probes and CLOSES what this
2698
+ * returns. `*` marks the unscoped walk, a genuinely different resolution that
2699
+ * must not share an entry with any single-repo one.
2700
+ *
2701
+ * Every dimension is a trailing `:`-prefixed segment so the completion
2702
+ * invalidation — which clears `stateKey` plus everything starting
2703
+ * `${stateKey}:` — keeps clearing the whole family as the key grows.
2704
+ */
2705
+ #probePrCacheKey(issue, opts = {}) {
2611
2706
  const issueKey = issueStateKey(issueRef(issue));
2612
- const key = `${opts.openOnly ? `${issueKey}:open` : issueKey}${opts.allowLegacyGithubBranch ? ':legacy' : ''}`;
2707
+ const repoScope = opts.repo ? opts.repo.trim().toLowerCase() : '*';
2708
+ return `${opts.openOnly ? `${issueKey}:open` : issueKey}${opts.allowLegacyGithubBranch ? ':legacy' : ''}:repo=${repoScope}`;
2709
+ }
2710
+ async #resolveIssuePr(issue, opts = {}) {
2711
+ const key = this.#probePrCacheKey(issue, opts);
2613
2712
  const now = this.#clock.now();
2614
2713
  const cached = this.#probePrResolvedCache.get(key);
2615
2714
  if (cached && cached.expiresAtMs > now) {
2616
2715
  return cached.pr;
2617
2716
  }
2618
- const mountPr = await resolveIssuePrFromMount(this.#mount, this.#config, issue, opts, (prefix) => this.#listRelayfileTree(prefix, 'PR probe resolution'));
2717
+ const mountPr = await resolveIssuePrFromMount(this.#mount, this.#config, issue, opts, (prefix) => this.#listRelayfileTree(prefix, 'PR probe resolution'), this.#probeMountWalkProgress('[factory] PR probe mount read progress', issue));
2619
2718
  if (mountPr) {
2719
+ // The mount branch runs first and is the common hit, and until now it was
2720
+ // the one branch that never wrote the cache it reads at the top of this
2721
+ // method. The cache had a reader and no writer on the hot path, so the
2722
+ // full tree walk repeated for every caller, on every sweep, forever.
2723
+ //
2724
+ // Cached on the same terms as the gh branch below — same key, same TTL,
2725
+ // same draft exclusion — because the reason to keep a draft uncached is a
2726
+ // property of the PR (its state is about to flip and the caller wants to
2727
+ // see that promptly), not of which resolver observed it.
2728
+ if (!mountPr.draft) {
2729
+ this.#probePrResolvedCache.set(key, { pr: mountPr, expiresAtMs: now + PROBE_PR_GH_BACKOFF_MS });
2730
+ }
2620
2731
  return mountPr;
2621
2732
  }
2622
2733
  const backoffUntil = this.#probePrGhBackoffUntilMs.get(key) ?? 0;
@@ -3095,6 +3206,7 @@ export class FactoryLoop {
3095
3206
  // current provider snapshots (or merged PR metadata) so a reopened issue
3096
3207
  // cannot remain permanently resolved after an earlier close event.
3097
3208
  this.#terminalDependencyIdentities.clear();
3209
+ this.#dependencyPrProbes.clear();
3098
3210
  this.#dependencyGithubPathsByIdentity = undefined;
3099
3211
  this.#dependencyLinearTreeLoaded = false;
3100
3212
  const issueSource = await this.#issueSource();
@@ -4183,6 +4295,7 @@ export class FactoryLoop {
4183
4295
  openOnly: true,
4184
4296
  failOnLookupError: true,
4185
4297
  allowLegacyGithubBranch: true,
4298
+ repo: this.#probeRepoForIssue(issue),
4186
4299
  });
4187
4300
  }
4188
4301
  async #adoptOrphanedGithubPullRequest(issue, pr, legacyUnownedAgents = []) {
@@ -4582,6 +4695,62 @@ export class FactoryLoop {
4582
4695
  #elapsedSince(startedAtMs) {
4583
4696
  return Math.max(0, this.#clock.now() - startedAtMs);
4584
4697
  }
4698
+ /**
4699
+ * Progress reporting for the mount PR walk.
4700
+ *
4701
+ * `listTree` inside that walk is wrapped by `#listRelayfileTree` — named,
4702
+ * timed and logged. The `readFile` per candidate path was not: it ran inside a
4703
+ * bare try/catch that swallows failures into `undefined`, with no logger, no
4704
+ * counter and no progress line. A walk of several thousand paths at ~175ms
4705
+ * each therefore emitted its last log line at the final `listTree` and then
4706
+ * went silent for twelve minutes, which is indistinguishable from a hung
4707
+ * process. Three investigation layers could not tell those apart from the logs
4708
+ * alone, so the missing instrumentation is a defect in its own right and not a
4709
+ * nice-to-have.
4710
+ *
4711
+ * Same cadence helper the ready-issue read loop uses, so a long PR probe reads
4712
+ * like a long issue read; the counter carries the same signal into /evidence.
4713
+ */
4714
+ #probeMountWalkProgress(message, issue) {
4715
+ const startedAtMs = this.#clock.now();
4716
+ let lastLoggedAtMs = startedAtMs;
4717
+ return {
4718
+ onRead: (progress) => {
4719
+ this.#increment('probePrMountReads');
4720
+ lastLoggedAtMs = this.#logTimedProgress(message, startedAtMs, lastLoggedAtMs, {
4721
+ issue: issue.key,
4722
+ read: progress.read,
4723
+ total: progress.total,
4724
+ path: progress.path,
4725
+ });
4726
+ },
4727
+ // One line per repository saying why the tree walk was necessary. A silent
4728
+ // fallback is indistinguishable from a fast path that is working, and
4729
+ // that is precisely how twelve minutes of sequential reads hid for so
4730
+ // long. Now that the index CAN answer the question, these reasons are the
4731
+ // only way to tell "the index answered" from "the index exists and was
4732
+ // useless" — and on a mount written before
4733
+ // `@relayfile/adapter-github@0.5.7`, `index-without-head-ref` is the
4734
+ // expected reading until a re-ingest converges the rows.
4735
+ onIndexFallback: (repo, reason) => {
4736
+ this.#increment('probePrIndexFallbacks');
4737
+ this.#increment(PROBE_PR_INDEX_FALLBACK_COUNTERS[reason]);
4738
+ this.#logger.debug?.('[factory] PR probe fell back to a full mount walk', {
4739
+ issue: issue.key,
4740
+ repo,
4741
+ reason,
4742
+ });
4743
+ },
4744
+ onIndexHit: (repo, prNumber) => {
4745
+ this.#increment('probePrIndexHits');
4746
+ this.#logger.debug?.('[factory] PR probe resolved from the pull index', {
4747
+ issue: issue.key,
4748
+ repo,
4749
+ prNumber,
4750
+ });
4751
+ },
4752
+ };
4753
+ }
4585
4754
  #logTimedProgress(message, startedAtMs, lastLoggedAtMs, metadata) {
4586
4755
  const now = this.#clock.now();
4587
4756
  if (now - lastLoggedAtMs < REMOTE_OPERATION_PROGRESS_INTERVAL_MS) {
@@ -5516,8 +5685,23 @@ export class FactoryLoop {
5516
5685
  consecutiveFailures,
5517
5686
  failureThreshold: READINESS_RECONCILE_FAILURE_THRESHOLD,
5518
5687
  intervalMs: this.#readinessReconcileIntervalMs,
5688
+ // The two bounds that can actually preempt this pass, published beside
5689
+ // the cadence that cannot. Omitting them made the stanza unreadable in
5690
+ // the one situation it exists for: `intervalMs` next to a climbing
5691
+ // `inFlightMs` looks exactly like an unbounded hang, and has twice been
5692
+ // reported as one. `timeoutMs` ends the wait, `sweepBudgetMs` unwinds
5693
+ // the sweep and hands the lease back — so the second is the one that
5694
+ // answers "when does this recover".
5695
+ timeoutMs: this.#readinessReconcileTimeoutMs,
5696
+ sweepBudgetMs: this.#discoverySweepBudgetMs,
5519
5697
  ...(Number.isFinite(inFlightSinceMs) ? { inFlightSinceMs } : {}),
5520
5698
  ...(inFlightMs !== undefined ? { inFlightMs } : {}),
5699
+ // Derived here rather than left to each reader. The public projection
5700
+ // already computed it (#295/#300); the heartbeat stanza did not, so the
5701
+ // surface an operator actually opens first was the one missing it.
5702
+ ...(inFlightMs !== undefined && this.#readinessReconcileIntervalMs > 0
5703
+ ? { missedPasses: Math.floor(inFlightMs / this.#readinessReconcileIntervalMs) }
5704
+ : {}),
5521
5705
  ...(this.#readinessReconcileLastDurationMs !== undefined
5522
5706
  ? { lastDurationMs: this.#readinessReconcileLastDurationMs }
5523
5707
  : {}),
@@ -6791,19 +6975,113 @@ export class FactoryLoop {
6791
6975
  }
6792
6976
  this.#increment('dispatchCapacityBackoffResets');
6793
6977
  }
6794
- #scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
6978
+ /**
6979
+ * Charge one failed release against a work unit's budget.
6980
+ *
6981
+ * Returns `false` once the budget is spent, and the caller must NOT re-arm.
6982
+ * Failing closed here is deliberate and is the opposite of a dispatch gate:
6983
+ * an unrecordable dispatch claim must abort the dispatch, but an unbounded
6984
+ * release must abort the RETRY — the work is already done either way, and
6985
+ * the only thing still running is the spin.
6986
+ */
6987
+ #chargeReleaseAttempt(record, key, context) {
6988
+ if (this.#dispatchLifecycleReleaseAbandoned.has(key))
6989
+ return false;
6990
+ const attempts = (this.#dispatchLifecycleReleaseAttempts.get(key) ?? 0) + 1;
6991
+ this.#dispatchLifecycleReleaseAttempts.set(key, attempts);
6992
+ if (attempts <= DISPATCH_LIFECYCLE_MAX_RELEASE_ATTEMPTS)
6993
+ return true;
6994
+ this.#dispatchLifecycleReleaseAbandoned.add(key);
6995
+ this.#dispatchLifecycleReleaseAttempts.delete(key);
6996
+ this.#increment('dispatchLifecycleReleaseAbandoned');
6997
+ const durableLifecycleRetained = this.#usesDurableDispatchLifecycle();
6998
+ // `error`, not `warn`. Every previous layer of this failure was invisible
6999
+ // until somebody read stderr by hand; a work unit whose cleanup this
7000
+ // process has permanently given up on is exactly the event that must not
7001
+ // be inferable only from the absence of further log lines.
7002
+ this.#logger.error?.('[factory] release retries exhausted; abandoning cleanup for this work unit', {
7003
+ issue: record.issue.key,
7004
+ attempts: attempts - 1,
7005
+ maxAttempts: DISPATCH_LIFECYCLE_MAX_RELEASE_ATTEMPTS,
7006
+ context,
7007
+ // The durable lifecycle is retained on purpose: a takeover or a restart
7008
+ // re-drives it from the persisted phase. This bounds THIS process's
7009
+ // spin, it does not declare the work unit clean.
7010
+ durableLifecycleRetained,
7011
+ });
7012
+ const drive = this.#releaseDeadLetteredSlot(record, key)
7013
+ .catch((error) => {
7014
+ this.#logger.warn?.('[factory] dead-lettered release could not free its batch slot', {
7015
+ issue: record.issue.key,
7016
+ error: describeError(error).errorMessage,
7017
+ });
7018
+ })
7019
+ .finally(() => this.#dispatchLifecycleDrives.delete(drive));
7020
+ this.#dispatchLifecycleDrives.add(drive);
7021
+ return false;
7022
+ }
7023
+ /**
7024
+ * Hand back the batch slot of a work unit whose release was dead-lettered.
7025
+ *
7026
+ * Without this the bound trades an unbounded 1 Hz spin for a permanently
7027
+ * leaked slot, which is not obviously the better failure (#379 review, P1):
7028
+ * a spin is loud and self-describing, whereas an in-flight record nobody will
7029
+ * ever complete silently reduces dispatch capacity until the process is
7030
+ * restarted. The slot is process-local accounting and is always freed here.
7031
+ *
7032
+ * What is NOT declared clean is the work itself. On a durable lifecycle the
7033
+ * persisted `releasing` phase is deliberately left in place, so a successor
7034
+ * or a restart re-drives the same cleanup with a fresh budget; freeing the
7035
+ * local slot does not write a terminal phase. On a local lifecycle there is
7036
+ * no durable record to retain, so the batch record is all there is and
7037
+ * completing it is what keeps capacity honest.
7038
+ */
7039
+ async #releaseDeadLetteredSlot(record, key) {
7040
+ const batch = await this.#batch();
7041
+ const next = batch.complete(record.issue);
7042
+ this.#uncompensatedDispatchClaims.delete(key);
7043
+ await this.#writeInFlightRegistry();
7044
+ // A freed slot that nothing is admitted into is only half the repair.
7045
+ if (next && !this.#stopping)
7046
+ await this.dispatch(next.decision, { dryRun: next.dryRun });
7047
+ }
7048
+ /** Clears a work unit's release budget once cleanup actually succeeds. */
7049
+ #clearReleaseAttempts(key) {
7050
+ this.#dispatchLifecycleReleaseAttempts.delete(key);
7051
+ this.#dispatchLifecycleReleaseAbandoned.delete(key);
7052
+ }
7053
+ #scheduleDispatchLifecycleRetry(record, delayMs = this.#dispatchLifecycleRetryMs, opts = {}) {
6795
7054
  const key = dispatchLifecycleKey(record.issue);
6796
7055
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
6797
7056
  return;
7057
+ // Only a release re-arm spends the budget. A capacity or ownership wait is
7058
+ // a legitimate wait for someone else to finish (#303) and is bounded by
7059
+ // its rate, not its count — spending the budget on one would abandon work
7060
+ // that was never failing.
7061
+ if (opts.releaseAttempt === true && !this.#chargeReleaseAttempt(record, key, 'durable-lifecycle'))
7062
+ return;
6798
7063
  const timer = setTimeout(() => {
6799
7064
  this.#dispatchLifecycleRetryTimers.delete(key);
6800
7065
  const drive = this.#driveDispatchLifecycle(key)
6801
7066
  .then(() => {
6802
7067
  this.#dispatchLifecycleCapacityWaits.delete(key);
6803
7068
  this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
7069
+ // DELIBERATELY NOT `#clearReleaseAttempts` (#379 review, P1).
7070
+ //
7071
+ // A FAILED release reaches here. `#finishDurableRelease` returns
7072
+ // `false` rather than throwing, and `#driveDispatchLifecycle`
7073
+ // discards that boolean in its `phase === 'releasing'` branch, so
7074
+ // the drive RESOLVES on a failed release and this handler runs on
7075
+ // every re-arm. Clearing the budget here zeroed it once per pass and
7076
+ // the counter could never reach the cap — the same never-fires this
7077
+ // bound exists to avoid, moved from the `.catch()` to the `.then()`.
7078
+ //
7079
+ // The budget is refunded where success is actually known:
7080
+ // `#finishDurableRelease` clears it on real per-agent progress and
7081
+ // again when the work unit completes.
6804
7082
  })
6805
7083
  .catch((error) => {
6806
- let nextDelayMs = DISPATCH_LIFECYCLE_RETRY_MS;
7084
+ let nextDelayMs = this.#dispatchLifecycleRetryMs;
6807
7085
  if (error instanceof DispatchLifecycleCapacityError) {
6808
7086
  this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
6809
7087
  nextDelayMs = this.#recordDispatchCapacityWait(record, key);
@@ -6818,7 +7096,7 @@ export class FactoryLoop {
6818
7096
  leaseRemainingMs: error.leaseUntilMs === undefined
6819
7097
  ? undefined
6820
7098
  : Math.max(0, error.leaseUntilMs - this.#clock.now()),
6821
- retryMs: DISPATCH_LIFECYCLE_RETRY_MS,
7099
+ retryMs: this.#dispatchLifecycleRetryMs,
6822
7100
  });
6823
7101
  }
6824
7102
  }
@@ -6830,6 +7108,20 @@ export class FactoryLoop {
6830
7108
  error: describeError(error).errorMessage,
6831
7109
  });
6832
7110
  }
7111
+ // Deliberately UNCHARGED (#379 review, P1). This arm re-arms for
7112
+ // dispatch, publishing and recovery failures as well as releases, and
7113
+ // charging all of them would dead-letter a work unit that was never
7114
+ // stuck in a release loop. Charging is confined to
7115
+ // `#scheduleReleaseRetry`, whose every caller is a release failure:
7116
+ // the three inside `#finishDurableRelease` and `#completeIssue`'s
7117
+ // catch once `releaseReasonForRetry` is set.
7118
+ //
7119
+ // The gap this leaves, stated plainly: a release failure that THREW
7120
+ // out of `#finishDurableRelease` instead of returning `false` would
7121
+ // reach here and re-arm unbounded. Every failure path in that method
7122
+ // returns `false` and schedules its own retry, so this is not a
7123
+ // reachable shape today — and if one appears it degrades to the
7124
+ // pre-existing unbounded behaviour rather than to a wrong dead-letter.
6833
7125
  this.#scheduleDispatchLifecycleRetry(record, nextDelayMs);
6834
7126
  })
6835
7127
  .finally(() => this.#dispatchLifecycleDrives.delete(drive));
@@ -6839,12 +7131,19 @@ export class FactoryLoop {
6839
7131
  }
6840
7132
  #scheduleReleaseRetry(record, reason) {
6841
7133
  if (this.#usesDurableDispatchLifecycle()) {
6842
- this.#scheduleDispatchLifecycleRetry(record);
7134
+ this.#scheduleDispatchLifecycleRetry(record, this.#dispatchLifecycleRetryMs, { releaseAttempt: true });
6843
7135
  return;
6844
7136
  }
6845
7137
  const key = dispatchLifecycleKey(record.issue);
6846
7138
  if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
6847
7139
  return;
7140
+ // Charged here rather than in the `.catch()` below, because the loop this
7141
+ // bounds does not go through the `.catch()`: `#finishDurableRelease`
7142
+ // returns `false` on a failed release and re-arms itself, so every re-arm
7143
+ // arrives on the resolved path. A bound on the rejection handler alone
7144
+ // would have been a fix that never fired.
7145
+ if (!this.#chargeReleaseAttempt(record, key, 'local-completion'))
7146
+ return;
6848
7147
  const timer = setTimeout(() => {
6849
7148
  this.#dispatchLifecycleRetryTimers.delete(key);
6850
7149
  const drive = this.#finishDurableRelease(record, reason)
@@ -6858,7 +7157,7 @@ export class FactoryLoop {
6858
7157
  })
6859
7158
  .finally(() => this.#dispatchLifecycleDrives.delete(drive));
6860
7159
  this.#dispatchLifecycleDrives.add(drive);
6861
- }, DISPATCH_LIFECYCLE_RETRY_MS);
7160
+ }, this.#dispatchLifecycleRetryMs);
6862
7161
  timer.unref?.();
6863
7162
  this.#dispatchLifecycleRetryTimers.set(key, timer);
6864
7163
  }
@@ -7071,6 +7370,9 @@ export class FactoryLoop {
7071
7370
  return;
7072
7371
  }
7073
7372
  if (lifecycle.phase === 'releasing') {
7373
+ // The boolean is discarded here, as it always was — and that is exactly
7374
+ // why the scheduler's success handler must not refund the release budget
7375
+ // (#379 review, P1). A failed release resolves through this line.
7074
7376
  await this.#finishDurableRelease(record, lifecycle.releaseReason);
7075
7377
  }
7076
7378
  }
@@ -7277,6 +7579,7 @@ export class FactoryLoop {
7277
7579
  .filter((agent) => agent.releasedAtMs !== undefined)
7278
7580
  .map((agent) => agent.name) ?? this.#localReleaseCheckpoints.get(releaseKey) ?? []);
7279
7581
  const failed = [];
7582
+ const releasedOnEntry = released.size;
7280
7583
  for (const agent of record.agents) {
7281
7584
  if (released.has(agent[0]))
7282
7585
  continue;
@@ -7297,6 +7600,13 @@ export class FactoryLoop {
7297
7600
  await this.#writeInFlightRegistry();
7298
7601
  if (failed.length > 0) {
7299
7602
  this.#increment('dispatchLifecycleReleaseRetries');
7603
+ // Real progress refunds the budget, so the ten attempts bound CONSECUTIVE
7604
+ // no-progress passes rather than capping a slow multi-agent release. This
7605
+ // terminates because an agent released once is checkpointed and skipped
7606
+ // on the next pass, so the remaining set strictly shrinks — a refund can
7607
+ // only be earned a finite number of times.
7608
+ if (released.size > releasedOnEntry)
7609
+ this.#clearReleaseAttempts(releaseKey);
7300
7610
  this.#scheduleReleaseRetry(record, reason);
7301
7611
  return false;
7302
7612
  }
@@ -7315,6 +7625,10 @@ export class FactoryLoop {
7315
7625
  }
7316
7626
  const next = this.#usesDurableDispatchLifecycle() ? undefined : batch.complete(record.issue);
7317
7627
  this.#localReleaseCheckpoints.delete(releaseKey);
7628
+ // Every agent is released. Nothing is left to retry, so the budget goes
7629
+ // back — including the abandoned marker, so a reopened work unit that
7630
+ // reuses this key starts with a full budget rather than a spent one.
7631
+ this.#clearReleaseAttempts(releaseKey);
7318
7632
  if (next)
7319
7633
  await this.dispatch(next.decision, { dryRun: next.dryRun });
7320
7634
  // Terminal lifecycle saves intentionally relinquish the owner epoch. Clear
@@ -8328,12 +8642,25 @@ export class FactoryLoop {
8328
8642
  const repo = dependencyRepoForIssue(issue, undefined, this.#config);
8329
8643
  if (!repo)
8330
8644
  return false;
8645
+ // This probe does NOT go through `#resolveIssuePr` — it must not fall back to
8646
+ // gh — so it never saw that method's cache, and `#terminalDependencyIdentities`
8647
+ // only ever memoises the TRUE answer. A dependency that is not merged was
8648
+ // therefore re-walked in full for every issue declaring it, on every sweep;
8649
+ // several issues blocked on one dependency multiplied a single tree walk by
8650
+ // the number of blocked issues. Memoise the negative answer too, on exactly
8651
+ // the lifetime of the terminal set beside it: cleared at the top of each
8652
+ // sweep, so a PR that merges between sweeps is still observed.
8653
+ const memoized = this.#dependencyPrProbes.get(identity);
8654
+ if (memoized !== undefined)
8655
+ return memoized;
8331
8656
  const pullRequest = await resolveIssuePrFromMount(this.#mount, this.#config, issue, {
8332
8657
  allowLegacyGithubBranch: true,
8333
8658
  repo,
8334
- }, (prefix) => this.#listRelayfileTree(prefix, 'dependency PR probe resolution'));
8335
- if (normalizePrState(pullRequest?.state) !== 'MERGED')
8659
+ }, (prefix) => this.#listRelayfileTree(prefix, 'dependency PR probe resolution'), this.#probeMountWalkProgress('[factory] dependency PR probe mount read progress', issue));
8660
+ if (normalizePrState(pullRequest?.state) !== 'MERGED') {
8661
+ this.#dependencyPrProbes.set(identity, false);
8336
8662
  return false;
8663
+ }
8337
8664
  this.#terminalDependencyIdentities.add(identity);
8338
8665
  return true;
8339
8666
  }
@@ -14770,8 +15097,25 @@ export class FactoryLoop {
14770
15097
  settleIssueWritebackOnce();
14771
15098
  this.#completionInFlight.delete(completionKey);
14772
15099
  const stateKey = issueStateKey(record.issue);
14773
- this.#probePrGhBackoffUntilMs.delete(stateKey);
14774
- this.#probePrResolvedCache.delete(stateKey);
15100
+ // Both maps are keyed by issue state key PLUS the option suffixes
15101
+ // `#resolveIssuePr` appends (`:open`, `:legacy`, `:open:legacy`), but this
15102
+ // invalidation only ever deleted the bare key. Every `openOnly` probe —
15103
+ // `#openPrForIssue` and `#openCompletionPr`, i.e. the completion path — was
15104
+ // therefore never invalidated at all. That was survivable only because the
15105
+ // mount branch never wrote the cache; now that it does, a stale OPEN entry
15106
+ // outliving completion would be a live correctness bug, so clear the whole
15107
+ // family. Suffixes always start with ':', and no other issue's state key can
15108
+ // be this one followed by ':', so the prefix test cannot over-delete.
15109
+ for (const cacheKey of [...this.#probePrResolvedCache.keys()]) {
15110
+ if (cacheKey === stateKey || cacheKey.startsWith(`${stateKey}:`)) {
15111
+ this.#probePrResolvedCache.delete(cacheKey);
15112
+ }
15113
+ }
15114
+ for (const backoffKey of [...this.#probePrGhBackoffUntilMs.keys()]) {
15115
+ if (backoffKey === stateKey || backoffKey.startsWith(`${stateKey}:`)) {
15116
+ this.#probePrGhBackoffUntilMs.delete(backoffKey);
15117
+ }
15118
+ }
14775
15119
  // Cancellation must see the subscription identity so it can issue the
14776
15120
  // idempotent Relayfile DELETE before clearing the local owner maps.
14777
15121
  await this.#cancelBabysittersForIssue(record.issue);
@@ -17439,6 +17783,7 @@ export class FactoryLoop {
17439
17783
  ? await this.#probePrResolver(issue)
17440
17784
  : await this.#resolveIssuePr(issue, {
17441
17785
  titleMarker: FACTORY_E2E_MARKER,
17786
+ repo: this.#probeRepoForIssue(issue),
17442
17787
  });
17443
17788
  if (!probe) {
17444
17789
  return;
@@ -17711,7 +18056,7 @@ const githubIssueSourceRef = (issue) => {
17711
18056
  }
17712
18057
  return { owner, repo, number: number, url };
17713
18058
  };
17714
- function dependencyRepoForIssue(issue, decision, config) {
18059
+ function dependencyRepoForIssue(issue, decision, config, opts = {}) {
17715
18060
  const source = githubIssueSourceRef(issue);
17716
18061
  if (source)
17717
18062
  return `${source.owner}/${source.repo}`;
@@ -17748,7 +18093,19 @@ function dependencyRepoForIssue(issue, decision, config) {
17748
18093
  ? Object.entries(config.repos.byProject)
17749
18094
  .find(([project]) => project.trim().toLowerCase() === issue.project.trim().toLowerCase())?.[1]
17750
18095
  : undefined;
17751
- return normalize(projectRepo) ?? normalize(config.repos.default);
18096
+ const projectResolved = normalize(projectRepo);
18097
+ if (projectResolved)
18098
+ return projectResolved;
18099
+ // `repos.default` is a DISPATCH fallback, not evidence of where a PR already
18100
+ // lives. Routing precedence is byLabel, byProject, keywordRules, default —
18101
+ // and this helper cannot see `keywordRules` at all, because those match on
18102
+ // title/description through triage rather than on the issue's own fields. So
18103
+ // for a keyword-routed issue the default answer here is confidently wrong:
18104
+ // dispatch opened the PR in the keyword-selected repository, not in
18105
+ // `repos.default`. Callers that only need a dispatch target still want that
18106
+ // fallback; a PROBE must not have it, because a mis-scoped probe reports "no
18107
+ // PR" for an issue that has one, which is worse than a slow unscoped walk.
18108
+ return opts.allowDefault === false ? undefined : normalize(config.repos.default);
17752
18109
  }
17753
18110
  function dependencyIdentityForIssue(issue, repo) {
17754
18111
  const source = githubIssueSourceRef(issue);
@@ -18736,7 +19093,211 @@ const linearIssueMirrorsGithubIssue = (issue, ghIssue) => {
18736
19093
  .split(/\r?\n/u)
18737
19094
  .some((line) => line.trim() === `${GITHUB_MIRROR_SOURCE_PREFIX}${ghIssue.url}`);
18738
19095
  };
18739
- const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, listTree = (prefix) => mount.listTree(prefix)) => {
19096
+ const PROBE_PR_INDEX_FALLBACK_COUNTERS = {
19097
+ 'index-absent': 'probePrIndexAbsent',
19098
+ 'index-shape-unrecognised': 'probePrIndexShapeUnrecognised',
19099
+ 'index-without-head-ref': 'probePrIndexWithoutHeadRef',
19100
+ 'index-row-malformed': 'probePrIndexRowMalformed',
19101
+ 'index-incomplete': 'probePrIndexIncomplete',
19102
+ 'index-no-match': 'probePrIndexNoMatch',
19103
+ 'index-disagreed': 'probePrIndexDisagreed',
19104
+ };
19105
+ /**
19106
+ * The lowest `issuePrMatchScore` tier the pull index can compute for itself.
19107
+ *
19108
+ * The scores are: branch match 30, issue key in title 20, explicit reference in
19109
+ * body 10. A row carries `headRef` and `title` but NOT `body`, so a row can be
19110
+ * scored exactly at the 30 and 20 tiers and is blind at 10. A pull request the
19111
+ * index scores at 20 or better therefore cannot be beaten by anything the index
19112
+ * could not see, because the best a body-only match can ever earn is 10 — which
19113
+ * makes an index hit of 20 or 30 provably the same winner the full walk would
19114
+ * have chosen. Below that threshold the index proves nothing and the walk is
19115
+ * mandatory.
19116
+ *
19117
+ * Deliberately expressed as a threshold rather than a "highest score wins"
19118
+ * shortcut: the constant is the whole safety argument, and inlining `20` at the
19119
+ * comparison would bury it.
19120
+ */
19121
+ const PULL_INDEX_DECISIVE_SCORE = 20;
19122
+ /**
19123
+ * Read `pulls/_index.json` and either return rows the probe may rank, or say
19124
+ * why it may not.
19125
+ *
19126
+ * All-or-nothing per repository, and the discipline is copied verbatim from
19127
+ * Factory's issue-side reader (`#githubIssuePathsFromIndex`): fall back for the
19128
+ * ENTIRE repository if any row is legacy or malformed. `headRef` was added to
19129
+ * the public GitHub pull index contract in `@relayfile/adapter-github@0.5.7`
19130
+ * and an already-written mount only converges on re-ingest, so mixed indexes
19131
+ * are the expected state, not a corruption. One legacy row is enough to poison
19132
+ * the whole conclusion: the primary match is a branch match worth 30, so a row
19133
+ * without `headRef` cannot be ruled out as the real winner, and ranking the
19134
+ * remaining rows against each other would answer a different question from the
19135
+ * one the walk answers.
19136
+ */
19137
+ const readPullIndexForProbe = async (mount, repo) => {
19138
+ const [owner, name] = repo.split('/');
19139
+ if (!owner || !name)
19140
+ return { reason: 'index-absent' };
19141
+ let parsed;
19142
+ try {
19143
+ const { content } = await mount.readFile(`${GITHUB_ISSUE_ROOT}/${owner}/${name}/pulls/_index.json`);
19144
+ parsed = parseJsonContent(content);
19145
+ }
19146
+ catch (error) {
19147
+ // A mount that is failing pass-wide must not be reported as "no index" —
19148
+ // that is the same convention the tree reads below already follow.
19149
+ if (isPassWideRelayfileFault(error))
19150
+ throw error;
19151
+ return { reason: 'index-absent' };
19152
+ }
19153
+ if (!Array.isArray(parsed))
19154
+ return { reason: 'index-shape-unrecognised' };
19155
+ // An empty index proves nothing about the row contract, so it must not read
19156
+ // as usable — `[].every(...)` is vacuously true.
19157
+ if (parsed.length === 0)
19158
+ return { reason: 'index-without-head-ref' };
19159
+ const rows = [];
19160
+ for (const entry of parsed) {
19161
+ const row = asRecord(entry);
19162
+ if (typeof row?.headRef !== 'string')
19163
+ return { reason: 'index-without-head-ref' };
19164
+ if (!Number.isSafeInteger(row.number) || Number(row.number) <= 0 ||
19165
+ typeof row.title !== 'string' ||
19166
+ typeof row.state !== 'string' ||
19167
+ (row.merged !== undefined && typeof row.merged !== 'boolean')) {
19168
+ return { reason: 'index-row-malformed' };
19169
+ }
19170
+ rows.push({
19171
+ number: Number(row.number),
19172
+ title: row.title,
19173
+ headRef: row.headRef,
19174
+ state: row.state,
19175
+ merged: row.merged,
19176
+ });
19177
+ }
19178
+ return { rows };
19179
+ };
19180
+ /**
19181
+ * Rank index rows exactly as the walk ranks records it has read.
19182
+ *
19183
+ * `body: ''` is a deliberate constant, not a placeholder for a value that
19184
+ * should have been threaded through. The index carries no body, so scoring with
19185
+ * an empty one makes `issuePrMatchScore` return only the tiers the index can
19186
+ * actually justify — 30 for a branch match, 20 for a title match, 0 otherwise —
19187
+ * and never invents the 10 it cannot see. Everything else (`requireTitleMarker`
19188
+ * gating, `allowLegacyGithubBranch`, the marker itself) is the SAME call the
19189
+ * walk makes on the same inputs, so the two paths cannot score differently.
19190
+ *
19191
+ * The tiebreak mirrors `b.score - a.score || b.prNumber - a.prNumber`: highest
19192
+ * score, then highest pull request number.
19193
+ */
19194
+ const bestPullIndexRow = (rows, issue, marker, opts) => {
19195
+ let best;
19196
+ for (const row of rows) {
19197
+ // Same derivation `readProbePrCandidate` applies to a record: a merged pull
19198
+ // request reports MERGED regardless of the raw `state` the provider wrote.
19199
+ const state = row.merged === true ? 'MERGED' : row.state;
19200
+ if (opts.openOnly && normalizePrState(state) !== 'OPEN')
19201
+ continue;
19202
+ const score = issuePrMatchScore({ title: row.title, body: '', headRef: row.headRef }, issue, marker, opts);
19203
+ if (score < PULL_INDEX_DECISIVE_SCORE)
19204
+ continue;
19205
+ if (!best || score > best.score || (score === best.score && row.number > best.number)) {
19206
+ best = { number: row.number, score };
19207
+ }
19208
+ }
19209
+ return best;
19210
+ };
19211
+ /**
19212
+ * Build the resolver's candidate shape from one pull request record.
19213
+ *
19214
+ * Shared by the index fast path and the walk so the two can never disagree on a
19215
+ * field. Differential equivalence is the whole safety property of the fast path
19216
+ * and it should hold structurally, not by two copies staying in sync.
19217
+ */
19218
+ const probePrCandidate = (repo, pr, path, score) => ({
19219
+ repo,
19220
+ prNumber: pr.number,
19221
+ draft: pr.draft,
19222
+ headRef: pr.headRef,
19223
+ headRepo: pr.headRepo,
19224
+ crossRepository: pr.crossRepository ?? (pr.headRepo ? pr.headRepo.toLowerCase() !== repo.toLowerCase() : undefined),
19225
+ state: pr.state,
19226
+ url: pr.url,
19227
+ path,
19228
+ score,
19229
+ });
19230
+ /**
19231
+ * Answer "which pull request belongs to this issue?" from `pulls/_index.json`
19232
+ * instead of reading every mounted pull request record, or say why it could not.
19233
+ *
19234
+ * Reads at most two files: the index, and the one record the index names. That
19235
+ * record read is not an optimisation that was left on the table — it is load
19236
+ * bearing twice over. The index carries no `draft`, `headRepo`, `url` or mount
19237
+ * path, all of which the resolver's result type promises and callers persist as
19238
+ * `ownedPullRequest`; and re-scoring the real record is what turns "the index
19239
+ * said so" into "the record agrees", so a stale row can never decide an answer
19240
+ * on its own.
19241
+ *
19242
+ * The tree listing is NOT avoided, and that is deliberate. It costs two calls
19243
+ * per repository where the record walk cost one call per pull request — 4,000
19244
+ * of them in the live workspace, which is the cost that stalled a sweep for
19245
+ * 11m53s. Keeping it buys two things nothing else can: the exact path spelling
19246
+ * the walk would have reported (the nested layout's slug cannot be constructed,
19247
+ * only listed), and the completeness check below.
19248
+ *
19249
+ * Every condition that still walks:
19250
+ *
19251
+ * - anything `readPullIndexForProbe` refuses — absent, wrong shape, any row
19252
+ * without `headRef`, any row whose `number`/`title`/`state` is unusable;
19253
+ * - a pull request on disk with no row describing it, because an unindexed
19254
+ * record could hold the score-30 branch match;
19255
+ * - no row scoring `PULL_INDEX_DECISIVE_SCORE` or better, because a body-only
19256
+ * reference worth 10 may exist on disk and no index row can see a body;
19257
+ * - the named record failing to confirm its row — missing, different number,
19258
+ * different score, or not open when `openOnly` is set.
19259
+ */
19260
+ const resolveProbePrFromPullIndex = async (mount, repo, issue, marker, opts, walk, pullNumbersOnDisk, observer) => {
19261
+ const reading = await readPullIndexForProbe(mount, repo);
19262
+ if (!reading.rows)
19263
+ return { reason: reading.reason };
19264
+ const indexed = new Set(reading.rows.map((row) => row.number));
19265
+ for (const number of pullNumbersOnDisk) {
19266
+ if (!indexed.has(number))
19267
+ return { reason: 'index-incomplete' };
19268
+ }
19269
+ const best = bestPullIndexRow(reading.rows, issue, marker, opts);
19270
+ if (!best)
19271
+ return { reason: 'index-no-match' };
19272
+ // The first spelling of this pull request in `walk` is by construction the
19273
+ // one the record loop below would have read: `walk` preserves the listing
19274
+ // order of `githubPullRoots` and has already collapsed the alias spellings,
19275
+ // keeping the first. So the fast path reports the same `path` the walk does.
19276
+ const path = walk.find((candidatePath) => githubPullPathParts(candidatePath)?.number === best.number);
19277
+ if (!path)
19278
+ return { reason: 'index-disagreed' };
19279
+ const pr = await readProbePrCandidate(mount, path);
19280
+ observer.onRead?.({ read: 1, total: 1, path });
19281
+ if (!pr)
19282
+ return { reason: 'index-disagreed' };
19283
+ if (opts.openOnly && normalizePrState(pr.state) !== 'OPEN')
19284
+ return { reason: 'index-disagreed' };
19285
+ // `readProbePrCandidate` takes the number from the PAYLOAD, falling back to
19286
+ // the path, so a record can disagree with the path that addressed it.
19287
+ if (pr.number !== best.number)
19288
+ return { reason: 'index-disagreed' };
19289
+ const score = issuePrMatchScore(pr, issue, marker, opts);
19290
+ if (score !== best.score)
19291
+ return { reason: 'index-disagreed' };
19292
+ observer.onIndexHit?.(repo, pr.number);
19293
+ return { candidate: probePrCandidate(repo, pr, path, score) };
19294
+ };
19295
+ // Exported for the differential-equivalence suite, which has to invoke the
19296
+ // index fast path and the record walk over ONE corpus and compare their two
19297
+ // answers field for field. Driving that through the loop's public surface would
19298
+ // compare a resolution to itself; the same precedent already exists in this
19299
+ // file for `githubIssuePathParts` and `keyFromPath`.
19300
+ export const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, listTree = (prefix) => mount.listTree(prefix), observer = {}) => {
18740
19301
  const candidates = [];
18741
19302
  const listErrors = [];
18742
19303
  for (const repo of opts.repo ? [opts.repo] : reposFromConfig(config)) {
@@ -18752,29 +19313,59 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, listTree
18752
19313
  listErrors.push(error);
18753
19314
  }
18754
19315
  }
19316
+ // `githubPullRoots` is plural, and both of its roots describe the SAME
19317
+ // repository: the nested `<owner>/<repo>/pulls/` layout and the flat
19318
+ // `<owner>__<repo>/pulls/by-id/` one. A pull request is generally present
19319
+ // under both, spelled differently, so the union walked most PRs twice — in
19320
+ // the live workspace 2877 nested paths plus 1156 by-id paths for roughly
19321
+ // 1156 actual pull requests.
19322
+ //
19323
+ // Collapse them on the identity the PATH already carries. `githubPullPathParts`
19324
+ // reads owner/repo/number out of either spelling and costs no mount read, so
19325
+ // the dedupe is free. Insertion order is preserved and the first spelling
19326
+ // encountered wins, which is the same candidate the existing stable sort kept
19327
+ // when two spellings of one PR tied — so the winner does not move.
19328
+ //
19329
+ // Paths the matcher does not recognise (`pulls/_index.json`, per-PR
19330
+ // `comments/*.json`) carry no PR identity, so they are left in the walk and
19331
+ // still read exactly as before rather than being filtered on a guess.
19332
+ const walk = [];
19333
+ const seenPulls = new Set();
19334
+ // The pull request numbers the TREE says exist, which is what makes the
19335
+ // index's completeness checkable below. Built here rather than separately
19336
+ // because `githubPullPathParts` has already been called on every path.
19337
+ const pullNumbersOnDisk = new Set();
18755
19338
  for (const path of paths) {
18756
19339
  if (!path.endsWith('.json'))
18757
19340
  continue;
19341
+ const parts = githubPullPathParts(path);
19342
+ if (parts) {
19343
+ const identity = `${parts.owner}/${parts.repo}#${parts.number}`.toLowerCase();
19344
+ if (seenPulls.has(identity))
19345
+ continue;
19346
+ seenPulls.add(identity);
19347
+ pullNumbersOnDisk.add(parts.number);
19348
+ }
19349
+ walk.push(path);
19350
+ }
19351
+ const marker = opts.titleMarker ?? config.safety.requireTitlePrefix;
19352
+ const fromIndex = await resolveProbePrFromPullIndex(mount, repo, issue, marker, opts, walk, pullNumbersOnDisk, observer);
19353
+ if (fromIndex.candidate) {
19354
+ candidates.push(fromIndex.candidate);
19355
+ continue;
19356
+ }
19357
+ observer.onIndexFallback?.(repo, fromIndex.reason);
19358
+ let read = 0;
19359
+ for (const path of walk) {
18758
19360
  const pr = await readProbePrCandidate(mount, path);
19361
+ read += 1;
19362
+ observer.onRead?.({ read, total: walk.length, path });
18759
19363
  if (opts.openOnly && normalizePrState(pr?.state) !== 'OPEN')
18760
19364
  continue;
18761
- const score = pr
18762
- ? issuePrMatchScore(pr, issue, opts.titleMarker ?? config.safety.requireTitlePrefix, opts)
18763
- : 0;
19365
+ const score = pr ? issuePrMatchScore(pr, issue, marker, opts) : 0;
18764
19366
  if (!pr || score <= 0)
18765
19367
  continue;
18766
- candidates.push({
18767
- repo,
18768
- prNumber: pr.number,
18769
- draft: pr.draft,
18770
- headRef: pr.headRef,
18771
- headRepo: pr.headRepo,
18772
- crossRepository: pr.crossRepository ?? (pr.headRepo ? pr.headRepo.toLowerCase() !== repo.toLowerCase() : undefined),
18773
- state: pr.state,
18774
- url: pr.url,
18775
- path,
18776
- score,
18777
- });
19368
+ candidates.push(probePrCandidate(repo, pr, path, score));
18778
19369
  }
18779
19370
  }
18780
19371
  const resolved = candidates.sort((a, b) => b.score - a.score || b.prNumber - a.prNumber)[0];