@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.
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +3 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +84 -40
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +14 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/environments/connection-registry.d.ts +20 -20
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +57 -19
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +10 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +90 -14
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-operation-timeout.d.ts +97 -0
- package/dist/mount/relayfile-operation-timeout.d.ts.map +1 -0
- package/dist/mount/relayfile-operation-timeout.js +149 -0
- package/dist/mount/relayfile-operation-timeout.js.map +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +117 -17
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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 = {
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
17191
|
+
if (isPassWideRelayfileFault(error))
|
|
17092
17192
|
throw error;
|
|
17093
17193
|
listErrors.push(error);
|
|
17094
17194
|
}
|