@agent-relay/factory 0.1.74 → 0.1.76
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/config/schema.d.ts +110 -2
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +61 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +22 -3
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/orchestrator/batch-tracker.d.ts +9 -0
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +539 -80
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/sweep-budget.d.ts +137 -0
- package/dist/orchestrator/sweep-budget.d.ts.map +1 -0
- package/dist/orchestrator/sweep-budget.js +208 -0
- package/dist/orchestrator/sweep-budget.js.map +1 -0
- package/dist/ports/state.d.ts +9 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +1 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +2 -2
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
5
|
-
import { DEFAULT_READINESS_RECONCILE_TIMEOUT_MS, FactoryConfigSchema } from '../config/schema.js';
|
|
5
|
+
import { DEFAULT_DISCOVERY_SWEEP_BUDGET_MS, DEFAULT_READINESS_RECONCILE_TIMEOUT_MS, FactoryConfigSchema, resolvedSweepBudgetMs, } from '../config/schema.js';
|
|
6
6
|
import { DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS, RelayfileOperationTimeoutError, relayfileTimeoutWithPhase, withRelayfileCallDeadline, } from '../mount/relayfile-operation-timeout.js';
|
|
7
7
|
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
8
8
|
import { stateResolutionFromIds } from '../linear/state-resolver.js';
|
|
@@ -11,8 +11,9 @@ import { factoryGithubIssueCommentDraftName, isFactoryGithubIssueCommentDraftNam
|
|
|
11
11
|
import { VerificationPipeline } from '../environments/verification-pipeline.js';
|
|
12
12
|
import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
|
|
13
13
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
14
|
+
import { DISCOVERY_SWEEP_TEARDOWN_TIMEOUT_MS, DiscoverySweepBudgetExceededError, startDiscoverySweepBudget, withSweepTeardownDeadline, } from './sweep-budget.js';
|
|
14
15
|
import { dispatchHandedOffToBabysitters, dispatchLifecycleOccupiesSlot, dispatchPhaseOccupiesSlot, } from '../state/dispatch-lifecycle-slot.js';
|
|
15
|
-
import { branchImplementsIssue, containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue, prBodyDisclaimsClosing, prClosureAuthority } from '../issue-key-match.js';
|
|
16
|
+
import { ISSUE_KEY_PARTS, branchImplementsIssue, containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue, prBodyDisclaimsClosing, prClosureAuthority } from '../issue-key-match.js';
|
|
16
17
|
import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
|
|
17
18
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
18
19
|
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
@@ -557,6 +558,31 @@ export class FactoryLoop {
|
|
|
557
558
|
* returns: a deadline checked between awaits never regains control to check.
|
|
558
559
|
*/
|
|
559
560
|
#relayfileOperationTimeoutMs = DEFAULT_RELAYFILE_OPERATION_TIMEOUT_MS;
|
|
561
|
+
/**
|
|
562
|
+
* Aggregate budget for ONE sweep (#372).
|
|
563
|
+
*
|
|
564
|
+
* The third bound on this path and the only transport-agnostic one.
|
|
565
|
+
* `#relayfileOperationTimeoutMs` bounds one relayfile call and cannot see the
|
|
566
|
+
* retry loop around it; `#readinessReconcileTimeoutMs` bounds the *wait* and
|
|
567
|
+
* leaves `runOnce()` running for the next cycle to coalesce onto. This is
|
|
568
|
+
* charged against one timer for the whole pass, so however many calls,
|
|
569
|
+
* retries and transports the sweep is spread across, it cannot outlive this.
|
|
570
|
+
*
|
|
571
|
+
* Held at `min(configured, #readinessReconcileTimeoutMs)`: a budget looser
|
|
572
|
+
* than the wait would let the wait give up first, which is the behaviour it
|
|
573
|
+
* exists to remove.
|
|
574
|
+
*/
|
|
575
|
+
#discoverySweepBudgetMs = DEFAULT_DISCOVERY_SWEEP_BUDGET_MS;
|
|
576
|
+
/**
|
|
577
|
+
* The budgets of every sweep currently in flight.
|
|
578
|
+
*
|
|
579
|
+
* `stop()` drains the sweep it started (see `#readinessReconcileAbandonedWait`
|
|
580
|
+
* below), so a wedged sweep holds shutdown open for the whole budget — 90
|
|
581
|
+
* minutes at the default, and unbounded before this budget existed. A set
|
|
582
|
+
* rather than one field because a live sweep and a mismatched dry-run one can
|
|
583
|
+
* be in flight at the same time.
|
|
584
|
+
*/
|
|
585
|
+
#discoverySweepBudgets = new Set();
|
|
560
586
|
// Set for exactly as long as a sweep is running. `state` is derived from
|
|
561
587
|
// this, so an in-flight pass can no longer masquerade as the last settled one.
|
|
562
588
|
#readinessReconcileInFlightSinceMs;
|
|
@@ -772,6 +798,7 @@ export class FactoryLoop {
|
|
|
772
798
|
// Also read here, not only in `#startLiveSubscription`: a standalone
|
|
773
799
|
// `runOnce()` never starts the live subscription and must still be bounded.
|
|
774
800
|
this.#relayfileOperationTimeoutMs = config.liveSubscription.relayfileOperationTimeoutMs;
|
|
801
|
+
this.#discoverySweepBudgetMs = resolvedSweepBudgetMs(config.liveSubscription.sweepBudgetMs, config.liveSubscription.reconcileTimeoutMs);
|
|
775
802
|
this.#mount = ports.mount;
|
|
776
803
|
// Resolved role<->state mapping. The CLI injects a name-resolved, per-team
|
|
777
804
|
// resolution via ports; fall back to one built from explicit stateIds plus
|
|
@@ -1202,31 +1229,50 @@ export class FactoryLoop {
|
|
|
1202
1229
|
clearTimeout(this.#heldAgentDeadlineTimer);
|
|
1203
1230
|
this.#heldAgentDeadlineTimer = undefined;
|
|
1204
1231
|
this.#heldAgentDeadlineDueAtMs = undefined;
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1232
|
+
// #372: the drain below is unbounded in the one case that matters — a
|
|
1233
|
+
// wedged sweep — so shutdown inherits the sweep budget, 90 minutes at the
|
|
1234
|
+
// default. Spending it after one teardown window bounds shutdown without
|
|
1235
|
+
// discarding a sweep that was about to finish.
|
|
1236
|
+
//
|
|
1237
|
+
// Armed BEFORE the first shutdown await, not just before the sweep drain
|
|
1238
|
+
// (cubic-dev-ai, #374 review). The grace is a timer, so arming it costs
|
|
1239
|
+
// nothing and starts the clock at the moment shutdown starts; arming it
|
|
1240
|
+
// after `#heldAgentDeadlineSweepInFlight` made the lever's start depend on
|
|
1241
|
+
// an UNRELATED in-flight sweep finishing first, so a slow held-agent pass
|
|
1242
|
+
// simply added its own latency to the wedged discovery sweep's reprieve —
|
|
1243
|
+
// in the limit the lever never arms at all, which is the bound this whole
|
|
1244
|
+
// change exists to provide.
|
|
1245
|
+
const releaseSweepBudgetGrace = this.#cutSweepBudgetsShortForStop();
|
|
1246
|
+
try {
|
|
1247
|
+
await this.#heldAgentDeadlineSweepInFlight;
|
|
1248
|
+
for (const timer of this.#dispatchLifecycleRetryTimers.values())
|
|
1249
|
+
clearTimeout(timer);
|
|
1250
|
+
this.#dispatchLifecycleRetryTimers.clear();
|
|
1251
|
+
this.#abandonedDispatchReasons.clear();
|
|
1252
|
+
this.#dispatchLifecycleCapacityWaits.clear();
|
|
1253
|
+
this.#dispatchLifecycleOwnershipWaitLogged.clear();
|
|
1254
|
+
if (this.#completionSweepTimer)
|
|
1255
|
+
clearTimeout(this.#completionSweepTimer);
|
|
1256
|
+
this.#completionSweepTimer = undefined;
|
|
1257
|
+
if (this.#readinessReconcileTimer)
|
|
1258
|
+
clearTimeout(this.#readinessReconcileTimer);
|
|
1259
|
+
this.#readinessReconcileTimer = undefined;
|
|
1260
|
+
if (this.#previewSweepTimer)
|
|
1261
|
+
clearTimeout(this.#previewSweepTimer);
|
|
1262
|
+
this.#previewSweepTimer = undefined;
|
|
1263
|
+
await this.#readinessReconcileInFlight;
|
|
1264
|
+
// #301 review: the deadline ends the *wait*, so `#readinessReconcileInFlight`
|
|
1265
|
+
// can settle with its `runOnce()` still live. Shutdown releases dispatch
|
|
1266
|
+
// lifecycle leases and disposes ports below, and `#isPassFatalFailure` only
|
|
1267
|
+
// fences a stopping sweep once something in it throws — so a sweep whose
|
|
1268
|
+
// dependency recovers cleanly would otherwise dispatch through torn-down
|
|
1269
|
+
// state. Draining here restores exactly the pre-deadline shutdown contract:
|
|
1270
|
+
// stop() outlives the sweep it started.
|
|
1271
|
+
await this.#readinessReconcileAbandonedWait;
|
|
1272
|
+
}
|
|
1273
|
+
finally {
|
|
1274
|
+
releaseSweepBudgetGrace();
|
|
1275
|
+
}
|
|
1230
1276
|
await this.#previewSweepInFlight;
|
|
1231
1277
|
this.#stoppingHeartbeatRefreshActive = await this.#stopLiveHeartbeat('stopping');
|
|
1232
1278
|
try {
|
|
@@ -1613,6 +1659,10 @@ export class FactoryLoop {
|
|
|
1613
1659
|
// floor here: a deadline under one interval would kill every pass.
|
|
1614
1660
|
this.#readinessReconcileTimeoutMs = Math.max(options.reconcileTimeoutMs, options.reconcileIntervalMs);
|
|
1615
1661
|
this.#relayfileOperationTimeoutMs = options.relayfileOperationTimeoutMs;
|
|
1662
|
+
// Same re-application as the deadline above: `start()` overrides bypass the
|
|
1663
|
+
// schema's cross-field check, and a budget looser than the wait is not a
|
|
1664
|
+
// budget.
|
|
1665
|
+
this.#discoverySweepBudgetMs = resolvedSweepBudgetMs(options.sweepBudgetMs, this.#readinessReconcileTimeoutMs);
|
|
1616
1666
|
this.#liveConnectStartedAtMs = this.#clock.now();
|
|
1617
1667
|
this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
|
|
1618
1668
|
const highWatermark = await this.#currentEventHighWatermark();
|
|
@@ -1776,6 +1826,7 @@ export class FactoryLoop {
|
|
|
1776
1826
|
reconcileTimeoutMs: overrides.reconcileTimeoutMs ?? this.#config.liveSubscription.reconcileTimeoutMs,
|
|
1777
1827
|
relayfileOperationTimeoutMs: overrides.relayfileOperationTimeoutMs
|
|
1778
1828
|
?? this.#config.liveSubscription.relayfileOperationTimeoutMs,
|
|
1829
|
+
sweepBudgetMs: overrides.sweepBudgetMs ?? this.#config.liveSubscription.sweepBudgetMs,
|
|
1779
1830
|
};
|
|
1780
1831
|
}
|
|
1781
1832
|
async #currentEventCursor(limit) {
|
|
@@ -2638,12 +2689,131 @@ export class FactoryLoop {
|
|
|
2638
2689
|
throw contextualError('Factory dispatch paused because the fleet control plane is unavailable', error);
|
|
2639
2690
|
}
|
|
2640
2691
|
}
|
|
2692
|
+
/**
|
|
2693
|
+
* One sweep, under one aggregate budget (#372).
|
|
2694
|
+
*
|
|
2695
|
+
* The budget wraps the fence rather than the fence's caller, and that is the
|
|
2696
|
+
* whole point. #296's deadline lives in `#runOnceWithReadinessDeadline`,
|
|
2697
|
+
* outside `runOnce()`: expiry rejects the wait and leaves the sweep running,
|
|
2698
|
+
* so the next cycle coalesces onto the same wedged promise (`runOnce()`, the
|
|
2699
|
+
* `#runOnceInFlight` branch) and the daemon never recovers. Expiring in HERE
|
|
2700
|
+
* unwinds the body below, which releases the discovery lease on its way out
|
|
2701
|
+
* and lets `runOnce()` clear `#runOnceInFlight` — so the next cycle claims a
|
|
2702
|
+
* fresh lease and runs clean.
|
|
2703
|
+
*
|
|
2704
|
+
* See `sweep-budget.ts` for what the mechanism can and cannot interrupt. In
|
|
2705
|
+
* short: it abandons the in-flight await, it does not cancel the call.
|
|
2706
|
+
*/
|
|
2641
2707
|
async #runOnceWithDiscoveryFence(opts) {
|
|
2708
|
+
const budget = startDiscoverySweepBudget(this.#discoverySweepBudgetMs);
|
|
2709
|
+
this.#discoverySweepBudgets.add(budget);
|
|
2710
|
+
const sweepStartedAtMs = this.#clock.now();
|
|
2711
|
+
// A sweep that starts while shutdown is already draining would otherwise
|
|
2712
|
+
// get a full fresh budget to hold `stop()` open with.
|
|
2713
|
+
if (this.#stopping)
|
|
2714
|
+
budget.expire();
|
|
2715
|
+
try {
|
|
2716
|
+
return await this.#runDiscoverySweep(opts, budget);
|
|
2717
|
+
}
|
|
2718
|
+
catch (error) {
|
|
2719
|
+
if (error instanceof DiscoverySweepBudgetExceededError) {
|
|
2720
|
+
this.#increment('discoverySweepBudgetExceeded');
|
|
2721
|
+
this.#logger.error?.('[factory] discovery sweep aborted at its aggregate budget', {
|
|
2722
|
+
budgetMs: error.budgetMs,
|
|
2723
|
+
// The await the sweep was abandoned on. The one diagnostic no
|
|
2724
|
+
// per-call bound can produce once the sweep is already wedged: it
|
|
2725
|
+
// says WHICH transport this wedge is on without anyone having to
|
|
2726
|
+
// guess which layer to bound next.
|
|
2727
|
+
phase: error.phase,
|
|
2728
|
+
elapsedMs: this.#elapsedSince(sweepStartedAtMs),
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
throw error;
|
|
2732
|
+
}
|
|
2733
|
+
finally {
|
|
2734
|
+
this.#discoverySweepBudgets.delete(budget);
|
|
2735
|
+
budget.dispose();
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Cut every in-flight sweep's budget short once shutdown starts draining.
|
|
2740
|
+
*
|
|
2741
|
+
* `stop()` deliberately outlives the sweep it started (#301), so a wedged
|
|
2742
|
+
* sweep makes shutdown as long as the budget. The grace window is what keeps
|
|
2743
|
+
* an ordinary restart from throwing away a sweep that was about to commit;
|
|
2744
|
+
* after it, expiry routes the sweep into exactly the abort path a real expiry
|
|
2745
|
+
* takes — lease released, teardown bounded — instead of holding the process.
|
|
2746
|
+
*
|
|
2747
|
+
* Returns the cancel for the grace timer. Always call it: on a prompt
|
|
2748
|
+
* shutdown there is nothing to cut short.
|
|
2749
|
+
*/
|
|
2750
|
+
#cutSweepBudgetsShortForStop() {
|
|
2751
|
+
if (this.#discoverySweepBudgets.size === 0)
|
|
2752
|
+
return () => undefined;
|
|
2753
|
+
const timer = setTimeout(() => {
|
|
2754
|
+
for (const budget of this.#discoverySweepBudgets) {
|
|
2755
|
+
if (budget.expired())
|
|
2756
|
+
continue;
|
|
2757
|
+
this.#increment('discoverySweepBudgetsCutShortForStop');
|
|
2758
|
+
this.#logger.warn?.('[factory] shutdown is draining a sweep; spending its budget now', {
|
|
2759
|
+
graceMs: STOP_TEARDOWN_TIMEOUT_MS,
|
|
2760
|
+
budgetMs: budget.budgetMs,
|
|
2761
|
+
});
|
|
2762
|
+
budget.expire();
|
|
2763
|
+
}
|
|
2764
|
+
}, STOP_TEARDOWN_TIMEOUT_MS);
|
|
2765
|
+
timer.unref?.();
|
|
2766
|
+
return () => clearTimeout(timer);
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Claim the sweep lease under the budget, compensating for a claim that
|
|
2770
|
+
* lands after we stopped waiting for it.
|
|
2771
|
+
*
|
|
2772
|
+
* The budget abandons the wait, not the call, so the store can still persist
|
|
2773
|
+
* a lease for a claim this sweep has already given up on — and that lease
|
|
2774
|
+
* would be held by an owner that will never renew, commit or release it, so
|
|
2775
|
+
* every later sweep defers until it expires. It is self-expiring and a later
|
|
2776
|
+
* sweep reclaims it as an orphan, which makes this a latency fix rather than
|
|
2777
|
+
* a correctness one; the latency is one whole lease window with no discovery,
|
|
2778
|
+
* which is the thing this PR exists to stop paying.
|
|
2779
|
+
*/
|
|
2780
|
+
async #claimDiscoverySweepUnderBudget(budget) {
|
|
2781
|
+
// Issued INSIDE the budget callback, so a budget that is already spent
|
|
2782
|
+
// rejects the phase without opening a lease it could only hand straight
|
|
2783
|
+
// back. The handle is kept out here because the compensation below needs
|
|
2784
|
+
// the promise the wait was abandoned on.
|
|
2785
|
+
let claim;
|
|
2786
|
+
try {
|
|
2787
|
+
return await budget.run('discovery-lease-claim', () => {
|
|
2788
|
+
claim = this.#state.claimDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, this.#clock.now(), DISCOVERY_SWEEP_LEASE_MS);
|
|
2789
|
+
return claim;
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
catch (error) {
|
|
2793
|
+
if (!(error instanceof DiscoverySweepBudgetExceededError) || claim === undefined)
|
|
2794
|
+
throw error;
|
|
2795
|
+
void claim.then(async (late) => {
|
|
2796
|
+
if (!late.acquired || !late.lease)
|
|
2797
|
+
return;
|
|
2798
|
+
this.#increment('discoverySweepStrandedClaimsReleased');
|
|
2799
|
+
this.#logger.warn?.('[factory] releasing a discovery lease that was claimed after the sweep budget expired', {
|
|
2800
|
+
epoch: late.lease.epoch,
|
|
2801
|
+
budgetMs: error.budgetMs,
|
|
2802
|
+
});
|
|
2803
|
+
await this.#sweepTeardownStep('stranded discovery lease release', () => this.#state.releaseDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, late.lease.epoch));
|
|
2804
|
+
}, () => undefined).catch(() => undefined);
|
|
2805
|
+
throw error;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
async #runDiscoverySweep(opts, budget) {
|
|
2642
2809
|
const sweepStartedAtMs = this.#clock.now();
|
|
2643
2810
|
if (!(opts.dryRun ?? this.#config.dryRun)) {
|
|
2644
|
-
|
|
2811
|
+
// Under the budget, and first, because this is where the 2026-08-25
|
|
2812
|
+
// 07:52:59Z wedge sat: a pre-claim probe on a transport neither #351 nor
|
|
2813
|
+
// #368 covers. The budget does not care which one it is.
|
|
2814
|
+
await budget.run('fleet-control-plane-probe', () => this.#assertFleetControlPlaneAvailable());
|
|
2645
2815
|
}
|
|
2646
|
-
let claim = await this.#
|
|
2816
|
+
let claim = await this.#claimDiscoverySweepUnderBudget(budget);
|
|
2647
2817
|
if (!claim.acquired && claim.reason === 'backoff') {
|
|
2648
2818
|
const delayMs = Math.max(0, claim.state.backoffUntilMs - this.#clock.now());
|
|
2649
2819
|
this.#increment('discoveryBackoffWaits');
|
|
@@ -2652,8 +2822,8 @@ export class FactoryLoop {
|
|
|
2652
2822
|
backoffUntilMs: claim.state.backoffUntilMs,
|
|
2653
2823
|
consecutiveOverloads: claim.state.consecutiveOverloads,
|
|
2654
2824
|
});
|
|
2655
|
-
await this.#clock.sleep(delayMs);
|
|
2656
|
-
claim = await this.#
|
|
2825
|
+
await budget.run('discovery-backoff-wait', () => this.#clock.sleep(delayMs));
|
|
2826
|
+
claim = await this.#claimDiscoverySweepUnderBudget(budget);
|
|
2657
2827
|
}
|
|
2658
2828
|
if (!claim.acquired || !claim.lease) {
|
|
2659
2829
|
this.#increment('discoverySweepsSkippedInFlight');
|
|
@@ -2698,13 +2868,13 @@ export class FactoryLoop {
|
|
|
2698
2868
|
this.#startDiscoverySweepRenewal(claim.lease.epoch);
|
|
2699
2869
|
let leaseReleased = false;
|
|
2700
2870
|
try {
|
|
2701
|
-
this.#discoverySession = await this.#prepareDiscoverySession(claim);
|
|
2871
|
+
this.#discoverySession = await budget.run('discovery-session', () => this.#prepareDiscoverySession(claim));
|
|
2702
2872
|
// #297: a 429 raised anywhere in the sweep used to latch and be rethrown
|
|
2703
2873
|
// here, discarding a completed pass — every issue read, every dispatch —
|
|
2704
2874
|
// because of one transient shed operation. The work this sweep did is
|
|
2705
2875
|
// now kept instead, and the ratchet below records that the dependency is
|
|
2706
2876
|
// shedding but still serving.
|
|
2707
|
-
const report = await this.#performRunOnce(opts);
|
|
2877
|
+
const report = await budget.run('run-once', () => this.#performRunOnce(opts, budget));
|
|
2708
2878
|
// The exception, and the reason skipping shed units cannot make a sweep
|
|
2709
2879
|
// unconditionally green: a sweep that was shed AND got no work unit
|
|
2710
2880
|
// through accomplished nothing. There is no progress to preserve, and
|
|
@@ -2715,17 +2885,20 @@ export class FactoryLoop {
|
|
|
2715
2885
|
if (this.#discoveryOverloadError !== undefined && !this.#discoverySweepProgress) {
|
|
2716
2886
|
throw this.#discoveryOverloadError;
|
|
2717
2887
|
}
|
|
2718
|
-
const checkpoint = await this.#finalizeDiscoveryCheckpoint();
|
|
2888
|
+
const checkpoint = await budget.run('discovery-checkpoint', () => this.#finalizeDiscoveryCheckpoint());
|
|
2719
2889
|
// Do not clear the durable lease while a renewal can still be waiting on
|
|
2720
2890
|
// the same state-file lock. A late renewal that observes the completed
|
|
2721
2891
|
// (lease-less) checkpoint is a false lease-loss signal and can poison an
|
|
2722
2892
|
// otherwise successful reconcile cycle.
|
|
2723
|
-
await this.#stopDiscoverySweepRenewal();
|
|
2893
|
+
await budget.run('discovery-renewal-stop', () => this.#stopDiscoverySweepRenewal());
|
|
2724
2894
|
if (this.#discoverySweepLeaseLost) {
|
|
2725
2895
|
throw new Error('discovery sweep lease was lost before checkpoint commit');
|
|
2726
2896
|
}
|
|
2727
2897
|
const residual = this.#discoveryOverloadOutcome(claim.state.consecutiveOverloads, 'committed');
|
|
2728
|
-
|
|
2898
|
+
// Under the budget too. A hung commit is the same class of wedge as a
|
|
2899
|
+
// hung read, and the epoch guard in the store makes a late one a no-op:
|
|
2900
|
+
// the teardown below has already released this epoch's lease.
|
|
2901
|
+
const completed = await budget.run('discovery-commit', () => this.#commitDiscoverySweep(claim.lease.epoch, checkpoint, residual));
|
|
2729
2902
|
leaseReleased = completed;
|
|
2730
2903
|
if (!completed)
|
|
2731
2904
|
throw new Error('discovery sweep lease was lost before completion');
|
|
@@ -2742,11 +2915,14 @@ export class FactoryLoop {
|
|
|
2742
2915
|
return report;
|
|
2743
2916
|
}
|
|
2744
2917
|
catch (error) {
|
|
2745
|
-
await this.#stopDiscoverySweepRenewal();
|
|
2918
|
+
await this.#sweepTeardownStep('discovery sweep renewal stop', () => this.#stopDiscoverySweepRenewal());
|
|
2746
2919
|
const overload = relayfileOverload(error);
|
|
2747
2920
|
if (overload) {
|
|
2748
2921
|
const outcome = this.#discoveryOverloadOutcome(claim.state.consecutiveOverloads, 'aborted', error);
|
|
2749
|
-
|
|
2922
|
+
// Bounded for the same reason the release below is: this is the other
|
|
2923
|
+
// path that hands the lease back, and an unbounded one would hold
|
|
2924
|
+
// `#runOnceInFlight` open past the budget that just expired.
|
|
2925
|
+
leaseReleased = await withSweepTeardownDeadline(DISCOVERY_SWEEP_TEARDOWN_TIMEOUT_MS, () => this.#state.deferDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, claim.lease.epoch, outcome.backoffUntilMs, outcome.consecutiveOverloads)) ?? false;
|
|
2750
2926
|
this.#logger.warn?.('[factory] Relayfile discovery overloaded; backing off before another sweep', {
|
|
2751
2927
|
status: overload.status,
|
|
2752
2928
|
reason: overload.reason,
|
|
@@ -2767,7 +2943,7 @@ export class FactoryLoop {
|
|
|
2767
2943
|
throw error;
|
|
2768
2944
|
}
|
|
2769
2945
|
finally {
|
|
2770
|
-
await this.#stopDiscoverySweepRenewal();
|
|
2946
|
+
await this.#sweepTeardownStep('discovery sweep renewal stop', () => this.#stopDiscoverySweepRenewal());
|
|
2771
2947
|
this.#discoverySession = undefined;
|
|
2772
2948
|
this.#discoverySweepEpoch = undefined;
|
|
2773
2949
|
this.#discoverySweepStartedAtMs = undefined;
|
|
@@ -2784,10 +2960,39 @@ export class FactoryLoop {
|
|
|
2784
2960
|
// happens to run and reset it at the top of this method.
|
|
2785
2961
|
this.#discoverySweepLeaseLost = false;
|
|
2786
2962
|
if (!leaseReleased) {
|
|
2787
|
-
|
|
2963
|
+
// The half of the budget that makes the NEXT cycle clean, so it gets
|
|
2964
|
+
// its own deadline rather than the spent one: an unbounded release
|
|
2965
|
+
// would re-create the wedge one layer down, which is the pattern this
|
|
2966
|
+
// change exists to end. An abandoned release is survivable — the
|
|
2967
|
+
// durable lease carries its own expiry and a later sweep reclaims it
|
|
2968
|
+
// as an orphan (`claim.reclaimedLease` above).
|
|
2969
|
+
await this.#sweepTeardownStep('discovery sweep lease release', () => this.#state.releaseDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, claim.lease.epoch));
|
|
2788
2970
|
}
|
|
2789
2971
|
}
|
|
2790
2972
|
}
|
|
2973
|
+
/**
|
|
2974
|
+
* One sweep-teardown step, under its own deadline.
|
|
2975
|
+
*
|
|
2976
|
+
* Teardown cannot run under the sweep's aggregate budget: on the path that
|
|
2977
|
+
* matters the budget is already spent, so every step would reject and the
|
|
2978
|
+
* lease would never be released. It gets a short independent deadline
|
|
2979
|
+
* instead. Abandoning it costs an orphaned lease for one expiry window;
|
|
2980
|
+
* NOT bounding it costs the whole invariant, because a hung release holds
|
|
2981
|
+
* `#runOnceInFlight` open and every later cycle coalesces onto it.
|
|
2982
|
+
*/
|
|
2983
|
+
async #sweepTeardownStep(label, step) {
|
|
2984
|
+
const outcome = await withSweepTeardownDeadline(DISCOVERY_SWEEP_TEARDOWN_TIMEOUT_MS, async () => {
|
|
2985
|
+
await step();
|
|
2986
|
+
return true;
|
|
2987
|
+
});
|
|
2988
|
+
if (outcome === undefined) {
|
|
2989
|
+
this.#increment('discoverySweepTeardownDeadlineExceeded');
|
|
2990
|
+
this.#logger.warn?.('[factory] discovery sweep teardown step abandoned at its deadline', {
|
|
2991
|
+
step: label,
|
|
2992
|
+
timeoutMs: DISCOVERY_SWEEP_TEARDOWN_TIMEOUT_MS,
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2791
2996
|
/**
|
|
2792
2997
|
* Commit the sweep, carrying any residual overload backoff into the store.
|
|
2793
2998
|
*
|
|
@@ -2876,7 +3081,7 @@ export class FactoryLoop {
|
|
|
2876
3081
|
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
2877
3082
|
};
|
|
2878
3083
|
}
|
|
2879
|
-
async #performRunOnce(opts = {}) {
|
|
3084
|
+
async #performRunOnce(opts = {}, budget) {
|
|
2880
3085
|
const dryRun = opts.dryRun ?? this.#config.dryRun;
|
|
2881
3086
|
const startedAtMs = this.#clock.now();
|
|
2882
3087
|
const relayfileWaitWarningsAtStart = this.#counters.relayfileOperationWaitWarnings ?? 0;
|
|
@@ -2936,6 +3141,12 @@ export class FactoryLoop {
|
|
|
2936
3141
|
let readyIssueReads = 0;
|
|
2937
3142
|
const issueEntries = [];
|
|
2938
3143
|
for (const path of paths) {
|
|
3144
|
+
// A between-await check, worth exactly what #368 said such a check is
|
|
3145
|
+
// worth against a call that never returns: nothing. What it buys is
|
|
3146
|
+
// the other half — a pass already abandoned at the budget unwinds at
|
|
3147
|
+
// its next iteration if it ever regains control, instead of running to
|
|
3148
|
+
// completion beside the sweep that replaced it.
|
|
3149
|
+
budget?.assertNotExpired('run-once');
|
|
2939
3150
|
let issue;
|
|
2940
3151
|
let shed = false;
|
|
2941
3152
|
try {
|
|
@@ -2986,8 +3197,11 @@ export class FactoryLoop {
|
|
|
2986
3197
|
? '[factory] GitHub ready issue read progress'
|
|
2987
3198
|
: '[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
|
|
2988
3199
|
if (!shed) {
|
|
2989
|
-
|
|
2990
|
-
|
|
3200
|
+
// Both surfaces. Gating this on Linear is what left GitHub ingestion
|
|
3201
|
+
// with no canonical state at all, so nothing could ever clear the
|
|
3202
|
+
// terminal row a completed run left behind (#334).
|
|
3203
|
+
if (issue) {
|
|
3204
|
+
await this.#recordCanonicalIssueState(issue, this.#issueLifecycleRole(issue));
|
|
2991
3205
|
}
|
|
2992
3206
|
issueEntries.push({ path, issue });
|
|
2993
3207
|
}
|
|
@@ -3009,6 +3223,10 @@ export class FactoryLoop {
|
|
|
3009
3223
|
});
|
|
3010
3224
|
}
|
|
3011
3225
|
for (const { issue } of issueEntries) {
|
|
3226
|
+
// The dispatch half of the same fence as the read loop above. Without
|
|
3227
|
+
// it a pass abandoned during enumeration would go on to dispatch after
|
|
3228
|
+
// its lease had been handed back, racing the sweep that replaced it.
|
|
3229
|
+
budget?.assertNotExpired('run-once');
|
|
3012
3230
|
await this.#refreshLiveHeartbeatIfDue();
|
|
3013
3231
|
if (!issue) {
|
|
3014
3232
|
continue;
|
|
@@ -3650,7 +3868,7 @@ export class FactoryLoop {
|
|
|
3650
3868
|
const activeAgents = lifecycle.agents.filter((agent) => agent.releasedAtMs === undefined);
|
|
3651
3869
|
const hasLiveAgent = activeAgents.some((agent) => onlineAgents.has(agent.name));
|
|
3652
3870
|
const exitRecoveryActive = activeAgents.some((agent) => this.#agentExitsInFlight.has(agent.name));
|
|
3653
|
-
const dispatchCallActive = this.#
|
|
3871
|
+
const dispatchCallActive = this.#hasDispatchCallInFlight(lifecycle.issue);
|
|
3654
3872
|
// The provider's `factory:in-progress` transition happens immediately
|
|
3655
3873
|
// before the durable lifecycle advances from dispatching to running.
|
|
3656
3874
|
// A crash can therefore leave any nonterminal phase behind while the
|
|
@@ -4194,10 +4412,35 @@ export class FactoryLoop {
|
|
|
4194
4412
|
const paths = session.checkpoint.trees[prefix];
|
|
4195
4413
|
return paths ? [...paths] : undefined;
|
|
4196
4414
|
}
|
|
4415
|
+
/**
|
|
4416
|
+
* True when this call is a continuation of a sweep that has already ended.
|
|
4417
|
+
*
|
|
4418
|
+
* #372: the aggregate budget abandons the WAIT, not the call, so a read
|
|
4419
|
+
* issued by an aborted sweep can still resolve — by which time the shared
|
|
4420
|
+
* `#discoverySession` and `#discoverySweepEpoch` may belong to the sweep
|
|
4421
|
+
* that replaced it. `discoveryEnumerationPass` is an `AsyncLocalStorage`, so
|
|
4422
|
+
* its store follows the async continuation and still carries the epoch that
|
|
4423
|
+
* ISSUED the read; comparing the two is what tells a live write from a
|
|
4424
|
+
* late one.
|
|
4425
|
+
*
|
|
4426
|
+
* Only when a store exists: a caller outside a discovery pass legitimately
|
|
4427
|
+
* has none, and treating that as stale would silence the live event drain.
|
|
4428
|
+
*/
|
|
4429
|
+
#isStaleDiscoveryContinuation() {
|
|
4430
|
+
const issuingPass = discoveryEnumerationPass.getStore();
|
|
4431
|
+
return issuingPass !== undefined && issuingPass.epoch !== this.#discoverySweepEpoch;
|
|
4432
|
+
}
|
|
4197
4433
|
async #rememberDiscoveryTree(prefix, paths) {
|
|
4198
4434
|
const session = this.#discoverySession;
|
|
4199
4435
|
if (!session)
|
|
4200
4436
|
return;
|
|
4437
|
+
// Committing this listing would put a tree from an abandoned pass into the
|
|
4438
|
+
// replacement sweep's checkpoint, under a watermark that claims to describe
|
|
4439
|
+
// the replacement. That is checkpoint corruption, and it outlives the sweep.
|
|
4440
|
+
if (this.#isStaleDiscoveryContinuation()) {
|
|
4441
|
+
this.#increment('discoveryStaleTreeWritesDropped');
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4201
4444
|
const uniquePaths = new Set();
|
|
4202
4445
|
for (let index = 0; index < paths.length; index += 1) {
|
|
4203
4446
|
uniquePaths.add(paths[index]);
|
|
@@ -4272,7 +4515,10 @@ export class FactoryLoop {
|
|
|
4272
4515
|
}
|
|
4273
4516
|
catch (error) {
|
|
4274
4517
|
const overload = relayfileOverload(error);
|
|
4275
|
-
|
|
4518
|
+
// The stale check for the same reason as the tree write above: a 429 that
|
|
4519
|
+
// arrives after its sweep was abandoned is not this sweep's evidence, and
|
|
4520
|
+
// attributing it here would drive the replacement's overload ratchet.
|
|
4521
|
+
if (overload && this.#discoverySweepEpoch !== undefined && !this.#isStaleDiscoveryContinuation()) {
|
|
4276
4522
|
this.#discoveryOverloadError ??= error;
|
|
4277
4523
|
this.#discoverySweepOverloads += 1;
|
|
4278
4524
|
if (overload.retryAfterSeconds !== undefined) {
|
|
@@ -4459,6 +4705,44 @@ export class FactoryLoop {
|
|
|
4459
4705
|
}
|
|
4460
4706
|
}
|
|
4461
4707
|
}
|
|
4708
|
+
/**
|
|
4709
|
+
* Is a `dispatch()` call that could own this work unit's durable lifecycle
|
|
4710
|
+
* running in this process right now?
|
|
4711
|
+
*
|
|
4712
|
+
* `#dispatchInFlight` is keyed by work unit *plus* the dry-run flag and phase
|
|
4713
|
+
* the call was made under, so this must be built from the same three parts
|
|
4714
|
+
* `dispatch()` writes rather than from the bare identity.
|
|
4715
|
+
*
|
|
4716
|
+
* Live only, but BOTH phases — the same `` `${key}:live:` `` prefix `stop()`
|
|
4717
|
+
* uses at `:1531`, and for the same reason.
|
|
4718
|
+
*
|
|
4719
|
+
* The dry-run half of the key is decided once and is stable across both
|
|
4720
|
+
* functions, and `durableDispatch` is `!dryRun && …`, so a `:dry-run:*` call
|
|
4721
|
+
* provably never claims a lifecycle and matching it would preserve a
|
|
4722
|
+
* genuinely orphaned claim (#369 review, codex).
|
|
4723
|
+
*
|
|
4724
|
+
* The phase half is NOT stable. `dispatch()` derives it from the incoming
|
|
4725
|
+
* decision, while `#dispatchUnlocked` re-derives the escalation reason from
|
|
4726
|
+
* the post-routing decision — and `authoritativeRoutedDecision` upgrades a
|
|
4727
|
+
* routeless `confidence: 'low'` triage to `'high'` when the live labels
|
|
4728
|
+
* resolve a repository. A call keyed `:live:escalation` therefore does reach
|
|
4729
|
+
* lifecycle creation, so excluding that phase would reopen exactly the defect
|
|
4730
|
+
* below for it (#369 review, cubic).
|
|
4731
|
+
*
|
|
4732
|
+
* #367: this was `#dispatchInFlight.has(issueKey(issue))`, which no key in
|
|
4733
|
+
* the map can ever equal. `issueKey` is `<key>:<uuid>:<path>`, while every
|
|
4734
|
+
* entry is `<dispatchLifecycleKey>:<dry-run|live>:<phase>` — so the guard was
|
|
4735
|
+
* unsatisfiable by construction and orphan recovery could class a lifecycle
|
|
4736
|
+
* as abandoned while its own dispatch was still mid-flight.
|
|
4737
|
+
*/
|
|
4738
|
+
#hasDispatchCallInFlight(issue) {
|
|
4739
|
+
const livePrefix = `${dispatchLifecycleKey(issue)}:live:`;
|
|
4740
|
+
for (const key of this.#dispatchInFlight.keys()) {
|
|
4741
|
+
if (key.startsWith(livePrefix))
|
|
4742
|
+
return true;
|
|
4743
|
+
}
|
|
4744
|
+
return false;
|
|
4745
|
+
}
|
|
4462
4746
|
async #dispatchUnlocked(decision, opts = {}) {
|
|
4463
4747
|
const dryRun = opts.dryRun ?? this.#config.dryRun;
|
|
4464
4748
|
const batch = await this.#batch();
|
|
@@ -7041,6 +7325,10 @@ export class FactoryLoop {
|
|
|
7041
7325
|
}
|
|
7042
7326
|
if (!await this.#saveDispatchLifecycle(record, 'complete'))
|
|
7043
7327
|
return false;
|
|
7328
|
+
// The terminal rows now exist. If a reopen was observed while they were
|
|
7329
|
+
// still being written, it found nothing to clear and consumed the edge; do
|
|
7330
|
+
// the clear it could not do (#334, #375 review).
|
|
7331
|
+
await this.#reconcileReopenObservedDuringCompletion(record);
|
|
7044
7332
|
this.#increment(releaseReason === 'issue-human-review' ? 'humanReview' : 'done');
|
|
7045
7333
|
this.#emit('issue-done', { issue: record.issue });
|
|
7046
7334
|
await this.#writeInFlightRegistry();
|
|
@@ -7084,8 +7372,8 @@ export class FactoryLoop {
|
|
|
7084
7372
|
}
|
|
7085
7373
|
try {
|
|
7086
7374
|
const issue = await this.#readIssue(path);
|
|
7087
|
-
if (issue
|
|
7088
|
-
await this.#recordCanonicalIssueState(issue);
|
|
7375
|
+
if (issue) {
|
|
7376
|
+
await this.#recordCanonicalIssueState(issue, this.#issueLifecycleRole(issue));
|
|
7089
7377
|
}
|
|
7090
7378
|
if (issue && this.#dependencyIssueIsTerminal(issue)) {
|
|
7091
7379
|
await this.#markDependencyTerminalAndReconcile(issue);
|
|
@@ -8195,38 +8483,152 @@ export class FactoryLoop {
|
|
|
8195
8483
|
state.backoffUntilMs = 0;
|
|
8196
8484
|
await this.#state.recordDispatchAttempt(this.#workspaceId, key, state);
|
|
8197
8485
|
}
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8486
|
+
/**
|
|
8487
|
+
* The lifecycle role a work unit is in, from whichever surface described it.
|
|
8488
|
+
*
|
|
8489
|
+
* A GitHub issue carries no Linear workflow state — `githubIssueAsFactoryIssue`
|
|
8490
|
+
* sets `stateId: ''` — so its role lives in the `factory:*` labels and the
|
|
8491
|
+
* open/closed flag instead, exactly as `#isIssueReady` and
|
|
8492
|
+
* `#isIssueExternallyTerminal` already read it. Canonical state is recorded as
|
|
8493
|
+
* this role rather than as a raw state id so one provider-neutral value means
|
|
8494
|
+
* the same thing on both surfaces (#334).
|
|
8495
|
+
*/
|
|
8496
|
+
#issueLifecycleRole(issue) {
|
|
8497
|
+
if (!isGithubIssue(issue))
|
|
8498
|
+
return this.#states.roleOf(issue.stateId);
|
|
8499
|
+
if (githubFactoryIssueIsClosed(issue))
|
|
8500
|
+
return 'done';
|
|
8501
|
+
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
8502
|
+
if (labels.has('factory:human-review'))
|
|
8503
|
+
return 'humanReview';
|
|
8504
|
+
if (this.#isIssueReady(issue))
|
|
8505
|
+
return 'readyForAgent';
|
|
8506
|
+
if (labels.has('factory:in-progress'))
|
|
8507
|
+
return 'agentImplementing';
|
|
8508
|
+
return undefined;
|
|
8509
|
+
}
|
|
8510
|
+
/**
|
|
8511
|
+
* Remember the role this work unit was last seen in, and clear the durable
|
|
8512
|
+
* refusals a completed run left behind when it comes back ready.
|
|
8513
|
+
*
|
|
8514
|
+
* Called for BOTH surfaces. It used to be gated to Linear, so on a
|
|
8515
|
+
* GitHub-sourced Factory the reopen cleanup below was never reached at all
|
|
8516
|
+
* and a terminal row refused the reopened work forever — seven work units
|
|
8517
|
+
* across five repositories on 2026-08-25 (#334).
|
|
8518
|
+
*/
|
|
8519
|
+
async #recordCanonicalIssueState(issue, role) {
|
|
8520
|
+
const ref = issueRefForState(issue);
|
|
8521
|
+
const canonicalKey = canonicalStateKey(ref);
|
|
8522
|
+
const previousRole = await this.#state.getCanonicalState(this.#workspaceId, canonicalKey);
|
|
8202
8523
|
const reopenedFromTerminal = previousRole === 'done' || previousRole === 'humanReview';
|
|
8203
|
-
if (reopenedFromTerminal &&
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8524
|
+
if (reopenedFromTerminal && role === 'readyForAgent') {
|
|
8525
|
+
await this.#clearTerminalRefusals(ref);
|
|
8526
|
+
}
|
|
8527
|
+
// Never erase a remembered terminal role on an intermediate observation.
|
|
8528
|
+
// A reopened issue is routinely seen in a non-ready shape first — reopened
|
|
8529
|
+
// while `factory:in-progress` is still hanging on it, or reopened one event
|
|
8530
|
+
// before the readiness label is reapplied — and writing `role ?? ''` there
|
|
8531
|
+
// overwrote the remembered `done`, disarming the reopen edge so the later
|
|
8532
|
+
// ready observation refused the work exactly as #334 did. Only the three
|
|
8533
|
+
// roles the comparison above acts on are recorded, and that comparison is
|
|
8534
|
+
// this row's only consumer: `getCanonicalState` has no other caller
|
|
8535
|
+
// (cubic-dev-ai, #375 review).
|
|
8536
|
+
if (role === 'done' || role === 'humanReview' || role === 'readyForAgent') {
|
|
8537
|
+
await this.#state.recordCanonicalState(this.#workspaceId, canonicalKey, role);
|
|
8538
|
+
}
|
|
8539
|
+
}
|
|
8540
|
+
/**
|
|
8541
|
+
* Clear the durable refusals a completed run left behind for this work unit,
|
|
8542
|
+
* and report whether anything was actually cleared.
|
|
8543
|
+
*
|
|
8544
|
+
* One increment per reopened work unit, not per cleared row: the attempt
|
|
8545
|
+
* counters and the durable lifecycle are two records of the same refusal and
|
|
8546
|
+
* a unit that clears both reopened once. Counted here rather than at the
|
|
8547
|
+
* attempt clear alone so a reopen that only had a durable row — the shape a
|
|
8548
|
+
* restart leaves, and the one #334 saw in production — is still visible to an
|
|
8549
|
+
* operator.
|
|
8550
|
+
*/
|
|
8551
|
+
async #clearTerminalRefusals(ref) {
|
|
8552
|
+
// Dispatch attempts stay on the surface key: that is the key every other
|
|
8553
|
+
// reader and writer of the attempt counters uses, and rekeying only this
|
|
8554
|
+
// one site is the writer/reader mismatch #367 was filed for.
|
|
8555
|
+
const attemptKey = issueStateKey(ref);
|
|
8556
|
+
let reopened = false;
|
|
8557
|
+
const dispatchState = await this.#state.getDispatchAttempts(this.#workspaceId, attemptKey);
|
|
8558
|
+
if (dispatchState?.terminal) {
|
|
8559
|
+
dispatchState.attempts = 0;
|
|
8560
|
+
dispatchState.inFlight = false;
|
|
8561
|
+
dispatchState.terminal = false;
|
|
8562
|
+
dispatchState.backoffUntilMs = 0;
|
|
8563
|
+
await this.#state.recordDispatchAttempt(this.#workspaceId, attemptKey, dispatchState);
|
|
8564
|
+
reopened = true;
|
|
8565
|
+
}
|
|
8566
|
+
// Match on the work unit, not the surface key: a completed Linear mirror
|
|
8567
|
+
// persists `AR-448` while the GitHub-native arrival of the same issue is
|
|
8568
|
+
// `448`, and comparing surface keys would leave that terminal row in
|
|
8569
|
+
// place to refuse the reopened work forever.
|
|
8570
|
+
const reopenedIdentity = safeDispatchLifecycleKey(ref);
|
|
8571
|
+
for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
|
|
8572
|
+
if (!isTerminalDispatchLifecycle(lifecycle))
|
|
8573
|
+
continue;
|
|
8574
|
+
const lifecycleIdentity = safeDispatchLifecycleKey(lifecycle.issue);
|
|
8575
|
+
const matches = reopenedIdentity !== undefined && lifecycleIdentity === reopenedIdentity;
|
|
8576
|
+
// The surface-key fallback covers only a row so old it cannot produce a
|
|
8577
|
+
// work-unit identity at all, and only for a Linear-shaped key. A GitHub
|
|
8578
|
+
// issue key is a bare number that repeats in every repository, so
|
|
8579
|
+
// `key === key` there would let a reopened `factory#364` clear
|
|
8580
|
+
// `cloud#364`'s completed row — the #334 evidence spans five
|
|
8581
|
+
// repositories with overlapping numbering.
|
|
8582
|
+
const legacySurfaceMatch = lifecycleIdentity === undefined &&
|
|
8583
|
+
ISSUE_KEY_PARTS.test(ref.key) &&
|
|
8584
|
+
lifecycle.issue.key === ref.key;
|
|
8585
|
+
if (!matches && !legacySurfaceMatch)
|
|
8586
|
+
continue;
|
|
8587
|
+
await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
|
|
8588
|
+
this.#dispatchLifecycleEpochs.delete(lifecycleKey);
|
|
8589
|
+
reopened = true;
|
|
8590
|
+
}
|
|
8591
|
+
if (reopened)
|
|
8592
|
+
this.#increment('dispatchTerminalReopened');
|
|
8593
|
+
return reopened;
|
|
8594
|
+
}
|
|
8595
|
+
/**
|
|
8596
|
+
* Consume a reopen that was observed *while* completion was still writing its
|
|
8597
|
+
* terminal rows.
|
|
8598
|
+
*
|
|
8599
|
+
* Completion records the terminal role at the provider write, but the durable
|
|
8600
|
+
* rows that refuse a redispatch land much later — `#recordDispatchTerminal`
|
|
8601
|
+
* and the `complete` lifecycle save happen after the completion comment, the
|
|
8602
|
+
* Slack thread and every agent release. A reopen observed inside that window
|
|
8603
|
+
* reads terminal → ready, finds nothing terminal to clear, and *consumes the
|
|
8604
|
+
* edge anyway* by recording `readyForAgent`. The terminal rows then land on a
|
|
8605
|
+
* work unit whose canonical role will never again read terminal → ready, so
|
|
8606
|
+
* the refusal is permanent: #334's exact failure reached through the
|
|
8607
|
+
* completion window instead of through a missing canonical row
|
|
8608
|
+
* (chatgpt-codex-connector P1, #375 review).
|
|
8609
|
+
*
|
|
8610
|
+
* So recheck at the terminal save, which is the first instant the rows the
|
|
8611
|
+
* reopen would have cleared actually exist. This clears nothing the reopen
|
|
8612
|
+
* path would not have cleared itself had it run a moment later — it only
|
|
8613
|
+
* repairs the ordering — and it is armed solely for a completion that
|
|
8614
|
+
* recorded a terminal role of its own.
|
|
8615
|
+
*/
|
|
8616
|
+
async #reconcileReopenObservedDuringCompletion(record) {
|
|
8617
|
+
if (!record.canonicalTerminalRoleRecorded)
|
|
8618
|
+
return;
|
|
8619
|
+
const ref = issueRefForState(record.issue);
|
|
8620
|
+
const role = await this.#state.getCanonicalState(this.#workspaceId, canonicalStateKey(ref));
|
|
8621
|
+
if (role !== 'readyForAgent')
|
|
8622
|
+
return;
|
|
8623
|
+
// Counted from the repair, not from the observation. A reopen that landed
|
|
8624
|
+
// after the terminal rows existed was already cleared by the observation
|
|
8625
|
+
// path itself and leaves the same `readyForAgent` row behind, so counting
|
|
8626
|
+
// the read would put a reopen completion never raced into the
|
|
8627
|
+
// completion-window bucket (cubic-dev-ai P3, #375 review). Only a clear
|
|
8628
|
+
// that actually found a refusal to remove is one this recheck repaired.
|
|
8629
|
+
if (await this.#clearTerminalRefusals(ref)) {
|
|
8630
|
+
this.#increment('dispatchTerminalReopenedDuringCompletion');
|
|
8228
8631
|
}
|
|
8229
|
-
await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId);
|
|
8230
8632
|
}
|
|
8231
8633
|
async #writeLoopHeartbeat(path, registryPath, status, iteration, maxIterations) {
|
|
8232
8634
|
const updatedAtMs = this.#clock.now();
|
|
@@ -13455,14 +13857,34 @@ export class FactoryLoop {
|
|
|
13455
13857
|
this.#postMergeDoneAdvances.add(advanceKey);
|
|
13456
13858
|
try {
|
|
13457
13859
|
const githubIssue = isGithubIssue(issue);
|
|
13860
|
+
let terminalStateObserved = true;
|
|
13458
13861
|
if (githubIssue) {
|
|
13459
|
-
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
|
|
13862
|
+
const closeWrite = await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
|
|
13863
|
+
if (closeWrite === undefined)
|
|
13864
|
+
this.#recordMissingGithubWritebackReceipt('closeIssue');
|
|
13865
|
+
terminalStateObserved = closeWrite !== undefined;
|
|
13460
13866
|
}
|
|
13461
13867
|
else {
|
|
13462
13868
|
const doneStateId = this.#states.idFor(issue.team, 'done');
|
|
13463
13869
|
await this.#linear.setState(issue, doneStateId);
|
|
13464
|
-
await this.#recordCanonicalIssueState({ ...issueRef(issue), stateId: doneStateId });
|
|
13465
13870
|
}
|
|
13871
|
+
// Recorded from the write, not left to the next sweep's read: a reopen
|
|
13872
|
+
// landing in that gap would otherwise be read as the FIRST terminal
|
|
13873
|
+
// observation of this unit and the reopen would never be seen (#334).
|
|
13874
|
+
//
|
|
13875
|
+
// Any defined close receipt records it, because canonical state answers
|
|
13876
|
+
// "what state is this unit in", which is a different question from the
|
|
13877
|
+
// "who created this transition" that `applied` alone answers and that
|
|
13878
|
+
// ownership gates elsewhere require. Every receipt in
|
|
13879
|
+
// `GithubIssueCloseWriteResult` is a state observation: `already-matched`
|
|
13880
|
+
// is a provider read of the closed state, `acknowledged` is by contract
|
|
13881
|
+
// an unattributed *visible* transition, and the shipped `gh` adapter
|
|
13882
|
+
// throws unless it reads the issue back as closed. Only a legacy void
|
|
13883
|
+
// adapter (`undefined`) carries no state evidence at all; there the next
|
|
13884
|
+
// sweep records whatever the provider does say (cubic-dev-ai, #375
|
|
13885
|
+
// review).
|
|
13886
|
+
if (terminalStateObserved)
|
|
13887
|
+
await this.#recordCanonicalIssueState(issueRef(issue), 'done');
|
|
13466
13888
|
this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
|
|
13467
13889
|
await this.#markDependencyTerminalAndReconcile(issue);
|
|
13468
13890
|
this.#increment('mergedPrAdvancedDone');
|
|
@@ -14200,6 +14622,16 @@ export class FactoryLoop {
|
|
|
14200
14622
|
// park during it must still abort immediately rather than waiting on a
|
|
14201
14623
|
// possibly long completion path.
|
|
14202
14624
|
this.#issueWritebackInFlight.set(completionKey, issueWritebackSettled);
|
|
14625
|
+
// Whether the PROVIDER's visible state is now terminal. Deliberately a
|
|
14626
|
+
// different question from `issueWritebackConfirmedAtMs`, which asks
|
|
14627
|
+
// whether THIS dispatch owns that transition and stays `applied`-only:
|
|
14628
|
+
// canonical state exists to answer "what state is this unit in", and
|
|
14629
|
+
// every defined receipt answers that (`already-matched` is a provider
|
|
14630
|
+
// read of the target state, `acknowledged` is by contract an
|
|
14631
|
+
// unattributed *visible* transition). Only a legacy void adapter
|
|
14632
|
+
// carries no state evidence, and there the next sweep still records
|
|
14633
|
+
// whatever the provider says (cubic-dev-ai, #375 review).
|
|
14634
|
+
let terminalStateObserved = false;
|
|
14203
14635
|
if (githubIssue) {
|
|
14204
14636
|
if (humanReview) {
|
|
14205
14637
|
const statusWrite = await this.#githubWriteback.setStatus(issue, 'human-review');
|
|
@@ -14211,6 +14643,7 @@ export class FactoryLoop {
|
|
|
14211
14643
|
if (statusWrite === 'applied') {
|
|
14212
14644
|
record.issueWritebackConfirmedAtMs ??= this.#clock.now();
|
|
14213
14645
|
}
|
|
14646
|
+
terminalStateObserved = statusWrite !== undefined;
|
|
14214
14647
|
// The lifecycle-state outcome is now known. Unblock the concurrent
|
|
14215
14648
|
// post-spawn read before the separate completion comment write.
|
|
14216
14649
|
settleIssueWritebackOnce();
|
|
@@ -14227,6 +14660,7 @@ export class FactoryLoop {
|
|
|
14227
14660
|
if (closeWrite === 'applied') {
|
|
14228
14661
|
record.issueWritebackConfirmedAtMs ??= this.#clock.now();
|
|
14229
14662
|
}
|
|
14663
|
+
terminalStateObserved = closeWrite !== undefined;
|
|
14230
14664
|
}
|
|
14231
14665
|
}
|
|
14232
14666
|
else {
|
|
@@ -14235,7 +14669,17 @@ export class FactoryLoop {
|
|
|
14235
14669
|
: this.#states.idFor(issueTeam, 'done');
|
|
14236
14670
|
await this.#linear.setState(issue, targetState);
|
|
14237
14671
|
record.issueWritebackConfirmedAtMs ??= this.#clock.now();
|
|
14238
|
-
|
|
14672
|
+
terminalStateObserved = true;
|
|
14673
|
+
}
|
|
14674
|
+
if (terminalStateObserved) {
|
|
14675
|
+
// Both surfaces now, and recorded from the write rather than left to
|
|
14676
|
+
// the next sweep so a reopen cannot slip into the gap (#334). The
|
|
14677
|
+
// marker is what lets the terminal save — which happens after the
|
|
14678
|
+
// completion comment, the Slack thread and every agent release —
|
|
14679
|
+
// detect a reopen that landed inside that window and consumed the
|
|
14680
|
+
// reopen edge before there was anything to clear.
|
|
14681
|
+
await this.#recordCanonicalIssueState(record.issue, humanReview ? 'humanReview' : 'done');
|
|
14682
|
+
record.canonicalTerminalRoleRecorded = true;
|
|
14239
14683
|
}
|
|
14240
14684
|
if (record.issueWritebackConfirmedAtMs !== undefined) {
|
|
14241
14685
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
@@ -17386,6 +17830,21 @@ const dispatchLifecycleKey = (issue) => dispatchIssueIdentity(issue);
|
|
|
17386
17830
|
// Preserve the historical Linear state namespace while keeping GitHub-native
|
|
17387
17831
|
// issue numbers independent across repositories in the same workspace.
|
|
17388
17832
|
const issueStateKey = (issue) => githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key;
|
|
17833
|
+
/**
|
|
17834
|
+
* The canonical-state key: the work unit, the same identity the dispatch
|
|
17835
|
+
* lifecycle it gates is keyed under (#211, #329, #367).
|
|
17836
|
+
*
|
|
17837
|
+
* Canonical state exists for exactly one decision — did this work unit reach a
|
|
17838
|
+
* terminal role and then come back ready — and that decision clears a durable
|
|
17839
|
+
* row keyed on the work unit. Keying the question per surface and the answer
|
|
17840
|
+
* per work unit is what made #334 reachable: a GitHub-native ref could not see
|
|
17841
|
+
* the terminal role its own Linear mirror had recorded.
|
|
17842
|
+
*
|
|
17843
|
+
* Falls back to the surface key for a ref with no derivable provider identity,
|
|
17844
|
+
* so a malformed row degrades to today's behaviour instead of throwing out of
|
|
17845
|
+
* ingestion.
|
|
17846
|
+
*/
|
|
17847
|
+
const canonicalStateKey = (issue) => safeDispatchLifecycleKey(issue) ?? issueStateKey(issue);
|
|
17389
17848
|
const pidsFromSpawnResult = (result) => {
|
|
17390
17849
|
const pids = new Set();
|
|
17391
17850
|
for (const pid of result?.pids ?? []) {
|