From 54bfd3fdcc2f27ea33fddac646f41ba5be4ac9e8 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 22:18:01 +0200 Subject: [PATCH 1/2] fix: surface persistent empty discovery --- src/orchestrator/factory.test.ts | 124 +++++++++++++++++++++++++ src/orchestrator/factory.ts | 146 +++++++++++++++++++++++++++++- src/orchestrator/public-health.ts | 30 ++++++ src/types.ts | 28 ++++++ 4 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 4a1f18c8..eeb5e47d 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -5783,6 +5783,130 @@ describe('FactoryLoop', () => { } }) + it('classifies an authoritative empty GitHub index without inventing tree reads', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-empty-index-signal-')) + const mount = new CountingListTreeMount({ + '/github/repos/AgentWorkforce/pear/issues/_index.json': [], + }) + const factory = createFactory(config({ + issueSource: 'github', + safety: { requireLabel: 'factory', requireTitlePrefix: '[factory]' }, + loop: { registryPath: join(root, 'registry.json'), heartbeatPath: join(root, 'heartbeat.json') }, + }), { + mount, + fleet: new RemoteLifecycleFleetClient(), + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + }) + try { + await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe', reconcileIntervalMs: 600_000 } }) + expect(factory.status().readinessReconcile).toMatchObject({ + candidates: 0, + treeReads: 0, + discoveryReposConfigured: 1, + discoveryIndexRepos: 1, + discoveryIndexEmptyRepos: 1, + discoveryCacheRepos: 0, + discoveryTreeRepos: 0, + consecutiveEmptySweeps: 1, + emptySweepWarningThreshold: 3, + }) + expect(mount.listTreePrefixes).toEqual([]) + } finally { + await factory.stop() + await rm(root, { recursive: true, force: true }) + } + }) + + it('warns once after three cache-backed empty sweeps with zero tree reads', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-empty-cache-signal-')) + const mount = new CountingListTreeMount() + mount.emit(changeEvent('/factory/observability/mount-health/current.json', 'event-empty-cache')) + const stateStore = new InMemoryStateStore({ batchSize: 2 }) + const make = (logger: { warn?: (message: string, details?: unknown) => void } = {}) => createFactory(config({ + issueSource: 'github', + safety: { requireLabel: 'factory', requireTitlePrefix: '[factory]' }, + loop: { registryPath: join(root, 'registry.json'), heartbeatPath: join(root, 'heartbeat.json') }, + }), { + mount, + fleet: new RemoteLifecycleFleetClient(), + stateStore, + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + logger, + }) + try { + const seed = make() + expect((await seed.runOnce()).pulled).toEqual([]) + await seed.stop() + expect(mount.listTreePrefixes).toHaveLength(2) + mount.listTreePrefixes.length = 0 + + const warnings: Array<{ message: string; details?: unknown }> = [] + const factory = make({ warn: (message, details) => warnings.push({ message, details }) }) + await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe', reconcileIntervalMs: 100 } }) + await vi.waitFor(() => expect(factory.status().readinessReconcile?.consecutiveEmptySweeps).toBe(3), { + timeout: 5_000, + }) + await factory.stop() + expect(factory.status().readinessReconcile).toMatchObject({ + candidates: 0, + treeReads: 0, + emptyTreeReads: 0, + discoveryReposConfigured: 1, + discoveryIndexRepos: 0, + discoveryCacheRepos: 1, + discoveryCacheEmptyRepos: 1, + discoveryTreeRepos: 0, + emptySweepWarningThreshold: 3, + }) + expect(factory.status().counters).toMatchObject({ + readinessZeroCandidateRepoSweeps: 3, + readinessZeroTreeReadRepoSweeps: 3, + readinessCacheEmptyRepoSweeps: 3, + readinessPersistentEmptyDiscoveryWarnings: 1, + }) + expect(mount.listTreePrefixes).toEqual([]) + expect(warnings.filter((entry) => entry.message.includes('persistently produced zero candidates'))).toEqual([ + expect.objectContaining({ details: expect.objectContaining({ consecutiveEmptySweeps: 3, cacheEmptyRepos: 1, treeReads: 0 }) }), + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('classifies a genuinely empty uncached tree separately from zero reads', async () => { + const root = await mkdtemp(join(tmpdir(), 'factory-empty-tree-signal-')) + const mount = new CountingListTreeMount() + const factory = createFactory(config({ + issueSource: 'github', + safety: { requireLabel: 'factory', requireTitlePrefix: '[factory]' }, + loop: { registryPath: join(root, 'registry.json'), heartbeatPath: join(root, 'heartbeat.json') }, + }), { + mount, + fleet: new RemoteLifecycleFleetClient(), + triage: new StaticTriage(), + githubWriteback: new RecordingGithubWriteback(), + }) + try { + await factory.start({ mode: 'live', liveSubscription: { transport: 'subscribe', reconcileIntervalMs: 600_000 } }) + expect(factory.status().readinessReconcile).toMatchObject({ + candidates: 0, + treeReads: 2, + emptyTreeReads: 2, + discoveryReposConfigured: 1, + discoveryIndexRepos: 0, + discoveryCacheRepos: 0, + discoveryTreeRepos: 1, + discoveryTreeEmptyRepos: 1, + }) + expect(mount.listTreePrefixes).toHaveLength(2) + } finally { + await factory.stop() + await rm(root, { recursive: true, force: true }) + } + }) + it('retains the durable GitHub tree fallback when the issue index is malformed', async () => { const root = await mkdtemp(join(tmpdir(), 'factory-github-index-cache-fallback-')) const indexPath = '/github/repos/AgentWorkforce/pear/issues/_index.json' diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index a86c1654..2f027782 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -548,6 +548,9 @@ const RELAYFILE_OPERATION_BACKSTOP_RATIO = 1.25 const DISCOVERY_SWEEP_LEASE_MS = 5 * 60_000 const DISCOVERY_SWEEP_RENEW_MS = 30_000 const READINESS_RECONCILE_FAILURE_THRESHOLD = 3 +// Three clean passes filters one-off empty indexes/checkpoints while making a +// silent discovery outage loud within two normal intervals after first sight. +const EMPTY_DISCOVERY_WARNING_THRESHOLD = 3 /** * A relayfile fault a swallowing catch must not turn into "no result". @@ -1006,6 +1009,7 @@ export class FactoryLoop implements Factory { #readinessReconcileAbandonedWait?: Promise #readinessReconcileAbandonedSinceMs?: number #readinessReconcileConsecutiveFailures = 0 + #readinessReconcileConsecutiveEmptySweeps = 0 #readinessReconcileLastDurationMs?: number #readinessReconcileLastStartedAtMs?: number #readinessReconcileLastCompletedAtMs?: number @@ -1029,6 +1033,13 @@ export class FactoryLoop implements Factory { /** Served tree reads, and how many were empty. Held even at zero. */ treeReads: number emptyTreeReads: number + discoveryReposConfigured: number + discoveryIndexRepos: number + discoveryIndexEmptyRepos: number + discoveryCacheRepos: number + discoveryCacheEmptyRepos: number + discoveryTreeRepos: number + discoveryTreeEmptyRepos: number dispatched: number skipped: number skipReasons: Partial> @@ -1191,6 +1202,20 @@ export class FactoryLoop implements Factory { #discoverySweepTreeReads = 0 /** How many of those were served with zero entries. */ #discoverySweepEmptyTreeReads = 0 + #discoverySweepReposConfigured = 0 + #discoverySweepIndexRepos = 0 + #discoverySweepIndexEmptyRepos = 0 + #discoverySweepCacheRepos = 0 + #discoverySweepCacheEmptyRepos = 0 + #discoverySweepTreeRepos = 0 + #discoverySweepTreeEmptyRepos = 0 + readonly #discoverySweepConfiguredRepoKeys = new Set() + readonly #discoverySweepIndexRepoKeys = new Set() + readonly #discoverySweepIndexEmptyRepoKeys = new Set() + readonly #discoverySweepCacheRepoKeys = new Set() + readonly #discoverySweepCacheEmptyRepoKeys = new Set() + readonly #discoverySweepTreeRepoKeys = new Set() + readonly #discoverySweepTreeEmptyRepoKeys = new Set() /** * The longest `Retry-After` any operation in this sweep advertised. * @@ -2415,6 +2440,17 @@ export class FactoryLoop implements Factory { skipReasons: factorySweepSkipReasonCounts(report.skipped), dispatchFailures: report.skipped.filter((entry) => entry.code === 'dispatch-failed').length, dispatchFailureReasons: factoryDispatchFailureReasonCounts(report.skipped), + discoveryReposConfigured: report.discoveryReposConfigured ?? 0, + discoveryIndexRepos: report.discoveryIndexRepos ?? 0, + discoveryIndexEmptyRepos: report.discoveryIndexEmptyRepos ?? 0, + discoveryCacheRepos: report.discoveryCacheRepos ?? 0, + discoveryCacheEmptyRepos: report.discoveryCacheEmptyRepos ?? 0, + discoveryTreeRepos: report.discoveryTreeRepos ?? 0, + discoveryTreeEmptyRepos: report.discoveryTreeEmptyRepos ?? 0, + treeReads: report.treeReads ?? 0, + emptyTreeReads: report.emptyTreeReads ?? 0, + consecutiveEmptySweeps: this.#readinessReconcileConsecutiveEmptySweeps, + emptySweepWarningThreshold: EMPTY_DISCOVERY_WARNING_THRESHOLD, discoveryDeferred: report.discoveryDeferred, }) } catch (error) { @@ -3389,6 +3425,14 @@ export class FactoryLoop implements Factory { this.#discoverySweepOverloads = 0 this.#discoverySweepTreeReads = 0 this.#discoverySweepEmptyTreeReads = 0 + this.#discoverySweepReposConfigured = 0 + this.#discoverySweepIndexRepos = 0 + this.#discoverySweepIndexEmptyRepos = 0 + this.#discoverySweepCacheRepos = 0 + this.#discoverySweepCacheEmptyRepos = 0 + this.#discoverySweepTreeRepos = 0 + this.#discoverySweepTreeEmptyRepos = 0 + this.#clearDiscoveryRepoSignals() this.#discoverySweepRetryAfterSeconds = undefined this.#discoverySweepProgress = false this.#startDiscoverySweepRenewal(claim.lease.epoch) @@ -3489,6 +3533,14 @@ export class FactoryLoop implements Factory { this.#discoverySweepOverloads = 0 this.#discoverySweepTreeReads = 0 this.#discoverySweepEmptyTreeReads = 0 + this.#discoverySweepReposConfigured = 0 + this.#discoverySweepIndexRepos = 0 + this.#discoverySweepIndexEmptyRepos = 0 + this.#discoverySweepCacheRepos = 0 + this.#discoverySweepCacheEmptyRepos = 0 + this.#discoverySweepTreeRepos = 0 + this.#discoverySweepTreeEmptyRepos = 0 + this.#clearDiscoveryRepoSignals() this.#discoverySweepRetryAfterSeconds = undefined this.#discoverySweepProgress = false // This sweep is over either way (committed, deferred, or lease lost) — @@ -4013,6 +4065,13 @@ export class FactoryLoop implements Factory { // is still inside that try, so the counts are this sweep's own. treeReads: this.#discoverySweepTreeReads, emptyTreeReads: this.#discoverySweepEmptyTreeReads, + discoveryReposConfigured: this.#discoverySweepReposConfigured, + discoveryIndexRepos: this.#discoverySweepIndexRepos, + discoveryIndexEmptyRepos: this.#discoverySweepIndexEmptyRepos, + discoveryCacheRepos: this.#discoverySweepCacheRepos, + discoveryCacheEmptyRepos: this.#discoverySweepCacheEmptyRepos, + discoveryTreeRepos: this.#discoverySweepTreeRepos, + discoveryTreeEmptyRepos: this.#discoverySweepTreeEmptyRepos, slackDegraded: this.#slackDegraded, ...(orphanRecoveryDegraded ? { orphanRecoveryDegraded } : {}), } @@ -6216,10 +6275,47 @@ export class FactoryLoop implements Factory { return } this.#readinessReconcileLastSweepDeferred = undefined + const configuredRepos = report.discoveryReposConfigured ?? 0 + if (configuredRepos > 0 && report.pulled.length === 0) { + this.#readinessReconcileConsecutiveEmptySweeps += 1 + this.#increment('readinessZeroCandidateRepoSweeps') + if ((report.treeReads ?? 0) === 0) this.#increment('readinessZeroTreeReadRepoSweeps') + if ((report.discoveryIndexEmptyRepos ?? 0) > 0) this.#increment('readinessIndexEmptyRepoSweeps') + if ((report.discoveryCacheEmptyRepos ?? 0) > 0) this.#increment('readinessCacheEmptyRepoSweeps') + if ((report.discoveryTreeEmptyRepos ?? 0) > 0) this.#increment('readinessTreeEmptyRepoSweeps') + // Warn only on the threshold crossing. A legitimately idle workspace + // remains observable through the streak, but does not emit once a minute + // forever. A later non-empty pass resets the edge and permits a new warn. + if (this.#readinessReconcileConsecutiveEmptySweeps === EMPTY_DISCOVERY_WARNING_THRESHOLD) { + this.#increment('readinessPersistentEmptyDiscoveryWarnings') + this.#logger.warn?.('[factory] discovery has persistently produced zero candidates for configured repositories', { + consecutiveEmptySweeps: this.#readinessReconcileConsecutiveEmptySweeps, + warningThreshold: EMPTY_DISCOVERY_WARNING_THRESHOLD, + configuredRepos, + indexRepos: report.discoveryIndexRepos ?? 0, + indexEmptyRepos: report.discoveryIndexEmptyRepos ?? 0, + cacheRepos: report.discoveryCacheRepos ?? 0, + cacheEmptyRepos: report.discoveryCacheEmptyRepos ?? 0, + treeRepos: report.discoveryTreeRepos ?? 0, + treeEmptyRepos: report.discoveryTreeEmptyRepos ?? 0, + treeReads: report.treeReads ?? 0, + emptyTreeReads: report.emptyTreeReads ?? 0, + }) + } + } else { + this.#readinessReconcileConsecutiveEmptySweeps = 0 + } this.#readinessReconcileLastSweep = { candidates: report.pulled.length, treeReads: report.treeReads ?? 0, emptyTreeReads: report.emptyTreeReads ?? 0, + discoveryReposConfigured: configuredRepos, + discoveryIndexRepos: report.discoveryIndexRepos ?? 0, + discoveryIndexEmptyRepos: report.discoveryIndexEmptyRepos ?? 0, + discoveryCacheRepos: report.discoveryCacheRepos ?? 0, + discoveryCacheEmptyRepos: report.discoveryCacheEmptyRepos ?? 0, + discoveryTreeRepos: report.discoveryTreeRepos ?? 0, + discoveryTreeEmptyRepos: report.discoveryTreeEmptyRepos ?? 0, dispatched: report.dispatched.length, skipped: report.skipped.length, skipReasons: factorySweepSkipReasonCounts(report.skipped), @@ -6339,6 +6435,15 @@ export class FactoryLoop implements Factory { // an empty workspace, not a silent mount (#351 follow-up). treeReads: this.#readinessReconcileLastSweep.treeReads, emptyTreeReads: this.#readinessReconcileLastSweep.emptyTreeReads, + discoveryReposConfigured: this.#readinessReconcileLastSweep.discoveryReposConfigured, + discoveryIndexRepos: this.#readinessReconcileLastSweep.discoveryIndexRepos, + discoveryIndexEmptyRepos: this.#readinessReconcileLastSweep.discoveryIndexEmptyRepos, + discoveryCacheRepos: this.#readinessReconcileLastSweep.discoveryCacheRepos, + discoveryCacheEmptyRepos: this.#readinessReconcileLastSweep.discoveryCacheEmptyRepos, + discoveryTreeRepos: this.#readinessReconcileLastSweep.discoveryTreeRepos, + discoveryTreeEmptyRepos: this.#readinessReconcileLastSweep.discoveryTreeEmptyRepos, + consecutiveEmptySweeps: this.#readinessReconcileConsecutiveEmptySweeps, + emptySweepWarningThreshold: EMPTY_DISCOVERY_WARNING_THRESHOLD, dispatched: this.#readinessReconcileLastSweep.dispatched, skipped: this.#readinessReconcileLastSweep.skipped, ...(Object.keys(this.#readinessReconcileLastSweep.skipReasons).length > 0 @@ -9033,10 +9138,31 @@ export class FactoryLoop implements Factory { return candidates } + #clearDiscoveryRepoSignals(): void { + this.#discoverySweepConfiguredRepoKeys.clear() + this.#discoverySweepIndexRepoKeys.clear() + this.#discoverySweepIndexEmptyRepoKeys.clear() + this.#discoverySweepCacheRepoKeys.clear() + this.#discoverySweepCacheEmptyRepoKeys.clear() + this.#discoverySweepTreeRepoKeys.clear() + this.#discoverySweepTreeEmptyRepoKeys.clear() + } + async #githubIssuePaths(): Promise { try { const issuePaths = new Map() - for (const { owner, repo } of configuredGithubRepoParts(this.#config)) { + const repos = configuredGithubRepoParts(this.#config) + // Match #listRelayfileTree's two load-bearing scopes: this method is also + // used by point lookups and startup work, so only the enumeration call + // issued by the currently leased pass may contribute to its diagnosis. + const issuingPass = discoveryEnumerationPass.getStore() + const recordDiscovery = issuingPass !== undefined && issuingPass.epoch === this.#discoverySweepEpoch + if (recordDiscovery) { + for (const { owner, repo } of repos) this.#discoverySweepConfiguredRepoKeys.add(`${owner}/${repo}`) + this.#discoverySweepReposConfigured = this.#discoverySweepConfiguredRepoKeys.size + } + for (const { owner, repo } of repos) { + const repoKey = `${owner}/${repo}` const roots = githubIssueRepoRoots(owner, repo) const cachedBatches = roots.map((root) => this.#cachedDiscoveryTree(root)) const allRootsCached = cachedBatches.every((paths): paths is string[] => paths !== undefined) @@ -9060,15 +9186,33 @@ export class FactoryLoop implements Factory { // comment-replay call sites that share this cache. pathBatches = [indexedPaths] this.#increment('githubIssueIndexReposUsed') + if (recordDiscovery) { + this.#discoverySweepIndexRepoKeys.add(repoKey) + if (indexedPaths.length === 0) this.#discoverySweepIndexEmptyRepoKeys.add(repoKey) + this.#discoverySweepIndexRepos = this.#discoverySweepIndexRepoKeys.size + this.#discoverySweepIndexEmptyRepos = this.#discoverySweepIndexEmptyRepoKeys.size + } } else if (allRootsCached) { pathBatches = cachedBatches this.#increment('githubIssueDiscoveryCacheReposUsed') + if (recordDiscovery) { + this.#discoverySweepCacheRepoKeys.add(repoKey) + if (cachedBatches.every((paths) => paths.length === 0)) this.#discoverySweepCacheEmptyRepoKeys.add(repoKey) + this.#discoverySweepCacheRepos = this.#discoverySweepCacheRepoKeys.size + this.#discoverySweepCacheEmptyRepos = this.#discoverySweepCacheEmptyRepoKeys.size + } } else { pathBatches = [] for (const root of roots) { pathBatches.push(await this.#listRelayfileTree(root, 'GitHub issue ingestion', { cache: true, enumeration: true })) } this.#increment('githubIssueIndexFallbacks') + if (recordDiscovery) { + this.#discoverySweepTreeRepoKeys.add(repoKey) + if (pathBatches.every((paths) => paths.length === 0)) this.#discoverySweepTreeEmptyRepoKeys.add(repoKey) + this.#discoverySweepTreeRepos = this.#discoverySweepTreeRepoKeys.size + this.#discoverySweepTreeEmptyRepos = this.#discoverySweepTreeEmptyRepoKeys.size + } } for (const paths of pathBatches) { for (let index = 0; index < paths.length; index += 1) { diff --git a/src/orchestrator/public-health.ts b/src/orchestrator/public-health.ts index 79fe9707..fbb8a97d 100644 --- a/src/orchestrator/public-health.ts +++ b/src/orchestrator/public-health.ts @@ -301,6 +301,15 @@ const sweepOutcome = ( dispatchFailureReasons?: unknown treeReads?: unknown emptyTreeReads?: unknown + discoveryReposConfigured?: unknown + discoveryIndexRepos?: unknown + discoveryIndexEmptyRepos?: unknown + discoveryCacheRepos?: unknown + discoveryCacheEmptyRepos?: unknown + discoveryTreeRepos?: unknown + discoveryTreeEmptyRepos?: unknown + consecutiveEmptySweeps?: unknown + emptySweepWarningThreshold?: unknown discoveryDeferred?: unknown lastEnumeratedAtMs?: unknown enumerationCountsInvalid?: unknown @@ -315,6 +324,15 @@ const sweepOutcome = ( | 'dispatchFailureReasons' | 'treeReads' | 'emptyTreeReads' + | 'discoveryReposConfigured' + | 'discoveryIndexRepos' + | 'discoveryIndexEmptyRepos' + | 'discoveryCacheRepos' + | 'discoveryCacheEmptyRepos' + | 'discoveryTreeRepos' + | 'discoveryTreeEmptyRepos' + | 'consecutiveEmptySweeps' + | 'emptySweepWarningThreshold' | 'discoveryDeferred' | 'lastEnumeratedAtMs' | 'enumerationCountsInvalid' @@ -356,12 +374,24 @@ const sweepOutcome = ( // apart, which is why this number exists next to the breakdown. const dispatchFailures = optionalCount('dispatchFailures', status.dispatchFailures) const dispatchFailureReasons = dispatchFailureReasonCounts(status.dispatchFailureReasons) + const discoveryCounts = { + ...optionalCount('discoveryReposConfigured', status.discoveryReposConfigured), + ...optionalCount('discoveryIndexRepos', status.discoveryIndexRepos), + ...optionalCount('discoveryIndexEmptyRepos', status.discoveryIndexEmptyRepos), + ...optionalCount('discoveryCacheRepos', status.discoveryCacheRepos), + ...optionalCount('discoveryCacheEmptyRepos', status.discoveryCacheEmptyRepos), + ...optionalCount('discoveryTreeRepos', status.discoveryTreeRepos), + ...optionalCount('discoveryTreeEmptyRepos', status.discoveryTreeEmptyRepos), + ...optionalCount('consecutiveEmptySweeps', status.consecutiveEmptySweeps), + ...optionalCount('emptySweepWarningThreshold', status.emptySweepWarningThreshold), + } return { ...candidates, ...dispatched, ...skipped, ...(skipReasons ? { skipReasons } : {}), ...dispatchFailures, + ...discoveryCounts, // A breakdown with no total is an orphan: a reader cannot check that the // parts sum, which is the one integrity check this surface offers. ...(dispatchFailures.dispatchFailures !== undefined && dispatchFailureReasons diff --git a/src/types.ts b/src/types.ts index 4362117c..ee87f414 100644 --- a/src/types.ts +++ b/src/types.ts @@ -317,6 +317,18 @@ export interface FactoryReadinessReconcileStatus { */ treeReads?: number emptyTreeReads?: number + /** GitHub repositories inspected by each discovery authority in this sweep. */ + discoveryReposConfigured?: number + discoveryIndexRepos?: number + discoveryIndexEmptyRepos?: number + discoveryCacheRepos?: number + discoveryCacheEmptyRepos?: number + discoveryTreeRepos?: number + discoveryTreeEmptyRepos?: number + /** Successful enumerating sweeps in a row that found no candidates. */ + consecutiveEmptySweeps?: number + /** The one-shot warning threshold for persistent empty discovery. */ + emptySweepWarningThreshold?: number /** Work units the last enumerating sweep actually dispatched. */ dispatched?: number /** Work units the last enumerating sweep saw and declined. */ @@ -439,6 +451,15 @@ export interface FactoryPublicReadinessReconcileHealth { */ treeReads?: number emptyTreeReads?: number + discoveryReposConfigured?: number + discoveryIndexRepos?: number + discoveryIndexEmptyRepos?: number + discoveryCacheRepos?: number + discoveryCacheEmptyRepos?: number + discoveryTreeRepos?: number + discoveryTreeEmptyRepos?: number + consecutiveEmptySweeps?: number + emptySweepWarningThreshold?: number /** * When the pass the counts describe finished enumerating. Dates them — * `lastCompletedAtMs` does not, since it advances on deferred passes too. @@ -806,6 +827,13 @@ export interface IterationReport { */ treeReads?: number emptyTreeReads?: number + discoveryReposConfigured?: number + discoveryIndexRepos?: number + discoveryIndexEmptyRepos?: number + discoveryCacheRepos?: number + discoveryCacheEmptyRepos?: number + discoveryTreeRepos?: number + discoveryTreeEmptyRepos?: number /** A cross-process owner was already enumerating this workspace. */ discoveryDeferred?: 'sweep-in-flight' error?: { message: string; stack?: string } From 23d0b516d72526aacdb8ea114ac373c7245e5508 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 26 Aug 2026 22:23:52 +0200 Subject: [PATCH 2/2] fix: recheck discovery epoch after index reads --- src/orchestrator/factory.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 2f027782..6912486c 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -9156,8 +9156,9 @@ export class FactoryLoop implements Factory { // used by point lookups and startup work, so only the enumeration call // issued by the currently leased pass may contribute to its diagnosis. const issuingPass = discoveryEnumerationPass.getStore() - const recordDiscovery = issuingPass !== undefined && issuingPass.epoch === this.#discoverySweepEpoch - if (recordDiscovery) { + const recordsCurrentDiscovery = (): boolean => + issuingPass !== undefined && issuingPass.epoch === this.#discoverySweepEpoch + if (recordsCurrentDiscovery()) { for (const { owner, repo } of repos) this.#discoverySweepConfiguredRepoKeys.add(`${owner}/${repo}`) this.#discoverySweepReposConfigured = this.#discoverySweepConfiguredRepoKeys.size } @@ -9186,7 +9187,7 @@ export class FactoryLoop implements Factory { // comment-replay call sites that share this cache. pathBatches = [indexedPaths] this.#increment('githubIssueIndexReposUsed') - if (recordDiscovery) { + if (recordsCurrentDiscovery()) { this.#discoverySweepIndexRepoKeys.add(repoKey) if (indexedPaths.length === 0) this.#discoverySweepIndexEmptyRepoKeys.add(repoKey) this.#discoverySweepIndexRepos = this.#discoverySweepIndexRepoKeys.size @@ -9195,7 +9196,7 @@ export class FactoryLoop implements Factory { } else if (allRootsCached) { pathBatches = cachedBatches this.#increment('githubIssueDiscoveryCacheReposUsed') - if (recordDiscovery) { + if (recordsCurrentDiscovery()) { this.#discoverySweepCacheRepoKeys.add(repoKey) if (cachedBatches.every((paths) => paths.length === 0)) this.#discoverySweepCacheEmptyRepoKeys.add(repoKey) this.#discoverySweepCacheRepos = this.#discoverySweepCacheRepoKeys.size @@ -9207,7 +9208,7 @@ export class FactoryLoop implements Factory { pathBatches.push(await this.#listRelayfileTree(root, 'GitHub issue ingestion', { cache: true, enumeration: true })) } this.#increment('githubIssueIndexFallbacks') - if (recordDiscovery) { + if (recordsCurrentDiscovery()) { this.#discoverySweepTreeRepoKeys.add(repoKey) if (pathBatches.every((paths) => paths.length === 0)) this.#discoverySweepTreeEmptyRepoKeys.add(repoKey) this.#discoverySweepTreeRepos = this.#discoverySweepTreeRepoKeys.size