@agent-relay/factory 0.1.69 → 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`. */
@@ -484,11 +501,22 @@ export class FactoryLoop {
484
501
  #liveHeartbeatInFlight = false;
485
502
  #liveHeartbeatRefresh;
486
503
  #liveHeartbeatLastWriteMs = 0;
504
+ #heartbeatStartedAtMs;
505
+ #progressSequence = 0;
506
+ #lastProgressAtMs;
487
507
  #stoppingHeartbeatRefreshActive = false;
488
508
  #readinessReconcileTimer;
489
509
  #readinessReconcileInFlight;
490
510
  #readinessReconcileIntervalMs = 60_000;
491
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;
492
520
  // Set for exactly as long as a sweep is running. `state` is derived from
493
521
  // this, so an in-flight pass can no longer masquerade as the last settled one.
494
522
  #readinessReconcileInFlightSinceMs;
@@ -645,8 +673,13 @@ export class FactoryLoop {
645
673
  #started = false;
646
674
  #startMode;
647
675
  #stopping = false;
676
+ #readOnly;
648
677
  constructor(config, ports) {
678
+ this.#readOnly = ports.readOnly ?? false;
649
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;
650
683
  this.#mount = ports.mount;
651
684
  // Resolved role<->state mapping. The CLI injects a name-resolved, per-team
652
685
  // resolution via ports; fall back to one built from explicit stateIds plus
@@ -731,7 +764,14 @@ export class FactoryLoop {
731
764
  }
732
765
  },
733
766
  });
734
- this.#wireFleetEvents();
767
+ // Read-only commands consume no fleet events, and subscribing is not free:
768
+ // the relay client mints this process's workspace identity to open the
769
+ // socket. Wiring it here unconditionally is what made `factory status`
770
+ // register an agent it then abandoned before presence (factory-cloud#55).
771
+ // The live paths wire in `#start()` instead, where the subscription is
772
+ // actually used and the identity is meant to persist.
773
+ if (!this.#readOnly)
774
+ this.#wireFleetEvents();
735
775
  }
736
776
  async #resolveIntegrationInstructions() {
