@agent-relay/factory 0.1.48 → 0.1.50

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.
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/orchestrator/factory.ts"],"names":[],"mappings":"AAIA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAI1E,OAAO,KAAK,EAMV,WAAW,EASX,WAAW,EAKZ,MAAM,UAAU,CAAA;AAiCjB,OAAO,KAAK,EACV,cAAc,EACd,OAAO,EACP,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,mBAAmB,EAEnB,qBAAqB,EACrB,oBAAoB,EACpB,mBAAmB,EAGnB,QAAQ,EACR,eAAe,EACf,WAAW,EAGX,cAAc,EAEf,MAAM,UAAU,CAAA;AA4BjB,KAAK,YAAY,GAAG,cAAc,GAAG,YAAY,GAAG,YAAY,GAAG,oBAAoB,GAAG,OAAO,CAAA;AACjG,KAAK,QAAQ,GAAG,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAA;AAoFtD,KAAK,iBAAiB,GAAG;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC7B,CAAA;AAgGD,eAAO,MAAM,mCAAmC,iDAAiD,CAAA;AACjG,eAAO,MAAM,kCAAkC,gDAAgD,CAAA;AAa/F,wBAAgB,aAAa,CAAC,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAEjF;AAED,qBAAa,WAAY,YAAW,OAAO;;gBAiK7B,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY;IAkIhD,uBAAuB,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEvD,KAAK,CAAC,IAAI,GAAE,mBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsHpD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAkJrB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA8mBxB,OAAO,CAAC,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAkoBlE,OAAO,CAAC,IAAI,GAAE,qBAA0B,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAyDrE,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC;IAOxD,QAAQ,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAsT7H,MAAM,IAAI,aAAa;IAiBvB,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,QAAQ,GAAG,MAAM,IAAI;CAolRxD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,WAAW,CAiC5E;AAyBD,wBAAsB,oCAAoC,CACxD,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,EACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,WAAW,CAAC,CAetB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,iBAAiB,CA6BlF;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,WAAW,CAEnF;AAoDD,wBAAsB,wBAAwB,CAC5C,IAAI,SAAsC,GACzC,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAM3C;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,oBAAoB,GAAG,SAAS,EAC3C,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,mBAAmB,CAgBrB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAO7D;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAiB/D;AAglBD,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAwBpH,CAAA;AA0ZD,eAAO,MAAM,2BAA2B,GAAI,QAAQ,aAAa,KAAG,MAAM,EAItE,CAAA;AA2mBJ,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAC4B,CAAA;AAkUvE,eAAO,MAAM,eAAe,GAAI,OAAO,WAAW,KAAG,MAAM,GAAG,SAG7D,CAAA"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/orchestrator/factory.ts"],"names":[],"mappings":"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"}
@@ -90,6 +90,7 @@ const DISPATCH_LIFECYCLE_LEASE_MS = 5 * 60_000;
90
90
  const DISPATCH_LIFECYCLE_RENEW_MS = 60_000;
91
91
  const DISPATCH_LIFECYCLE_RETRY_MS = 1_000;
92
92
  const STARTUP_AGENT_EXIT_DRAIN_TIMEOUT_MS = 30_000;
93
+ const RECONCILED_AGENT_EXIT_CONCURRENCY = 4;
93
94
  const SLACK_EVENT_WATERMARK_CACHE_MS = 60_000;
94
95
  const SLACK_CONVERSATION_TURN_LEASE_MS = 60_000;
95
96
  const SLACK_CONVERSATION_TURN_RETRY_MS = 1_000;
