@agent-relay/factory 0.1.44 → 0.1.46

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.
@@ -105,10 +105,19 @@ const GITHUB_FACTORY_LABEL = 'factory';
105
105
  const GITHUB_LIFECYCLE_LABELS = new Set(['factory:in-progress', 'factory:human-review']);
106
106
  const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
107
107
  const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
108
+ const STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS = 3;
109
+ const STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS = 500;
108
110
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
109
111
  export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
110
112
  class DispatchLifecycleCapacityError extends Error {
111
113
  }
114
+ class DispatchLifecycleOwnedElsewhereError extends Error {
115
+ leaseUntilMs;
116
+ constructor(leaseUntilMs) {
117
+ super('durable dispatch is still owned by another publisher');
118
+ this.leaseUntilMs = leaseUntilMs;
119
+ }
120
+ }
112
121
  const realClock = {
113
122
  now: () => Date.now(),
114
123
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -199,6 +208,7 @@ export class FactoryLoop {
199
208
  #dispatchLifecycleRetryTimers = new Map();
200
209
  #dispatchLifecycleDrives = new Set();
201
210
  #dispatchLifecycleCapacityWaitLogged = new Set();
211
+ #dispatchLifecycleOwnershipWaitLogged = new Set();
202
212
  #localReleaseCheckpoints = new Map();
203
213
  #dispatchLifecycleRenewTimer;
204
214
  #clarificationSweepTimer;
@@ -598,6 +608,7 @@ export class FactoryLoop {
598
608
  for (const timer of this.#dispatchLifecycleRetryTimers.values())
599
609
  clearTimeout(timer);
600
610
  this.#dispatchLifecycleRetryTimers.clear();
611
+ this.#dispatchLifecycleOwnershipWaitLogged.clear();
601
612
  if (this.#completionSweepTimer)
602
613
  clearTimeout(this.#completionSweepTimer);
603
614
  this.#completionSweepTimer = undefined;
@@ -671,7 +682,16 @@ export class FactoryLoop {
671
682
  }
672
683
  }
673
684
  async #releaseOwnedDispatchLifecycleLeases() {
674
- for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
685
+ // The epoch cache is an execution optimization, not the durable ownership
686
+ // authority. Error/fence paths may evict a cached epoch while its persisted
687
+ // lease is still ours, so enumerate state before shutdown relinquishment.
688
+ const owned = new Map(this.#dispatchLifecycleEpochs);
689
+ for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
690
+ if (lifecycle.lease?.owner === this.#dispatchLifecycleOwner) {
691
+ owned.set(key, lifecycle.lease.epoch);
692
+ }
693
+ }
694
+ for (const [key, epoch] of owned) {
675
695
  await this.#state.releaseDispatchLifecycleLease(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch);
676
696
  if (this.#dispatchLifecycleEpochs.get(key) === epoch) {
677
697
  this.#dispatchLifecycleEpochs.delete(key);
@@ -2739,9 +2759,11 @@ export class FactoryLoop {
2739
2759
  const drive = this.#driveDispatchLifecycle(key)
2740
2760
  .then(() => {
2741
2761
  this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2762
+ this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
2742
2763
  })
2743
2764
  .catch((error) => {
2744
2765
  if (error instanceof DispatchLifecycleCapacityError) {
2766
+ this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
2745
2767
  if (!this.#dispatchLifecycleCapacityWaitLogged.has(key)) {
2746
2768
  this.#dispatchLifecycleCapacityWaitLogged.add(key);
2747
2769
  this.#increment('dispatchLifecycleCapacityWaits');
@@ -2751,8 +2773,23 @@ export class FactoryLoop {
2751
2773
  });
2752
2774
  }
2753
2775
  }
2776
+ else if (error instanceof DispatchLifecycleOwnedElsewhereError) {
2777
+ this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2778
+ if (!this.#dispatchLifecycleOwnershipWaitLogged.has(key)) {
2779
+ this.#dispatchLifecycleOwnershipWaitLogged.add(key);
2780
+ this.#increment('dispatchLifecycleOwnershipWaits');
2781
+ this.#logger.warn?.('[factory] durable dispatch is leased by another publisher; waiting for lease release', {
2782
+ issue: record.issue.key,
2783
+ leaseRemainingMs: error.leaseUntilMs === undefined
2784
+ ? undefined
2785
+ : Math.max(0, error.leaseUntilMs - this.#clock.now()),
2786
+ retryMs: DISPATCH_LIFECYCLE_RETRY_MS,
2787
+ });
2788
+ }
2789
+ }
2754
2790
  else {
2755
2791
  this.#dispatchLifecycleCapacityWaitLogged.delete(key);
2792
+ this.#dispatchLifecycleOwnershipWaitLogged.delete(key);
2756
2793
  this.#logger.warn?.('[factory] durable dispatch lifecycle retry failed', {
2757
2794
  issue: record.issue.key,
2758
2795
  error: describeError(error).errorMessage,
@@ -2806,7 +2843,7 @@ export class FactoryLoop {
2806
2843
  if (!this.#dispatchLifecycleEpochs.has(key)) {
2807
2844
  const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
2808
2845
  if (!claim.acquired || !claim.lease) {
2809
- throw new Error(`durable dispatch ${lifecycle.issue.key} is still owned by another publisher`);
2846
+ throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
2810
2847
  }
2811
2848
  this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
2812
2849
  this.#scheduleDispatchLifecycleRenewal();
@@ -4678,6 +4715,20 @@ export class FactoryLoop {
4678
4715
  return;
4679
4716
  }
4680
4717
  }
4718
+ // The internal broker can retain a historical name after its cloud
4719
+ // presence is authoritatively offline (relay#1116-family). Reclaim that
4720
+ // supported fleet registration before attempting the deterministic
4721
+ // resume/respawn; otherwise recovery immediately collides with the dead
4722
+ // name and concludes useful work as terminal.
4723
+ if (tracingReconciledExit && this.#fleet.placementLocality === 'local') {
4724
+ if (!await this.#reclaimStaleLocalAgentName(record, name)) {
4725
+ // Do not immediately collide with a row the broker just refused to
4726
+ // release. Leave durable ownership intact and let the lifecycle retry
4727
+ // re-run reconciliation after broker pressure subsides.
4728
+ this.#scheduleDispatchLifecycleRetry(record);
4729
+ return;
4730
+ }
4731
+ }
4681
4732
  let recovered = false;
4682
4733
  if (tracked.sessionRef) {
4683
4734
  const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
@@ -4785,6 +4836,35 @@ export class FactoryLoop {
4785
4836
  this.#error(error, record.issue);
4786
4837
  }
4787
4838
  }
4839
+ async #reclaimStaleLocalAgentName(record, name) {
4840
+ let lastError;
4841
+ for (let attempt = 1; attempt <= STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS; attempt += 1) {
4842
+ try {
4843
+ await this.#fleet.release(name, 'reconciled-missing');
4844
+ this.#increment('staleLocalAgentNamesReclaimed');
4845
+ this.#logger.info?.('[factory] reclaimed stale local agent name before recovery', {
4846
+ issue: record.issue.key,
4847
+ name,
4848
+ attempt,
4849
+ });
4850
+ return true;
4851
+ }
4852
+ catch (error) {
4853
+ lastError = error;
4854
+ if (attempt < STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS) {
4855
+ await this.#clock.sleep(STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS * attempt);
4856
+ }
4857
+ }
4858
+ }
4859
+ this.#increment('staleLocalAgentNameReclaimFailures');
4860
+ this.#logger.warn?.('[factory] stale local agent name reclaim failed; deferring recovery', {
4861
+ issue: record.issue.key,
4862
+ name,
4863
+ attempts: STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS,
4864
+ error: lastError,
4865
+ });
4866
+ return false;
4867
+ }
4788
4868
  // Publish a PR from the implementer's committed branch when it exited without
4789
4869
  // opening one. Best-effort and idempotent: returns undefined when there is no
4790
4870
  // clone or nothing publishable (no branch / no commits ahead of base —