diff --git a/src/commands/sync.ts b/src/commands/sync.ts index fdf852346..e739e5339 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1819,6 +1819,11 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise r.from), + ]; + const gate = await applySyncFailureGate({ sourceId: opts.sourceId ?? DEFAULT_SOURCE_ID, failedFiles, - succeededPaths, + succeededPaths: resolvedPaths, commit: pin, skipFailed: opts.skipFailed === true, advance, diff --git a/src/core/sync-failure-ledger.ts b/src/core/sync-failure-ledger.ts index de18218c2..3bc4e4c2e 100644 --- a/src/core/sync-failure-ledger.ts +++ b/src/core/sync-failure-ledger.ts @@ -651,8 +651,11 @@ export function decideSyncFailureSeverity(args: { } const blockedTooLong = Number.isFinite(oldestOpenMs) && args.nowMs - oldestOpenMs > args.failHours * 3_600_000; - const status: 'warn' | 'fail' = - unresolved.length >= 10 || blockedTooLong ? 'fail' : 'warn'; + // FAIL keys off OPEN (blocking) failures only — many open, or one blocking the + // bookmark past the fail cadence. `auto_skipped` rows already advanced the + // bookmark (indexing is NOT wedged) so they stay WARN-visible regardless of + // count, matching the state-machine contract. (#1939 adversarial finding #3.) + const status: 'warn' | 'fail' = open >= 10 || blockedTooLong ? 'fail' : 'warn'; return { status, unresolved: unresolved.length, open, auto_skipped: autoSkipped }; } diff --git a/test/e2e/sync.test.ts b/test/e2e/sync.test.ts index 92b926b11..e8cc62631 100644 --- a/test/e2e/sync.test.ts +++ b/test/e2e/sync.test.ts @@ -628,4 +628,30 @@ describeE2E('E2E: sync --skip-failed structured summary loop (v0.22.12, issue #5 else process.env.GBRAIN_SYNC_AUTOSKIP_AFTER = prevThreshold; } }); + + // issue #1939 adversarial finding #1: a parse-failed file that is later DELETED + // from the repo must not leave a permanent open ledger row (which would age + // doctor to FAIL forever). The incremental gate treats removed paths as resolved. + test('deleting a failed file clears its ledger row (self-heal, no stuck FAIL)', async () => { + const { performSync } = await import('../../src/commands/sync.ts'); + const { loadSyncFailures } = await import('../../src/core/sync.ts'); + const engine = getEngine(); + + writeFileSync(join(repoPath, 'people/gonepoison.md'), [ + '---', 'type: person', 'title: Gone', 'slug: wrong-derived-slug', '---', '', 'Body.', + ].join('\n')); + execSync('git add -A && git commit -m "add a file that fails to parse"', { cwd: repoPath, stdio: 'pipe' }); + + // Sync blocks; an open ledger row exists for the bad file. + let result = await performSync(engine, { repoPath, noPull: true, noEmbed: true }); + expect(result.status).toBe('blocked_by_failures'); + expect(loadSyncFailures().some(f => f.path.includes('gonepoison'))).toBe(true); + + // Delete the file and sync. The removed path is treated as resolved, so the + // ledger row is cleared and the bookmark advances. + execSync('git rm people/gonepoison.md && git commit -m "delete the bad file"', { cwd: repoPath, stdio: 'pipe' }); + result = await performSync(engine, { repoPath, noPull: true, noEmbed: true }); + expect(result.status).not.toBe('blocked_by_failures'); + expect(loadSyncFailures().some(f => f.path.includes('gonepoison'))).toBe(false); + }); }); diff --git a/test/sync-failure-ledger.serial.test.ts b/test/sync-failure-ledger.serial.test.ts index f52123d22..76eab4e3a 100644 --- a/test/sync-failure-ledger.serial.test.ts +++ b/test/sync-failure-ledger.serial.test.ts @@ -189,10 +189,17 @@ describe('#1 severity — auto_skipped stays visible (WARN)', () => { nowMs: now, failHours, }).status).toBe('fail'); - // 10 recent open → fail (count) + // 10 recent OPEN → fail (count of blocking failures) const ten = Array.from({ length: 10 }, (_, i) => ({ ...base, path: `a${i}.md`, state: 'open' as const, ts: '2026-06-06T23:00:00Z' })); expect(decideSyncFailureSeverity({ entries: ten, nowMs: now, failHours }).status).toBe('fail'); + // 10 recent AUTO_SKIPPED → still warn (#3): the valve already advanced the + // bookmark, so indexing is not wedged. Visible, not gating. + const tenSkipped = Array.from({ length: 10 }, (_, i) => ({ ...base, path: `s${i}.md`, state: 'auto_skipped' as const, ts: '2026-06-06T23:00:00Z' })); + const skSev = decideSyncFailureSeverity({ entries: tenSkipped, nowMs: now, failHours }); + expect(skSev.status).toBe('warn'); + expect(skSev.auto_skipped).toBe(10); + // auto_skipped only → warn (still visible), counted const sev = decideSyncFailureSeverity({ entries: [{ ...base, state: 'auto_skipped', ts: '2026-06-01T00:00:00Z' }],