@@ -207,6 +208,7 @@ export class FactoryLoop {
207
208
  #dispatchTerminalWaiters = new Map();
208
209
  #dispatchLifecycleRetryTimers = new Map();
209
210
  #dispatchLifecycleDrives = new Set();
211
+ #abandonedDispatchReasons = new Map();
210
212
  #dispatchLifecycleCapacityWaitLogged = new Set();
211
213
  #dispatchLifecycleOwnershipWaitLogged = new Set();
212
214
  #localReleaseCheckpoints = new Map();
@@ -246,6 +248,8 @@ export class FactoryLoop {
246
248
  #completionSweepActive = false;
247
249
  #completionInFlight = new Set();
248
250
  #agentExitsInFlight = new Map();
251
+ #reconciledAgentExitsActive = 0;
252
+ #reconciledAgentExitWaiters = [];
249
253
  #agentLifecycleSignalsInFlight = new Map();
250
254
  #startupAgentAdoptionActive = false;
251
255
  #startupRosterExitSignals;
@@ -608,6 +612,7 @@ export class FactoryLoop {
608
612
  for (const timer of this.#dispatchLifecycleRetryTimers.values())
609
613
  clearTimeout(timer);
610
614
  this.#dispatchLifecycleRetryTimers.clear();
615
+ this.#abandonedDispatchReasons.clear();
611
616
  this.#dispatchLifecycleOwnershipWaitLogged.clear();
612
617
  if (this.#completionSweepTimer)
613
618
  clearTimeout(this.#completionSweepTimer);
@@ -2341,7 +2346,13 @@ export class FactoryLoop {
2341
2346
  const previous = this.#agentExitsInFlight.get(name) ?? Promise.resolve();
2342
2347
  const handling = previous
2343
2348
  .catch(() => undefined)
2344
- .then(async () => await this.#handleAgentExit(name, reason))
2349
+ .then(async () => {
2350
+ if (reason === 'reconciled-missing') {
2351
+ await this.#withReconciledAgentExitSlot(async () => this.#handleAgentExit(name, reason));
2352
+ return;
2353
+ }
2354
+ await this.#handleAgentExit(name, reason);
2355
+ })
2345
2356
  .catch((error) => this.#error(error))
2346
2357
  .finally(() => {
2347
2358
  if (this.#agentExitsInFlight.get(name) === handling) {
@@ -2376,7 +2387,7 @@ export class FactoryLoop {
2376
2387
  this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
2377
2388
  continue;
2378
2389
  }
2379
- this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
2390
+ this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
2380
2391
  if (claim.lifecycle.phase === 'waiting-for-human')
2381
2392
  continue;
2382
2393
  const durableRecord = inFlightRecordFromLifecycle(claim.lifecycle);
@@ -2448,7 +2459,12 @@ export class FactoryLoop {
2448
2459
  // the hydrated durable session is no longer usable. Classify it as a
2449
2460
  // reconciled miss so remote implementers recover a PR or restart
2450
2461
  // instead of waiting forever for branch replication after a crash.
2451
- for (const name of signalled)
2462
+ const rolePriority = (name) => {
2463
+ const role = batch.getIssueByAgent(name)?.agents.get(name)?.spec.role;
2464
+ return role === 'implementer' ? 0 : role === 'babysitter' ? 1 : 2;
2465
+ };
2466
+ const orderedSignals = [...signalled].sort((left, right) => rolePriority(left) - rolePriority(right) || left.localeCompare(right));
2467
+ for (const name of orderedSignals)
2452
2468
  this.#queueAgentExit(name, 'reconciled-missing');
2453
2469
  if (synthesized.length > 0) {
2454
2470
  this.#increment('startupRosterMissingExitsSynthesized', synthesized.length);
@@ -2566,6 +2582,20 @@ export class FactoryLoop {
2566
2582
  clearTimeout(timer);
2567
2583
  return completed;
2568
2584
  }
2585
+ async #withReconciledAgentExitSlot(run) {
2586
+ if (this.#reconciledAgentExitsActive >= RECONCILED_AGENT_EXIT_CONCURRENCY) {
2587
+ this.#increment('reconciledAgentExitBackpressure');
2588
+ await new Promise((resolve) => this.#reconciledAgentExitWaiters.push(resolve));
2589
+ }
2590
+ this.#reconciledAgentExitsActive += 1;
2591
+ try {
2592
+ await run();
2593
+ }
2594
+ finally {
2595
+ this.#reconciledAgentExitsActive -= 1;
2596
+ this.#reconciledAgentExitWaiters.shift()?.();
2597
+ }
2598
+ }
2569
2599
  #scheduleDispatchLifecycleRenewal() {
2570
2600
  if (this.#dispatchLifecycleRenewTimer || this.#dispatchLifecycleEpochs.size === 0)
2571
2601
  return;
@@ -2620,7 +2650,7 @@ export class FactoryLoop {
2620
2650
  : `dispatch lifecycle is owned by ${claim.lifecycle.lease?.owner ?? 'another publisher'}`;
2621
2651
  throw new Error(`Refusing to dispatch ${decision.issue.key}: ${reason}`);
2622
2652
  }
2623
- this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
2653
+ this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
2624
2654
  this.#scheduleDispatchLifecycleRenewal();
2625
2655
  if (claim.created) {
2626
2656
  await this.#reportLifecycle(claim.lifecycle, 'run.started');
@@ -2845,7 +2875,7 @@ export class FactoryLoop {
2845
2875
  if (!claim.acquired || !claim.lease) {
2846
2876
  throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
2847
2877
  }
2848
- this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
2878
+ this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
2849
2879
  this.#scheduleDispatchLifecycleRenewal();
2850
2880
  lifecycle = claim.lifecycle;
2851
2881
  acquiredNow = true;
@@ -2893,6 +2923,11 @@ export class FactoryLoop {
2893
2923
  return;
2894
2924
  if (acquiredNow && this.#config.babysitter.enabled)
2895
2925
  await this.#restoreBabysitterOwnership();
2926
+ const abandonedReason = this.#abandonedDispatchReasons.get(key);
2927
+ if (abandonedReason !== undefined) {
2928
+ await this.#abandonStuckDispatch(record, abandonedReason);
2929
+ return;
2930
+ }
2896
2931
  if (acquiredNow && lifecycle.phase === 'running') {
2897
2932
  if (this.#fleet.hydrateTracked) {
2898
2933
  this.#fleet.hydrateTracked(lifecycle.agents.map((agent) => ({
@@ -4567,6 +4602,7 @@ export class FactoryLoop {
4567
4602
  if (isCompletionReason(reason)) {
4568
4603
  if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
4569
4604
  openOnly: this.#config.babysitter.enabled,
4605
+ preferExactBranch: tracingReconciledExit,
4570
4606
  }, exiting)) {
4571
4607
  if (this.#config.babysitter.enabled)
4572
4608
  await this.#ensureBabysitterForIssue(record);
@@ -4624,6 +4660,7 @@ export class FactoryLoop {
4624
4660
  const hasCompletionPr = tracked.spec.role === 'implementer'
4625
4661
  ? await this.#issueHasCompletionPr(record, {
4626
4662
  openOnly: this.#config.babysitter.enabled,
4663
+ preferExactBranch: tracingReconciledExit,
4627
4664
  }, tracked)
4628
4665
  : false;
4629
4666
  if (tracingReconciledExit) {
@@ -4729,30 +4766,43 @@ export class FactoryLoop {
4729
4766
  return;
4730
4767
  }
4731
4768
  }
4732
- let recovered = false;
4733
- if (tracked.sessionRef) {
4734
- const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
4735
- if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
4736
- // Already resumed once and STILL exiting with no completion PR — the
4737
- // agent isn't making progress. Conclude the dispatch so a human
4738
- // notices AND the reviewer waiting on the implementer's DM is torn
4739
- // down, instead of leaving the issue silently in-flight forever with
4740
- // a live reviewer stalling the owned-broker dispose-wait (#67).
4741
- if (tracked.spec.role === 'implementer') {
4742
- await this.#concludeTerminalImplementer(record, name, 'stalled-no-pr');
4743
- }
4744
- return;
4769
+ // Bound recovery by the durable logical agent, not by the session ref.
4770
+ // Harnesses may return a fresh session ref from every successful resume,
4771
+ // and no-sessionRef workers use respawn instead. Keying only resumptions
4772
+ // by the changing ref (and not recording respawns at all) let an agent
4773
+ // that exited immediately consume a batch slot forever while Factory
4774
+ // continuously recreated it. One successful recovery is enough; a
4775
+ // subsequent no-PR implementer exit is terminal and frees the slot.
4776
+ const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
4777
+ const recoveryRunIdentity = recoveryLifecycle?.runId
4778
+ ?? tracked.spec.branch
4779
+ ?? batch.invocationIdFor(record.issue, tracked.spec);
4780
+ const recoveryKey = `${recoveryRunIdentity}:${tracked.spec.name}`;
4781
+ if (await this.#state.isResumed(this.#workspaceId, recoveryKey)) {
4782
+ if (tracked.spec.role === 'implementer') {
4783
+ await this.#concludeTerminalImplementer(record, name, 'stalled-no-pr');
4745
4784
  }
4746
- const existing = this.#resumeInFlight.get(resumeKey);
4747
- if (existing) {
4785
+ return;
4786
+ }
4787
+ const existing = this.#resumeInFlight.get(recoveryKey);
4788
+ if (existing) {
4789
+ try {
4748
4790
  await existing;
4749
- return;
4750
4791
  }
4792
+ catch {
4793
+ // The initiating recovery handler owns classification and logging.
4794
+ // Followers only coalesce onto its lifetime and must not re-report
4795
+ // the same failure.
4796
+ }
4797
+ return;
4798
+ }
4799
+ let recovered = false;
4800
+ if (tracked.sessionRef) {
4751
4801
  const resume = this.#resumeTrackedAgent(record, name, tracked);
4752
- this.#resumeInFlight.set(resumeKey, resume);
4802
+ this.#resumeInFlight.set(recoveryKey, resume);
4753
4803
  try {
4754
4804
  await resume;
4755
- await this.#state.markResumed(this.#workspaceId, resumeKey);
4805
+ await this.#state.markResumed(this.#workspaceId, recoveryKey);
4756
4806
  recovered = true;
4757
4807
  }
4758
4808
  catch (error) {
@@ -4760,11 +4810,11 @@ export class FactoryLoop {
4760
4810
  // The broker never released this agent's name on exit
4761
4811
  // (relay#1116-family), so re-registering collides with the stuck
4762
4812
  // name. Retrying just re-collides forever. Treat it as terminal for
4763
- // this name: record the resume key so subsequent exit events
4813
+ // this name: record the recovery key so subsequent exit events
4764
4814
  // short-circuit, count it, and warn once. The external reaper / a
4765
4815
  // broker restart reclaims the leaked name.
4766
4816
  this.#fleet.markAgentTerminal?.(name, 'resume-already-exists');
4767
- await this.#state.markResumed(this.#workspaceId, resumeKey);
4817
+ await this.#state.markResumed(this.#workspaceId, recoveryKey);
4768
4818
  this.#increment('resumeNameCollisions');
4769
4819
  this.#logger.warn?.('[factory] resume skipped: broker still holds agent name (relay#1116); not retrying', {
4770
4820
  issue: record.issue.key,
@@ -4783,12 +4833,12 @@ export class FactoryLoop {
4783
4833
  }
4784
4834
  }
4785
4835
  finally {
4786
- this.#resumeInFlight.delete(resumeKey);
4836
+ this.#resumeInFlight.delete(recoveryKey);
4787
4837
  }
4788
4838
  }
4789
4839
  else {
4790
4840
  const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
4791
- try {
4841
+ const respawn = (async () => {
4792
4842
  await this.#prepareAgentWorktree(record, tracked.spec);
4793
4843
  const result = await this.#fleet.spawn({
4794
4844
  name: tracked.spec.name,
@@ -4804,6 +4854,11 @@ export class FactoryLoop {
4804
4854
  channel: tracked.spec.channel,
4805
4855
  });
4806
4856
  batch.recordSpawn(record, tracked.spec, invocationId, result);
4857
+ })();
4858
+ this.#resumeInFlight.set(recoveryKey, respawn);
4859
+ try {
4860
+ await respawn;
4861
+ await this.#state.markResumed(this.#workspaceId, recoveryKey);
4807
4862
  recovered = true;
4808
4863
  }
4809
4864
  catch (error) {
@@ -4817,6 +4872,7 @@ export class FactoryLoop {
4817
4872
  // the dispatch so a reviewer blocked on the implementer's DM does not
4818
4873
  // hang the owned-broker dispose-wait (#67).
4819
4874
  this.#fleet.markAgentTerminal?.(name, 'respawn-already-exists');
4875
+ await this.#state.markResumed(this.#workspaceId, recoveryKey);
4820
4876
  this.#increment('resumeNameCollisions');
4821
4877
  this.#logger.warn?.('[factory] respawn skipped: broker still holds agent name (relay#1116); not retrying', {
4822
4878
  issue: record.issue.key,
@@ -4826,6 +4882,9 @@ export class FactoryLoop {
4826
4882
  await this.#concludeTerminalImplementer(record, name, 'respawn-already-exists');
4827
4883
  }
4828
4884
  }
4885
+ finally {
4886
+ this.#resumeInFlight.delete(recoveryKey);
4887
+ }
4829
4888
  }
4830
4889
  if (recovered && tracked.spec.role === 'implementer') {
4831
4890
  await this.#writeInFlightRegistry();
@@ -5302,6 +5361,8 @@ export class FactoryLoop {
5302
5361
  // the release-driven exit event so it cannot re-trigger a resume before the
5303
5362
  // record leaves the batch.
5304
5363
  async #abandonStuckDispatch(record, reason) {
5364
+ const key = issueKey(record.issue);
5365
+ this.#abandonedDispatchReasons.set(key, reason);
5305
5366
  const agents = [...record.agents];
5306
5367
  for (const [agentName, tracked] of agents) {
5307
5368
  if (tracked.spec.role === 'implementer')
@@ -5334,6 +5395,22 @@ export class FactoryLoop {
5334
5395
  await this.#writeInFlightRegistry();
5335
5396
  return;
5336
5397
  }
5398
+ // Batch completion alone only frees the process-local slot. Durable
5399
+ // capacity is computed from lifecycle phases, so leaving this row in
5400
+ // `publishing`/`running` makes every queued issue wait forever even though
5401
+ // all agents and worktrees are already gone. Fence the terminal phase
5402
+ // before promoting the next issue.
5403
+ if (!await this.#saveDispatchLifecycle(record, 'abandoned', undefined, reason)) {
5404
+ this.#increment('abandonedDispatchReleaseRetries');
5405
+ // #saveDispatchLifecycle already schedules the generic durable retry. The
5406
+ // pending reason makes that retry re-acquire ownership and return here,
5407
+ // rather than merely restoring the old running lifecycle and leaking the
5408
+ // slot. The abandoned-specific scheduler remains the non-durable fallback.
5409
+ this.#scheduleAbandonedDispatchRetry(record, reason);
5410
+ await this.#writeInFlightRegistry();
5411
+ return;
5412
+ }
5413
+ this.#abandonedDispatchReasons.delete(key);
5337
5414
  await this.#recordDispatchTerminal(record.issue);
5338
5415
  const next = (await this.#batch()).complete(record.issue);
5339
5416
  await this.#drainReadyClarificationWake();
@@ -5367,7 +5444,12 @@ export class FactoryLoop {
5367
5444
  if (!issue) {
5368
5445
  return false;
5369
5446
  }
5370
- if (implementer?.spec.branch && record.decision.implementers.length > 1) {
5447
+ // During startup recovery, the implementer's deterministic branch is the
5448
+ // strongest and cheapest lookup. Avoid scanning mounted PR metadata across
5449
+ // every configured repository. Normal event-driven exits keep using the
5450
+ // webhook-fed mount so they do not introduce a GitHub API dependency.
5451
+ if (implementer?.spec.branch &&
5452
+ (opts.preferExactBranch || record.decision.implementers.length > 1)) {
5371
5453
  const sourceOwner = record.issue.path
5372
5454
  ? githubIssuePathParts(record.issue.path)?.owner
5373
5455
  : undefined;