737
777
  if (this.#integrationInstructionsRefresh) {
@@ -895,6 +935,13 @@ export class FactoryLoop {
895
935
  return observedPhase;
896
936
  }
897
937
  async start(opts = {}) {
938
+ // A read-only instance was built with no fleet event wiring and, in the
939
+ // CLI, with a fleet client that cannot register. Starting one would be a
940
+ // half-live daemon; refuse rather than let a future caller reintroduce the
941
+ // side effect this mode exists to remove.
942
+ if (this.#readOnly) {
943
+ throw new Error('Factory was constructed read-only and cannot be started');
944
+ }
898
945
  if (this.#started) {
899
946
  return;
900
947
  }
@@ -1215,6 +1262,7 @@ export class FactoryLoop {
1215
1262
  // `start()` overrides skip the schema's cross-field check, so re-apply its
1216
1263
  // floor here: a deadline under one interval would kill every pass.
1217
1264
  this.#readinessReconcileTimeoutMs = Math.max(options.reconcileTimeoutMs, options.reconcileIntervalMs);
1265
+ this.#relayfileOperationTimeoutMs = options.relayfileOperationTimeoutMs;
1218
1266
  this.#liveConnectStartedAtMs = this.#clock.now();
1219
1267
  this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
1220
1268
  const highWatermark = await this.#currentEventHighWatermark();
@@ -1292,6 +1340,7 @@ export class FactoryLoop {
1292
1340
  }
1293
1341
  }
1294
1342
  async #startLiveHeartbeat() {
1343
+ this.#beginHeartbeatLifetime();
1295
1344
  this.#liveHeartbeatActive = true;
1296
1345
  await this.#writeLiveHeartbeat('running');
1297
1346
  this.#scheduleLiveHeartbeatRefresh();
@@ -1356,6 +1405,11 @@ export class FactoryLoop {
1356
1405
  await this.#writeLoopHeartbeat(this.#config.loop.heartbeatPath, this.#config.loop.registryPath, status, 0, 0);
1357
1406
  this.#liveHeartbeatLastWriteMs = this.#clock.now();
1358
1407
  }
1408
+ #beginHeartbeatLifetime() {
1409
+ this.#heartbeatStartedAtMs = this.#clock.now();
1410
+ this.#progressSequence = 0;
1411
+ this.#lastProgressAtMs = undefined;
1412
+ }
1359
1413
  #liveOptions(overrides) {
1360
1414
  return {
1361
1415
  transport: overrides.transport ?? this.#config.liveSubscription.transport,
@@ -1364,6 +1418,8 @@ export class FactoryLoop {
1364
1418
  replaySkewMarginMs: overrides.replaySkewMarginMs ?? this.#config.liveSubscription.replaySkewMarginMs,
1365
1419
  reconcileIntervalMs: overrides.reconcileIntervalMs ?? this.#config.liveSubscription.reconcileIntervalMs,
1366
1420
  reconcileTimeoutMs: overrides.reconcileTimeoutMs ?? this.#config.liveSubscription.reconcileTimeoutMs,
1421
+ relayfileOperationTimeoutMs: overrides.relayfileOperationTimeoutMs
1422
+ ?? this.#config.liveSubscription.relayfileOperationTimeoutMs,
1367
1423
  };
1368
1424
  }
1369
1425
  async #currentEventCursor(limit) {
@@ -2296,6 +2352,10 @@ export class FactoryLoop {
2296
2352
  leaseReleased = completed;
2297
2353
  if (!completed)
2298
2354
  throw new Error('discovery sweep lease was lost before completion');
2355
+ // This is the progress boundary consumed by deployment health. A timer
2356
+ // firing, a sweep starting, or a failed/deferred sweep must not move it.
2357
+ this.#progressSequence += 1;
2358
+ this.#lastProgressAtMs = this.#clock.now();
2299
2359
  this.#logger.info?.('[factory] discovery sweep checkpoint committed', {
2300
2360
  owner: claim.lease.owner,
2301
2361
  epoch: claim.lease.epoch,
@@ -2462,7 +2522,13 @@ export class FactoryLoop {
2462
2522
  }
2463
2523
  const paths = await this.#readyIssuePaths();
2464
2524
  const orphanRecovery = issueSource === 'github'
2465
- ? 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')
2466
2532
  : undefined;
2467
2533
  const pulled = [];
2468
2534
  const triaged = [];
@@ -2719,7 +2785,15 @@ export class FactoryLoop {
2719
2785
  this.#reconciledGithubInProgress.delete(recoveredIdentity);
2720
2786
  }
2721
2787
  }
2722
- 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
+ };
2723
2797
  return report;
2724
2798
  }
