fix(sync): failed git pull with zero imports reports partial (pull_failed) instead of up_to_date (#3068) (#3253)

* fix(sync): report partial (pull_failed) instead of up_to_date when git pull fails with zero imports (#3068)

A warn-and-continue internal git pull failure (e.g. a local-path origin
rejected by protocol.file.allow=never) combined with a zero-import run
previously reported `up_to_date`, exited 0, and bumped the last_sync_at
freshness heartbeat. A permanently-failing pull was therefore invisible
forever: doctor's sync_freshness never fired and every scheduled sync
looked clean while the source silently went stale.

Now, when the pull failed and the run imported nothing, sync returns
`partial` with the new reason `pull_failed`, leaves last_commit AND
last_sync_at untouched (so staleness monitoring fires), and prints a
dedicated non-success message. The fall-through-to-working-tree design
is unchanged: local commits still import when the remote is unreachable,
and the anchor still advances over commits that were actually imported.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): surface pull_failed to CLI exit codes, sync --all JSON, and the cycle phase (#3068 review round)

Codex review round 1 follow-ups:

- Single-source `gbrain sync` sets exit code 1 on partial/pull_failed
  (timeout-class partials keep exit 0 — they converge on retry; a failing
  pull does not).
- `sync --all` exits 1 when any source reports pull_failed, and the
  --json envelope carries the per-source partial `reason`.
- The autopilot cycle's sync phase maps partial/pull_failed to `warn`
  with a dedicated summary and a `syncReason` detail, so a scheduled
  cycle no longer reports a clean run over a wedged source.
- The regression test now isolates GBRAIN_HOME to a temp dir so the
  first full sync cannot touch the real sync-failure ledger.
- Current-state docs: KEY_FILES.md sync.ts entry + TESTING.md inventory
  describe the pull_failed contract and the new test.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): route pull_failed exit through the owned verdict channel; make the regression test serial (#3068 review round 2)

Codex review round 2 follow-ups:

- Single-source exit now uses setCliExitVerdict(1) instead of a raw
  process.exitCode assignment, which the CLI teardown deliberately
  ignores (PGLite's Emscripten runtime clobbers process.exitCode
  mid-run; the owned channel in src/core/cli-force-exit.ts is the only
  trusted verdict). Pinned by test/cli-exit-verdict-pin.test.ts.
- The regression test is renamed to *.serial.test.ts because it pins
  GBRAIN_HOME for the whole file (scripts/check-test-isolation.sh R1);
  docs updated to the new name.

Addresses the report in #3068.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Masa
2026-07-23 14:09:30 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 22fca8f891
commit 00bcd66c3e
5 changed files with 291 additions and 6 deletions
+1
View File
@@ -189,6 +189,7 @@ Unit tests and what they cover:
- `test/orphans.test.ts` — orphans command: detection, pseudo filtering, text/json/count outputs, MCP op.
- `test/postgres-engine.test.ts``statement_timeout` scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against a reintroduced bare `SET statement_timeout`.
- `test/sync.test.ts` — sync logic + regression guard asserting top-level `engine.transaction` is not called.
- `test/sync-pull-failed-anchor.serial.test.ts`#3068 regression: a failed internal `git pull` (local-path origin vs `protocol.file.allow=never`) with zero imports returns `partial`/`pull_failed` (not `up_to_date`), freezes `last_commit` + `last_sync_at`, recovers after a manual pull; fall-through import of local commits preserved. Serial: pins `GBRAIN_HOME` to a temp dir for the whole file.
- `test/sync-concurrency.test.ts``autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping; `shouldRunParallel()` explicit-bypasses-floor contract; `parseWorkers()` validation rejecting `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars.
- `test/sync-parallel.test.ts` — PGLite-routed coverage of the bookmark gate under concurrency, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract.
- `test/sync-failures.test.ts``classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts` and `import-file.ts`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` `AcknowledgeResult` shape + backfill on legacy entries.
File diff suppressed because one or more lines are too long
+88 -3
View File
@@ -213,7 +213,7 @@ export interface SyncResult {
* cron operators can disambiguate timeout vs pull-timeout in monitoring.
*/
filesImported?: number;
reason?: 'timeout' | 'pull_timeout' | 'stall_timeout' | 'checkpoint_unavailable';
reason?: 'timeout' | 'pull_timeout' | 'pull_failed' | 'stall_timeout' | 'checkpoint_unavailable';
/**
* v0.42.x (#1794): cumulative file paths durably banked to the checkpoint
* across THIS run + prior resumed runs. Surfaced on every partial/blocked
@@ -1761,7 +1761,7 @@ function buildPartialResult(opts: {
modified: number;
deleted: number;
renamed: number;
reason: 'timeout' | 'pull_timeout' | 'stall_timeout' | 'checkpoint_unavailable';
reason: 'timeout' | 'pull_timeout' | 'pull_failed' | 'stall_timeout' | 'checkpoint_unavailable';
bankedFiles?: number;
}): SyncResult {
return {
@@ -1992,6 +1992,15 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
});
}
// #3068: remember a warn-and-continue pull failure. The fall-through-to-
// working-tree design stays (local commits still import when the remote is
// unreachable), but a ZERO-import sync after a failed pull must not report
// `up_to_date` / bump the freshness heartbeat — that is what made a
// permanently-failing pull (e.g. a local-path origin rejected by
// protocol.file.allow=never, #1315) invisible forever: every nightly run
// exited 0 with "Already up to date" and doctor's sync_freshness never
// fired because last_sync_at kept advancing.
let pullFailed = false;
if (!opts.noPull && !detachedHead && originRemotePresent) {
const _t0 = Date.now();
serr(`[gbrain phase] sync.git_pull start`);
@@ -2034,6 +2043,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
reason: 'pull_timeout',
});
}
pullFailed = true;
if (msg.includes('non-fast-forward') || msg.includes('diverged')) {
serr(`Warning: git pull failed (remote diverged). Syncing from local state.`);
} else {
@@ -2208,6 +2218,29 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
detachedWorkingTreeManifest.renamed.length > 0);
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) {
// #3068: the pull failed and nothing local advanced — this run imported
// NOTHING and the remote may hold commits we could not fetch. Reporting
// `up_to_date` here (and bumping the heartbeat below) is exactly the
// silent-wedge from the issue: every scheduled sync exits 0 forever while
// the source is stale. Return `partial` instead (not a clean status, and
// last_sync_at stays frozen so doctor/sources-status staleness fires).
// The anchor is untouched; the next sync retries the pull from the same
// bookmark.
if (pullFailed) {
serr(
`[sync] git pull failed and no local changes imported — reporting partial ` +
`(not up_to_date); sync anchor unchanged at ${lastCommit.slice(0, 8)}.`,
);
return buildPartialResult({
fromCommit: lastCommit,
toCommit: lastCommit,
filesImported: 0,
pagesAffected: [],
chunksCreated: 0,
added: 0, modified: 0, deleted: 0, renamed: 0,
reason: 'pull_failed',
});
}
// v0.42.52.0 (PR #22xx): bump last_sync_at as a heartbeat on every successful
// 0-changes sync. D4 invariant ("never advance last_commit on partial") is
// preserved: last_sync_at is a monitoring signal (doctor sync_freshness
@@ -2392,6 +2425,27 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
}
if (totalChanges === 0) {
// #3068: same guard as the git-HEAD-equality gate above — a failed pull
// plus zero imports must not produce a clean `up_to_date` (and must not
// advance the anchor past commits this run never looked at remotely).
// Reached when local-only commits landed with no syncable content while
// the pull kept failing. Nothing is written; the next sync re-diffs the
// same trivial range and retries the pull.
if (pullFailed) {
serr(
`[sync] git pull failed and no syncable changes imported — reporting partial ` +
`(not up_to_date); sync anchor unchanged at ${lastCommit.slice(0, 8)}.`,
);
return buildPartialResult({
fromCommit: lastCommit,
toCommit: lastCommit,
filesImported: 0,
pagesAffected: [],
chunksCreated: 0,
added: 0, modified: 0, deleted: 0, renamed: 0,
reason: 'pull_failed',
});
}
// Update sync state even with no syncable changes (git advanced). v0.42.x
// (#1794): advance to the PINNED target, and clear any checkpoint (a resume
// whose remaining range turned out to have no syncable changes still
@@ -4616,6 +4670,9 @@ See also:
status: r.status,
...(r.result ? {
sync_status: r.result.status,
// #3068: surface the partial reason (e.g. pull_failed) so JSON
// consumers can distinguish a self-healing timeout from a wedge.
...(r.result.reason ? { reason: r.result.reason } : {}),
added: r.result.added,
modified: r.result.modified,
deleted: r.result.deleted,
@@ -4637,7 +4694,14 @@ See also:
// Best-effort, stderr-only; skipped on dry-run.
if (!dryRun) await maybeExtractionNudge(engine);
if (errCount > 0) process.exit(1);
// #3068: any source wedged on a failed pull (partial/pull_failed) makes
// the whole --all run non-zero — it will not self-heal on retry, so a
// green exit would hide it from cron/monitoring. Timeout-class partials
// keep the pre-existing exit-0 behavior (they converge on retry).
const pullFailedCount = perSourceResults.filter(
(r) => r.status === 'ok' && r.result?.status === 'partial' && r.result.reason === 'pull_failed',
).length;
if (errCount > 0 || pullFailedCount > 0) process.exit(1);
return;
}
@@ -4725,6 +4789,16 @@ See also:
process.off('SIGINT', onSingleSourceSigint);
}
printSyncResult(result);
// #3068: a pull_failed partial is NOT a success — unlike timeout-class
// partials (which converge on retry), a failing pull will not self-heal.
// Exit non-zero so cron/monitoring sees the wedge instead of a green run.
// Routed through the owned verdict channel (NOT bare `process.exitCode`,
// which PGLite's Emscripten runtime clobbers mid-run — see
// src/core/cli-force-exit.ts).
if (result.status === 'partial' && result.reason === 'pull_failed') {
const { setCliExitVerdict } = await import('../core/cli-force-exit.ts');
setCliExitVerdict(1);
}
// v0.42.7 (#1696, D5): extraction-lag nudge after a completed single-source
// sync. Fire on every non-error completion (synced | first_sync | up_to_date)
// — NOT just 'synced'; a fresh/--full import (`first_sync`) is the biggest
@@ -5430,6 +5504,17 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
case 'partial':
// #3068: a failed (non-timeout) pull with zero imports gets its own
// message — "imported 0 of 0" reads like success, but the local
// checkout may be behind a remote we could not fetch.
if (result.reason === 'pull_failed') {
write(
`Sync INCOMPLETE at ${result.fromCommit?.slice(0, 8) ?? '<initial>'}: ` +
`git pull failed — the local checkout may be behind its remote.`,
);
write(` Fix the pull (see the warning above), then re-run 'gbrain sync' (last_commit unchanged; safe to retry).`);
break;
}
// v0.41.13.0 (T7 / D-V3-5): --timeout fired before the bookmark write
// so last_commit is UNCHANGED. The next sync re-walks the same diff
// and content_hash short-circuits already-imported files at ~10ms each.
+11 -2
View File
@@ -935,13 +935,21 @@ async function runPhaseSync(
// sync's inline extract still runs to preserve prior behavior.
});
const syncedCount = result.added + result.modified;
// #3068: a pull_failed partial means the internal git pull failed and the
// run imported nothing — the source may be silently behind its remote and
// will not self-heal. Surface it as 'warn' (not 'ok') so a scheduled cycle
// doesn't report a clean run over a wedged source. Timeout-class partials
// keep the pre-existing 'ok' mapping (they converge on retry by design).
const pullFailedPartial = result.status === 'partial' && result.reason === 'pull_failed';
return {
phase: 'sync',
status: result.status === 'blocked_by_failures' ? 'warn' : 'ok',
status: result.status === 'blocked_by_failures' || pullFailedPartial ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${syncedCount} page(s) would sync, ${result.deleted} would delete`
: `+${result.added} added, ~${result.modified} modified, -${result.deleted} deleted`,
: pullFailedPartial
? `git pull failed, nothing imported — source may be behind its remote (sync anchor unchanged)`
: `+${result.added} added, ~${result.modified} modified, -${result.deleted} deleted`,
details: {
added: result.added,
modified: result.modified,
@@ -950,6 +958,7 @@ async function runPhaseSync(
chunksCreated: result.chunksCreated,
failedFiles: result.failedFiles ?? 0,
syncStatus: result.status,
...(result.reason ? { syncReason: result.reason } : {}),
dryRun,
},
pagesAffected: result.pagesAffected,
+190
View File
@@ -0,0 +1,190 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
/**
* #3068 regression: a failed internal `git pull` (warn-and-continue class,
* e.g. a local-filesystem-path origin rejected by the SSRF flag
* `protocol.file.allow=never`) combined with a zero-import run must NOT be
* reported as a clean `up_to_date` sync:
*
* - status must be `partial` with reason `pull_failed` (not `up_to_date`),
* - `last_commit` (the sync anchor) must not move,
* - `last_sync_at` (the freshness heartbeat doctor/sources-status read)
* must not be bumped — otherwise a permanently-failing pull keeps the
* source looking fresh forever while it silently goes stale.
*
* The fall-through-to-working-tree design is unchanged: local commits still
* import when the remote is unreachable, and once the operator repairs the
* checkout (e.g. a manual `git pull`, which does not carry the SSRF flags)
* the next sync imports the missed content from the untouched anchor.
*
* The local-path-origin topology below reproduces the pull failure
* deterministically: `pullRepo` always passes `-c protocol.file.allow=never`,
* so its internal pull fails on every cycle while plain `git pull` succeeds.
*/
describe('#3068: failed git pull + zero imports must not report up_to_date', () => {
let engine: PGLiteEngine;
const dirs: string[] = [];
// Hermetic GBRAIN_HOME: performFullSync (first sync) reads/writes the
// sync-failure ledger under the gbrain home — never touch the real one.
let isolatedHome: string;
let origGbrainHome: string | undefined;
beforeAll(async () => {
origGbrainHome = process.env.GBRAIN_HOME;
isolatedHome = mkdtempSync(join(tmpdir(), 'gbrain-3068-home-'));
process.env.GBRAIN_HOME = isolatedHome;
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
if (origGbrainHome !== undefined) process.env.GBRAIN_HOME = origGbrainHome;
else delete process.env.GBRAIN_HOME;
rmSync(isolatedHome, { recursive: true, force: true });
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (dirs.length) {
const d = dirs.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
function mkUpstream(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-3068-upstream-'));
dirs.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
/** Clone `upstream` to a sibling temp dir — origin is a local filesystem path. */
function mkMirror(upstream: string): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-3068-mirror-'));
dirs.push(dir);
rmSync(dir, { recursive: true, force: true });
execSync(`git clone ${JSON.stringify(upstream)} ${JSON.stringify(dir)}`, { stdio: 'pipe' });
return dir;
}
async function sourceRow(): Promise<{ last_commit: string | null; last_sync_at: string | null }> {
const rows = await engine.executeRaw<{ last_commit: string | null; last_sync_at: string | null }>(
`SELECT last_commit, last_sync_at FROM sources WHERE id = 'default'`,
);
return rows[0] ?? { last_commit: null, last_sync_at: null };
}
// Pull ENABLED (no noPull) — the internal pull must actually run and fail.
const SYNC_OPTS = { noEmbed: true, noExtract: true, sourceId: 'default' } as const;
test('zero-import sync after failed pull reports partial/pull_failed; anchor and heartbeat frozen; manual pull recovers', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
// First sync: the internal pull already fails (local-path origin), but the
// fall-through imports the working tree — unchanged behavior.
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const afterFirst = await sourceRow();
expect(afterFirst.last_commit).not.toBeNull();
expect(afterFirst.last_sync_at).not.toBeNull();
// New content lands upstream; the mirror's internal pull can never fetch it.
writeFileSync(join(upstream, 'people/bob.md'), personMd('Bob', 'zzuniquetoken99 new content.'));
execSync('git add -A && git commit -m "new page"', { cwd: upstream, stdio: 'pipe' });
// Wait so a (buggy) heartbeat bump would be observable as a changed timestamp.
await new Promise((r) => setTimeout(r, 1100));
// Pre-fix this reported `up_to_date`, bumped last_sync_at, and exited clean
// forever — the #3068 silent wedge.
const wedged = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(wedged.status).toBe('partial');
expect(wedged.reason).toBe('pull_failed');
expect(wedged.added + wedged.modified + wedged.deleted + wedged.renamed).toBe(0);
expect(wedged.fromCommit).toBe(afterFirst.last_commit);
expect(wedged.toCommit).toBe(afterFirst.last_commit ?? '');
const afterWedged = await sourceRow();
expect(afterWedged.last_commit).toBe(afterFirst.last_commit); // anchor unchanged
expect(afterWedged.last_sync_at).toEqual(afterFirst.last_sync_at); // heartbeat NOT bumped
// Recovery: a manual pull (no SSRF flags) fast-forwards the mirror; the
// next sync imports the missed content from the untouched anchor.
execSync('git pull', { cwd: mirror, stdio: 'pipe' });
const recovered = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(recovered.status).toBe('synced');
expect(recovered.added).toBe(1);
expect(await engine.getPage('people/bob')).not.toBeNull();
const afterRecovered = await sourceRow();
expect(afterRecovered.last_commit).not.toBe(afterFirst.last_commit); // anchor advanced with the import
});
test('failed pull with local syncable commits still imports them (fall-through preserved)', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
// A local commit in the mirror itself — importable without any pull.
execSync('git config user.email "test@test.com"', { cwd: mirror, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: mirror, stdio: 'pipe' });
writeFileSync(join(mirror, 'people/carol.md'), personMd('Carol', 'Carol is local.'));
execSync('git add -A && git commit -m "local carol"', { cwd: mirror, stdio: 'pipe' });
// The internal pull still fails, but there is real local work — the
// warn-and-continue design imports it and advances the anchor over the
// commits that were actually imported.
const synced = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(synced.status).toBe('synced');
expect(synced.added).toBe(1);
expect(await engine.getPage('people/carol')).not.toBeNull();
});
test('local no-syncable-content commit after failed pull also reports partial without advancing the anchor', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const upstream = mkUpstream({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
const mirror = mkMirror(upstream);
const first = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const anchor = (await sourceRow()).last_commit;
// A local commit with no syncable content (non-markdown file).
execSync('git config user.email "test@test.com"', { cwd: mirror, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: mirror, stdio: 'pipe' });
writeFileSync(join(mirror, 'notes.txt'), 'not syncable');
execSync('git add -A && git commit -m "local txt"', { cwd: mirror, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: mirror, ...SYNC_OPTS });
expect(result.status).toBe('partial');
expect(result.reason).toBe('pull_failed');
expect((await sourceRow()).last_commit).toBe(anchor); // anchor unchanged
});
});