@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.
- package/dist/orchestrator/factory.d.ts +65 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +623 -32
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/public-health.d.ts.map +1 -1
- package/dist/orchestrator/public-health.js +7 -0
- package/dist/orchestrator/public-health.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
},
|
|
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
|
-
|
|
14774
|
-
|
|
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
|
-
|
|
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
|
|
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];
|