2725
2799
  catch (error) {
@@ -2740,6 +2814,7 @@ export class FactoryLoop {
2740
2814
  dispatched: report.dispatched.length,
2741
2815
  skipped: report.skipped.length,
2742
2816
  slackDegraded: report.slackDegraded ?? false,
2817
+ orphanRecoveryDegraded: report.orphanRecoveryDegraded,
2743
2818
  relayfileWaitWarnings: (this.#counters.relayfileOperationWaitWarnings ?? 0) - relayfileWaitWarningsAtStart,
2744
2819
  relayfileSlowOperations: (this.#counters.relayfileSlowOperations ?? 0) - relayfileSlowOperationsAtStart,
2745
2820
  relayfileOperationFailures: (this.#counters.relayfileOperationFailures ?? 0) - relayfileOperationFailuresAtStart,
@@ -3010,7 +3085,7 @@ export class FactoryLoop {
3010
3085
  : { available: true, highWatermark };
3011
3086
  }
3012
3087
  catch (error) {
3013
- if (relayfileOverload(error))
3088
+ if (isPassWideRelayfileFault(error))
3014
3089
  throw error;
3015
3090
  this.#increment('discoveryHighWatermarkFailures');
3016
3091
  this.#logger.warn?.('[factory] discovery high-watermark unavailable; using a full sweep without caching', {
@@ -3033,7 +3108,7 @@ export class FactoryLoop {
3033
3108
  events = [...page.events].sort(compareDiscoveryEvents);
3034
3109
  }
3035
3110
  catch (error) {
3036
- if (relayfileOverload(error))
3111
+ if (isPassWideRelayfileFault(error))
3037
3112
  throw error;
3038
3113
  this.#logger.warn?.('[factory] discovery change feed unavailable; refreshing tree prefixes once', {
3039
3114
  error: describeError(error).errorMessage,
@@ -3095,7 +3170,34 @@ export class FactoryLoop {
3095
3170
  }
3096
3171
  }
3097
3172
  }
3098
- 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
+ }
3099
3201
  try {
3100
3202
  const [registry, roster, lifecycles, waitingClarifications] = await Promise.all([
3101
3203
  readFactoryInFlightRegistry(this.#config.loop.registryPath),
@@ -3672,7 +3774,17 @@ export class FactoryLoop {
3672
3774
  progressTimer.unref?.();
3673
3775
  }
3674
3776
  try {
3675
- 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);
3676
3788
  const elapsedMs = this.#elapsedSince(startedAtMs);
3677
3789
  const count = opts.count?.(result);
3678
3790
  if (opts.logComplete) {
@@ -3729,7 +3841,11 @@ export class FactoryLoop {
3729
3841
  error: describeError(error).errorMessage,
3730
3842
  });
3731
3843
  }
3732
- 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);
3733
3849
  }
3734
3850
  finally {
3735
3851
  if (progressTimer) {
@@ -3737,6 +3853,19 @@ export class FactoryLoop {
3737
3853
  }
3738
3854
  }
3739
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
+ }
3740
3869
  #elapsedSince(startedAtMs) {
3741
3870
  return Math.max(0, this.#clock.now() - startedAtMs);
3742
3871
  }
@@ -3756,6 +3885,7 @@ export class FactoryLoop {
3756
3885
  const maxConsecutiveFailures = Math.min(5, Math.max(1, Math.trunc(opts.maxConsecutiveFailures ?? this.#config.loop.maxConsecutiveFailures)));
3757
3886
  const heartbeatPath = opts.heartbeatPath ?? this.#config.loop.heartbeatPath;
3758
3887
  const registryPath = opts.registryPath ?? this.#config.loop.registryPath;
3888
+ this.#beginHeartbeatLifetime();
3759
3889
  this.#loopReapPaths = { heartbeatPath, registryPath };
3760
3890
  const reports = [];
3761
3891
  let consecutiveFailures = 0;
@@ -6541,7 +6671,7 @@ export class FactoryLoop {
6541
6671
  return [...issuePaths.values()].sort();
6542
6672
  }
6543
6673
  catch (error) {
6544
- if (relayfileOverload(error))
6674
+ if (isPassWideRelayfileFault(error))
6545
6675
  throw error;
6546
6676
  this.#githubIssuePathIndexReady = false;
6547
6677
  this.#increment('githubIssueListFailures');
@@ -6557,7 +6687,7 @@ export class FactoryLoop {
6557
6687
  parsed = parseJsonContent(content);
6558
6688
  }
6559
6689
  catch (error) {
6560
- if (relayfileOverload(error))
6690
+ if (isPassWideRelayfileFault(error))
6561
6691
  throw error;
6562
6692
  return undefined;
6563
6693
  }
@@ -6654,7 +6784,7 @@ export class FactoryLoop {
6654
6784
  this.#increment('githubIssueMirrorsCreated');
6655
6785
  }
6656
6786
  catch (error) {
6657
- if (relayfileOverload(error))
6787
+ if (isPassWideRelayfileFault(error))
6658
6788
  throw error;
6659
6789
  this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
6660
6790
  }
@@ -7205,13 +7335,28 @@ export class FactoryLoop {
7205
7335
  }
7206
7336
  async #writeLoopHeartbeat(path, registryPath, status, iteration, maxIterations) {
7207
7337
  const updatedAtMs = this.#clock.now();
7338
+ this.#heartbeatStartedAtMs ??= updatedAtMs;
7208
7339
  const heartbeat = {
7209
7340
  pid: process.pid,
7210
7341
  status,
7342
+ source: maxIterations === 0 ? 'live-timer' : 'bounded-loop',
7211
7343
  iteration,
7212
7344
  maxIterations,
7345
+ startedAt: new Date(this.#heartbeatStartedAtMs).toISOString(),
7346
+ startedAtMs: this.#heartbeatStartedAtMs,
7347
+ progressContract: 'discovery-sweep-v1',
7213
7348
  updatedAt: new Date(updatedAtMs).toISOString(),
7214
7349
  updatedAtMs,
7350
+ ...(this.#lastProgressAtMs !== undefined
7351
+ ? {
7352
+ progress: {
7353
+ sequence: this.#progressSequence,
7354
+ operation: 'discovery-sweep',
7355
+ updatedAt: new Date(this.#lastProgressAtMs).toISOString(),
7356
+ updatedAtMs: this.#lastProgressAtMs,
7357
+ },
7358
+ }
7359
+ : {}),
7215
7360
  registryPath,
7216
7361
  eventListener: this.#eventListenerStatus(),
7217
7362
  readinessReconcile: this.#readinessReconcileStatus(),
@@ -7443,7 +7588,7 @@ export class FactoryLoop {
7443
7588
  return resolvedIssue;
7444
7589
  }
7445
7590
  catch (error) {
7446
- if (relayfileOverload(error))
7591
+ if (isPassWideRelayfileFault(error))
7447
7592
  throw error;
7448
7593
  if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
7449
7594
  this.#increment('phantomSkipped');
@@ -8539,7 +8684,7 @@ export class FactoryLoop {
8539
8684
  paths = await this.#listRelayfileTree(root, 'exact-head PR confirmation');
8540
8685
  }
8541
8686
  catch (error) {
8542
- if (relayfileOverload(error))
8687
+ if (isPassWideRelayfileFault(error))
8543
8688
  throw error;
8544
8689
  continue;
8545
8690
  }
@@ -8809,7 +8954,7 @@ export class FactoryLoop {
8809
8954
  return await this.#listRelayfileTree(root, 'published PR confirmation');
8810
8955
  }
8811
8956
  catch (error) {
8812
- if (relayfileOverload(error))
8957
+ if (isPassWideRelayfileFault(error))
8813
8958
  throw error;
8814
8959
  return [];
8815
8960
  }
@@ -10391,7 +10536,7 @@ export class FactoryLoop {
10391
10536
  }
10392
10537
  }
10393
10538
  catch (error) {
10394
- if (relayfileOverload(error))
10539
+ if (isPassWideRelayfileFault(error))
10395
10540
  throw error;
10396
10541
  this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
10397
10542
  }
@@ -12937,7 +13082,7 @@ export class FactoryLoop {
12937
13082
  found.push(...tree.filter((path) => path.endsWith('.json') && numberSegment.test(path)));
12938
13083
  }
12939
13084
  catch (error) {
12940
- if (relayfileOverload(error))
13085
+ if (isPassWideRelayfileFault(error))
12941
13086
  throw error;
12942
13087
  // try the next root
12943
13088
  }
@@ -14116,7 +14261,7 @@ export class FactoryLoop {
14116
14261
  return await this.#listRelayfileTree(prefix, 'Slack identity lookup');
14117
14262
  }
14118
14263
  catch (error) {
14119
- if (relayfileOverload(error))
14264
+ if (isPassWideRelayfileFault(error))
14120
14265
  throw error;
14121
14266
  return [];
14122
14267
  }
@@ -17043,7 +17188,7 @@ const resolveIssuePrFromMount = async (mount, config, issue, opts = {}, listTree
17043
17188
  paths.add(path);
17044
17189
  }
17045
17190
  catch (error) {
17046
- if (relayfileOverload(error))
17191
+ if (isPassWideRelayfileFault(error))
17047
17192
  throw error;
17048
17193
  listErrors.push(error);
17049
17194
  }