Ctrl K
ComunidadGuías…

test/connect-catalog-delatch.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// #234 — the connect live catalog must keep syncing as the pool changes.
//
// It used to be one module-level boolean: the first active account to sync set it,
// nothing ever cleared it, so adding a paid account to a free-only pool never
// refreshed the selector set. The pool-wide union was therefore unobtainable.
//
// De-latching alone would have been WORSE than the latch, which is what these
// tests pin: setLiveCatalogSelectors clears and repopulates, so a second account
// syncing after the first would SHRINK the live set to just its own selectors.
// Hence per-account rows unioned before every write.

import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  __resetModelCatalogState, __setModelCatalogDeps, __waitForModelCatalogSync,
  addAccountByKey, removeAccount, getAccountInternal, setAccountStatus, setAccountTokens,
  isConnectSelectorAllowedForAccount, trySyncModelCatalog,
} from '../src/auth.js';
import {
  setLiveCatalogSelectors as applyLiveCatalog,
  resolveConnectSelector,
} from '../src/devin-connect-models.js';

const created = [];
let liveWrites = [];
let fetchCalls = [];
// Mutable so a test can change what an account answers on a LATER sync — needed to
// prove an empty answer leaves the account eligible rather than latched.
let perAccountRows = {};
const SAVED_CONNECT = process.env.DEVIN_CONNECT;
const SAVED_CATALOG_TTL = process.env.DEVIN_CONNECT_CATALOG_TTL_MS;
const SAVED_CONNECT_TOKEN = process.env.DEVIN_CONNECT_TOKEN;
const SAVED_WINDSURF_API_KEY = process.env.WINDSURF_API_KEY;
const RETRY_BASE_MS = 30_000;

/** Install seams: record every fetch and every write to the resolver. */
function installDeps({
  perAccount = {},
  now,
  fetchConnectCatalog,
  retryJitterBasisPoints = () => 10_000,
}) {
  liveWrites = [];
  fetchCalls = [];
  perAccountRows = { ...perAccount };
  __setModelCatalogDeps({
    // Cascade sync is irrelevant here and would make assertions noisy.
    getCascadeModelConfigs: async () => ({ configs: [] }),
    scheduleCatalogRetry: () => () => {},
    fetchConnectCatalog: async ({ token }) => {
      fetchCalls.push(token);
      if (fetchConnectCatalog) return fetchConnectCatalog({ token });
      return perAccountRows[token] || [];
    },
    ...(retryJitterBasisPoints
      ? { connectCatalogRetryJitterBasisPoints: retryJitterBasisPoints }
      : {}),
    ...(now ? { now } : {}),
    setLiveCatalogSelectors: (rows, options) => {
      liveWrites.push((rows || []).map((r) => (typeof r === 'string' ? r : r.selector)));
      applyLiveCatalog(rows, options);
    },
  });
}

function mk(apiKey, tier = 'free') {
  const a = addAccountByKey(apiKey, 'delatch');
  const acct = getAccountInternal(a.id);
  acct.status = 'active';
  acct.tier = tier;
  created.push(a.id);
  return acct;
}

/** The union the resolver was last told about. */
function lastUnion() {
  return liveWrites.length ? [...liveWrites[liveWrites.length - 1]].sort() : [];
}

function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
  return { promise, resolve, reject };
}

async function flushAsync() {
  await new Promise((resolve) => setImmediate(resolve));
}

beforeEach(() => {
  process.env.DEVIN_CONNECT = '1';
  delete process.env.DEVIN_CONNECT_CATALOG_TTL_MS;
  delete process.env.DEVIN_CONNECT_TOKEN;
  delete process.env.WINDSURF_API_KEY;
  __resetModelCatalogState();
});

afterEach(async () => {
  while (created.length) removeAccount(created.pop());
  __setModelCatalogDeps(null);
  __resetModelCatalogState();
  if (SAVED_CONNECT === undefined) delete process.env.DEVIN_CONNECT;
  else process.env.DEVIN_CONNECT = SAVED_CONNECT;
  if (SAVED_CATALOG_TTL === undefined) delete process.env.DEVIN_CONNECT_CATALOG_TTL_MS;
  else process.env.DEVIN_CONNECT_CATALOG_TTL_MS = SAVED_CATALOG_TTL;
  if (SAVED_CONNECT_TOKEN === undefined) delete process.env.DEVIN_CONNECT_TOKEN;
  else process.env.DEVIN_CONNECT_TOKEN = SAVED_CONNECT_TOKEN;
  if (SAVED_WINDSURF_API_KEY === undefined) delete process.env.WINDSURF_API_KEY;
  else process.env.WINDSURF_API_KEY = SAVED_WINDSURF_API_KEY;
});

