/**
* RefReconcilerManager — gip-overlay P3e consumer lifecycle.
*
* One RefReconciler per repo, lazily started. ensureFor(repo):
* - opens the per-repo HyperIssues tracker (the replicated ref + bundle
* authority),
* - inits the bare repo if it doesn't exist yet (a peer that joined a repo
* wants a local materialization),
* - wires the overlay-backed ensureObjects (fetch missing objects via the
* bundle chain),
* - starts a RefReconciler so the bare repo follows the autobase.
*
* Called from the join-repo path (a peer materializes what it joined) and the
* owner publish path (keeps the owner's bare repo consistent with the view —
* a near no-op there). Idempotent + in-flight-deduped per repo.
*/
import { join } from '#node:path'
import fs from '#node:fs'
import { execFileSync } from '../_compat-exec.js'
import { invalidateRepoActivity } from './repo-activity.js'
import { recordRefActivity } from './repo-ref-activity.js'
import { RefReconciler } from './ref-reconciler.js'
import { makeReconcilingEnsureObjects } from '../git-overlay-fetch.js'
import { ensureExportMarker } from '../repo-access.js'
export class RefReconcilerManager {
constructor (opts = {}) {
this._issueRegistry = opts.issueRegistry
this._overlay = opts.overlay // HyperOverlayV2
this._reposDir = opts.reposDir
// Lazy accessor for the hyper-sync server (created AFTER this manager), so
// the ensureObjects fallback can punch:// PACK-pull missing git objects.
this._getSync = typeof opts.getSync === 'function' ? opts.getSync : (() => null)
// Lazy for the same reason: the retired-set is folded from the RepoEventLog
// AFTER this manager is constructed.
this._isRetired = typeof opts.isRetired === 'function' ? opts.isRetired : (() => false)
this._git = opts.git || execFileSync
this._log = opts.logger || { warn () {}, info () {} }
this._recs = new Map() // repoName -> RefReconciler
this._starting = new Map() // repoName -> Promise<RefReconciler>
}
get enabled () {
return !!(this._issueRegistry && this._overlay && this._reposDir)
}
async ensureFor (repoName) {
if (!repoName || !this.enabled) return null
// TOMBSTONE GATE. This is resurrection path 4 — "residual references:
// issue-registry-index.json, tracker autobases" in services/retired-repos.js.
// Opening a tracker here calls getForRepo(), and the block below runs
// `git init --bare` when the directory is missing, so a retired repo whose
// tracker is still in the index came straight back on the next boot.
//
// Observed on the live fleet: after retiring 16 repos and removing their
// bytes, a restart re-created exactly the two that had real tracker
// autobases (nvidia-rtx-godot, pear-git-v2) as empty 124K skeletons —
// still tombstoned, still listed as retired, and back on disk anyway. The
// other 14 had no tracker to open and stayed gone.
//
// reconcileAllRepoRefs already gates its readdir on isRetired; this path
// was the one left open.
if (this._isRetired(repoName)) {
this._log.info('[ref-reconcile] skipping retired repo ' + repoName)
return null
}
if (this._recs.has(repoName)) return this._recs.get(repoName)
if (this._starting.has(repoName)) return this._starting.get(repoName)
const opening = (async () => {
const tracker = await this._issueRegistry.getForRepo(repoName)
const repoPath = join(this._reposDir, repoName + '.git')
if (!fs.existsSync(repoPath)) {
fs.mkdirSync(this._reposDir, { recursive: true })
this._git('git', ['init', '--bare', '-q', repoPath], { encoding: 'utf8' })
// Materializing a repo this peer JOINED. Safe to mark public: an
// unexported repo is never onboarded, so it never replicates — what we
// just materialized was already public at the source (#138).
ensureExportMarker(this._reposDir, repoName)
}
const ensureObjects = makeReconcilingEnsureObjects({
tracker, overlay: this._overlay, repoPath, repoName, getSync: this._getSync, log: this._log
})
// Replication-side cache invalidation. A ref that arrives via the
// autobase view never fires ref-update-observed, which is where
// invalidateRepoActivity is called on the PUSH path — and materializing
// a ref does not restamp objects/pack, so the activity cache's mtime key
// still matches and the repo serves a stale commit count until the
// backstop TTL. That is exactly what the public backend was doing.
const rec = new RefReconciler({
tracker,
repoPath,
ensureObjects,
logger: this._log,
onChange: () => { invalidateRepoActivity(repoName) },
// The /repos ordering key. Recorded per pass whether or not the
// pass changed anything on disk — see repo-ref-activity.js.
onRefActivity: (ms) => { recordRefActivity(repoName, ms) }
})
await rec.start()
this._recs.set(repoName, rec)
return rec
})()
this._starting.set(repoName, opening)
try {
return await opening
} finally {
this._starting.delete(repoName)
}
}
/**
* Self-heal re-attach (#3, Increment 2). After IssueRegistry reopens a repo's
* tracker under a fresh namespace (because the old one's autobase errored),
* drop the RefReconciler still bound to the OLD tracker and re-ensure so a
* fresh one attaches to the new tracker and re-materializes refs from the
* clean re-synced view. Idempotent.
*/
async resyncFor (repoName) {
if (!repoName || !this.enabled) return null
const old = this._recs.get(repoName)
if (old) {
try { await old.stop() } catch {}
this._recs.delete(repoName)
}
this._starting.delete(repoName)
return this.ensureFor(repoName)
}
/**
* State reconciler — the `repo scan`-style sweep. Walk every known repo,
* ensure a per-repo RefReconciler exists (which materializes refs + pulls any
* missing git objects via the overlay/punch ensureObjects), and re-trigger a
* reconcile pass on already-started ones. Idempotent + cheap when up to date
* (git fetch is a no-op; the object check short-circuits). Call on boot, on a
* slow interval, and on demand. Cursor-independent — heals refs-but-no-objects
* from ANY cause (join-after, failed pull, crash mid-clone). pear-git #3.
*/
async reconcileAll () {
if (!this.enabled || !this._issueRegistry) return { scanned: 0 }
let repos = []
try { repos = this._issueRegistry.listRepos() || [] } catch { repos = [] }
let scanned = 0
let skipped = 0
for (const repoName of repos) {
try {
const existing = this._recs.get(repoName)
if (existing) {
// Ask before working. A materialized, quiet repo gains nothing from
// another listRefs() — the tracker's own `update` event already drives
// its reconcile — and doing it anyway across every repo is what made
// the sweep degrade the daemon for ~57s of every 300s (146 repos:
// 11ms -> ~450ms TTFB, git subprocess count 0 throughout, so the cost
// is per-tracker autobase work). A reconciler that predates needsPass()
// is never skipped.
if (typeof existing.needsPass === 'function' && !existing.needsPass()) {
skipped++
} else {
await existing.reconcile()
scanned++
}
} else {
await this.ensureFor(repoName) // starts + reconciles once
scanned++
}
} catch (err) {
this._log.warn('reconcileAll ' + repoName + ': ' + (err && err.message))
}
// Yield between repos. Each pass costs a per-repo `base.update()` plus
// git plumbing; run back-to-back across N repos that saturates the loop
// for the whole sweep, and every in-flight HTTP request waits it out
// (measured: 139 repos => ~27s during which the web UI served nothing).
// This is a background SAFETY NET — per-repo reconcile is already
// event-driven off each tracker's `update` — so latency here is free,
// and handing the loop back between repos keeps the daemon responsive.
await new Promise((resolve) => setImmediate(resolve))
}
return { scanned, skipped }
}
async closeAll () {
for (const rec of this._recs.values()) {
try { await rec.stop() } catch {}
}
this._recs.clear()
}
}