Documentation
¶
Overview ¶
bootstrap.go seeds shadow_paths from the active branch's HEAD tree before the first capture pass.
Without this, the very first capture would see every file already on disk as "unknown" → emit a 'create' event per path → drive the daemon to commit files that already exist at HEAD. The legacy daemon's bootstrap_shadow solves the same problem; this helper is its Go port.
Atomicity ¶
Seeding writes shadow_paths in fixed-size chunks (`shadowBootstrapChunkSize`) using state.AppendShadowBatch — each chunk is its own transaction with a reused prepared statement, avoiding the per-row begin/commit fsync overhead that previously wedged 30k+ file repos at startup. Chunking trades "all-or-nothing across the whole reseed" for "each chunk is atomic", but completion is still all-or-nothing at the daemon-meta level: the MetaKeyShadowBootstrapped marker is set ONLY after every chunk succeeds. On any chunk failure we delete the partial rows for the active (branch_ref, branch_generation) before returning the error — so a retry starts from an empty shadow set instead of resuming half-seeded state.
Idempotency ¶
Idempotency is keyed on a daemon_meta marker (`shadow.bootstrapped:<branch_ref>:<branch_generation>`) rather than a COUNT(*) probe. The COUNT-based check could not distinguish "fully seeded" from "crashed mid-seed" and would skip reseed after a partial failure, leaving the next capture pass to classify every tracked file as a phantom `create`. The marker is the explicit completion signal.
Capture/replay must refuse to operate on a generation without this marker. BootstrapShadow itself surfaces the gate: it returns early if the marker is already present, otherwise it does the work and writes the marker as the last step.
branch_token.go implements the branch-generation token per §8.9.
Token shape:
- "rev:<sha> <branch-ref>" when HEAD resolves to a commit on a branch. Same generation between iterations means the branch fast-forwarded (no rebase, no force-push, no branch switch).
- "rev:<sha>" when HEAD resolves while detached.
- "missing <branch-ref>" when HEAD does not resolve (orphan repo, just-init'd).
A bumped token signals a force-push or reset; the daemon records the transition in daemon_meta so operators can spot the divergence.
Generation semantics ¶
The token alone cannot distinguish a normal ACD fast-forward (the daemon just landed a commit and HEAD advanced one step) from an external rewrite (operator ran `git reset` / `git rebase` / branch switch). Both look like "the rev token changed". The daemon disambiguates by re-resolving HEAD's ancestry against the previously observed HEAD:
- newHead descends from prevHead (or prevHead == ""): ACD-style fast-forward. Generation is preserved; queued events captured against the prior HEAD remain valid because their BaseHead is still an ancestor of HEAD.
- newHead does NOT descend from prevHead, OR transitioned to/from "missing": the branch was rewritten under us. Generation bumps, daemon_meta records the transition, and any queued events captured under the old generation are terminally blocked at replay time.
The generation counter is persisted in daemon_meta under MetaKeyBranchGeneration so a daemon restart picks up the last-known value instead of resetting to 1 (which would cause stale events to look fresh).
capture.go walks the worktree, hashes every captured file into the git object store, and emits classify ops persisted into capture_events + capture_ops per §8.2.
Walk semantics carried over from the legacy daemon (snapshot-capture.py):
- filepath.WalkDir + manual symlink handling (do NOT call WalkDir on followlinks=true — this is the regression CLAUDE.md calls out).
- Symlinks always emit mode 120000 regardless of target type. NEVER descend into a symlinked directory.
- Skip nested .git (file or dir) and submodule (gitlink mode 160000).
- Skip ACD's own .git/acd state subdir.
- Sensitive default-deny via state.SensitiveMatcher.
- Generated dependency/cache tree pruning via state.SafeIgnoreMatcher.
- Gitignored paths via batch git.IgnoreChecker.
- Oversize regulars (> ACD_MAX_FILE_BYTES, default 5 MiB) -> meta-only.
- Regular files opened with O_NOFOLLOW + post-open lstat/fstat ino+dev+mode verification (TOCTOU defense against symlink swap).
classify.go diffs a captured live worktree map against the persisted shadow_paths table and emits create/modify/delete/mode/rename ops per §8.2.
Rename heuristic: when the (oid, mode) signature of a deletion uniquely matches a single creation (no other create shares it), we pair them as a rename. Multi-match collisions fall back to plain create+delete because guessing wrong would produce a misleading commit history.
Package daemon implements the long-running per-repo capture+replay loop.
The exported entry point is Run, which composes all the Phase 1 building blocks (capture, replay, refcount, prune, lock, signals, scheduler) into the loop body §8.1 specifies.
Run is single-goroutine: every per-tick mutation happens on the run-loop goroutine. Signals dispatch via os/signal in a small helper goroutine but only push notifications onto buffered channels — the loop itself reads those channels and never holds shared state outside its own stack.
dead_branch_sweep.go houses the helpers that prune unpublished capture_events rows (pending + blocked_conflict + failed) whose owning branch ref no longer resolves. Two callers live here:
- the runtime Diverged transition path (daemon.go) calls pruneDeadBranchTerminals after the prior generation's pending rows have already been swept by DeletePendingForGeneration. When the prior branch ref is gone, its terminal rows (and any pending rows that escaped the generation-only sweep, e.g. captured under a different active generation) would otherwise accumulate forever — `acd status` and the PendingEvents barrier path would surface phantom blocked counts for a branch the operator has long since deleted.
- daemon Run init schedules runStartupDeadBranchSweep on a goroutine after the running-mode publish so a daemon restart that discovers pre-existing dead-branch rows cleans them up off the blocking startup path (the sweep can shell out to git for-each-ref and walk the entire terminal-pair set, neither of which we want on the start-latency budget).
Both paths honor EnvKeepDeadBranchBarriers as an operator opt-out — set it truthy when you want to keep the rows around for forensic inspection.
Pending + terminal must drop together for the dead-branch case. Leaving pending rows behind while deleting their terminal predecessor lets PendingEvents re-expose them on the next replay pass; replay then re-evaluates them against the prior (now-irrelevant) generation, mismatches in checkEventGeneration, and stamps a fresh blocked_conflict. The state-layer helper PurgeUnpublishedForDeadBranch enforces this in one transaction.
fsnotify_watcher.go implements the recursive fsnotify watcher per §8.5 (D11 hybrid: fsnotify is the low-latency wake source; the poll loop is the safety net that runs regardless).
Behavior, in one paragraph: at construction the watcher pre-walks the repo (skipping `.git`, gitignored, sensitive, submodules, and never descending symlinked directories — the regression CLAUDE.md calls out) and registers an fsnotify watch per directory. If the platform watch budget would be exceeded, it falls back to poll-only mode immediately and records the reason on `daemon_meta`. While running, it coalesces bursts of events into a leading-edge wake plus a trailing-edge debounce (default 100ms) with a hard tail clamp (500ms) so a continuous auto-formatter cannot starve the wake. Directory creates seen on the dispatch goroutine are handed off to a sibling worker that performs the recursive re-walk; the dispatch goroutine itself never blocks on IgnoreChecker round-trips so fsnotify Events do not back up. New directories created at runtime become watched too; if that push exceeds the budget mid-flight the watcher transparently falls back to poll-only. ACD_DISABLE_FSNOTIFY=1 forces poll-only at construction time.
The watcher is safe to Stop concurrently with Start; both honor the passed context.
lock.go provides per-repo flock primitives:
- daemon.lock — held exclusively by the live daemon for its entire run. Contention => another daemon is already alive; the caller exits with EX_TEMPFAIL (75) so wrappers can distinguish "peer running" from "started cleanly".
- control.lock — held briefly by `acd start`/`stop`/`wake`/`touch` to serialize read-modify-write of daemon_clients. The daemon itself does NOT hold this except during sweeps where the GC needs an atomic view of the table. Brief acquisition/release pattern.
Implementation uses syscall.Flock (no cgo). LOCK_EX | LOCK_NB returns immediately if the lock is contended, which is what every caller wants.
message.go is the daemon-side adapter onto the Phase 5 ai package.
Phase 1 owned a local rule-based generator; Phase 5 (this lane) moved the canonical implementation into internal/ai/deterministic.go so the replay path can swap providers without code churn here. This file is now a thin wrapper that:
- translates the daemon's EventContext into ai.CommitContext;
- invokes the ai.Provider's Generate;
- composes the resulting Result.Subject + Result.Body into the single-string message MessageFn returns.
Output is **byte-identical** to the previous Phase 1 implementation: single-op events produce just the subject, multi-op events produce `subject + "\n\n" + bullets`. Existing replay tests pin the subject shape and continue to pass unchanged.
Diff text reconstruction ------------------------ Network-bound providers (openai-compat, plugin subprocess) want a unified diff describing the captured change so the model can produce a commit subject grounded in the actual delta. The diff is rebuilt from the per-op `before_oid` / `after_oid` blobs persisted at capture time (NOT from the live worktree, which may have moved on by the time the drain runs). Implementation choice: shell `git diff --no-color --no-ext-diff <before> <after>` per op and rewrite the synthetic `a/<oid>` `b/<oid>` paths with the captured path. This keeps us on the same git binary the rest of the daemon already drives — no second diff library, no bespoke text-diff implementation. For create/delete we substitute the well-known empty-blob OID (`e69de29bb2d1d6434b8b29ae775ad8c2e48c5391`) for the missing side. Diff egress is gated on TWO signals:
- The selected provider declares NeedsDiff=true (network-bound providers do; the deterministic provider does not).
- The operator has opted in via ACD_AI_DIFF_EGRESS=1.
Both must be true for the daemon to populate CommitContext.DiffText. The provider-level gate prevents wasting work on providers that ignore diffs; the operator-level gate prevents silently shipping reconstructed source bytes off the host on upgrade. The legacy ACD_AI_SEND_DIFF env var is deprecated and ignored; a one-shot warn fires at startup when it is set.
poll_scheduler.go implements the run-loop's idle/error backoff per §8.6.
Pure function: no goroutine ownership; the run loop owns timers. The scheduler simply tells the loop "given this current delay, what should the next delay be on idle/error/success?".
Defaults:
- Base: 750ms
- IdleCeiling: 30s
- ErrorCeiling: 60s
Tests can construct a Scheduler with smaller bases/ceilings to keep the suite fast (Base = 10ms etc.).
prune.go drops stale terminal capture_events rows so the per-repo state DB does not grow without bound. Terminal failure rows are retained while they still form an active replay barrier for later pending events.
Default retention is 7 days; override via env ACD_EVENT_RETENTION_DAYS.
refcount.go implements the daemon_clients GC sweep + self-terminate gate per §3.4 + §8.4.
A row is dead when ANY of these holds:
- last_seen_ts + TTL < now (TTL refresh expired)
- watch_pid != 0 AND !identity.Alive (peer died)
- watch_pid != 0 AND fingerprint != stored (PID reuse defense)
The daemon self-terminates after EmptySweepThreshold consecutive sweeps returning alive==0, but only past BootGrace from boot — a short-lived `acd start` that races the daemon's first sweep must not get evicted.
replay.go drains pending capture_events into per-event commits per §8.3.
Atomic-per-file: every event becomes ONE commit. Coalescing multi-file events into a single commit is OFF by default in v1 — even when an event happens to carry multiple ops, a single commit is produced via the legacy update-index --index-info path.
AI commit messages land in Phase 5 (internal/ai). Phase 1 ships a deterministic message helper in this package; the run loop wires it via the MessageFn hook so Phase 5 can swap the implementation without touching the replay state machine.
Package daemon — per-repo daily rollup aggregator (§8.10).
Once per UTC day boundary, RunDailyRollup walks from the day after the last successfully-rolled day through yesterday (inclusive), and writes one daily_rollups row per day into the per-repo state.db.
daily_rollups is the long-term backward-compat anchor (D9 / §6.1) and only supports INSERT OR IGNORE. Re-running RunDailyRollup is a no-op for any day already rolled. The aggregator advances the "rollup.last_day" daemon_meta key only after a day's row is committed, so a crash mid-day re-rolls that day next iteration without losing or duplicating numbers.
shadow_io.go bridges the daemon-internal classify shapes to the state.ShadowPath / sql.NullString types persisted in shadow_paths.
signals_unix.go installs SIGTERM/SIGINT/SIGUSR1 handlers and exposes the channels the run loop uses to coalesce wakes and observe shutdown.
SIGUSR1 wakes are coalesced — the run loop only needs to know "wake at least once", so a buffered channel of capacity 1 is sufficient and we drop duplicate sends when the buffer is full. Mirrors the legacy snapshot-daemon's wake_event Event semantics.
SIGTERM/SIGINT both flow through the same Shutdown channel; the run loop treats them identically (graceful drain + return).
Index ¶
- Constants
- Variables
- func BootstrapShadow(ctx context.Context, repoDir string, db *state.DB, cctx CaptureContext) (int, error)
- func BranchGenerationToken(ctx context.Context, repoDir string) (string, error)
- func BuildOpsDiff(ctx context.Context, repoRoot string, ops []state.CaptureOp) (string, error)
- func ClampRewindGraceAtStartup(ctx context.Context, db *state.DB, now time.Time) (clamped bool, original, replacement string, err error)
- func DeterministicMessage(ctx context.Context, ec EventContext) (string, error)
- func FingerprintToken(fp identity.Fingerprint) string
- func GitOperationInProgress(gitDir string) (string, bool)
- func IsShadowBootstrapped(ctx context.Context, db *state.DB, branchRef string, generation int64) (bool, error)
- func LoadBranchGeneration(ctx context.Context, db *state.DB) (int64, error)
- func LoadBranchHead(ctx context.Context, db *state.DB) (string, error)
- func PruneCaptureEvents(ctx context.Context, db *state.DB, now time.Time, retention time.Duration) (int, error)
- func ReseedShadowFromHead(ctx context.Context, repoDir string, db *state.DB, cctx CaptureContext) (int, error)
- func Run(ctx context.Context, opts Options) error
- func RunDailyRollup(ctx context.Context, db *state.DB, opts RunDailyRollupOpts) (int, error)
- func SameGeneration(a, b string) bool
- func SaveBranchGeneration(ctx context.Context, db *state.DB, generation int64, head string) error
- func ShadowBootstrappedKey(branchRef string, generation int64) string
- func ShouldSelfTerminate(emptySweepCount int, sinceBoot time.Duration, opts SelfTerminateOpts) bool
- func SweepClients(ctx context.Context, db *state.DB, now time.Time, opts SweepOpts) (int, error)
- type CaptureContext
- type CaptureOpts
- type CaptureSummary
- type ClassifiedOp
- type ControlLock
- type DaemonLock
- type EventContext
- type FsnotifyOptions
- type FsnotifyWatcher
- type LiveEntry
- type LiveIndexRepairSummary
- type MessageFn
- type Options
- type ReplayOpts
- type ReplaySummary
- type RunDailyRollupOpts
- type Scheduler
- type SelfTerminateOpts
- type ShadowEntry
- type Signals
- type SweepOpts
- type TokenTransition
- type WatcherDiagnostics
Constants ¶
const ( // MetaKeyBranchGeneration stores the integer branch_generation value // the run loop is currently using. Persisted across daemon restarts so // queued events captured under generation N remain comparable to a // freshly-booted daemon. MetaKeyBranchGeneration = "branch.generation" // MetaKeyBranchHead stores the last HEAD OID the run loop observed. // Used to drive the ancestry-based ACD-vs-external classification on // the next token transition. MetaKeyBranchHead = "branch.head" // MetaKeyBranchToken stores the raw last-known generation token // ("rev:<sha>" / "missing"). Operator-facing breadcrumb. MetaKeyBranchToken = "branch_token" // MetaKeyBranchTokenChangedAt stamps the wall-clock seconds at which // the token last changed. Operator breadcrumb only — the loop never // reads it back. MetaKeyBranchTokenChangedAt = "branch_token_changed_at" // MetaKeyDetachedHeadPaused is stamped when the daemon sees a detached // HEAD and pauses capture/replay instead of inventing a branch ref. MetaKeyDetachedHeadPaused = "detached_head_paused" // MetaKeyOperationInProgress stores the active git operation name when // capture/replay are paused for rebase, merge, cherry-pick, or bisect. MetaKeyOperationInProgress = "operation_in_progress" // MetaKeyOperationInProgressSetAt stamps the wall-clock seconds at which // MetaKeyOperationInProgress was first observed. Used to detect stale // markers (rebase aborted but the marker file lingered) and warn the // operator without auto-clearing. MetaKeyOperationInProgressSetAt = "operation_in_progress.set_at" // MetaKeyOperationInProgressHead stamps the HEAD SHA observed when the // marker first appeared. Used together with MetaKeyOperationInProgressSetAt // to decide whether HEAD has moved since the marker showed up — the // "stale" heuristic only fires when both the marker AND HEAD have been // motionless for the threshold. MetaKeyOperationInProgressHead = "operation_in_progress.head_at" // MetaKeyReplayPausedUntil stores an RFC3339 UTC timestamp until which // replay should skip drain passes after a detected branch rewind. MetaKeyReplayPausedUntil = "replay.paused_until" // MetaKeyManualPauseResumedAt stores the wall-clock seconds when // `acd resume` removed a manual pause marker. The run loop uses this // short-lived breadcrumb to distinguish a fast-forward that landed during // a manual pause from an ordinary upstream pull observed while unpaused. MetaKeyManualPauseResumedAt = "manual_pause.resumed_at" )
daemon_meta keys for the branch-generation machinery.
const ( // DefaultClientSweepInterval matches the legacy daemon's // CLIENT_SWEEP_INTERVAL_SECONDS — sweep refcount roughly every 5 // seconds. Cheap operation. DefaultClientSweepInterval = 5 * time.Second // DefaultPruneInterval matches the legacy PRUNE_INTERVAL_SECONDS — // run the capture_events pruner roughly once per minute. DefaultPruneInterval = 60 * time.Second // DefaultRollupInterval is the minimum gap between RunDailyRollup // attempts. The aggregator is also forced once per UTC-day boundary // crossing regardless of this floor. DefaultRollupInterval = 5 * time.Minute // DefaultFlushLimit caps how many flush_requests are drained per // run-loop iteration. A bursty enqueue (1500+ rows) must not starve // other Run-loop work, and the inner drain must remain context- // cancelable. Tests can override Options.FlushLimit (e.g. 1) for // tighter control. DefaultFlushLimit = 256 // OrphanFlushAckThreshold is how long a flush_request may stay in the // "acknowledged" state before the daemon's startup sweep marks it // "failed". Acknowledged-but-never-completed rows are an orphan from a // prior daemon crash between ClaimNextFlushRequest and // CompleteFlushRequest. Sweeping them at startup keeps `acd status` / // queue depth metrics from accumulating ghosts forever. OrphanFlushAckThreshold = 5 * time.Minute )
Default knobs the run loop uses when Options leaves them zero.
const ( DefaultDebounce = 100 * time.Millisecond DefaultLinuxWatchBudget = 8000 // fallback when /proc isn't readable DefaultDarwinWatchBudget = 1024 // half of typical macOS rlimit nofile WatchBudgetMargin = 0.90 // use 90% of detected platform max MaxConsecutiveErrors = 10 // streak after which we give up on fsnotify // MaxDebounceTail is the hard upper bound on how long the trailing-edge // debounce will keep deferring a wake under continuous events. Without // this clamp, an auto-formatter that fires faster than the debounce // interval would starve WakeFn forever. MaxDebounceTail = 500 * time.Millisecond )
Defaults for the watcher's tunables. Public so tests and the run loop can reference them without magic numbers.
const ( FallbackDisabled = "disabled_by_env" FallbackInitFailed = "fsnotify_init_failed" FallbackBudgetExceeded = "watch_budget_exceeded" FallbackErrorsExceeded = "errors_exceeded" )
FallbackReason values stamped into daemon_meta when fsnotify cannot run.
const ( DefaultSchedulerBase = 750 * time.Millisecond DefaultSchedulerIdleCeiling = 30 * time.Second DefaultSchedulerErrorCeiling = 60 * time.Second )
Default scheduler knobs per §8.6.
const BranchTokenMissing = "missing"
BranchTokenMissing is the canonical "no HEAD" token.
const CapDropReasonAtCap = "pending depth at cap"
CapDropReasonAtCap is the trace reason emitted when the pending-depth cap drops a captured op rather than appending it to capture_events.
const CapDropReasonBackpressureEntry = "capture saturated; skipped walk"
CapDropReasonBackpressureEntry is the trace reason emitted on the pass that first observes saturation and skips walk+classify entirely. The dropped events count for the pass is unknown (we never walked) so the trace event records the cap and the cumulative dropped-total instead.
const CaptureBackpressureClearRatio = 0.8
CaptureBackpressureClearRatio is the high-water fraction of ACD_MAX_PENDING_EVENTS at which capture lifts the durable backpressure pause. Pending must drop strictly below cap*ratio before MetaKeyCaptureBackpressurePausedAt is cleared. 0.8 keeps capture suppressed until replay has made meaningful progress, avoiding a thrash where each pass alternates between paused and resumed.
const DefaultBootGrace = 30 * time.Second
DefaultBootGrace is the window after daemon boot during which empty sweeps do NOT count toward self-termination. Allows an `acd start` that races the daemon's first sweep to register without getting evicted on the spot.
const DefaultClientTTL = 30 * time.Minute
DefaultClientTTL is the heartbeat staleness ceiling (D21).
const DefaultDiffBlobsTimeout = 5 * time.Second
DefaultDiffBlobsTimeout caps the wall-clock time spent on a single `git diff <before> <after>` call inside BuildOpsDiff. A pathological blob (giant binary, repacked alternates) can otherwise stall the commit-message rendering pass long enough to starve replay; 5s is short enough to fall back to header-only without operator-visible latency, long enough that ordinary text diffs always succeed. Tests override via diffBlobsTimeoutOverride.
const DefaultEmptySweepThreshold = 2
DefaultEmptySweepThreshold is the count of consecutive empty sweeps past BootGrace required before the run-loop self-terminates (§8.4).
const DefaultEventRetention = 7 * 24 * time.Hour
DefaultEventRetention is the default retention window for published capture_events (7 days).
const DefaultLiveIndexRepairLimit = 128
const DefaultMaxFileBytes int64 = 5 << 20
DefaultMaxFileBytes is the default per-file size cap (5 MiB).
const DefaultMaxPendingEvents = 50_000
DefaultMaxPendingEvents is the default per-generation pending-depth cap applied when EnvMaxPendingEvents is unset. 50_000 events is well above "normal capture" volume but small enough to bound memory + replay cost during a multi-day pause.
const DefaultReplayLimit = 64
DefaultReplayLimit caps a single replay pass at 64 events. Beyond this budget the daemon yields control back to the run loop so flush_requests, heartbeat refreshes, and shutdown signals are not starved by a long queue. ReplaySummary.HasMore tells the run loop whether to fire another pass immediately.
const DefaultReplayPerEventTimeout = 60 * time.Second
DefaultReplayPerEventTimeout caps the heavy git work for a single replay event (write-tree, commit-tree, update-ref retries). A pathological worktree (multi-GB rename, foreign object database, GC contention) could otherwise stall the run loop for minutes per event and starve flush requests, heartbeat refreshes, and shutdown signals. On timeout the per-event deadline fires inside the inner git op, the event is marked failed/blocked, and the batch halts so the next pass starts fresh.
const DefaultShadowRetentionGenerations int64 = 1
DefaultShadowRetentionGenerations keeps one previous generation for local inspection while bounding shadow_paths growth across repeated rebases.
const EnvClientTTLSeconds = "ACD_CLIENT_TTL_SECONDS"
EnvClientTTLSeconds is the environment knob for ACD_CLIENT_TTL_SECONDS (D21). The default is DefaultClientTTL (30 minutes).
const EnvDisableFsnotify = "ACD_DISABLE_FSNOTIFY"
EnvDisableFsnotify is the toggle that forces poll-only mode at watcher construction. Any non-empty value other than "0"/"false" disables fsnotify wakes. Mirrors the legacy SNAPSHOTD_DISABLE_FSNOTIFY knob with the new ACD_ prefix.
const EnvEventRetentionDays = "ACD_EVENT_RETENTION_DAYS"
EnvEventRetentionDays is the env knob for capture_events retention.
const EnvKeepDeadBranchBarriers = "ACD_KEEP_DEAD_BRANCH_BARRIERS"
EnvKeepDeadBranchBarriers, when truthy ("1"/"true"/"yes"/"on", case-insensitive), disables both the runtime Diverged-hook prune and the daemon-startup sweep. Operators set it when they want to inspect blocked_conflict / failed rows for branches that have since been deleted.
const EnvMaxFileBytes = "ACD_MAX_FILE_BYTES"
EnvMaxFileBytes is the per-file size cap. Mirrors the legacy SNAPSHOTD_MAX_FILE_BYTES knob with the new ACD_ prefix.
const EnvMaxInotifyWatches = "ACD_MAX_INOTIFY_WATCHES"
EnvMaxInotifyWatches lets ops cap the watch budget below the platform detected default. Useful in shared CI containers where /proc/sys/fs/inotify/max_user_watches is misleading.
const EnvMaxPendingEvents = "ACD_MAX_PENDING_EVENTS"
EnvMaxPendingEvents bounds capture_events FIFO depth for the active (branch_ref, branch_generation). When the depth meets or exceeds the cap the new event is dropped (history is preserved; only the *new* tail is refused) and a rate-limited slog.Warn fires. 0 disables the cap.
const EnvRewindGraceSeconds = "ACD_REWIND_GRACE_SECONDS"
EnvRewindGraceSeconds controls the post-rewind replay pause window. The default is intentionally short: enough for an operator's reset/revert flow to settle, but not long enough to surprise a normal daemon session.
const EnvShadowRetentionGenerations = "ACD_SHADOW_RETENTION_GENERATIONS"
EnvShadowRetentionGenerations controls how many prior shadow generations are retained after a successful reseed.
const ExitTempFail = 75
ExitTempFail is the EX_TEMPFAIL exit code from sysexits.h. Callers that observe ErrDaemonLockHeld should exit with this code.
const MetaKeyCaptureBackpressurePausedAt = "capture.backpressure_paused_at"
MetaKeyCaptureBackpressurePausedAt is the daemon_meta key whose presence signals that capture has entered durable backpressure: the pending FIFO for the active (branch_ref, branch_generation) reached ACD_MAX_PENDING_EVENTS and the daemon refuses to walk + classify until replay drains the queue below the high-water mark (or the operator explicitly accepts the loss via `acd resume --accept-overflow`). The value is the RFC3339 UTC timestamp of the FIRST observation; subsequent passes that re-encounter the saturated cap leave the timestamp untouched so operators can see how long backpressure has been active.
const MetaKeyCaptureEventsDroppedTotal = "capture.events_dropped_total"
MetaKeyCaptureEventsDroppedTotal is a cumulative counter of capture ops that the backpressure gate refused to enqueue across the lifetime of the state.db. Persisted as a base-10 int64. Surfaced via `acd diagnose --json` so operators can detect silent loss without scraping logs.
const MetaKeyDeadBranchPruneLastCount = "dead_branch_prune.last_count"
MetaKeyDeadBranchPruneLastCount records the total number of capture_events rows pruned by the most recent non-empty dead-branch prune action. Stored as a base-10 string. Reset to the new total on every non-empty prune; not cumulative.
const MetaKeyDeadBranchPruneLastRefs = "dead_branch_prune.last_refs"
MetaKeyDeadBranchPruneLastRefs is a JSON-encoded []string of the branch refs whose terminals were pruned in the most recent non-empty dead-branch prune action. The slice is bounded by deadBranchSweepRefsCap so a sweep across many stale branches does not balloon the meta payload.
const MetaKeyDeadBranchPruneLastRunTS = "dead_branch_prune.last_run_ts"
MetaKeyDeadBranchPruneLastRunTS records the wall-clock unix-seconds of the most recent dead-branch prune action that actually deleted rows (in either the runtime Diverged-hook path or the startup sweep). Operators read this via `acd diagnose --json` to reason about whether stale-branch hygiene is keeping pace. No-op sweeps (zero rows pruned) do NOT update this key — the surface is intentionally "last action that did something" so a long quiet period after an operator deletes a branch remains visible.
const MetaKeyPendingHighWater = "capture.pending_high_water"
MetaKeyPendingHighWater is the daemon_meta key under which the highest-observed pending depth (a.k.a. "watermark") is persisted for `acd diagnose --json`. Persisted as a base-10 integer string.
const MetaKeyShadowBootstrappedPrefix = "shadow.bootstrapped:"
MetaKeyShadowBootstrappedPrefix is the daemon_meta key prefix used to mark a (branch_ref, branch_generation) pair as fully seeded. The full key is formatted by ShadowBootstrappedKey.
Variables ¶
var ErrControlLockHeld = errors.New("daemon: control.lock held by another process")
ErrControlLockHeld is the equivalent for the brief control lock used by CLI subcommands.
var ErrDaemonLockHeld = errors.New("daemon: daemon.lock held by another process")
ErrDaemonLockHeld is returned by AcquireDaemonLock when another daemon already holds the per-repo daemon.lock. Callers can check via errors.Is to map onto EX_TEMPFAIL (75).
Functions ¶
func BootstrapShadow ¶
func BootstrapShadow(ctx context.Context, repoDir string, db *state.DB, cctx CaptureContext) (int, error)
BootstrapShadow seeds shadow_paths for (cctx.BranchRef, cctx.BranchGeneration) from HEAD's tree at cctx.BaseHead. Returns the number of rows seeded (0 when the marker was already present and no work was needed). A missing/empty BaseHead is a no-op (orphan repo case — the next capture pass will see every file as a create against an empty shadow, which is the correct behaviour on a brand new branch). The completion marker is still set in the orphan case so capture/replay can proceed.
Submodule entries (mode 160000) are skipped — submodules live outside the worktree the daemon owns.
On any chunk failure mid-seed, partial rows for the active (branch_ref, branch_generation) are deleted before the error is returned. The completion marker is NOT set in that case — a retry starts from an empty shadow set.
func BranchGenerationToken ¶
BranchGenerationToken returns the current generation token by resolving HEAD and the symbolic branch ref. ErrRefNotFound from git is mapped to a missing token; any other error is surfaced verbatim.
func BuildOpsDiff ¶
BuildOpsDiff reconstructs a unified diff for one event's ops by running `git diff` against the per-op `before_oid` / `after_oid` blobs. Each op contributes one diff section; sections are joined with a single newline. Soft errors per-op (missing blob, unknown op) are swallowed and the corresponding section is omitted; the function only returns an error when the underlying git binary is unreachable in a way that should surface up to the caller.
The returned text is capped to ai.DiffCap while sections are appended, so large multi-op events stop rendering once the provider budget is consumed. Callers still apply redaction + ai.Truncate before handing the diff to a provider because redaction can change the final byte length.
func ClampRewindGraceAtStartup ¶
func ClampRewindGraceAtStartup(ctx context.Context, db *state.DB, now time.Time) (clamped bool, original, replacement string, err error)
ClampRewindGraceAtStartup defends against wall-clock rewinds. The grace marker is persisted as an absolute RFC3339 timestamp, so a backward NTP step (or a clock that was set wrong before crash and corrected at boot) can leave the persisted `paused_until` arbitrarily far in the future. We cap any persisted value to at most `2 * grace` ahead of `now`; legitimate markers are always within `grace` of now and remain untouched.
Returns (clamped, original, replacement) when a rewrite occurred so callers can log the transition. clamped=false means the marker was absent, in range, already expired, or unparseable.
Best-effort: any DB / parse failure short-circuits with the corresponding error and the marker is left as-is.
func DeterministicMessage ¶
func DeterministicMessage(ctx context.Context, ec EventContext) (string, error)
DeterministicMessage produces a commit subject + optional body from the event + ops alone. Pure forwarder over ai.DeterministicProvider.
The deterministic provider does not consult DiffText / RepoRoot, so we pass an empty repo root here — the daemon-side wiring (providerMessageFn) is what populates the diff for AI providers.
func FingerprintToken ¶
func FingerprintToken(fp identity.Fingerprint) string
FingerprintToken is the canonical persisted form of a Fingerprint. Exposed for callers (e.g. `acd start`) that need to write watch_fp consistently with what the GC compares against.
func GitOperationInProgress ¶
GitOperationInProgress is the exported wrapper around gitOperationInProgress used by CLI helpers that need to refuse running while a git operation is active in <gitDir>. Returns the human-readable marker name (e.g. "merge", "rebase-merge") and true when a marker is present.
func IsShadowBootstrapped ¶
func IsShadowBootstrapped(ctx context.Context, db *state.DB, branchRef string, generation int64) (bool, error)
IsShadowBootstrapped reports whether the (branch_ref, branch_generation) pair has a completion marker in daemon_meta. Capture/replay should refuse to operate on a generation that returns false here — the shadow set is either unseeded or known-partial from a crashed reseed.
func LoadBranchGeneration ¶
LoadBranchGeneration reads the persisted branch_generation from daemon_meta. Returns (1, nil) when the key is absent — the legacy default — so a fresh repo starts at generation 1 just like the in-memory seed. Bad / unparseable values fall back to (1, nil) with no error so an operator who hand-edits the row can't crash the daemon; the next bump will overwrite the row anyway.
func LoadBranchHead ¶
LoadBranchHead reads the last-known HEAD OID stored alongside the generation counter. Returns ("", nil) when the key is absent.
func PruneCaptureEvents ¶
func PruneCaptureEvents(ctx context.Context, db *state.DB, now time.Time, retention time.Duration) (int, error)
PruneCaptureEvents drops terminal capture_events older than retention. Returns the number of rows removed.
func ReseedShadowFromHead ¶
func ReseedShadowFromHead(ctx context.Context, repoDir string, db *state.DB, cctx CaptureContext) (int, error)
ReseedShadowFromHead replaces the shadow rows for the active (branch_ref, branch_generation) with cctx.BaseHead's tree even when the normal bootstrap marker is already present. External same-branch fast-forwards use this to absorb upstream changes into the shadow baseline instead of capturing them as local worktree edits.
func Run ¶
Run executes the per-repo daemon run loop. Returns nil on graceful shutdown (SIGTERM/SIGINT, ctx.Done, self-terminate). Returns ErrDaemonLockHeld when another daemon already owns daemon.lock — the caller should map this onto exit ExitTempFail (75).
Run does NOT close opts.DB; the caller owns the database lifetime.
func RunDailyRollup ¶
RunDailyRollup aggregates capture_events into daily_rollups for every completed UTC day from "rollup.last_day"+1 through yesterday. Returns the number of new rows written.
Idempotency: the per-repo daily_rollups table uses INSERT OR IGNORE on (day, repo_root); re-running on a day that already has a row is a no-op. rollup.last_day is advanced one day at a time, only after the row for that day commits successfully — so a crash mid-loop re-rolls the unfinished day next time.
func SameGeneration ¶
SameGeneration reports whether two tokens describe the same generation. Two empty tokens compare equal (boot-time bootstrap); two non-empty tokens compare by exact-string equality, which captures both the rev-vs-missing transition and the rev-vs-different-rev transition.
func SaveBranchGeneration ¶
SaveBranchGeneration upserts the persisted branch_generation alongside the last-known HEAD OID. Both writes are best-effort from the run loop's perspective — the loop logs a warn but does not abort on failure. We nonetheless surface the underlying error so tests can assert it.
func ShadowBootstrappedKey ¶
ShadowBootstrappedKey returns the daemon_meta key under which the completion marker is stored for a given (branch_ref, branch_generation) pair. Format: `shadow.bootstrapped:<branch_ref>:<branch_generation>`.
func ShouldSelfTerminate ¶
func ShouldSelfTerminate(emptySweepCount int, sinceBoot time.Duration, opts SelfTerminateOpts) bool
ShouldSelfTerminate is the run-loop's exit gate per §8.4.
True iff EmptySweepCount >= threshold AND sinceBoot >= BootGrace. The two conditions are AND-ed: a race between `acd start` registering its row and the daemon's first sweep would otherwise evict the row before the wake reached the loop.
func SweepClients ¶
SweepClients runs one refcount-GC pass over daemon_clients (§8.4).
Returns the number of rows that survived (alive). Drops every row that fails the §3.4 liveness predicate. now is passed in (rather than read from time.Now) so tests can advance a deterministic clock; production callers pass time.Now().
The fingerprint comparison treats an absent stored fingerprint as "no fingerprint check" — peers that registered without one fall back to the pid liveness probe alone, matching legacy behaviour.
Types ¶
type CaptureContext ¶
type CaptureContext struct {
BranchRef string
BranchGeneration int64
BaseHead string // HEAD OID at start of pass (or "" if no HEAD)
}
CaptureContext carries the per-pass repository identity that the legacy daemon calls "ctx" (branch_ref, branch_generation, base_head). Phase 1 keeps this struct small and lets the run loop populate it; the branch-generation token implementation lives elsewhere (§8.9).
type CaptureOpts ¶
type CaptureOpts struct {
// MaxFileBytes overrides EnvMaxFileBytes / DefaultMaxFileBytes.
MaxFileBytes int64
// IgnoreChecker batches gitignore checks. Caller owns the lifetime —
// typically built once at daemon start and reused for the run.
IgnoreChecker *git.IgnoreChecker
// SensitiveMatcher precomputes the active sensitive glob set. Caller
// owns the lifetime; nil falls back to a fresh matcher per pass (slow
// but correct).
SensitiveMatcher *state.SensitiveMatcher
// SafeIgnoreMatcher precomputes generated dependency/cache trees that
// ACD skips internally even when they are not gitignored. Nil falls
// back to a fresh matcher per pass.
SafeIgnoreMatcher *state.SafeIgnoreMatcher
// SubmodulePaths is the set of repo-relative paths that are submodules
// (mode 160000 in HEAD's tree). Capture must not descend into them.
SubmodulePaths map[string]bool
// Trace receives best-effort decision records. Nil disables tracing.
Trace acdtrace.Logger
// GitDir is the absolute .git directory. Required to consult the
// daemon pause gate (manual marker + rewind grace meta). Empty
// disables the in-Capture pause check entirely; callers that have
// already gated externally (e.g. the run loop) should set
// SkipPauseCheck instead so the gate is symmetric across direct and
// run-loop invocations.
GitDir string
// SkipPauseCheck disables the daemon pause gate inside Capture. The
// run loop already gates capture+replay on the same pause state and
// emits a single trace event before either pass runs; setting this
// flag avoids a double-trace. Direct callers (tests, future CLI
// wrappers) leave it false to honor the gate.
SkipPauseCheck bool
// SortByPath, when true, reorders the slice returned by Classify into
// lexicographic ascending Path order BEFORE the per-op
// AppendCaptureEvent insert loop runs and BEFORE the pending-depth
// cap is applied; events that overflow the cap mid-pass are
// therefore the lex-largest paths, not the most recently edited.
// Daemon run-loop callers leave this false; the live walk's iteration
// order is preserved exactly as before. Used by tests and tooling
// (`acd commit-all`) that need deterministic seq ordering across passes.
SortByPath bool
// DisablePendingCap, when true, disables the per-generation
// pending-depth cap (EnvMaxPendingEvents) for THIS Capture call only.
// Daemon run-loop callers leave this false so the documented
// backpressure invariant holds. Single-shot tools like
// `acd commit-all` set it true so a cold-start dirty worktree can
// drain in one pass without the mid-walk drop fence kicking in.
// Mutually exclusive with MaxPendingEventsOverride: when both are
// set, DisablePendingCap wins. The process-wide env var is NOT
// touched.
DisablePendingCap bool
// MaxPendingEventsOverride overrides the per-generation pending-depth
// cap for this Capture call only when > 0. -1 (or any negative value)
// is treated as unset and falls back to resolveMaxPendingEvents().
// Zero falls through to the env-derived default; use
// DisablePendingCap to actually turn the cap off. The process-wide
// env var is NOT touched.
MaxPendingEventsOverride int64
}
CaptureOpts configures one capture pass. Zero-valued fields fall back to production defaults; tests inject lighter substitutes.
type CaptureSummary ¶
type CaptureSummary struct {
EventsAppended int // number of capture_events rows inserted
EventsDropped int // ops refused due to ACD_MAX_PENDING_EVENTS cap
Oversize int // files skipped due to size cap
Errors int // soft errors (per-file lstat/open failures)
WalkedFiles int64 // for diagnostics
PendingDepth int // pending depth observed for the active generation at end of pass (0 if cap disabled)
PendingHighWater int64 // updated daemon_meta.capture.pending_high_water value (0 if not bumped)
// Skipped is true when Capture intentionally skipped the walk before
// touching the worktree (e.g. an active manual pause marker or rewind
// grace). Mirrors ReplaySummary.Skipped so direct callers can short-
// circuit the same way the run loop does.
Skipped bool
// SkipReason is a short human-readable label populated alongside
// Skipped. Empty when Skipped is false.
SkipReason string
// BackpressurePaused is true when the pass observed the durable
// capture-backpressure gate as active (either entered this pass or
// entered earlier and not yet cleared). The walk is skipped on entry;
// the field is also true for the same pass that drops the gate to
// describe the state across the transition.
BackpressurePaused bool
// BackpressureCleared is true when this pass observed the durable
// capture-backpressure gate transitioning from active to inactive
// (pending dropped below CaptureBackpressureClearRatio * cap).
BackpressureCleared bool
// EventsDroppedTotal mirrors daemon_meta.capture.events_dropped_total
// after this pass. 0 when the cumulative counter has never advanced.
EventsDroppedTotal int64
}
CaptureSummary describes one capture pass.
func Capture ¶
func Capture(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureContext, opts CaptureOpts) (CaptureSummary, error)
Capture walks the repo, builds the live map, classifies vs the persisted shadow_paths for this (branch, generation), persists capture events + updates shadow rows, and returns a summary. The caller is expected to have bootstrapped the shadow against HEAD before the first capture; this helper does not own the bootstrap path.
Callers must pass a stable cctx — the (branch, generation) tuple keys both the shadow_paths read AND the capture_events insert, so a concurrent branch swap mid-walk would emit events keyed to the new generation while the live map was sampled under the old one.
type ClassifiedOp ¶
type ClassifiedOp struct {
Op string // "create" | "modify" | "delete" | "mode" | "rename"
Path string
OldPath string
BeforeOID string
BeforeMode string
AfterOID string
AfterMode string
Fidelity string // "rescan" for poll-driven capture
}
ClassifiedOp is one op emitted by Classify. The output is consumed by the capture writer (which persists capture_events + capture_ops) and later by the replay step (which feeds it through update-index --index-info).
func Classify ¶
func Classify(shadow map[string]ShadowEntry, live map[string]LiveEntry) []ClassifiedOp
Classify compares a shadow snapshot against a live snapshot and returns the ordered list of ops. Output is deterministic: rename ops appear first (driven by deletion order), followed by create/modify/mode ops sorted by path, then plain delete ops sorted by path. This matches the legacy _classify_changes output ordering, which downstream tests pin.
type ControlLock ¶
type ControlLock struct {
// contains filtered or unexported fields
}
ControlLock is the brief flock held by CLI subcommands.
func AcquireControlLock ¶
func AcquireControlLock(gitDir string) (*ControlLock, error)
AcquireControlLock acquires <gitDir>/acd/control.lock with LOCK_EX|LOCK_NB. Returns ErrControlLockHeld on contention.
func (*ControlLock) Release ¶
func (l *ControlLock) Release() error
Release drops the control lock and closes the file.
type DaemonLock ¶
type DaemonLock struct {
// contains filtered or unexported fields
}
DaemonLock is the held-for-life-of-daemon flock handle.
func AcquireDaemonLock ¶
func AcquireDaemonLock(gitDir string) (*DaemonLock, error)
AcquireDaemonLock acquires <gitDir>/acd/daemon.lock with LOCK_EX|LOCK_NB. Returns ErrDaemonLockHeld on contention; any other error is a hard failure (mkdir, open) that callers should surface verbatim.
func (*DaemonLock) Release ¶
func (l *DaemonLock) Release() error
Release drops the daemon lock and closes the underlying file. Safe to call multiple times; subsequent calls are no-ops.
type EventContext ¶
type EventContext struct {
Event state.CaptureEvent
Ops []state.CaptureOp
}
EventContext is the input handed to MessageFn. Mirrors the fields the legacy daemon passes to its message generator (event row + ops).
type FsnotifyOptions ¶
type FsnotifyOptions struct {
RepoPath string
GitDir string
IgnoreChecker fsnotifyIgnoreChecker
Sensitive *state.SensitiveMatcher
SafeIgnore *state.SafeIgnoreMatcher
Debounce time.Duration
MaxWatches int
WakeFn func()
Logger *slog.Logger
DiagnosticsFn func(WatcherDiagnostics)
}
FsnotifyOptions configures one watcher. RepoPath + WakeFn are required; everything else has a usable default.
type FsnotifyWatcher ¶
type FsnotifyWatcher struct {
// contains filtered or unexported fields
}
FsnotifyWatcher is the live watcher handle. It is safe to call Stop from any goroutine; double-Stop is a no-op.
func NewFsnotifyWatcher ¶
func NewFsnotifyWatcher(opts FsnotifyOptions) (*FsnotifyWatcher, error)
NewFsnotifyWatcher constructs a watcher and pre-walks the repo to seed directory watches. On any failure mode that disables OS-level events (ACD_DISABLE_FSNOTIFY=1, fsnotify init error, watch budget exceeded) the returned watcher is valid but operates in poll-only mode — its WakeFn will never fire and Diagnostics() reflects the fallback reason.
NewFsnotifyWatcher does NOT spawn goroutines; call Start to begin dispatching events.
func (*FsnotifyWatcher) Diagnostics ¶
func (w *FsnotifyWatcher) Diagnostics() WatcherDiagnostics
Diagnostics returns a snapshot of the current watcher state. Safe to call from any goroutine.
func (*FsnotifyWatcher) Start ¶
func (w *FsnotifyWatcher) Start(ctx context.Context) error
Start dispatches OS events on a worker goroutine. It is a no-op if the watcher has already fallen back to poll mode. Subsequent calls beyond the first are no-ops too.
func (*FsnotifyWatcher) Stop ¶
func (w *FsnotifyWatcher) Stop(ctx context.Context) error
Stop tears down the watcher and waits for every owned goroutine (dispatch, rewalk worker, diagnostics worker) to exit. Safe to call multiple times; only the first does work. Also safe to call when Start was never invoked: in that case there is no dispatch goroutine to drain so we close doneCh ourselves.
The provided ctx bounds the wait. Stop cancels the watcher-scoped context so any in-flight IgnoreChecker round-trip aborts immediately; it then blocks until all workers exit OR ctx is cancelled. A nil ctx is treated as context.Background().
shutdown-lane note: this signature accepts a caller-scoped ctx so outer shutdown logic can deadline the teardown without changing internals.
func (*FsnotifyWatcher) WatchedPaths ¶
func (w *FsnotifyWatcher) WatchedPaths() []string
WatchedPaths returns a sorted snapshot of the currently watched directories. Test-only — production callers should use Diagnostics(). We expose it so the symlink-not-descending regression test can verify the watcher actually skipped a symlinked subtree.
type LiveEntry ¶
type LiveEntry struct {
Path string
Mode string // git mode bits ("100644", "100755", "120000")
OID string // blob OID
}
LiveEntry is one path in the live worktree snapshot. Mirrors the legacy daemon's `live[rel] = {path, mode, oid}` shape.
type LiveIndexRepairSummary ¶
type LiveIndexRepairSummary struct {
Candidates int
Applied int
Skipped []git.LiveIndexSkip
}
type MessageFn ¶
type MessageFn func(ctx context.Context, ec EventContext) (string, error)
MessageFn produces a commit message for one event + its ops. Phase 1 callers pass DeterministicMessage; Phase 5 swaps in an AI-backed implementation.
func ProviderMessageFn ¶
ProviderMessageFn adapts an ai.Provider into the daemon's MessageFn signature for direct (non-run-loop) callers like `acd commit-all`. It is a thin exported wrapper over the internal providerMessageFn so command-mode tooling can route per-event messages through the same provider the daemon itself would use.
repoRoot is used to reconstruct the unified diff from captured blob OIDs; pass "" when the caller cannot supply a repo root (the deterministic provider tolerates an empty DiffText, and AI providers will simply receive an empty diff field).
type Options ¶
type Options struct {
// RepoPath is the absolute path to the worktree root.
RepoPath string
// GitDir is the absolute .git directory.
GitDir string
// DB is the already-open per-repo state database. Run does NOT close
// the DB on exit — caller owns the lifetime.
DB *state.DB
// Logger emits all run-loop progress. Nil falls back to slog.Default().
Logger *slog.Logger
// Scheduler is the backoff helper. Zero-valued struct = production
// defaults; tests pass a Scheduler with smaller bases/ceilings to keep
// the suite fast.
Scheduler Scheduler
// BootGrace is the post-start window during which empty refcount
// sweeps do not count toward self-termination. Zero falls back to
// DefaultBootGrace.
BootGrace time.Duration
// EventRetention overrides the capture_events retention window. Zero
// falls back to DefaultEventRetention (with EnvEventRetentionDays
// honored).
EventRetention time.Duration
// ClientTTL overrides the daemon_clients TTL. Zero falls back to
// DefaultClientTTL (or EnvClientTTLSeconds if set).
ClientTTL time.Duration
// EmptySweepThreshold overrides the consecutive-empty-sweeps gate.
// Zero falls back to DefaultEmptySweepThreshold.
EmptySweepThreshold int
// ClientSweepInterval throttles refcount sweeps. Zero falls back to
// DefaultClientSweepInterval.
ClientSweepInterval time.Duration
// PruneInterval throttles the capture_events pruner. Zero falls back
// to DefaultPruneInterval.
PruneInterval time.Duration
// RollupInterval caps how often the daily rollup hook may run. Zero
// falls back to DefaultRollupInterval. The aggregator is also fired
// immediately when a UTC-day boundary crossing is detected.
RollupInterval time.Duration
// CentralStatsDBPath, when non-empty, opens the central stats.db at
// daemon start and pushes per-repo daily_rollups into it after each
// rollup pass. Empty means "skip central push" — only the per-repo
// daily_rollups table is updated. Tests typically leave this empty.
CentralStatsDBPath string
// CentralStats, when non-nil, is used as the central stats handle
// instead of opening one from CentralStatsDBPath. Tests inject a
// pre-opened *central.StatsDB this way to avoid filesystem coupling.
CentralStats *central.StatsDB
// RepoHash is the stable cross-repo identifier used when pushing
// per-repo daily_rollups into the central stats.db. Empty disables
// the central push (logged but non-fatal).
RepoHash string
// MessageFn produces commit messages. Nil falls back to a MessageFn
// derived from MessageProvider (or, when MessageProvider is also nil,
// from ai.BuildProvider(ai.LoadProviderConfigFromEnv())). Tests may
// pin a deterministic MessageFn here directly without involving the
// ai package at all.
MessageFn MessageFn
// MessageProvider, when non-nil, is the ai.Provider used to compose
// commit messages on the replay path. Nil triggers env-driven
// selection via ai.LoadProviderConfigFromEnv + ai.BuildProvider —
// production callers leave this nil and rely on ACD_AI_*. Tests can
// inject a stub Provider to assert the message reaches the commit.
MessageProvider ai.Provider
// MessageProviderCloser, when non-nil, is closed on Run shutdown.
// Pair this with MessageProvider when the provider holds OS
// resources (currently only ai.SubprocessProvider). When Run
// constructs the provider itself from env vars, the closer returned
// by ai.BuildProvider is captured automatically.
MessageProviderCloser io.Closer
// Now lets tests inject a fake clock. Nil falls back to time.Now.
Now func() time.Time
// WakeCh is an optional injection point so tests can trigger wakes
// without sending real OS signals. Production callers leave this nil
// and the loop relies on InstallSignalHandlers' SIGUSR1 channel.
WakeCh <-chan struct{}
// ShutdownCh is the test-side equivalent for SIGTERM/SIGINT. Nil
// falls back to InstallSignalHandlers' shutdown channel.
ShutdownCh <-chan struct{}
// SkipSignals disables the real os/signal registration. Tests that
// inject WakeCh / ShutdownCh set this to true so the test goroutine
// has full control over wake + shutdown.
SkipSignals bool
// FlushLimit caps how many flush_requests are drained per iteration.
// Zero falls back to DefaultFlushLimit (256). Tests set it to 1 for
// tighter control.
FlushLimit int
// FsnotifyEnabled turns on the recursive fsnotify watcher (D11 hybrid).
// Default is false so the existing test suite keeps deterministic
// poll-only timing; production callers (and the integration test) opt
// in by setting this true. Even when true, ACD_DISABLE_FSNOTIFY=1
// forces poll-only mode at watcher construction time.
FsnotifyEnabled bool
// FsnotifyDebounce overrides the trailing-edge debounce on fsnotify
// wakes. Zero falls back to DefaultDebounce.
FsnotifyDebounce time.Duration
// FsnotifyMaxWatches caps the OS watch budget. Zero asks the watcher
// to derive a sensible default from the platform.
FsnotifyMaxWatches int
// Trace receives best-effort decision records. Nil uses ACD_TRACE env
// wiring; disabled env returns a no-op logger.
Trace acdtrace.Logger
}
Options configures one Run invocation.
Required: RepoPath, GitDir, DB. Everything else has a usable default.
type ReplayOpts ¶
type ReplayOpts struct {
// MessageFn produces the commit message. Nil falls back to
// DeterministicMessage.
MessageFn MessageFn
// IndexFile is the GIT_INDEX_FILE path used for an isolated index. When
// empty, Replay creates a per-pass tempfile under <gitDir>/acd and
// removes it before returning. Caller-provided values are left in place
// for tests that need to inspect the index.
IndexFile string
// GitDir is the absolute git dir for the worktree. Required to seed a
// default IndexFile.
GitDir string
// Limit caps the number of events drained per call. 0 = no limit.
//
// The run loop sets Limit = DefaultReplayLimit so each replay pass returns
// to the daemon promptly enough to claim flush_requests, refresh the
// heartbeat, and observe shutdown. ReplaySummary.HasMore signals whether
// the queue still contains pending work so the run loop can schedule an
// immediate follow-up wake instead of waiting for the next poll tick.
Limit int
// Trace receives best-effort decision records. Nil disables tracing.
Trace acdtrace.Logger
// PromptTrace receives opt-in provider prompt records. Nil disables prompt
// persistence.
PromptTrace prompttrace.Logger
// CommitStrategy selects one-event replay or intent-grouped replay. Empty
// resolves from ACD_COMMIT_STRATEGY, preserving event replay by default.
CommitStrategy ai.CommitStrategy
// IntentPlanner chooses selected/deferred capture groups when
// CommitStrategy is intent. Nil falls back to the env-selected AI provider,
// and then deterministic planning if that provider is unavailable.
IntentPlanner ai.IntentPlanner
// IntentWindow caps the normal planning window. Zero resolves from env.
IntentWindow int
// IntentMinPending is the preferred pending-count gate before intent
// planning starts. Zero resolves from env.
IntentMinPending int
// IntentMaxPendingAge is the bounded wait escape hatch for sparse pending
// queues that have not reached IntentMinPending. Zero resolves from env.
IntentMaxPendingAge time.Duration
// IntentRecentCommits caps recent history context supplied to the planner.
// Zero resolves from env.
IntentRecentCommits int
// IntentDeferLimit controls forced-aging windows. Zero resolves from env;
// use a negative value in tests to force zero.
IntentDeferLimit int
// IntentIncludeDiffs permits captured diffs in planner requests. Production
// callers should leave this false unless diff egress is explicitly enabled.
IntentIncludeDiffs bool
// IntentBypassBatchWait lets explicit flush requests plan the currently
// visible pending window without waiting for IntentMinPending or
// IntentMaxPendingAge.
IntentBypassBatchWait bool
}
ReplayOpts configures one replay pass.
type ReplaySummary ¶
type ReplaySummary struct {
Published int // events that produced a new commit
Conflicts int // events terminally settled in state.EventStateBlockedConflict
Failed int // events marked failed (validation/commit errors)
BaseHead string
Skipped bool // replay drain was intentionally skipped without publishing
// SkippedReason distinguishes intentional no-op passes from an empty
// pending queue. Empty means replay was not skipped or the legacy pause
// skip path set only Skipped.
SkippedReason string
// HasMore is true when ReplayOpts.Limit capped the batch and at least one
// additional pending event was visible beyond the cap. The run loop uses
// this to schedule an immediate follow-up replay pass without waiting for
// the next poll tick. Always false when Limit <= 0 (unbounded drain).
HasMore bool
}
ReplaySummary describes one drain.
func Replay ¶
func Replay(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureContext, opts ReplayOpts) (ReplaySummary, error)
Replay drains pending capture_events for the active branch into commits.
One pass per call: the run loop is expected to invoke this on every poll-tick. Coalescing OFF — each event becomes its own commit, with the previous event's commit as the new HEAD's parent.
Conflict semantics: when the scratch replay index for any path touched by an event disagrees with the event's before-state, OR the branch ref CAS fails on update-ref, the event is settled in state.EventStateBlockedConflict (terminal — never retried automatically) and publish_state.status is set to "blocked_conflict". The daemon also stamps daemon_meta.last_replay_conflict so operators can spot a divergence at a glance. Resolution is the operator's job (out of scope for v1 automation).
Batch halt: a conflict or commit-build failure short-circuits the rest of the pending queue. Subsequent events were captured assuming the broken predecessor would land first; replaying them on top of a stale parent would produce a tree that diverges from the operator's intent. The next poll tick sees those events still pending and re-attempts them only after the operator has reconciled the blocker (which advances BaseHead / branch_generation and lets the queue drain naturally).
type RunDailyRollupOpts ¶
type RunDailyRollupOpts struct {
// RepoPath is stamped into daily_rollups.repo_root for cross-repo
// joins. Required.
RepoPath string
// Now lets tests inject a fake clock. Nil falls back to time.Now.
Now func() time.Time
}
RunDailyRollupOpts tunes RunDailyRollup. Zero value = production defaults (real time.Now, repo path inferred from caller).
type Scheduler ¶
Scheduler is the run-loop's pluggable backoff helper. Zero-valued fields fall back to the production defaults so production callers can leave the struct empty.
func (Scheduler) NextError ¶
NextError doubles the current delay, capped at ErrorCeiling. A current delay <= 0 starts from Base.
type SelfTerminateOpts ¶
SelfTerminateOpts configures ShouldSelfTerminate.
type ShadowEntry ¶
ShadowEntry is one row from shadow_paths reduced to the fields classify actually consumes. Avoids leaking sql.Null* into the diff function.
type Signals ¶
type Signals struct {
Wake <-chan struct{}
Shutdown <-chan struct{}
}
Signals carries the channels the run loop selects on.
Wake fires (at least once) on each SIGUSR1 received. Shutdown fires once on the first SIGTERM or SIGINT. Both channels are read-only from the run-loop's perspective.
func InstallSignalHandlers ¶
InstallSignalHandlers registers signal.Notify on SIGTERM, SIGINT, and SIGUSR1, returning Signals (the receive channels) plus a cleanup func that detaches the handlers and stops the dispatcher goroutine.
Honors ctx: when ctx.Done fires, the dispatcher exits.
type SweepOpts ¶
type SweepOpts struct {
// TTL overrides DefaultClientTTL. Zero falls back to default.
TTL time.Duration
// CaptureFingerprint resolves a live fingerprint for a pid. Defaulted to
// identity.Capture; tests inject a deterministic stub so they don't need
// a real `ps`. A nil function defaults to identity.Capture.
CaptureFingerprint func(context.Context, int) (identity.Fingerprint, error)
// AliveFn checks pid liveness. Defaulted to identity.Alive; tests inject.
AliveFn func(context.Context, int) bool
}
SweepOpts configures one GC pass.
type TokenTransition ¶
type TokenTransition int
TokenTransition classifies how the active branch ref moved between two observations of HEAD.
const ( // TokenTransitionUnchanged means the token is identical (same SHA or // both "missing"). The run loop does nothing. TokenTransitionUnchanged TokenTransition = iota // TokenTransitionFastForward means newHead is a descendant of prevHead // (or prevHead was empty). Compatible with queued events captured at // prevHead because prevHead is still in the new HEAD's history. The // daemon's own commits land here; an operator running `git pull` // against an upstream that fast-forwards also lands here. TokenTransitionFastForward // TokenTransitionDiverged means newHead does not descend from prevHead // (rebase, reset, branch-switch, force-push, transition to/from // "missing"). Queued events captured under the prior generation are // no longer safe to replay — their BaseHead is no longer reachable // from HEAD. TokenTransitionDiverged )
func ClassifyTokenTransition ¶
func ClassifyTokenTransition(ctx context.Context, repoDir, prevToken, newToken string) (TokenTransition, error)
ClassifyTokenTransition reports whether the move from prev->new HEAD is a fast-forward (newHead descends from prevHead) or a divergence (rebase, reset, branch-switch). prevHead == "" is treated as fast-forward — there was no prior history to descend from. A transition to or from BranchTokenMissing is always a divergence.
repoDir is required for the merge-base ancestry probe. Callers that already know prevHead == newHead should short-circuit with TokenTransitionUnchanged; this helper does that anyway, but the caller can avoid the git-shellout in the common case.
func (TokenTransition) String ¶
func (t TokenTransition) String() string
String lets logs and tests render the transition without a switch.
type WatcherDiagnostics ¶
type WatcherDiagnostics struct {
// Mode is "fsnotify" while the OS watcher is live, "poll" once we've
// fallen back to poll-only.
Mode string
// WatchCount is the number of directories the OS watcher currently
// holds. Zero in poll mode.
WatchCount int
// DroppedEvents is the running count of fsnotify channel errors. The
// poll loop is still the safety net so a few drops are non-fatal.
DroppedEvents int
// FallbackReason names the trigger that put us in poll mode. Empty
// while Mode=="fsnotify".
FallbackReason string
}
WatcherDiagnostics is the snapshot exported to daemon_meta. The run loop wires DiagnosticsFn into MetaSet calls; tests inspect this struct directly.