describe('connect catalog de-latch (#234)', () => {
  it('coalesces per account, not globally, when two accounts need their first sync together', async () => {
    installDeps({
      perAccount: {
        'sk-concurrent-a': [{ selector: 'swe-1-6-slow' }],
        'sk-concurrent-b': [{ selector: 'claude-opus-4-8-medium' }],
      },
    });

    mk('sk-concurrent-a', 'free');
    mk('sk-concurrent-b', 'pro');
    await __waitForModelCatalogSync();

    assert.deepEqual(new Set(fetchCalls), new Set(['sk-concurrent-a', 'sk-concurrent-b']));
  });

  it('refreshes a successful Connect catalog after the five-minute TTL', async () => {
    let now = 1_000_000;
    installDeps({
      perAccount: { 'sk-ttl': [{ selector: 'swe-1-6-slow' }] },
      now: () => now,
    });

    mk('sk-ttl', 'free');
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1);

    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1, 'fresh catalog is reused inside the TTL');

    now += 5 * 60 * 1000 + 1;
    perAccountRows['sk-ttl'] = [{ selector: 'swe-1-6-slow' }, { selector: 'swe-1-7' }];
    trySyncModelCatalog();
    await __waitForModelCatalogSync();

    assert.equal(fetchCalls.length, 2, 'expired catalog is fetched again');
    assert.ok(lastUnion().includes('swe-1-7'));
  });

  it('syncs a second account instead of latching after the first', async () => {
    installDeps({
      perAccount: {
        'sk-free-acct': [{ selector: 'swe-1-6-slow' }],
        'sk-paid-acct': [{ selector: 'claude-opus-4-8-medium' }],
      },
    });

    mk('sk-free-acct', 'free');
    await __waitForModelCatalogSync();
    mk('sk-paid-acct', 'pro');
    await __waitForModelCatalogSync();

    assert.ok(fetchCalls.includes('sk-paid-acct'),
      'the newly added account must be fetched; a module-level latch skipped it entirely');
  });

  it('unions selectors across accounts rather than replacing them', async () => {
    // The regression that makes a naive de-latch worse than the latch:
    // setLiveCatalogSelectors clears and repopulates, so the second sync would
    // otherwise drop the first account's selectors.
    installDeps({
      perAccount: {
        'sk-free-acct': [{ selector: 'swe-1-6-slow' }],
        'sk-paid-acct': [{ selector: 'claude-opus-4-8-medium' }],
      },
    });

    mk('sk-free-acct', 'free');
    await __waitForModelCatalogSync();
    mk('sk-paid-acct', 'pro');
    await __waitForModelCatalogSync();

    assert.deepEqual(lastUnion(), ['claude-opus-4-8-medium', 'swe-1-6-slow'],
      'the resolver must hold the union of both accounts, not just the last one');
  });

  it('keeps paid-account routing inside each account own live catalog', async () => {
    installDeps({
      perAccount: {
        'sk-opus-acct': [{ selector: 'claude-opus-4-8-medium' }],
        'sk-gpt-acct': [{ selector: 'gpt-5-5-low' }],
      },
    });

    const opus = mk('sk-opus-acct', 'pro');
    await __waitForModelCatalogSync();
    const gpt = mk('sk-gpt-acct', 'pro');
    await __waitForModelCatalogSync();

    assert.equal(isConnectSelectorAllowedForAccount(opus, 'claude-opus-4-8-medium'), true);
    assert.equal(isConnectSelectorAllowedForAccount(opus, 'gpt-5-5-low'), false,
      'a paid tier is not permission to route a selector absent from this account catalog');
    assert.equal(isConnectSelectorAllowedForAccount(gpt, 'gpt-5-5-low'), true);
    assert.equal(isConnectSelectorAllowedForAccount(gpt, 'claude-opus-4-8-medium'), false,
      'mixed-pool union must not erase per-account routing boundaries');
  });

  it('keeps snapshot resolution for a paid account whose catalog has no LKG', async () => {
    installDeps({
      perAccount: {
        'sk-restricted-live': [{ selector: 'claude-opus-4-8-medium' }],
        'sk-empty-fallback': [],
      },
    });

    const restricted = mk('sk-restricted-live', 'pro');
    await __waitForModelCatalogSync();
    const fallback = mk('sk-empty-fallback', 'pro');
    await __waitForModelCatalogSync();

    assert.deepEqual(
      resolveConnectSelector('gpt-5.5', { warnOnFallback: false }),
      { selector: 'gpt-5-5-low', mapped: true },
      'one successful account must not globally disable a failed account snapshot fallback',
    );
    assert.equal(isConnectSelectorAllowedForAccount(restricted, 'gpt-5-5-low'), false,
      'the restricted account remains bound to its own live rows');
    assert.equal(isConnectSelectorAllowedForAccount(fallback, 'gpt-5-5-low'), true,
      'the account with no LKG keeps its tier-controlled snapshot fallback');

    created.splice(created.indexOf(fallback.id), 1);
    removeAccount(fallback.id);
    assert.equal(resolveConnectSelector('gpt-5.5', { warnOnFallback: false }).mapped, false,
      'removing the last fallback contributor makes the remaining live catalog authoritative');
  });

  it('keeps a catalog-independent synthetic selector behind the paid tier gate', async () => {
    installDeps({
      perAccount: {
        'sk-synthetic-pro': [{ selector: 'claude-opus-4-8-medium' }],
        'sk-synthetic-free': [{ selector: 'swe-1-6-slow' }],
      },
    });

    const pro = mk('sk-synthetic-pro', 'pro');
    const free = mk('sk-synthetic-free', 'free');
    await __waitForModelCatalogSync();

    assert.equal(isConnectSelectorAllowedForAccount(pro, 'subagent-default'), true,
      'synthetic selectors cannot be required to appear in GetCliModelConfigs');
    assert.equal(isConnectSelectorAllowedForAccount(free, 'subagent-default'), false,
      'catalog independence must not bypass the existing paid tier requirement');
  });

  it('does not let an empty response shrink the union', async () => {
    // Same asymmetry as the Cascade empty-catalog guard: an empty response is no
    // data, not "this account reaches nothing".
    let now = 2_000_000;
    installDeps({
      perAccount: {
        'sk-good-acct': [{ selector: 'swe-1-6-slow' }, { selector: 'claude-opus-4-8-medium' }],
        'sk-empty-acct': [],
      },
      now: () => now,
    });

    mk('sk-good-acct', 'pro');
    await __waitForModelCatalogSync();
    const afterGood = lastUnion();

    const emptyAcct = mk('sk-empty-acct', 'free');
    await __waitForModelCatalogSync();

    assert.deepEqual(lastUnion(), afterGood,
      'an empty catalog response must leave the existing union in place');
    assert.ok(afterGood.length === 2);

    // The union staying intact is NOT the interesting part — a union with an empty
    // contributor is unchanged arithmetically, so that assertion alone passes even
    // if the empty response was accepted and stored.
    //
    // The real damage of accepting it is that the account gets recorded as SYNCED
    // with nothing to contribute, and the per-key check then skips it forever — so
    // it never picks up selectors it genuinely gains later. Assert that instead:
    // the account must remain eligible, and a later non-empty response must land.
    const beforeRetry = fetchCalls.filter((k) => k === 'sk-empty-acct').length;
    setAccountStatus(emptyAcct.id, 'active');
    await __waitForModelCatalogSync();

    assert.equal(
      fetchCalls.filter((k) => k === 'sk-empty-acct').length,
      beforeRetry,
      'an empty response must not hot-loop when another lifecycle trigger fires inside backoff',
    );

    now += RETRY_BASE_MS;
    setAccountStatus(emptyAcct.id, 'active');
    await __waitForModelCatalogSync();
    const afterSecondEmpty = fetchCalls.filter((k) => k === 'sk-empty-acct').length;

    assert.equal(afterSecondEmpty, beforeRetry + 1,
      'an account that answered empty must stay eligible for re-sync, not be latched as synced');
    assert.deepEqual(lastUnion(), afterGood, 'a second empty answer still preserves the union');

    perAccountRows['sk-empty-acct'] = [{ selector: 'glm-5-2-none' }];
    now += (RETRY_BASE_MS * 2) - 1;
    setAccountStatus(emptyAcct.id, 'active');
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.filter((k) => k === 'sk-empty-acct').length, afterSecondEmpty,
      'consecutive empty responses use the doubled 60s delay');

    now += 1;
    setAccountStatus(emptyAcct.id, 'active');
    await __waitForModelCatalogSync();
    assert.ok(lastUnion().includes('glm-5-2-none'),
      'once it answers non-empty, its selectors must reach the union');
  });

  it('uses deterministic exponential backoff for failures and a success resets the ladder', async () => {
    let now = 3_000_000;
    let attemptNo = 0;
    installDeps({
      now: () => now,
      fetchConnectCatalog: async () => {
        attemptNo += 1;
        if (attemptNo === 1 || attemptNo === 2 || attemptNo === 4) {
          throw new Error(`catalog failure ${attemptNo}`);
        }
        return [{ selector: `recovered-${attemptNo}` }];
      },
    });

    mk('sk-backoff-failure', 'pro');
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1, 'precondition: the initial request failed once');

    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1, 'a trigger at the same timestamp stays inside 30s backoff');

    now += RETRY_BASE_MS - 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1, 'the first retry is not eligible one millisecond early');

    now += 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 2, 'the first retry is eligible at exactly 30s');

    now += (RETRY_BASE_MS * 2) - 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 2, 'the second failure doubles the deterministic delay to 60s');

    now += 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 3, 'the second retry is eligible at exactly 60s');
    assert.deepEqual(lastUnion(), ['recovered-3']);

    // Expire the successful snapshot, fail once more, and prove the success above
    // cleared the streak: the next delay is 30s again, not the old ladder's 120s.
    now += (5 * 60 * 1000) + 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 4, 'the stale successful snapshot refreshes and fails');

    now += RETRY_BASE_MS - 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 4, 'the reset ladder still observes its fresh 30s delay');

    now += 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 5, 'success reset the ladder to the 30s base');
    assert.deepEqual(lastUnion(), ['recovered-5']);
  });

  it('stably staggers account retries instead of releasing a fleet-wide failure at once', async () => {
    let now = 3_250_000;
    const attempts = new Map();
    installDeps({
      now: () => now,
      retryJitterBasisPoints: ({ apiKey }) => (
        apiKey === 'sk-jitter-early' ? 8_000 : 10_000
      ),
      fetchConnectCatalog: async ({ token }) => {
        attempts.set(token, (attempts.get(token) || 0) + 1);
        throw new Error('shared upstream outage');
      },
    });

    mk('sk-jitter-early', 'pro');
    mk('sk-jitter-late', 'pro');
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-early'), 1);
    assert.equal(attempts.get('sk-jitter-late'), 1);

    now += 24_000 - 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-early'), 1, '80% account must not retry before 24s');
    assert.equal(attempts.get('sk-jitter-late'), 1);

    now += 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-early'), 2, '80% account retries at 24s');
    assert.equal(attempts.get('sk-jitter-late'), 1, '100% account remains held until 30s');

    now += 6_000;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-early'), 2,
      'the early account starts its doubled delay from its own retry time');
    assert.equal(attempts.get('sk-jitter-late'), 2, 'the late account retries at 30s');
  });

  it('derives stable retry jitter from the actual credential identity', async () => {
    let now = 3_400_000;
    const attempts = new Map();
    installDeps({
      now: () => now,
      // Deliberately omit the injected jitter seam. These fixed credentials pin
      // the production FNV-1a path and its 80%-100% basis-point mapping.
      retryJitterBasisPoints: null,
      fetchConnectCatalog: async ({ token }) => {
        attempts.set(token, (attempts.get(token) || 0) + 1);
        throw new Error('shared upstream outage');
      },
    });

    mk('sk-jitter-stable-a', 'pro'); // basis=9993, first delay=29_979ms
    mk('sk-jitter-stable-b', 'pro'); // basis=8758, first delay=26_274ms
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-stable-a'), 1);
    assert.equal(attempts.get('sk-jitter-stable-b'), 1);

    now += 26_273;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-stable-a'), 1);
    assert.equal(attempts.get('sk-jitter-stable-b'), 1,
      'the 8758-basis credential must remain held one millisecond before its boundary');

    now += 1;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-stable-a'), 1,
      'the 9993-basis credential remains held until its own deterministic boundary');
    assert.equal(attempts.get('sk-jitter-stable-b'), 2,
      'the 8758-basis credential retries at exactly 26_274ms');

    now += 3_705;
    trySyncModelCatalog();
    await __waitForModelCatalogSync();
    assert.equal(attempts.get('sk-jitter-stable-a'), 2,
      'the 9993-basis credential retries at exactly 29_979ms');
    assert.equal(attempts.get('sk-jitter-stable-b'), 2,
      'the earlier credential is already on its doubled per-credential delay');
  });

  it('caps a permanently failing catalog at five minutes', async () => {
    let now = 3_500_000;
    installDeps({
      now: () => now,
      fetchConnectCatalog: async () => { throw new Error('still unavailable'); },
    });

    mk('sk-backoff-cap', 'pro');
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1);

    // failure 1..6 schedule 30, 60, 120, 240, 300, 300 seconds.
    // The repeated 300 proves the cap is stable instead of merely crossing it once.
    const delays = [30, 60, 120, 240, 300, 300].map((seconds) => seconds * 1000);
    for (const [index, delay] of delays.entries()) {
      now += delay - 1;
      trySyncModelCatalog();
      await __waitForModelCatalogSync();
      assert.equal(fetchCalls.length, index + 1,
        `failure ${index + 1} retried before its ${delay}ms delay elapsed`);

      now += 1;
      trySyncModelCatalog();
      await __waitForModelCatalogSync();
      assert.equal(fetchCalls.length, index + 2,
        `failure ${index + 1} did not retry at its exact ${delay}ms boundary`);
    }
  });

  it('clears empty-response backoff when the account is disabled and re-enabled', async () => {
    let now = 4_000_000;
    let attemptNo = 0;
    installDeps({
      now: () => now,
      fetchConnectCatalog: async () => {
        attemptNo += 1;
        return attemptNo === 1 ? [] : [{ selector: 'fresh-lifecycle-selector' }];
      },
    });

    const acct = mk('sk-backoff-lifecycle', 'free');
    await __waitForModelCatalogSync();
    assert.equal(fetchCalls.length, 1, 'precondition: the empty response armed backoff');

    setAccountStatus(acct.id, 'disabled');
    setAccountStatus(acct.id, 'active');
    await __waitForModelCatalogSync();

    assert.equal(fetchCalls.length, 2,
      'a new active lifecycle must fetch immediately instead of inheriting old backoff');
    assert.deepEqual(lastUnion(), ['fresh-lifecycle-selector']);
  });

  it('discards an in-flight response after the account is disabled', async () => {
    const pending = deferred();
    installDeps({ fetchConnectCatalog: () => pending.promise });

    const acct = mk('sk-disable-inflight', 'pro');
    assert.equal(fetchCalls.length, 1);
    setAccountStatus(acct.id, 'disabled');

    pending.resolve([{ selector: 'stale-after-disable' }]);
    await flushAsync();

    assert.deepEqual(liveWrites, [], 'a disabled account cannot commit its old response');
  });

  it('discards an in-flight response after the account is removed', async () => {
    const pending = deferred();
    installDeps({ fetchConnectCatalog: () => pending.promise });

    const acct = mk('sk-remove-inflight', 'pro');
    assert.equal(fetchCalls.length, 1);
    created.splice(created.indexOf(acct.id), 1);
    removeAccount(acct.id);

    pending.resolve([{ selector: 'stale-after-remove' }]);
    await flushAsync();

    assert.deepEqual(liveWrites, [], 'a removed account cannot commit its old response');
  });

  it('discards an in-flight response after the account key changes', async () => {
    const pending = new Map();
    installDeps({
      fetchConnectCatalog: ({ token }) => {
        const request = deferred();
        pending.set(token, request);
        return request.promise;
      },
    });

    const acct = mk('sk-key-before', 'pro');
    setAccountTokens(acct.id, { apiKey: 'sk-key-after' });
    assert.deepEqual(fetchCalls, ['sk-key-before', 'sk-key-after']);

    pending.get('sk-key-after').resolve([{ selector: 'current-key-selector' }]);
    await __waitForModelCatalogSync();
    pending.get('sk-key-before').resolve([{ selector: 'stale-key-selector' }]);
    await flushAsync();

    assert.deepEqual(lastUnion(), ['current-key-selector']);
    assert.ok(!liveWrites.flat().includes('stale-key-selector'),
      'the retired key must never write, even when it resolves last');
  });

  it('withdraws already-accepted rows immediately when an account key rotates', async () => {
    installDeps({
      perAccount: {
        'sk-stable-baseline': [{ selector: 'baseline-selector' }],
        'sk-accepted-before': [{ selector: 'paid-selector-from-retired-key' }],
        'sk-empty-after': [],
      },
    });

    mk('sk-stable-baseline', 'free');
    await __waitForModelCatalogSync();
    const acct = mk('sk-accepted-before', 'pro');
    await __waitForModelCatalogSync();
    assert.deepEqual(lastUnion(), ['baseline-selector', 'paid-selector-from-retired-key']);

    setAccountTokens(acct.id, { apiKey: 'sk-empty-after' });
    assert.deepEqual(lastUnion(), ['baseline-selector'],
      'key invalidation must synchronously withdraw rows accepted under the retired credential');
    await __waitForModelCatalogSync();
    assert.deepEqual(lastUnion(), ['baseline-selector'],
      'an empty replacement catalog must not resurrect selectors from the old key');
  });

  it('ignores a retired lifecycle rejection instead of extending the replacement backoff', async () => {
    let now = 4_500_000;
    const pending = [];
    installDeps({
      now: () => now,
      fetchConnectCatalog: () => {
        const request = deferred();
        pending.push(request);
        return request.promise;
      },
    });

    const acct = mk('sk-reject-aba', 'pro');
    setAccountStatus(acct.id, 'disabled');
    setAccountStatus(acct.id, 'active');
    assert.equal(pending.length, 2);

    pending[0].reject(new Error('retired lifecycle failure'));
    await flushAsync();
    pending[1].resolve([]);
    await __waitForModelCatalogSync();

    now += RETRY_BASE_MS;
    trySyncModelCatalog();
    assert.equal(pending.length, 3,
      'only lifecycle B\'s empty response may arm backoff; lifecycle A rejection must be ignored');
    pending[2].resolve([{ selector: 'replacement-after-one-delay' }]);
    await __waitForModelCatalogSync();
    assert.deepEqual(lastUnion(), ['replacement-after-one-delay']);
  });

  it('uses request identity to defeat same-key disable-enable ABA', async () => {
    const pending = [];
    installDeps({
      fetchConnectCatalog: () => {
        const request = deferred();
        pending.push(request);
        return request.promise;
      },
    });

    const acct = mk('sk-same-key-aba', 'pro');
    assert.equal(pending.length, 1, 'precondition: lifecycle A has one pending request');

    setAccountStatus(acct.id, 'disabled');
    setAccountStatus(acct.id, 'active');
    assert.equal(pending.length, 2,
      'lifecycle B must start a fresh request even though the key string is unchanged');

    pending[0].resolve([{ selector: 'stale-lifecycle-a' }]);
    await flushAsync();
    assert.deepEqual(liveWrites, [],
      'matching active status and key are insufficient: lifecycle A must still be rejected');

    pending[1].resolve([{ selector: 'current-lifecycle-b' }]);
    await __waitForModelCatalogSync();
    assert.deepEqual(lastUnion(), ['current-lifecycle-b']);
  });

  it('withdraws a removed account\'s contribution from the union', async () => {
    installDeps({
      perAccount: {
        'sk-stay-acct': [{ selector: 'swe-1-6-slow' }],
        'sk-go-acct': [{ selector: 'claude-opus-4-8-medium' }],
      },
    });

    const stay = mk('sk-stay-acct', 'free');
    await __waitForModelCatalogSync();
    const go = mk('sk-go-acct', 'pro');
    await __waitForModelCatalogSync();
    assert.equal(lastUnion().length, 2, 'precondition: both contributed');

    // Remove the paid account directly (not via the shared cleanup list).
    const goIndex = created.indexOf(go.id);
    if (goIndex >= 0) created.splice(goIndex, 1);
    removeAccount(go.id);
    await __waitForModelCatalogSync();

    assert.ok(!lastUnion().includes('claude-opus-4-8-medium'),
      'a departed account must stop widening pool-wide discovery');
    assert.ok(stay.id, 'the remaining account is untouched');
  });

  it('does not re-fetch an account whose key has not changed', async () => {
    // The de-latch must not turn into a per-request fetch storm: the recorded value
    // is the apiKey, so an unchanged key stays satisfied and is skipped.
    installDeps({
      perAccount: { 'sk-stable-acct': [{ selector: 'swe-1-6-slow' }] },
    });

    const acct = mk('sk-stable-acct', 'pro');
    await __waitForModelCatalogSync();
    const firstCount = fetchCalls.filter((k) => k === 'sk-stable-acct').length;
    assert.equal(firstCount, 1, 'precondition: synced exactly once');

    // Drive the same trigger again with nothing changed.
    setAccountStatus(acct.id, 'active');
    await __waitForModelCatalogSync();

    assert.equal(
      fetchCalls.filter((k) => k === 'sk-stable-acct').length,
      firstCount,
      'an unchanged key must not be re-fetched — the de-latch is per key, not per call',
    );
  });
});