@agent-relay/factory 0.1.70 → 0.1.71

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.
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, resolve } from 'node:path';
4
4
  import { DEFAULT_READINESS_RECONCILE_TIMEOUT_MS, FactoryConfigSchema } from '../config/schema.js';
5
+ import { DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS, RelayfileOperationTimeoutError, relayfileTimeoutWithPhase, withRelayfileCallDeadline, } from '../mount/relayfile-operation-timeout.js';
5
6
  import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
6
7
  import { stateResolutionFromIds } from '../linear/state-resolver.js';
7
8
  import { GithubMergeGate, closeProbePr } from '../github/index.js';
@@ -204,9 +205,25 @@ const DISPATCH_FAILURE_HANDOFF_UNRESOLVED_TTL_MS = 5 * 60_000;
204
205
  const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
205
206
  const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
206
207
  const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
208
+ /** How far the cycle-level relayfile backstop sits above the transport deadline. */
209
+ const RELAYFILE_OPERATION_BACKSTOP_RATIO = 1.25;
207
210
  const DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000;
208
211
  const DISCOVERY_SWEEP_RENEW_MS = 30_000;
209
212
  const READINESS_RECONCILE_FAILURE_THRESHOLD = 3;
213
+ /**
214
+ * A relayfile fault a swallowing catch must not turn into "no result".
215
+ *
216
+ * A 429 already escaped every one of these (#297): it is a fact about the
217
+ * dependency, not about the one item being read, so folding it into an empty
218
+ * result reports a clean pass over work that was never served.
219
+ *
220
+ * A per-call timeout (#351) is the same fact in a worse costume. The read that
221
+ * hung will hang for the next item too, and `#githubIssuePaths` swallowing it
222
+ * turns a wedged dependency into a *successful* sweep that discovered zero
223
+ * issues — `consecutiveFailures: 0`, no `lastError`, nothing dispatched. That
224
+ * is the exact silence this bound exists to remove, so it escapes here too.
225
+ */
226
+ const isPassWideRelayfileFault = (error) => Boolean(relayfileOverload(error)) || error instanceof RelayfileOperationTimeoutError;
210
227
  const DISCOVERY_CHANGE_EVENT_LIMIT = 1_000;
211
228
  const DISCOVERY_OVERLOAD_BACKOFF_MAX_MS = 5 * 60_000;
212
229
  /** First rung of the ladder when the 429 advertises no `Retry-After`. */
