@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.
@@ -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
- await this.#heldAgentDeadlineSweepInFlight;
1206
- for (const timer of this.#dispatchLifecycleRetryTimers.values())
1207
- clearTimeout(timer);
1208
- this.#dispatchLifecycleRetryTimers.clear();
1209
- this.#abandonedDispatchReasons.clear();
1210
- this.#dispatchLifecycleCapacityWaits.clear();
1211
- this.#dispatchLifecycleOwnershipWaitLogged.clear();
1212
- if (this.#completionSweepTimer)
1213
- clearTimeout(this.#completionSweepTimer);
1214
- this.#completionSweepTimer = undefined;
1215
- if (this.#readinessReconcileTimer)
1216
- clearTimeout(this.#readinessReconcileTimer);
1217
- this.#readinessReconcileTimer = undefined;
1218
- if (this.#previewSweepTimer)
1219
- clearTimeout(this.#previewSweepTimer);
1220
- this.#previewSweepTimer = undefined;
1221
- await this.#readinessReconcileInFlight;
1222
- // #301 review: the deadline ends the *wait*, so `#readinessReconcileInFlight`
1223
- // can settle with its `runOnce()` still live. Shutdown releases dispatch
1224
- // lifecycle leases and disposes ports below, and `#isPassFatalFailure` only
1225
- // fences a stopping sweep once something in it throws — so a sweep whose
1226
- // dependency recovers cleanly would otherwise dispatch through torn-down
1227
- // state. Draining here restores exactly the pre-deadline shutdown contract:
1228
- // stop() outlives the sweep it started.
1229
- await this.#readinessReconcileAbandonedWait;
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
- await this.#assertFleetControlPlaneAvailable();
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.#state.claimDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, this.#clock.now(), DISCOVERY_SWEEP_LEASE_MS);
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.#state.claimDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, this.#clock.now(), DISCOVERY_SWEEP_LEASE_MS);
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
- const completed = await this.#commitDiscoverySweep(claim.lease.epoch, checkpoint, residual);
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
- leaseReleased = await this.#state.deferDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, claim.lease.epoch, outcome.backoffUntilMs, outcome.consecutiveOverloads);
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
- await this.#state.releaseDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, claim.lease.epoch);
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
- if (issue && issueSource === 'linear') {
2990
- await this.#recordCanonicalIssueState(issue);
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.#dispatchInFlight.has(issueKey(lifecycle.issue));
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
- if (overload && this.#discoverySweepEpoch !== undefined) {
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 && !isGithubIssue(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
- async #recordCanonicalIssueState(issue) {
8199
- const key = issueStateKey(issue);
8200
- const previousStateId = await this.#state.getCanonicalState(this.#workspaceId, key);
8201
- const previousRole = this.#states.roleOf(previousStateId);
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 && this.#states.isRole(issue.stateId, 'readyForAgent')) {
8204
- const dispatchState = await this.#state.getDispatchAttempts(this.#workspaceId, key);
8205
- if (dispatchState?.terminal) {
8206
- dispatchState.attempts = 0;
8207
- dispatchState.inFlight = false;
8208
- dispatchState.terminal = false;
8209
- dispatchState.backoffUntilMs = 0;
8210
- await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState);
8211
- this.#increment('dispatchTerminalReopened');
8212
- }
8213
- // Match on the work unit, not the surface key: a completed Linear mirror
8214
- // persists `AR-448` while the GitHub-native arrival of the same issue is
8215
- // `448`, and comparing surface keys would leave that terminal row in
8216
- // place to refuse the reopened work forever.
8217
- const reopenedIdentity = safeDispatchLifecycleKey(issueRefForState(issue));
8218
- for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
8219
- if (!isTerminalDispatchLifecycle(lifecycle))
8220
- continue;
8221
- const matches = reopenedIdentity !== undefined &&
8222
- safeDispatchLifecycleKey(lifecycle.issue) === reopenedIdentity;
8223
- if (!matches && lifecycle.issue.key !== issue.key)
8224
- continue;
8225
- await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
8226
- this.#dispatchLifecycleEpochs.delete(lifecycleKey);
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
- await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
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 ?? []) {