@@ -492,6 +509,14 @@ export class FactoryLoop {
492
509
  #readinessReconcileInFlight;
493
510
  #readinessReconcileIntervalMs = 60_000;
494
511
  #readinessReconcileTimeoutMs = DEFAULT_READINESS_RECONCILE_TIMEOUT_MS;
512
+ /**
513
+ * Deadline for ONE relayfile call (#351).
514
+ *
515
+ * `#readinessReconcileTimeoutMs` bounds the sweep; this bounds the calls
516
+ * inside it. Only the second one can reach a dependency call that never
517
+ * returns: a deadline checked between awaits never regains control to check.
518
+ */
519
+ #relayfileOperationTimeoutMs = DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS;
495
520
  // Set for exactly as long as a sweep is running. `state` is derived from
496
521
  // this, so an in-flight pass can no longer masquerade as the last settled one.
497
522
  #readinessReconcileInFlightSinceMs;
@@ -652,6 +677,9 @@ export class FactoryLoop {
652
677
  constructor(config, ports) {
653
678
  this.#readOnly = ports.readOnly ?? false;
654
679
  this.#config = config;
680
+ // Also read here, not only in `#startLiveSubscription`: a standalone
681
+ // `runOnce()` never starts the live subscription and must still be bounded.
682
+ this.#relayfileOperationTimeoutMs = config.liveSubscription.relayfileOperationTimeoutMs;
655
683
  this.#mount = ports.mount;
656
684
  // Resolved role<->state mapping. The CLI injects a name-resolved, per-team
657
685
  // resolution via ports; fall back to one built from explicit stateIds plus
@@ -1234,6 +1262,7 @@ export class FactoryLoop {
1234
1262
  // `start()` overrides skip the schema's cross-field check, so re-apply its
1235
1263
  // floor here: a deadline under one interval would kill every pass.
1236
1264
  this.#readinessReconcileTimeoutMs = Math.max(options.reconcileTimeoutMs, options.reconcileIntervalMs);
1265
+ this.#relayfileOperationTimeoutMs = options.relayfileOperationTimeoutMs;
1237
1266
  this.#liveConnectStartedAtMs = this.#clock.now();
1238
1267
  this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
1239
1268
  const highWatermark = await this.#currentEventHighWatermark();
@@ -1389,6 +1418,8 @@ export class FactoryLoop {
1389
1418
  replaySkewMarginMs: overrides.replaySkewMarginMs ?? this.#config.liveSubscription.replaySkewMarginMs,
1390
1419
  reconcileIntervalMs: overrides.reconcileIntervalMs ?? this.#config.liveSubscription.reconcileIntervalMs,
1391
1420
  reconcileTimeoutMs: overrides.reconcileTimeoutMs ?? this.#config.liveSubscription.reconcileTimeoutMs,
1421
+ relayfileOperationTimeoutMs: overrides.relayfileOperationTimeoutMs
1422
+ ?? this.#config.liveSubscription.relayfileOperationTimeoutMs,
1392
1423
  };
1393
1424
  }
1394
1425
  async #currentEventCursor(limit) {
@@ -2491,7 +2522,13 @@ export class FactoryLoop {
2491
2522
  }
2492
2523
  const paths = await this.#readyIssuePaths();
2493
2524
  const orphanRecovery = issueSource === 'github'
2494
- ? await this.#githubOrphanRecoveryContext()
2525
+ ? await this.#githubOrphanRecoveryContext(dryRun)
2526
+ : undefined;
2527
+ // Preserved-not-reconciled is a fact the caller needs either way, but the
2528
+ // two causes are not the same event: a dry run skips the context by
2529
+ // design, a live sweep that has none failed to build one.
2530
+ const orphanRecoveryDegraded = issueSource === 'github' && orphanRecovery === undefined
2531
+ ? (dryRun ? 'dry-run' : 'context-unavailable')
2495
2532
  : undefined;
2496
2533
  const pulled = [];
2497
2534
  const triaged = [];
@@ -2748,7 +2785,15 @@ export class FactoryLoop {
2748
2785
  this.#reconciledGithubInProgress.delete(recoveredIdentity);
2749
2786
  }
2750
2787
  }
2751
- report = { pulled, triaged, dispatched, skipped, dryRun, slackDegraded: this.#slackDegraded };
2788
+ report = {
2789
+ pulled,
2790
+ triaged,
2791
+ dispatched,
2792
+ skipped,
2793
+ dryRun,
2794
+ slackDegraded: this.#slackDegraded,
2795
+ ...(orphanRecoveryDegraded ? { orphanRecoveryDegraded } : {}),
2796
+ };
2752
2797
  return report;
2753
2798
  }
2754
2799
  catch (error) {
@@ -2769,6 +2814,7 @@ export class FactoryLoop {
2769
2814
  dispatched: report.dispatched.length,
2770
2815
  skipped: report.skipped.length,
2771
2816
  slackDegraded: report.slackDegraded ?? false,
2817
+ orphanRecoveryDegraded: report.orphanRecoveryDegraded,
2772
2818
  relayfileWaitWarnings: (this.#counters.relayfileOperationWaitWarnings ?? 0) - relayfileWaitWarningsAtStart,
2773
2819
  relayfileSlowOperations: (this.#counters.relayfileSlowOperations ?? 0) - relayfileSlowOperationsAtStart,
2774
2820
  relayfileOperationFailures: (this.#counters.relayfileOperationFailures ?? 0) - relayfileOperationFailuresAtStart,
@@ -3039,7 +3085,7 @@ export class FactoryLoop {
3039
3085
  : { available: true, highWatermark };
3040
3086
  }
3041
3087
  catch (error) {
3042
- if (relayfileOverload(error))
3088
+ if (isPassWideRelayfileFault(error))
3043
3089
  throw error;
3044
3090
  this.#increment('discoveryHighWatermarkFailures');
3045
3091
  this.#logger.warn?.('[factory] discovery high-watermark unavailable; using a full sweep without caching', {
@@ -3062,7 +3108,7 @@ export class FactoryLoop {
3062
3108
  events = [...page.events].sort(compareDiscoveryEvents);
3063
3109
  }
3064
3110
  catch (error) {
3065
- if (relayfileOverload(error))
3111
+ if (isPassWideRelayfileFault(error))
3066
3112
  throw error;
3067
3113
  this.#logger.warn?.('[factory] discovery change feed unavailable; refreshing tree prefixes once', {
3068
3114
  error: describeError(error).errorMessage,
@@ -3124,7 +3170,34 @@ export class FactoryLoop {
3124
3170
  }
3125
3171
  }
3126
3172
  }
3127
- async #githubOrphanRecoveryContext() {
3173
+ /**
3174
+ * The safety context orphan recovery needs before it may release a leaked
3175
+ * `factory:in-progress` claim: who is online, which issues are actively
3176
+ * owned, and which durable lifecycle rows look abandoned.
3177
+ *
3178
+ * A dry run never builds it. `#reconcileOrphanedGithubInProgress` refuses to
3179
+ * release a claim under `dryRun` before it so much as looks at the context,
3180
+ * and `mayRecoverGithubOrphan` in `#performRunOnce` is itself `!dryRun` —
3181
+ * so the context was already provably unused on that path. Gathering it
3182
+ * anyway was not free: `fleet.roster()` mints this process's workspace
3183
+ * identity on demand, which is exactly what a read-only client refuses
3184
+ * (#343) and exactly what a dry run must not need. Deciding what a sweep
3185
+ * WOULD do must not require an identity to do it with.
3186
+ *
3187
+ * Returning `undefined` is therefore the honest answer for a dry run, not a
3188
+ * failure: the caller reports the sweep as `orphanRecoveryDegraded:
3189
+ * 'dry-run'` and preserves in-progress issues, which is what a dry run does
3190
+ * regardless. The failure counter and the warn below stay for the live path,
3191
+ * where an absent context IS a degradation.
3192
+ */
3193
+ async #githubOrphanRecoveryContext(dryRun) {
3194
+ if (dryRun) {
3195
+ this.#increment('githubOrphanRecoveryContextSkippedDryRun');
3196
+ this.#logger.info?.('[factory] dry run skipped the orphan-recovery safety context; in-progress issues are preserved', {
3197
+ reason: 'a dry run never releases an in-progress claim, so it needs no workspace identity to build one',
3198
+ });
3199
+ return undefined;
3200
+ }
3128
3201
  try {
3129
3202
  const [registry, roster, lifecycles, waitingClarifications] = await Promise.all([
3130
3203
  readFactoryInFlightRegistry(this.#config.loop.registryPath),
@@ -3701,7 +3774,17 @@ export class FactoryLoop {
3701
3774
  progressTimer.unref?.();
3702
3775
  }
3703
3776
  try {
3704
- const result = await fn();
3777
+ // The bound the 2026-08-23 wedge needed (#351). `#readinessReconcileTimeoutMs`
3778
+ // is 90 minutes and rejects only the WAIT — the sweep keeps its discovery
3779
+ // lease and the next cycle coalesces onto it — so a call that never
3780
+ // returns stopped dispatch for as long as the process lived. Bounding the
3781
+ // call instead makes the pass unwind, which releases the lease and lets
3782
+ // the next cycle actually start.
3783
+ //
3784
+ // Slightly above the transport's own deadline so the mount's cancellation
3785
+ // wins the race and reports the more precise error; this is the backstop
3786
+ // for mounts that cannot honour a signal.
3787
+ const result = await withRelayfileCallDeadline(operation, details.phase, this.#relayfileOperationBackstopMs(), fn);
3705
3788
  const elapsedMs = this.#elapsedSince(startedAtMs);
3706
3789
  const count = opts.count?.(result);
3707
3790
  if (opts.logComplete) {
@@ -3758,7 +3841,11 @@ export class FactoryLoop {
3758
3841
  error: describeError(error).errorMessage,
3759
3842
  });
3760
3843
  }
3761
- throw error;
3844
+ // The transport deadline is meant to win the race above, and the mount
3845
+ // does not know which phase it was serving. Without this, `lastError`
3846
+ // would name the call but not the context — one of many list/read sites
3847
+ // (codex on #354).
3848
+ throw relayfileTimeoutWithPhase(error, details.phase);
3762
3849
  }
3763
3850
  finally {
3764
3851
  if (progressTimer) {
@@ -3766,6 +3853,19 @@ export class FactoryLoop {
3766
3853
  }
3767
3854
  }
3768
3855
  }
3856
+ /**
3857
+ * The cycle-level backstop budget: the transport deadline plus a margin.
3858
+ *
3859
+ * Proportional rather than a flat grace so it holds at both ends of the
3860
+ * range — a five-minute production budget and a millisecond test budget both
3861
+ * leave the transport room to fire first.
3862
+ */
3863
+ #relayfileOperationBackstopMs() {
3864
+ const timeoutMs = this.#relayfileOperationTimeoutMs;
3865
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
3866
+ return undefined;
3867
+ return Math.ceil(timeoutMs * RELAYFILE_OPERATION_BACKSTOP_RATIO);
3868
+ }
3769
3869
  #elapsedSince(startedAtMs) {
3770
3870
  return Math.max(0, this.#clock.now() - startedAtMs);
3771
3871
  }
@@ -6571,7 +6671,7 @@ export class FactoryLoop {
6571
6671
  return [...issuePaths.values()].sort();
6572
6672
  }
6573
6673
  catch (error) {
6574
- if (relayfileOverload(error))
6674
+ if (isPassWideRelayfileFault(error))
6575
6675
  throw error;
6576
6676
  this.#githubIssuePathIndexReady = false;
6577
6677
  this.#increment('githubIssueListFailures');
@@ -6587,7 +6687,7 @@ export class FactoryLoop {
6587
6687
  parsed = parseJsonContent(content);
6588
6688
  }
6589
6689
  catch (error) {
6590
- if (relayfileOverload(error))
6690
+ if (isPassWideRelayfileFault(error))
6591
6691
  throw error;
6592
6692
  return undefined;
6593
6693
  }
@@ -6684,7 +6784,7 @@ export class FactoryLoop {
6684
6784
  this.#increment('githubIssueMirrorsCreated');
6685
6785
  }
6686
6786
  catch (error) {
6687
- if (relayfileOverload(error))
6787
+ if (isPassWideRelayfileFault(error))
6688
6788
  throw error;
6689
6789
  this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
6690
6790
  }
@@ -7488,7 +7588,7 @@ export class FactoryLoop {
7488
7588
  return resolvedIssue;
7489
7589
  }
7490
7590
  catch (error) {
7491
- if (relayfileOverload(error))
7591
+ if (isPassWideRelayfileFault(error))
7492
7592
  throw error;
7493
7593
  if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
7494
7594
  this.#increment('phantomSkipped');
@@ -8584,7 +8684,7 @@ export class FactoryLoop {
8584
8684
  paths = await this.#listRelayfileTree(root, 'exact-head PR confirmation');
8585
8685
  }
8586
8686
  catch (error) {
8587
- if (relayfileOverload(error))
8687
+ if (isPassWideRelayfileFault(error))
8588
8688
  throw error;
8589
8689
  continue;
8590
8690
  }
@@ -8854,7 +8954,7 @@ export class FactoryLoop {
8854
8954
  return await this.#listRelayfileTree(root, 'published PR confirmation');
8855
8955
  }
8856
8956
  catch (error) {
8857
- if (relayfileOverload(error))
8957
+ if (isPassWideRelayfileFault(error))
8858
8958
  throw error;
8859
8959
  return [];
8860
8960
  }
@@ -10436,7 +10536,7 @@ export class FactoryLoop {
10436
10536
  }
10437
10537
  }
10438
10538
  catch (error) {
10439
- if (relayfileOverload(error))
10539
+ if (isPassWideRelayfileFault(error))
10440
10540
  throw error;
10441
10541
  this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
10442
10542
  }
@@ -12982,7 +13082,7 @@ export class FactoryLoop {
12982
13082
  found.push(...tree.filter((path) => path.endsWith('.json') && numberSegment.test(path)));
12983
13083
  }
12984
13084
  catch (error) {
12985
- if (relayfileOverload(error))
13085
+ if (isPassWideRelayfileFault(error))
12986
13086
  throw error;
12987
13087
  // try the next root
12988
13088
  }
@@ -14161,7 +14261,7 @@ export class FactoryLoop {
14161
14261
  return await this.#listRelayfileTree(prefix, 'Slack identity lookup');
14162
14262
  }
14163
14263
  catch (error) {
14164
- if (relayfileOverload(error))
14264
+ if (isPassWideRelayfileFault(error))
14165
14265
  throw error;
14166
14266
  return [];
14167
14267
  }
@@ -17088,7 +17188,7 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, listTree
17088
17188
  paths.add(path);
17089
17189
  }
17090
17190
  catch (error) {
17091
- if (relayfileOverload(error))
17191
+ if (isPassWideRelayfileFault(error))
17092
17192
  throw error;
17093
17193
  listErrors.push(error);
17094
17194
  }