diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b752aa..7ba8809f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: contents: read env: - GO_VERSION: '1.26.4' + GO_VERSION: '1.26.5' jobs: test: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30a8dc3d..a2fb41cf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,7 +12,7 @@ permissions: contents: read env: - GO_VERSION: '1.26.4' + GO_VERSION: '1.26.5' jobs: analyze: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53c3831b..14b7633b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - GO_VERSION: '1.26.4' + GO_VERSION: '1.26.5' jobs: goreleaser: diff --git a/.gitignore b/.gitignore index a27a6d31..235b2eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .idea/ .vscode/ .claude/scheduled_tasks.lock +.editorconfig diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4aa89c..cb5d8ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,56 @@ ## Unreleased +### Added + +- Bare `acd` now gives a read-only health summary and one recommended next + action. `acd on` and `acd off` provide idempotent everyday controls while + preserving per-repo state. +- Intent mode now persists planner circuit health. Transport failures open the + circuit immediately, repeated invalid plans open it after three failures, + and deterministic planning keeps commits moving during 30-second, 2-minute, + and 10-minute cooldowns. +- Recovery snapshots and hidden `refs/acd/recovery/*` refs now preserve exact + unpublished branch-generation chains before ACD changes their queue state. + ### Changed +- Replay, branch transitions, dead-branch cleanup, `acd fix`, and `commit-all` + now reconcile whole unpublished chains. A stable exact `HEAD` match is proven + as published; any unresolved chain is archived without changing the live + worktree, index, or branch. +- `acd fix --force` now selects archive-only recovery. It no longer purges + terminal barriers or retargets captures to a different branch generation. +- `acd status` and `acd diagnose` now show planner circuit state, failures, + deterministic bypasses, and the next automatic provider probe. - Updated pinned GitHub Actions dependency `actions/setup-go` from `v6.4.0` to `v6.5.0`. +- Raised the minimum Go toolchain to 1.26.5 to include the standard-library + fix for GO-2026-5856. + +### Fixed + +- `commit-all --dry-run`, JSON consent refusal, and declined confirmation now + leave ACD state and recovery refs unchanged and do not start the AI provider. +- `acd fix` backups now include committed SQLite WAL frames and pass + `PRAGMA quick_check` before recovery mutation continues. +- `acd fix` and `commit-all` now recheck branch, Git-operation, and manual-pause + safety at mutation boundaries instead of relying on an earlier plan snapshot. +- `commit-all` now exits non-zero when unpublished events remain, instead of + reporting a partial drain as successful. +- Intent `commit-all` now reuses the planner circuit across replay passes, so a + provider outage falls back once instead of repeating the remote timeout. +- Recovery now proves grouped same-path commit prefixes cumulatively and keeps + each hidden evidence ref locked through its SQLite lifecycle transition. +- Published-event retention now preserves same-base recovery prefixes. Schema + v13 adds a covering index so the safety check remains bounded at ledger cap. +- Recovery now treats captured filenames as literal Git pathspecs without + trimming legal whitespace or interpreting pathspec-magic characters. +- Planner circuit state now initializes only after daemon-lock ownership, + caller cancellation wins provider-error races, and persisted errors redact + URL paths that may contain credentials. +- Daemon shutdown now cancels and joins the asynchronous startup recovery sweep + before releasing the daemon lock or returning database ownership. ## v2026-06-26 diff --git a/CLAUDE.md b/CLAUDE.md index 33e81f70..cc029387 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,231 +3,182 @@ ## Project - Static Go CLI/daemon for macOS/Linux `arm64`/`amd64`; no Windows v1. -- Module `github.com/KristjanPikhof/Auto-Commit-Daemon`; Go 1.26.4; `modernc.org/sqlite v1.52.0`; MIT. -- Tags use `vYYYY-MM-DD`. Verify latest with `git tag --sort=-creatordate | head`; local latest at this refresh was `v2026-05-29`. `v2026-05-10` exists at `4d9dfef`. Do not move published tags unless requested. -- `AGENTS.md -> CLAUDE.md`; edit `CLAUDE.md`, preserve the symlink. +- Module: `github.com/KristjanPikhof/Auto-Commit-Daemon`; Go 1.26.5; `modernc.org/sqlite v1.53.0`; MIT. +- Tags use `vYYYY-MM-DD`. Find the latest with `git tag --sort=-creatordate | head`; never move a published tag unless requested. +- `AGENTS.md -> CLAUDE.md`. Edit `CLAUDE.md` and preserve the symlink. +- README/docs are product contracts. Link canonical runbooks instead of copying them here; nested Markdown fences use `~~~`. ## Commands -```bash -make build # static bin/acd, CGO_ENABLED=0, -tags=netgo,osusergo +~~~bash +make build # static bin/acd; CGO_ENABLED=0; netgo,osusergo make test # go test ./... -race -count=1 make lint # go vet ./... + gofmt check make fmt # gofmt -w . make tidy # go mod tidy -``` +~~~ Pre-PR gate: -```bash +~~~bash cleanenv() { env -u ACD_INTENT_MIN_PENDING -u ACD_INTENT_MAX_PENDING_AGE -u ACD_INTENT_SETTLE_WINDOW -u ACD_INTENT_WINDOW -u ACD_INTENT_RECENT_COMMITS -u ACD_INTENT_DEFER_LIMIT ACD_COMMIT_STRATEGY=event "$@"; } cleanenv make lint cleanenv make test cleanenv go test ./test/integration/... -tags=integration -race -count=1 -timeout 5m cleanenv go test ./internal/daemon/... ./internal/git/... ./internal/state/... ./internal/pause/... ./internal/cli/... -race -count=3 -timeout 10m -``` +~~~ -Release smoke only after install/tag approval: +Release only after explicit install/tag approval: -```bash +~~~bash make build && install -m 0755 ./bin/acd ~/.local/bin/acd git tag v2026-MM-DD && git push origin v2026-MM-DD gh run list --workflow release.yml --branch v2026-MM-DD --limit 1 gh run watch gh release view v2026-MM-DD --json isDraft,isPrerelease,isLatest,url,assets ACD_VERSION=v2026-MM-DD sh scripts/install.sh -``` +~~~ -- `make build` uses `git describe`; before tagging it can show the prior tag plus commit count. -- Release workflow uses GoReleaser `--skip=homebrew`, then 6 install-smoke retries for asset propagation. If smoke sees asset 404 after publish, verify release view, checksums/downloads, and `ACD_VERSION= sh scripts/install.sh` before retagging. Reruns can fail because the release exists. -- `.goreleaser.yaml` sets `prerelease: false`; date tags otherwise break `releases/latest`. Brew stays skipped until tap credentials exist. +- `make build` uses `git describe`, so an untagged build shows the prior tag plus commit count. +- Release runs `goreleaser release --clean`, publishes the Homebrew formula with `TAP_GITHUB_TOKEN`, then retries install smoke 6 times for asset propagation. `.goreleaser.yaml` sets `prerelease: false` so date tags work with `releases/latest`. +- A rerun can fail because the GitHub release already exists. Inspect the release, checksums/downloads, tap formula, and version-pinned installer before considering any new tag. ## Map | Path | Purpose | |---|---| | `cmd/acd/main.go` | CLI entrypoint | -| `internal/cli` | Cobra commands: setup, hooks, status, diagnose, doctor, fix, commit-all, start cache | -| `internal/daemon` | run loop, capture/replay, intent, branch tokens, bootstrap/shadow, fsnotify, refcount, live-index repair, trace | -| `internal/state` | SQLite v11: events/ops, decisions, planner_state, planner windows, shadow/meta/clients/flush/safe-ignore/sensitive | -| `internal/git` | bounded refs/tree/diff/blob/scratch-index/history/ignore helpers | -| `internal/ai` | deterministic/openai-compat/subprocess providers, commit message prompts, intent planner | -| `internal/adapter` | harness detection and markers; do not restore TODO stubs | -| `internal/{central,identity,logger,paths,pause,trace}` | registry/stats, fingerprints, logs, XDG, pause markers, trace | -| `templates/{claude-code,codex,cursor,opencode,pi,shell}` | setup snippets; Codex and Cursor use `hooks.json`; legacy TOML removed | -| `test/integration` | build-tagged lifecycle, adapter, recovery, AI, explainable, self-heal, intent, latency-budget tests | -| `.github/workflows/{ci,codeql,release}.yml` | CI, CodeQL, tag release | -| `README.md`, `docs/*`, `CHANGELOG.md` | user contract; nested fences use `~~~` | - -## Workflow - -- Scope changes; prefer `rg`; never revert unrelated work. -- Docs are release contract. Update README/changelog/docs only when stale; release notes describe user impact. -- After `git.Init`/`git init` in tests: `git symbolic-ref HEAD refs/heads/main`. -- Stubs compile: `package ` plus `// TODO(phase N): `. -- Races, panics, nil pointers, ordering failures, CI flakes, and read-only path migrations are bugs. Inspect before retry. -- Timing: focused `-count=10`; ordering hazards `GOMAXPROCS=1 -count=50`. -- Broad-run-sensitive tests: `TestRun_FsnotifyDrivesWake`, `TestRun_LifecycleHappyPath`, `TestRun_WakeBurstCoalesced`, `TestRun_RealSIGUSR1`, repeated edits, external FF reseed, FF-in-grace self-heal. -- HEAD-transition tests: `waitForMetaValue(MetaKeyBranchHead, , 3s)`. -- CLI changes need Cobra help/examples. Template changes need `internal/cli/setup_test.go` plus AdapterE2E coverage. -- Self-hosting: ACD may auto-commit this repo. Pause only for branch/history surgery, recovery/state-db mutation, or tests that intentionally exercise daemon capture/replay against this repo; after: `acd resume --repo . --yes`. -- Source edits do not affect the already-running daemon. Ordinary implementation work does not require pausing; when testing daemon or prompt behavior, `make build`, install/use the new `bin/acd`, then restart the daemon or run the intended binary directly. -- Intent-env failures: rerun with `cleanenv`; verify suspected main flakes on `main`. - -Commit message format expected from AI and manual fixes defaults to the -imperative format. This is the release contract and existing behavior; do not -change it unless the user opts in with `ACD_COMMIT_FORMAT=conventional`. - -```text -Line 1: -- max 50 characters -- no trailing period - -Line 2: blank - -Line 3+: bullet list for why/context -- each bullet starts with "- " -- max 72 characters per line -- wrapped continuation lines must not start with "- " -``` - -- Line 1 starts with an imperative verb such as Add, Fix, Refactor, Remove, Rename, Simplify, Update, or Document. -- Describe the semantic change, not just the filename. Do not mention filenames in line 1 unless the change is specifically about that file itself. -- Body explains why, intent, impact, or context; do not restate the diff. -- Intent planner grouping rationale belongs in `grouping_reason`, never in commit body. -- Avoid generic messages: `Update file`, `WIP`, `changes`. -- Optional conventional mode uses scope-less subjects like `feat: add intent format reporting`. -- Conventional types are `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, `perf`, `style`, and `revert`; scopes and breaking markers are rejected. -- Code facts: `ai.SubjectCap = 50`, `ai.BodyWrap = 72`, per-event `ai.DiffCap = 4000`, planner `ai.IntentStageDiffCap = 16000`. - -## State, Branch, Capture - -- Repo DB: `/acd/state.db`; central registry/stats use XDG state/share. -- Start cache: `/acd/start-cache-.json`, schema v2. `acd stop` removes matching/all caches. Atomic tmp+rename prevents corruption. -- `SchemaVersion = 11`: v5 `decision_records`; v6 `decision_records.event_seq`; v7 `planner_state`; v8-v9 rewrite-plan state; v10 `rewrite_plans.commit_format`; v11 `intent_planner_windows`. -- `shadow_paths` key `(branch_ref, branch_generation, path)`; read-heavy paths use `state.DB.ReadSQL()`. -- Shadow bootstrap: 5000-row chunks; marker `shadow.bootstrapped::` only after all chunks commit; clean partial rows on failure. Empty active shadow with marker means delete marker and re-bootstrap. -- Reseed prunes old generations via `ACD_SHADOW_RETENTION_GENERATIONS` default `1`. -- Branch tokens: attached `rev: `; detached `rev:`; missing `missing `. FF keeps generation; reset/rebase/switch/same-SHA ref switch bumps; legacy bare rev upgraded to attached forces Diverged. -- Detached HEAD pauses capture/replay and `acd start` refuses. Never fall back to `refs/heads/main` when `git symbolic-ref` fails. -- Git-op markers pause capture/replay: `rebase-merge`, `rebase-apply`, `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`. Non-`ErrNotExist` stat errors fail open. -- Same-branch rewinds set `daemon_meta.replay.paused_until = now + ACD_REWIND_GRACE_SECONDS`; `0` disables. Manual `/acd/paused` wins. -- Diverged drops stale `pending` for prior generation, keeps `published`, and handles dead refs through `state.PurgeUnpublishedForDeadBranch`. Live refs preserve blockers. -- Dead-branch startup sweep runs after running-mode publish, honors manual pause and `ACD_KEEP_DEAD_BRANCH_BARRIERS=1`, stamps `dead_branch_prune.{last_run_ts,last_count,last_refs}`, and surfaces in `acd diagnose --json`. SQLite pause-read errors fail closed. -- Capture compares live worktree to shadow; stale/missing bootstrap creates phantom creates. -- `walkLive` BFSes by directory; ignore checks batched (`ignoreCheckBatchSize=1000`); ignored/sensitive/safe-ignore dirs pruned before readdir. -- Never prune worktree-rooted `acd/`; `.git/acd` is daemon state. Symlink mode is `120000`; never descend. -- Empty `ACD_SENSITIVE_GLOBS` keeps defaults; typos must not disable defaults. Safe-ignore prunes descendants, not same-named files. `ACD_SAFE_IGNORE=0|false|no|off` disables; `ACD_SAFE_IGNORE_EXTRA=dist/,build/` appends. Restart daemon for env changes. -- `IgnoreChecker.Check` uses long-lived `git check-ignore --stdin -z --non-matching --verbose`; stream stdin while reading stdout. One large `stdin.Write` deadlocks on macOS 16 KiB pipes. `Close` is non-blocking cancel + kill + bounded `cmd.Wait` 2s. - -## Replay and Intent - -- Default `ACD_COMMIT_STRATEGY=event`: one capture per commit; must not call planner. -- `ACD_COMMIT_STRATEGY=intent`: AI planner selects one capture or a non-empty subset; capture durability unchanged. -- Intent defaults: `ACD_INTENT_WINDOW=10`, `ACD_INTENT_MIN_PENDING=10`, `ACD_INTENT_SETTLE_WINDOW=10s`, `ACD_INTENT_MAX_PENDING_AGE=5m`, `ACD_INTENT_RECENT_COMMITS=5`, `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_PATH_COALESCE` is off by default. -- Planner must classify every offered seq as selected or deferred. Invalid/missing/unsafe output records `intent_planner_error` and falls back to deterministic one-item. -- Planner may return ordered `commit_groups` to partition one visible window into multiple atomic commits. Replay publishes valid groups sequentially. -- `deferred_reasons[i].seq` must appear in `deferred_seqs`. Providers normalize spurious deferred reasons before validation and warn with dropped seqs. -- Deferred stays pending in `planner_state`; at `defer_count >= ACD_INTENT_DEFER_LIMIT`, oldest overdue is forced one-item. -- Same-path coalesce is legacy opt-in with truthy `ACD_INTENT_PATH_COALESCE`. By default, consecutive same-path captures remain separate planner-visible seqs. If coalescing is enabled, boundaries are different path, multi-path, rename, delete, or `(branch_ref, branch_generation, base_head)` change. On success every original seq is marked published with the same `commit_oid`; `acd events --json grouped_seqs` and planner-window `hidden_seqs` show the run. -- Planner windows persist in `intent_planner_windows`/`intent_planner_window_events`: offered seqs, visible original seqs, hidden/coalesced seqs, selected groups, deferred seqs/reasons, forced/fallback metadata, provider/model/source/format, and per-event flags. They are privacy-safe summaries, not raw diffs. -- Planner rejects log: `/acd/planner-rejects.jsonl`, 5 MiB + `.1`, best effort. Fields include `ts`, `provider`, `offered_seqs`, `code`, `message`, raw response size, sha256, and parsed-plan summary. `raw_response` is redacted by default; verbatim only with truthy `ACD_INTENT_REJECTS_RAW`, which logs a startup warning. -- `acd status --json` and `acd diagnose --json` expose `intent_strategy`, including settle fields, `last_planner_window`, and recent planner/singleton rates over fixed denominator 100. `diagnose` hints when `planner_error_rate_recent > 0.05`. -- Per-pass scratch index `/acd/replay-*.index` is seeded from `cctx.BaseHead`; reads via `git.LsFilesIndex(...)`. -- CAS targets literal `HEAD` via `git.UpdateRef`; named refs use `--no-deref`. -- `DefaultReplayLimit = 64`; query `Limit+1`, trim, set `ReplaySummary.HasMore`. `DefaultReplayPerEventTimeout = 60s`; timeout/cancel marks event `failed` and stops batch. -- `blocked_conflict`/`failed` are terminal seq barriers; `PendingEvents` hides later pending behind prior terminal rows for same branch/generation. -- Idempotent publish checks current `HEAD` before before-state blocking; matching desired blob/mode/absence means publish with `commit_oid=HEAD`. -- `superseded_external` requires bounded history proof, parent/base tree match, and live worktree before-state match. Incomplete proof means no supersede. -- Live-index reconciliation is guarded/path-scoped and must not overwrite user-staged changes. See `internal/git/tree.go`, `internal/daemon/replay.go`, `internal/daemon/live_index_repair.go`. - -## Run Loop and Observability - -- `processBranchTokenChange` runs before capture and after flush drain; do not collapse. Post-flush recheck handles git surgery outside `wakeCh`. -- Branch settle `100ms`; flush drain `DefaultFlushLimit = 256`. -- Daemon stamps `commit.strategy`, `intent.{window,min_pending,settle_window,max_pending_age,recent_commits,defer_limit,diff_egress}`; CLI reads meta before env. -- Startup sweeps `acknowledged` flush requests older than `OrphanFlushAckThreshold = 5m` to `failed`. -- fsnotify dispatch must not block: runtime creates use `rewalkCh`/`rewalkWorker`; diagnostics `diagCh`; tail clamps `MaxDebounceTail = 500ms`; ENOSPC -> `errBudgetExceeded`. -- Daemon log: `paths.Roots.RepoLogPath(repoHash)` (`~/.local/state/acd//daemon.log`) with rotation/compression. Hook log: `${XDG_STATE_HOME:-$HOME/.local/state}/acd/-hook.log`. -- `acd logs --follow` streams from EOF reached by initial tail read; do not re-`Stat`. `acd list` on a TTY defaults to compact watch (`REPO`/`DAEMON`/`PEND`/`BLK`/`HEAD`/`STATUS`; two-segment `REPO`, `#hash` on collision; compact tokens `blk`/`wait`/`miss`/`bad`; disabled rows are hidden); non-TTY and `--once` one-shot compact; `--verbose` wide table; `--json` one-shot on TTY; explicit `--watch --json` errors; `--interactive` opens the repo manager. `acd rewrite-commits` prefers explicit selectors `--from-sha`, `--from-nr`, `--range-nr`, and `--range-sha`; compatibility `--from`/`--range` remain. `--progress auto|plain|json|off` writes only to stderr, and `--quiet` suppresses progress. `acd rewrite-commits --plan-only` ends with plan-saved + `Next:` apply commands; declined apply prints `No rewrite performed.`. `acd events --watch` without `--since` starts at current ledger tail; with `--since` resumes after cursor. -- Probes: `acd status --repo .`; `acd events --watch`; `acd logs --repo . --lines 50 --follow`; `acd diagnose --repo . --json`; `acd doctor --repo . --json`; `git status --short --ignored`. - -## CLI, Git, AI - -- `events`/`explain`/`doctor` read paths must not call `state.Open` or migrate DBs; use read-only SQLite. Missing decision tables mean empty summaries, clear text, valid JSON, no table creation. -- Status JSON includes `decision_counts`, `recent_decisions`, `decision_cursor`, `failed_events`, `failed_blocking_pending`, `intent_strategy`. `explain --since` summarizes newest post-cursor decision. -- `acd doctor` detects drift when active hooks lack both `acd start` and `acd wake`, falls back on EACCES/EIO, tails Codex hook log, and surfaces error count plus first line. -- `acd setup --raw` emits only snippet body. Default keeps comment-wrapped instructions/README. Shell `--raw` writes a `\n` separator between direnv and zshrc snippets. -- `acd commit-all`: one-shot capture+replay without persistent daemon. Refuses on detached HEAD, git-op markers, manual pause marker, or running per-repo daemon. Force-reseeds shadow from HEAD; drops stale pending for active `(branch_ref, generation)`. -- `acd start` short-circuit: start-cache + central registry can skip control.lock, SQLite migration, registry rewrite. Manual start without `--session-id` registers `human:`. Harness starts require `--session-id` when `--harness` is set. -- Per-repo disabled lifecycle state lives in the central registry. `acd repo disable` stops a live daemon, clears start caches, preserves `.git/acd/state.db`, and makes hook `start`/`wake`/`touch`/`flush` skip with `repo_disabled`; manual start reports `acd repo enable --repo `. `acd repo enable` clears disabled state without starting the daemon. `acd repo manage` and `acd list --interactive` share the line-oriented manager (`t/e/d N`, `r`, `v`, `q`). -- Registry-backed early short-circuit runs before `git.ResolveWorktree`; cold path always uses `git.ResolveWorktree`. -- Path lookups canonicalize via `git.ResolveWorktree` (`rev-parse --show-toplevel` + `--absolute-git-dir`, EvalSymlinks both). `ErrNotWorktree` refuses non-Git paths. `lookupRegisteredRepo` is shared by read-only commands. -- `central.Registry.UpsertRepo` matches by canonical `state_db` or path. `CleanupLegacyDuplicates` runs under lock with up to 8 workers; `acd gc` reports `merged []LegacyDuplicateChange`. -- `internal/git`: `RunOpts.Timeout`, `RunWithLimit`, `ErrStdoutOverflow`, `DefaultReadTimeout=30s`, `DefaultWriteTimeout=60s`, `git.DefaultDiffCap` 1 MiB. Ambiguous `RevParse` means `git.ErrRefAmbiguous`. -- Pinned `ps`: `/bin/ps` Darwin, `/usr/bin/ps` Linux. `isSQLiteLocked` must unwrap `*sqlite.Error` and compare typed code before substring fallback. -- `acd hook-stdin-extract [field...]`: rejects CR/LF/NUL; empty required fields are field-not-found; required outputs flush after every required field resolves; 1 MiB stdin truncation is distinct. Optional fields use `?`. -- AI providers declare `NeedsDiff`; network providers receive redacted diffs only when `NeedsDiff=true` and `ACD_AI_DIFF_EGRESS` is truthy. `DeterministicProvider` has `NeedsDiff=false`. -- `BuildOpsDiff` caps rendered text at `ai.DiffCap`; per-op git diff uses `2 * ai.DiffCap` and 5s timeout. Redact/truncate before provider send. -- `ACD_AI_SEND_DIFF` is removed; if set, emits one startup deprecation warning. -- `ACD_TRACE=1` writes best-effort JSONL `/acd/trace/YYYY-MM-DD.jsonl`; `ACD_TRACE_DIR` overrides. It never blocks/aborts. Verify classes with `rg -n "EventClass:" internal/`. +| `internal/cli` | Cobra commands, health/control, recovery, setup, start cache | +| `internal/daemon` | Run loop, capture/replay, exact-chain reconciliation, intent circuit, shadow, fsnotify | +| `internal/state` | SQLite v13: events/ops, planner state/windows, recovery snapshots, meta/clients/flush | +| `internal/git` | Bounded Git/ref/tree/diff/blob/index/history/ignore helpers | +| `internal/ai` | Deterministic, OpenAI-compatible, subprocess providers; prompts and planner | +| `internal/{central,config,identity,logger,paths,pause,prompttrace,trace}` | Registry/config, XDG/logging, pause and diagnostics | +| `internal/adapter` | Harness detection/markers; do not restore old TODO stubs | +| `templates/{claude-code,codex,cursor,opencode,pi,shell}` | Install snippets; Codex/Cursor use `hooks.json` | +| `test/integration` | Build-tagged lifecycle, adapter, recovery, AI, intent and latency tests | +| `.github/workflows/{ci,codeql,release}.yml` | CI, CodeQL and tag release | +| `README.md`, `docs/*`, `CHANGELOG.md` | User and release contracts | + +## Workflow and tests + +- Scope changes, prefer `rg`, preserve unrelated work and inspect failures before retrying. Races, panics, ordering failures, flakes and read-only migrations are bugs. +- After test `git.Init`/`git init`, run `git symbolic-ref HEAD refs/heads/main`. HEAD-transition tests use `waitForMetaValue(MetaKeyBranchHead, , 3s)`. +- Focused stability: `-count=10`. Ordering hazards: `GOMAXPROCS=1 -count=50`. +- Broad-run-sensitive coverage includes fsnotify wake, lifecycle, wake coalescing, real SIGUSR1, repeated edits, external FF reseed, FF-in-rewind-grace, and `TestSquashBacklogRecovery_PreservesSixtyCapturesAndControls`. +- CLI changes need Cobra help/examples. Template changes need `internal/cli/setup_test.go` and AdapterE2E coverage. +- ACD self-hosts this repo. Pause only for branch/history surgery, recovery/DB mutation, or tests that deliberately run capture/replay against this checkout; resume with `acd resume --repo . --yes`. +- Source edits do not change the running daemon. For daemon/provider tests, build and install/use `bin/acd`, then restart or invoke it directly. +- Rerun environment-sensitive failures through `cleanenv` and verify suspected baseline flakes on `main`. + +Commit messages: + +| Mode | Contract | +|---|---| +| Default `imperative` | Semantic imperative subject, max 50 chars, no period; blank line; `- ` why/context bullets wrapped at 72 | +| Optional `conventional` | Scope-less `type: subject`; types: `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, `perf`, `style`, `revert`; no scopes/breaking markers | + +- Avoid filename-only subjects, `Update file`, `WIP` and `changes`. +- Planner rationale belongs in `grouping_reason`, never the commit body. +- Caps: `ai.SubjectCap=50`, `ai.BodyWrap=72`, per-event `ai.DiffCap=4000`, planner `ai.IntentStageDiffCap=16000`. + +## State, branches and capture + +- Repo DB: `/acd/state.db`; central registry/stats use XDG paths. v12 adds immutable `recovery_snapshots` and ordered `recovery_snapshot_events`; `SchemaVersion=13` adds the same-base recovery-prefix retention index; see `internal/state/migrate.go`. +- Start cache: `/acd/start-cache-.json`, schema v2, atomic temp+rename. `acd stop` removes matching/all caches. +- Branch tokens: attached `rev: `; detached `rev:`; missing `missing `. Reset/rebase/switch/same-SHA ref switch bumps generation; ordinary FF keeps it, except FF during rewind grace bumps, reseeds, and clears grace. Legacy bare rev upgrades as Diverged. +- Every non-unchanged token transition reconciles the prior exact pair before acceptance; dead-ref sweeps alone skip live refs. +- Detached HEAD pauses capture/replay and `acd start` refuses; never fall back to `refs/heads/main` after symbolic-ref failure. +- Git-op markers: `rebase-merge`, `rebase-apply`, `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`. Log non-`ErrNotExist` stat errors and treat the marker absent for that tick to avoid a permanent latch. +- Same-branch rewind sets `daemon_meta.replay.paused_until` using `ACD_REWIND_GRACE_SECONDS`. Manual `/acd/paused` always wins. +- `shadow_paths` is keyed by `(branch_ref, branch_generation, path)`. Bootstrap uses 5000-row chunks and writes `shadow.bootstrapped::` only after completion; clean partial/empty-marked state before re-bootstrap. +- Capture compares live worktree to shadow; stale bootstrap causes phantom creates. Reseed keeps one old generation by default. +- `walkLive` is directory-BFS with 1000-path ignore batches; prune ignored/sensitive/safe-ignore dirs before readdir. Worktree `acd/` is data; only `.git/acd` is state. Symlinks are mode `120000` and never descended. +- Empty `ACD_SENSITIVE_GLOBS` retains defaults. Safe-ignore prunes descendants, not same-named files; false-like `ACD_SAFE_IGNORE` disables and `ACD_SAFE_IGNORE_EXTRA=dist/,build/` appends. +- `IgnoreChecker` keeps `git check-ignore --stdin -z --non-matching --verbose` alive. Stream writes while reading stdout; one large write can deadlock on macOS 16 KiB pipes. Close is cancel+kill with a 2s wait. + +## Replay and intent + +- Default `ACD_COMMIT_STRATEGY=event` publishes one capture per commit and never calls the planner. `intent` selects one capture or a non-empty subset; capture durability is unchanged. +- Every offered seq must be selected or deferred. Invalid/missing/unsafe output records `intent_planner_error` and falls back to a deterministic singleton. +- Ordered `commit_groups` partition and publish a visible window. `deferred_reasons[i].seq` must be in `deferred_seqs`; drop spurious reasons with a warning. At the defer limit, force the oldest overdue singleton. +- Same-path coalescing is legacy opt-in. Without it, consecutive captures stay visible; with it, different/multi-path, rename, delete, or branch/generation/base-head changes break the run and every original seq shares the commit. +- Planner windows persist privacy-safe summaries, not raw diffs. Rejects go to `/acd/planner-rejects.jsonl` (5 MiB plus `.1`); raw responses are redacted unless `ACD_INTENT_REJECTS_RAW` is truthy. +- Planner health is versioned JSON in `daemon_meta.intent.planner.health`. A transport failure opens the circuit immediately; 3 consecutive validation/safety failures open it; cooldowns are 30s, 2m, 10m; one half-open probe runs while other windows use deterministic fallback. +- Reuse one provider. Successfully received malformed/empty rewrites are `IntentMessageRewriteValidationError`; operational failures are transport. Preserve `MessageQualityError.Unwrap`; caller cancellation never mutates health. +- Per-pass scratch index `/acd/replay-*.index` starts from `cctx.BaseHead`. CAS updates literal `HEAD`; named refs use `--no-deref`. +- Event-mode run-loop budgeting is 64 rows; intent uses its planner gate/window. Per-event timeout is 60s and settles that event terminal before halting; caller cancellation propagates. +- `blocked_conflict`/`failed` are terminal barriers, so later pending rows for the exact pair stay hidden. +- Idempotent publish checks current HEAD before before-state blocking. `superseded_external` requires bounded history, parent/base-tree and live before-state proof. Live-index repair is path-scoped and never overwrites user staging. ## Recovery -```bash -acd diagnose --repo . --json +~~~bash acd fix --dry-run acd fix --yes acd fix --force --dry-run acd fix --force --yes -acd wake --repo . --session-id -acd status --repo . -``` - -- `acd fix` is the recovery entrypoint. Dry-run is default without `--yes`. -- `--yes` applies safe actions: resolve already-landed barriers, retarget stale anchors, delete obsolete barriers, drop protected generated pending rows, mark externally-published rows, clear expired manual pauses, clear drained backpressure. -- `--force` also purges terminal replay barriers with pending successors, including failed rows; combine with `--yes` to apply. -- Fixes refuse while a live daemon owns the state DB; state.db is backed up before mutation. -- `acd resume --yes` lifts only manual pause. -- `acd recover` and `acd purge-events` are deprecated and hidden; use `acd fix`. - -## Harness Templates - -| Harness | Start | Active hooks | End | Notes | -|---|---|---|---|---| -| Claude Code | `SessionStart -> acd start` fail-soft | `Pre/PostToolUse`: start+wake and-chain + log fallback | `Stop -> acd flush --logical`; `SessionEnd -> acd stop --session-id` | `CLAUDE_PROJECT_DIR:-$PWD`; nested JSON | -| Codex | `SessionStart -> acd start` | `UserPromptSubmit`/`PreToolUse`/`PostToolUse`; matcher `apply_patch\|Edit\|Write\|Bash` | `Stop -> acd touch` | `templates/codex/hooks.json`; active timeout 15s | -| Cursor | `sessionStart -> acd start` | `postToolUse`/`afterFileEdit` -> start+wake inline | `stop -> flush`; `sessionEnd -> stop` | User-global `~/.cursor/hooks.json`; `conversation_id`; `--watch-pid 0`; approve in Settings → Hooks | -| OpenCode | `session.created -> acd start` | `tool.before.*`/`tool.after.*`: start+wake and-chain + log fallback | `session.idle -> acd flush --logical`; `session.deleted -> acd stop --session-id` | `OPENCODE_SESSION_ID`/`OPENCODE_PROJECT_DIR`; `~/.config/opencode/hook/hooks.yaml` | -| Pi | `session.created -> acd start` | `tool.before.*`/`tool.after.*`: start+wake and-chain + log fallback | `session.idle -> acd flush --logical`; `session.deleted -> acd stop --session-id` | `SID="${PI_SESSION_ID:-pi-$$-$(date +%s)}"`; no `uuidgen`; `~/.pi/agent/hook/hooks.yaml` | - -Active-hook body pattern: - -```bash -LOG="${XDG_STATE_HOME:-$HOME/.local/state}/acd/-hook.log" -[ -d "$(dirname "$LOG")" ] || mkdir -p "$(dirname "$LOG")" 2>/dev/null || true -{ acd start --... && acd wake --... ; } 2>>"$LOG" || { printf '[%s] active hook failed exit=%d cmd=acd-start-wake\n' "$(date +%FT%T%z)" "$?" >>"$LOG"; exit 1; } -``` - -- `acd start` failure is no longer masked by wake; active hook exits nonzero. AdapterE2E covers stop-all self-heal and corrupt-DB negatives. -- Existing-user migration: rerun `acd setup ` and replace installed hooks. `acd doctor` flags drift. -- Markers: TOML/YAML/shell snippets use leading `# acd-managed: true` comments. JSON harnesses are schema-clean and detected by ACD hook command signatures; top-level `_acd_managed` is legacy-only fallback detection. Keep hookhelper, setup tests, templates, AdapterE2E in sync. -- Codex: `~/.codex/hooks.json` wins over `~/.codex/config.toml`; legacy TOML deleted; hooks JSON must have only `hooks` at the root. Current Codex rejects old top-level `_acd_managed`; `acd doctor` warns when JSON hooks and legacy TOML both contain ACD installs because events double. -- Codex Stop deliberately stays on `acd touch`: Codex Stop fires on every assistant turn and overlaps tool runs, so `flush --logical` there would chain commits per tool turn. If Codex later adds a true session-idle event, mirror the Claude/OpenCode/Pi flush behavior. -- Codex `/hooks` re-approval is required after every `~/.codex/hooks.json` change; until approved, `SessionStart` never fires. -- `acd setup codex --raw > ~/.codex/hooks.json` destroys non-ACD entries; custom-hook users must merge manually. -- Cursor: user-global `~/.cursor/hooks.json` only (not repo `.cursor/hooks.json`); hook commands are inline in `templates/cursor/hooks.json`. `acd setup cursor --raw > ~/.cursor/hooks.json` replaces the entire file; merge the five lifecycle events manually when non-acd hooks exist. Approve in Settings → Hooks after install. -- Codex hooks are enabled by default. If `~/.codex/config.toml` pins feature flags, keep `[features].hooks = true`; `features.codex_hooks` is only a deprecated alias. `hooks.json` carries hook bodies, not feature flags. -- Codex `cwd` comes from stdin via `acd hook-stdin-extract session_id cwd? <&0`; missing `cwd` falls back to `$PWD`. `CODEX_PROJECT_DIR`/`printf "{}\n"` are gone. Bash bodies use `|| exit 0` after helper so missing `acd` does not block hook. -- `acd flush --logical` refreshes heartbeat, enqueues `flush_logical`, and SIGUSR1s the daemon. Only drained `flush_logical` sets `IntentBypassBatchWait`; plain `acd wake` only nudges capture/replay. Logical flush requires a registered active session id and reports refusals in JSON without blocking the harness hook. - -## Env - -| Group | Vars | +~~~ + +- Reconcile from the earliest unpublished seq of one exact `(branch_ref,generation)` chain. A represented published prefix is materialization context only and is never transitioned. +- HEAD+ancestry+final-state proof marks the complete unpublished chain `published` under a `/published` ref; otherwise archive reconstructs immutable provenance under `/archive` and marks it `recovered`. Missing objects, partial proof, collisions or races mean no DB transition. +- Recovery refs include a 96-bit SHA-256 target digest: archive hashes `baseHead + NUL + treeOID`; published hashes `commitOID`. This prevents selector reuse across linked worktrees/reset DBs. +- Dead-ref recovery locks/verifies the proof ref and expected-absent branch in one `git update-ref --stdin` transaction held through the SQLite transition. The dead-branch sweep proves or archives; legacy `dead_branch_prune.*` records only the last non-empty recovery. +- Archive recovery invalidates the exact shadow pair, then reseeds from HEAD and recaptures the dirty worktree. Reconciliation never changes live HEAD, index or worktree. +- `acd fix` is dry-run without `--yes`. Safe apply reconciles exact pairs and may clean explicitly selected protected-generated pending rows, expired manual pause and drained backpressure; exact-pair reconciliation never retargets or deletes captured chains. +- `--force` selects archive-only exact-chain recovery; it does not purge captured work. Fix acquires `daemon.lock` after consent, rechecks daemon/HEAD/git-op/pause safety, refuses a live owner, then creates a WAL-consistent `VACUUM INTO` backup verified with `PRAGMA quick_check` before migration/mutation. +- `commit-all` reconciles before reseed. Its dry-run, decline and JSON without `--yes` must not open writable state, lock, create refs, capture or build providers; incomplete recovery returns nonzero. +- `acd recover` and `acd purge-events` are deprecated/hidden. Purge selectors `--blocked|--pending|--failed` fail closed; only explicit `--all` delegates archive-only whole-repo recovery. +- Published-event pruning defaults to 7 days, never removes unresolved terminal rows, and prunes recovered members only while their recovery ref is verified and locked. +- `acd resume --yes` lifts only manual pause. Restore/archive workflows are in `docs/user-workflows.md`. + +## Run loop, CLI and observability + +- Keep `processBranchTokenChange` before capture and after flush drain; the second catches Git surgery outside `wakeCh`. Branch settle is 100ms, flush drain 256, and startup fails acknowledged flushes older than 5m. +- fsnotify is opt-in; poll is the safety net. Dispatch never blocks: rewalk/diagnostics use replaceable worker channels, debounce tail is 500ms, and budget exhaustion falls back to poll. +- Bare `acd` is read-only health. Active-tail terminal events mean `needs_attention`; inactive historical terminals do not. `acd on` is idempotent but renders diagnostics and returns nonzero if still unhealthy. `acd off` idempotently disables/stops and preserves state. +- `events`, `explain` and `doctor` read paths must use read-only SQLite and never migrate/create tables. Missing decision tables yield empty valid output. +- `status --json`/`diagnose --json` expose intent windows, circuit and recovery. Open-circuit guidance is fallback plus automatic probe, not restart. +- Logs: `${XDG_STATE_HOME:-$HOME/.local/state}/acd//daemon.log` with rotation/compression; hooks use `${XDG_STATE_HOME:-$HOME/.local/state}/acd/-hook.log`. `acd logs --follow` continues from the EOF reached by initial tail. +- `events --watch` starts at ledger tail unless `--since` is supplied. Rewrite progress uses stderr; see `docs/rewrite-commits.md`. +- Start cache plus central registry may bypass control lock, DB migration and registry rewrite. Manual start uses `human:`; harness starts require `--session-id`. +- Repo autodiscovery defaults enabled. `${XDG_CONFIG_HOME:-$HOME/.config}/acd/config.json` or `ACD_REPO_AUTODISCOVERY` overrides it. Invalid policy fails closed/skips for hooks but errors for manual callers; `acd repo init` explicitly registers. +- Central lifecycle: `repo disable` stops, clears caches and preserves DB; hooks skip `repo_disabled`; `repo enable` only clears disabled state. +- Canonicalize with `git.ResolveWorktree` (toplevel, absolute Git dir, symlinks); reject non-worktrees. Registry upsert matches canonical DB/path. +- Git helpers use timeouts and bounded output: read 30s, write 60s, diff 1 MiB; ambiguous rev parse is `git.ErrRefAmbiguous`. Pin `ps` to `/bin/ps` on Darwin and `/usr/bin/ps` on Linux. +- AI providers declare `NeedsDiff`. Network diff egress requires both `NeedsDiff=true` and truthy `ACD_AI_DIFF_EGRESS`; redact/truncate before send. `ACD_AI_SEND_DIFF` is removed and warns once if set. +- `ACD_TRACE=1` writes best-effort JSONL to `/acd/trace/YYYY-MM-DD.jsonl` (or `ACD_TRACE_DIR`) and must never block/abort. +- Useful probes: `acd status --repo .`, `acd events --watch`, `acd logs --repo . --lines 50 --follow`, `acd diagnose --repo . --json`, `acd doctor --repo . --json`, `git status --short --ignored`. + +## Harness templates + +| Harness | Start | Active | End | +|---|---|---|---| +| Claude Code | `SessionStart -> start` | `Pre/PostToolUse -> start && wake` | `Stop -> flush --logical`; `SessionEnd -> stop` | +| Codex | `SessionStart -> start` | `UserPromptSubmit/PreToolUse/PostToolUse -> start && wake` | `Stop -> touch` | +| Cursor | `sessionStart -> start` | `postToolUse/afterFileEdit -> start && wake` | `stop -> flush`; `sessionEnd -> stop` | +| OpenCode/Pi | `session.created -> start` | `tool.before/after.* -> start && wake` | `session.idle -> flush --logical`; `session.deleted -> stop` | + +- Templates are source of truth. Keep hook helper, setup tests and AdapterE2E synchronized. Active hooks use `start && wake`, log failures and return nonzero; never mask start failures. +- TOML/YAML/shell use leading `# acd-managed: true`. JSON must be schema-clean and is detected by command signatures; top-level `_acd_managed` is legacy-only. +- Codex uses `~/.codex/hooks.json` with only root `hooks`; legacy TOML duplicates events. Keep `[features].hooks=true` if pinned; `features.codex_hooks` is deprecated. +- Codex Stop stays `acd touch` because it fires every turn. Changes require `/hooks` re-approval. `setup codex --raw > ~/.codex/hooks.json` replaces the file; merge custom hooks. +- Codex extracts `session_id cwd?` from stdin; missing cwd falls back to `$PWD`. Missing `acd` stays fail-soft. +- Cursor installs only to `~/.cursor/hooks.json`; `--raw` replaces it, so merge custom hooks and re-approve in Settings -> Hooks. +- `flush --logical` refreshes heartbeat, enqueues `flush_logical` and signals SIGUSR1. Only a drained logical flush bypasses intent batch wait; it requires a registered active session and reports refusal without blocking the harness. + +## Environment + +Runtime environment is resolved at daemon start; restart after changes. + +| Group | Variables and defaults | |---|---| -| Trace | `ACD_TRACE`; `ACD_TRACE_DIR` default `/acd/trace` | -| Shadow/rewind | `ACD_SHADOW_RETENTION_GENERATIONS=1`; `ACD_REWIND_GRACE_SECONDS=60` (`0` disables); `ACD_KEEP_DEAD_BRANCH_BARRIERS` disables auto-prune | -| Capture | `ACD_SENSITIVE_GLOBS`; `ACD_SAFE_IGNORE`; `ACD_SAFE_IGNORE_EXTRA`; `ACD_MAX_PENDING_EVENTS`; `ACD_PATH_QUIESCENCE_SECONDS=0` (off; restart to apply; capture remains durable, planner offer waits for quiet path) | -| AI | `ACD_AI_PROVIDER=deterministic|openai-compat|subprocess:`; `ACD_AI_BASE_URL`; `ACD_AI_API_KEY`; `ACD_AI_MODEL`; `ACD_AI_TIMEOUT=30s`; `ACD_AI_CA_FILE`; `ACD_AI_DIFF_EGRESS`; `ACD_COMMIT_FORMAT=imperative|conventional`; `ACD_INTENT_REJECTS_RAW` | -| Strategy | `ACD_COMMIT_STRATEGY=event|intent`; `ACD_INTENT_WINDOW=10`; `ACD_INTENT_MIN_PENDING=10`; `ACD_INTENT_SETTLE_WINDOW=10s`; `ACD_INTENT_MAX_PENDING_AGE=5m`; `ACD_INTENT_RECENT_COMMITS=5`; `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_PATH_COALESCE` off by default; `ACD_RECENT_COMMIT_AFFINITY_SECONDS=0`; planner cap `ai.IntentStageDiffCap=16000` | -| Watcher/client | `ACD_FSNOTIFY_ENABLED`; `ACD_DISABLE_FSNOTIFY`; `ACD_MAX_INOTIFY_WATCHES`; `ACD_CLIENT_TTL_SECONDS` | +| Repo | `ACD_REPO_AUTODISCOVERY=enabled`; durable override `${XDG_CONFIG_HOME:-$HOME/.config}/acd/config.json` | +| Trace | `ACD_TRACE=off`; `ACD_TRACE_DIR=/acd/trace`; `ACD_AI_PROMPT_TRACE=off` (sensitive) | +| Shadow/recovery | `ACD_SHADOW_RETENTION_GENERATIONS=1`; `ACD_REWIND_GRACE_SECONDS=60`; `ACD_KEEP_DEAD_BRANCH_BARRIERS` disables dead-ref recovery sweep | +| Capture | `ACD_MAX_FILE_BYTES=5 MiB`; `ACD_MAX_PENDING_EVENTS=50000`; `ACD_PATH_QUIESCENCE_SECONDS=0`; `ACD_EVENT_RETENTION_DAYS=7`; `ACD_SENSITIVE_GLOBS`; `ACD_SAFE_IGNORE`; `ACD_SAFE_IGNORE_EXTRA` | +| AI | `ACD_AI_PROVIDER`: `deterministic`, `openai-compat` or `subprocess:`; `ACD_AI_BASE_URL=https://api.openai.com/v1`; `ACD_AI_MODEL=gpt-5.4-mini`; `ACD_AI_TIMEOUT=30s`; `ACD_AI_API_KEY`; `ACD_AI_CA_FILE`; `ACD_AI_DIFF_EGRESS=off`; `ACD_COMMIT_FORMAT`: `imperative` or `conventional`; `ACD_INTENT_REJECTS_RAW=off` | +| Intent | `ACD_COMMIT_STRATEGY`: `event` or `intent`; `ACD_INTENT_WINDOW=10`; `ACD_INTENT_MIN_PENDING=10`; `ACD_INTENT_SETTLE_WINDOW=10s`; `ACD_INTENT_MAX_PENDING_AGE=5m`; `ACD_INTENT_RECENT_COMMITS=5`; `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_RETRY_ON_INVALID=2`; `ACD_INTENT_PATH_COALESCE=off`; `ACD_RECENT_COMMIT_AFFINITY_SECONDS=0` | +| Watch/client | `ACD_FSNOTIFY_ENABLED=off`; `ACD_DISABLE_FSNOTIFY` forces poll; `ACD_MAX_INOTIFY_WATCHES`; `ACD_CLIENT_TTL_SECONDS=1800` | + +Canonical details: `docs/overview.md`, `docs/capture-replay.md`, `docs/intent-commit-flow.md`, `docs/ai-providers.md`, `docs/user-workflows.md` and `docs/rewrite-commits.md`. diff --git a/README.md b/README.md index 8457afba..e8d8b5ea 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ flowchart TB ## Install -Building from source or using `go install` requires Go 1.26.4 or newer. +Building from source or using `go install` requires Go 1.26.5 or newer. ~~~bash brew install KristjanPikhof/tap/acd @@ -143,6 +143,14 @@ settle window lets a burst of related edits reach one planner-visible window, while `acd flush --logical` still drains the current visible batch from an active harness session. +If the configured planner times out or returns unsafe output, ACD commits with +the deterministic planner instead of stalling the queue. A persisted circuit +breaker pauses repeated provider calls for 30 seconds, then 2 minutes, then 10 +minutes. One probe is allowed after each cooldown, and a successful validated +plan closes the circuit. Bare `acd` reports degraded fallback health; +`acd status` and `acd diagnose` show the circuit state, failure count, bypass +count, and next probe time. + When one visible window contains separate intents, the planner prompt asks for ordered `commit_groups` so replay can publish atomic commits instead of forcing the whole window into one commit. @@ -151,7 +159,7 @@ Message format: | Format | Example subject | Notes | |---|---|---| -| `imperative` | `Add commit format selection` | Default and recommended. Existing behavior is unchanged. | +| `imperative` | `Add commit format selection` | Default. Subjects start with an imperative verb. | | `conventional` | `feat: add commit format selection` | Optional scope-less Conventional Commit style. | ~~~bash @@ -165,55 +173,84 @@ rules as the default format. ## Daily commands +After setup, leave ACD running in the background. Use the three short commands +below for normal health and lifecycle control; the hook protocol stays +automatic. + | Need | Command | |---|---| -| Start or refresh the current repo daemon | `acd start` | -| Watch all registered repos | `acd list` | +| Check this repo and get one recommended next action | `acd` | +| Enable ACD and ensure the daemon is running | `acd on` | +| Disable ACD and stop the daemon without deleting state | `acd off` | +| Watch enabled repos | `acd list` | | Show this repo state | `acd status` | | Follow capture, group, publish, and block decisions | `acd events --watch` | | Ask why a path behaved a certain way | `acd explain --path FILE` | | Ask what ACD did for a commit | `acd explain --commit HEAD` | -| Flush the current visible intent batch from an active harness session | `acd flush --session-id "$ACD_SESSION_ID" --logical` | -| Nudge capture and replay without bypassing intent batch gates | `acd wake --session-id "$ACD_SESSION_ID"` | -| Stop this repo daemon | `acd stop` | -| Stop every registered daemon | `acd stop --all` | | Tail the daemon log | `acd logs --follow` | | Create a support bundle | `acd doctor --bundle` | -`acd start` resolves your current directory to the canonical Git worktree root, -so calling it from a subdirectory refreshes the same daemon. +Bare `acd` is read-only. `acd on` and `acd off` are idempotent, work from a +subdirectory, and preserve `.git/acd/state.db`. Harness integrations continue +to use the lower-level `start`, `wake`, `touch`, `flush`, and `stop` commands. +See the [command reference](docs/commands.md) for every public command and its +read or write behavior. ## When commits stop -Use the same ladder every time: +ACD first tries to heal the queue itself. It reconciles the complete unpublished +chain for one branch generation, never an isolated blocker: + +- If stable `HEAD` already has the chain's exact final touched-path state, ACD + marks the chain published and keeps a hidden proof ref. +- Otherwise ACD writes the reconstructed tree to a hidden recovery ref, marks + the chain recovered, reseeds from `HEAD`, and captures the still-dirty + worktree again. +- If objects are missing, the branch changes during proof, or a recovery ref + collides, ACD leaves the queue, live `HEAD`, index, and worktree unchanged. + A hidden evidence ref may remain so a safe retry can reuse the same tree. + +Start with the short read-only path: ~~~bash -acd status +acd +acd diagnose acd events --watch -acd explain --path path/to/file -acd diagnose --json +~~~ + +Use `fix` when you want to preview or run the same recovery manually. Preview +first, turn ACD off so hooks cannot restart the daemon during repair, then turn +it on again: + +~~~bash acd fix --dry-run +acd off acd fix --yes -acd status +acd on +acd ~~~ -Only use the force path after the dry-run shows terminal barriers with pending -successors and you have checked that the blocked changes are already in `HEAD` -or should be discarded: +`--force` means archive-only recovery. It does not purge, retarget, or discard +captured events: ~~~bash acd fix --force --dry-run +acd off acd fix --force --yes +acd on ~~~ -`acd fix` backs up `state.db` before it mutates state and refuses to run while a -live daemon owns the database. If the problem is only a manual pause marker, -run: +`acd fix` creates a SQLite-consistent backup before it mutates state and refuses +to run while a live daemon owns the database. If the problem is only a manual +pause marker, run: ~~~bash acd resume --yes ~~~ +To inspect or restore an archived chain, use the `/archive` ref printed by +`acd events`; see [Inspect or restore archived work](docs/user-workflows.md#inspect-or-restore-archived-work). + If `acd diagnose --json` reports generated pending deletes under a tracked cache directory such as `.derivedData-provider-core`, `acd fix --yes` cleans only ACD's queue. Record the Git cleanup separately after review: @@ -234,17 +271,22 @@ acd commit-all --yes acd commit-all --yes --json ~~~ -It refuses on detached HEAD, in-progress Git operations, manual pause markers, -or while the per-repo daemon is alive. +Detached HEAD, in-progress Git operations, and manual pause markers are refused +for previews and apply. If an authorized run reaches its mutation phase, it +also refuses while the per-repo daemon is alive. Dry-run, a declined +confirmation, and a clean no-op do not acquire `daemon.lock`; the first two are +read-only and do not capture files, start the AI provider, create recovery refs, +or write ACD state. An incomplete drain exits non-zero and leaves the captured +queue protected for diagnosis. ## Repo registration Most repos need no manual setup. Harness hooks call `acd start`, which creates `/acd/state.db` and registers the repo. -Use explicit lifecycle commands when autodiscovery is disabled or when old rows -need cleanup, or when you want to keep global autodiscovery on but exclude one -repo: +For normal use, prefer `acd on` and `acd off`. Use the explicit lifecycle +commands below for bulk administration, registry cleanup, or when global +autodiscovery is disabled: ~~~bash acd repo init @@ -299,8 +341,10 @@ explicit local cleanup before sharing a branch: ~~~bash acd rewrite-commits --from-nr 5 --plan-out rewrite.json --plan-only acd rewrite-commits --show-plan rewrite.json +acd off acd rewrite-commits --apply-plan rewrite.json --dry-run acd rewrite-commits --apply-plan rewrite.json --yes +acd on ~~~ Use `--from-sha ` when you want a commit-ish selector, `--range-nr 5-12` @@ -333,7 +377,7 @@ git reset --hard | `ACD_INTENT_RETRY_ON_INVALID` | `2` | Max correction retries after invalid planner output. | | `ACD_SAFE_IGNORE` | enabled | Set false-like value to stop pruning generated trees. | | `ACD_SAFE_IGNORE_EXTRA` | unset | Extra generated trees, such as `dist/,build/`. | -| `ACD_SENSITIVE_GLOBS` | built in | Extra sensitive path globs. Empty keeps defaults. | +| `ACD_SENSITIVE_GLOBS` | built in | Non-empty values replace the protected path globs. Unset or empty uses the defaults. | | `ACD_TRACE` | off | Writes daemon decision summaries under `/acd/trace/`. | | `ACD_AI_PROMPT_TRACE` | off | Writes local AI request diagnostics. Treat as sensitive. | @@ -344,6 +388,7 @@ Restart a running daemon after changing daemon runtime environment. | Doc | Use it for | |---|---| | [docs/overview.md](docs/overview.md) | A short system map. | +| [docs/commands.md](docs/commands.md) | Every public command, its side effects, and safe examples. | | [docs/user-workflows.md](docs/user-workflows.md) | Daily status, recovery, support bundles, and `commit-all`. | | [docs/capture-replay.md](docs/capture-replay.md) | Storage, replay, branch safety, blockers, and trace classes. | | [docs/intent-commit-flow.md](docs/intent-commit-flow.md) | Intent grouping behavior and planner observability. | diff --git a/docs/ai-providers.md b/docs/ai-providers.md index f8ff1278..e68ea13e 100644 --- a/docs/ai-providers.md +++ b/docs/ai-providers.md @@ -77,8 +77,6 @@ Before sending, it redacts common secret shapes and truncates: Redaction is a backstop, not a guarantee. Do not enable diff egress for a private repo unless the endpoint or plugin is trusted. -`ACD_AI_SEND_DIFF` is removed. Use `ACD_AI_DIFF_EGRESS=1`. - ## Environment reference | Variable | Default | Notes | @@ -92,20 +90,21 @@ private repo unless the endpoint or plugin is trusted. | `ACD_AI_DIFF_EGRESS` | off | Truthy sends redacted captured diffs when the provider can use them. | | `ACD_AI_PROMPT_TRACE` | off | Writes local prompt diagnostics under `/acd/prompt-trace/`. | | `ACD_COMMIT_STRATEGY` | `event` | Set `intent` to ask the planner to group captures. | -| `ACD_COMMIT_FORMAT` | `imperative` | `imperative` keeps the current subject rules; `conventional` opts into scope-less Conventional Commit subjects. | +| `ACD_COMMIT_FORMAT` | `imperative` | `imperative` uses verb-led subjects; `conventional` uses scope-less Conventional Commit subjects. | | `ACD_INTENT_WINDOW` | `10` | Max captures offered to one planner pass. | | `ACD_INTENT_MIN_PENDING` | `10` | Preferred pending count before planning. | | `ACD_INTENT_SETTLE_WINDOW` | `10s` | Burst settle delay after the count gate. `0` disables it. | | `ACD_INTENT_MAX_PENDING_AGE` | `5m` | Age trigger for sparse queues. | | `ACD_INTENT_RECENT_COMMITS` | `5` | Recent commits sent as compact context. | | `ACD_INTENT_DEFER_LIMIT` | `1` | Deferrals before forced one-capture planning. | -| `ACD_INTENT_PATH_COALESCE` | off | Truthy restores legacy folding of consecutive same-path captures into one planner offer. | +| `ACD_INTENT_PATH_COALESCE` | off | Truthy folds consecutive same-path captures into one planner offer. | | `ACD_INTENT_RETRY_ON_INVALID` | `2` | Max correction retries after typed planner validation errors. `0` or false-like values disable retries. | | `ACD_INTENT_REJECTS_RAW` | off | Truthy stores raw rejected planner responses. Sensitive. | | `ACD_PATH_QUIESCENCE_SECONDS` | `0` | Waits for paths to go quiet before planner offer. Capture still persists. | | `ACD_RECENT_COMMIT_AFFINITY_SECONDS` | `0` | Adds a recent-HEAD hint when enabled. Off avoids extra `git log` work. | -Restart the daemon after changing provider, format, or intent environment. +Provider, format, and intent environment settings are read when the daemon +starts. `ACD_COMMIT_FORMAT=conventional` accepts only `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, `perf`, `style`, and `revert` @@ -218,8 +217,8 @@ For windows that contain several independent intents, return ordered } ~~~ -The top-level `selected_seqs`, `subject`, `body`, and `grouping_reason` remain -required for legacy compatibility. When `commit_groups` is present, +The top-level `selected_seqs`, `subject`, `body`, and `grouping_reason` are +required. When `commit_groups` is present, `selected_seqs` must be the union of all group selections; the top-level message can mirror the first group or summarize the selected window. @@ -237,7 +236,7 @@ Rules: | `deferred_reasons` may mention only deferred seqs | Reasons stay aligned with the plan. | | `subject` must match `commit_format` | Wrong-format output gets rejected, corrected, or falls back deterministically. | | Non-empty `error` is a soft error | ACD keeps the plugin alive and falls back for that request. | -| Timeout, EOF, crash, or I/O error is a hard error | ACD kills the plugin and respawns it on the next request. | +| Timeout, EOF, crash, or I/O error is a hard error | ACD kills the plugin. Event mode restarts it on the next request; intent mode waits until the circuit allows a provider probe. | Minimal smoke test: @@ -265,9 +264,48 @@ Expected output is one JSON line with a non-empty `subject` and an empty | Provider unset | Deterministic provider. | | `openai-compat` succeeds | Provider result is used. | | Provider returns the wrong message format | ACD rejects the response, retries when configured, then falls back deterministically. | -| `openai-compat` fails, times out, or has no key | Deterministic fallback. | +| Intent planner transport failure | Open the persisted circuit immediately and use deterministic fallback. | +| Three consecutive intent validation failures | Open the persisted circuit after configured correction retries are exhausted. | +| Intent circuit open | Skip the remote planner and use deterministic fallback without repeated planner-error decisions. | +| Intent circuit half-open | Allow one provider probe; other windows use deterministic fallback. | +| `openai-compat` has no key | Deterministic provider. | | Subprocess response has `error` | Deterministic fallback, plugin stays alive. | -| Subprocess crashes or times out | Deterministic fallback, plugin restarts next time. | +| Subprocess crashes or times out | Deterministic fallback; the plugin restarts on the next allowed provider probe. | The deterministic provider is the final backstop and should always return a message. + +### Inspect the intent planner circuit + +Use either read-only command: + +~~~bash +acd status --repo . --json +acd diagnose --repo . --json +~~~ + +Both expose `intent_strategy.planner_health`. The useful fields are: + +| Field | Meaning | +|---|---| +| `state` | `closed`, `open`, or `half_open`. | +| `consecutive_failures` | Failures counted toward the current open state. | +| `backoff_level` | Cooldown step: 30 seconds, 2 minutes, then 10 minutes. | +| `next_probe_ts` | Unix timestamp when one half-open probe may run. | +| `opened_ts` | Unix timestamp when the current circuit-open period began. | +| `last_failure_ts` | Unix timestamp of the most recent counted failure. | +| `last_failure_class` | `transport` or `validation`. | +| `last_error` | Bounded, redacted diagnostic text. | +| `bypass_count` | Cumulative windows served by fallback while the circuit denied a provider call. | +| `provider_fingerprint` | Hash of provider, model, sanitized endpoint, and provider mode. | +| `updated_ts` | Unix timestamp of the latest persisted health update. | + +If the persisted record is empty, malformed, unsafe to expose, or uses an +unsupported version, `status` and `diagnose` omit `planner_health` and return +`planner_health_warning`. The read path never repairs or deletes the meta row. + +The circuit record persists across daemon restarts. A successful half-open +probe closes it. A failed probe advances cooldown from 30 seconds to 2 minutes, +then caps at 10 minutes. API keys are never part of the provider fingerprint; +endpoint credentials, query strings, authorization values, and common token +shapes are removed from stored errors. diff --git a/docs/capture-replay.md b/docs/capture-replay.md index 0761aaf4..207ff56d 100644 --- a/docs/capture-replay.md +++ b/docs/capture-replay.md @@ -24,9 +24,13 @@ stateDiagram-v2 pending --> published: commit written or HEAD already matches pending --> blocked_conflict: replay not safe pending --> failed: replay data or git operation failed + blocked_conflict --> published: complete chain proven at HEAD + failed --> published: complete chain proven at HEAD + blocked_conflict --> recovered: complete chain archived + failed --> recovered: complete chain archived + pending --> recovered: branch transition archives chain published --> [*] - blocked_conflict --> [*] - failed --> [*] + recovered --> [*] ~~~ | State | Meaning | @@ -35,9 +39,12 @@ stateDiagram-v2 | `published` | ACD wrote a commit or proved the current `HEAD` already has the captured after-state. | | `blocked_conflict` | ACD cannot safely apply the event. Later pending rows for the same branch generation wait behind it. | | `failed` | Replay could not build or apply the event. It is also a terminal barrier when later pending rows exist. | +| `recovered` | ACD preserved the complete unpublished chain as a hidden Git commit before taking it out of replay. | -ACD never retries terminal rows automatically. Use `acd fix --dry-run` before -mutating state. +ACD reconciles terminal rows automatically. It operates on the complete exact +branch-generation suffix, not one blocker in isolation. If it cannot prove or +archive that chain without a race, missing object, or ref collision, it leaves +the queue unchanged for inspection. ## Product decisions @@ -53,6 +60,8 @@ daemon logs. | `handled_external` | Another commit already contains the captured after-state. | | `handled_external_after_block` | A blocked row was promoted after an external commit landed the captured change. | | `superseded_external` | External history made the queued work obsolete. | +| `recovery_published` | The complete unpublished chain was proven at stable `HEAD`. | +| `recovery_archived` | The complete unpublished chain was preserved at a hidden recovery ref. | | `blocked` | Replay stopped because the next event was not provably safe. | | `paused` / `resumed` | Capture or replay pause state changed. | @@ -101,9 +110,9 @@ work from replaying on top of the wrong branch history. | HEAD movement | Classification | Effect | |---|---|---| | New HEAD descends from the previous HEAD on the same branch | Fast-forward | Generation stays the same. | -| Rebase, reset, branch switch, or same-SHA ref switch | Diverged | Generation bumps. Stale pending rows from the old generation are dropped. | +| Rebase, reset, branch switch, or same-SHA ref switch | Diverged | ACD first proves or archives each exact unpublished pair, then accepts the new generation and reseeds shadow state. | | Detached HEAD | Paused/refused | `acd start` refuses and capture/replay stay disabled until HEAD is attached. | -| Deleted old branch ref | Dead branch | Startup cleanup can prune stale unpublished rows for that ref. | +| Deleted old branch ref | Dead branch | Startup cleanup archives the complete unpublished pair before it can become eligible for retention pruning. | Same-branch rewinds set a short grace pause: @@ -155,10 +164,24 @@ Plain `acd wake` nudges capture and replay. It does not bypass intent batch gates. Planner-window records are stored separately from raw prompt traces. They show -which seqs were offered, selected, deferred, forced, or hidden by legacy +which seqs were offered, selected, deferred, forced, or hidden by optional same-path coalescing. Prompt traces remain the opt-in source for exact provider requests and may contain source text. +### Planner circuit breaker + +| Condition | Circuit action | Replay action | +|---|---|---| +| Transport, timeout, HTTP, or subprocess failure | Open immediately. | Record the first failure, use deterministic planning. | +| Invalid or unsafe plan | Open after three consecutive failures. | Reject the plan, use deterministic planning. | +| Circuit open | Wait 30 seconds, then 2 minutes, then 10 minutes after repeated probe failures. | Bypass the provider without adding repeated planner-error rows. | +| Cooldown expired | Allow one half-open provider probe. | Other windows keep using deterministic planning. | +| Validated probe succeeds | Close and reset the backoff. | Resume configured provider planning. | + +Circuit health survives daemon restarts in `daemon_meta`. Bare `acd` reports +degraded fallback health; `acd status` and `acd diagnose` show the full circuit +record. A restart is not required to recover from a provider outage. + ## One-shot capture with `commit-all` `acd commit-all` is for a dirty worktree when the daemon was off. @@ -169,9 +192,13 @@ acd commit-all --yes acd commit-all --yes --json ~~~ -It reseeds shadow state from `HEAD`, drops stale pending rows for the active -branch generation, captures the live diff, sorts paths lexicographically, and -replays with the configured strategy. +It first proves or preserves every pre-existing exact unpublished pair, then +reseeds shadow state from `HEAD`, captures the live diff, sorts paths +lexicographically, and replays with the configured strategy. + +Dry-run and a declined confirmation do not open writable state, start the AI +provider, capture files, or create recovery refs. If unpublished rows remain at +the end, the command exits non-zero and leaves them protected. Refusals: @@ -180,7 +207,7 @@ Refusals: | Detached HEAD | Check out a branch. | | Rebase, merge, cherry-pick, or bisect in progress | Finish the Git operation. | | Manual pause marker | `acd resume --yes` | -| Per-repo daemon is running | `acd stop` first. | +| The daemon is running when the command is ready to write | `acd off` first. Dry-run, a declined prompt, and a clean no-op do not acquire `daemon.lock`. | | No initial commit | Create the first commit yourself. | ## Blocked conflicts @@ -206,32 +233,45 @@ acd events acd explain --path path/from/status acd diagnose --json acd fix --dry-run +acd off acd fix --yes -acd fix --force --dry-run -acd fix --force --yes +acd on +acd ~~~ -Safe apply handles verifiable cleanup. Force apply is only for terminal barriers -with pending successors after you check the blocked changes. +Safe apply runs the same exact-chain proof used by the daemon and archives the +chain when stable `HEAD` does not match. If you specifically want to save the +chain without trying the publish proof, preview and apply +`acd fix --force`. Both paths reconstruct the full chain at a hidden recovery +ref before marking rows recovered. Neither path purges, retargets, or discards +captures. ## Operator commands +See [commands.md](commands.md) for the full command reference. These are the +commands most useful while reading the capture and replay internals: + | Task | Command | |---|---| +| Quick health and next action | `acd` | +| Enable and start this repo | `acd on` | +| Disable and stop while preserving state | `acd off` | | Current repo health | `acd status` | | Live status refresh | `acd status --watch` | | Decision ledger | `acd events` | | Stream new decisions | `acd events --watch` | | Explain one path | `acd explain --path FILE` | | Explain one commit | `acd explain --commit HEAD` | -| All registered repos | `acd list` | -| Wide repo table | `acd list --verbose` | +| Enabled repo dashboard | `acd list` | +| One wide repo snapshot | `acd list --once --verbose` | | Machine-readable repo table | `acd list --json` | | Interactive repo lifecycle manager | `acd list --interactive` | | Raw daemon log | `acd logs --follow` | | Recovery report | `acd diagnose --json` | | Safe recovery plan | `acd fix --dry-run` | | Apply safe recovery plan | `acd fix --yes` | +| Preview archive-only recovery | `acd fix --force --dry-run` | +| Apply archive-only recovery | `acd fix --force --yes` | | Support zip | `acd doctor --bundle` | `acd list` compact status tokens: @@ -240,16 +280,16 @@ with pending successors after you check the blocked changes. |---|---| | `OK` | Running with no queued or blocked work. | | `wait` | Queued work remains. In intent mode this may be a normal batch wait. | -| `blk` | Terminal barrier needs operator action. | +| `blk` | A barrier remains after automatic reconciliation and needs inspection. | | `pause` | Manual pause or rewind grace is active. | | `miss` | Repo or state DB is missing. | | `bad` | State DB exists but cannot be read. | Disabled repos are hidden from normal `acd list` snapshots. A disabled registry row makes hook-driven `start`, `wake`, `touch`, and `flush` skip with -`repo_disabled` before capture or replay state is opened. Re-enable with -`acd repo enable --repo `, or use `acd repo manage` / -`acd list --interactive` to toggle from the manager. +`repo_disabled` before capture or replay state is opened. Run `acd on` for +normal use, or use `acd repo manage` / `acd list --interactive` for registry +administration. ## Safe-ignore and sensitive paths @@ -267,7 +307,7 @@ acd doctor |---|---| | `ACD_SAFE_IGNORE=0` | Disable generated-tree pruning. | | `ACD_SAFE_IGNORE_EXTRA=dist/,build/` | Add generated trees. | -| `ACD_SENSITIVE_GLOBS=...` | Replace or extend sensitive path handling. Empty keeps defaults. | +| `ACD_SENSITIVE_GLOBS=...` | Replace the protected path globs. Unset or empty uses the defaults. | Restart the daemon after changing these values. @@ -301,7 +341,7 @@ acd wake --repo . --session-id "$ACD_SESSION_ID" | `git revert` | Fast-forward commit. Queued matching work may settle as already handled at `HEAD`. | | `git reset --soft` or `--mixed` | Same-branch rewind. Capture and replay pause for rewind grace. | | `git reset --hard` | Same rewind behavior, with worktree overwritten by Git. | -| `git rebase -i` | Git operation marker pauses capture and replay. After rebase, generation bumps and stale pending rows are dropped. | +| `git rebase -i` | Git operation marker pauses capture and replay. After rebase, the generation bumps and each exact unpublished pair is proved or archived before capture resumes. | ## Trace event classes @@ -321,6 +361,7 @@ Trace files live under `/acd/trace/` and avoid full prompts and diffs. | `capture.pause` | Capture skipped because replay is paused. | | `replay.commit` | A queued event published or settled at `HEAD`. | | `replay.self_heal` | A blocked row was promoted because `HEAD` now matches the after-state. | +| `replay.chain_reconcile` | A complete unpublished chain was proven at `HEAD` or preserved under `refs/acd/recovery/*`. | | `replay.conflict` | Replay produced `blocked_conflict`. | | `replay.failed` | Replay produced `failed`. | | `replay.update_ref` | A `git update-ref` attempt ran. | diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 00000000..6a434037 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,262 @@ +# Command reference + +Start with `acd`. It reads the current repository, tells you whether ACD is +healthy, and gives you one next action. Most people only need `acd`, `acd on`, +and `acd off` during normal work. + +Commands use the Git worktree containing your current directory. Pass +`--repo /path/to/repo` when you want to target another worktree. Add `--json` +to commands that support structured output, and use +`acd --help` for every flag and example. + +## Daily control + +| Command | Use it for | What it changes | +|---|---|---| +| `acd` | Check one repo and get a recommended next action. | Nothing. It reads health only. | +| `acd on` | Register or enable the repo and make sure its daemon is running. | Desired state, registration, client heartbeat, and daemon process as needed. | +| `acd off` | Stop ACD for this repo until you turn it on again. | Desired state and daemon process. Captured state stays in `.git/acd`. | +| `acd status` | Inspect the daemon, clients, queue, branch, pauses, and recent decisions. | Nothing. | +| `acd list` | Watch enabled repos from one terminal. | Nothing, unless you open the interactive manager. | + +The short loop is: + +~~~bash +acd +acd on +acd off +~~~ + +Bare `acd` reports one of these states: + +| State | Meaning | +|---|---| +| `healthy` | The daemon is running and no action is needed. | +| `waiting` | Intent mode is waiting for its configured batch boundary. | +| `degraded` | ACD is still running, usually with deterministic planner fallback. | +| `needs_attention` | The daemon, pause state, or replay queue needs inspection. | +| `off` | This repo is disabled. | +| `not_a_repo` | The selected path is not inside a Git worktree. | + +Bare `acd` returns its classification as information, including unhealthy +states. `acd on` returns a failure when it cannot leave the repo running in a +healthy state. + +`acd status --watch` refreshes one repo. `acd list` behaves differently based +on its output: + +| Command | Behavior | +|---|---| +| `acd list` in a terminal | Opens the compact live dashboard. | +| `acd list` in a pipe | Prints one compact snapshot. | +| `acd list --once` | Prints one snapshot even in a terminal. | +| `acd list --once --verbose` | Adds clients, last commit, full paths, and status notes. | +| `acd list --json` | Prints one machine-readable snapshot. | +| `acd list --interactive` | Opens the repo lifecycle manager. | + +Watch mode and `--json` cannot be combined. + +## Set up an AI tool + +`acd setup [harness]` prints the hook snippet for one supported integration. It +does not edit the target configuration file. + +~~~bash +acd setup claude-code +acd setup codex +acd setup cursor +acd setup opencode +acd setup pi +acd setup shell +~~~ + +Use `--raw` when you need only the snippet body: + +~~~bash +acd setup codex --raw +~~~ + +Redirecting raw output with `>` replaces the destination file. Merge the +snippet by hand when that file already contains custom hooks or settings. Run +`acd doctor` after setup to check the installed hook against the current +template. + +The adapter guides under [`templates/`](../templates/) contain the exact +configuration path, hook mapping, verification steps, and uninstall steps for +each harness. + +## See what ACD is doing + +| Command | What it answers | Notes | +|---|---|---| +| `acd status` | Is this repo running, waiting, paused, blocked, or degraded? | Use `--watch` for a live view or `--json` for automation. | +| `acd events` | What did ACD capture, group, publish, skip, or block? | `--watch` starts at the current activity tail unless `--since` is set. | +| `acd explain` | Why did one path or commit behave this way? | Use `--path FILE`, `--commit REV`, or `--last`. | +| `acd diagnose` | Which branch anchor, pause, queue barrier, or planner state needs attention? | Read-only. Start here before recovery. | +| `acd logs` | What did the daemon write to its raw JSONL log? | Use `--lines N` and `--follow`. | +| `acd prompt` | What did a traced AI request contain? | Traces exist only when `ACD_AI_PROMPT_TRACE=1` was set at daemon start. | +| `acd doctor` | Are the binary, hooks, provider settings, registry, and runtime healthy? | `--bundle` writes a sanitized zip for support. | + +Useful examples: + +~~~bash +acd events --watch +acd explain --path internal/state/schema.go +acd explain --commit HEAD +acd diagnose --json +acd logs --lines 100 --follow +acd prompt --last +acd doctor --bundle --output /tmp +~~~ + +`acd events` is the activity history intended for normal use. `acd logs` is +the lower-level daemon log. Prompt traces may contain source text, so review +them before sharing. + +## Recover a repo + +ACD normally resolves queued work by itself. If the branch already contains +every captured change, ACD marks that work published. Otherwise it saves the +captured chain under `refs/acd/recovery/*`, reseeds from the current branch, +and captures any work that is still present in the worktree. + +Use this manual sequence when `acd` says the repo needs attention: + +~~~bash +acd +acd diagnose --json +acd fix --dry-run +acd off +acd fix --yes +acd on +acd +~~~ + +`acd fix --dry-run` only prints the plan. `acd fix --yes` backs up the SQLite +state, reruns its safety checks, and applies safe recovery while the daemon is +off. It never changes the live worktree, index, or branch. + +Use the force mode when you want ACD to save the captured chain without first +trying to prove that the branch already contains it: + +~~~bash +acd fix --force --dry-run +acd off +acd fix --force --yes +acd on +~~~ + +Here, `--force` selects archive-only recovery. It does not authorize the write +by itself, and it does not delete captured work. `--yes` is still required. + +| Command | Purpose | +|---|---| +| `acd pause --reason "branch surgery"` | Stop capture and replay while keeping the daemon registered. | +| `acd pause --ttl 1h --reason "maintenance"` | Add a pause that expires automatically. | +| `acd resume --yes` | Remove a manual pause marker. | +| `acd resume --accept-overflow` | Clear durable capture backpressure and accept that filesystem events may have been missed. | + +Use `--accept-overflow` only after reading `acd diagnose`. It is separate from +`--yes` because it accepts a different risk. + +See [user-workflows.md](user-workflows.md) for archived-work inspection, +generated-file cleanup, branch surgery, and symptom-based recovery. + +## Commit work made while ACD was off + +`acd commit-all` captures a dirty worktree and replays it with the configured +commit strategy. It first resolves any older unpublished chain, then reseeds +from `HEAD` before capturing the live files. + +~~~bash +acd off +acd commit-all --dry-run +acd commit-all --yes +acd on +~~~ + +The command refuses detached HEAD, an in-progress Git operation, a manual +pause, and a live daemon when it reaches the write phase. `--json` requires +`--yes` for an apply run. If any captured work remains unpublished, the command +returns a failure instead of reporting a partial drain as success. + +## Rewrite local commit messages + +`acd rewrite-commits` creates a reviewable message-only plan for a linear range +on the current branch. The daemon never starts a rewrite on its own. + +~~~bash +acd rewrite-commits --from-nr 5 --plan-out rewrite.json --plan-only +acd rewrite-commits --show-plan rewrite.json + +acd off +acd rewrite-commits --apply-plan rewrite.json --dry-run +acd rewrite-commits --apply-plan rewrite.json --yes +acd on +~~~ + +Use `--from-sha`, `--from-nr`, `--range-sha`, `--range-nr`, or `--last` to +select commits. Creating a new plan requires intent mode and a working +non-deterministic provider. Showing, editing, or applying a saved plan does not +call the provider. + +Apply verifies the branch, creates backup refs, recreates commits with the new +messages, and moves the branch only after the checks pass. File contents stay +the same. Merge commits, detached HEAD, a live daemon, pending captures, and a +branch that no longer matches the plan are refused. + +For a fresh selection, `--json` prints the resolved selection and exits before +plan generation. Use normal text output when you want to generate a plan. + +See [rewrite-commits.md](rewrite-commits.md) for selectors, plan editing, +progress output, and backup recovery. + +## Manage registered repos + +Most repos need no manual registration because harness hooks call `acd start`. +Use `acd on` and `acd off` for normal control. The `acd repo` commands are for +explicit registry administration. + +| Command | What it does | +|---|---| +| `acd repo init` | Creates or opens `.git/acd/state.db` and registers an attached worktree without starting a daemon. | +| `acd repo disable` | Disables a registered repo, stops its daemon, clears start caches, and preserves state. | +| `acd repo enable` | Enables a registered repo without starting its daemon. | +| `acd repo list` | Lists every registry row, including disabled, missing, and state-database-missing repos. | +| `acd repo manage` | Opens the interactive lifecycle manager. | +| `acd repo remove --dry-run` | Previews registration removal. | +| `acd repo remove --yes` | Removes the registry row and start caches, but keeps `.git/acd`. | +| `acd repo remove --yes --purge-state` | Removes the registry row and deletes `.git/acd` state. | + +`acd repo disable` and `acd repo enable` require an existing registry row. In +contrast, `acd off` records the disabled state even for a repo ACD has not seen +before, and `acd on` can register and start it in one command. + +## Maintenance and build information + +| Command | What it does | +|---|---| +| `acd stats` | Reports central commit, event, file, byte, and error totals for the last seven days. Use `--since 30d` to change the window. | +| `acd gc` | Merges duplicate registry rows and removes missing or long-dead entries. It does not delete repo state databases or captured events. | +| `acd version` | Prints the version and commit embedded in the binary. | + +`acd stats` may initialize the central statistics database. `acd gc` changes +the central registry, so run `acd list` or `acd repo list` first when you want +to inspect what is registered. + +## Hook protocol + +Harness templates call these commands automatically. Use `acd setup` instead +of wiring them by hand unless you are building an adapter. + +| Command | Hook contract | +|---|---| +| `acd start` | Register a client session and start or refresh the repo daemon. Harnesses pass `--session-id`, `--harness`, and sometimes `--watch-pid`. | +| `acd stop` | Stop one repo, deregister one session, or stop every daemon with `--all`. `--force` bypasses session refcounts. | +| `acd wake` | Refresh a session heartbeat and nudge the daemon. It requires `--session-id` and does not bypass intent batch waits. | +| `acd touch` | Refresh a session heartbeat without signaling the daemon. Codex uses this for its turn-level `Stop` event. | +| `acd flush` | Refresh a heartbeat. With `--logical`, ask the next replay pass to drain the visible intent window now. | + +Logical flush requires an already registered session. It bypasses only the +intent batch wait. Detached HEAD, Git operations, manual pauses, validation, +and replay safety still apply. diff --git a/docs/intent-commit-flow.md b/docs/intent-commit-flow.md index 8c839652..f865f8ea 100644 --- a/docs/intent-commit-flow.md +++ b/docs/intent-commit-flow.md @@ -91,10 +91,9 @@ flowchart TB By default, consecutive captures that touch the same path remain separate planner-visible seqs. This lets the planner split independent edits inside one -file when replay can apply them safely. `ACD_INTENT_PATH_COALESCE=1` restores -the legacy behavior that folds consecutive same-path captures into one planner -offer; `hidden_seqs` then records the original seqs covered by that folded -offer. +file when replay can apply them safely. Set `ACD_INTENT_PATH_COALESCE=1` to fold +consecutive same-path captures into one planner offer. `hidden_seqs` records +the original seqs covered by that folded offer. The planner must put every offered seq in either `selected_seqs` or `deferred_seqs`. It may select one seq or a larger related subset. Deferred @@ -102,8 +101,7 @@ seqs need reasons. For a mixed window, the planner may return `commit_groups`. Groups publish in the returned order, each with its own selected seqs, message, and grouping -reason. Top-level `selected_seqs` remains the union of all group selections for -legacy compatibility. +reason. Top-level `selected_seqs` is the union of all group selections. The built-in planner prompt asks for `commit_groups` when one visible window contains multiple independent commit intents. It also requires chronological @@ -112,7 +110,8 @@ same-path capture before an earlier one. ## Message rules -`ACD_COMMIT_FORMAT=imperative` is the default and keeps the existing rules. +`ACD_COMMIT_FORMAT=imperative` is the default. Subjects start with an +imperative verb and stay within 50 characters. Small single-file commits can use only a subject: ~~~text @@ -170,12 +169,41 @@ one-capture planning window unless an earlier related-path capture must land first. If the provider fails there, deterministic fallback publishes the capture safely. +One-capture windows use the same planner and circuit as larger windows. There +is no production shortcut to a separate commit-message provider. + +## Recovering from planner failures + +ACD keeps replay moving when a remote planner is unhealthy. The first +transport failure opens a persisted circuit immediately. A planning window +counts as one validation failure only after configured correction retries are +exhausted. Three consecutive validation failures open the circuit. + +| Open attempt | Cooldown before one probe | +|---:|---:| +| 1 | 30 seconds | +| 2 | 2 minutes | +| 3 and later | 10 minutes | + +While the circuit is open, each planning window publishes through the +deterministic one-capture fallback. These bypasses do not add repeated +`intent_planner_error` decisions. When the cooldown expires, one window owns +the half-open provider probe; other windows continue through deterministic +fallback. A successful probe closes the circuit and resets the failure count. + +Circuit state survives daemon restarts in `daemon_meta` under +`intent.planner.health`. Changing the provider identity resets state from the +old provider. The stored identity is a hash of non-secret provider settings, +not an API key or raw endpoint. Stored errors are bounded and redacted before +they reach status or diagnose output. + ## Observability | Question | Command | |---|---| | What strategy and batch gate are active? | `acd status --json` | | Why is intent waiting? | `acd diagnose --json` or `acd doctor` | +| Is the planner circuit open? | `acd status --json` or `acd diagnose --json` | | Which seqs were grouped or deferred? | `acd events --json` | | What did the provider see? | `ACD_AI_PROMPT_TRACE=1` then `acd prompt --seq ` | | Where are rejected planner responses? | `/acd/planner-rejects.jsonl` | @@ -183,7 +211,17 @@ capture safely. `intent_strategy` reports `commit_format`, window settings, batch wait state, settle countdowns, deferred counts, forced-aging readiness, planner error rate, singleton commit rate, message-quality rewrite or fallback counts, and the most -recent privacy-safe `last_planner_window` summary. +recent privacy-safe `last_planner_window` summary. Its `planner_health` object +reports `state`, `consecutive_failures`, `backoff_level`, `next_probe_ts`, +`last_failure_class`, `last_error`, and cumulative `bypass_count`. +If the persisted record is invalid or uses an unsupported version, the commands +omit `planner_health` and return a safe `planner_health_warning` without +changing `state.db`. + +When the circuit is open or half-open, `acd diagnose` explains that +deterministic fallback remains active and shows the next probe time. Check +provider connectivity and configuration for transport failures. For validation +failures, inspect `/acd/planner-rejects.jsonl`. `acd events --json` adds `planner_window` to decisions with a known planner window. The summary shows `offered_seqs`, `visible_original_seqs`, diff --git a/docs/multi-tool.md b/docs/multi-tool.md index d2538eab..975d2471 100644 --- a/docs/multi-tool.md +++ b/docs/multi-tool.md @@ -76,13 +76,16 @@ acd events --watch acd explain --path path/to/file acd diagnose --repo . acd fix --dry-run +acd off acd fix --yes -acd fix --force --dry-run -acd fix --force --yes +acd on +acd ~~~ -Use force only after checking the blocked changes are already represented in -`HEAD` or should be discarded. +Normal `acd fix --yes` proves a chain at stable `HEAD` when possible and archives +it under `refs/acd/recovery/*` otherwise. Use `--force` only when you explicitly +want archive-only recovery without attempting the publish proof. Neither path +discards captured changes. ## Recommended configurations diff --git a/docs/overview.md b/docs/overview.md index 97597f03..61a4f738 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -39,19 +39,24 @@ flowchart LR |---|---|---| | Capture | Walks the worktree, filters ignored or sensitive paths, writes file contents as Git blobs, and records ops in `/acd/state.db`. | `acd events`, `acd explain --path FILE` | | Replay | Applies pending ops to a private scratch index, creates commits with `git commit-tree`, and advances the branch with `git update-ref`. | `acd status`, `acd logs --follow` | -| Settle | Marks queued work as published when another committer already landed the same final state. | `acd explain --commit HEAD` | -| Block | Stops behind `blocked_conflict` or `failed` terminal rows when replay cannot prove the next commit is safe. | `acd diagnose --json`, `acd fix --dry-run` | +| Reconcile | Proves a complete unpublished chain already landed, or preserves its reconstructed tree at a hidden recovery ref before reseeding and recapturing. | `acd events`, `acd diagnose` | +| Block | Stops behind `blocked_conflict` or `failed` only when ACD cannot complete an all-or-none proof or archive safely. | `acd diagnose --json`, `acd fix --dry-run` | ## Commit strategies | Strategy | Behavior | |---|---| | `event` | FIFO replay. One capture can produce one commit. This is the default. | -| `intent` | A bounded window of pending captures goes to the planner. Selected captures replay as one commit; deferred captures stay pending. | +| `intent` | A bounded window goes to the planner. It can split selected captures into ordered groups, and replay publishes one commit per group. Deferred captures stay pending. | Both strategies use the same capture rows and replay safety checks. Intent mode changes grouping, not durability. +Intent mode also has a persisted provider circuit breaker. A planner outage or +repeated unsafe output switches planning to the deterministic provider during a +bounded cooldown, so capture and replay keep moving without repeated network +failures. + ## Data boundaries | Data | Stored in | Leaves the machine by default? | @@ -67,6 +72,7 @@ changes grouping, not durability. | Task | Doc | |---|---| | Install and set up hooks | [README](../README.md) | +| Find the right command | [commands.md](commands.md) | | Recover from a stuck queue | [user-workflows.md](user-workflows.md) | | Understand replay safety | [capture-replay.md](capture-replay.md) | | Configure AI providers | [ai-providers.md](ai-providers.md) | diff --git a/docs/rewrite-commits.md b/docs/rewrite-commits.md index 1a737283..151d2b52 100644 --- a/docs/rewrite-commits.md +++ b/docs/rewrite-commits.md @@ -46,7 +46,7 @@ export ACD_AI_API_KEY=... fine for showing or applying an existing saved plan. Rewrite plans use the active `ACD_COMMIT_FORMAT` when they are generated. The -default is `imperative`, so existing rewrite behavior stays unchanged. Set +default is `imperative`. Set `ACD_COMMIT_FORMAT=conventional` to request scope-less Conventional Commit subjects such as `fix: preserve rewrite plan format`. Accepted conventional types are `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, @@ -62,10 +62,6 @@ bullets keep the same `- ` prefix and wrapping rules in both modes. | `--range-nr ` | Rewrite a 1-based contiguous range. Newer commits are recreated unchanged when needed. | | `--range-sha ..` | Rewrite a simple git range. Base is exclusive, head is inclusive. | | `--last ` | Rewrite the newest `n` commits. | -| `--from ` | Compatibility selector; prefer `--from-sha` or `--from-nr`. | -| `--range ` | Compatibility selector; prefer `--range-nr`. | -| `--git-range ..` | Advanced compatibility revset. | -| `--base --head ` | Deprecated alias for the explicit git range. | Examples: @@ -75,9 +71,11 @@ acd rewrite-commits --from-nr 5 --plan-only acd rewrite-commits --range-nr 5-12 --review --format text acd rewrite-commits --range-sha main~12..main~4 --format json acd rewrite-commits --last 4 --no-review --yes -acd rewrite-commits --git-range main~12..main~4 --format json ~~~ +The CLI also accepts compatibility selectors. Use `acd rewrite-commits --help` +when you need to open a plan created with one of those forms. + ## Progress output | Mode | Behavior | @@ -122,16 +120,16 @@ without accidentally applying malformed subjects. `--progress auto|plain|json|off` is accepted by generate, edit, and apply modes that run through the rewrite command. +For fresh plan generation, `--json` prints the resolved selection and exits +before calling the provider. Use normal text output to generate and save a new +plan. + ~~~text acd rewrite-commits --from-sha [--plan-out FILE] [--review|--no-review] [--format text|json] [--progress auto|plain|json|off] [--plan-only|--yes] acd rewrite-commits --from-nr [--plan-out FILE] [--review|--no-review] [--format text|json] [--progress auto|plain|json|off] [--plan-only|--yes] acd rewrite-commits --range-nr [--plan-out FILE] [--review|--no-review] [--format text|json] [--progress auto|plain|json|off] [--plan-only|--yes] acd rewrite-commits --range-sha .. [--plan-out FILE] [--review|--no-review] [--format text|json] [--progress auto|plain|json|off] [--plan-only|--yes] acd rewrite-commits --last [--plan-out FILE] [--review|--no-review] [--format text|json] [--plan-only|--yes] -acd rewrite-commits --git-range .. [--plan-out FILE] [--review|--no-review] [--format text|json] [--plan-only|--yes] -acd rewrite-commits --from [compatibility] -acd rewrite-commits --range [compatibility] -acd rewrite-commits --base [--head ] [--plan-out FILE] [--review|--no-review] [--format text|json] [--plan-only|--yes] acd rewrite-commits --show-plan FILE acd rewrite-commits --edit [--format text|json] [--plan-only|--yes|--dry-run] acd rewrite-commits --apply-plan FILE (--yes | --dry-run) diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 15016885..50cf3744 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -7,6 +7,9 @@ appear, or how to recover a stuck queue. | Need | Command | Reads or writes | |---|---|---| +| Health summary and one next action | `acd` | Read | +| Enable and ensure the daemon is running | `acd on` | Write desired state, start if needed | +| Disable and stop while preserving state | `acd off` | Write desired state, stop if needed | | Current repo state | `acd status` | Read | | Live state refresh | `acd status --watch` | Read | | Product decision ledger | `acd events` | Read | @@ -15,7 +18,7 @@ appear, or how to recover a stuck queue. | Explain one commit | `acd explain --commit HEAD` | Read | | Inspect latest AI prompt trace | `acd prompt --last` | Read | | Tail raw daemon log | `acd logs --follow` | Read | -| Create support bundle | `acd doctor --bundle` | Read | +| Create support bundle | `acd doctor --bundle` | Read diagnostics and write a sanitized zip | `acd prompt` only works after a non-deterministic provider ran with `ACD_AI_PROMPT_TRACE=1`. @@ -23,11 +26,18 @@ appear, or how to recover a stuck queue. `acd events --watch` starts at the current ledger tail unless you pass `--since `. +See [commands.md](commands.md) for the complete public command reference, +including repo administration, maintenance, and the hook protocol. + ## Repo registration Most repos are automatic. A harness hook calls `acd start`, which creates `/acd/state.db` and registers the canonical worktree root. +For normal manual control, use `acd on` and `acd off`. Both are idempotent and +preserve `.git/acd` state. The commands below are for registry administration +or bulk lifecycle work. + | Task | Command | |---|---| | Register without starting the daemon | `acd repo init` | @@ -85,35 +95,69 @@ seen, harnesses, and status details. ## Recovery ladder +ACD automatically reconciles a blocked or failed queue as one complete, +immutable branch-generation chain. It either proves that stable `HEAD` already +contains the final touched-path state or archives the reconstructed chain under +`refs/acd/recovery/*`, then reseeds and captures the live worktree again. + Run these in order. Stop when the queue is healthy again. | Step | Command | What to decide | |---|---|---| -| Observe | `acd status` | Is the daemon running, paused, waiting, or blocked? | +| Observe | `acd` | Is the repo healthy, waiting, degraded, off, or in need of attention? | | Inspect decisions | `acd events --watch` | What is ACD doing now? | | Inspect one path | `acd explain --path FILE` | Is the file captured, skipped, protected, or blocked? | | Inspect blockers | `acd diagnose --json` | Which branch anchor or terminal row is holding replay? | -| Preview cleanup | `acd fix --dry-run` | Does the plan match what happened? | -| Safe apply | `acd fix --yes` | Apply only verifiable cleanup. | -| Force preview | `acd fix --force --dry-run` | Use only when terminal barriers still hold pending successors. | -| Force apply | `acd fix --force --yes` | Apply only after checking the blocked changes. | -| Post-check | `acd status` | Confirm `blk`/`blocked` cleared. | - -`acd fix` backs up `state.db` before mutation and refuses a live daemon owner. +| Preview reconciliation | `acd fix --dry-run` | Does the exact-chain proof match what happened? | +| Prevent hook restart during repair | `acd off` | Stop the daemon and keep the repo disabled while state changes. | +| Apply reconciliation | `acd fix --yes` | Prove the chain at `HEAD`; otherwise preserve it at an archive ref. | +| Preview archive-only recovery | `acd fix --force --dry-run` | Show which complete chain and recovery ref would be used. | +| Apply archive-only recovery | `acd fix --force --yes` | Save the complete chain without trying the publish proof. | +| Start normal operation again | `acd on` | Enable the repo and ensure the daemon is healthy. | +| Post-check | `acd` | Confirm the repo no longer needs attention. | + +`acd fix` creates and verifies a SQLite-consistent backup before migration or +recovery mutation, and refuses a live daemon owner. It never retargets captures +to another branch generation, deletes terminal rows, or changes the live +worktree, index, or branch. If the only problem is a manual pause marker, use: ~~~bash acd resume --yes ~~~ +## Inspect or restore archived work + +Recovery decisions name the exact hidden ref. Find recent archive decisions and +list the refs without changing your worktree: + +~~~bash +acd events --json --limit 50 +git for-each-ref --format='%(refname) %(objectname:short)' refs/acd/recovery/ +~~~ + +Use the `/archive` ref from the `recovery_archived` decision: + +| Task | Command | +|---|---| +| Compare the archive with current `HEAD` | `git diff HEAD --` | +| Read one archived file | `git show :path/to/file` | +| Restore one path into the worktree | `git restore --source= -- path/to/file` | +| Keep the whole archive as a review branch | `git branch acd-recovery-review ` | + +The first two commands are read-only. The restore and branch commands are +explicit operator actions; ACD never applies an archive back onto the active +branch by itself. `/published` refs are proof that stable `HEAD` already held +the final captured state, so they normally need no restore. + ## Common symptoms | Symptom | First check | Likely answer | |---|---|---| | File was not committed | `acd explain --path FILE` | It may be pending, skipped, protected, or unseen. | | Queue says `wait` | `acd status` | Intent mode may be waiting for count or age. | -| Queue says `blk` | `acd diagnose --json` | A terminal barrier needs operator action. | -| Commit message is generic | `acd status --json` | Provider may be deterministic fallback. | +| Queue says `blk` | `acd diagnose --json` | ACD may be reconciling a complete chain, or proof needs inspection. | +| Commit message is generic | `acd status --json` | Planner circuit may be using deterministic fallback. | | Path under generated tree is ignored | `acd doctor` | Safe-ignore pruned it. | | Prompt trace is missing | `acd prompt --last` | Tracing was not enabled before the provider call. | | External tool already committed the file | `acd explain --commit HEAD` | Expect `handled_external` or `superseded_external`. | @@ -134,7 +178,7 @@ acd events --path path/to/file | `committed` | ACD already published it. | Check `git log -- path/to/file`. | | `protected` or `skipped` | ACD intentionally left it alone. | Check safe-ignore, sensitive globs, `.gitignore`, or path location. | | No decision | ACD has not seen it. | Check daemon liveness and ignore settings. | -| `blocked` | Replay stopped first. | Run `acd diagnose --json`, then `acd fix --dry-run`. | +| `blocked` | Replay stopped first. | Let automatic chain reconciliation run, then use `acd diagnose --json` if it remains. | If the daemon is alive and the path still has no decision: @@ -157,6 +201,8 @@ acd explain --commit HEAD | `handled_external` | Current `HEAD` already has the captured after-state. | | `handled_external_after_block` | A blocked row self-healed after an external commit landed the captured state. | | `superseded_external` | External history made the queued event obsolete. | +| `recovery_published` | ACD proved the complete unpublished chain at stable `HEAD`. | +| `recovery_archived` | ACD preserved the complete chain under a hidden recovery ref. | No action is needed when `explain` says `HEAD` contains the change. If the queue stays blocked, go through the recovery ladder. @@ -190,6 +236,13 @@ acd flush --repo . --session-id "$ACD_SESSION_ID" --logical Plain `acd wake` does not bypass intent batch gates. +Planner failure does not block intent replay. Transport failures open a +persisted circuit immediately; three consecutive invalid or unsafe plans also +open it. During the 30-second, 2-minute, and 10-minute cooldowns ACD uses the +deterministic planner, then sends one automatic probe. Inspect +`intent_strategy.planner_health` in status or diagnose JSON for the circuit +state, failure class, bypass count, and next probe time. + To check whether work was captured, planner-visible, and committed as intended: | Question | Command | @@ -254,10 +307,10 @@ acd wake --repo . --session-id "$ACD_SESSION_ID" | `git reset --soft` or `--mixed` | Same-branch rewind. Capture and replay pause for rewind grace. | | `git reset --hard` | Same rewind behavior, with worktree overwritten by Git. | | `git rebase -i` | Git operation marker pauses capture and replay. Generation bumps after rebase. | -| Deleted merged branch | Startup cleanup can remove stale unpublished rows for that dead ref. | +| Deleted merged branch | Startup cleanup archives complete unpublished pairs before accepting cleanup. | -Set `ACD_KEEP_DEAD_BRANCH_BARRIERS=1` before daemon start if you need to inspect -deleted-branch rows before cleanup. +Set `ACD_KEEP_DEAD_BRANCH_BARRIERS=1` before daemon start if you need to keep +deleted-branch unpublished rows in their original queue state for inspection. ## Skipped generated or sensitive files @@ -273,7 +326,7 @@ acd doctor |---|---| | `ACD_SAFE_IGNORE=0` | Disable generated-tree pruning. | | `ACD_SAFE_IGNORE_EXTRA=dist/,build/` | Add generated-tree patterns. | -| `ACD_SENSITIVE_GLOBS=...` | Configure sensitive globs. Empty keeps defaults. | +| `ACD_SENSITIVE_GLOBS=...` | Replace the protected path globs. Unset or empty uses the defaults. | Restart the daemon after changing these settings. @@ -286,8 +339,9 @@ for example `.derivedData-provider-core/`. |---|---|---| | Inspect | `acd diagnose --json` | Shows `generated_pending` roots, queued delete count, and tracked count. | | Preview ACD cleanup | `acd fix --dry-run` | Shows `drop_generated_pending` actions. | -| Stop if needed | `acd stop` | Required when a live daemon owns the state DB. | +| Turn ACD off | `acd off` | Stops the daemon and prevents hooks from restarting it during cleanup. | | Clean ACD queue | `acd fix --yes` | Removes only protected generated pending rows from ACD state. | +| Turn ACD on | `acd on` | Restarts normal capture and replay after cleanup. | | Review Git cleanup | `git status -- .derivedData-provider-core` | Confirms which tracked generated files are deleted. | | Stage Git cleanup | `git add -u -- .derivedData-provider-core` | Stages the tracked generated file removals. | | Commit Git cleanup | `git commit -m "Remove tracked generated cache files"` | Records the repository cleanup. | @@ -299,8 +353,9 @@ stages or commits those removals for you. ## Blocked conflicts and failed barriers -`blocked_conflict` and `failed` are terminal. Later pending rows for the same -branch generation wait behind them. +`blocked_conflict` and `failed` stop ordinary FIFO replay. Later pending rows for +the same branch generation wait while ACD tries all-or-none chain +reconciliation. ~~~bash acd status @@ -313,41 +368,50 @@ acd fix --dry-run Apply only after reading the plan: ~~~bash +acd off acd fix --yes -acd status +acd on +acd ~~~ -Use the force path only when the plan says barriers still have pending -successors: +Use the force path when the chain cannot be proven at `HEAD` and you want an +explicit archive-only recovery: ~~~bash acd fix --force --dry-run +acd off acd fix --force --yes -acd status +acd on +acd ~~~ -## Cold start commit cleanup +## Commit work made while ACD was off Use `commit-all` when the daemon was off and the worktree is dirty: ~~~bash +acd off acd commit-all --dry-run acd commit-all --yes acd commit-all --yes --json acd commit-all --repo /path/to/repo --yes +acd on ~~~ What it does: | Step | Behavior | |---|---| +| Preserve | Proves or archives every pre-existing exact unpublished pair. | | Reseed | Rebuilds shadow state from `HEAD`. | -| Drop stale pending | Removes old pending rows for the active branch generation. | | Capture | Captures live worktree vs `HEAD`. | | Sort | Orders paths lexicographically. | | Replay | Uses the configured commit strategy. | `--json` requires `--yes` because there is no interactive prompt. +`--dry-run` and declined confirmation are read-only and do not start the AI +provider. If unpublished rows remain after replay, `commit-all` exits non-zero +with an incomplete result instead of reporting success. Refusals: @@ -356,7 +420,7 @@ Refusals: | Detached HEAD | Check out a branch. | | Git operation in progress | Finish the operation. | | Manual pause marker | `acd resume --yes` | -| Per-repo daemon is running | `acd stop` first. | +| The daemon is running when the command is ready to write | `acd off` first. Dry-run, a declined prompt, and a clean no-op do not acquire `daemon.lock`. | | No initial commit | Create an initial commit. | ## Support bundle @@ -395,6 +459,8 @@ fsnotify information, state/meta JSON, and daemon log tails. | `handled_external` | Another commit already contains the captured after-state. | | `handled_external_after_block` | A blocked row was promoted after `HEAD` matched its after-state. | | `superseded_external` | External history made queued work obsolete. | +| `recovery_published` | ACD proved that the branch already contains every captured change in the unpublished chain. | +| `recovery_archived` | ACD saved the unpublished chain under a hidden recovery ref before removing it from replay. | | `blocked` | Replay stopped because applying the event was not safe. | | `paused` / `resumed` | Manual pause, rewind grace, or Git operation state changed. | diff --git a/go.mod b/go.mod index d6cc7a8a..bb440882 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/KristjanPikhof/Auto-Commit-Daemon -go 1.26.4 +go 1.26.5 require ( github.com/fsnotify/fsnotify v1.10.1 diff --git a/internal/ai/error_sanitize.go b/internal/ai/error_sanitize.go new file mode 100644 index 00000000..37c5bd71 --- /dev/null +++ b/internal/ai/error_sanitize.go @@ -0,0 +1,111 @@ +package ai + +import ( + "encoding/json" + "net/url" + "regexp" + "strings" + "unicode" +) + +const plannerErrorCap = 512 + +var ( + plannerErrorURLPattern = regexp.MustCompile(`(?i)https?://[^\s]+`) + plannerErrorAuthPattern = regexp.MustCompile(`(?i)(["']?authorization["']?\s*:\s*["']?(?:bearer|basic)\s+)[^\s,;"'}]+`) + plannerErrorCredentialPattern = regexp.MustCompile(`(?i)(["']?(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password)["']?\s*(?:[=:]|\s)\s*["']?)[^\s,;"'}]+`) + plannerErrorBearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[^\s,;"'}]+`) + plannerErrorOpenAIKeyPattern = regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{8,}\b`) +) + +// SanitizePlannerError returns a bounded, single-line planner error suitable +// for durable metadata, traces, and operator output. It removes common secret +// forms without requiring callers to know which provider produced the error. +func SanitizePlannerError(raw string) string { + clean := redactPlannerJSONSuffix(raw) + clean = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, clean) + clean = plannerErrorURLPattern.ReplaceAllStringFunc(clean, sanitizePlannerErrorURL) + clean = plannerErrorAuthPattern.ReplaceAllString(clean, `${1}[REDACTED]`) + clean = plannerErrorCredentialPattern.ReplaceAllString(clean, `${1}[REDACTED]`) + clean = plannerErrorBearerPattern.ReplaceAllString(clean, `Bearer [REDACTED]`) + clean = plannerErrorOpenAIKeyPattern.ReplaceAllString(clean, `[REDACTED]`) + clean = strings.Join(strings.Fields(clean), " ") + runes := []rune(clean) + if len(runes) > plannerErrorCap { + clean = string(runes[:plannerErrorCap]) + } + return clean +} + +func redactPlannerJSONSuffix(raw string) string { + start := strings.IndexAny(raw, "{[") + if start < 0 { + return raw + } + suffix := strings.TrimSpace(raw[start:]) + var value any + if err := json.Unmarshal([]byte(suffix), &value); err != nil { + return raw + } + redactPlannerJSONValue(value) + encoded, err := json.Marshal(value) + if err != nil { + return raw + } + return raw[:start] + string(encoded) +} + +func redactPlannerJSONValue(value any) { + switch current := value.(type) { + case map[string]any: + for key, child := range current { + if plannerJSONCredentialKey(key) { + current[key] = "[REDACTED]" + continue + } + redactPlannerJSONValue(child) + } + case []any: + for _, child := range current { + redactPlannerJSONValue(child) + } + } +} + +func plannerJSONCredentialKey(key string) bool { + normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", " ", "").Replace(key)) + return normalized == "authorization" || + strings.HasSuffix(normalized, "apikey") || + strings.HasSuffix(normalized, "token") || + strings.HasSuffix(normalized, "secret") || + strings.HasSuffix(normalized, "password") +} + +func sanitizePlannerErrorURL(raw string) string { + urlText, suffix := splitPlannerURLPunctuation(raw) + u, err := url.Parse(urlText) + if err != nil { + return "[REDACTED_URL]" + suffix + } + u.User = nil + u.Path = "" + u.RawPath = "" + u.RawQuery = "" + u.ForceQuery = false + u.Fragment = "" + u.RawFragment = "" + return u.String() + suffix +} + +func splitPlannerURLPunctuation(raw string) (string, string) { + cut := len(raw) + for cut > 0 && strings.ContainsRune("\"'.,;:)]}", rune(raw[cut-1])) { + cut-- + } + return raw[:cut], raw[cut:] +} diff --git a/internal/ai/error_sanitize_test.go b/internal/ai/error_sanitize_test.go new file mode 100644 index 00000000..7e9b9549 --- /dev/null +++ b/internal/ai/error_sanitize_test.go @@ -0,0 +1,60 @@ +package ai + +import ( + "strings" + "testing" + "unicode" +) + +func TestSanitizePlannerErrorRedactsQuotedJSONCredentials(t *testing.T) { + raw := `openai-compat: http 400: {"error":{"message":"denied","token":"quoted-token","api_key":"quoted-key","headers":{"Authorization":"Bearer auth-token"}},"password":"quoted-pass","usage":{"max_tokens":100}}` + clean := SanitizePlannerError(raw) + for _, secret := range []string{"quoted-token", "quoted-key", "auth-token", "quoted-pass"} { + if strings.Contains(clean, secret) { + t.Fatalf("sanitized error contains %q: %q", secret, clean) + } + } + if !strings.Contains(clean, `"max_tokens":100`) { + t.Fatalf("sanitizer removed non-credential metadata: %q", clean) + } +} + +func TestSanitizePlannerErrorRedactsMalformedJSONFallback(t *testing.T) { + raw := `openai-compat: invalid response {"token":"truncated-secret` + clean := SanitizePlannerError(raw) + if strings.Contains(clean, "truncated-secret") || !strings.Contains(clean, "[REDACTED]") { + t.Fatalf("sanitized malformed JSON=%q", clean) + } +} + +func TestSanitizePlannerErrorBoundsAndNormalizes(t *testing.T) { + raw := "request https://alice:password@example.com/v1?api_key=query-secret#fragment " + + "Authorization: Bearer bearer-secret token=token-secret api_key=key-secret " + + "sk-1234567890abcdef\x00\n" + strings.Repeat("x", plannerErrorCap+100) + clean := SanitizePlannerError(raw) + for _, secret := range []string{ + "alice", "password", "query-secret", "bearer-secret", "token-secret", + "key-secret", "sk-1234567890abcdef", + } { + if strings.Contains(clean, secret) { + t.Fatalf("sanitized error contains %q: %q", secret, clean) + } + } + if got := len([]rune(clean)); got > plannerErrorCap { + t.Fatalf("sanitized error runes=%d want <=%d", got, plannerErrorCap) + } + for _, r := range clean { + if unicode.IsControl(r) { + t.Fatalf("sanitized error contains control rune %U", r) + } + } +} + +func TestSanitizePlannerErrorRedactsURLPathAndPreservesPunctuation(t *testing.T) { + raw := `openai-compat: Post "https://example.test/v1/path-secret/chat/completions": timeout` + clean := SanitizePlannerError(raw) + want := `openai-compat: Post "https://example.test": timeout` + if clean != want { + t.Fatalf("clean=%q want %q", clean, want) + } +} diff --git a/internal/ai/intent_planner.go b/internal/ai/intent_planner.go index 3fab0395..45098044 100644 --- a/internal/ai/intent_planner.go +++ b/internal/ai/intent_planner.go @@ -289,6 +289,30 @@ type MessageQualityError struct { Cause error } +// IntentMessageRewriteValidationError identifies a rewrite response that was +// received successfully but could not be decoded into a valid commit message. +// It lets planner health distinguish malformed provider output from transport +// failures that should open the circuit immediately. +type IntentMessageRewriteValidationError struct{ Err error } + +func (e *IntentMessageRewriteValidationError) Error() string { + if e == nil || e.Err == nil { + return "intent message rewrite validation failed" + } + return e.Err.Error() +} + +func (e *IntentMessageRewriteValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func intentMessageRewriteSubjectEmpty(subject string) bool { + return strings.TrimSpace(reControl.ReplaceAllString(subject, "")) == "" +} + func (e *MessageQualityError) Error() string { if e == nil { return "" @@ -303,6 +327,16 @@ func (e *MessageQualityError) Error() string { return fmt.Sprintf("intent planner: %s: %s", e.Provider, reason) } +// Unwrap preserves provider failures from the message-rewrite round trip so +// callers can distinguish transport and cancellation failures from a rewrite +// that completed but still failed message-quality validation. +func (e *MessageQualityError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + // IntentReasonCap bounds planner explanation fields before they are persisted // into the decision ledger. const IntentReasonCap = 512 @@ -384,10 +418,26 @@ type IntentPlanValidationError struct { Code IntentPlanValidationCode Seq int64 Message string + cause error } func (e *IntentPlanValidationError) Error() string { return e.Message } +func (e *IntentPlanValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func intentPlanShapeValidationError(message string, cause error) *IntentPlanValidationError { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: message, + cause: cause, + } +} + // ValidateIntentPlan rejects malformed or incomplete planner output before it // can influence replay. All failures return *IntentPlanValidationError so the // composed retry loop can quote the message back to the planner; the Code diff --git a/internal/ai/intent_planner_test.go b/internal/ai/intent_planner_test.go index 7ba787bd..bd052259 100644 --- a/internal/ai/intent_planner_test.go +++ b/internal/ai/intent_planner_test.go @@ -1302,6 +1302,7 @@ func TestComposedPlanIntentRejectsMalformedRewriteBody(t *testing.T) { func TestComposedPlanIntentSurfacesRewriteProviderError(t *testing.T) { req := sampleIntentPlanRequest(t) + rewriteErr := errors.New("rewrite unavailable") plan := IntentPlan{ SelectedSeqs: []int64{101}, DeferredSeqs: []int64{102}, @@ -1315,7 +1316,7 @@ func TestComposedPlanIntentSurfacesRewriteProviderError(t *testing.T) { primary := &scriptedIntentPlanner{ name: "scripted-primary", plans: []IntentPlan{plan}, - rewriteErrs: []error{errors.New("rewrite unavailable")}, + rewriteErrs: []error{rewriteErr}, } planner := Compose(primary, DeterministicProvider{}) _, err := planner.(IntentPlanner).PlanIntent(context.Background(), req) @@ -1325,6 +1326,9 @@ func TestComposedPlanIntentSurfacesRewriteProviderError(t *testing.T) { if !strings.Contains(err.Error(), "rewrite unavailable") { t.Fatalf("error=%v missing provider cause", err) } + if !errors.Is(err, rewriteErr) { + t.Fatalf("error=%v does not unwrap provider cause", err) + } } // TestNormalizeIntentPlanDeferredReasonsSynthesizesMissingEntries covers the diff --git a/internal/ai/openai_compat.go b/internal/ai/openai_compat.go index c55711f9..dca946ae 100644 --- a/internal/ai/openai_compat.go +++ b/internal/ai/openai_compat.go @@ -376,7 +376,8 @@ func (p *OpenAIProvider) PlanIntent(ctx context.Context, plannerReq IntentPlanRe return IntentPlan{}, err } if strings.TrimSpace(plan.Subject) == "" { - err := errors.New("openai-compat: intent plan returned empty subject") + cause := errors.New("openai-compat: intent plan returned empty subject") + err := intentPlanShapeValidationError(cause.Error(), cause) p.recordPromptResponse(ctx, model, "intent", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: err.Error()}) return IntentPlan{}, err } @@ -489,8 +490,16 @@ func (p *OpenAIProvider) RewriteIntentMessage(ctx context.Context, rewriteReq In subject, bodyOut, err := parseToolCall(raw) if err != nil { - p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: err.Error()}) - return Result{}, err + validationErr := &IntentMessageRewriteValidationError{Err: err} + p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: validationErr.Error()}) + return Result{}, validationErr + } + if intentMessageRewriteSubjectEmpty(subject) { + validationErr := &IntentMessageRewriteValidationError{ + Err: errors.New("openai-compat: empty subject after intent message rewrite sanitize"), + } + p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: validationErr.Error()}) + return Result{}, validationErr } cleaned := SanitizeMessage(subject + "\n\n" + bodyOut) parts := strings.SplitN(cleaned, "\n\n", 2) @@ -499,9 +508,11 @@ func (p *OpenAIProvider) RewriteIntentMessage(ctx context.Context, rewriteReq In result.Body = parts[1] } if strings.TrimSpace(result.Subject) == "" { - err := errors.New("openai-compat: empty subject after intent message rewrite sanitize") - p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: err.Error()}) - return Result{}, err + validationErr := &IntentMessageRewriteValidationError{ + Err: errors.New("openai-compat: empty subject after intent message rewrite sanitize"), + } + p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{StatusCode: resp.StatusCode, ValidationError: validationErr.Error()}) + return Result{}, validationErr } p.recordPromptResponse(ctx, model, "intent_message_rewrite", prompttrace.Response{ StatusCode: resp.StatusCode, @@ -567,6 +578,8 @@ func (p *OpenAIProvider) recordPromptResponse(ctx context.Context, model, strate if meta.Model == "" { meta.Model = model } + response.Error = SanitizePlannerError(response.Error) + response.ValidationError = SanitizePlannerError(response.ValidationError) meta = promptTraceMetadata(meta, p.Name(), meta.Model) logger.Record(prompttrace.Record{ Stage: "response", @@ -1069,13 +1082,15 @@ func parseIntentPlanToolCall(raw []byte) (IntentPlan, error) { } var r respShape if err := json.Unmarshal(raw, &r); err != nil { - return IntentPlan{}, fmt.Errorf("openai-compat: parse response: %w", err) + cause := fmt.Errorf("openai-compat: parse response: %w", err) + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } if r.Error != nil && r.Error.Message != "" { return IntentPlan{}, fmt.Errorf("openai-compat: api error: %s", r.Error.Message) } if len(r.Choices) == 0 { - return IntentPlan{}, errors.New("openai-compat: no choices in response") + cause := errors.New("openai-compat: no choices in response") + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } msg := r.Choices[0].Message @@ -1083,21 +1098,25 @@ func parseIntentPlanToolCall(raw []byte) (IntentPlan, error) { switch { case len(msg.ToolCalls) > 0 && msg.ToolCalls[0].Function.Arguments != "": if msg.ToolCalls[0].Function.Name != "capture_intent_plan" { - return IntentPlan{}, fmt.Errorf("openai-compat: unexpected tool %q", msg.ToolCalls[0].Function.Name) + cause := fmt.Errorf("openai-compat: unexpected tool %q", msg.ToolCalls[0].Function.Name) + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } args = msg.ToolCalls[0].Function.Arguments case msg.FunctionCall != nil && msg.FunctionCall.Arguments != "": if msg.FunctionCall.Name != "capture_intent_plan" { - return IntentPlan{}, fmt.Errorf("openai-compat: unexpected function %q", msg.FunctionCall.Name) + cause := fmt.Errorf("openai-compat: unexpected function %q", msg.FunctionCall.Name) + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } args = msg.FunctionCall.Arguments default: - return IntentPlan{}, errors.New("openai-compat: response carried no intent-plan tool_call arguments") + cause := errors.New("openai-compat: response carried no intent-plan tool_call arguments") + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } var plan IntentPlan if err := json.Unmarshal([]byte(args), &plan); err != nil { - return IntentPlan{}, fmt.Errorf("openai-compat: parse intent-plan tool arguments: %w", err) + cause := fmt.Errorf("openai-compat: parse intent-plan tool arguments: %w", err) + return IntentPlan{}, intentPlanShapeValidationError(cause.Error(), cause) } return plan, nil } diff --git a/internal/ai/openai_compat_test.go b/internal/ai/openai_compat_test.go index 4b9336e9..b197b8d2 100644 --- a/internal/ai/openai_compat_test.go +++ b/internal/ai/openai_compat_test.go @@ -715,6 +715,32 @@ func TestOpenAIIntentPlan_HappyPath(t *testing.T) { } } +func TestOpenAIIntentPlan_ResponseShapeErrorsAreTypedValidation(t *testing.T) { + for _, tc := range []struct { + name string + response string + }{ + {name: "malformed response", response: `{"choices":`}, + {name: "missing choices", response: `{"choices":[]}`}, + {name: "missing tool call", response: `{"choices":[{"message":{"content":"no tool"}}]}`}, + {name: "malformed arguments", response: `{"choices":[{"message":{"tool_calls":[{"function":{"name":"capture_intent_plan","arguments":"{"}}]}}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + p, _, _ := newOpenAIMock(t, func(capturedReq) (int, string) { + return 200, tc.response + }) + _, err := p.PlanIntent(context.Background(), sampleIntentPlanRequest(t)) + var validationErr *IntentPlanValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error=%T %v want *IntentPlanValidationError", err, err) + } + if validationErr.Code != IntentPlanValidationShape { + t.Fatalf("validation code=%v want shape", validationErr.Code) + } + }) + } +} + func TestOpenAIIntentMessageRewrite_HappyPath(t *testing.T) { req := sampleIntentPlanRequest(t) plan := IntentPlan{ @@ -773,6 +799,40 @@ func TestOpenAIIntentMessageRewrite_HappyPath(t *testing.T) { } } +func TestOpenAIIntentMessageRewrite_ValidationErrorsAreTyped(t *testing.T) { + for _, tc := range []struct { + name string + response string + }{ + {name: "malformed response", response: "{"}, + {name: "empty subject after sanitize", response: cannedToolCall("\x01", "")}, + } { + t.Run(tc.name, func(t *testing.T) { + req := sampleIntentPlanRequest(t) + plan := IntentPlan{ + SelectedSeqs: []int64{101}, + DeferredSeqs: []int64{102}, + Subject: "Update parsed", + GroupingReason: "checkout service change is focused", + DeferredReasons: []DeferredReason{{ + Seq: 102, + Reason: "docs change is independent", + }}, + } + rewriteReq := NewIntentMessageRewriteRequest(req, plan, EvaluateIntentPlanMessageQuality(req, plan)) + p, _, _ := newOpenAIMock(t, func(capturedReq) (int, string) { + return http.StatusOK, tc.response + }) + + _, err := p.RewriteIntentMessage(context.Background(), rewriteReq) + var validationErr *IntentMessageRewriteValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error=%T %v want IntentMessageRewriteValidationError", err, err) + } + }) + } +} + func TestOpenAIIntentMessageRewrite_PromptTraceUsesRewriteStrategy(t *testing.T) { req := sampleIntentPlanRequest(t) plan := IntentPlan{ @@ -875,6 +935,22 @@ func TestOpenAI_PromptTraceRecordsExactIntentRequest(t *testing.T) { } } +func TestOpenAI_PromptTraceSanitizesProviderError(t *testing.T) { + const secret = "prompt-trace-secret" + p, _, _ := newOpenAIMock(t, func(capturedReq) (int, string) { + return 400, `{"error":{"token":"` + secret + `"}}` + }) + writer, records := newPromptTraceTestWriter(t) + ctx := prompttrace.With(context.Background(), writer, prompttrace.Metadata{Strategy: string(CommitStrategyIntent)}) + if _, err := p.PlanIntent(ctx, sampleIntentPlanRequest(t)); err == nil { + t.Fatal("PlanIntent unexpectedly succeeded") + } + got := promptTraceRecordByStage(t, records(), "response") + if got.Response == nil || strings.Contains(got.Response.Error, secret) || !strings.Contains(got.Response.Error, "[REDACTED]") { + t.Fatalf("prompt response=%+v", got.Response) + } +} + // TestOpenAIIntentPlan_NormalizesSpuriousDeferredReason exercises case (a) // from the [Tests] task: when the planner emits a deferred_reasons entry whose // seq is selected (not deferred), the openai-compat provider drops the @@ -1076,6 +1152,7 @@ type promptTraceJSONRecord struct { UserMessage string `json:"user_message"` ToolSchema any `json:"tool_schema"` Request json.RawMessage `json:"request"` + Response *prompttrace.Response `json:"response"` } func promptTraceRecordByStage(t *testing.T, records []promptTraceJSONRecord, stage string) promptTraceJSONRecord { diff --git a/internal/ai/plugin_subprocess.go b/internal/ai/plugin_subprocess.go index e05006b0..31e5f76b 100644 --- a/internal/ai/plugin_subprocess.go +++ b/internal/ai/plugin_subprocess.go @@ -321,6 +321,15 @@ func (p *SubprocessProvider) PlanIntent(ctx context.Context, plannerReq IntentPl p.markCrashed(session) } if err != nil { + var decodeErr *subprocessResponseDecodeError + if errors.As(err, &decodeErr) { + validationErr := intentPlanShapeValidationError( + fmt.Sprintf("subprocess:%s: %v", p.name, decodeErr), + decodeErr, + ) + p.recordSubprocessResponse(ctx, "intent", prompttrace.Response{ValidationError: validationErr.Error()}) + return IntentPlan{}, validationErr + } p.recordSubprocessResponse(ctx, "intent", prompttrace.Response{Error: err.Error()}) return IntentPlan{}, err } @@ -341,7 +350,8 @@ func (p *SubprocessProvider) PlanIntent(ctx context.Context, plannerReq IntentPl Source: p.Name(), } if strings.TrimSpace(plan.Subject) == "" { - err := fmt.Errorf("subprocess:%s: intent plan returned empty subject", p.name) + cause := fmt.Errorf("subprocess:%s: intent plan returned empty subject", p.name) + err := intentPlanShapeValidationError(cause.Error(), cause) p.recordSubprocessResponse(ctx, "intent", prompttrace.Response{ValidationError: err.Error()}) return IntentPlan{}, err } @@ -447,6 +457,14 @@ func (p *SubprocessProvider) RewriteIntentMessage(ctx context.Context, rewriteRe p.markCrashed(session) } if err != nil { + var decodeErr *subprocessResponseDecodeError + if errors.As(err, &decodeErr) { + validationErr := &IntentMessageRewriteValidationError{ + Err: fmt.Errorf("subprocess:%s: %w", p.name, decodeErr), + } + p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{ValidationError: validationErr.Error()}) + return Result{}, validationErr + } p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{Error: err.Error()}) return Result{}, err } @@ -455,6 +473,13 @@ func (p *SubprocessProvider) RewriteIntentMessage(ctx context.Context, rewriteRe p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{Error: err.Error()}) return Result{}, err } + if intentMessageRewriteSubjectEmpty(resp.Subject) { + validationErr := &IntentMessageRewriteValidationError{ + Err: fmt.Errorf("subprocess:%s: empty subject after intent message rewrite sanitize", p.name), + } + p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{ValidationError: validationErr.Error()}) + return Result{}, validationErr + } cleaned := SanitizeMessage(resp.Subject + "\n\n" + resp.Body) parts := strings.SplitN(cleaned, "\n\n", 2) @@ -463,9 +488,11 @@ func (p *SubprocessProvider) RewriteIntentMessage(ctx context.Context, rewriteRe result.Body = parts[1] } if strings.TrimSpace(result.Subject) == "" { - err := fmt.Errorf("subprocess:%s: empty subject after intent message rewrite sanitize", p.name) - p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{ValidationError: err.Error()}) - return Result{}, err + validationErr := &IntentMessageRewriteValidationError{ + Err: fmt.Errorf("subprocess:%s: empty subject after intent message rewrite sanitize", p.name), + } + p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{ValidationError: validationErr.Error()}) + return Result{}, validationErr } p.recordSubprocessResponse(ctx, "intent_message_rewrite", prompttrace.Response{Subject: result.Subject, Body: result.Body}) return result, nil @@ -608,6 +635,8 @@ func (p *SubprocessProvider) recordSubprocessResponse(ctx context.Context, strat if meta.Strategy == "" { meta.Strategy = strategy } + response.Error = SanitizePlannerError(response.Error) + response.ValidationError = SanitizePlannerError(response.ValidationError) meta = promptTraceMetadata(meta, p.Name(), "") logger.Record(prompttrace.Record{ Stage: "response", @@ -677,6 +706,22 @@ type pluginReply struct { err error } +type subprocessResponseDecodeError struct{ err error } + +func (e *subprocessResponseDecodeError) Error() string { + if e == nil || e.err == nil { + return "decode response" + } + return "decode response: " + e.err.Error() +} + +func (e *subprocessResponseDecodeError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + // pluginSession owns a single child process plus the goroutine that // serialises stdin writes and stdout reads. Lifecycle: startPlugin -> run // -> shutdown. The dead flag is set when the owner goroutine exits, so @@ -822,8 +867,8 @@ func (s *pluginSession) run() { } var resp subprocessResponse if err := json.Unmarshal(line, &resp); err != nil { - req.reply <- pluginReply{err: fmt.Errorf("decode response: %w", err)} - s.logger.Debug("plugin response decode failed", slog.Any("err", err), slog.String("line", string(line))) + req.reply <- pluginReply{err: &subprocessResponseDecodeError{err: err}} + s.logger.Debug("plugin response decode failed", slog.Any("err", err), slog.Int("response_bytes", len(line))) return } req.reply <- pluginReply{resp: resp} diff --git a/internal/ai/plugin_subprocess_test.go b/internal/ai/plugin_subprocess_test.go index 518d8d3e..02f8e9f2 100644 --- a/internal/ai/plugin_subprocess_test.go +++ b/internal/ai/plugin_subprocess_test.go @@ -242,6 +242,51 @@ done } } +func TestSubprocess_RewriteIntentMessageValidationErrorsAreTyped(t *testing.T) { + for _, tc := range []struct { + name string + response string + }{ + {name: "malformed response", response: "{"}, + {name: "empty subject", response: `{"version":1,"subject":"","body":"","error":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + skipIfWindows(t) + dir := t.TempDir() + script := fmt.Sprintf(` +while IFS= read -r line; do + printf '%%s\n' '%s' +done +`, tc.response) + bin := writePluginScript(t, dir, "test", script) + p := NewSubprocessProvider("test", SubprocessOptions{ + LookPath: fixedLookPath("acd-provider-test", bin), + Timeout: 5 * time.Second, + Stderr: io.Discard, + }) + t.Cleanup(func() { _ = p.Close() }) + + req := sampleIntentPlanRequest(t) + plan := IntentPlan{ + SelectedSeqs: []int64{101}, + DeferredSeqs: []int64{102}, + Subject: "Update parsed", + GroupingReason: "checkout service change is focused", + DeferredReasons: []DeferredReason{{ + Seq: 102, + Reason: "docs change is independent", + }}, + } + rewriteReq := NewIntentMessageRewriteRequest(req, plan, EvaluateIntentPlanMessageQuality(req, plan)) + _, err := p.RewriteIntentMessage(context.Background(), rewriteReq) + var validationErr *IntentMessageRewriteValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error=%T %v want IntentMessageRewriteValidationError", err, err) + } + }) + } +} + func TestSubprocess_RewriteIntentMessagePromptTraceUsesRewriteStrategy(t *testing.T) { skipIfWindows(t) dir := t.TempDir() @@ -533,9 +578,9 @@ done // TestSubprocess_Timeout makes the plugin sleep longer than the timeout. // The runner must kill the plugin on timeout and respawn on the next -// Generate. We point the second invocation at a *different* script (via -// a fresh provider sharing the same temp dir prefix) to keep the test -// straightforward. +// Generate. We point the same provider at a different script before the +// second invocation so the killed shell cannot race an in-place rewrite of +// its executable. func TestSubprocess_Timeout(t *testing.T) { skipIfWindows(t) dir := t.TempDir() @@ -565,14 +610,18 @@ done t.Errorf("timeout fired far too late: %v", elapsed) } - // After timeout the provider should be ready to respawn. Swap the - // plugin to a fast one and retry — the new process is a fresh pid. - fastBin := writePluginScript(t, dir, "slow", ` + // After timeout the provider should be ready to respawn. Point it at a + // distinct fast plugin and retry — the new process is a fresh pid. + fastBin := writePluginScript(t, dir, "fast", ` while IFS= read -r line; do printf '{"version":1,"subject":"fast","body":"","error":""}\n' done `) - _ = fastBin // same path as slowBin, overwritten in place + p.binary = fastBin + // The timeout behavior was proved above. Give process startup a wider + // budget for the respawn assertion because package-level race tests run in + // parallel and can delay a healthy child under load. + p.timeout = 10 * time.Second r, err := p.Generate(context.Background(), CommitContext{Path: "a", Op: "modify"}) if err != nil { t.Fatalf("respawn Generate: %v", err) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index dcb55b99..518979e6 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -139,6 +139,7 @@ func recordPromptFallback(ctx context.Context, strategy, primary, fallback, reas if meta.Strategy == "" { meta.Strategy = strategy } + reason = SanitizePlannerError(reason) logger.Record(prompttrace.Record{ Stage: "fallback", Strategy: meta.Strategy, @@ -289,7 +290,7 @@ func (c *composed) runPrimaryWithRetry(ctx context.Context, primary IntentPlanne slog.Int("max_retries", maxRetries), slog.Int("code", int(typed.Code)), slog.Int64("seq", typed.Seq), - slog.String("error", typed.Message), + slog.String("error", SanitizePlannerError(typed.Message)), ) currentReq = req currentReq.RetryCorrection = typed.Message diff --git a/internal/cli/cli_testutil_test.go b/internal/cli/cli_testutil_test.go index fcee59a0..93be5e8f 100644 --- a/internal/cli/cli_testutil_test.go +++ b/internal/cli/cli_testutil_test.go @@ -2,6 +2,11 @@ package cli import ( "context" + "errors" + "io" + "os" + "path/filepath" + "sync" "testing" "time" @@ -11,6 +16,20 @@ import ( "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) +var ( + testRepoFixturesOnce sync.Once + testRepoFixturesRoot string + testRepoFixturesErr error +) + +func TestMain(m *testing.M) { + code := m.Run() + if testRepoFixturesRoot != "" { + _ = os.RemoveAll(testRepoFixturesRoot) + } + os.Exit(code) +} + // withIsolatedHome installs a fresh $HOME for the duration of a test so // that paths.Resolve() points at a tempdir-scoped XDG layout. Returns the // resolved Roots for direct use. @@ -40,21 +59,17 @@ func withIsolatedHome(t *testing.T) paths.Roots { // process tries to open the file read-only on Windows-y filesystems; on // Linux/macOS WAL is fine but we keep the contract explicit. func makeRepoStateDB(t *testing.T) (repoDir, stateDB string, db *state.DB) { + return makeRepoStateDBFromFixture(t, false) +} + +func makeSeededRepoStateDB(t *testing.T) (repoDir, stateDB string, db *state.DB) { + return makeRepoStateDBFromFixture(t, true) +} + +func makeRepoStateDBFromFixture(t *testing.T, seeded bool) (repoDir, stateDB string, db *state.DB) { t.Helper() - repoDir = t.TempDir() - ctx := context.Background() - if err := git.Init(ctx, repoDir); err != nil { - t.Fatalf("git init: %v", err) - } - if _, err := git.Run(ctx, git.RunOpts{Dir: repoDir}, "symbolic-ref", "HEAD", "refs/heads/main"); err != nil { - t.Fatalf("symbolic-ref HEAD: %v", err) - } - wt, err := git.ResolveWorktree(ctx, repoDir) - if err != nil { - t.Fatalf("resolve worktree: %v", err) - } - repoDir = wt.Root - dbPath := state.DBPathFromGitDir(wt.GitDir) + repoDir = materializeTestRepo(t, seeded) + dbPath := state.DBPathFromGitDir(filepath.Join(repoDir, ".git")) d, err := state.Open(context.Background(), dbPath) if err != nil { t.Fatalf("state.Open: %v", err) @@ -63,6 +78,141 @@ func makeRepoStateDB(t *testing.T) (repoDir, stateDB string, db *state.DB) { return repoDir, dbPath, d } +func materializeTestRepo(t *testing.T, seeded bool) string { + t.Helper() + testRepoFixturesOnce.Do(initTestRepoFixtures) + if testRepoFixturesErr != nil { + t.Fatalf("initialize Git fixtures: %v", testRepoFixturesErr) + } + + name := "empty" + if seeded { + name = "seeded" + } + repoDir := t.TempDir() + if err := copyFixtureTree(filepath.Join(testRepoFixturesRoot, name), repoDir); err != nil { + t.Fatalf("materialize %s Git fixture: %v", name, err) + } + realRepoDir, err := filepath.EvalSymlinks(repoDir) + if err != nil { + t.Fatalf("canonicalize temp repo: %v", err) + } + return realRepoDir +} + +func initTestRepoFixtures() { + root, err := os.MkdirTemp("", "acd-cli-repo-fixtures-") + if err != nil { + testRepoFixturesErr = err + return + } + testRepoFixturesRoot = root + + empty := filepath.Join(root, "empty") + if err := os.MkdirAll(empty, 0o700); err != nil { + testRepoFixturesErr = err + return + } + ctx := context.Background() + if _, err := git.Run(ctx, git.RunOpts{Dir: empty}, "init", "-q", "-b", "main"); err != nil { + testRepoFixturesErr = err + return + } + for _, setting := range [][2]string{ + {"user.name", "ACD Test"}, + {"user.email", "acd@example.invalid"}, + {"commit.gpgsign", "false"}, + } { + if _, err := git.Run(ctx, git.RunOpts{Dir: empty}, "config", setting[0], setting[1]); err != nil { + testRepoFixturesErr = err + return + } + } + dbPath := state.DBPathFromGitDir(filepath.Join(empty, ".git")) + db, err := state.Open(ctx, dbPath) + if err != nil { + testRepoFixturesErr = err + return + } + if _, err := db.SQL().ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + _ = db.Close() + testRepoFixturesErr = err + return + } + if err := db.Close(); err != nil { + testRepoFixturesErr = err + return + } + for _, suffix := range []string{"-wal", "-shm"} { + if err := os.Remove(dbPath + suffix); err != nil && !os.IsNotExist(err) { + testRepoFixturesErr = err + return + } + } + + seeded := filepath.Join(root, "seeded") + if err := os.MkdirAll(seeded, 0o700); err != nil { + testRepoFixturesErr = err + return + } + if err := copyFixtureTree(empty, seeded); err != nil { + testRepoFixturesErr = err + return + } + if err := os.WriteFile(filepath.Join(seeded, "seed.txt"), []byte("seed\n"), 0o644); err != nil { + testRepoFixturesErr = err + return + } + if _, err := git.Run(ctx, git.RunOpts{Dir: seeded}, "add", "seed.txt"); err != nil { + testRepoFixturesErr = err + return + } + if _, err := git.Run(ctx, git.RunOpts{Dir: seeded}, "commit", "-q", "-m", "seed"); err != nil { + testRepoFixturesErr = err + } +} + +func copyFixtureTree(src, dst string) error { + return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if entry.Type()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(linkTarget, target) + } + in, err := os.Open(path) + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + _ = in.Close() + return err + } + _, copyErr := io.Copy(out, in) + return errors.Join(copyErr, out.Close(), in.Close()) + }) +} + // registerRepo writes a single repo entry into the central registry under // roots, atomically. Mirrors what `acd start` does at first registration. func registerRepo(t *testing.T, roots paths.Roots, repoDir, stateDB, harness string) { diff --git a/internal/cli/commitall.go b/internal/cli/commitall.go index efd8ad95..a43c275c 100644 --- a/internal/cli/commitall.go +++ b/internal/cli/commitall.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -29,6 +30,48 @@ import ( // no" from "ran successfully with nothing to do". var errCommitAllAborted = errors.New("acd commit-all: aborted by user") +// commitAllIncompleteError reports that commit-all stopped safely but did not +// drain every unpublished event. The rendered result carries the detailed +// counts; the typed error gives scripts a reliable non-zero outcome. +type commitAllIncompleteError struct { + PendingAfter int + Conflicts int + Failed int +} + +type commitAllReplayError struct { + Cause error + PendingAfter int +} + +func (e *commitAllReplayError) Error() string { + return fmt.Sprintf("acd commit-all: replay stopped with %d unpublished event(s): %v", e.PendingAfter, e.Cause) +} + +func (e *commitAllReplayError) Unwrap() error { return e.Cause } + +// commitAllRecoveryError reports a failure while preserving pre-existing +// provenance pairs. Earlier pairs may already be protected and transitioned, +// so callers must receive the rendered partial result as well as a non-zero +// error. +type commitAllRecoveryError struct { + Cause error + PendingAfter int +} + +func (e *commitAllRecoveryError) Error() string { + return fmt.Sprintf("acd commit-all: pre-existing recovery stopped with %d unpublished event(s): %v", e.PendingAfter, e.Cause) +} + +func (e *commitAllRecoveryError) Unwrap() error { return e.Cause } + +func (e *commitAllIncompleteError) Error() string { + return fmt.Sprintf( + "acd commit-all: incomplete (pending=%d conflicts=%d failed=%d)", + e.PendingAfter, e.Conflicts, e.Failed, + ) +} + // commitAllResult is the JSON payload returned by `acd commit-all --json`. type commitAllResult struct { OK bool `json:"ok"` @@ -47,9 +90,13 @@ type commitAllResult struct { Drained int `json:"drained"` Conflicts int `json:"conflicts,omitempty"` Failed int `json:"failed,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` + Error string `json:"error,omitempty"` DryRun bool `json:"dry_run,omitempty"` Confirmed bool `json:"confirmed,omitempty"` DroppedStalePending int `json:"dropped_stale_pending,omitempty"` + PreservedPending int `json:"preserved_pending,omitempty"` + RecoveryRefs []string `json:"recovery_refs,omitempty"` DurationMillis int64 `json:"duration_ms"` Notes []string `json:"notes,omitempty"` } @@ -65,9 +112,11 @@ The active commit strategy is read from existing config (daemon meta first, then ACD_COMMIT_STRATEGY env, then the canonical default). There is no --strategy override: commit-all matches what the daemon would do on its own. -Refuses to run on detached HEAD, while a git operation is in progress -(rebase/merge/cherry-pick/bisect), while a manual pause marker is present, -or while the per-repo daemon is alive.`, +Detached HEAD, an in-progress git operation +(rebase/merge/cherry-pick/bisect), and a manual pause marker are refused for +both previews and apply. If an authorized run reaches its mutation phase, it +also refuses while the per-repo daemon is alive; dry-run, a declined prompt, +and a clean no-op do not acquire daemon.lock.`, Example: ` acd commit-all --dry-run acd commit-all --yes acd commit-all --repo /path/to/repo --yes --json`, @@ -93,13 +142,69 @@ or while the per-repo daemon is alive.`, return cmd } -const commitAllZeroProgressLimit = 3 +const ( + commitAllZeroProgressLimit = 3 + commitAllRecaptureLimit = 3 +) + +type commitAllMutationStage string + +const ( + commitAllStagePostLock commitAllMutationStage = "post_lock" + commitAllStageRecoveryPair commitAllMutationStage = "recovery_pair" + commitAllStageReseed commitAllMutationStage = "reseed" + commitAllStageCapture commitAllMutationStage = "capture" + commitAllStageReplay commitAllMutationStage = "replay" + commitAllStageRecaptureReseed commitAllMutationStage = "recapture_reseed" + commitAllStageRecaptureCapture commitAllMutationStage = "recapture_capture" +) + +// commitAllHooks is a per-invocation test seam. It deliberately lives on the +// call stack instead of in package globals so parallel CLI tests cannot change +// another invocation's mutation timing. +type commitAllHooks struct { + BeforeMutationCheck func(context.Context, commitAllMutationStage) error +} + +type commitAllMutationGuard struct { + repo string + gitDir string + expectedBranch string + hooks commitAllHooks +} + +func (g commitAllMutationGuard) check( + ctx context.Context, + stage commitAllMutationStage, + expectedHead string, +) error { + if g.hooks.BeforeMutationCheck != nil { + if err := g.hooks.BeforeMutationCheck(ctx, stage); err != nil { + return fmt.Errorf("acd commit-all: %s pre-mutation hook: %w", stage, err) + } + } + return revalidateCommitAllMutationAnchor(ctx, g.repo, g.gitDir, g.expectedBranch, expectedHead) +} func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag string, yes, dryRun, jsonOut bool) error { + return runCommitAllWithHooks(ctx, out, in, repoFlag, yes, dryRun, jsonOut, commitAllHooks{}) +} + +func runCommitAllWithHooks( + ctx context.Context, + out io.Writer, + in io.Reader, + repoFlag string, + yes, dryRun, jsonOut bool, + hooks commitAllHooks, +) error { if ctx == nil { ctx = context.Background() } start := time.Now() + if jsonOut && !yes && !dryRun { + return errors.New("acd commit-all: --json requires --yes (no interactive prompt available)") + } repo, err := resolveRepo(repoFlag) if err != nil { @@ -126,12 +231,87 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str return fmt.Errorf("acd commit-all: refusing while manual pause marker is present at %s; run `acd resume` first", pausepkg.Path(gitDir)) } - // daemon.lock acquire is the live-daemon refusal gate: if the daemon - // owns the lock our acquire fails with ErrDaemonLockHeld. We hold it - // across the entire run so a daemon that starts mid-flight cannot - // race the capture/replay loop. The previous control.lock - // acquire+release dance was no-op (released before any work) and has - // been dropped — daemon.lock already covers the only real exclusion. + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + if errors.Is(err, git.ErrRefNotFound) { + return errors.New("acd commit-all: no commits on branch yet; create the initial commit first") + } + return fmt.Errorf("acd commit-all: resolve HEAD: %w", err) + } + if strings.TrimSpace(head) == "" { + return errors.New("acd commit-all: no commits on branch yet; create the initial commit first") + } + guard := commitAllMutationGuard{ + repo: repo, + gitDir: gitDir, + expectedBranch: branchRef, + hooks: hooks, + } + dbPath := state.DBPathFromGitDir(gitDir) + preflight, err := inspectCommitAllPreflight(ctx, repo, dbPath, branchRef, head) + if err != nil { + return err + } + cfg := ai.LoadProviderConfigFromEnv() + cfg.CommitStrategy = preflight.Strategy + estimatedPending := preflight.ExistingCount + preflight.WorktreeChanges + res := commitAllResult{ + OK: true, + Repo: repo, + BranchRef: branchRef, + HeadBefore: head, + Strategy: string(preflight.Strategy), + Provider: commitAllConfiguredProviderName(cfg), + IntentWindow: cfg.IntentWindow, + IntentDeferLim: cfg.IntentDeferLimit, + PendingBefore: estimatedPending, + EstimatedPass: commitAllEstimatePasses(preflight.Strategy, estimatedPending, cfg.IntentWindow), + DryRun: dryRun, + PreservedPending: preflight.ExistingCount, + } + if preflight.ExistingCount > 0 { + res.Notes = append(res.Notes, fmt.Sprintf( + "%swould preserve %d pre-existing event(s) across %d exact pair(s) before reseeding shadow", + commitAllDryRunPrefix(dryRun), preflight.ExistingCount, len(preflight.ExistingPairs), + )) + } + if preflight.WorktreeChanges > 0 { + res.Notes = append(res.Notes, fmt.Sprintf( + "%sread-only worktree estimate found %d changed path(s)", + commitAllDryRunPrefix(dryRun), preflight.WorktreeChanges, + )) + } + if dryRun { + res.Notes = append(res.Notes, "dry-run: no capture, recovery ref, or replay state was written") + res.DurationMillis = time.Since(start).Milliseconds() + return renderCommitAll(out, res, jsonOut) + } + if estimatedPending == 0 { + res.Notes = append(res.Notes, "no pending events; worktree already clean") + res.HeadAfter = head + res.DurationMillis = time.Since(start).Milliseconds() + return renderCommitAll(out, res, jsonOut) + } + if !yes { + ok, err := promptCommitAllConfirm(out, in, res) + if err != nil { + return err + } + if !ok { + res.Notes = append(res.Notes, "aborted by user; no state changes were made") + res.OK = false + res.DurationMillis = time.Since(start).Milliseconds() + if rerr := renderCommitAll(out, res, jsonOut); rerr != nil { + return rerr + } + return errCommitAllAborted + } + } + res.Confirmed = true + + // Acquire the daemon lock only after the operator authorizes mutation. Hold + // it through the state reload and replay so a daemon cannot start between + // the post-prompt safety checks and capture. dlock, err := daemon.AcquireDaemonLock(gitDir) if err != nil { if errors.Is(err, daemon.ErrDaemonLockHeld) { @@ -140,24 +320,18 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str return fmt.Errorf("acd commit-all: acquire daemon.lock: %w", err) } defer func() { _ = dlock.Release() }() + if err := guard.check(ctx, commitAllStagePostLock, head); err != nil { + return err + } - dbPath := state.DBPathFromGitDir(gitDir) + // Writable state is opened only after dry-run and confirmation paths have + // returned. Reload the state-derived values so the mutation phase acts on + // the exact queue and generation it is about to reconcile. db, err := state.Open(ctx, dbPath) if err != nil { return fmt.Errorf("acd commit-all: open state.db: %w", err) } defer func() { _ = db.Close() }() - - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - if errors.Is(err, git.ErrRefNotFound) { - return errors.New("acd commit-all: no commits on branch yet; create the initial commit first") - } - return fmt.Errorf("acd commit-all: resolve HEAD: %w", err) - } - if strings.TrimSpace(head) == "" { - return errors.New("acd commit-all: no commits on branch yet; create the initial commit first") - } gen, err := loadCommitAllGeneration(ctx, db) if err != nil { return err @@ -167,6 +341,48 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str BranchGeneration: gen, BaseHead: head, } + strategy, err := ResolveEffectiveCommitStrategy(ctx, db.SQL()) + if err != nil { + return fmt.Errorf("acd commit-all: resolve strategy: %w", err) + } + cfg.CommitStrategy = strategy + res.Strategy = string(strategy) + provider, providerCloser, err := ai.BuildProvider(cfg) + if err != nil { + return fmt.Errorf("acd commit-all: build provider: %w", err) + } + if providerCloser != nil { + defer func() { _ = providerCloser.Close() }() + } + res.Provider = ai.PrimaryProviderName(provider) + if err := guard.check(ctx, commitAllStagePostLock, head); err != nil { + return err + } + existingPairs, err := inspectCommitAllExistingPairs(ctx, db, cctx) + if err != nil { + return err + } + existingCount := commitAllExistingPairCount(existingPairs) + res.PreservedPending = existingCount + res.PendingBefore = existingCount + + // Preserve every pre-existing exact-pair queue before reseeding shadow. The + // shared reconciler makes each pair reachable independently before active + // capture begins. + for _, pair := range existingPairs { + if err := guard.check(ctx, commitAllStageRecoveryPair, head); err != nil { + res.DurationMillis = time.Since(start).Milliseconds() + return finishCommitAllRecoveryError(ctx, out, repo, db, cctx, res, jsonOut, err) + } + result, err := reconcileCommitAllExistingPair(ctx, repo, gitDir, db, pair) + if err != nil { + res.DurationMillis = time.Since(start).Milliseconds() + return finishCommitAllRecoveryError(ctx, out, repo, db, cctx, res, jsonOut, err) + } + if result.Handled { + appendCommitAllRecoveryResult(&res, result) + } + } // commit-all is the "cold start, dirty repo, daemon was off" entrypoint. // Users expect the captured events to reflect a diff of live worktree @@ -178,22 +394,12 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str // leaving the next Capture to compare live worktree against a shadow // that already mirrors live state and emit zero events. Force a reseed // so the diff is always vs HEAD's tree. + if err := guard.check(ctx, commitAllStageReseed, head); err != nil { + return err + } if _, err := daemon.ReseedShadowFromHead(ctx, repo, db, cctx); err != nil { return fmt.Errorf("acd commit-all: reseed shadow from HEAD: %w", err) } - // Stale pending rows from earlier daemon runs reference an outdated - // baseline. Per the replay model, blocked_conflict and failed are - // terminal seq barriers and PendingEvents hides later pending rows - // behind prior terminal rows for the same (branch_ref, generation). If - // these stale pending rows replay first as blocked/failed, they will - // stop our newly captured events from making progress. Drop them up - // front, scoped to the active branch+generation (not generation-only, - // which would also nuke rows on co-existing refs). - dropped, err := state.DeletePendingForBranchGeneration(ctx, db, branchRef, gen) - if err != nil { - return fmt.Errorf("acd commit-all: drop stale pending events: %w", err) - } - checker := git.NewIgnoreChecker(repo) defer func() { _ = checker.Close() }() sensitive := state.NewSensitiveMatcher() @@ -204,6 +410,9 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str // the mid-pass cap; using a typed CaptureOpts field keeps the daemon's // process-wide invariant (DefaultMaxPendingEvents) untouched and // avoids racing the env with concurrent goroutines. + if err := guard.check(ctx, commitAllStageCapture, head); err != nil { + return err + } if _, err := daemon.Capture(ctx, repo, db, cctx, daemon.CaptureOpts{ IgnoreChecker: checker, SensitiveMatcher: sensitive, @@ -215,47 +424,14 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str return fmt.Errorf("acd commit-all: capture: %w", err) } - pending, err := state.PendingEvents(ctx, db, 0) + pendingCount, err := countCommitAllUnpublishedPair(ctx, db, cctx) if err != nil { return fmt.Errorf("acd commit-all: count pending: %w", err) } - pendingCount := len(pending) - strategy, err := ResolveEffectiveCommitStrategy(ctx, db.SQL()) - if err != nil { - return fmt.Errorf("acd commit-all: resolve strategy: %w", err) - } - cfg := ai.LoadProviderConfigFromEnv() - cfg.CommitStrategy = strategy - provider, providerCloser, err := ai.BuildProvider(cfg) - if err != nil { - return fmt.Errorf("acd commit-all: build provider: %w", err) - } - if providerCloser != nil { - defer func() { _ = providerCloser.Close() }() - } - - estimated := commitAllEstimatePasses(strategy, pendingCount, cfg.IntentWindow) - - res := commitAllResult{ - OK: true, - Repo: repo, - BranchRef: branchRef, - HeadBefore: head, - Strategy: string(strategy), - Provider: ai.PrimaryProviderName(provider), - IntentWindow: cfg.IntentWindow, - IntentDeferLim: cfg.IntentDeferLimit, - PendingBefore: pendingCount, - EstimatedPass: estimated, - DryRun: dryRun, - DroppedStalePending: dropped, - } - if dropped > 0 { - res.Notes = append(res.Notes, fmt.Sprintf("shadow reseeded from HEAD; %d stale pending events dropped", dropped)) - } else { - res.Notes = append(res.Notes, "shadow reseeded from HEAD") - } + res.PendingBefore = pendingCount + res.EstimatedPass = commitAllEstimatePasses(strategy, pendingCount, cfg.IntentWindow) + res.Notes = append(res.Notes, "shadow reseeded from HEAD") if pendingCount == 0 { res.Notes = append(res.Notes, "no pending events; worktree already clean") @@ -263,56 +439,159 @@ func runCommitAll(ctx context.Context, out io.Writer, in io.Reader, repoFlag str return renderCommitAll(out, res, jsonOut) } - if dryRun { - previewIntentDryRun(ctx, repo, db, cctx, strategy, cfg, provider, &res) - res.DurationMillis = time.Since(start).Milliseconds() - return renderCommitAll(out, res, jsonOut) + commits, drained, conflicts, failed, after, err := commitAllReplayLoop(ctx, repo, gitDir, db, cctx, strategy, cfg, provider, pendingCount, &res.Notes, &guard) + res.Commits = commits + res.Drained = drained + res.Conflicts = conflicts + res.Failed = failed + res.PendingAfter = after + res.DurationMillis = time.Since(start).Milliseconds() + return finishCommitAllReplay(ctx, out, repo, db, cctx, res, jsonOut, err) +} + +type commitAllPreflight struct { + Generation int64 + Strategy ai.CommitStrategy + ExistingPairs []commitAllExistingPair + ExistingCount int + WorktreeChanges int +} + +// inspectCommitAllPreflight gathers enough information to render a dry-run or +// confirmation prompt without opening the writable state handle. A missing +// state DB is a valid cold-start case and contributes no queued events. +func inspectCommitAllPreflight( + ctx context.Context, + repo, stateDB, branchRef, head string, +) (commitAllPreflight, error) { + preflight := commitAllPreflight{Generation: 1} + strategy, err := ResolveEffectiveCommitStrategy(ctx, nil) + if err != nil { + return commitAllPreflight{}, fmt.Errorf("acd commit-all: resolve strategy: %w", err) } + preflight.Strategy = strategy - if !yes { - if jsonOut { - return errors.New("acd commit-all: --json requires --yes (no interactive prompt available)") + if _, statErr := os.Stat(stateDB); statErr == nil { + conn, openErr := openStateDBReadOnly(ctx, stateDB) + if openErr != nil { + return commitAllPreflight{}, fmt.Errorf("acd commit-all: open state.db read-only: %w", openErr) } - ok, err := promptCommitAllConfirm(out, in, res) + defer func() { _ = conn.Close() }() + + preflight.Generation, err = loadCommitAllGenerationSQL(ctx, conn) if err != nil { - return err + return commitAllPreflight{}, err } - if !ok { - res.Notes = append(res.Notes, "aborted by user") - res.OK = false - res.DurationMillis = time.Since(start).Milliseconds() - if rerr := renderCommitAll(out, res, jsonOut); rerr != nil { - return rerr + cctx := daemon.CaptureContext{ + BranchRef: branchRef, + BranchGeneration: preflight.Generation, + BaseHead: head, + } + preflight.ExistingPairs, err = inspectCommitAllExistingPairsSQL(ctx, conn, cctx) + if err != nil { + return commitAllPreflight{}, err + } + preflight.ExistingCount = commitAllExistingPairCount(preflight.ExistingPairs) + preflight.Strategy, err = ResolveEffectiveCommitStrategy(ctx, conn) + if err != nil { + return commitAllPreflight{}, fmt.Errorf("acd commit-all: resolve strategy: %w", err) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return commitAllPreflight{}, fmt.Errorf("acd commit-all: stat state.db: %w", statErr) + } + + preflight.WorktreeChanges, err = estimateCommitAllWorktreeChanges(ctx, repo) + if err != nil { + return commitAllPreflight{}, err + } + return preflight, nil +} + +// estimateCommitAllWorktreeChanges counts tracked worktree differences from +// HEAD plus untracked, non-ignored paths. It reads Git only; capture remains the +// authority for the exact event count after the operator authorizes mutation. +func estimateCommitAllWorktreeChanges(ctx context.Context, repo string) (int, error) { + tracked, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "diff", "--no-ext-diff", "--name-only", "-z", "HEAD", "--") + if err != nil { + return 0, fmt.Errorf("acd commit-all: estimate tracked worktree changes: %w", err) + } + untracked, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "ls-files", "--others", "--exclude-standard", "-z", "--") + if err != nil { + return 0, fmt.Errorf("acd commit-all: estimate untracked worktree changes: %w", err) + } + + sensitive := state.NewSensitiveMatcher() + safeIgnore := state.NewSafeIgnoreMatcher() + paths := make(map[string]struct{}) + add := func(raw []byte) { + for _, rel := range strings.Split(string(raw), "\x00") { + if rel == "" || sensitive.Match(rel) || safeIgnore.MatchFile(rel) { + continue } - // Return a sentinel error so cobra exits non-zero. Scripts - // can grep for "aborted by user" in JSON output and rely - // on the exit code matching the human signal. - return errCommitAllAborted + paths[rel] = struct{}{} } } - res.Confirmed = true + add(tracked) + add(untracked) + return len(paths), nil +} + +func commitAllDryRunPrefix(dryRun bool) string { + if dryRun { + return "dry-run: " + } + return "" +} + +func commitAllConfiguredProviderName(cfg ai.ProviderConfig) string { + switch { + case cfg.Mode == "openai-compat" && cfg.APIKey != "": + return "openai-compat" + case strings.HasPrefix(cfg.Mode, "subprocess:") && + strings.TrimSpace(strings.TrimPrefix(cfg.Mode, "subprocess:")) != "": + return cfg.Mode + default: + return "deterministic" + } +} - commits, drained, conflicts, failed, after, err := commitAllReplayLoop(ctx, repo, gitDir, db, cctx, strategy, cfg, provider, pendingCount) +func revalidateCommitAllMutationAnchor( + ctx context.Context, + repo, gitDir, expectedBranch, expectedHead string, +) error { + branchRef, err := git.RunBranchRef(ctx, repo) if err != nil { - return err + return fmt.Errorf("acd commit-all: recheck HEAD branch: %w", err) } - res.Commits = commits - res.Drained = drained - res.Conflicts = conflicts - res.Failed = failed - res.PendingAfter = after - if newHead, herr := git.RevParse(ctx, repo, "HEAD"); herr == nil { - res.HeadAfter = newHead - } else { - slog.Default().Warn("acd commit-all: post-loop HEAD lookup failed", slog.String("err", herr.Error())) - res.Notes = append(res.Notes, fmt.Sprintf("post-loop HEAD lookup failed: %v", herr)) + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + return fmt.Errorf("acd commit-all: recheck HEAD: %w", err) } - res.DurationMillis = time.Since(start).Milliseconds() - return renderCommitAll(out, res, jsonOut) + if branchRef != expectedBranch || head != expectedHead { + return fmt.Errorf( + "acd commit-all: refusing because HEAD changed after preflight (%s @ %s -> %s @ %s)", + expectedBranch, shortenSHA(expectedHead), branchRef, shortenSHA(head), + ) + } + if name, active := daemon.GitOperationInProgress(gitDir); active { + return fmt.Errorf("acd commit-all: refusing while git operation %q is in progress", name) + } + if _, present, err := pausepkg.Read(gitDir); err != nil { + return fmt.Errorf("acd commit-all: recheck pause marker: %w", err) + } else if present { + return fmt.Errorf("acd commit-all: refusing while manual pause marker is present at %s; run `acd resume` first", pausepkg.Path(gitDir)) + } + return nil } func loadCommitAllGeneration(ctx context.Context, db *state.DB) (int64, error) { - v, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyBranchGeneration) + return loadCommitAllGenerationSQL(ctx, db.ReadSQL()) +} + +func loadCommitAllGenerationSQL(ctx context.Context, conn *sql.DB) (int64, error) { + v, ok, err := metaLookup(ctx, conn, daemon.MetaKeyBranchGeneration) if err != nil { return 0, fmt.Errorf("acd commit-all: load branch generation: %w", err) } @@ -334,6 +613,97 @@ func loadCommitAllGeneration(ctx context.Context, db *state.DB) (int64, error) { return parsed, nil } +type commitAllExistingPair struct { + BranchRef string + Generation int64 + FirstSeq int64 + EventCount int + Active bool +} + +func inspectCommitAllExistingPairs( + ctx context.Context, + db *state.DB, + cctx daemon.CaptureContext, +) ([]commitAllExistingPair, error) { + return inspectCommitAllExistingPairsSQL(ctx, db.ReadSQL(), cctx) +} + +func inspectCommitAllExistingPairsSQL( + ctx context.Context, + conn *sql.DB, + cctx daemon.CaptureContext, +) ([]commitAllExistingPair, error) { + rows, err := conn.QueryContext(ctx, ` +SELECT branch_ref, branch_generation, MIN(seq), COUNT(*) +FROM capture_events +WHERE state IN (?, ?, ?) +GROUP BY branch_ref, branch_generation +ORDER BY MIN(seq) ASC`, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed, + ) + if err != nil { + return nil, fmt.Errorf("acd commit-all: inspect pre-existing unpublished pairs: %w", err) + } + defer rows.Close() + var pairs []commitAllExistingPair + for rows.Next() { + var pair commitAllExistingPair + if err := rows.Scan(&pair.BranchRef, &pair.Generation, &pair.FirstSeq, &pair.EventCount); err != nil { + return nil, fmt.Errorf("acd commit-all: scan pre-existing unpublished pair: %w", err) + } + pair.Active = pair.BranchRef == cctx.BranchRef && pair.Generation == cctx.BranchGeneration + pairs = append(pairs, pair) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("acd commit-all: iterate pre-existing unpublished pairs: %w", err) + } + return pairs, nil +} + +func commitAllExistingPairCount(pairs []commitAllExistingPair) int { + total := 0 + for _, pair := range pairs { + total += pair.EventCount + } + return total +} + +func reconcileCommitAllExistingPair( + ctx context.Context, + repo, gitDir string, + db *state.DB, + pair commitAllExistingPair, +) (daemon.RecoveryChainResult, error) { + result, err := daemon.ReconcileUnpublishedChain(ctx, repo, db, daemon.RecoveryReconcileOptions{ + GitDir: gitDir, + BranchRef: pair.BranchRef, + BranchGeneration: pair.Generation, + FirstSeq: pair.FirstSeq, + Trigger: "commit_all_preflight", + InvalidateShadow: pair.Active, + }) + if err != nil { + return daemon.RecoveryChainResult{}, fmt.Errorf("acd commit-all: preserve pre-existing unpublished pair %s generation %d: %w", pair.BranchRef, pair.Generation, err) + } + return result, nil +} + +func countCommitAllUnpublishedPair(ctx context.Context, db *state.DB, cctx daemon.CaptureContext) (int, error) { + var count int + if err := db.ReadSQL().QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND state IN (?, ?, ?)`, cctx.BranchRef, cctx.BranchGeneration, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed, + ).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + func commitAllEstimatePasses(strategy ai.CommitStrategy, pending, window int) int { if pending <= 0 { return 0 @@ -387,8 +757,10 @@ func commitAllReplayLoop( cfg ai.ProviderConfig, provider ai.Provider, startingPending int, + noteSink *[]string, + guard *commitAllMutationGuard, ) (commits, drained, conflicts, failed, after int, err error) { - return commitAllReplayLoopWith(ctx, repo, gitDir, db, cctx, strategy, cfg, provider, startingPending, commitAllRunReplayDefault, nil) + return commitAllReplayLoopWithSafety(ctx, repo, gitDir, db, cctx, strategy, cfg, provider, startingPending, commitAllRunReplayDefault, noteSink, guard) } // commitAllReplayLoopWith is the testable form of commitAllReplayLoop; @@ -407,8 +779,28 @@ func commitAllReplayLoopWith( startingPending int, replayFn commitAllReplayer, noteSink *[]string, +) (commits, drained, conflicts, failed, after int, err error) { + return commitAllReplayLoopWithSafety( + ctx, repo, gitDir, db, cctx, strategy, cfg, provider, + startingPending, replayFn, noteSink, nil, + ) +} + +func commitAllReplayLoopWithSafety( + ctx context.Context, + repo, gitDir string, + db *state.DB, + cctx daemon.CaptureContext, + strategy ai.CommitStrategy, + cfg ai.ProviderConfig, + provider ai.Provider, + startingPending int, + replayFn commitAllReplayer, + noteSink *[]string, + guard *commitAllMutationGuard, ) (commits, drained, conflicts, failed, after int, err error) { zeroProgress := 0 + recapturePasses := 0 prevHead := cctx.BaseHead // Provider-driven message fn matches what the daemon's run loop @@ -420,20 +812,53 @@ func commitAllReplayLoopWith( msgFn = daemon.ProviderMessageFn(provider, repo) } - var planner ai.IntentPlanner + var ( + planner ai.IntentPlanner + intentHealth *daemon.IntentPlannerHealth + plannerProvider string + plannerModel string + ) if strategy == ai.CommitStrategyIntent { if p, ok := provider.(ai.IntentPlanner); ok { planner = p + } else { + planner = ai.DeterministicProvider{CommitFormat: cfg.CommitFormat} + } + plannerProvider = ai.PrimaryProviderName(planner) + if plannerProvider == "openai-compat" { + plannerModel = cfg.Model } } for { + if guard != nil { + if safetyErr := guard.check(ctx, commitAllStageReplay, cctx.BaseHead); safetyErr != nil { + err = safetyErr + return + } + } + // Construct one circuit for this commit-all process after the first + // pass safety check succeeds. Reusing it in every ReplayOpts prevents a + // failing remote planner from being retried on each bounded pass. + if strategy == ai.CommitStrategyIntent && intentHealth == nil { + intentHealth = daemon.NewIntentPlannerHealth(ctx, db, daemon.IntentPlannerHealthOptions{ + Provider: daemon.IntentPlannerProviderIdentity{ + Provider: plannerProvider, + Model: plannerModel, + Endpoint: cfg.BaseURL, + Deterministic: plannerProvider == (ai.DeterministicProvider{}).Name(), + }, + }) + } opts := daemon.ReplayOpts{ GitDir: gitDir, Limit: daemon.DefaultReplayLimit, MessageFn: msgFn, CommitStrategy: strategy, IntentPlanner: planner, + IntentHealth: intentHealth, + IntentPlannerProvider: plannerProvider, + IntentPlannerModel: plannerModel, IntentWindow: cfg.IntentWindow, IntentMinPending: cfg.IntentMinPending, IntentMaxPendingAge: cfg.IntentMaxPendingAge, @@ -442,13 +867,13 @@ func commitAllReplayLoopWith( IntentBypassBatchWait: true, } sum, rerr := replayFn(ctx, repo, db, cctx, opts) + commits += sum.Published + conflicts += sum.Conflicts + failed += sum.Failed if rerr != nil { err = fmt.Errorf("acd commit-all: replay: %w", rerr) return } - commits += sum.Published - conflicts += sum.Conflicts - failed += sum.Failed // Refresh BaseHead so the next pass sees the just-committed HEAD. // A failure here is fatal: a stale BaseHead would target the // wrong CAS base and produce spurious supersede / conflict @@ -459,15 +884,34 @@ func commitAllReplayLoopWith( return } cctx.BaseHead = newHead + if sum.RecaptureRequired { + recapturePasses++ + if recapturePasses > commitAllRecaptureLimit { + err = fmt.Errorf("acd commit-all: recovery recapture did not converge after %d passes; captured work remains protected in recovery refs", commitAllRecaptureLimit) + return + } + captureSummary, captureErr := recaptureCommitAllWorktreeWithSafety(ctx, repo, gitDir, db, cctx, guard) + if captureErr != nil { + err = captureErr + return + } + if noteSink != nil { + *noteSink = append(*noteSink, fmt.Sprintf( + "recovery invalidated shadow; recaptured %d event(s) before continuing", + captureSummary.EventsAppended)) + } + } - remaining, perr := state.PendingEvents(ctx, db, 0) + remaining, perr := countCommitAllUnpublishedPair(ctx, db, cctx) if perr != nil { err = fmt.Errorf("acd commit-all: count pending after pass: %w", perr) return } - after = len(remaining) + after = remaining - if sum.Published == 0 && cctx.BaseHead == prevHead { + if sum.RecaptureRequired { + zeroProgress = 0 + } else if sum.Published == 0 && cctx.BaseHead == prevHead { zeroProgress++ } else { zeroProgress = 0 @@ -483,7 +927,7 @@ func commitAllReplayLoopWith( } break } - if sum.Conflicts > 0 || sum.Failed > 0 { + if !sum.RecaptureRequired && (sum.Conflicts > 0 || sum.Failed > 0) { break } } @@ -496,69 +940,146 @@ func commitAllReplayLoopWith( return } -func previewIntentDryRun( +func recaptureCommitAllWorktreeWithSafety( ctx context.Context, - repo string, + repo, gitDir string, db *state.DB, cctx daemon.CaptureContext, - strategy ai.CommitStrategy, - cfg ai.ProviderConfig, - provider ai.Provider, - res *commitAllResult, -) { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: %d events would be processed", res.PendingBefore)) - if strategy != ai.CommitStrategyIntent { - return + guard *commitAllMutationGuard, +) (daemon.CaptureSummary, error) { + if guard != nil { + if err := guard.check(ctx, commitAllStageRecaptureReseed, cctx.BaseHead); err != nil { + return daemon.CaptureSummary{}, err + } } - planner, ok := provider.(ai.IntentPlanner) - if !ok { - res.Notes = append(res.Notes, "dry-run: provider does not implement intent planning; would fall back to deterministic single-event grouping") - return + if _, err := daemon.ReseedShadowFromHead(ctx, repo, db, cctx); err != nil { + return daemon.CaptureSummary{}, fmt.Errorf("acd commit-all: reseed shadow after recovery: %w", err) } - // Refuse to call a network provider during dry-run. Users reasonably - // expect --dry-run to be airgapped; the planner request still leaks - // captured paths/ops to the configured AI endpoint even though no - // diff egress is involved. Skip the planner peek when the provider - // declares NeedsDiff (network-bound) or is anything other than the - // always-local deterministic provider. - mode := strings.TrimSpace(strings.ToLower(cfg.Mode)) - if ai.ProviderNeedsDiff(provider) || (mode != "" && mode != "deterministic") { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: planner peek skipped (network provider %q; would call out otherwise)", ai.PrimaryProviderName(provider))) - return + checker := git.NewIgnoreChecker(repo) + defer func() { _ = checker.Close() }() + if guard != nil { + if err := guard.check(ctx, commitAllStageRecaptureCapture, cctx.BaseHead); err != nil { + return daemon.CaptureSummary{}, err + } } - pending, err := state.PendingEvents(ctx, db, cfg.IntentWindow) - if err != nil || len(pending) == 0 { - return + summary, err := daemon.Capture(ctx, repo, db, cctx, daemon.CaptureOpts{ + IgnoreChecker: checker, + SensitiveMatcher: state.NewSensitiveMatcher(), + SafeIgnoreMatcher: state.NewSafeIgnoreMatcher(), + GitDir: gitDir, + SortByPath: true, + DisablePendingCap: true, + }) + if err != nil { + return daemon.CaptureSummary{}, fmt.Errorf("acd commit-all: recapture after recovery: %w", err) } - offered := make([]ai.OfferedCapture, 0, len(pending)) - for _, ev := range pending { - offered = append(offered, ai.OfferedCapture{ - Seq: ev.Seq, - Path: ev.Path, - Op: ev.Operation, - Timestamp: time.Unix(0, int64(ev.CapturedTS*1e9)), - Fidelity: ev.Fidelity, - }) - } - req, rerr := ai.NewIntentPlanRequest(ai.IntentPlanRequestOptions{OfferedCaptures: offered}) - if rerr != nil { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: build planner request: %v", rerr)) - return + return summary, nil +} + +func finishCommitAll(out io.Writer, res commitAllResult, jsonOut bool) error { + if res.PendingAfter == 0 { + return renderCommitAll(out, res, jsonOut) } - plan, perr := planner.PlanIntent(ctx, req) - if perr != nil { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: planner preview failed: %v", perr)) + + res.OK = false + res.Incomplete = true + res.Notes = append(res.Notes, fmt.Sprintf( + "commit-all stopped incomplete with pending=%d conflicts=%d failed=%d; captured work remains protected", + res.PendingAfter, res.Conflicts, res.Failed, + )) + if err := renderCommitAll(out, res, jsonOut); err != nil { + return err + } + return &commitAllIncompleteError{ + PendingAfter: res.PendingAfter, + Conflicts: res.Conflicts, + Failed: res.Failed, + } +} + +func appendCommitAllRecoveryResult(res *commitAllResult, result daemon.RecoveryChainResult) { + if res == nil { return } - if len(plan.SelectedSeqs) > 0 { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: planner would select %d capture(s) for the next commit", len(plan.SelectedSeqs))) + res.RecoveryRefs = append(res.RecoveryRefs, result.RecoveryRef) + res.Notes = append(res.Notes, fmt.Sprintf( + "preserved %d pre-existing event(s) as %s at %s", + result.EventCount, result.Outcome, result.RecoveryRef)) +} + +func finishCommitAllRecoveryError( + ctx context.Context, + out io.Writer, + repo string, + db *state.DB, + cctx daemon.CaptureContext, + res commitAllResult, + jsonOut bool, + recoveryErr error, +) error { + pairs, err := inspectCommitAllExistingPairs(ctx, db, cctx) + if err != nil { + res.Notes = append(res.Notes, fmt.Sprintf("post-error unpublished recount failed: %v", err)) + } else { + res.PendingAfter = commitAllExistingPairCount(pairs) + if res.PendingBefore >= res.PendingAfter { + res.Drained = res.PendingBefore - res.PendingAfter + } } - if len(plan.DeferredSeqs) > 0 { - res.Notes = append(res.Notes, fmt.Sprintf("dry-run: planner would defer %d capture(s)", len(plan.DeferredSeqs))) + if newHead, err := git.RevParse(ctx, repo, "HEAD"); err == nil { + res.HeadAfter = newHead + } else { + res.Notes = append(res.Notes, fmt.Sprintf("post-error HEAD lookup failed: %v", err)) + } + res.OK = false + res.Incomplete = true + res.Error = recoveryErr.Error() + res.Notes = append(res.Notes, + "pre-existing chain recovery stopped with an error; completed recovery refs remain valid and remaining captures are unchanged") + if err := renderCommitAll(out, res, jsonOut); err != nil { + return err + } + return &commitAllRecoveryError{Cause: recoveryErr, PendingAfter: res.PendingAfter} +} + +func finishCommitAllReplay( + ctx context.Context, + out io.Writer, + repo string, + db *state.DB, + cctx daemon.CaptureContext, + res commitAllResult, + jsonOut bool, + replayErr error, +) error { + if replayErr != nil { + pending, err := countCommitAllUnpublishedPair(ctx, db, cctx) + if err != nil { + res.Notes = append(res.Notes, fmt.Sprintf("post-error pending recount failed: %v", err)) + } else { + res.PendingAfter = pending + if res.PendingBefore >= pending { + res.Drained = res.PendingBefore - pending + } + } + res.OK = false + res.Incomplete = true + res.Error = replayErr.Error() + res.Notes = append(res.Notes, "replay stopped with an error; captured work remains protected") } - if subj := strings.TrimSpace(plan.Subject); subj != "" { - res.Notes = append(res.Notes, "dry-run: planner subject preview: "+subj) + if newHead, err := git.RevParse(ctx, repo, "HEAD"); err == nil { + res.HeadAfter = newHead + } else { + slog.Default().Warn("acd commit-all: post-loop HEAD lookup failed", slog.String("err", err.Error())) + res.Notes = append(res.Notes, fmt.Sprintf("post-loop HEAD lookup failed: %v", err)) + } + if replayErr == nil { + return finishCommitAll(out, res, jsonOut) + } + if err := renderCommitAll(out, res, jsonOut); err != nil { + return err } + return &commitAllReplayError{Cause: replayErr, PendingAfter: res.PendingAfter} } func renderCommitAll(out io.Writer, res commitAllResult, jsonOut bool) error { @@ -569,6 +1090,10 @@ func renderCommitAll(out io.Writer, res commitAllResult, jsonOut bool) error { } if res.DryRun { fmt.Fprintf(out, "commit-all DRY RUN for %s (%s @ %s)\n", res.Repo, res.BranchRef, shortenSHA(res.HeadBefore)) + } else if res.Incomplete { + fmt.Fprintf(out, "commit-all incomplete for %s (%s)\n", res.Repo, res.BranchRef) + } else if !res.OK { + fmt.Fprintf(out, "commit-all stopped for %s (%s)\n", res.Repo, res.BranchRef) } else { fmt.Fprintf(out, "commit-all complete for %s (%s)\n", res.Repo, res.BranchRef) } diff --git a/internal/cli/commitall_test.go b/internal/cli/commitall_test.go index 7c1f606d..fc52ae94 100644 --- a/internal/cli/commitall_test.go +++ b/internal/cli/commitall_test.go @@ -115,6 +115,9 @@ func TestCommitAll_RefusesManualPauseMarker(t *testing.T) { func TestCommitAll_RefusesWhileDaemonLockHeld(t *testing.T) { repo, _, _ := makeRegisteredGitRepoStateDB(t) ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } held, err := daemon.AcquireDaemonLock(filepath.Join(repo, ".git")) if err != nil { t.Fatalf("pre-acquire daemon.lock: %v", err) @@ -131,6 +134,78 @@ func TestCommitAll_RefusesWhileDaemonLockHeld(t *testing.T) { } } +func TestCommitAll_DryRunAllowedWhileDaemonLockHeld(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + held, err := daemon.AcquireDaemonLock(filepath.Join(repo, ".git")) + if err != nil { + t.Fatalf("pre-acquire daemon.lock: %v", err) + } + defer func() { _ = held.Release() }() + + var out bytes.Buffer + if err := runCommitAll(ctx, &out, nil, repo, false, true, true); err != nil { + t.Fatalf("runCommitAll dry-run with daemon.lock held: %v", err) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if !got.OK || !got.DryRun || got.PendingBefore == 0 { + t.Fatalf("unexpected dry-run result: %+v", got) + } +} + +func TestCommitAll_CleanNoOpAllowedWhileDaemonLockHeld(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + held, err := daemon.AcquireDaemonLock(filepath.Join(repo, ".git")) + if err != nil { + t.Fatalf("pre-acquire daemon.lock: %v", err) + } + defer func() { _ = held.Release() }() + + var out bytes.Buffer + if err := runCommitAll(ctx, &out, nil, repo, true, false, true); err != nil { + t.Fatalf("runCommitAll clean no-op with daemon.lock held: %v", err) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if !got.OK || got.DryRun || got.PendingBefore != 0 { + t.Fatalf("unexpected clean no-op result: %+v", got) + } +} + +func TestCommitAll_DeclineAllowedWhileDaemonLockHeld(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + held, err := daemon.AcquireDaemonLock(filepath.Join(repo, ".git")) + if err != nil { + t.Fatalf("pre-acquire daemon.lock: %v", err) + } + defer func() { _ = held.Release() }() + + var out bytes.Buffer + err = runCommitAll(ctx, &out, strings.NewReader("n\n"), repo, false, false, false) + if !errors.Is(err, errCommitAllAborted) { + t.Fatalf("runCommitAll decline with daemon.lock held: %v", err) + } + if !strings.Contains(out.String(), "aborted by user") { + t.Fatalf("decline output missing abort result: %s", out.String()) + } +} + // TestCommitAll_CleanWorktreeNoOp covers the success path on a clean worktree: // capture finds no events, command exits zero with PendingBefore=0. func TestCommitAll_CleanWorktreeNoOp(t *testing.T) { @@ -157,7 +232,7 @@ func TestCommitAll_CleanWorktreeNoOp(t *testing.T) { // TestCommitAll_DryRunNeverCommits pins that --dry-run leaves HEAD unchanged // even with a dirty worktree. func TestCommitAll_DryRunNeverCommits(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) _ = db.Close() ctx := context.Background() if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("dirty\n"), 0o644); err != nil { @@ -167,6 +242,11 @@ func TestCommitAll_DryRunNeverCommits(t *testing.T) { if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } + dbBefore, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum state.db before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) var out bytes.Buffer if err := runCommitAll(ctx, &out, nil, repo, true, true, true); err != nil { @@ -186,26 +266,81 @@ func TestCommitAll_DryRunNeverCommits(t *testing.T) { if headAfter != headBefore { t.Fatalf("dry-run mutated HEAD: before=%s after=%s", headBefore, headAfter) } + dbAfter, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum state.db after: %v", err) + } + if dbAfter != dbBefore { + t.Fatalf("dry-run mutated state.db: before=%s after=%s", dbBefore, dbAfter) + } + if refsAfter := commitAllRecoveryRefs(t, ctx, repo); refsAfter != refsBefore { + t.Fatalf("dry-run mutated recovery refs: before=%q after=%q", refsBefore, refsAfter) + } +} + +func TestCommitAll_PreviewAndDeclineDoNotBuildProvider(t *testing.T) { + for _, tc := range []struct { + name string + yes bool + dryRun bool + input string + }{ + {name: "dry-run", yes: true, dryRun: true}, + {name: "decline", input: "n\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("ACD_AI_PROVIDER", "openai-compat") + t.Setenv("ACD_AI_API_KEY", "sk-test") + t.Setenv("ACD_AI_BASE_URL", "http://insecure.example/v1") + repo, _, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + + var out bytes.Buffer + err := runCommitAll(context.Background(), &out, strings.NewReader(tc.input), repo, tc.yes, tc.dryRun, false) + if tc.dryRun && err != nil { + t.Fatalf("dry-run built invalid provider: %v", err) + } + if !tc.dryRun && !errors.Is(err, errCommitAllAborted) { + t.Fatalf("decline built invalid provider: %v", err) + } + }) + } } // TestCommitAll_JSONRequiresYesWhenInteractive pins that --json without --yes // refuses because there is no interactive prompt available. func TestCommitAll_JSONRequiresYesWhenInteractive(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) _ = db.Close() ctx := context.Background() if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("dirty\n"), 0o644); err != nil { t.Fatalf("write dirty file: %v", err) } + dbBefore, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) var out bytes.Buffer - err := runCommitAll(ctx, &out, nil, repo, false, false, true) + err = runCommitAll(ctx, &out, nil, repo, false, false, true) if err == nil { t.Fatalf("expected --json without --yes to refuse") } if !strings.Contains(err.Error(), "--yes") { t.Fatalf("expected --yes prompt error, got: %v", err) } + dbAfter, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + if dbAfter != dbBefore || commitAllRecoveryRefs(t, ctx, repo) != refsBefore { + t.Fatalf("--json refusal mutated state: db %s->%s refs %q->%q", + dbBefore, dbAfter, refsBefore, commitAllRecoveryRefs(t, ctx, repo)) + } } // TestCommitAll_RefusesAllGitOperationMarkers covers every marker @@ -428,59 +563,9 @@ func TestCommitAllEstimatePasses_Boundaries(t *testing.T) { } } -// TestPreviewIntentDryRun_NonIntentNoPlannerCalls confirms previewIntentDryRun -// is a no-op planner-wise when strategy is event: it adds the standard -// "would be processed" note and returns without consulting the planner. -func TestPreviewIntentDryRun_EventStrategyAddsBaseNoteOnly(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - // Dirty the worktree and let runCommitAll do bootstrap + capture so - // pending > 0, then call previewIntentDryRun directly. - if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\n"), 0o644); err != nil { - t.Fatalf("write dirty: %v", err) - } - gitDir := filepath.Join(repo, ".git") - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } - cctx := daemon.CaptureContext{BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head} - if _, err := daemon.BootstrapShadow(ctx, repo, db, cctx); err != nil { - t.Fatalf("BootstrapShadow: %v", err) - } - checker := git.NewIgnoreChecker(repo) - defer func() { _ = checker.Close() }() - if _, err := daemon.Capture(ctx, repo, db, cctx, daemon.CaptureOpts{ - IgnoreChecker: checker, - SensitiveMatcher: state.NewSensitiveMatcher(), - GitDir: gitDir, - }); err != nil { - t.Fatalf("Capture: %v", err) - } - - cfg := ai.LoadProviderConfigFromEnv() - provider, closer, err := ai.BuildProvider(cfg) - if err != nil { - t.Fatalf("BuildProvider: %v", err) - } - if closer != nil { - defer func() { _ = closer.Close() }() - } - - res := commitAllResult{PendingBefore: 1, Strategy: string(ai.CommitStrategyEvent)} - previewIntentDryRun(ctx, repo, db, cctx, ai.CommitStrategyEvent, cfg, provider, &res) - if len(res.Notes) != 1 { - t.Fatalf("event strategy should add exactly one base note, got: %+v", res.Notes) - } - if !strings.Contains(res.Notes[0], "would be processed") { - t.Fatalf("note format changed: %q", res.Notes[0]) - } -} - // TestCommitAll_DryRunWithPendingPreservesHEAD asserts that even when the -// worktree is dirty and pending > 0, --dry-run does NOT mutate HEAD or -// touch capture_events state beyond the normal capture pass. +// worktree is dirty and the read-only estimate is non-zero, --dry-run does +// not mutate HEAD or publish commits. func TestCommitAll_DryRunWithPendingPreservesHEAD(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) _ = db.Close() @@ -528,119 +613,273 @@ func TestCommitAll_DryRunWithPendingPreservesHEAD(t *testing.T) { } } -// TestCommitAll_ReseedsStaleShadowAndDropsStalePending exercises the -// real-world bug: the daemon previously captured edits into shadow_paths -// without successful replay, so the bootstrap marker is set AND the shadow -// already mirrors live worktree. A stale pending event from that session is -// also still on disk. Without the fix, commit-all skipped reseed (marker -// present) and Capture saw zero diff -> "0 pending, no commits". With the -// fix, commit-all force-reseeds shadow from HEAD, drops the stale pending -// row, then captures a real diff against HEAD. We assert dry-run reports -// dropped_stale_pending > 0 and pending_before > 0. -func TestCommitAll_ReseedsStaleShadowAndDropsStalePending(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) +func TestCommitAll_DryRunReportsPreexistingPairWithoutReconciling(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + _ = db.Close() + before, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + + var out bytes.Buffer + if err := runCommitAll(ctx, &out, nil, repo, true, true, true); err != nil { + t.Fatalf("runCommitAll dry-run: %v\n%s", err, out.String()) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if got.PreservedPending != 2 || got.DroppedStalePending != 0 || len(got.RecoveryRefs) != 0 { + t.Fatalf("dry-run preservation report=%+v", got) + } + if !containsStringWith(got.Notes, "would preserve 2 pre-existing event(s)") { + t.Fatalf("dry-run notes=%v", got.Notes) + } + after, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + if before != after { + t.Fatalf("dry-run reconciled state: before=%s after=%s", before, after) + } + db2, err := state.Open(ctx, stateDB) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db2.Close() + assertFixEventState(t, ctx, db2, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db2, second, state.EventStatePending) + if got := countRowsWhere(t, db2, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("dry-run created recovery snapshot: %d", got) + } +} - // Drop two dirty files so a real reseed-then-capture would see them. +func TestCommitAll_PreservesBarrierThenCommitsDirtyWork(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) if err := os.WriteFile(filepath.Join(repo, "dirty-a.txt"), []byte("aa\n"), 0o644); err != nil { t.Fatalf("write dirty-a: %v", err) } if err := os.WriteFile(filepath.Join(repo, "dirty-b.txt"), []byte("bb\n"), 0o644); err != nil { t.Fatalf("write dirty-b: %v", err) } + _ = db.Close() - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse: %v", err) + var out bytes.Buffer + if err := runCommitAll(ctx, &out, nil, repo, true, false, true); err != nil { + t.Fatalf("runCommitAll apply: %v\n%s", err, out.String()) } - branchRef := "refs/heads/main" - gen := int64(1) - - // Simulate a poisoned shadow: write rows that already mirror live - // worktree blobs, plus the bootstrap completion marker. This is what a - // previous daemon capture pass + failed replay leaves behind. - hashAndStage := func(path, content string) string { - t.Helper() - // Use git hash-object to compute the OID the same way bootstrap - // would after a successful capture absorbed the edit. - out, err := git.Run(ctx, git.RunOpts{Dir: repo}, "hash-object", "-w", path) - if err != nil { - t.Fatalf("hash-object %s: %v", path, err) - } - _ = content - return strings.TrimSpace(string(out)) - } - for _, p := range []string{"dirty-a.txt", "dirty-b.txt"} { - oid := hashAndStage(p, "") - if err := state.UpsertShadowPath(ctx, db, state.ShadowPath{ - BranchRef: branchRef, - BranchGeneration: gen, - Path: p, - Operation: "create", - Mode: sql.NullString{String: "100644", Valid: true}, - OID: sql.NullString{String: oid, Valid: true}, - BaseHead: head, - Fidelity: "full", - }); err != nil { - t.Fatalf("UpsertShadowPath %s: %v", p, err) + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if got.PreservedPending != 2 || got.DroppedStalePending != 0 || len(got.RecoveryRefs) != 1 { + t.Fatalf("preservation result=%+v", got) + } + if got.PendingAfter != 0 || got.Commits < 2 { + t.Fatalf("commit-all did not converge: %+v", got) + } + db2, err := state.Open(ctx, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db2.Close() + assertFixEventState(t, ctx, db2, first, state.EventStateRecovered) + assertFixEventState(t, ctx, db2, second, state.EventStateRecovered) + for _, path := range []string{"dirty-a.txt", "dirty-b.txt"} { + if _, err := git.LsTreeBlobOID(ctx, repo, "HEAD", path); err != nil { + t.Fatalf("HEAD missing %s: %v", path, err) } } - // Mark the (branch_ref, gen) pair as fully bootstrapped — this is the - // idempotency gate that BootstrapShadow checks. Without ReseedShadowFromHead - // the daemon helper short-circuits here. - if err := state.MetaSet(ctx, db, daemon.ShadowBootstrappedKey(branchRef, gen), "1"); err != nil { - t.Fatalf("set bootstrap marker: %v", err) - } - // Seed a stale pending event from a "previous session". - staleSeq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: branchRef, - BranchGeneration: gen, - BaseHead: "stale-base", - Operation: "modify", - Path: "stale-pending.txt", - Fidelity: "full", - }, []state.CaptureOp{{Op: "modify", Path: "stale-pending.txt", Fidelity: "full"}}) + status, err := git.Run(ctx, git.RunOpts{Dir: repo}, "status", "--porcelain") if err != nil { - t.Fatalf("seed stale pending: %v", err) + t.Fatalf("git status: %v", err) } - if staleSeq <= 0 { - t.Fatalf("staleSeq=%d", staleSeq) + if strings.TrimSpace(string(status)) != "" { + t.Fatalf("commit-all left dirty worktree: %s", status) + } +} + +func TestCommitAll_PreservesNonActivePairBeforeActiveDirtyWork(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + staleFirst, staleSecond := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/stale", 4) + if err := os.WriteFile(filepath.Join(repo, "active-dirty.txt"), []byte("active\n"), 0o644); err != nil { + t.Fatalf("write active dirty file: %v", err) } - // Close the test handle before runCommitAll opens its own. _ = db.Close() var out bytes.Buffer - if err := runCommitAll(ctx, &out, nil, repo, true, true, true); err != nil { - t.Fatalf("runCommitAll dry-run: %v", err) + if err := runCommitAll(ctx, &out, nil, repo, true, false, true); err != nil { + t.Fatalf("runCommitAll: %v\n%s", err, out.String()) } var got commitAllResult if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v\n%s", err, out.String()) } + if got.PreservedPending != 2 || len(got.RecoveryRefs) != 1 || got.PendingAfter != 0 || got.Commits < 1 { + t.Fatalf("nonactive preservation result=%+v", got) + } + db2, err := state.Open(ctx, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db2.Close() + assertFixEventState(t, ctx, db2, staleFirst, state.EventStateRecovered) + assertFixEventState(t, ctx, db2, staleSecond, state.EventStateRecovered) + var branchRef string + var generation int64 + if err := db2.SQL().QueryRowContext(ctx, + `SELECT branch_ref, branch_generation FROM capture_events WHERE seq = ?`, staleFirst, + ).Scan(&branchRef, &generation); err != nil { + t.Fatalf("query stale provenance: %v", err) + } + if branchRef != "refs/heads/stale" || generation != 4 { + t.Fatalf("stale pair was retargeted: %s/g%d", branchRef, generation) + } + if _, err := git.LsTreeBlobOID(ctx, repo, "HEAD", "active-dirty.txt"); err != nil { + t.Fatalf("active dirty file not committed: %v", err) + } +} + +func TestCommitAll_PreflightRecoveryLeavesGitStateUntouched(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, _ := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + if err := os.WriteFile(filepath.Join(repo, "staged-user.txt"), []byte("staged\n"), 0o644); err != nil { + t.Fatalf("write staged file: %v", err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "staged-user.txt"); err != nil { + t.Fatalf("git add: %v", err) + } + headBefore, _ := git.RevParse(ctx, repo, "HEAD") + indexBefore, err := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--name-status") + if err != nil { + t.Fatalf("index before: %v", err) + } + worktreeBefore, err := os.ReadFile(filepath.Join(repo, "staged-user.txt")) + if err != nil { + t.Fatalf("read worktree before: %v", err) + } - // Without the fix, PendingBefore would be 0 (bug behavior). With the - // fix, ReseedShadowFromHead nukes the stale shadow, DeletePendingForBranchGeneration - // drops the stale pending row, and Capture re-classifies the dirty - // files as new pending events. - if got.PendingBefore == 0 { - t.Fatalf("PendingBefore=0 reproduces the bug; want >=2 after reseed.\nresult=%+v\nout=%s", got, out.String()) + result, err := reconcileCommitAllExistingPair(ctx, repo, filepath.Join(repo, ".git"), db, commitAllExistingPair{ + BranchRef: "refs/heads/main", Generation: 1, FirstSeq: first, EventCount: 2, Active: true, + }) + if err != nil { + t.Fatalf("reconcile preflight: %v", err) } - if got.PendingBefore < 2 { - t.Fatalf("PendingBefore=%d, want >=2 (two dirty files)", got.PendingBefore) + if !result.Handled || result.Outcome != state.EventStateRecovered || result.RecoveryRef == "" { + t.Fatalf("reconcile result=%+v", result) } - if got.DroppedStalePending < 1 { - t.Fatalf("DroppedStalePending=%d, want >=1 (stale pending row should have been dropped)", got.DroppedStalePending) + headAfter, _ := git.RevParse(ctx, repo, "HEAD") + indexAfter, _ := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--name-status") + worktreeAfter, _ := os.ReadFile(filepath.Join(repo, "staged-user.txt")) + if headAfter != headBefore || string(indexAfter) != string(indexBefore) || string(worktreeAfter) != string(worktreeBefore) { + t.Fatalf("preflight mutated Git state: HEAD %s->%s index %q->%q worktree %q->%q", + headBefore, headAfter, indexBefore, indexAfter, worktreeBefore, worktreeAfter) } - // The "shadow reseeded" note must be present so users see what happened. - gotReseedNote := false - for _, n := range got.Notes { - if strings.Contains(n, "shadow reseeded from HEAD") { - gotReseedNote = true - break +} + +func TestCommitAll_MissingPreexistingObjectFailsClosed(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + seq := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "missing-preflight.txt", Fidelity: "exact", + State: state.EventStateFailed, Error: sql.NullString{String: "missing object", Valid: true}, + }, []state.CaptureOp{{ + Op: "create", Path: "missing-preflight.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("f", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + _ = db.Close() + + var out bytes.Buffer + err = runCommitAll(ctx, &out, nil, repo, true, false, true) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("runCommitAll err=%v want missing-object refusal\n%s", err, out.String()) + } + db2, err := state.Open(ctx, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db2.Close() + assertFixEventState(t, ctx, db2, seq, state.EventStateFailed) + if got := countRowsWhere(t, db2, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("missing object wrote snapshot: %d", got) + } +} + +func TestCommitAll_RecaptureLoopFailsWhenRecoveryDoesNotConverge(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "never-converges.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + calls := 0 + replayFn := func(context.Context, string, *state.DB, daemon.CaptureContext, daemon.ReplayOpts) (daemon.ReplaySummary, error) { + calls++ + return daemon.ReplaySummary{RecaptureRequired: true, BaseHead: head}, nil + } + var notes []string + _, _, _, _, _, err = commitAllReplayLoopWith(ctx, repo, filepath.Join(repo, ".git"), db, daemon.CaptureContext{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + }, ai.CommitStrategyEvent, ai.ProviderConfig{}, nil, 1, replayFn, ¬es) + if err == nil || !strings.Contains(err.Error(), "did not converge") { + t.Fatalf("commitAllReplayLoopWith err=%v want bounded nonconvergence", err) + } + if calls != commitAllRecaptureLimit+1 { + t.Fatalf("replay calls=%d want %d", calls, commitAllRecaptureLimit+1) + } +} + +func TestCommitAll_RecaptureLoopReplaysFreshCapture(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "recaptured.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + calls := 0 + replayFn := func(ctx context.Context, repo string, db *state.DB, cctx daemon.CaptureContext, opts daemon.ReplayOpts) (daemon.ReplaySummary, error) { + calls++ + if calls == 1 { + return daemon.ReplaySummary{RecaptureRequired: true, BaseHead: head}, nil } + return daemon.Replay(ctx, repo, db, cctx, opts) + } + var notes []string + commits, _, conflicts, failed, after, err := commitAllReplayLoopWith( + ctx, repo, filepath.Join(repo, ".git"), db, + daemon.CaptureContext{BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head}, + ai.CommitStrategyEvent, ai.ProviderConfig{}, nil, 1, replayFn, ¬es, + ) + if err != nil { + t.Fatalf("commitAllReplayLoopWith: %v", err) + } + if calls < 2 || commits != 1 || conflicts != 0 || failed != 0 || after != 0 { + t.Fatalf("recapture result calls=%d commits=%d conflicts=%d failed=%d after=%d notes=%v", + calls, commits, conflicts, failed, after, notes) + } + if !containsStringWith(notes, "recaptured 1 event(s)") { + t.Fatalf("recapture note missing: %v", notes) } - if !gotReseedNote { - t.Fatalf("expected 'shadow reseeded from HEAD' note; got: %+v", got.Notes) + if _, err := git.LsTreeBlobOID(ctx, repo, "HEAD", "recaptured.txt"); err != nil { + t.Fatalf("recaptured file not committed: %v", err) } } @@ -659,6 +898,252 @@ func (e *errOnReadReader) Read(p []byte) (int, error) { // errStdinUnexpected is a sentinel returned by errOnReadReader. var errStdinUnexpected = errors.New("stdin must not be read on this path") +type commitAllPromptHookReader struct { + hook func() error + reader *strings.Reader + ran bool +} + +func (r *commitAllPromptHookReader) Read(p []byte) (int, error) { + if !r.ran { + r.ran = true + if err := r.hook(); err != nil { + return 0, err + } + } + return r.reader.Read(p) +} + +func TestCommitAll_RechecksHEADAfterConfirmation(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + dbBefore, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) + reader := &commitAllPromptHookReader{ + reader: strings.NewReader("y\n"), + hook: func() error { + _, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", + "commit", "--allow-empty", "-m", "advance while prompting") + return err + }, + } + + var out bytes.Buffer + err = runCommitAll(ctx, &out, reader, repo, false, false, false) + if err == nil || !strings.Contains(err.Error(), "HEAD changed after preflight") { + t.Fatalf("runCommitAll err=%v want post-prompt HEAD refusal\n%s", err, out.String()) + } + dbAfter, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + if dbAfter != dbBefore || commitAllRecoveryRefs(t, ctx, repo) != refsBefore { + t.Fatalf("post-prompt HEAD refusal mutated ACD state") + } +} + +func TestCommitAll_AcquiresDaemonLockAfterConfirmation(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + dbBefore, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) + var held *daemon.DaemonLock + reader := &commitAllPromptHookReader{ + reader: strings.NewReader("y\n"), + hook: func() error { + var err error + held, err = daemon.AcquireDaemonLock(filepath.Join(repo, ".git")) + return err + }, + } + defer func() { + if held != nil { + _ = held.Release() + } + }() + + var out bytes.Buffer + err = runCommitAll(ctx, &out, reader, repo, false, false, false) + if err == nil || !strings.Contains(err.Error(), "per-repo daemon is alive") { + t.Fatalf("runCommitAll err=%v want post-consent daemon-lock refusal\n%s", err, out.String()) + } + dbAfter, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + if dbAfter != dbBefore || commitAllRecoveryRefs(t, ctx, repo) != refsBefore { + t.Fatalf("daemon-lock refusal mutated ACD state") + } +} + +func TestCommitAll_StopsBeforeRecoveryWhenPauseAppears(t *testing.T) { + t.Setenv(ai.EnvProvider, "deterministic") + t.Setenv(ai.EnvCommitStrategy, string(ai.CommitStrategyEvent)) + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + _ = db.Close() + headBefore, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("HEAD before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) + gitDir := filepath.Join(repo, ".git") + paused := false + hooks := commitAllHooks{BeforeMutationCheck: func(_ context.Context, stage commitAllMutationStage) error { + if stage != commitAllStageRecoveryPair || paused { + return nil + } + paused = true + _, err := pausepkg.Write(pausepkg.Path(gitDir), pausepkg.Marker{ + Reason: "operator paused during commit-all", + SetBy: "test", + }, false) + return err + }} + + var out bytes.Buffer + err = runCommitAllWithHooks(ctx, &out, nil, repo, true, false, true, hooks) + if err == nil || !strings.Contains(err.Error(), "manual pause marker") { + t.Fatalf("runCommitAllWithHooks err=%v want pause refusal\n%s", err, out.String()) + } + if !paused { + t.Fatal("recovery-stage hook did not run") + } + if refsAfter := commitAllRecoveryRefs(t, ctx, repo); refsAfter != refsBefore { + t.Fatalf("pause refusal wrote recovery ref: before=%q after=%q", refsBefore, refsAfter) + } + headAfter, err := git.RevParse(ctx, repo, "HEAD") + if err != nil || headAfter != headBefore { + t.Fatalf("pause refusal changed HEAD: %q -> %q err=%v", headBefore, headAfter, err) + } + dbAfter, err := state.Open(ctx, state.DBPathFromGitDir(gitDir)) + if err != nil { + t.Fatalf("reopen state: %v", err) + } + defer dbAfter.Close() + assertFixEventState(t, ctx, dbAfter, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, dbAfter, second, state.EventStatePending) + if got := countRowsWhere(t, dbAfter, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("pause refusal wrote %d recovery snapshot(s)", got) + } +} + +func TestCommitAll_StopsBeforeCaptureWhenPauseAppears(t *testing.T) { + t.Setenv(ai.EnvProvider, "deterministic") + t.Setenv(ai.EnvCommitStrategy, string(ai.CommitStrategyEvent)) + repo, _, db := makeRegisteredGitRepoStateDB(t) + _ = db.Close() + ctx := context.Background() + if err := os.WriteFile(filepath.Join(repo, "paused-before-capture.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + headBefore, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("HEAD before: %v", err) + } + gitDir := filepath.Join(repo, ".git") + paused := false + hooks := commitAllHooks{BeforeMutationCheck: func(_ context.Context, stage commitAllMutationStage) error { + if stage != commitAllStageCapture || paused { + return nil + } + paused = true + _, err := pausepkg.Write(pausepkg.Path(gitDir), pausepkg.Marker{ + Reason: "operator paused before capture", + SetBy: "test", + }, false) + return err + }} + + var out bytes.Buffer + err = runCommitAllWithHooks(ctx, &out, nil, repo, true, false, true, hooks) + if err == nil || !strings.Contains(err.Error(), "manual pause marker") { + t.Fatalf("runCommitAllWithHooks err=%v want pause refusal", err) + } + if !paused { + t.Fatal("capture-stage hook did not run") + } + dbAfter, err := state.Open(ctx, state.DBPathFromGitDir(gitDir)) + if err != nil { + t.Fatalf("reopen state: %v", err) + } + defer dbAfter.Close() + if got := countRowsWhere(t, dbAfter, "capture_events", "1 = 1"); got != 0 { + t.Fatalf("pause refusal captured %d event(s)", got) + } + headAfter, err := git.RevParse(ctx, repo, "HEAD") + if err != nil || headAfter != headBefore { + t.Fatalf("pause refusal changed HEAD: %q -> %q err=%v", headBefore, headAfter, err) + } + if _, err := os.Stat(filepath.Join(repo, "paused-before-capture.txt")); err != nil { + t.Fatalf("dirty worktree file was not preserved: %v", err) + } +} + +func TestCommitAll_RechecksGitOperationBeforeEveryReplayPass(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("HEAD: %v", err) + } + seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "pending.txt", Fidelity: "full", + }, []state.CaptureOp{{Op: "create", Path: "pending.txt", Fidelity: "full"}}) + if err != nil { + t.Fatalf("seed pending: %v", err) + } + gitDir := filepath.Join(repo, ".git") + replayCalls := 0 + stub := func(context.Context, string, *state.DB, daemon.CaptureContext, daemon.ReplayOpts) (daemon.ReplaySummary, error) { + replayCalls++ + return daemon.ReplaySummary{}, nil + } + checks := 0 + guard := &commitAllMutationGuard{ + repo: repo, gitDir: gitDir, expectedBranch: "refs/heads/main", + hooks: commitAllHooks{BeforeMutationCheck: func(_ context.Context, stage commitAllMutationStage) error { + if stage != commitAllStageReplay { + return nil + } + checks++ + if checks == 2 { + return os.WriteFile(filepath.Join(gitDir, "MERGE_HEAD"), []byte(head+"\n"), 0o600) + } + return nil + }}, + } + _, _, _, _, _, err = commitAllReplayLoopWithSafety( + ctx, repo, gitDir, db, + daemon.CaptureContext{BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head}, + ai.CommitStrategyEvent, ai.ProviderConfig{}, nil, 1, stub, nil, guard, + ) + if err == nil || !strings.Contains(err.Error(), "git operation") { + t.Fatalf("replay loop err=%v want git-operation refusal", err) + } + if replayCalls != 1 { + t.Fatalf("replay called %d times; want one call before marker appeared", replayCalls) + } + assertFixEventState(t, ctx, db, seq, state.EventStatePending) +} + // TestCommitAll_RefusesOrphanBranch covers P1-2: an empty repo with a // branch ref pointing to no commits (orphan branch) used to silently // produce empty BaseHead and crash deep in replay. We now refuse with a @@ -695,16 +1180,22 @@ func TestCommitAll_RefusesOrphanBranch(t *testing.T) { // prompt receives "no" the renderer must still emit a payload, but the // caller must observe a non-nil sentinel error so cobra exits non-zero. func TestCommitAll_UserDeclineExitsNonZero(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - _ = db.Close() + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + _ = db.Close() if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("dirty\n"), 0o644); err != nil { t.Fatalf("write dirty: %v", err) } + dbBefore, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + refsBefore := commitAllRecoveryRefs(t, ctx, repo) var out bytes.Buffer in := strings.NewReader("n\n") - err := runCommitAll(ctx, &out, in, repo, false, false, false) + err = runCommitAll(ctx, &out, in, repo, false, false, false) if err == nil { t.Fatalf("expected non-nil error on user decline; got nil") } @@ -716,6 +1207,235 @@ func TestCommitAll_UserDeclineExitsNonZero(t *testing.T) { if !strings.Contains(out.String(), "aborted by user") { t.Fatalf("decline output missing aborted note: %q", out.String()) } + if strings.Contains(out.String(), "commit-all complete") { + t.Fatalf("decline output claimed completion: %q", out.String()) + } + dbAfter, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + refsAfter := commitAllRecoveryRefs(t, ctx, repo) + if dbAfter != dbBefore || refsAfter != refsBefore { + t.Fatalf("decline mutated state: db %s->%s refs %q->%q", dbBefore, dbAfter, refsBefore, refsAfter) + } + db2, err := state.Open(ctx, stateDB) + if err != nil { + t.Fatalf("reopen state DB: %v", err) + } + defer db2.Close() + assertFixEventState(t, ctx, db2, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db2, second, state.EventStatePending) + if got := countRowsWhere(t, db2, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("decline created recovery snapshot: %d", got) + } +} + +func TestFinishCommitAll_IncompleteReturnsTypedError(t *testing.T) { + res := commitAllResult{ + OK: true, + Repo: "/tmp/repo", + BranchRef: "refs/heads/main", + PendingAfter: 2, + Conflicts: 1, + Failed: 1, + RecoveryRefs: []string{"refs/acd/recovery/example"}, + } + var out bytes.Buffer + err := finishCommitAll(&out, res, true) + var incomplete *commitAllIncompleteError + if !errors.As(err, &incomplete) { + t.Fatalf("finishCommitAll err=%v want *commitAllIncompleteError", err) + } + if incomplete.PendingAfter != 2 || incomplete.Conflicts != 1 || incomplete.Failed != 1 { + t.Fatalf("typed incomplete counts=%+v", incomplete) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if got.OK || !got.Incomplete || got.PendingAfter != 2 || len(got.RecoveryRefs) != 1 { + t.Fatalf("incomplete result=%+v", got) + } + + out.Reset() + if err := finishCommitAll(&out, res, false); err == nil { + t.Fatal("human incomplete result returned nil") + } + if strings.Contains(out.String(), "commit-all complete") || !strings.Contains(out.String(), "commit-all incomplete") { + t.Fatalf("human incomplete wording=%q", out.String()) + } +} + +func TestFinishCommitAll_RecoveredThenDrainedSucceeds(t *testing.T) { + res := commitAllResult{ + OK: true, + Repo: "/tmp/repo", + BranchRef: "refs/heads/main", + PendingAfter: 0, + Conflicts: 1, + Failed: 1, + RecoveryRefs: []string{"refs/acd/recovery/example"}, + } + var out bytes.Buffer + if err := finishCommitAll(&out, res, true); err != nil { + t.Fatalf("recovered-and-drained result: %v", err) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out.String()) + } + if !got.OK || got.Incomplete || got.PendingAfter != 0 || len(got.RecoveryRefs) != 1 { + t.Fatalf("recovered-and-drained result=%+v", got) + } +} + +func TestFinishCommitAllReplay_RendersPartialProgress(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + cctx := daemon.CaptureContext{ + BranchRef: "refs/heads/main", + BranchGeneration: 1, + BaseHead: head, + } + if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: cctx.BranchRef, + BranchGeneration: cctx.BranchGeneration, + BaseHead: head, + Operation: "create", + Path: "still-pending.txt", + Fidelity: "exact", + }, []state.CaptureOp{{ + Op: "create", Path: "still-pending.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("a", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}); err != nil { + t.Fatalf("append pending event: %v", err) + } + replayFailure := errors.New("provider stopped after first commit") + res := commitAllResult{ + OK: true, + Repo: repo, + BranchRef: cctx.BranchRef, + HeadBefore: head, + PendingBefore: 2, + Commits: 1, + Drained: 1, + RecoveryRefs: []string{"refs/acd/recovery/protected"}, + } + var out bytes.Buffer + err = finishCommitAllReplay(ctx, &out, repo, db, cctx, res, true, replayFailure) + var partial *commitAllReplayError + if !errors.As(err, &partial) || !errors.Is(err, replayFailure) { + t.Fatalf("finishCommitAllReplay err=%v want wrapped *commitAllReplayError", err) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("unmarshal partial result: %v\n%s", err, out.String()) + } + if got.OK || !got.Incomplete || got.Error != replayFailure.Error() || + got.PendingAfter != 1 || got.HeadAfter != head || got.Commits != 1 || got.Drained != 1 || + len(got.RecoveryRefs) != 1 { + t.Fatalf("partial replay result=%+v", got) + } +} + +func TestCommitAll_RendersPartialPreexistingRecovery(t *testing.T) { + t.Setenv(ai.EnvProvider, "deterministic") + t.Setenv(ai.EnvCommitStrategy, string(ai.CommitStrategyEvent)) + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + validOIDRaw, err := git.Run(ctx, git.RunOpts{ + Dir: repo, + Stdin: strings.NewReader("protected first pair\n"), + }, "hash-object", "-w", "--stdin") + if err != nil { + t.Fatalf("hash valid recovery object: %v", err) + } + validOID := strings.TrimSpace(string(validOIDRaw)) + firstSeq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/old-first", + BranchGeneration: 2, + BaseHead: head, + Operation: "create", + Path: "first-protected.txt", + Fidelity: "exact", + }, []state.CaptureOp{{ + Op: "create", Path: "first-protected.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: validOID, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + if err != nil { + t.Fatalf("append valid first pair: %v", err) + } + secondSeq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/old-second", + BranchGeneration: 3, + BaseHead: head, + Operation: "create", + Path: "second-missing.txt", + Fidelity: "exact", + }, []state.CaptureOp{{ + Op: "create", Path: "second-missing.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("b", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + if err != nil { + t.Fatalf("append missing-object second pair: %v", err) + } + + var out bytes.Buffer + err = runCommitAll(ctx, &out, strings.NewReader(""), repo, true, false, true) + var partial *commitAllRecoveryError + if !errors.As(err, &partial) { + t.Fatalf("runCommitAll err=%v want *commitAllRecoveryError\n%s", err, out.String()) + } + var got commitAllResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("decode partial recovery result: %v\n%s", err, out.String()) + } + if got.OK || !got.Incomplete || got.PendingBefore != 2 || got.PendingAfter != 1 || + got.Drained != 1 || len(got.RecoveryRefs) != 1 || got.Error == "" { + t.Fatalf("partial recovery result=%+v", got) + } + if !strings.Contains(got.Error, "missing blob object") { + t.Fatalf("partial recovery error=%q", got.Error) + } + var firstState, secondState string + if err := db.ReadSQL().QueryRowContext(ctx, + `SELECT state FROM capture_events WHERE seq = ?`, firstSeq).Scan(&firstState); err != nil { + t.Fatalf("read first pair state: %v", err) + } + if err := db.ReadSQL().QueryRowContext(ctx, + `SELECT state FROM capture_events WHERE seq = ?`, secondSeq).Scan(&secondState); err != nil { + t.Fatalf("read second pair state: %v", err) + } + if firstState != state.EventStateRecovered { + t.Fatalf("first pair state=%q want recovered", firstState) + } + if secondState != state.EventStatePending { + t.Fatalf("second pair state=%q want pending", secondState) + } + if _, err := git.RevParse(ctx, repo, got.RecoveryRefs[0]); err != nil { + t.Fatalf("completed recovery ref is not reachable: %v", err) + } +} + +func commitAllRecoveryRefs(t *testing.T, ctx context.Context, repo string) string { + t.Helper() + out, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "for-each-ref", "--format=%(refname):%(objectname)", "refs/acd/recovery/") + if err != nil { + t.Fatalf("list recovery refs: %v", err) + } + return string(out) } // fakePlannerProvider is an ai.Provider + IntentPlanner whose calls are @@ -727,6 +1447,7 @@ type fakePlannerProvider struct { planCalls int genCalls int planSubject string + planErr error } func (p *fakePlannerProvider) Name() string { return p.name } @@ -737,6 +1458,9 @@ func (p *fakePlannerProvider) Generate(ctx context.Context, cc ai.CommitContext) } func (p *fakePlannerProvider) PlanIntent(ctx context.Context, req ai.IntentPlanRequest) (ai.IntentPlan, error) { p.planCalls++ + if p.planErr != nil { + return ai.IntentPlan{}, p.planErr + } seqs := make([]int64, 0, len(req.OfferedCaptures)) for _, c := range req.OfferedCaptures { seqs = append(seqs, c.Seq) @@ -744,51 +1468,83 @@ func (p *fakePlannerProvider) PlanIntent(ctx context.Context, req ai.IntentPlanR return ai.IntentPlan{SelectedSeqs: seqs, Subject: p.planSubject}, nil } -// TestPreviewIntentDryRun_SkipsNetworkPlanner covers P1-5: dry-run must -// never fan out a planner request to a network-bound provider. -func TestPreviewIntentDryRun_SkipsNetworkPlanner(t *testing.T) { +func TestCommitAllReplayLoop_ReusesPlannerHealthAfterTransportFailure(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\n"), 0o644); err != nil { - t.Fatalf("write dirty: %v", err) - } - gitDir := filepath.Join(repo, ".git") head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("HEAD: %v", err) + } + cctx := daemon.CaptureContext{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, } - cctx := daemon.CaptureContext{BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head} if _, err := daemon.BootstrapShadow(ctx, repo, db, cctx); err != nil { - t.Fatalf("BootstrapShadow: %v", err) + t.Fatalf("bootstrap shadow: %v", err) + } + for _, path := range []string{"transport-a.txt", "transport-b.txt"} { + if err := os.WriteFile(filepath.Join(repo, path), []byte(path+"\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } } checker := git.NewIgnoreChecker(repo) defer func() { _ = checker.Close() }() - if _, err := daemon.Capture(ctx, repo, db, cctx, daemon.CaptureOpts{ - IgnoreChecker: checker, - SensitiveMatcher: state.NewSensitiveMatcher(), - GitDir: gitDir, + if summary, err := daemon.Capture(ctx, repo, db, cctx, daemon.CaptureOpts{ + IgnoreChecker: checker, + SensitiveMatcher: state.NewSensitiveMatcher(), + SafeIgnoreMatcher: state.NewSafeIgnoreMatcher(), + GitDir: filepath.Join(repo, ".git"), + SortByPath: true, + DisablePendingCap: true, }); err != nil { - t.Fatalf("Capture: %v", err) + t.Fatalf("capture: %v", err) + } else if summary.EventsAppended < 2 { + t.Fatalf("captured %d events; want at least two passes", summary.EventsAppended) } + planner := &fakePlannerProvider{ + name: "openai-compat", + planErr: errors.New("planner transport unavailable"), + needsDiff: false, + } cfg := ai.LoadProviderConfigFromEnv() - cfg.Mode = "openai-compat" // pretend a network provider was wired up - provider := &fakePlannerProvider{name: "fake-network", needsDiff: true} - res := commitAllResult{PendingBefore: 1, Strategy: string(ai.CommitStrategyIntent)} - previewIntentDryRun(ctx, repo, db, cctx, ai.CommitStrategyIntent, cfg, provider, &res) - if provider.planCalls != 0 { - t.Fatalf("network provider PlanIntent called %d times during dry-run; want 0", provider.planCalls) - } - // The note must explain why the planner peek was skipped. - found := false - for _, n := range res.Notes { - if strings.Contains(n, "planner peek skipped") { - found = true - break - } + cfg.CommitStrategy = ai.CommitStrategyIntent + cfg.CommitFormat = ai.CommitFormatImperative + cfg.IntentWindow = 1 + cfg.IntentMinPending = 1 + cfg.IntentDeferLimit = 1 + cfg.Model = "test-model" + cfg.BaseURL = "https://user:secret@planner.example/v1?token=hidden" + + commits, _, conflicts, failed, after, err := commitAllReplayLoopWithSafety( + ctx, repo, filepath.Join(repo, ".git"), db, cctx, + ai.CommitStrategyIntent, cfg, planner, 2, + commitAllRunReplayDefault, nil, nil, + ) + if err != nil { + t.Fatalf("commitAllReplayLoopWithSafety: %v", err) + } + if commits < 2 || conflicts != 0 || failed != 0 || after != 0 { + t.Fatalf("replay result commits=%d conflicts=%d failed=%d after=%d", commits, conflicts, failed, after) } - if !found { - t.Fatalf("expected 'planner peek skipped' note; got: %+v", res.Notes) + if planner.planCalls != 1 { + t.Fatalf("primary planner called %d times; want once before circuit bypass", planner.planCalls) + } + raw, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyIntentPlannerHealth) + if err != nil || !ok { + t.Fatalf("load planner health ok=%v err=%v", ok, err) + } + health, err := daemon.DecodeIntentPlannerHealthSnapshot(raw) + if err != nil { + t.Fatalf("decode planner health: %v", err) + } + wantFingerprint := daemon.IntentPlannerProviderFingerprint(daemon.IntentPlannerProviderIdentity{ + Provider: "openai-compat", + Model: cfg.Model, + Endpoint: cfg.BaseURL, + }) + if health.State != daemon.IntentPlannerCircuitOpen || health.BypassCount < 1 || + health.ProviderFingerprint != wantFingerprint { + t.Fatalf("planner health=%+v want open shared circuit fingerprint=%q", health, wantFingerprint) } } @@ -878,6 +1634,30 @@ func TestCommitAllReplayLoop_UsesProviderMessageFn(t *testing.T) { _ = replayCalls } +func TestCommitAllReplayLoop_PreservesPartialSummaryOnError(t *testing.T) { + ctx := context.Background() + repo, _, db := makeRegisteredGitRepoStateDB(t) + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + replayFailure := errors.New("replay stopped after partial pass") + stub := func(context.Context, string, *state.DB, daemon.CaptureContext, daemon.ReplayOpts) (daemon.ReplaySummary, error) { + return daemon.ReplaySummary{Published: 2, Conflicts: 1, Failed: 1}, replayFailure + } + commits, _, conflicts, failed, _, err := commitAllReplayLoopWith( + ctx, repo, filepath.Join(repo, ".git"), db, + daemon.CaptureContext{BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head}, + ai.CommitStrategyEvent, ai.ProviderConfig{}, nil, 4, stub, nil, + ) + if !errors.Is(err, replayFailure) { + t.Fatalf("commitAllReplayLoopWith err=%v want wrapped replay failure", err) + } + if commits != 2 || conflicts != 1 || failed != 1 { + t.Fatalf("partial summary lost: commits=%d conflicts=%d failed=%d", commits, conflicts, failed) + } +} + // TestCommitAllReplayLoop_ZeroProgressEscape covers P2-6: when Replay // reports Published=0 with pending still present, the loop must exit // after exactly commitAllZeroProgressLimit (3) consecutive zero-progress diff --git a/internal/cli/control.go b/internal/cli/control.go new file mode 100644 index 00000000..0708ad06 --- /dev/null +++ b/internal/cli/control.go @@ -0,0 +1,444 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/central" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/identity" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/paths" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +const ( + controlHealthHealthy = "healthy" + controlHealthWaiting = "waiting" + controlHealthDegraded = "degraded" + controlHealthNeedsAttention = "needs_attention" + controlHealthOff = "off" + controlHealthNotRepo = "not_a_repo" +) + +// controlResult is the stable response shared by bare acd, acd on, and +// acd off. Keep fields non-optional so --json consumers receive the same +// shape for every outcome; Actions is initialized to an empty slice rather +// than null for the same reason. +type controlResult struct { + OK bool `json:"ok"` + Command string `json:"command"` + Repo string `json:"repo"` + Health string `json:"health"` + Summary string `json:"summary"` + NextAction string `json:"next_action"` + Registered bool `json:"registered"` + Enabled bool `json:"enabled"` + Daemon string `json:"daemon"` + DaemonPID int `json:"daemon_pid"` + PendingEvents int `json:"pending_events"` + BlockedEvents int `json:"blocked_events"` + Changed bool `json:"changed"` + Actions []string `json:"actions"` + StatePreserved bool `json:"state_preserved"` +} + +type controlRepoLookup struct { + Worktree git.Worktree + Roots paths.Roots + Record central.RepoRecord + Registered bool +} + +func newOnCmd() *cobra.Command { + return &cobra.Command{ + Use: "on", + Short: "Enable ACD and ensure its daemon is running for this repo", + Long: `Put the current repository into ACD's enabled desired state. + +The command is idempotent: it registers an unknown repo, enables a disabled +repo, and starts or refreshes its daemon. Existing hook commands and sessions +keep their current behavior. State is preserved throughout.`, + Example: ` acd on + acd on --repo /path/to/repo + acd on --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + jsonOut, _ := cmd.Flags().GetBool("json") + return runControlOn(cmd.Context(), cmd.OutOrStdout(), repo, jsonOut) + }, + } +} + +func newOffCmd() *cobra.Command { + return &cobra.Command{ + Use: "off", + Short: "Durably disable ACD for this repo while preserving state", + Long: `Put the current repository into ACD's disabled desired state. + +The command is idempotent: it records an opt-out even for a previously unknown +repo, stops a live daemon, clears start caches, and preserves .git/acd state. +Harness hooks then skip this repo until acd on is run.`, + Example: ` acd off + acd off --repo /path/to/repo + acd off --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + jsonOut, _ := cmd.Flags().GetBool("json") + return runControlOff(cmd.Context(), cmd.OutOrStdout(), repo, jsonOut) + }, + } +} + +// runControlStatus is the read-only default action for bare `acd`. It only +// reads Git, the central registry, and an existing state DB through the same +// read-only report used by `acd status`. +func runControlStatus(ctx context.Context, out io.Writer, repoFlag string, jsonOut bool) error { + res, err := inspectControl(ctx, repoFlag) + if err != nil { + return err + } + return renderControl(out, res, jsonOut) +} + +func runControlOn(ctx context.Context, out io.Writer, repoFlag string, jsonOut bool) error { + if ctx == nil { + ctx = context.Background() + } + lookup, err := loadControlRepo(ctx, repoFlag) + if err != nil { + return controlWorktreeError("on", repoFlag, err) + } + + actions := make([]string, 0, 3) + if !lookup.Registered { + if err := runRepoInit(ctx, io.Discard, lookup.Worktree.Root, true); err != nil { + return fmt.Errorf("acd on: register repo: %w", err) + } + actions = append(actions, "registered") + lookup, err = loadControlRepo(ctx, lookup.Worktree.Root) + if err != nil || !lookup.Registered { + if err == nil { + err = errors.New("registry row missing after registration") + } + return fmt.Errorf("acd on: reload registered repo: %w", err) + } + } + + target := central.RepoRemovalTarget{ + Path: lookup.Worktree.Root, + StateDB: state.DBPathFromGitDir(lookup.Worktree.GitDir), + } + if lookup.Record.LifecycleDisabled() { + life, err := applyRepoEnable(ctx, lookup.Roots, target) + if err != nil { + return fmt.Errorf("acd on: enable repo: %w", err) + } + if life.Updated { + actions = append(actions, "enabled") + } + lookup.Record = life.Record + } + + wasRunning := controlDaemonRunning(ctx, lookup.Record) + if err := runStart(ctx, io.Discard, lookup.Worktree.Root, "", "", 0, false); err != nil { + return fmt.Errorf("acd on: ensure daemon: %w", err) + } + if !wasRunning { + actions = append(actions, "started") + } + + res, err := inspectControl(ctx, lookup.Worktree.Root) + if err != nil { + return err + } + res.Command = "on" + res.Changed = len(actions) > 0 + res.Actions = actions + res.StatePreserved = true + if err := renderControl(out, res, jsonOut); err != nil { + return err + } + if !res.OK { + return fmt.Errorf("acd on: repository remains unhealthy: %s", res.Summary) + } + return nil +} + +func runControlOff(ctx context.Context, out io.Writer, repoFlag string, jsonOut bool) error { + if ctx == nil { + ctx = context.Background() + } + lookup, err := loadControlRepo(ctx, repoFlag) + if err != nil { + return controlWorktreeError("off", repoFlag, err) + } + + actions := make([]string, 0, 3) + if !lookup.Registered { + // A durable opt-out needs a registry row; otherwise the next harness + // start would rediscover and enable the repo again. + if err := registerControlOptOut(lookup); err != nil { + return fmt.Errorf("acd off: register opt-out: %w", err) + } + actions = append(actions, "registered") + lookup, err = loadControlRepo(ctx, lookup.Worktree.Root) + if err != nil || !lookup.Registered { + if err == nil { + err = errors.New("registry row missing after registration") + } + return fmt.Errorf("acd off: reload registered repo: %w", err) + } + } + + target := central.RepoRemovalTarget{ + Path: lookup.Worktree.Root, + StateDB: state.DBPathFromGitDir(lookup.Worktree.GitDir), + } + life, err := applyRepoDisable(ctx, lookup.Roots, target) + if err != nil { + return fmt.Errorf("acd off: disable repo: %w", err) + } + if life.Updated { + actions = append(actions, "disabled") + } + if life.Stopped != nil && life.Stopped.Stopped { + actions = append(actions, "stopped") + } + + res, err := inspectControl(ctx, lookup.Worktree.Root) + if err != nil { + return err + } + res.Command = "off" + res.Changed = len(actions) > 0 + res.Actions = actions + res.StatePreserved = true + return renderControl(out, res, jsonOut) +} + +// registerControlOptOut records lifecycle intent without opening state.db or +// requiring an attached branch. This lets users turn ACD off safely during a +// detached-HEAD inspection; acd on will create state when the repo is attached +// again and ready to start. +func registerControlOptOut(lookup controlRepoLookup) error { + return central.WithLock(lookup.Roots, func(reg *central.Registry) error { + _, err := reg.RegisterResolvedRepo(lookup.Worktree, "", time.Now().Unix()) + return err + }) +} + +func inspectControl(ctx context.Context, repoFlag string) (controlResult, error) { + if ctx == nil { + ctx = context.Background() + } + base := controlResult{ + OK: true, + Command: "status", + Daemon: "unknown", + Actions: []string{}, + NextAction: "No action needed.", + } + lookup, err := loadControlRepo(ctx, repoFlag) + if err != nil { + if errors.Is(err, git.ErrNotWorktree) { + base.OK = false + base.Repo = controlRequestedPath(repoFlag) + base.Health = controlHealthNotRepo + base.Summary = "The current directory is not inside a Git worktree." + base.NextAction = "Run `acd` from inside a Git worktree." + return base, nil + } + return controlResult{}, err + } + base.Repo = lookup.Worktree.Root + if !lookup.Registered { + base.Health = controlHealthOff + base.Daemon = "stopped" + base.Summary = "ACD is not enabled for this repository." + base.NextAction = "Run `acd on` to enable it." + return base, nil + } + + base.Registered = true + base.StatePreserved = true + if lookup.Record.LifecycleDisabled() { + base.Health = controlHealthOff + base.Daemon = "stopped" + base.Summary = "ACD is durably disabled for this repository." + base.NextAction = "Run `acd on` to enable it." + return base, nil + } + base.Enabled = true + if !fileExists(lookup.Record.StateDB) { + base.OK = false + base.Health = controlHealthNeedsAttention + base.Daemon = "stopped" + base.Summary = "The registered ACD state database is missing." + base.NextAction = "Run `acd on` to recreate state and start ACD." + return base, nil + } + + status, err := buildStatusReport(ctx, lookup.Record, time.Now()) + if err != nil { + base.OK = false + base.Health = controlHealthNeedsAttention + base.Summary = "ACD could not read the current repository health." + base.NextAction = "Run `acd diagnose` for details." + return base, nil + } + applyControlStatus(&base, status) + return base, nil +} + +func loadControlRepo(ctx context.Context, repoFlag string) (controlRepoLookup, error) { + wt, err := git.ResolveWorktree(ctx, repoFlag) + if err != nil { + return controlRepoLookup{}, err + } + roots, err := paths.Resolve() + if err != nil { + return controlRepoLookup{}, fmt.Errorf("resolve paths: %w", err) + } + reg, err := central.Load(roots) + if err != nil { + return controlRepoLookup{}, fmt.Errorf("load registry: %w", err) + } + rec, ok := findRepo(reg, wt.Root, state.DBPathFromGitDir(wt.GitDir)) + return controlRepoLookup{ + Worktree: wt, + Roots: roots, + Record: rec, + Registered: ok, + }, nil +} + +func controlDaemonRunning(ctx context.Context, rec central.RepoRecord) bool { + if rec.StateDB == "" || !fileExists(rec.StateDB) { + return false + } + report, err := buildStatusReport(ctx, rec, time.Now()) + return err == nil && report.Daemon == "running" && !report.Stale && + report.PID > 0 && identity.AliveContext(ctx, report.PID) +} + +func applyControlStatus(res *controlResult, status statusReport) { + res.Daemon = status.Daemon + res.DaemonPID = status.PID + res.PendingEvents = status.PendingEvents + res.BlockedEvents = status.BlockedConflicts + + switch { + case status.Stale: + res.OK = false + res.Health = controlHealthNeedsAttention + res.Summary = "The ACD daemon heartbeat is stale." + res.NextAction = "Run `acd on` to restart it." + case status.Daemon != "running" || status.PID <= 0 || !identity.Alive(status.PID): + res.OK = false + res.Health = controlHealthNeedsAttention + res.Summary = "ACD is enabled, but its daemon process is not running." + res.NextAction = "Run `acd on` to start it." + case status.Paused: + res.OK = false + res.Health = controlHealthNeedsAttention + res.Summary = "ACD capture and replay are paused." + res.NextAction = "Run `acd status` to review the pause reason." + case status.BackpressurePaused: + res.OK = false + res.Health = controlHealthNeedsAttention + res.Summary = "ACD capture is paused by durable backpressure." + res.NextAction = "Run `acd diagnose` before clearing backpressure." + case status.ActiveTerminalEvents > 0 || status.ActiveBarriers > 0: + res.OK = false + res.Health = controlHealthNeedsAttention + res.Summary = "A terminal replay event needs recovery on the active branch." + res.NextAction = "Run `acd diagnose` to inspect the blocker." + case status.IntentStrategy.PlannerHealth != nil && + (status.IntentStrategy.PlannerHealth.State == daemon.IntentPlannerCircuitOpen || + status.IntentStrategy.PlannerHealth.State == daemon.IntentPlannerCircuitHalfOpen): + res.Health = controlHealthDegraded + res.Summary = "The intent planner is degraded; deterministic fallback is keeping replay moving." + if status.IntentStrategy.PlannerHealth.State == daemon.IntentPlannerCircuitHalfOpen { + res.NextAction = "No immediate action needed; ACD is running the automatic provider probe. Run `acd diagnose` for details." + } else { + res.NextAction = "No immediate action needed; ACD will probe the provider automatically after cooldown. Run `acd diagnose` for details." + } + case status.IntentStrategy.PlannerHealthWarning != "": + res.Health = controlHealthDegraded + res.Summary = "ACD is running, but persisted intent planner health metadata could not be read safely." + res.NextAction = "Run `acd diagnose` for the safe metadata warning." + case status.CaptureErrors > 0 || status.IntentStrategy.PlannerErrorRateRecentWarn: + res.Health = controlHealthDegraded + res.Summary = "ACD is running with recoverable errors or deterministic fallback." + res.NextAction = "Run `acd diagnose` if the degraded state persists." + case status.PendingEvents > 0 && status.IntentStrategy.Active && status.IntentStrategy.BatchWaitActive: + res.Health = controlHealthWaiting + res.Summary = fmt.Sprintf("Intent mode is waiting normally with %d pending event(s).", status.PendingEvents) + res.NextAction = "No action needed; ACD will publish at the configured batch boundary." + case status.PendingEvents > 0: + res.Health = controlHealthHealthy + res.Summary = fmt.Sprintf("ACD is running and draining %d pending event(s).", status.PendingEvents) + res.NextAction = "No action needed." + default: + res.Health = controlHealthHealthy + res.Summary = "ACD is enabled and running normally." + res.NextAction = "No action needed." + } +} + +func controlWorktreeError(command, repoFlag string, err error) error { + if errors.Is(err, git.ErrNotWorktree) { + return fmt.Errorf("acd %s: repo %q is not inside a Git worktree: %w", command, repoFlag, err) + } + return fmt.Errorf("acd %s: %w", command, err) +} + +func controlRequestedPath(repoFlag string) string { + if repoFlag == "" { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "." + } + if abs, err := filepath.Abs(repoFlag); err == nil { + return abs + } + return repoFlag +} + +func renderControl(out io.Writer, res controlResult, jsonOut bool) error { + if res.Actions == nil { + res.Actions = []string{} + } + if jsonOut { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(res) + } + if res.Command != "status" { + change := "no changes" + if len(res.Actions) > 0 { + change = strings.Join(res.Actions, ", ") + } + fmt.Fprintf(out, "ACD %s: %s\n", res.Command, change) + } + fmt.Fprintf(out, "Health: %s\n", strings.ReplaceAll(res.Health, "_", " ")) + if res.Repo != "" { + fmt.Fprintf(out, "Repo: %s\n", res.Repo) + } + fmt.Fprintf(out, "Summary: %s\n", res.Summary) + fmt.Fprintf(out, "Next: %s\n", res.NextAction) + return nil +} diff --git a/internal/cli/control_test.go b/internal/cli/control_test.go new file mode 100644 index 00000000..de6180ca --- /dev/null +++ b/internal/cli/control_test.go @@ -0,0 +1,545 @@ +package cli + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/central" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +func TestControlBareUnregisteredIsReadOnly(t *testing.T) { + _ = withIsolatedHome(t) + repo := makeStartRepo(t) + stateDB := state.DBPathFromGitDir(filepath.Join(repo, ".git")) + + var out bytes.Buffer + if err := runControlStatus(context.Background(), &out, repo, true); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.Health != controlHealthOff || got.Registered || got.Enabled || !got.OK { + t.Fatalf("unexpected unregistered health: %+v", got) + } + if got.NextAction != "Run `acd on` to enable it." { + t.Fatalf("next_action=%q", got.NextAction) + } + if _, err := os.Stat(stateDB); !os.IsNotExist(err) { + t.Fatalf("bare acd created state DB %s: err=%v", stateDB, err) + } +} + +func TestControlBareNonRepoReturnsClassification(t *testing.T) { + _ = withIsolatedHome(t) + nonRepo := t.TempDir() + + var out bytes.Buffer + if err := runControlStatus(context.Background(), &out, nonRepo, true); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.OK || got.Health != controlHealthNotRepo || got.NextAction == "" { + t.Fatalf("unexpected non-repo health: %+v", got) + } +} + +func TestControlBareFreshHeartbeatWithDeadPIDNeedsAttention(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, stateDB, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, stateDB, "") + if err := state.SaveDaemonState(ctx, db, state.DaemonState{ + PID: 999_999_999, + Mode: "running", + HeartbeatTS: nowFloat(), + UpdatedTS: nowFloat(), + }); err != nil { + t.Fatalf("save dead daemon state: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close state DB: %v", err) + } + + var out bytes.Buffer + if err := runControlStatus(ctx, &out, repo, true); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.OK || got.Health != controlHealthNeedsAttention || got.NextAction != "Run `acd on` to start it." { + t.Fatalf("unexpected dead-pid health: %+v", got) + } +} + +func TestControlBareIgnoresHistoricalInactiveTerminalEvents(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, stateDB, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, stateDB, "") + if err := state.SaveDaemonState(ctx, db, state.DaemonState{ + PID: os.Getpid(), + Mode: "running", + HeartbeatTS: nowFloat(), + BranchRef: sqlNullStr("refs/heads/main"), + BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, + UpdatedTS: nowFloat(), + }); err != nil { + t.Fatalf("save daemon state: %v", err) + } + seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/old-topic", + BranchGeneration: 7, + BaseHead: "deadbeef", + Operation: "modify", + Path: "old.go", + Fidelity: "exact", + }, []state.CaptureOp{{Op: "modify", Path: "old.go", Fidelity: "exact"}}) + if err != nil { + t.Fatalf("append historical conflict: %v", err) + } + if err := state.MarkEventBlocked(ctx, db, seq, "before-state mismatch", nowFloat(), + sqlNullStr("refs/heads/old-topic"), sql.NullInt64{Int64: 7, Valid: true}, sqlNullStr("deadbeef")); err != nil { + t.Fatalf("mark historical conflict: %v", err) + } + failedSeq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/old-topic", + BranchGeneration: 7, + BaseHead: "deadbeef", + Operation: "modify", + Path: "failed.go", + Fidelity: "exact", + }, []state.CaptureOp{{Op: "modify", Path: "failed.go", Fidelity: "exact"}}) + if err != nil { + t.Fatalf("append historical failure: %v", err) + } + if err := state.MarkEventPublished(ctx, db, failedSeq, state.EventStateFailed, + sql.NullString{}, sqlNullStr("provider failed"), sql.NullString{}, nowFloat()); err != nil { + t.Fatalf("mark historical failure: %v", err) + } + if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/old-topic", + BranchGeneration: 7, + BaseHead: "deadbeef", + Operation: "modify", + Path: "historical-pending.go", + Fidelity: "exact", + }, []state.CaptureOp{{Op: "modify", Path: "historical-pending.go", Fidelity: "exact"}}); err != nil { + t.Fatalf("append historical pending event: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close state DB: %v", err) + } + + var out bytes.Buffer + if err := runControlStatus(ctx, &out, repo, true); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if !got.OK || got.Health == controlHealthNeedsAttention || got.BlockedEvents != 1 { + t.Fatalf("historical conflict affected active health: %+v", got) + } +} + +func TestControlBareNeedsAttentionForActiveTailTerminalEvent(t *testing.T) { + for _, terminalState := range []string{state.EventStateBlockedConflict, state.EventStateFailed} { + t.Run(terminalState, func(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, stateDB, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, stateDB, "") + if err := state.SaveDaemonState(ctx, db, state.DaemonState{ + PID: os.Getpid(), + Mode: "running", + HeartbeatTS: nowFloat(), + BranchRef: sqlNullStr("refs/heads/main"), + BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, + UpdatedTS: nowFloat(), + }); err != nil { + t.Fatalf("save daemon state: %v", err) + } + seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, + BaseHead: "deadbeef", Operation: "modify", Path: "stuck.go", + Fidelity: "exact", + }, []state.CaptureOp{{Op: "modify", Path: "stuck.go", Fidelity: "exact"}}) + if err != nil { + t.Fatalf("append terminal event: %v", err) + } + switch terminalState { + case state.EventStateBlockedConflict: + err = state.MarkEventBlocked(ctx, db, seq, "before-state mismatch", nowFloat(), + sqlNullStr("refs/heads/main"), sql.NullInt64{Int64: 1, Valid: true}, sqlNullStr("deadbeef")) + case state.EventStateFailed: + err = state.MarkEventPublished(ctx, db, seq, state.EventStateFailed, + sql.NullString{}, sqlNullStr("provider failed"), sql.NullString{}, nowFloat()) + } + if err != nil { + t.Fatalf("mark terminal event: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close state DB: %v", err) + } + + var out bytes.Buffer + if err := runControlStatus(ctx, &out, repo, true); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.OK || got.Health != controlHealthNeedsAttention || !strings.Contains(got.Summary, "terminal replay") { + t.Fatalf("active tail terminal event was not surfaced: %+v", got) + } + }) + } +} + +func TestControlOnReturnsErrorAfterRenderingUnhealthyResult(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, stateDB, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, stateDB, "") + if err := state.SaveDaemonState(ctx, db, state.DaemonState{ + PID: os.Getpid(), + Mode: "running", + HeartbeatTS: nowFloat(), + BranchRef: sqlNullStr("refs/heads/main"), + BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, + UpdatedTS: nowFloat(), + }); err != nil { + t.Fatalf("save daemon state: %v", err) + } + seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, + BaseHead: "deadbeef", Operation: "modify", Path: "stuck.go", Fidelity: "exact", + }, []state.CaptureOp{{Op: "modify", Path: "stuck.go", Fidelity: "exact"}}) + if err != nil { + t.Fatalf("append terminal event: %v", err) + } + if err := state.MarkEventBlocked(ctx, db, seq, "before-state mismatch", nowFloat(), + sqlNullStr("refs/heads/main"), sql.NullInt64{Int64: 1, Valid: true}, sqlNullStr("deadbeef")); err != nil { + t.Fatalf("mark terminal event: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close state DB: %v", err) + } + + var out bytes.Buffer + err = runControlOn(ctx, &out, repo, true) + if err == nil || !strings.Contains(err.Error(), "repository remains unhealthy") { + t.Fatalf("runControlOn err=%v want unhealthy result", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.OK || got.Command != "on" || got.Health != controlHealthNeedsAttention { + t.Fatalf("unhealthy diagnostic was not rendered: %+v", got) + } +} + +func TestApplyControlStatusPlannerCircuitDegraded(t *testing.T) { + for _, tc := range []struct { + state daemon.IntentPlannerCircuitState + nextText string + }{ + {state: daemon.IntentPlannerCircuitOpen, nextText: "probe the provider automatically after cooldown"}, + {state: daemon.IntentPlannerCircuitHalfOpen, nextText: "running the automatic provider probe"}, + } { + t.Run(string(tc.state), func(t *testing.T) { + status := statusReport{ + Daemon: "running", + PID: os.Getpid(), + CaptureErrors: 2, + IntentStrategy: intentStrategyReport{PlannerHealth: &daemon.IntentPlannerHealthSnapshot{ + State: tc.state, + }}, + } + res := controlResult{OK: true} + applyControlStatus(&res, status) + if res.Health != controlHealthDegraded || + !strings.Contains(res.Summary, "deterministic fallback") || + !strings.Contains(res.NextAction, tc.nextText) { + t.Fatalf("control result=%+v", res) + } + }) + } +} + +func TestApplyControlStatusPlannerCircuitRespectsBlockerPrecedence(t *testing.T) { + health := &daemon.IntentPlannerHealthSnapshot{State: daemon.IntentPlannerCircuitOpen} + for _, tc := range []struct { + name string + mutate func(*statusReport) + want string + }{ + {name: "pause", mutate: func(s *statusReport) { s.Paused = true }, want: "paused"}, + {name: "backpressure", mutate: func(s *statusReport) { s.BackpressurePaused = true }, want: "backpressure"}, + {name: "barrier", mutate: func(s *statusReport) { s.ActiveBarriers = 1 }, want: "terminal replay"}, + } { + t.Run(tc.name, func(t *testing.T) { + status := statusReport{ + Daemon: "running", + PID: os.Getpid(), + IntentStrategy: intentStrategyReport{PlannerHealth: health}, + } + tc.mutate(&status) + res := controlResult{OK: true} + applyControlStatus(&res, status) + if res.Health != controlHealthNeedsAttention || !strings.Contains(strings.ToLower(res.Summary), tc.want) { + t.Fatalf("control result=%+v", res) + } + }) + } +} + +func TestControlOnRegistersStartsAndIsIdempotent(t *testing.T) { + roots := withIsolatedHome(t) + repo := makeStartRepo(t) + spawnCount, restoreSpawn := installFakeSpawn(t, os.Getpid()) + t.Cleanup(restoreSpawn) + + var firstOut bytes.Buffer + if err := runControlOn(context.Background(), &firstOut, repo, true); err != nil { + t.Fatalf("first runControlOn: %v", err) + } + first := decodeControlResult(t, firstOut.Bytes()) + if !first.OK || first.Health != controlHealthHealthy || !first.Registered || !first.Enabled || !first.Changed { + t.Fatalf("unexpected first on result: %+v", first) + } + if want := []string{"registered", "started"}; !reflect.DeepEqual(first.Actions, want) { + t.Fatalf("actions=%v want=%v", first.Actions, want) + } + if !first.StatePreserved || spawnCount.Load() != 1 { + t.Fatalf("state_preserved=%v spawn_count=%d", first.StatePreserved, spawnCount.Load()) + } + reg, err := central.Load(roots) + if err != nil { + t.Fatalf("load registry: %v", err) + } + if rec, ok := reg.FindRepo(repo, state.DBPathFromGitDir(filepath.Join(repo, ".git"))); !ok || rec.LifecycleDisabled() { + t.Fatalf("on did not leave enabled registry row: ok=%v rec=%+v", ok, rec) + } + + var secondOut bytes.Buffer + if err := runControlOn(context.Background(), &secondOut, repo, true); err != nil { + t.Fatalf("second runControlOn: %v", err) + } + second := decodeControlResult(t, secondOut.Bytes()) + if second.Changed || len(second.Actions) != 0 || second.Health != controlHealthHealthy { + t.Fatalf("second on was not idempotent: %+v", second) + } + if spawnCount.Load() != 1 { + t.Fatalf("spawn_count=%d want=1", spawnCount.Load()) + } + + var thirdOut bytes.Buffer + if err := runControlOn(context.Background(), &thirdOut, repo, true); err != nil { + t.Fatalf("third runControlOn: %v", err) + } + if secondOut.String() != thirdOut.String() { + t.Fatalf("idempotent JSON changed\nsecond=%s\nthird=%s", secondOut.String(), thirdOut.String()) + } +} + +func TestControlOffDisablesStopsPreservesAndIsIdempotent(t *testing.T) { + roots := withIsolatedHome(t) + repo := makeStartRepo(t) + spawnCount, restoreSpawn := installFakeSpawn(t, os.Getpid()) + t.Cleanup(restoreSpawn) + + if err := runControlOn(context.Background(), io.Discard, repo, true); err != nil { + t.Fatalf("seed on: %v", err) + } + + previousStop := repoDisableStopOneRepo + repoDisableStopOneRepo = func(ctx context.Context, repoPath, sessionID string, force bool) (stopRepoResult, error) { + db, err := state.Open(ctx, state.DBPathFromGitDir(filepath.Join(repoPath, ".git"))) + if err != nil { + return stopRepoResult{}, err + } + defer db.Close() + if err := state.SaveDaemonState(ctx, db, state.DaemonState{Mode: "stopped", UpdatedTS: nowFloat()}); err != nil { + return stopRepoResult{}, err + } + return stopRepoResult{Repo: repoPath, Stopped: true, Force: force, DaemonPID: os.Getpid()}, nil + } + t.Cleanup(func() { repoDisableStopOneRepo = previousStop }) + + var firstOut bytes.Buffer + if err := runControlOff(context.Background(), &firstOut, repo, true); err != nil { + t.Fatalf("first runControlOff: %v", err) + } + first := decodeControlResult(t, firstOut.Bytes()) + if !first.OK || first.Health != controlHealthOff || first.Enabled || !first.Registered || !first.Changed || !first.StatePreserved { + t.Fatalf("unexpected first off result: %+v", first) + } + if want := []string{"disabled", "stopped"}; !reflect.DeepEqual(first.Actions, want) { + t.Fatalf("actions=%v want=%v", first.Actions, want) + } + if _, err := os.Stat(state.DBPathFromGitDir(filepath.Join(repo, ".git"))); err != nil { + t.Fatalf("off did not preserve state DB: %v", err) + } + reg, err := central.Load(roots) + if err != nil { + t.Fatalf("load registry: %v", err) + } + rec, ok := reg.FindRepo(repo, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if !ok || !rec.LifecycleDisabled() { + t.Fatalf("off did not leave disabled registry row: ok=%v rec=%+v", ok, rec) + } + + var secondOut bytes.Buffer + if err := runControlOff(context.Background(), &secondOut, repo, true); err != nil { + t.Fatalf("second runControlOff: %v", err) + } + second := decodeControlResult(t, secondOut.Bytes()) + if second.Changed || len(second.Actions) != 0 || second.Health != controlHealthOff { + t.Fatalf("second off was not idempotent: %+v", second) + } + + var onOut bytes.Buffer + if err := runControlOn(context.Background(), &onOut, repo, true); err != nil { + t.Fatalf("runControlOn after off: %v", err) + } + on := decodeControlResult(t, onOut.Bytes()) + if want := []string{"enabled", "started"}; !reflect.DeepEqual(on.Actions, want) { + t.Fatalf("on actions=%v want=%v", on.Actions, want) + } + if !on.Enabled || on.Health != controlHealthHealthy || spawnCount.Load() != 2 { + t.Fatalf("unexpected re-enabled result=%+v spawn_count=%d", on, spawnCount.Load()) + } +} + +func TestControlOnRestartsFreshLookingDeadDaemon(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, stateDB, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, stateDB, "") + if err := state.SaveDaemonState(ctx, db, state.DaemonState{ + PID: 999_999_999, + Mode: "running", + HeartbeatTS: nowFloat(), + UpdatedTS: nowFloat(), + }); err != nil { + t.Fatalf("save dead daemon state: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close state DB: %v", err) + } + spawnCount, restoreSpawn := installFakeSpawn(t, os.Getpid()) + t.Cleanup(restoreSpawn) + + var out bytes.Buffer + if err := runControlOn(ctx, &out, repo, true); err != nil { + t.Fatalf("runControlOn: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if want := []string{"started"}; !reflect.DeepEqual(got.Actions, want) { + t.Fatalf("actions=%v want=%v", got.Actions, want) + } + if !got.Changed || got.Health != controlHealthHealthy || spawnCount.Load() != 1 { + t.Fatalf("unexpected restart result=%+v spawn_count=%d", got, spawnCount.Load()) + } +} + +func TestControlOffUnknownRepoRecordsDurableOptOut(t *testing.T) { + roots := withIsolatedHome(t) + repo := makeStartRepo(t) + + var out bytes.Buffer + if err := runControlOff(context.Background(), &out, repo, true); err != nil { + t.Fatalf("runControlOff: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if want := []string{"registered", "disabled"}; !reflect.DeepEqual(got.Actions, want) { + t.Fatalf("actions=%v want=%v", got.Actions, want) + } + reg, err := central.Load(roots) + if err != nil { + t.Fatalf("load registry: %v", err) + } + rec, ok := reg.FindRepo(repo, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if !ok || !rec.LifecycleDisabled() { + t.Fatalf("unknown off was not durable: ok=%v rec=%+v", ok, rec) + } +} + +func TestControlOffUnknownDetachedRepoRecordsDurableOptOut(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo := makeStartRepo(t) + commitStartRepoSeed(t, repo) + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "checkout", "--detach", "--quiet"); err != nil { + t.Fatalf("detach HEAD: %v", err) + } + + var out bytes.Buffer + if err := runControlOff(ctx, &out, repo, true); err != nil { + t.Fatalf("runControlOff detached: %v", err) + } + got := decodeControlResult(t, out.Bytes()) + if got.Health != controlHealthOff || !reflect.DeepEqual(got.Actions, []string{"registered", "disabled"}) { + t.Fatalf("unexpected detached off result: %+v", got) + } + reg, err := central.Load(roots) + if err != nil { + t.Fatalf("load registry: %v", err) + } + rec, ok := reg.FindRepo(repo, state.DBPathFromGitDir(filepath.Join(repo, ".git"))) + if !ok || !rec.LifecycleDisabled() { + t.Fatalf("detached off was not durable: ok=%v rec=%+v", ok, rec) + } + if _, err := os.Stat(state.DBPathFromGitDir(filepath.Join(repo, ".git"))); !os.IsNotExist(err) { + t.Fatalf("detached off should not initialize state DB: err=%v", err) + } +} + +func TestControlBareHumanHasOneHealthAndNextLine(t *testing.T) { + _ = withIsolatedHome(t) + repo := makeStartRepo(t) + + var out bytes.Buffer + if err := runControlStatus(context.Background(), &out, repo, false); err != nil { + t.Fatalf("runControlStatus: %v", err) + } + if got := strings.Count(out.String(), "Health:"); got != 1 { + t.Fatalf("Health line count=%d\n%s", got, out.String()) + } + if got := strings.Count(out.String(), "Next:"); got != 1 { + t.Fatalf("Next line count=%d\n%s", got, out.String()) + } +} + +func TestControlRootBareUsesReadOnlyHealth(t *testing.T) { + _ = withIsolatedHome(t) + repo := makeStartRepo(t) + root := newRootCmd() + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"--repo", repo, "--json"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("bare root: %v\nstderr=%s", err, errOut.String()) + } + got := decodeControlResult(t, out.Bytes()) + if got.Command != "status" || got.Health != controlHealthOff || got.Registered { + t.Fatalf("unexpected bare root result: %+v", got) + } +} + +func decodeControlResult(t *testing.T, body []byte) controlResult { + t.Helper() + var got controlResult + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode control result: %v\n%s", err, body) + } + if got.Actions == nil { + t.Fatalf("actions must encode as [] rather than null: %s", body) + } + return got +} diff --git a/internal/cli/diagnose.go b/internal/cli/diagnose.go index a8c2e4e0..47cad9bc 100644 --- a/internal/cli/diagnose.go +++ b/internal/cli/diagnose.go @@ -20,6 +20,7 @@ import ( "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/central" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -88,11 +89,11 @@ type diagnoseReport struct { OperationMarkerDuration string `json:"operation_marker_duration,omitempty"` // DeadBranchPruneLastRunTS / DeadBranchPruneLastCount / // DeadBranchPruneLastRefs surface the most recent non-empty dead-branch - // terminal prune action so operators can confirm stale-branch hygiene + // recovery action so operators can confirm stale-branch hygiene // is keeping pace. The two int fields are ALWAYS present in the JSON — // a zero value is the documented "daemon has never recorded a non-empty - // prune" sentinel, distinguishable from any real prune (real prunes - // stamp last_run_ts to the wall-clock unix-second the prune ran and + // recovery" sentinel, distinguishable from any real recovery (real actions + // stamp last_run_ts to the wall-clock unix-second the recovery ran and // last_count >= 1). The slice keeps `omitempty` so an empty list // serializes as absent rather than `null`/`[]`. DeadBranchPruneLastRunTS int64 `json:"dead_branch_prune_last_run_ts"` @@ -115,7 +116,7 @@ func newDiagnoseCmd() *cobra.Command { Short: "Inspect replay blockers and branch anchors without mutating state", Long: `Inspect replay blockers, pending depth, branch anchor state, git-operation markers, and remediation hints for one repo. -The default repo is the current working directory. Diagnose opens state read-only and verifies the state DB checksum before and after inspection. Pending-only intent queues are reported as waiting/draining, while terminal barriers point to acd fix --dry-run and force-only purge previews when needed.`, +The default repo is the current working directory. Diagnose opens state read-only and verifies the state DB checksum before and after inspection. Pending-only intent queues are reported as waiting/draining, while terminal barriers point to acd fix --dry-run and archive-only recovery previews when needed.`, Example: ` acd diagnose acd diagnose --repo /path/to/repo acd diagnose --json @@ -376,12 +377,11 @@ func diagnoseGeneratedPending(ctx context.Context, conn *sql.DB, repo string, re return nil } -// diagnoseDeadBranchPrune surfaces the daemon-recorded "last non-empty -// dead-branch prune action" via three meta keys stamped by -// daemon.recordDeadBranchPruneMeta: +// diagnoseDeadBranchPrune surfaces the daemon-recorded last non-empty +// dead-branch recovery action through legacy prune-named meta keys: // // - dead_branch_prune.last_run_ts unix seconds (string-encoded int) -// - dead_branch_prune.last_count rows pruned (string-encoded int) +// - dead_branch_prune.last_count rows recovered (string-encoded int) // - dead_branch_prune.last_refs JSON-encoded []string of refs // // Missing keys default to zero / nil. The two int fields render as 0 in the @@ -654,6 +654,28 @@ func diagnoseRemediation(report diagnoseReport) []string { fmt.Sprintf("capture is in durable backpressure (paused at %s, %d events dropped lifetime); replay must drain pending below the high-water mark, or run `acd resume --accept-overflow` to clear the gate and accept the loss.", report.BackpressurePausedAt, report.EventsDroppedTotal)) } + if report.IntentStrategy.PlannerHealthWarning != "" { + remediation = append(remediation, + "Intent planner health metadata is invalid or unsupported; detailed circuit state was omitted and state.db was left unchanged. Check daemon logs and update ACD if the stored record uses a newer schema.") + } + if health := report.IntentStrategy.PlannerHealth; health != nil { + switch health.State { + case daemon.IntentPlannerCircuitOpen: + nextProbe := "when its backoff expires" + if health.NextProbeTS > 0 { + nextProbe = "at " + time.Unix(int64(health.NextProbeTS), 0).UTC().Format(time.RFC3339) + } + remediation = append(remediation, + fmt.Sprintf("intent planner circuit is open after %d consecutive %s failure(s); deterministic fallback remains active until the next provider probe %s. Check provider connectivity and configuration; for validation failures, review .git/acd/%s.", + health.ConsecutiveFailures, + valueOrUnset(string(health.LastFailureClass)), + nextProbe, + ai.IntentRejectsFileName)) + case daemon.IntentPlannerCircuitHalfOpen: + remediation = append(remediation, + "intent planner circuit is half-open with one provider probe in progress; deterministic fallback remains active for other planning windows until that probe succeeds.") + } + } if report.IntentStrategy.LastPlannerError != "" { remediation = append(remediation, fmt.Sprintf("intent planner last failed validation for seq %d (%s); replay will use deterministic fallback until planner output is valid.", @@ -796,11 +818,11 @@ func renderDiagnoseHuman(out io.Writer, r diagnoseReport) error { } } - // Dead-branch prune surface. Zero last_run_ts means the daemon has - // never recorded a non-empty prune — render nothing in that case so a + // Dead-branch recovery surface. Zero last_run_ts means the daemon has + // never recorded a non-empty recovery — render nothing in that case so a // fresh repo's diagnose output is not cluttered with "never ran" noise. if r.DeadBranchPruneLastRunTS > 0 { - fmt.Fprintf(out, "Dead-branch prune: %d row(s) pruned at %s, refs=%v\n", + fmt.Fprintf(out, "Dead-branch recovery: %d row(s) preserved at %s, branches=%v\n", r.DeadBranchPruneLastCount, time.Unix(r.DeadBranchPruneLastRunTS, 0).Format(time.RFC3339), r.DeadBranchPruneLastRefs, diff --git a/internal/cli/diagnose_test.go b/internal/cli/diagnose_test.go index 3b05d36a..65b70c6a 100644 --- a/internal/cli/diagnose_test.go +++ b/internal/cli/diagnose_test.go @@ -677,6 +677,120 @@ func TestDiagnose_IntentBatchWaitUsesDefaultsWithoutNewMetadata(t *testing.T) { } } +func TestDiagnose_IntentPlannerHealthIsReadOnly(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, _, d := makeDiagnoseRepo(t, roots) + + if err := state.MetaSet(ctx, d, "commit.strategy", "intent"); err != nil { + t.Fatalf("set commit strategy: %v", err) + } + nextProbe := time.Date(2026, 7, 13, 4, 30, 0, 0, time.UTC) + health := daemon.IntentPlannerHealthSnapshot{ + State: daemon.IntentPlannerCircuitOpen, + ProviderFingerprint: testPlannerHealthFingerprint(), + ConsecutiveFailures: 1, + BackoffLevel: 1, + NextProbeTS: float64(nextProbe.Unix()), + LastFailureClass: daemon.IntentPlannerFailureTransport, + LastError: "provider request timed out", + BypassCount: 4, + } + if err := state.MetaSetJSON(ctx, d, daemon.MetaKeyIntentPlannerHealth, struct { + Version int `json:"version"` + daemon.IntentPlannerHealthSnapshot + }{Version: 1, IntentPlannerHealthSnapshot: health}); err != nil { + t.Fatalf("set planner health: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + + var out bytes.Buffer + if err := runDiagnose(ctx, &out, repo, true); err != nil { + t.Fatalf("runDiagnose: %v", err) + } + var rep diagnoseReport + if err := json.Unmarshal(out.Bytes(), &rep); err != nil { + t.Fatalf("unmarshal diagnose: %v\n%s", err, out.String()) + } + if rep.IntentStrategy.PlannerHealth == nil || + rep.IntentStrategy.PlannerHealth.State != daemon.IntentPlannerCircuitOpen || + rep.IntentStrategy.PlannerHealth.BypassCount != 4 { + t.Fatalf("planner health=%+v", rep.IntentStrategy.PlannerHealth) + } + if !rep.StateDBChecksumVerified || rep.StateDBChecksumBefore != rep.StateDBChecksumAfter { + t.Fatalf("diagnose mutated state.db: before=%q after=%q verified=%v", + rep.StateDBChecksumBefore, rep.StateDBChecksumAfter, rep.StateDBChecksumVerified) + } + var sawCircuitHint bool + for _, item := range rep.Remediation { + if strings.Contains(item, "intent planner circuit is open") && + strings.Contains(item, "deterministic fallback remains active") && + strings.Contains(item, "2026-07-13T04:30:00Z") { + sawCircuitHint = true + break + } + } + if !sawCircuitHint { + t.Fatalf("remediation lacks circuit-open hint: %v", rep.Remediation) + } +} + +func TestDiagnose_IntentPlannerHealthWarningIsReadOnly(t *testing.T) { + for _, tc := range []struct { + name string + raw string + warning string + }{ + {name: "empty", raw: "", warning: plannerHealthInvalidWarning}, + {name: "invalid", raw: `{"version":1,"last_error":"token=secret-value`, warning: plannerHealthInvalidWarning}, + {name: "unsupported", raw: `{"version":99,"state":"open","last_error":"token=secret-value"}`, warning: plannerHealthVersionWarning}, + } { + t.Run(tc.name, func(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, _, d := makeDiagnoseRepo(t, roots) + if err := state.MetaSet(ctx, d, daemon.MetaKeyIntentPlannerHealth, tc.raw); err != nil { + t.Fatalf("set planner health: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + + var out bytes.Buffer + if err := runDiagnose(ctx, &out, repo, true); err != nil { + t.Fatalf("runDiagnose: %v", err) + } + var rep diagnoseReport + if err := json.Unmarshal(out.Bytes(), &rep); err != nil { + t.Fatalf("unmarshal diagnose: %v\n%s", err, out.String()) + } + if rep.IntentStrategy.PlannerHealth != nil || rep.IntentStrategy.PlannerHealthWarning != tc.warning { + t.Fatalf("intent strategy=%+v", rep.IntentStrategy) + } + if !rep.StateDBChecksumVerified || rep.StateDBChecksumBefore != rep.StateDBChecksumAfter { + t.Fatalf("diagnose mutated state.db: before=%q after=%q verified=%v", + rep.StateDBChecksumBefore, rep.StateDBChecksumAfter, rep.StateDBChecksumVerified) + } + if strings.Contains(out.String(), "secret-value") { + t.Fatalf("diagnose leaked malformed metadata: %s", out.String()) + } + var sawWarning bool + for _, item := range rep.Remediation { + if strings.Contains(item, "health metadata is invalid or unsupported") && + strings.Contains(item, "state.db was left unchanged") { + sawWarning = true + break + } + } + if !sawWarning { + t.Fatalf("diagnose remediation missing safe warning: %v", rep.Remediation) + } + }) + } +} + func makeDiagnoseRepo(t *testing.T, roots paths.Roots) (repoDir, dbPath string, d *state.DB) { t.Helper() ctx := context.Background() @@ -733,8 +847,8 @@ func itoa64(v int64) string { // TestDiagnose_DeadBranchPrune_Populated seeds the three daemon_meta keys // stamped by daemon.recordDeadBranchPruneMeta and asserts diagnose surfaces // them on the JSON report with the agreed snake_case field names. This is -// the operator-visible signal that stale-branch hygiene ran and what it -// pruned. +// the operator-visible signal that stale-branch recovery ran and what it +// preserved. The legacy JSON key names remain stable. func TestDiagnose_DeadBranchPrune_Populated(t *testing.T) { roots := withIsolatedHome(t) ctx := context.Background() @@ -873,12 +987,12 @@ func TestDiagnose_RenderHumanIncludesDeadBranchPrune(t *testing.T) { if err := runDiagnose(ctx, &out, repo, false); err != nil { t.Fatalf("runDiagnose human: %v", err) } - want := "Dead-branch prune: 5 row(s) pruned at " + time.Unix(wantTS, 0).Format(time.RFC3339) + want := "Dead-branch recovery: 5 row(s) preserved at " + time.Unix(wantTS, 0).Format(time.RFC3339) if !strings.Contains(out.String(), want) { - t.Fatalf("human renderer missing dead-branch prune line %q in:\n%s", want, out.String()) + t.Fatalf("human renderer missing dead-branch recovery line %q in:\n%s", want, out.String()) } if !strings.Contains(out.String(), "refs/heads/old-feature") { - t.Fatalf("human renderer missing pruned ref name in:\n%s", out.String()) + t.Fatalf("human renderer missing recovered branch name in:\n%s", out.String()) } } @@ -897,8 +1011,8 @@ func TestDiagnose_RenderHumanOmitsDeadBranchPruneWhenZero(t *testing.T) { if err := runDiagnose(ctx, &out, repo, false); err != nil { t.Fatalf("runDiagnose human: %v", err) } - if strings.Contains(out.String(), "Dead-branch prune") { - t.Fatalf("human renderer included dead-branch prune line on never-ran repo:\n%s", out.String()) + if strings.Contains(out.String(), "Dead-branch recovery") { + t.Fatalf("human renderer included dead-branch recovery line on never-ran repo:\n%s", out.String()) } } diff --git a/internal/cli/fix.go b/internal/cli/fix.go index f23ebac1..7fabdf87 100644 --- a/internal/cli/fix.go +++ b/internal/cli/fix.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io" + "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -22,14 +24,10 @@ import ( ) const ( - fixActionClearExpiredManualPause = "clear_expired_manual_pause" - fixActionClearDrainedBackpressure = "clear_drained_backpressure" - fixActionDeleteObsoleteBarrier = "delete_obsolete_barrier" - fixActionMarkExternalPublished = "mark_external_published" - fixActionDropGeneratedPending = "drop_generated_pending" - fixActionResolveAlreadyLandedBarrier = "resolve_already_landed_barrier" - fixActionRetargetStaleAnchor = "retarget_stale_anchor" - fixActionPurgeBarrierWithSuccessors = "purge_barrier_with_successors" + fixActionClearExpiredManualPause = "clear_expired_manual_pause" + fixActionClearDrainedBackpressure = "clear_drained_backpressure" + fixActionDropGeneratedPending = "drop_generated_pending" + fixActionReconcileUnpublishedChain = "reconcile_unpublished_chain" ) type fixPlan struct { @@ -53,14 +51,14 @@ type fixPlan struct { RemainingBlockers *fixBlockerVerification `json:"remaining_blockers,omitempty"` ManualPauseRemoved bool `json:"manual_pause_removed,omitempty"` ManualPausePath string `json:"manual_pause_path,omitempty"` - // Retarget bookkeeping (mirrors recoverPlan fields so JSON callers can - // follow ported acd recover semantics without losing data). - ManualMarkerRemoved bool `json:"manual_marker_removed,omitempty"` - ManualMarkerPreserved bool `json:"manual_marker_preserved,omitempty"` - ManualMarkerRemoveError string `json:"manual_marker_remove_error,omitempty"` - LiveIndexCandidates int `json:"live_index_candidates,omitempty"` - LiveIndexApplied int `json:"live_index_applied,omitempty"` - LiveIndexSkipped int `json:"live_index_skipped,omitempty"` + + // Apply-only proof and hooks. These never cross the JSON plan boundary. + plannedPauseMarker *pausepkg.Marker + plannedPauseInfo os.FileInfo + afterBackup func() + beforePauseRemove func() + afterPauseQuarantine func(string) + commitTransaction func(*sql.Tx) error } type fixBlockerVerification struct { @@ -96,6 +94,9 @@ type fixAction struct { SetAt string `json:"set_at,omitempty"` RequiresForce bool `json:"requires_force,omitempty"` State string `json:"state,omitempty"` + ArchiveOnly bool `json:"archive_only,omitempty"` + InvalidateShadow bool `json:"invalidate_shadow,omitempty"` + RecoveryRef string `json:"recovery_ref,omitempty"` } func newFixCmd() *cobra.Command { @@ -105,13 +106,13 @@ func newFixCmd() *cobra.Command { Long: `Plan or apply guided remediation for common stuck ACD states. ` + "`acd fix`" + ` is the single recovery entrypoint. Without --yes and -without --force it prints a dry-run plan only. --yes applies the safe, -auto-resolvable actions (resolve already-landed barriers, retarget stale -anchors, clear obsolete barriers, mark externally-published rows, clear -expired manual pauses, clear drained backpressure). --force opts into the -destructive purge of replay barriers that still have pending successors; ---force without --yes is still dry-run. All actions refuse while a live -daemon owns the state DB, and state.db is backed up before any mutation.`, +without --force it prints a dry-run plan only. --yes applies safe recovery: +each stuck unpublished branch/generation pair is either proven present at a +stable HEAD or preserved at a hidden recovery ref before its queue state +changes. --force selects archive-only recovery for otherwise unresolved pairs; +it never deletes captured work. --force without --yes is still dry-run. All +actions refuse while a live daemon owns the state DB, and state.db is backed +up before any mutation.`, Example: ` acd fix --dry-run acd fix --yes acd fix --force --dry-run @@ -130,8 +131,8 @@ daemon owns the state DB, and state.db is backed up before any mutation.`, } cmd.Flags().Bool("dry-run", false, "Show the guided remediation plan without mutating state") cmd.Flags().Bool("yes", false, "Apply safe remediation actions (auto/safe set)") - cmd.Flags().Bool("force", false, "Include destructive purge of replay barriers with pending successors in the plan; combine with --yes to apply") - cmd.Flags().Bool("clear-pause", false, "Also remove the manual pause marker when retargeting a stale anchor") + cmd.Flags().Bool("force", false, "Use archive-only recovery for unresolved unpublished pairs; captured work is protected before state changes") + cmd.Flags().Bool("clear-pause", false, "Also remove the manual pause marker after safe recovery") return cmd } @@ -139,12 +140,10 @@ func runFix(ctx context.Context, out io.Writer, repo string, dryRun, yes, force, if ctx == nil { ctx = context.Background() } - // Resolve effective dry-run vs apply mode. The rules per SPEC LOCK: - // neither --yes nor --force: dry-run default (same as --dry-run). - // --yes alone: apply auto/safe actions (no purge). - // --force without --yes: dry-run that INCLUDES purge plan. - // --yes --force: apply auto + purge. - // An explicit --dry-run always wins (operator inspection). + // Resolve effective dry-run vs apply mode. Neither --yes nor --force + // mutates by itself: --yes authorizes apply, while --force only changes + // planned pair reconciliation to archive-only recovery. An explicit + // --dry-run always wins. if !yes { dryRun = true } @@ -168,6 +167,7 @@ func runFix(ctx context.Context, out io.Writer, repo string, dryRun, yes, force, } if len(plan.Actions) > 0 { if err := applyFixPlan(ctx, rec.StateDB, &plan); err != nil { + markFixIncomplete(&plan, err) if rerr := renderFix(out, plan, jsonOut); rerr != nil { return rerr } @@ -189,6 +189,14 @@ func runFix(ctx context.Context, out io.Writer, repo string, dryRun, yes, force, return renderFix(out, plan, jsonOut) } +func markFixIncomplete(plan *fixPlan, err error) { + if plan.Incomplete { + return + } + plan.Incomplete = true + plan.VerifyErrors = append(plan.VerifyErrors, err.Error()) +} + func buildFixPlan(ctx context.Context, repo, stateDB string, dryRun, force, clearPause bool) (fixPlan, error) { gitDir, err := resolveGitDir(ctx, repo) if err != nil { @@ -199,9 +207,12 @@ func buildFixPlan(ctx context.Context, repo, stateDB string, dryRun, force, clea return fixPlan{}, fmt.Errorf("acd fix: resolve HEAD branch: %w", err) } head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { + if err != nil && !errors.Is(err, git.ErrRefNotFound) { return fixPlan{}, fmt.Errorf("acd fix: resolve HEAD: %w", err) } + if errors.Is(err, git.ErrRefNotFound) { + head = "" + } conn, err := openStateDBReadOnly(ctx, stateDB) if err != nil { @@ -237,6 +248,13 @@ func buildFixPlan(ctx context.Context, repo, stateDB string, dryRun, force, clea if branchRef == "" { plan.Unsafe = append(plan.Unsafe, "detached HEAD is not safe for guided state mutation") plan.Suggestions = append(plan.Suggestions, "Checkout an attached branch before applying fixes.") + } else if head == "" && !force { + plan.Unsafe = append(plan.Unsafe, "attached HEAD has no commit; archive-only recovery must be explicitly requested") + plan.Suggestions = append(plan.Suggestions, "Rerun with `acd fix --force --yes` to preserve complete unpublished pairs at hidden recovery refs.") + } + if name, active := daemon.GitOperationInProgress(gitDir); active { + plan.Unsafe = append(plan.Unsafe, fmt.Sprintf("Git operation %s is in progress", name)) + plan.Suggestions = append(plan.Suggestions, "Finish or abort the Git operation before applying `acd fix --yes`.") } if err := planManualPauseFix(ctx, gitDir, &plan); err != nil { @@ -249,61 +267,122 @@ func buildFixPlan(ctx context.Context, repo, stateDB string, dryRun, force, clea if err != nil { return fixPlan{}, fmt.Errorf("acd fix: check decision ledger: %w", err) } - if hasDecisionRecords { - if err := planExternalDecisionFix(ctx, conn, repo, head, &plan); err != nil { - return fixPlan{}, err - } - } if err := planGeneratedPendingCleanup(ctx, conn, repo, &plan); err != nil { return fixPlan{}, err } - // resolve_already_landed_barrier must precede delete_obsolete_barrier: - // a blocked row whose captured after-state matches HEAD is BOTH "obsolete" - // (no pending successors) AND "already landed externally". The promote - // path preserves the decision_records audit trail, so we want that - // outcome instead of a silent delete. Build a skip-set keyed by seq so - // planObsoleteBarrierFix never plans a delete for the same row. - resolveSeqs := map[int64]struct{}{} - if branchRef != "" { - if err := planResolveAlreadyLandedBarrier(ctx, conn, repo, head, branchRef, plan.Generation, &plan); err != nil { - return fixPlan{}, err - } - for _, a := range plan.Actions { - if a.Kind == fixActionResolveAlreadyLandedBarrier { - resolveSeqs[a.Seq] = struct{}{} + if err := planUnpublishedChainReconciliation(ctx, conn, branchRef, plan.Generation, head, force, hasDecisionRecords, &plan); err != nil { + return fixPlan{}, err + } + return plan, nil +} + +type unpublishedFixPair struct { + branchRef string + generation int64 + firstSeq int64 + lastSeq int64 + eventCount int + hasTerminal bool + decisionLed bool +} + +// planUnpublishedChainReconciliation emits one action per exact provenance +// pair. It never rewrites a row onto the live branch and never peels one root +// barrier away from its successors. The daemon reconciler owns the eventual +// all-or-none proof/archive transition. +func planUnpublishedChainReconciliation( + ctx context.Context, + conn *sql.DB, + currentBranch string, + currentGeneration int64, + currentHead string, + force bool, + hasDecisionRecords bool, + plan *fixPlan, +) error { + decisionExpr := "0" + if hasDecisionRecords { + decisionExpr = `EXISTS ( + SELECT 1 + FROM decision_records d + WHERE d.event_seq = e.seq + AND d.kind IN ('handled_external', 'handled_external_after_block', 'superseded_external') +)` + } + rows, err := conn.QueryContext(ctx, ` +SELECT e.seq, e.branch_ref, e.branch_generation, e.state, `+decisionExpr+` +FROM capture_events e +WHERE e.state IN (?, ?, ?) +ORDER BY e.branch_ref, e.branch_generation, e.seq`, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed) + if err != nil { + return fmt.Errorf("acd fix: scan unpublished recovery pairs: %w", err) + } + defer rows.Close() + + pairs := make(map[string]*unpublishedFixPair) + var order []string + for rows.Next() { + var seq, generation int64 + var branchRef, eventState string + var decisionLed bool + if err := rows.Scan(&seq, &branchRef, &generation, &eventState, &decisionLed); err != nil { + return fmt.Errorf("acd fix: scan unpublished recovery row: %w", err) + } + key := fmt.Sprintf("%s\x00%d", branchRef, generation) + pair := pairs[key] + if pair == nil { + pair = &unpublishedFixPair{ + branchRef: branchRef, generation: generation, + firstSeq: seq, lastSeq: seq, } + pairs[key] = pair + order = append(order, key) } + pair.lastSeq = seq + pair.eventCount++ + pair.hasTerminal = pair.hasTerminal || eventState == state.EventStateBlockedConflict || eventState == state.EventStateFailed + pair.decisionLed = pair.decisionLed || decisionLed } - if err := planObsoleteBarrierFix(ctx, conn, hasDecisionRecords, &plan, resolveSeqs); err != nil { - return fixPlan{}, err + if err := rows.Err(); err != nil { + return fmt.Errorf("acd fix: iterate unpublished recovery rows: %w", err) } - if branchRef != "" { - if force { - // Destructive purges must be applied before retarget_stale_anchor can - // reset blocked_conflict rows back to pending. Keep this order in the - // action list so --force --yes is order-safe. - if err := planPurgeBarrierWithSuccessors(ctx, conn, branchRef, plan.Generation, &plan); err != nil { - return fixPlan{}, err - } - } else { - // Without --force we never plan the purge, but we still nudge the - // operator when a stuck barrier exists so they can opt in. - counts, err := loadRecoveryBlockerCounts(ctx, conn, branchRef, plan.Generation) - if err != nil { - return fixPlan{}, err - } - n := counts.ActiveBlockedBarriersWithSuccessors + counts.FailedBarriersWithSuccessors - if n > 0 { - plan.ForceRequired = true - plan.Suggestions = append(plan.Suggestions, fmt.Sprintf( - "%d replay barrier row(s) still have pending successors; run `acd fix --repo %s --force --yes` to purge them.", n, plan.Repo)) - } + + for _, key := range order { + pair := pairs[key] + activePair := pair.branchRef == currentBranch && pair.generation == currentGeneration + stalePair := !activePair + if !pair.hasTerminal && !stalePair && !pair.decisionLed { + continue + } + archiveOnly := force || currentHead == "" + reasonParts := make([]string, 0, 3) + if pair.hasTerminal { + reasonParts = append(reasonParts, "terminal replay barrier") } - if err := planRetargetStaleAnchor(ctx, conn, repo, head, branchRef, plan.Generation, &plan); err != nil { - return fixPlan{}, err + if stalePair { + reasonParts = append(reasonParts, "non-active provenance pair") } + if pair.decisionLed { + reasonParts = append(reasonParts, "external decision evidence") + } + plan.Actions = append(plan.Actions, fixAction{ + ID: fmt.Sprintf("%s:%s:%d:%d", fixActionReconcileUnpublishedChain, pair.branchRef, pair.generation, pair.firstSeq), + Kind: fixActionReconcileUnpublishedChain, + Description: "prove or preserve the exact unpublished branch/generation chain", + Reason: strings.Join(reasonParts, "; "), + Seq: pair.firstSeq, + BranchRef: pair.branchRef, + BranchGeneration: pair.generation, + OldestSeq: pair.firstSeq, + NewestSeq: pair.lastSeq, + PendingCount: pair.eventCount, + ArchiveOnly: archiveOnly, + InvalidateShadow: activePair, + RequiresForce: force, + }) } - return plan, nil + return nil } func daemonAliveSQL(ctx context.Context, conn *sql.DB) (bool, string, error) { @@ -318,23 +397,13 @@ func daemonAliveSQL(ctx context.Context, conn *sql.DB) (bool, string, error) { } switch mode { case "running", "starting", "draining": - if pid > 0 && identityAlive(ctx, pid) { + if pid > 0 && identity.AliveContext(ctx, pid) { return true, fmt.Sprintf("daemon pid %d is alive in mode %s", pid, mode), nil } } return false, "", nil } -func identityAlive(ctx context.Context, pid int) bool { - return pid > 0 && identityAliveContext(ctx, pid) -} - -var identityAliveContext = identityAliveContextDefault - -func identityAliveContextDefault(ctx context.Context, pid int) bool { - return identity.AliveContext(ctx, pid) -} - func planManualPauseFix(ctx context.Context, gitDir string, plan *fixPlan) error { marker, ok, err := ReadMarker(gitDir) if err != nil { @@ -344,7 +413,26 @@ func planManualPauseFix(ctx context.Context, gitDir string, plan *fixPlan) error if !ok { return nil } + markerInfo, err := os.Lstat(markerPath) + if err != nil { + return fmt.Errorf("acd fix: stat planned manual pause marker: %w", err) + } + if !markerInfo.Mode().IsRegular() { + return fmt.Errorf("acd fix: manual pause marker %s is not a regular file", markerPath) + } plan.ManualPausePath = markerPath + plan.plannedPauseMarker = &marker + plan.plannedPauseInfo = markerInfo + if plan.ClearPause { + plan.Actions = append(plan.Actions, fixAction{ + ID: fixActionClearExpiredManualPause, + Kind: fixActionClearExpiredManualPause, + Description: "remove manual pause marker after safe recovery", + Reason: "operator explicitly requested --clear-pause", + SetAt: marker.SetAt, + }) + return nil + } if marker.ExpiresAt == nil || strings.TrimSpace(*marker.ExpiresAt) == "" { plan.Suggestions = append(plan.Suggestions, "Manual pause marker is active; run `acd resume --yes` when the pause is intentional to lift.") return nil @@ -397,191 +485,6 @@ func planBackpressureFix(ctx context.Context, conn *sql.DB, plan *fixPlan) error return nil } -func planObsoleteBarrierFix(ctx context.Context, conn *sql.DB, hasDecisionRecords bool, plan *fixPlan, skipSeqs map[int64]struct{}) error { - decisionFilter := "" - if hasDecisionRecords { - decisionFilter = ` - AND NOT EXISTS ( - SELECT 1 - FROM decision_records d - WHERE d.event_seq = e.seq - AND d.kind IN (?, ?) - AND d.commit_oid IS NOT NULL - AND d.commit_oid <> '' - )` - } - q := ` -SELECT seq, path, state -FROM capture_events e -WHERE state IN (?, ?) -` + decisionFilter + ` - AND NOT EXISTS ( - SELECT 1 - FROM capture_events pending - WHERE pending.branch_ref = e.branch_ref - AND pending.branch_generation = e.branch_generation - AND pending.seq > e.seq - AND pending.state = ? - ) -ORDER BY seq ASC` - args := []any{state.EventStateBlockedConflict, state.EventStateFailed} - if hasDecisionRecords { - args = append(args, state.DecisionKindHandledExternal, state.DecisionKindSupersededExternal) - } - args = append(args, state.EventStatePending) - rows, err := conn.QueryContext(ctx, q, args...) - if err != nil { - return fmt.Errorf("acd fix: query obsolete barriers: %w", err) - } - defer rows.Close() - - for rows.Next() { - var seq int64 - var path, st string - if err := rows.Scan(&seq, &path, &st); err != nil { - return fmt.Errorf("acd fix: scan obsolete barrier: %w", err) - } - if _, skip := skipSeqs[seq]; skip { - // resolve_already_landed_barrier already owns this seq; skip the - // delete so the promote path can run and keep the audit trail. - continue - } - plan.Actions = append(plan.Actions, fixAction{ - ID: fmt.Sprintf("%s:%d", fixActionDeleteObsoleteBarrier, seq), - Kind: fixActionDeleteObsoleteBarrier, - Description: "delete obsolete terminal replay barrier with no pending successors", - Reason: st, - Seq: seq, - Path: path, - }) - } - if err := rows.Err(); err != nil { - return fmt.Errorf("acd fix: iterate obsolete barriers: %w", err) - } - - var blocking int - blockingDecisionFilter := "" - if hasDecisionRecords { - blockingDecisionFilter = ` - AND NOT EXISTS ( - SELECT 1 - FROM decision_records d - WHERE d.event_seq = e.seq - AND d.kind IN (?, ?) - AND d.commit_oid IS NOT NULL - AND d.commit_oid <> '' - )` - } - blockingQ := ` -SELECT COUNT(*) -FROM capture_events e -WHERE state IN (?, ?) -` + blockingDecisionFilter + ` - AND EXISTS ( - SELECT 1 - FROM capture_events pending - WHERE pending.branch_ref = e.branch_ref - AND pending.branch_generation = e.branch_generation - AND pending.seq > e.seq - AND pending.state = ? - )` - blockingArgs := []any{state.EventStateBlockedConflict, state.EventStateFailed} - if hasDecisionRecords { - blockingArgs = append(blockingArgs, state.DecisionKindHandledExternal, state.DecisionKindSupersededExternal) - } - blockingArgs = append(blockingArgs, state.EventStatePending) - if err := conn.QueryRowContext(ctx, blockingQ, blockingArgs...).Scan(&blocking); err != nil { - return fmt.Errorf("acd fix: count active barriers: %w", err) - } - if blocking > 0 { - plan.Suggestions = append(plan.Suggestions, fmt.Sprintf("%d terminal barrier rows still have pending successors; inspect with `acd diagnose` before purging.", blocking)) - } - return nil -} - -type externalDecisionCandidate struct { - seq int64 - path string - branchRef string - generation int64 - decisionID int64 - kind string - reason string - commitOID string -} - -func planExternalDecisionFix(ctx context.Context, conn *sql.DB, repo, head string, plan *fixPlan) error { - rows, err := conn.QueryContext(ctx, ` -SELECT e.seq, e.path, e.branch_ref, e.branch_generation, - d.id, d.kind, COALESCE(d.reason, ''), COALESCE(d.commit_oid, '') -FROM capture_events e -JOIN decision_records d ON d.event_seq = e.seq -WHERE e.state IN (?, ?) - AND d.kind IN (?, ?) - AND d.commit_oid IS NOT NULL - AND d.commit_oid <> '' - AND (d.branch_ref IS NULL OR d.branch_ref = e.branch_ref) - AND (d.branch_generation IS NULL OR d.branch_generation = e.branch_generation) -ORDER BY e.seq ASC, d.id DESC`, - state.EventStatePending, state.EventStateBlockedConflict, - state.DecisionKindHandledExternal, state.DecisionKindSupersededExternal) - if err != nil { - return fmt.Errorf("acd fix: query external decisions: %w", err) - } - defer rows.Close() - - seen := map[int64]bool{} - for rows.Next() { - var c externalDecisionCandidate - if err := rows.Scan(&c.seq, &c.path, &c.branchRef, &c.generation, &c.decisionID, &c.kind, &c.reason, &c.commitOID); err != nil { - return fmt.Errorf("acd fix: scan external decision: %w", err) - } - if seen[c.seq] { - continue - } - seen[c.seq] = true - ops, err := loadFixCaptureOps(ctx, conn, c.seq) - if err != nil { - return err - } - if len(ops) == 0 { - plan.Unsafe = append(plan.Unsafe, fmt.Sprintf("decision %d for event %d has no capture ops to verify", c.decisionID, c.seq)) - continue - } - ok, err := git.IsAncestor(ctx, repo, c.commitOID, head) - if err != nil { - plan.Unsafe = append(plan.Unsafe, fmt.Sprintf("decision %d for event %d references unresolved commit %s", c.decisionID, c.seq, c.commitOID)) - continue - } - if !ok { - plan.Unsafe = append(plan.Unsafe, fmt.Sprintf("decision %d for event %d references commit %s outside current HEAD history", c.decisionID, c.seq, shortOID(c.commitOID, 12))) - continue - } - matches, err := currentHeadMatchesFixDecision(ctx, repo, head, c.kind, ops) - if err != nil { - return err - } - if !matches { - plan.Unsafe = append(plan.Unsafe, fmt.Sprintf("decision %d for event %d no longer matches current HEAD tree", c.decisionID, c.seq)) - continue - } - plan.Actions = append(plan.Actions, fixAction{ - ID: fmt.Sprintf("%s:%d", fixActionMarkExternalPublished, c.seq), - Kind: fixActionMarkExternalPublished, - Description: "mark externally handled queued event as published", - Reason: c.kind + ":" + c.reason, - Seq: c.seq, - Path: c.path, - DecisionID: c.decisionID, - CommitOID: c.commitOID, - }) - } - if err := rows.Err(); err != nil { - return fmt.Errorf("acd fix: iterate external decisions: %w", err) - } - return nil -} - func planGeneratedPendingCleanup(ctx context.Context, conn *sql.DB, repo string, plan *fixPlan) error { groups, err := state.ScanGeneratedPendingDeletes(ctx, conn, state.NewSafeIgnoreMatcher(), 0) if err != nil { @@ -650,114 +553,67 @@ ORDER BY ord ASC`, seq) return ops, nil } -func currentHeadMatchesFixDecision(ctx context.Context, repo, head, kind string, ops []state.CaptureOp) (bool, error) { - for _, op := range ops { - var ok bool - var err error - switch kind { - case state.DecisionKindHandledExternal: - ok, err = treeMatchesCapturedAfter(ctx, repo, head, op) - case state.DecisionKindSupersededExternal: - ok, err = treeMatchesCapturedBeforeForFix(ctx, repo, head, op) - default: - ok = false - } - if err != nil { - return false, err - } - if !ok { - return false, nil - } - } - return true, nil -} - -func treeMatchesCapturedAfter(ctx context.Context, repo, ref string, op state.CaptureOp) (bool, error) { - if op.Op == "delete" { - return treePathAbsent(ctx, repo, ref, op.Path) - } - if !op.AfterOID.Valid || op.AfterOID.String == "" { - return false, nil - } - if ok, err := treeBlobMatches(ctx, repo, ref, op.Path, op.AfterOID.String, op.AfterMode); err != nil || !ok { - return ok, err - } - if op.Op == "rename" && op.OldPath.Valid && op.OldPath.String != "" { - return treePathAbsent(ctx, repo, ref, op.OldPath.String) +func applyFixPlan(ctx context.Context, stateDB string, plan *fixPlan) error { + if plan.CurrentBranchRef == "" { + return fmt.Errorf("acd fix: refusing to mutate state while HEAD is detached") } - return true, nil -} - -func treeMatchesCapturedBeforeForFix(ctx context.Context, repo, ref string, op state.CaptureOp) (bool, error) { - switch op.Op { - case "create": - return treePathAbsent(ctx, repo, ref, op.Path) - case "delete", "modify": - if !op.BeforeOID.Valid || op.BeforeOID.String == "" { - return false, nil + // Defense-in-depth: refuse archive-only recovery actions unless the plan's + // Force flag was set at build time. Planner gating already prevents these + // actions from appearing without --force, so this catches re-hydrated plans + // or alternate callers that try to bypass the explicit opt-in. + for _, action := range plan.Actions { + if action.RequiresForce && !plan.Force { + return fmt.Errorf("acd fix: refusing to apply %s without --force (planner gating bypassed)", action.Kind) } - return treeBlobMatches(ctx, repo, ref, op.Path, op.BeforeOID.String, op.BeforeMode) - default: - return false, nil } -} - -func treeBlobMatches(ctx context.Context, repo, ref, path, oid string, mode sql.NullString) (bool, error) { - entries, err := git.LsTree(ctx, repo, ref, false, path) + daemonLock, err := daemon.AcquireDaemonLock(plan.GitDir) if err != nil { - return false, fmt.Errorf("acd fix: ls-tree %s %s: %w", ref, path, err) - } - for _, entry := range entries { - if entry.Path != path || entry.Type != "blob" { - continue - } - if entry.OID != oid { - return false, nil - } - if mode.Valid && mode.String != "" && entry.Mode != mode.String { - return false, nil + if errors.Is(err, daemon.ErrDaemonLockHeld) { + return fmt.Errorf("acd fix: refusing while the per-repo daemon owns daemon.lock: %w", err) } - return true, nil + return fmt.Errorf("acd fix: acquire daemon.lock: %w", err) } - return false, nil -} + defer func() { _ = daemonLock.Release() }() -func treePathAbsent(ctx context.Context, repo, ref, path string) (bool, error) { - entries, err := git.LsTree(ctx, repo, ref, false, path) - if err != nil { - return false, fmt.Errorf("acd fix: ls-tree %s %s: %w", ref, path, err) + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err } - for _, entry := range entries { - if entry.Path == path { - return false, nil - } + if err := preflightFixFS(plan); err != nil { + return err } - return true, nil -} - -func applyFixPlan(ctx context.Context, stateDB string, plan *fixPlan) error { - if plan.CurrentBranchRef == "" { - return fmt.Errorf("acd fix: refusing to mutate state while HEAD is detached") + // Preserve the original WAL-consistent database through a raw connection. + // state.Open may apply schema DDL, so it must not run until this verified + // pre-migration backup exists. + backupConn, err := openFixBackupDB(ctx, stateDB) + if err != nil { + return err } - // Defense-in-depth: refuse to apply any RequiresForce action unless the - // plan's Force flag was set at build time. Planner gating already prevents - // these actions from appearing without --force, so this guard catches - // future refactors (re-hydrated plans, alternate callers) before they can - // silently apply a destructive purge. - for _, action := range plan.Actions { - if action.RequiresForce && !plan.Force { - return fmt.Errorf("acd fix: refusing to apply %s without --force (planner gating bypassed)", action.Kind) - } + if err := refuseRecoverWhenDaemonAliveSQL(ctx, backupConn); err != nil { + _ = backupConn.Close() + return err } - if err := preflightFixFS(plan); err != nil { + if err := revalidateFixApplySafety(ctx, plan); err != nil { + _ = backupConn.Close() return err } - backup, err := backupStateDB(stateDB) + backup, err := backupStateDB(ctx, backupConn, stateDB) if err != nil { + _ = backupConn.Close() return fmt.Errorf("acd fix: backup state.db: %w", err) } plan.BackupPath = backup + if err := backupConn.Close(); err != nil { + return fmt.Errorf("acd fix: close pre-migration backup connection: %w", err) + } + if plan.afterBackup != nil { + plan.afterBackup() + } + // daemon.lock excludes daemon startup, but external Git surgery does not use + // it. Repeat every apply-safety check after the potentially long backup. + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err + } db, err := state.Open(ctx, stateDB) if err != nil { return fmt.Errorf("acd fix: open state.db: %w", err) @@ -766,83 +622,195 @@ func applyFixPlan(ctx context.Context, stateDB string, plan *fixPlan) error { if err := refuseRecoverWhenDaemonAlive(ctx, db); err != nil { return err } - branchRef, err := git.RunBranchRef(ctx, plan.Repo) - if err != nil { - return fmt.Errorf("acd fix: recheck HEAD branch: %w", err) - } - head, err := git.RevParse(ctx, plan.Repo, "HEAD") - if err != nil { - return fmt.Errorf("acd fix: recheck HEAD: %w", err) - } - if branchRef != plan.CurrentBranchRef || head != plan.CurrentHead { - return fmt.Errorf("acd fix: refusing to mutate state because HEAD changed during planning") - } - - tx, err := db.SQL().BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("acd fix: begin transaction: %w", err) + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err } - defer func() { _ = tx.Rollback() }() - nowSec := float64(time.Now().UTC().UnixNano()) / 1e9 + // Git-backed reconciliation owns its own ref-first/SQLite transaction. Run + // it before housekeeping so generated-row cleanup or breadcrumb changes can + // never remove captured work before the hidden proof/recovery ref exists. for i := range plan.Actions { - n, err := applyFixAction(ctx, tx, plan.Actions[i], plan, nowSec) - if err != nil { + action := &plan.Actions[i] + if action.Kind != fixActionReconcileUnpublishedChain { + continue + } + if err := revalidateFixApplySafety(ctx, plan); err != nil { return err } - plan.Actions[i].RowsChanged = n - plan.Actions[i].Applied = true - plan.RowsChanged += n + result, err := daemon.ReconcileUnpublishedChain(ctx, plan.Repo, db, daemon.RecoveryReconcileOptions{ + GitDir: plan.GitDir, + BranchRef: action.BranchRef, + BranchGeneration: action.BranchGeneration, + FirstSeq: action.Seq, + Trigger: "cli_fix", + ArchiveOnly: action.ArchiveOnly, + InvalidateShadow: action.InvalidateShadow, + }) + if err != nil { + return fmt.Errorf("acd fix: reconcile %s generation %d from seq %d: %w", + action.BranchRef, action.BranchGeneration, action.Seq, err) + } + action.Applied = true + if result.Handled { + action.RowsChanged = int64(result.EventCount) + action.CommitOID = result.CommitOID + action.RecoveryRef = result.RecoveryRef + action.State = result.Outcome + plan.RowsChanged += int64(result.EventCount) + } } - if err := clearPublishBarrierIfSafe(ctx, tx, nowSec); err != nil { - return err + + transactional := false + for _, action := range plan.Actions { + if action.Kind != fixActionReconcileUnpublishedChain && action.Kind != fixActionClearExpiredManualPause { + transactional = true + break + } } - if err := tx.Commit(); err != nil { - return fmt.Errorf("acd fix: commit transaction: %w", err) + if transactional { + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err + } + tx, err := db.SQL().BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("acd fix: begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + type pendingActionResult struct { + index int + rows int64 + } + var pendingResults []pendingActionResult + nowSec := float64(time.Now().UTC().UnixNano()) / 1e9 + for i := range plan.Actions { + if plan.Actions[i].Kind == fixActionReconcileUnpublishedChain || plan.Actions[i].Kind == fixActionClearExpiredManualPause { + continue + } + n, err := applyFixAction(ctx, tx, plan.Actions[i], nowSec) + if err != nil { + return err + } + pendingResults = append(pendingResults, pendingActionResult{index: i, rows: n}) + } + if err := clearPublishBarrierIfSafe(ctx, tx, nowSec); err != nil { + return err + } + commit := tx.Commit + if plan.commitTransaction != nil { + commit = func() error { return plan.commitTransaction(tx) } + } + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err + } + if err := commit(); err != nil { + return fmt.Errorf("acd fix: commit transaction: %w", err) + } + for _, result := range pendingResults { + plan.Actions[result.index].RowsChanged = result.rows + plan.Actions[result.index].Applied = true + plan.RowsChanged += result.rows + } } for _, action := range plan.Actions { if action.Kind != fixActionClearExpiredManualPause { continue } - if err := os.Remove(plan.ManualPausePath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("acd fix: remove manual pause marker: %w", err) + if err := revalidateFixApplySafety(ctx, plan); err != nil { + return err + } + if plan.beforePauseRemove != nil { + plan.beforePauseRemove() + } + if err := removePlannedPauseMarker(plan); err != nil { + return err } stampManualPauseResume(ctx, plan.GitDir) + for i := range plan.Actions { + if plan.Actions[i].Kind == fixActionClearExpiredManualPause { + plan.Actions[i].Applied = true + } + } plan.ManualPauseRemoved = true } + if err := verifyFixPostApply(ctx, db.SQL(), plan); err != nil { + return err + } + return nil +} + +func openFixBackupDB(ctx context.Context, stateDB string) (*sql.DB, error) { + q := url.Values{} + q.Add("mode", "rw") + q.Add("_pragma", "busy_timeout(5000)") + conn, err := sql.Open("sqlite", "file:"+stateDB+"?"+q.Encode()) + if err != nil { + return nil, fmt.Errorf("acd fix: open state.db for pre-migration backup: %w", err) + } + conn.SetMaxOpenConns(1) + conn.SetMaxIdleConns(1) + if err := conn.PingContext(ctx); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("acd fix: ping state.db for pre-migration backup: %w", err) + } + return conn, nil +} - // Retarget post-commit phase: when retarget_stale_anchor was applied, - // run the live-index repair pass and reconcile the manual pause marker - // the same way acd recover did. These steps must run AFTER tx.Commit so - // the slow git ops never hold the SQLite write lock. - if planHasAction(plan, fixActionRetargetStaleAnchor) { - if err := finalizeRetargetPostCommit(ctx, db, plan); err != nil { - return err - } +func revalidateFixApplySafety(ctx context.Context, plan *fixPlan) error { + branchRef, err := git.RunBranchRef(ctx, plan.Repo) + if err != nil { + return fmt.Errorf("acd fix: recheck HEAD branch: %w", err) } - if err := verifyFixPostApply(ctx, db.SQL(), plan); err != nil { + head, err := git.RevParse(ctx, plan.Repo, "HEAD") + if err != nil && !errors.Is(err, git.ErrRefNotFound) { + return fmt.Errorf("acd fix: recheck HEAD: %w", err) + } + if errors.Is(err, git.ErrRefNotFound) { + head = "" + } + if branchRef != plan.CurrentBranchRef || head != plan.CurrentHead { + return fmt.Errorf("acd fix: refusing to mutate state because HEAD changed during planning") + } + if name, active := daemon.GitOperationInProgress(plan.GitDir); active { + return fmt.Errorf("acd fix: refusing to mutate state while Git operation %s is in progress", name) + } + if err := revalidateFixPauseState(plan); err != nil { return err } return nil } -func planHasAction(plan *fixPlan, kind string) bool { - for _, action := range plan.Actions { - if action.Kind == kind { - return true +func revalidateFixPauseState(plan *fixPlan) error { + marker, ok, err := pausepkg.Read(plan.GitDir) + if err != nil { + return fmt.Errorf("acd fix: re-read manual pause marker: %w", err) + } + if plan.plannedPauseMarker == nil { + if ok { + return fmt.Errorf("acd fix: manual pause marker appeared since planning; preserving current marker") } + return nil } - return false + if !ok { + return fmt.Errorf("acd fix: manual pause marker disappeared since planning") + } + info, err := os.Lstat(plan.ManualPausePath) + if err != nil { + return fmt.Errorf("acd fix: stat manual pause marker: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("acd fix: manual pause marker %s is not a regular file", plan.ManualPausePath) + } + if plan.plannedPauseInfo == nil || !os.SameFile(plan.plannedPauseInfo, info) || + !samePauseMarker(*plan.plannedPauseMarker, marker) { + return fmt.Errorf("acd fix: manual pause marker changed since planning; preserving current marker") + } + return nil } func verifyFixPostApply(ctx context.Context, conn *sql.DB, plan *fixPlan) error { var errs []string - for _, action := range plan.Actions { - if action.Kind == fixActionPurgeBarrierWithSuccessors && action.RowsChanged == 0 { - errs = append(errs, fmt.Sprintf("planned purge seq=%d changed zero rows", action.Seq)) - } - } counts, err := loadRecoveryBlockerCounts(ctx, conn, plan.CurrentBranchRef, plan.Generation) if err != nil { return fmt.Errorf("acd fix: verify post-apply blockers: %w", err) @@ -874,43 +842,111 @@ func preflightFixFS(plan *fixPlan) error { if action.Kind != fixActionClearExpiredManualPause { continue } - info, err := os.Lstat(plan.ManualPausePath) - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("acd fix: manual pause marker disappeared before apply") - } - if err != nil { - return fmt.Errorf("acd fix: stat manual pause marker: %w", err) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("acd fix: manual pause marker %s is not a regular file", plan.ManualPausePath) + if err := verifyPlannedPauseMarker(plan); err != nil { + return err } if err := checkParentDirWritable(plan.ManualPausePath); err != nil { return fmt.Errorf("acd fix: manual pause marker parent not writable: %w", err) } } - // Retarget with --clear-pause also wants to remove the manual pause - // marker; perform the same FS preflight BEFORE opening the SQLite - // write tx so a slow stat on a network mount cannot hold the write - // lock. The retarget path uses plan.ManualMarkerPath (computed by - // planRetargetStaleAnchor) which may equal plan.ManualPausePath; the - // stat is cheap and idempotent so duplication is fine. - if plan.ClearPause && planHasAction(plan, fixActionRetargetStaleAnchor) { - markerPath := plan.ManualPausePath - if info, err := os.Lstat(markerPath); err == nil { - if !info.Mode().IsRegular() { - return fmt.Errorf("acd fix: manual pause marker %s is not a regular file", markerPath) - } - if err := checkParentDirWritable(markerPath); err != nil { - return fmt.Errorf("acd fix: manual pause marker parent not writable: %w", err) - } - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("acd fix: stat manual pause marker %s: %w", markerPath, err) - } + return nil +} + +func verifyPlannedPauseMarker(plan *fixPlan) error { + if plan.plannedPauseMarker == nil || plan.plannedPauseInfo == nil { + return fmt.Errorf("acd fix: manual pause marker proof missing from plan") + } + return revalidateFixPauseState(plan) +} + +func samePauseMarker(a, b pausepkg.Marker) bool { + if a.Reason != b.Reason || a.SetAt != b.SetAt || a.SetBy != b.SetBy || a.Version != b.Version { + return false + } + if a.ExpiresAt == nil || b.ExpiresAt == nil { + return a.ExpiresAt == nil && b.ExpiresAt == nil + } + return *a.ExpiresAt == *b.ExpiresAt +} + +func removePlannedPauseMarker(plan *fixPlan) error { + if plan.plannedPauseMarker == nil || plan.plannedPauseInfo == nil { + return fmt.Errorf("acd fix: manual pause marker proof missing from plan") + } + quarantineDir, err := os.MkdirTemp(filepath.Dir(plan.ManualPausePath), ".paused-fix-quarantine-") + if err != nil { + return fmt.Errorf("acd fix: create pause marker quarantine: %w", err) + } + quarantinePath := filepath.Join(quarantineDir, "paused") + if err := os.Rename(plan.ManualPausePath, quarantinePath); err != nil { + _ = os.Remove(quarantineDir) + return fmt.Errorf("acd fix: quarantine manual pause marker: %w", err) + } + if plan.afterPauseQuarantine != nil { + plan.afterPauseQuarantine(quarantinePath) + } + + marker, info, err := readQuarantinedPauseMarker(quarantinePath) + if err != nil { + return preserveQuarantinedPauseMarker(plan.ManualPausePath, quarantinePath, err) + } + if !os.SameFile(plan.plannedPauseInfo, info) || !samePauseMarker(*plan.plannedPauseMarker, marker) { + return preserveQuarantinedPauseMarker(plan.ManualPausePath, quarantinePath, + fmt.Errorf("manual pause marker changed since planning")) + } + if err := os.Remove(quarantinePath); err != nil { + return fmt.Errorf("acd fix: remove quarantined planned pause marker at %s: %w", quarantinePath, err) + } + _ = os.Remove(quarantineDir) + if _, err := os.Lstat(plan.ManualPausePath); err == nil { + return fmt.Errorf("acd fix: planned pause marker removed, but a new marker was created and preserved at %s", plan.ManualPausePath) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("acd fix: check for replacement pause marker after removal: %w", err) } return nil } -func applyFixAction(ctx context.Context, tx *sql.Tx, action fixAction, plan *fixPlan, nowSec float64) (int64, error) { +func readQuarantinedPauseMarker(path string) (pausepkg.Marker, os.FileInfo, error) { + var marker pausepkg.Marker + info, err := os.Lstat(path) + if err != nil { + return marker, nil, fmt.Errorf("stat quarantined marker: %w", err) + } + if !info.Mode().IsRegular() { + return marker, info, fmt.Errorf("quarantined marker is not a regular file") + } + body, err := os.ReadFile(path) + if err != nil { + return marker, info, fmt.Errorf("read quarantined marker: %w", err) + } + if err := json.Unmarshal(body, &marker); err != nil { + return marker, info, fmt.Errorf("decode quarantined marker: %w", err) + } + return marker, info, nil +} + +func preserveQuarantinedPauseMarker(originalPath, quarantinePath string, cause error) error { + if _, err := os.Lstat(originalPath); errors.Is(err, os.ErrNotExist) { + if linkErr := os.Link(quarantinePath, originalPath); linkErr == nil { + if removeErr := os.Remove(quarantinePath); removeErr != nil { + return fmt.Errorf("acd fix: %v; marker restored at %s but quarantine cleanup failed at %s: %w", + cause, originalPath, quarantinePath, removeErr) + } + _ = os.Remove(filepath.Dir(quarantinePath)) + return fmt.Errorf("acd fix: %v; current marker restored without removal at %s", cause, originalPath) + } else if !errors.Is(linkErr, os.ErrExist) { + return fmt.Errorf("acd fix: %v; marker preserved at %s after restore failed: %w", + cause, quarantinePath, linkErr) + } + } else if err != nil { + return fmt.Errorf("acd fix: %v; marker preserved at %s after original-path probe failed: %w", + cause, quarantinePath, err) + } + return fmt.Errorf("acd fix: %v; marker preserved at quarantine path %s because a marker exists at %s", + cause, quarantinePath, originalPath) +} + +func applyFixAction(ctx context.Context, tx *sql.Tx, action fixAction, nowSec float64) (int64, error) { switch action.Kind { case fixActionClearExpiredManualPause: return 0, nil @@ -934,137 +970,8 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.upd changed += n } return changed, nil - case fixActionDeleteObsoleteBarrier: - res, err := tx.ExecContext(ctx, ` -DELETE FROM capture_events -WHERE seq = ? - AND state IN (?, ?) - AND NOT EXISTS ( - SELECT 1 - FROM capture_events pending - WHERE pending.branch_ref = capture_events.branch_ref - AND pending.branch_generation = capture_events.branch_generation - AND pending.seq > capture_events.seq - AND pending.state = ? - )`, - action.Seq, state.EventStateBlockedConflict, state.EventStateFailed, state.EventStatePending) - if err != nil { - return 0, fmt.Errorf("acd fix: delete obsolete barrier seq %d: %w", action.Seq, err) - } - n, _ := res.RowsAffected() - return n, nil - case fixActionMarkExternalPublished: - res, err := tx.ExecContext(ctx, ` -UPDATE capture_events -SET state = ?, commit_oid = ?, error = NULL, published_ts = ? -WHERE seq = ? AND state IN (?, ?)`, - state.EventStatePublished, action.CommitOID, nowSec, action.Seq, - state.EventStatePending, state.EventStateBlockedConflict) - if err != nil { - return 0, fmt.Errorf("acd fix: mark event %d published: %w", action.Seq, err) - } - n, _ := res.RowsAffected() - if n > 0 { - if _, err := tx.ExecContext(ctx, ` -INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, target_commit_oid, status, error, updated_ts) -VALUES (1, ?, ?, ?, ?, ?, 'published', NULL, ?) -ON CONFLICT(id) DO UPDATE SET - event_seq = excluded.event_seq, - branch_ref = excluded.branch_ref, - branch_generation = excluded.branch_generation, - source_head = excluded.source_head, - target_commit_oid = excluded.target_commit_oid, - status = excluded.status, - error = excluded.error, - updated_ts = excluded.updated_ts`, - action.Seq, plan.CurrentBranchRef, plan.Generation, plan.CurrentHead, action.CommitOID, nowSec); err != nil { - return 0, fmt.Errorf("acd fix: update publish_state for event %d: %w", action.Seq, err) - } - } - return n, nil - case fixActionResolveAlreadyLandedBarrier: - // Promote the blocked_conflict row to published(commit_oid=HEAD) - // and record decision handled_external_after_block. Race-safe: the - // UPDATE includes the state predicate so a concurrent transition - // out of blocked_conflict cannot be overwritten. - res, err := tx.ExecContext(ctx, ` -UPDATE capture_events -SET state = ?, commit_oid = ?, error = NULL, published_ts = ? -WHERE seq = ? AND state = ?`, - state.EventStatePublished, action.CommitOID, nowSec, action.Seq, - state.EventStateBlockedConflict) - if err != nil { - return 0, fmt.Errorf("acd fix: promote blocked seq %d: %w", action.Seq, err) - } - n, _ := res.RowsAffected() - if n == 0 { - // Row already moved out of blocked_conflict (race with another - // recovery action or replay pass). Treat as a no-op — the row - // is no longer ours to settle here. - return 0, nil - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, target_commit_oid, status, error, updated_ts) -VALUES (1, ?, ?, ?, ?, ?, 'published', NULL, ?) -ON CONFLICT(id) DO UPDATE SET - event_seq = excluded.event_seq, - branch_ref = excluded.branch_ref, - branch_generation = excluded.branch_generation, - source_head = excluded.source_head, - target_commit_oid = excluded.target_commit_oid, - status = excluded.status, - error = excluded.error, - updated_ts = excluded.updated_ts`, - action.Seq, action.BranchRef, action.BranchGeneration, action.BaseHead, action.CommitOID, nowSec); err != nil { - return 0, fmt.Errorf("acd fix: upsert publish_state for self-heal seq %d: %w", action.Seq, err) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO decision_records( - decision_ts, kind, path, reason, event_seq, head_sha, commit_oid, - branch_ref, branch_generation, action_taken, user_message -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - nowSec, state.DecisionKindHandledExternalAfterBlock, - nullableString(action.Path), - nullableString("already_published_by_external_committer"), - sql.NullInt64{Int64: action.Seq, Valid: true}, - nullableString(action.BaseHead), - nullableString(action.CommitOID), - nullableString(action.BranchRef), - sql.NullInt64{Int64: action.BranchGeneration, Valid: true}, - nullableString("handled externally after block"), - nullableString(fmt.Sprintf("External commit cleared blocked replay for %s.", action.Path)), - ); err != nil { - return 0, fmt.Errorf("acd fix: append decision for self-heal seq %d: %w", action.Seq, err) - } - return n, nil case fixActionDropGeneratedPending: return applyDropGeneratedPending(ctx, tx, action) - case fixActionRetargetStaleAnchor: - return applyRetargetStaleAnchorTx(ctx, tx, plan, nowSec) - case fixActionPurgeBarrierWithSuccessors: - // Delete one specific terminal replay barrier by seq (planner enumerates - // rows with pending successors). Tightly scoped to seq + terminal states - // so concurrent transitions cannot accidentally clobber an unrelated row. - res, err := tx.ExecContext(ctx, ` -DELETE FROM capture_events -WHERE seq = ? AND state IN (?, ?)`, - action.Seq, state.EventStateBlockedConflict, state.EventStateFailed) - if err != nil { - return 0, fmt.Errorf("acd fix: purge terminal barrier seq %d: %w", action.Seq, err) - } - n, _ := res.RowsAffected() - if n > 0 { - // Clear publish_state breadcrumb if it pointed at this seq. - if _, err := tx.ExecContext(ctx, ` -UPDATE publish_state -SET status = 'ok', error = NULL, updated_ts = ? -WHERE id = 1 - AND status = 'blocked_conflict' - AND event_seq = ?`, nowSec, action.Seq); err != nil { - return 0, fmt.Errorf("acd fix: clear publish_state for purged seq %d: %w", action.Seq, err) - } - } - return n, nil default: return 0, fmt.Errorf("acd fix: unknown action kind %q", action.Kind) } @@ -1079,11 +986,21 @@ func applyDropGeneratedPending(ctx context.Context, tx *sql.Tx, action fixAction placeholders := placeholders(len(chunk)) args := int64Args(chunk) if _, err := tx.ExecContext(ctx, - `DELETE FROM planner_state WHERE event_seq IN (`+placeholders+`)`, args...); err != nil { + `DELETE FROM planner_state +WHERE event_seq IN (`+placeholders+`) + AND EXISTS ( + SELECT 1 FROM capture_events e + WHERE e.seq = planner_state.event_seq AND e.state = 'pending' AND e.operation = 'delete' + )`, args...); err != nil { return 0, fmt.Errorf("acd fix: delete generated planner state for %s: %w", action.GeneratedRoot, err) } if _, err := tx.ExecContext(ctx, - `DELETE FROM capture_ops WHERE event_seq IN (`+placeholders+`)`, args...); err != nil { + `DELETE FROM capture_ops +WHERE event_seq IN (`+placeholders+`) + AND EXISTS ( + SELECT 1 FROM capture_events e + WHERE e.seq = capture_ops.event_seq AND e.state = 'pending' AND e.operation = 'delete' + )`, args...); err != nil { return 0, fmt.Errorf("acd fix: delete generated capture ops for %s: %w", action.GeneratedRoot, err) } eventArgs := append(int64Args(chunk), state.EventStatePending, "delete") @@ -1128,10 +1045,6 @@ func int64Args(values []int64) []any { return args } -func nullableString(s string) sql.NullString { - return sql.NullString{String: s, Valid: s != ""} -} - func clearPublishBarrierIfSafe(ctx context.Context, tx *sql.Tx, nowSec float64) error { var blocked int if err := tx.QueryRowContext(ctx, @@ -1158,252 +1071,6 @@ WHERE id = 1`, nowSec); err != nil { return nil } -// planResolveAlreadyLandedBarrier appends resolve_already_landed_barrier -// actions for every blocked_conflict row whose self-heal predicate currently -// holds. Predicate parity with the daemon-side probe is enforced by -// scanAutoResolvableBlockedRows (internal/cli/recovery_probe.go). -func planResolveAlreadyLandedBarrier(ctx context.Context, conn *sql.DB, repo, head, branchRef string, generation int64, plan *fixPlan) error { - candidates, err := scanAutoResolvableBlockedRows(ctx, conn, repo, head, branchRef, generation) - if err != nil { - return fmt.Errorf("acd fix: scan auto-resolvable blocked rows: %w", err) - } - for _, c := range candidates { - afterOID := "" - if len(c.Ops) > 0 && c.Ops[0].AfterOID.Valid { - afterOID = c.Ops[0].AfterOID.String - } - blobOID := "" - if len(c.Ops) > 0 { - if oid, err := git.LsTreeBlobOID(ctx, repo, c.HeadOID, c.Ops[0].Path); err == nil { - blobOID = oid - } - } - plan.Actions = append(plan.Actions, fixAction{ - ID: fmt.Sprintf("%s:%d", fixActionResolveAlreadyLandedBarrier, c.Seq), - Kind: fixActionResolveAlreadyLandedBarrier, - Description: "promote already-landed blocked barrier to published", - Reason: "captured after-state matches HEAD; external committer landed the change", - Seq: c.Seq, - Path: c.Path, - CommitOID: c.HeadOID, - BlobOID: shortOID(blobOID, 12), - CapturedAfterOID: shortOID(afterOID, 12), - BranchRef: c.BranchRef, - BranchGeneration: c.Generation, - BaseHead: c.BaseHead, - }) - } - return nil -} - -// planRetargetStaleAnchor decides whether a retarget_stale_anchor action is -// warranted. We mirror acd recover's gating: refuse on detached HEAD (already -// recorded as plan.Unsafe at the start of buildFixPlan); otherwise plan the -// retarget if there are pending/blocked rows on a stale (branch_ref, -// generation) that no longer matches the live anchor, OR replay-pause meta -// keys remain that the daemon never cleared. -func planRetargetStaleAnchor(ctx context.Context, conn *sql.DB, repo, head, branchRef string, generation int64, plan *fixPlan) error { - if branchRef == "" { - return nil - } - // Two trigger conditions for retarget (either alone is enough): - // 1. capture_events rows in (pending, blocked_conflict) reference a - // (branch_ref, branch_generation, base_head) tuple that no longer - // matches the current attached anchor; - // 2. daemon_meta still carries stale replay/pause breadcrumbs the - // daemon should have cleared on resume. - var staleRows int - if err := conn.QueryRowContext(ctx, ` -SELECT COUNT(*) -FROM capture_events -WHERE state IN (?, ?) - AND (branch_ref <> ? OR branch_generation <> ? OR base_head <> ?)`, - state.EventStatePending, state.EventStateBlockedConflict, - branchRef, generation, head).Scan(&staleRows); err != nil { - return fmt.Errorf("acd fix: count stale anchor rows: %w", err) - } - hasStaleMeta := false - for _, key := range []string{ - "last_replay_conflict", - "last_replay_conflict_legacy", - "last_replay_error", - "detached_head_paused", - "operation_in_progress", - daemon.MetaKeyReplayPausedUntil, - } { - if _, ok, err := metaLookup(ctx, conn, key); err != nil { - return fmt.Errorf("acd fix: read meta %s: %w", key, err) - } else if ok { - hasStaleMeta = true - break - } - } - if staleRows == 0 && !hasStaleMeta { - return nil - } - description := "retarget stale capture/shadow rows to current branch/generation/head and clear stale replay/pause meta" - plan.Actions = append(plan.Actions, fixAction{ - ID: fixActionRetargetStaleAnchor, - Kind: fixActionRetargetStaleAnchor, - Description: description, - Reason: fmt.Sprintf("stale_rows=%d stale_meta=%v", staleRows, hasStaleMeta), - BranchRef: branchRef, - BranchGeneration: generation, - BaseHead: head, - }) - return nil -} - -// planPurgeBarrierWithSuccessors enumerates terminal replay barriers whose -// (branch_ref, generation) still has pending successors. blocked_conflict -// rows are scoped to the active anchor; failed rows are global because -// status/list/diagnose count failed barriers globally. Only invoked when -// --force is set on the planner inputs. -func planPurgeBarrierWithSuccessors(ctx context.Context, conn *sql.DB, branchRef string, generation int64, plan *fixPlan) error { - rows, err := scanTerminalBarrierRowsWithSuccessors(ctx, conn, branchRef, generation) - if err != nil { - return fmt.Errorf("acd fix: scan barrier-with-successors rows: %w", err) - } - for _, r := range rows { - plan.Actions = append(plan.Actions, fixAction{ - ID: fmt.Sprintf("%s:%d", fixActionPurgeBarrierWithSuccessors, r.Seq), - Kind: fixActionPurgeBarrierWithSuccessors, - Description: "purge replay barrier with pending successors (destructive)", - Reason: fmt.Sprintf("%s row still has pending successors on same (branch_ref, generation)", r.State), - Seq: r.Seq, - Path: r.Path, - BranchRef: r.BranchRef, - BranchGeneration: r.Generation, - RequiresForce: true, - State: r.State, - }) - } - return nil -} - -// applyRetargetStaleAnchorTx ports the in-tx half of applyRecoverPlan into -// acd fix. The mutations are deliberately identical so existing recover_test -// fixtures still describe the contract. Live-index repair and the manual -// pause marker reconciliation run AFTER tx.Commit in finalizeRetargetPostCommit -// so slow FS/git ops never hold the SQLite write lock. -func applyRetargetStaleAnchorTx(ctx context.Context, tx *sql.Tx, plan *fixPlan, nowSec float64) (int64, error) { - var rowsChanged int64 - execTracked := func(q string, args ...any) error { - res, err := tx.ExecContext(ctx, q, args...) - if err != nil { - return err - } - if n, rerr := res.RowsAffected(); rerr == nil { - rowsChanged += n - } - return nil - } - - if err := execTracked(`UPDATE capture_events -SET branch_ref = ?, branch_generation = ?, base_head = ? -WHERE state IN (?, ?)`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead, - state.EventStatePending, state.EventStateBlockedConflict); err != nil { - return 0, fmt.Errorf("acd fix: retarget capture_events: %w", err) - } - if err := execTracked(`UPDATE capture_events -SET state = ?, published_ts = NULL, error = NULL -WHERE state = ?`, - state.EventStatePending, state.EventStateBlockedConflict); err != nil { - return 0, fmt.Errorf("acd fix: reset blocked events: %w", err) - } - if err := execTracked(`DELETE FROM shadow_paths -WHERE rowid NOT IN ( - SELECT keep_rowid FROM ( - SELECT MAX(rowid) AS keep_rowid - FROM shadow_paths - GROUP BY path - ) -)`); err != nil { - return 0, fmt.Errorf("acd fix: dedupe shadow_paths: %w", err) - } - if err := execTracked(`UPDATE shadow_paths -SET branch_ref = ?, branch_generation = ?, base_head = ?`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead); err != nil { - return 0, fmt.Errorf("acd fix: retarget shadow_paths: %w", err) - } - if err := execTracked(`UPDATE publish_state -SET branch_ref = ?, branch_generation = ?, source_head = ?, status = 'idle', error = NULL`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead); err != nil { - return 0, fmt.Errorf("acd fix: retarget publish_state: %w", err) - } - if err := execTracked(`UPDATE daemon_state -SET branch_ref = ?, branch_generation = ?, mode = 'stopped', note = NULL`, - plan.CurrentBranchRef, plan.Generation); err != nil { - return 0, fmt.Errorf("acd fix: retarget daemon_state: %w", err) - } - for _, key := range []string{ - "last_replay_conflict", - "last_replay_conflict_legacy", - "last_replay_error", - "detached_head_paused", - "operation_in_progress", - daemon.MetaKeyReplayPausedUntil, - } { - if err := execTracked(`DELETE FROM daemon_meta WHERE key = ?`, key); err != nil { - return 0, fmt.Errorf("acd fix: clear %s: %w", key, err) - } - } - if err := execTracked(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch_token', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - "rev:"+plan.CurrentHead+" "+plan.CurrentBranchRef, nowSec); err != nil { - return 0, fmt.Errorf("acd fix: set branch token: %w", err) - } - if err := execTracked(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch.generation', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - fmt.Sprintf("%d", plan.Generation), nowSec); err != nil { - return 0, fmt.Errorf("acd fix: set branch generation: %w", err) - } - if err := execTracked(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch.head', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - plan.CurrentHead, nowSec); err != nil { - return 0, fmt.Errorf("acd fix: set branch head: %w", err) - } - return rowsChanged, nil -} - -// finalizeRetargetPostCommit runs the live-index repair pass and reconciles -// the manual pause marker after the retarget tx commits. Failures here are -// reported but the DB transaction has already landed, so we never re-open it. -func finalizeRetargetPostCommit(ctx context.Context, db *state.DB, plan *fixPlan) error { - repaired, err := daemon.RepairPublishedLiveIndex(ctx, plan.Repo, db, plan.CurrentHead, daemon.DefaultLiveIndexRepairLimit) - if err != nil { - return fmt.Errorf("acd fix: repair live index: %w", err) - } - plan.LiveIndexCandidates = repaired.Candidates - plan.LiveIndexApplied = repaired.Applied - plan.LiveIndexSkipped = len(repaired.Skipped) - - if plan.ManualPausePath == "" { - return nil - } - if _, err := os.Lstat(plan.ManualPausePath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("acd fix: stat manual pause marker after commit: %w", err) - } - if !plan.ClearPause { - plan.ManualMarkerPreserved = true - return nil - } - if err := os.Remove(plan.ManualPausePath); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - plan.ManualMarkerRemoveError = err.Error() - return nil - } - plan.ManualMarkerRemoved = true - stampManualPauseResume(ctx, plan.GitDir) - return nil -} - func renderFix(out io.Writer, plan fixPlan, jsonOut bool) error { if jsonOut { enc := json.NewEncoder(out) @@ -1411,7 +1078,9 @@ func renderFix(out io.Writer, plan fixPlan, jsonOut bool) error { return enc.Encode(plan) } mode := "planned" - if !plan.DryRun { + if plan.Incomplete { + mode = "incomplete" + } else if !plan.DryRun { mode = "applied" } fmt.Fprintf(out, "Fix %s for %s\n", mode, plan.Repo) @@ -1431,6 +1100,12 @@ func renderFix(out io.Writer, plan fixPlan, jsonOut bool) error { if action.Kind == fixActionDropGeneratedPending { target = fmt.Sprintf(" root=%s pending=%d tracked=%d seq=%d..%d", action.GeneratedRoot, action.PendingCount, action.TrackedCount, action.OldestSeq, action.NewestSeq) + } else if action.Kind == fixActionReconcileUnpublishedChain { + target = fmt.Sprintf(" pair=%s/g%d seq=%d..%d", + action.BranchRef, action.BranchGeneration, action.OldestSeq, action.NewestSeq) + if action.ArchiveOnly { + target += " archive-only" + } } else if action.Seq > 0 { target = fmt.Sprintf(" seq=%d", action.Seq) } @@ -1474,30 +1149,10 @@ func renderFix(out io.Writer, plan fixPlan, jsonOut bool) error { if plan.ManualPauseRemoved { fmt.Fprintf(out, "Manual pause marker removed: %s\n", plan.ManualPausePath) } - switch { - case plan.ManualMarkerRemoved: - fmt.Fprintf(out, "Manual pause marker (retarget) removed: %s\n", plan.ManualPausePath) - case plan.ManualMarkerPreserved: - fmt.Fprintf(out, "Manual pause marker (retarget) preserved: %s (use --clear-pause to remove)\n", plan.ManualPausePath) - } - if plan.ManualMarkerRemoveError != "" { - fmt.Fprintf(out, "WARNING: manual pause marker remove failed after commit: %s\n", plan.ManualMarkerRemoveError) - } - if plan.LiveIndexCandidates > 0 || plan.LiveIndexApplied > 0 || plan.LiveIndexSkipped > 0 { - fmt.Fprintf(out, "Live index repair: candidates=%d applied=%d skipped=%d\n", - plan.LiveIndexCandidates, plan.LiveIndexApplied, plan.LiveIndexSkipped) - } } else { hint := "pass --yes to apply safe actions" if plan.Force { - hint = "pass --yes to apply safe actions; combine --yes --force to also apply destructive purge" - } else { - for _, action := range plan.Actions { - if action.RequiresForce { - hint = "pass --yes to apply safe actions; rerun with --force to plan purge_barrier_with_successors" - break - } - } + hint = "pass --yes to apply archive-only recovery; captured work will be protected first" } fmt.Fprintf(out, "(dry-run; %s)\n", hint) } diff --git a/internal/cli/fix_test.go b/internal/cli/fix_test.go index 8bc30e52..7337b4e6 100644 --- a/internal/cli/fix_test.go +++ b/internal/cli/fix_test.go @@ -5,10 +5,9 @@ import ( "context" "database/sql" "encoding/json" - "fmt" + "errors" "os" "path/filepath" - "reflect" "strings" "testing" "time" @@ -20,35 +19,33 @@ import ( ) func TestFix_DefaultsToDryRunWhenNoFlagsPassed(t *testing.T) { - // Without --yes (and without --force) `acd fix` must be a pure dry-run. - // The old "refuse without --yes" guard has been replaced by silent - // dry-run default per SPEC LOCK so casual operators see the plan first. repo, stateDB, _ := makeRegisteredGitRepoStateDB(t) before, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum before: %v", err) } + var out bytes.Buffer - if err := runFix(context.Background(), &out, repo, false /*dryRun*/, false /*yes*/, false /*force*/, false /*clearPause*/, true /*jsonOut*/); err != nil { - t.Fatalf("runFix without --yes must dry-run silently, got err=%v\n%s", err, out.String()) + if err := runFix(context.Background(), &out, repo, false, false, false, false, true); err != nil { + t.Fatalf("runFix: %v\n%s", err, out.String()) } var plan fixPlan if err := json.Unmarshal(out.Bytes(), &plan); err != nil { t.Fatalf("unmarshal: %v\n%s", err, out.String()) } - if !plan.DryRun { - t.Fatalf("plan.DryRun=false in no-flag default: %+v", plan) + if !plan.DryRun || plan.BackupPath != "" { + t.Fatalf("default fix was not a pure dry-run: %+v", plan) } after, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum after: %v", err) } if before != after { - t.Fatalf("no-flag default mutated state.db: before=%s after=%s", before, after) + t.Fatalf("dry-run mutated state.db: before=%s after=%s", before, after) } } -func TestFix_DryRunPlansSafeActionsWithoutMutation(t *testing.T) { +func TestFix_DryRunPlansExactPairWithoutMutation(t *testing.T) { repo, stateDB, db := makeRegisteredGitRepoStateDB(t) seedPurgeFixtureRows(t, db) before, err := fileSHA256(stateDB) @@ -56,22 +53,13 @@ func TestFix_DryRunPlansSafeActionsWithoutMutation(t *testing.T) { t.Fatalf("checksum before: %v", err) } - var out bytes.Buffer - if err := runFix(context.Background(), &out, repo, true, false, false, false, true); err != nil { - t.Fatalf("runFix dry-run: %v\n%s", err, out.String()) + plan := runFixJSON(t, repo, true, false, false, false) + action := findFixAction(plan, fixActionReconcileUnpublishedChain) + if action == nil { + t.Fatalf("plan lacks exact-pair reconciliation: %+v", plan.Actions) } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal fix plan: %v\n%s", err, out.String()) - } - if !plan.DryRun { - t.Fatalf("plan.DryRun=false: %+v", plan) - } - if plan.BackupPath != "" { - t.Fatalf("dry-run wrote backup %q", plan.BackupPath) - } - if !hasFixAction(plan, fixActionDeleteObsoleteBarrier) { - t.Fatalf("fix plan lacks obsolete barrier cleanup: %+v", plan.Actions) + if action.BranchRef != "refs/heads/main" || action.BranchGeneration != 1 || action.Seq < 1 { + t.Fatalf("action lost exact provenance: %+v", *action) } after, err := fileSHA256(stateDB) if err != nil { @@ -89,16 +77,9 @@ func TestFix_DryRunToleratesPreV5DB(t *testing.T) { t.Fatalf("drop decision_records: %v", err) } - var out bytes.Buffer - if err := runFix(context.Background(), &out, repo, true, false, false, false, true); err != nil { - t.Fatalf("runFix dry-run should tolerate missing decision_records: %v\n%s", err, out.String()) - } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal fix plan: %v\n%s", err, out.String()) - } - if !hasFixAction(plan, fixActionDeleteObsoleteBarrier) { - t.Fatalf("pre-v5 fix plan lacks obsolete barrier cleanup: %+v", plan.Actions) + plan := runFixJSON(t, repo, true, false, false, false) + if findFixAction(plan, fixActionReconcileUnpublishedChain) == nil { + t.Fatalf("pre-v5 plan lacks exact-pair reconciliation: %+v", plan.Actions) } } @@ -111,862 +92,1035 @@ func TestFix_ApplyClearsExpiredPauseAndDrainedBackpressure(t *testing.T) { markerPath := filepath.Join(repo, ".git", "acd", "paused") expiredAt := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339) if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ - Reason: "old maintenance", - SetAt: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), - SetBy: "test", - ExpiresAt: &expiredAt, + Reason: "old maintenance", SetAt: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), + SetBy: "test", ExpiresAt: &expiredAt, }, true); err != nil { t.Fatalf("write marker: %v", err) } - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, false, false, true); err != nil { - t.Fatalf("runFix apply: %v\n%s", err, out.String()) + plan := runFixJSON(t, repo, false, true, false, false) + if plan.BackupPath == "" || !plan.ManualPauseRemoved { + t.Fatalf("safe housekeeping did not apply: %+v", plan) } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal fix result: %v\n%s", err, out.String()) + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("manual pause marker still exists: %v", err) } - if plan.BackupPath == "" { - t.Fatalf("apply did not create backup: %+v", plan) + if _, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyCaptureBackpressurePausedAt); err != nil || ok { + t.Fatalf("backpressure meta remains: ok=%v err=%v", ok, err) } +} + +func TestFix_ClearPauseRemovesActiveMarkerOnlyWhenRequested(t *testing.T) { + repo, _, _ := makeRegisteredGitRepoStateDB(t) + markerPath := filepath.Join(repo, ".git", "acd", "paused") + if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ + Reason: "maintenance", SetAt: time.Now().UTC().Format(time.RFC3339), SetBy: "test", + }, true); err != nil { + t.Fatalf("write marker: %v", err) + } + + plan := runFixJSON(t, repo, false, true, false, true) if !plan.ManualPauseRemoved { - t.Fatalf("manual pause not marked removed: %+v", plan) + t.Fatalf("--clear-pause did not remove marker: %+v", plan) } if _, err := os.Stat(markerPath); !os.IsNotExist(err) { t.Fatalf("manual pause marker still exists: %v", err) } - if _, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyCaptureBackpressurePausedAt); err != nil { - t.Fatalf("MetaGet backpressure: %v", err) - } else if ok { - t.Fatalf("backpressure meta still present") +} + +func TestFix_CommitFailureDoesNotReportTransactionalActionApplied(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + if err := state.MetaSet(ctx, db, daemon.MetaKeyCaptureBackpressurePausedAt, + time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("seed backpressure: %v", err) + } + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) + } + injected := errors.New("injected transaction commit failure") + plan.commitTransaction = func(*sql.Tx) error { return injected } + + err = applyFixPlan(ctx, stateDB, &plan) + if !errors.Is(err, injected) { + t.Fatalf("applyFixPlan err=%v want injected commit failure", err) + } + markFixIncomplete(&plan, err) + action := findFixAction(plan, fixActionClearDrainedBackpressure) + if action == nil || action.Applied || action.RowsChanged != 0 || plan.RowsChanged != 0 { + t.Fatalf("rolled-back action reported applied: action=%+v plan=%+v", action, plan) } - if _, ok, err := state.MetaGet(ctx, db, "capture.backpressure_overridden_at"); err != nil { - t.Fatalf("MetaGet override: %v", err) - } else if !ok { - t.Fatalf("backpressure override stamp missing") + if _, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyCaptureBackpressurePausedAt); err != nil || !ok { + t.Fatalf("rollback lost backpressure marker: ok=%v err=%v", ok, err) } - if _, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyManualPauseResumedAt); err != nil { - t.Fatalf("MetaGet manual resume: %v", err) - } else if !ok { - t.Fatalf("manual pause resume stamp missing") + if _, ok, err := state.MetaGet(ctx, db, "capture.backpressure_overridden_at"); err != nil || ok { + t.Fatalf("rollback retained override marker: ok=%v err=%v", ok, err) + } + + var out bytes.Buffer + if err := renderFix(&out, plan, true); err != nil { + t.Fatalf("render commit failure: %v", err) + } + var rendered fixPlan + if err := json.Unmarshal(out.Bytes(), &rendered); err != nil { + t.Fatalf("unmarshal commit failure result: %v\n%s", err, out.String()) + } + renderedAction := findFixAction(rendered, fixActionClearDrainedBackpressure) + if !rendered.Incomplete || renderedAction == nil || renderedAction.Applied || + renderedAction.RowsChanged != 0 || rendered.RowsChanged != 0 { + t.Fatalf("rendered rollback claimed applied rows: action=%+v plan=%+v", renderedAction, rendered) } } -func TestFix_ApplyDeletesObsoleteBarrierAndLeavesPending(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - seedPurgeFixtureRows(t, db) +func TestFix_PreservesAtomicallyReplacedPauseMarker(t *testing.T) { + repo, stateDB, _ := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + markerPath := filepath.Join(repo, ".git", "acd", "paused") + original := pausepkg.Marker{ + Reason: "planned maintenance", + SetAt: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), + SetBy: "test", + } + if _, err := pausepkg.Write(markerPath, original, false); err != nil { + t.Fatalf("write original marker: %v", err) + } + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, true) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) + } + plannedInfo := plan.plannedPauseInfo + var replaceErr error + plan.beforePauseRemove = func() { + _, replaceErr = pausepkg.Write(markerPath, original, true) + } - var out bytes.Buffer - if err := runFix(context.Background(), &out, repo, false, true, false, false, true); err != nil { - t.Fatalf("runFix apply: %v\n%s", err, out.String()) + err = applyFixPlan(ctx, stateDB, &plan) + if replaceErr != nil { + t.Fatalf("replace pause marker: %v", replaceErr) } - got := countCaptureRowsByState(t, db) - if got[state.EventStateBlockedConflict] != 0 { - t.Fatalf("blocked rows remain: %v", got) + if err == nil || !strings.Contains(err.Error(), "changed since planning") { + t.Fatalf("applyFixPlan err=%v want changed-marker refusal", err) } - if got[state.EventStateFailed] != 0 { - t.Fatalf("failed rows remain: %v", got) + current, ok, readErr := pausepkg.Read(filepath.Join(repo, ".git")) + if readErr != nil || !ok || !samePauseMarker(original, current) { + t.Fatalf("replacement marker not preserved: marker=%+v ok=%v err=%v", current, ok, readErr) } - if got[state.EventStatePending] != 2 { - t.Fatalf("pending rows changed unexpectedly: %v", got) + currentInfo, statErr := os.Lstat(markerPath) + if statErr != nil { + t.Fatalf("stat replacement marker: %v", statErr) } - var status string - if err := db.SQL().QueryRowContext(context.Background(), - `SELECT status FROM publish_state WHERE id = 1`).Scan(&status); err != nil { - t.Fatalf("read publish_state: %v", err) + if os.SameFile(plannedInfo, currentInfo) { + t.Fatal("fixture did not atomically replace the planned marker inode") } - if status == state.EventStateBlockedConflict { - t.Fatalf("publish_state.status still blocked_conflict") + action := findFixAction(plan, fixActionClearExpiredManualPause) + if plan.ManualPauseRemoved || action == nil || action.Applied { + t.Fatalf("replacement marker reported removed: action=%+v plan=%+v", action, plan) } } -func TestFix_ApplyRefusesWhenPlanHasUnsafeReasons(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) +func TestFix_PreservesPauseCreatedAfterQuarantineRename(t *testing.T) { + repo, stateDB, _ := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse: %v", err) + markerPath := filepath.Join(repo, ".git", "acd", "paused") + original := pausepkg.Marker{ + Reason: "planned maintenance", + SetAt: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), + SetBy: "test", } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "unsafe.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "before-state mismatch", Valid: true}, - }, nil) + if _, err := pausepkg.Write(markerPath, original, false); err != nil { + t.Fatalf("write original marker: %v", err) + } + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, true) if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) - } - if _, err := state.AppendDecision(ctx, db, state.DecisionRecord{ - Kind: state.DecisionKindHandledExternal, - Path: sql.NullString{String: "unsafe.txt", Valid: true}, - Reason: sql.NullString{String: "already_published_by_external_committer", Valid: true}, - EventSeq: sql.NullInt64{Int64: seq, Valid: true}, - CommitOID: sql.NullString{String: "1111111111111111111111111111111111111111", Valid: true}, - BranchRef: sql.NullString{String: "refs/heads/main", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, - }); err != nil { - t.Fatalf("AppendDecision: %v", err) + t.Fatalf("buildFixPlan: %v", err) + } + replacement := pausepkg.Marker{ + Reason: "new operator pause", + SetAt: time.Now().UTC().Format(time.RFC3339), + SetBy: "replacement-test", + } + var quarantinePath string + var replaceErr error + plan.afterPauseQuarantine = func(path string) { + quarantinePath = path + _, replaceErr = pausepkg.Write(markerPath, replacement, false) } - var out bytes.Buffer - err = runFix(ctx, &out, repo, false, true, false, false, true) - if err == nil { - t.Fatalf("runFix apply succeeded despite unsafe plan:\n%s", out.String()) + err = applyFixPlan(ctx, stateDB, &plan) + if replaceErr != nil { + t.Fatalf("create replacement pause marker: %v", replaceErr) + } + if err == nil || !strings.Contains(err.Error(), "new marker was created and preserved") { + t.Fatalf("applyFixPlan err=%v want new-marker preservation signal", err) + } + current, ok, readErr := pausepkg.Read(filepath.Join(repo, ".git")) + if readErr != nil || !ok || !samePauseMarker(replacement, current) { + t.Fatalf("new marker did not survive quarantine removal: marker=%+v ok=%v err=%v", current, ok, readErr) + } + if quarantinePath == "" { + t.Fatal("quarantine hook did not run") } - if !strings.Contains(err.Error(), "unsafe conditions") { - t.Fatalf("error=%v want unsafe refusal", err) + if _, statErr := os.Stat(quarantinePath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("matched planned marker remained quarantined: %v", statErr) + } + action := findFixAction(plan, fixActionClearExpiredManualPause) + if plan.ManualPauseRemoved || action == nil || action.Applied { + t.Fatalf("new pause marker reported removed: action=%+v plan=%+v", action, plan) + } +} + +func TestFix_ApplyPublishesWholePairAgainstHEAD(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + parent, head, beforeOID, afterOID := commitExternalSeedChange(t, ctx, repo) + seq := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: parent, + Operation: "modify", Path: "seed.txt", Fidelity: "exact", + State: state.EventStateBlockedConflict, + Error: sql.NullString{String: "modify before-state mismatch", Valid: true}, + }, []state.CaptureOp{{ + Op: "modify", Path: "seed.txt", Fidelity: "exact", + BeforeOID: sql.NullString{String: beforeOID, Valid: true}, + BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: afterOID, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + + plan := runFixJSON(t, repo, false, true, false, false) + action := findFixAction(plan, fixActionReconcileUnpublishedChain) + if action == nil || !action.Applied || action.State != state.EventStatePublished || action.RecoveryRef == "" { + t.Fatalf("published reconciliation missing proof: %+v actions=%+v", action, plan.Actions) } - var stateName string - if err := db.SQL().QueryRowContext(ctx, `SELECT state FROM capture_events WHERE seq = ?`, seq).Scan(&stateName); err != nil { + var gotState, gotCommit, branchRef string + var generation int64 + if err := db.SQL().QueryRowContext(ctx, + `SELECT state, commit_oid, branch_ref, branch_generation FROM capture_events WHERE seq = ?`, seq, + ).Scan(&gotState, &gotCommit, &branchRef, &generation); err != nil { t.Fatalf("query event: %v", err) } - if stateName != state.EventStateBlockedConflict { - t.Fatalf("event state=%q want blocked_conflict", stateName) + if gotState != state.EventStatePublished || gotCommit != head || branchRef != "refs/heads/main" || generation != 1 { + t.Fatalf("event=%s commit=%s pair=%s/g%d want published %s original pair", gotState, gotCommit, branchRef, generation, head) } } -func TestFix_ApplyMarksDecisionLedExternalRowPublished(t *testing.T) { +func TestFix_ForceArchivesWholePairWithoutPeelingBarrier(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - if err := os.WriteFile(filepath.Join(repo, "external.txt"), []byte("landed outside acd\n"), 0o644); err != nil { - t.Fatalf("write external: %v", err) + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + + plan := runFixJSON(t, repo, false, true, true, false) + action := findFixAction(plan, fixActionReconcileUnpublishedChain) + if action == nil || !action.ArchiveOnly || action.State != state.EventStateRecovered || action.RowsChanged != 2 || action.RecoveryRef == "" { + t.Fatalf("archive-only whole-pair action=%+v plan=%+v", action, plan) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "external.txt"); err != nil { - t.Fatalf("git add external: %v", err) + var firstState, secondState, firstCommit, secondCommit string + if err := db.SQL().QueryRowContext(ctx, `SELECT state, commit_oid FROM capture_events WHERE seq = ?`, first).Scan(&firstState, &firstCommit); err != nil { + t.Fatalf("query first: %v", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", "commit", "-m", "external"); err != nil { - t.Fatalf("git commit external: %v", err) + if err := db.SQL().QueryRowContext(ctx, `SELECT state, commit_oid FROM capture_events WHERE seq = ?`, second).Scan(&secondState, &secondCommit); err != nil { + t.Fatalf("query second: %v", err) } - head, err := git.RevParse(ctx, repo, "HEAD") + if firstState != state.EventStateRecovered || secondState != state.EventStateRecovered || firstCommit != secondCommit { + t.Fatalf("chain peeled or split: first=%s/%s second=%s/%s", firstState, firstCommit, secondState, secondCommit) + } + if got := countRowsWhere(t, db, "capture_events", "seq IN (?, ?)", first, second); got != 2 { + t.Fatalf("reconciliation deleted capture rows: %d", got) + } + if plan.BackupPath == "" { + t.Fatal("archive-only recovery did not back up state.db") + } +} + +func TestFix_BackupIncludesWALOnlyCommittedRows(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + if _, err := db.SQL().ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + t.Fatalf("checkpoint baseline: %v", err) + } + reader, err := db.ReadSQL().BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("begin snapshot reader: %v", err) + } + defer reader.Rollback() + var baseline int + if err := reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events`).Scan(&baseline); err != nil { + t.Fatalf("establish snapshot reader: %v", err) } - afterOID, err := git.LsTreeBlobOID(ctx, repo, head, "external.txt") + + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + walInfo, err := os.Stat(stateDB + "-wal") + if err != nil || walInfo.Size() == 0 { + t.Fatalf("fixture did not retain committed WAL frames: info=%v err=%v", walInfo, err) + } + + // A raw main-file copy must not contain the pair, proving the committed rows + // are visible only through the live WAL snapshot at backup time. + rawMain := filepath.Join(t.TempDir(), "raw-main.db") + rawBytes, err := os.ReadFile(stateDB) if err != nil { - t.Fatalf("ls-tree external: %v", err) - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "create", - Path: "external.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "before-state mismatch", Valid: true}, - }, []state.CaptureOp{{ - Op: "create", - Path: "external.txt", - AfterOID: sql.NullString{String: afterOID, Valid: true}, - AfterMode: sql.NullString{String: "100644", Valid: true}, - Fidelity: "exact", - }}) + t.Fatalf("read raw main DB: %v", err) + } + if err := os.WriteFile(rawMain, rawBytes, 0o600); err != nil { + t.Fatalf("write raw main DB copy: %v", err) + } + rawConn, err := sql.Open("sqlite", "file:"+rawMain+"?immutable=1") if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) - } - if _, err := state.AppendDecision(ctx, db, state.DecisionRecord{ - Kind: state.DecisionKindHandledExternal, - Path: sql.NullString{String: "external.txt", Valid: true}, - Reason: sql.NullString{String: "already_published_by_external_committer", Valid: true}, - EventSeq: sql.NullInt64{Int64: seq, Valid: true}, - CommitOID: sql.NullString{String: head, Valid: true}, - BranchRef: sql.NullString{String: "refs/heads/main", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, - }); err != nil { - t.Fatalf("AppendDecision: %v", err) + t.Fatalf("open raw main DB: %v", err) + } + defer rawConn.Close() + var rawRows int + if err := rawConn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq IN (?, ?)`, first, second, + ).Scan(&rawRows); err != nil { + t.Fatalf("query raw main DB: %v", err) + } + if rawRows != 0 { + t.Fatalf("fixture rows unexpectedly checkpointed into main DB: %d", rawRows) } - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, false, false, true); err != nil { - t.Fatalf("runFix apply: %v\n%s", err, out.String()) + plan := runFixJSON(t, repo, false, true, true, false) + if plan.BackupPath == "" { + t.Fatal("fix did not report a backup path") } - var stateName, commitOID string - var errMsg sql.NullString - if err := db.SQL().QueryRowContext(ctx, - `SELECT state, commit_oid, error FROM capture_events WHERE seq = ?`, seq, - ).Scan(&stateName, &commitOID, &errMsg); err != nil { - t.Fatalf("query event: %v", err) + backup, err := openStateDBReadOnly(ctx, plan.BackupPath) + if err != nil { + t.Fatalf("open WAL-safe backup: %v", err) } - if stateName != state.EventStatePublished || commitOID != head || errMsg.Valid { - t.Fatalf("event state=%q commit=%q err=%v, want published %s nil", stateName, commitOID, errMsg, head) + defer backup.Close() + var backedUpRows int + if err := backup.QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq IN (?, ?)`, first, second, + ).Scan(&backedUpRows); err != nil { + t.Fatalf("query WAL-safe backup: %v", err) } -} - -func hasFixAction(plan fixPlan, kind string) bool { - for _, action := range plan.Actions { - if action.Kind == kind { - return true - } + if backedUpRows != 2 { + t.Fatalf("WAL-safe backup rows=%d want 2", backedUpRows) + } + var integrity string + if err := backup.QueryRowContext(ctx, `PRAGMA quick_check`).Scan(&integrity); err != nil { + t.Fatalf("quick_check backup: %v", err) + } + if integrity != "ok" { + t.Fatalf("backup quick_check=%q want ok", integrity) } - return false } -func TestFix_DryRunPlansGeneratedPendingCleanup(t *testing.T) { +func TestFix_BackupPrecedesSchemaMigration(t *testing.T) { repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - generatedSeqs := seedGeneratedPendingFixFixture(t, ctx, repo, db) - before, err := fileSHA256(stateDB) - if err != nil { - t.Fatalf("checksum before: %v", err) + if err := state.MetaSet(ctx, db, daemon.MetaKeyCaptureBackpressurePausedAt, + time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("seed backpressure: %v", err) + } + if _, err := db.SQL().ExecContext(ctx, ` +DROP TABLE recovery_snapshot_events; +DROP TABLE recovery_snapshots; +PRAGMA user_version = 11;`); err != nil { + t.Fatalf("downgrade recovery schema: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close downgraded DB: %v", err) } - var out bytes.Buffer - if err := runFix(ctx, &out, repo, true, false, false, false, true); err != nil { - t.Fatalf("runFix generated dry-run: %v\n%s", err, out.String()) + plan := runFixJSON(t, repo, false, true, false, false) + if plan.BackupPath == "" { + t.Fatal("fix did not report a backup path") } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + backup, err := openStateDBReadOnly(ctx, plan.BackupPath) + if err != nil { + t.Fatalf("open pre-migration backup: %v", err) } - var generated *fixAction - for i := range plan.Actions { - if plan.Actions[i].Kind == fixActionDropGeneratedPending { - generated = &plan.Actions[i] - break - } + defer backup.Close() + var backupVersion, backupRecoveryTables int + if err := backup.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&backupVersion); err != nil { + t.Fatalf("read backup user_version: %v", err) } - if generated == nil { - t.Fatalf("plan missing generated cleanup action: %+v", plan.Actions) + if err := backup.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM sqlite_master +WHERE type = 'table' AND name IN ('recovery_snapshots', 'recovery_snapshot_events')`).Scan(&backupRecoveryTables); err != nil { + t.Fatalf("count backup recovery tables: %v", err) } - if generated.GeneratedRoot != ".derivedData-provider-core" || - generated.SafeIgnorePattern != ".derivedData*/" || - generated.PendingCount != 2 || - generated.TrackedCount != 2 || - !reflect.DeepEqual(generated.EventSeqs, generatedSeqs) { - t.Fatalf("generated action=%+v, seqs=%v", *generated, generatedSeqs) + if backupVersion != 11 || backupRecoveryTables != 0 { + t.Fatalf("backup captured post-migration schema: version=%d tables=%d", backupVersion, backupRecoveryTables) } - after, err := fileSHA256(stateDB) + + live, err := openStateDBReadOnly(ctx, stateDB) if err != nil { - t.Fatalf("checksum after: %v", err) + t.Fatalf("open migrated live DB: %v", err) } - if before != after { - t.Fatalf("dry-run mutated state.db: before=%s after=%s", before, after) + defer live.Close() + var liveVersion, liveRecoveryTables int + if err := live.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&liveVersion); err != nil { + t.Fatalf("read live user_version: %v", err) } - - out.Reset() - if err := runFix(ctx, &out, repo, true, false, false, false, false); err != nil { - t.Fatalf("runFix generated human dry-run: %v\n%s", err, out.String()) + if err := live.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM sqlite_master +WHERE type = 'table' AND name IN ('recovery_snapshots', 'recovery_snapshot_events')`).Scan(&liveRecoveryTables); err != nil { + t.Fatalf("count live recovery tables: %v", err) } - human := out.String() - for _, want := range []string{ - "drop protected generated pending deletes", - "root=.derivedData-provider-core pending=2 tracked=2", - "git add -u -- .derivedData-provider-core", - "git commit -m \"Remove tracked generated cache files\"", - } { - if !strings.Contains(human, want) { - t.Fatalf("human output missing %q:\n%s", want, human) - } + if liveVersion != state.SchemaVersion || liveRecoveryTables != 2 { + t.Fatalf("live schema not migrated: version=%d tables=%d", liveVersion, liveRecoveryTables) } } -func TestFix_ApplyDropsGeneratedPendingOnly(t *testing.T) { +func TestFix_ReconcileIsIdempotent(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - generatedSeqs := seedGeneratedPendingFixFixture(t, ctx, repo, db) - beforeIndex := gitCachedNameStatus(t, ctx, repo) + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, false, false, true); err != nil { - t.Fatalf("runFix generated apply: %v\n%s", err, out.String()) + firstPlan := runFixJSON(t, repo, false, true, false, false) + firstAction := findFixAction(firstPlan, fixActionReconcileUnpublishedChain) + if firstAction == nil || !firstAction.Applied || firstAction.RecoveryRef == "" { + t.Fatalf("first reconciliation=%+v", firstAction) } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 1 { + t.Fatalf("snapshots after first run=%d want 1", got) } - var generated *fixAction - for i := range plan.Actions { - if plan.Actions[i].Kind == fixActionDropGeneratedPending { - generated = &plan.Actions[i] - break - } + + secondPlan := runFixJSON(t, repo, false, true, false, false) + if action := findFixAction(secondPlan, fixActionReconcileUnpublishedChain); action != nil { + t.Fatalf("idempotent rerun planned reconciliation: %+v", *action) } - if generated == nil || !generated.Applied || generated.RowsChanged != 2 { - t.Fatalf("generated action not applied as expected: %+v actions=%+v", generated, plan.Actions) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 1 { + t.Fatalf("idempotent rerun created snapshot: %d", got) } - if plan.BackupPath == "" { - t.Fatalf("apply did not create backup: %+v", plan) + assertFixEventState(t, ctx, db, first, state.EventStateRecovered) + assertFixEventState(t, ctx, db, second, state.EventStateRecovered) +} + +func TestFix_HeadChangeBetweenPlanAndApplyFailsClosed(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) } - for _, seq := range generatedSeqs { - if got := countRowsWhere(t, db, "capture_events", "seq = ?", seq); got != 0 { - t.Fatalf("generated capture_event seq=%d remains: %d", seq, got) - } - if got := countRowsWhere(t, db, "capture_ops", "event_seq = ?", seq); got != 0 { - t.Fatalf("generated capture_ops seq=%d remains: %d", seq, got) - } - if got := countRowsWhere(t, db, "planner_state", "event_seq = ?", seq); got != 0 { - t.Fatalf("generated planner_state seq=%d remains: %d", seq, got) - } + if err := os.WriteFile(filepath.Join(repo, "advance-head.txt"), []byte("advance\n"), 0o644); err != nil { + t.Fatalf("write advance file: %v", err) } - for _, path := range []string{"build/output.js", "src/ordinary.txt"} { - if got := countRowsWhere(t, db, "capture_events", "path = ? AND state = ?", path, state.EventStatePending); got != 1 { - t.Fatalf("unrelated pending path %s count=%d want 1", path, got) - } + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "advance-head.txt"); err != nil { + t.Fatalf("git add: %v", err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", + "commit", "-m", "advance head"); err != nil { + t.Fatalf("git commit: %v", err) + } + + err = applyFixPlan(ctx, stateDB, &plan) + if err == nil || !strings.Contains(err.Error(), "HEAD changed during planning") { + t.Fatalf("applyFixPlan err=%v want HEAD race refusal", err) } - if afterIndex := gitCachedNameStatus(t, ctx, repo); afterIndex != beforeIndex { - t.Fatalf("fix mutated git index:\nbefore:\n%s\nafter:\n%s", beforeIndex, afterIndex) + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("HEAD race wrote recovery snapshot: %d", got) } } -// TestFix_DryRunListsResolveAlreadyLandedBarrier exercises the Wave 3a -// resolve_already_landed_barrier action: when an external committer already -// landed the captured modify at HEAD and the corresponding capture_events -// row is blocked_conflict with a before-state-mismatch error, the planner -// must list it for auto-resolution under --yes alone. -func TestFix_DryRunListsResolveAlreadyLandedBarrier(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) +func TestFix_GitOperationAppearsBetweenPlanAndApplyFailsClosed(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, ".git", "MERGE_HEAD"), []byte(strings.Repeat("0", 40)+"\n"), 0o600); err != nil { + t.Fatalf("write MERGE_HEAD: %v", err) + } - // Land the "external" commit modifying seed.txt so HEAD blob matches what - // the captured op claims as after_oid. - seedPath := filepath.Join(repo, "seed.txt") - if err := os.WriteFile(seedPath, []byte("seed\nlanded externally\n"), 0o644); err != nil { - t.Fatalf("write seed.txt: %v", err) + err = applyFixPlan(ctx, stateDB, &plan) + if err == nil || !strings.Contains(err.Error(), "Git operation merge") { + t.Fatalf("applyFixPlan err=%v want merge-operation refusal", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "seed.txt"); err != nil { - t.Fatalf("git add: %v", err) + if plan.BackupPath != "" { + t.Fatalf("pre-backup Git-operation refusal created backup: %s", plan.BackupPath) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", "commit", "-m", "external modify"); err != nil { - t.Fatalf("git commit external: %v", err) + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("Git-operation race wrote recovery snapshot: %d", got) } - head, err := git.RevParse(ctx, repo, "HEAD") +} + +func TestFix_GitOperationAppearsAfterBackupFailsClosed(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) + t.Fatalf("buildFixPlan: %v", err) + } + var markerErr error + plan.afterBackup = func() { + markerErr = os.Mkdir(filepath.Join(repo, ".git", "rebase-merge"), 0o700) + } + + err = applyFixPlan(ctx, stateDB, &plan) + if markerErr != nil { + t.Fatalf("create rebase-merge marker: %v", markerErr) + } + if err == nil || !strings.Contains(err.Error(), "Git operation rebase-merge") { + t.Fatalf("applyFixPlan err=%v want post-backup rebase refusal", err) + } + if plan.BackupPath == "" { + t.Fatal("post-backup Git-operation fixture did not reach backup boundary") } - afterOID, err := git.LsTreeBlobOID(ctx, repo, head, "seed.txt") + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("post-backup Git-operation race wrote recovery snapshot: %d", got) + } +} + +func TestFix_PauseAppearsBetweenPlanAndApplyFailsClosed(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) if err != nil { - t.Fatalf("ls-tree HEAD seed.txt: %v", err) + t.Fatalf("buildFixPlan: %v", err) + } + markerPath := pausepkg.Path(filepath.Join(repo, ".git")) + if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ + Reason: "new maintenance", SetAt: time.Now().UTC().Format(time.RFC3339), SetBy: "test", + }, false); err != nil { + t.Fatalf("write new pause marker: %v", err) + } + + err = applyFixPlan(ctx, stateDB, &plan) + if err == nil || !strings.Contains(err.Error(), "pause marker appeared since planning") { + t.Fatalf("applyFixPlan err=%v want new-pause refusal", err) + } + if plan.BackupPath != "" { + t.Fatalf("pre-backup pause refusal created backup: %s", plan.BackupPath) + } + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) +} + +func TestFix_StableManualPauseAllowsRecovery(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + markerPath := pausepkg.Path(filepath.Join(repo, ".git")) + marker := pausepkg.Marker{ + Reason: "stable maintenance", SetAt: time.Now().UTC().Format(time.RFC3339), SetBy: "test", + } + if _, err := pausepkg.Write(markerPath, marker, false); err != nil { + t.Fatalf("write stable pause marker: %v", err) + } + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) + } + + if err := applyFixPlan(ctx, stateDB, &plan); err != nil { + t.Fatalf("applyFixPlan with stable pause: %v", err) } - parent, err := git.RevParse(ctx, repo, "HEAD~1") + assertFixEventState(t, ctx, db, first, state.EventStateRecovered) + assertFixEventState(t, ctx, db, second, state.EventStateRecovered) + current, ok, err := pausepkg.Read(filepath.Join(repo, ".git")) + if err != nil || !ok || !samePauseMarker(marker, current) { + t.Fatalf("stable pause was not preserved: marker=%+v ok=%v err=%v", current, ok, err) + } +} + +func TestFix_PlannedPauseDisappearsAfterBackupFailsClosed(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + markerPath := pausepkg.Path(filepath.Join(repo, ".git")) + if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ + Reason: "planned maintenance", SetAt: time.Now().UTC().Format(time.RFC3339), SetBy: "test", + }, false); err != nil { + t.Fatalf("write planned pause marker: %v", err) + } + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) if err != nil { - t.Fatalf("rev-parse HEAD~1: %v", err) + t.Fatalf("buildFixPlan: %v", err) + } + var removeErr error + plan.afterBackup = func() { removeErr = os.Remove(markerPath) } + + err = applyFixPlan(ctx, stateDB, &plan) + if removeErr != nil { + t.Fatalf("remove planned pause marker: %v", removeErr) + } + if err == nil || !strings.Contains(err.Error(), "pause marker disappeared since planning") { + t.Fatalf("applyFixPlan err=%v want disappeared-pause refusal", err) + } + if plan.BackupPath == "" { + t.Fatal("disappeared-pause fixture did not reach backup boundary") + } + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("pause-state race wrote recovery snapshot: %d", got) } - beforeOID, err := git.LsTreeBlobOID(ctx, repo, parent, "seed.txt") +} + +func TestFix_FirstPairFailureDoesNotMutateOtherPair(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("ls-tree HEAD~1 seed.txt: %v", err) - } - - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: parent, - Operation: "modify", - Path: "seed.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "modify before-state mismatch", Valid: true}, + t.Fatalf("rev-parse: %v", err) + } + bad := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/a-bad", BranchGeneration: 2, BaseHead: head, + Operation: "create", Path: "bad.txt", Fidelity: "exact", + State: state.EventStateFailed, Error: sql.NullString{String: "missing object", Valid: true}, }, []state.CaptureOp{{ - Op: "modify", - Path: "seed.txt", - BeforeOID: sql.NullString{String: beforeOID, Valid: true}, - BeforeMode: sql.NullString{String: "100644", Valid: true}, - AfterOID: sql.NullString{String: afterOID, Valid: true}, - AfterMode: sql.NullString{String: "100644", Valid: true}, - Fidelity: "exact", + Op: "create", Path: "bad.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("f", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, }}) + goodFirst, goodSecond := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/z-good", 3) + + var out bytes.Buffer + err = runFix(ctx, &out, repo, false, true, false, false, true) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("runFix err=%v want first-pair failure\n%s", err, out.String()) + } + assertFixEventState(t, ctx, db, bad, state.EventStateFailed) + assertFixEventState(t, ctx, db, goodFirst, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, goodSecond, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("cross-pair failure wrote snapshots: %d", got) + } +} + +func TestFix_SecondPairFailureReportsIncompleteApply(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + goodFirst, goodSecond := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/a-good", 2) + head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) + t.Fatalf("rev-parse: %v", err) } + bad := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/z-bad", BranchGeneration: 3, BaseHead: head, + Operation: "create", Path: "missing.txt", Fidelity: "exact", + State: state.EventStateFailed, Error: sql.NullString{String: "missing object", Valid: true}, + }, []state.CaptureOp{{ + Op: "create", Path: "missing.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("f", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) var out bytes.Buffer - if err := runFix(ctx, &out, repo, true, false, false, false, true); err != nil { - t.Fatalf("runFix dry-run: %v\n%s", err, out.String()) + err = runFix(ctx, &out, repo, false, true, false, false, true) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("runFix err=%v want second-pair failure\n%s", err, out.String()) } var plan fixPlan if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + t.Fatalf("unmarshal incomplete plan: %v\n%s", err, out.String()) } - if !hasFixAction(plan, fixActionResolveAlreadyLandedBarrier) { - t.Fatalf("plan lacks resolve_already_landed_barrier: %+v", plan.Actions) + if !plan.Incomplete || plan.RowsChanged != 2 || len(plan.VerifyErrors) == 0 || + !strings.Contains(strings.Join(plan.VerifyErrors, " "), "missing") { + t.Fatalf("partial failure not reported as incomplete: %+v", plan) } - // Find the action and confirm it carries the expected commit OID + seq. - var found *fixAction + var goodAction, badAction *fixAction for i := range plan.Actions { - if plan.Actions[i].Kind == fixActionResolveAlreadyLandedBarrier && plan.Actions[i].Seq == seq { - found = &plan.Actions[i] - break + switch plan.Actions[i].BranchRef { + case "refs/heads/a-good": + goodAction = &plan.Actions[i] + case "refs/heads/z-bad": + badAction = &plan.Actions[i] } } - if found == nil { - t.Fatalf("no resolve action for seq=%d: %+v", seq, plan.Actions) + if goodAction == nil || !goodAction.Applied || goodAction.RowsChanged != 2 || goodAction.RecoveryRef == "" { + t.Fatalf("first pair success not retained in result: %+v", goodAction) + } + if badAction == nil || badAction.Applied { + t.Fatalf("failed second pair reported applied: %+v", badAction) } - if found.CommitOID != head { - t.Fatalf("resolve action commit_oid=%q want %q", found.CommitOID, head) + assertFixEventState(t, ctx, db, goodFirst, state.EventStateRecovered) + assertFixEventState(t, ctx, db, goodSecond, state.EventStateRecovered) + assertFixEventState(t, ctx, db, bad, state.EventStateFailed) + + var human bytes.Buffer + if err := renderFix(&human, plan, false); err != nil { + t.Fatalf("render incomplete human result: %v", err) + } + if !strings.Contains(human.String(), "Fix incomplete") || !strings.Contains(human.String(), "missing") { + t.Fatalf("human output hid partial failure:\n%s", human.String()) } } -// TestFix_ApplyYesPromotesAlreadyLandedBarrier confirms --yes alone (no -// --force) is sufficient to promote the blocked_conflict row to published -// and append a handled_external_after_block decision row. -func TestFix_ApplyYesPromotesAlreadyLandedBarrier(t *testing.T) { +func TestFix_ReconciliationLeavesLiveGitStateUntouched(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - if err := os.WriteFile(filepath.Join(repo, "seed.txt"), []byte("seed\nlanded externally\n"), 0o644); err != nil { - t.Fatalf("write seed.txt: %v", err) + stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + if err := os.WriteFile(filepath.Join(repo, "staged-user.txt"), []byte("staged\n"), 0o644); err != nil { + t.Fatalf("write staged file: %v", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "seed.txt"); err != nil { + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "staged-user.txt"); err != nil { t.Fatalf("git add: %v", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", "commit", "-m", "external modify"); err != nil { - t.Fatalf("git commit external: %v", err) + headBefore, _ := git.RevParse(ctx, repo, "HEAD") + indexBefore, err := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--binary") + if err != nil { + t.Fatalf("index before: %v", err) } - head, err := git.RevParse(ctx, repo, "HEAD") + statusBefore, err := git.Run(ctx, git.RunOpts{Dir: repo}, "status", "--porcelain=v1") if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("status before: %v", err) } - afterOID, err := git.LsTreeBlobOID(ctx, repo, head, "seed.txt") + worktreeBefore, err := os.ReadFile(filepath.Join(repo, "staged-user.txt")) if err != nil { - t.Fatalf("ls-tree: %v", err) + t.Fatalf("read worktree before: %v", err) + } + + runFixJSON(t, repo, false, true, false, false) + headAfter, _ := git.RevParse(ctx, repo, "HEAD") + indexAfter, _ := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--binary") + statusAfter, _ := git.Run(ctx, git.RunOpts{Dir: repo}, "status", "--porcelain=v1") + worktreeAfter, _ := os.ReadFile(filepath.Join(repo, "staged-user.txt")) + if headAfter != headBefore || string(indexAfter) != string(indexBefore) || + string(statusAfter) != string(statusBefore) || string(worktreeAfter) != string(worktreeBefore) { + t.Fatalf("fix mutated live Git state: HEAD %s->%s index_equal=%v status %q->%q worktree_equal=%v", + headBefore, headAfter, string(indexAfter) == string(indexBefore), statusBefore, statusAfter, + string(worktreeAfter) == string(worktreeBefore)) } - parent, err := git.RevParse(ctx, repo, "HEAD~1") +} + +func TestFix_RecoveredGeneratedDeleteKeepsCaptureOps(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + generatedPath := ".derivedData-overlap/cache.db" + if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, generatedPath)), 0o755); err != nil { + t.Fatalf("mkdir generated root: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, generatedPath), []byte("generated before\n"), 0o644); err != nil { + t.Fatalf("write generated file: %v", err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "-f", generatedPath); err != nil { + t.Fatalf("git add generated file: %v", err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, + "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", + "commit", "-m", "seed generated file"); err != nil { + t.Fatalf("git commit generated file: %v", err) + } + head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("rev-parse HEAD~1: %v", err) + t.Fatalf("rev-parse: %v", err) } - beforeOID, err := git.LsTreeBlobOID(ctx, repo, parent, "seed.txt") + createdOID, err := git.HashObjectStdin(ctx, repo, []byte("captured trigger\n")) if err != nil { - t.Fatalf("ls-tree HEAD~1: %v", err) - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: parent, - Operation: "modify", - Path: "seed.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "modify before-state mismatch", Valid: true}, + t.Fatalf("hash trigger: %v", err) + } + first := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "trigger.txt", Fidelity: "exact", + State: state.EventStateBlockedConflict, + Error: sql.NullString{String: "before-state mismatch", Valid: true}, }, []state.CaptureOp{{ - Op: "modify", - Path: "seed.txt", - BeforeOID: sql.NullString{String: beforeOID, Valid: true}, - BeforeMode: sql.NullString{String: "100644", Valid: true}, - AfterOID: sql.NullString{String: afterOID, Valid: true}, - AfterMode: sql.NullString{String: "100644", Valid: true}, - Fidelity: "exact", + Op: "create", Path: "trigger.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: createdOID, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, }}) + generatedOID, err := git.LsTreeBlobOID(ctx, repo, head, generatedPath) if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) + t.Fatalf("ls-tree generated file: %v", err) } - - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, false, false, true); err != nil { - t.Fatalf("runFix apply: %v\n%s", err, out.String()) - } - var stateName, commitOID string - if err := db.SQL().QueryRowContext(ctx, - `SELECT state, commit_oid FROM capture_events WHERE seq = ?`, seq, - ).Scan(&stateName, &commitOID); err != nil { - t.Fatalf("query (post-apply event):\n%s\n%v", out.String(), err) + generated := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "delete", Path: generatedPath, Fidelity: "exact", + State: state.EventStatePending, + }, []state.CaptureOp{{ + Op: "delete", Path: generatedPath, Fidelity: "exact", + BeforeOID: sql.NullString{String: generatedOID, Valid: true}, + BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + if err := state.RecordPlannerOffer(ctx, db, generated, 123); err != nil { + t.Fatalf("RecordPlannerOffer: %v", err) } - if stateName != state.EventStatePublished || commitOID != head { - t.Fatalf("event after fix state=%q commit=%q want published+%s\n%s", stateName, commitOID, head, out.String()) + + plan := runFixJSON(t, repo, false, true, false, false) + generatedAction := findFixAction(plan, fixActionDropGeneratedPending) + if generatedAction == nil || !generatedAction.Applied || generatedAction.RowsChanged != 0 { + t.Fatalf("generated cleanup should skip recovered row: %+v", generatedAction) } - var kind string - if err := db.SQL().QueryRowContext(ctx, - `SELECT kind FROM decision_records WHERE event_seq = ? AND kind = ?`, - seq, state.DecisionKindHandledExternalAfterBlock, - ).Scan(&kind); err != nil { - t.Fatalf("query decision (out=%s): %v", out.String(), err) + assertFixEventState(t, ctx, db, first, state.EventStateRecovered) + assertFixEventState(t, ctx, db, generated, state.EventStateRecovered) + if got := countRowsWhere(t, db, "capture_ops", "event_seq = ?", generated); got != 1 { + t.Fatalf("generated recovery lost capture ops: %d", got) } } -// TestFix_YesAloneRefusesToIncludePurge pins the SPEC LOCK constraint that -// --yes WITHOUT --force never plans purge_barrier_with_successors, even when -// a barrier-with-successors row exists. -func TestFix_YesAloneRefusesToIncludePurge(t *testing.T) { +func TestFix_ReconcilePreservesUnrelatedPublishBreadcrumb(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - // Stage a blocked row at seq=N and a pending row at seq=N+1 on the - // current branch+head so the barrier-with-successors predicate matches - // without also tripping retarget_stale_anchor. + stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { t.Fatalf("rev-parse: %v", err) } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "barrier.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "old conflict", Valid: true}, - }, nil); err != nil { - t.Fatalf("seed blocked: %v", err) - } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "successor.txt", - Fidelity: "exact", - State: state.EventStatePending, - }, nil); err != nil { - t.Fatalf("seed pending successor: %v", err) - } - - // Dry-run plan only — apply-mode here would invoke clearPublishBarrierIfSafe - // and other unrelated mutations. The contract we care about is the - // presence/absence of purge in the planned action set. - var out bytes.Buffer - if err := runFix(ctx, &out, repo, true /*dryRun*/, false /*yes*/, false /*force*/, false /*clearPause*/, true); err != nil { - t.Fatalf("runFix dry-run (no force): %v\n%s", err, out.String()) - } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + unrelated := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/other", BranchGeneration: 9, BaseHead: head, + Operation: "create", Path: "other.txt", Fidelity: "exact", State: state.EventStatePublished, + CommitOID: sql.NullString{String: head, Valid: true}, + }, []state.CaptureOp{{Op: "create", Path: "other.txt", Fidelity: "exact"}}) + if err := state.SavePublishState(ctx, db, state.Publish{ + EventSeq: sql.NullInt64{Int64: unrelated, Valid: true}, + BranchRef: sql.NullString{String: "refs/heads/other", Valid: true}, + BranchGeneration: sql.NullInt64{Int64: 9, Valid: true}, + SourceHead: sql.NullString{String: head, Valid: true}, + Status: "blocked_conflict", Error: sql.NullString{String: "unrelated", Valid: true}, + }); err != nil { + t.Fatalf("SavePublishState: %v", err) } - if hasFixAction(plan, fixActionPurgeBarrierWithSuccessors) { - t.Fatalf("--force not set: plan must NOT include purge: %+v", plan.Actions) + + runFixJSON(t, repo, false, true, false, false) + publish, ok, err := state.LoadPublishState(ctx, db) + if err != nil || !ok { + t.Fatalf("LoadPublishState: ok=%v err=%v", ok, err) } - // Operator nudge: when a barrier-with-successors exists we still surface - // the --force suggestion so the path is discoverable. - foundNudge := false - for _, s := range plan.Suggestions { - if strings.Contains(s, "--force") { - foundNudge = true - break - } + if !publish.EventSeq.Valid || publish.EventSeq.Int64 != unrelated || publish.Status != "blocked_conflict" || + !publish.BranchRef.Valid || publish.BranchRef.String != "refs/heads/other" { + t.Fatalf("unrelated publish breadcrumb changed: %+v", publish) } - if !foundNudge { - t.Fatalf("plan must surface --force nudge in Suggestions: %+v", plan.Suggestions) +} + +func TestFix_StalePairPreservesOriginalProvenance(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) + ctx := context.Background() + first, _ := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/stale", 7) + + plan := runFixJSON(t, repo, false, true, false, false) + action := findFixAction(plan, fixActionReconcileUnpublishedChain) + if action == nil || action.BranchRef != "refs/heads/stale" || action.BranchGeneration != 7 { + t.Fatalf("stale pair not planned exactly: %+v", plan.Actions) } - if !plan.ForceRequired { - t.Fatalf("plan.force_required=false with active force-only barrier: %+v", plan) + var branchRef, eventState string + var generation int64 + if err := db.SQL().QueryRowContext(ctx, + `SELECT branch_ref, branch_generation, state FROM capture_events WHERE seq = ?`, first, + ).Scan(&branchRef, &generation, &eventState); err != nil { + t.Fatalf("query stale event: %v", err) } - got := countCaptureRowsByState(t, db) - if got[state.EventStateBlockedConflict] != 1 || got[state.EventStatePending] != 1 { - t.Fatalf("normal fix must not destructively purge barrier rows: %v", got) + if branchRef != "refs/heads/stale" || generation != 7 || eventState != state.EventStateRecovered { + t.Fatalf("stale provenance rewritten: %s/g%d state=%s", branchRef, generation, eventState) } } -func TestFix_SafePlanPrintsExactForceCommandWithRepoPath(t *testing.T) { +func TestFix_MissingObjectLeavesWholePairUnchanged(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - stageBarrierWithSuccessors(t, ctx, repo, db) + head, err := git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + first := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "missing.txt", Fidelity: "exact", + State: state.EventStateFailed, Error: sql.NullString{String: "missing object", Valid: true}, + }, []state.CaptureOp{{ + Op: "create", Path: "missing.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("f", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + second := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "successor.txt", Fidelity: "exact", State: state.EventStatePending, + }, []state.CaptureOp{{ + Op: "create", Path: "successor.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: strings.Repeat("e", 40), Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) var out bytes.Buffer - if err := runFix(ctx, &out, repo, true, false, false, false, false); err != nil { - t.Fatalf("runFix dry-run human: %v\n%s", err, out.String()) + err = runFix(ctx, &out, repo, false, true, false, false, true) + if err == nil || !strings.Contains(err.Error(), "missing blob object") { + t.Fatalf("runFix err=%v want missing-object refusal\n%s", err, out.String()) } - want := "acd fix --repo " + repo + " --force --yes" - if !strings.Contains(out.String(), want) { - t.Fatalf("human output missing exact force command %q:\n%s", want, out.String()) + assertFixEventState(t, ctx, db, first, state.EventStateFailed) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("missing-object failure wrote snapshot: %d", got) } } -// TestFix_ForceDryRunListsPurge confirms --force without --yes still plans -// purge_barrier_with_successors (dry-run) so operators can preview the -// destructive action before opting in. -func TestFix_ForceDryRunListsPurge(t *testing.T) { +func TestFix_RefusesWhileDaemonIsLive(t *testing.T) { repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - stageBarrierWithSuccessors(t, ctx, repo, db) + if err := state.SaveDaemonState(ctx, db, state.DaemonState{PID: os.Getpid(), Mode: "running"}); err != nil { + t.Fatalf("SaveDaemonState: %v", err) + } before, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum before: %v", err) } - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, false /*yes*/, true /*force*/, false, true); err != nil { - t.Fatalf("runFix --force --dry-run: %v\n%s", err, out.String()) - } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) - } - if !plan.DryRun { - t.Fatalf("--force without --yes must dry-run: %+v", plan) - } - if !hasFixAction(plan, fixActionPurgeBarrierWithSuccessors) { - t.Fatalf("--force dry-run did not plan purge: %+v", plan.Actions) + err = runFix(ctx, &out, repo, false, true, false, false, true) + if err == nil || !strings.Contains(err.Error(), "unsafe conditions") { + t.Fatalf("runFix err=%v want live-daemon refusal", err) } after, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum after: %v", err) } if before != after { - t.Fatalf("--force --dry-run mutated state.db: before=%s after=%s", before, after) + t.Fatalf("live-daemon refusal mutated state.db: before=%s after=%s", before, after) } } -// TestFix_ForceYesAppliesPurge exercises the full destructive path: -// --force --yes deletes the blocked barrier row and clears the matching -// publish_state breadcrumb. Pending successors are left untouched. -func TestFix_ForceYesAppliesPurge(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) +func TestFix_ApplyRefusesDaemonLockAcquiredAfterPlan(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - stageBarrierWithSuccessors(t, ctx, repo, db) + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + plan, err := buildFixPlan(ctx, repo, stateDB, false, false, false) + if err != nil { + t.Fatalf("buildFixPlan: %v", err) + } + gitDir := filepath.Join(repo, ".git") + daemonLock, err := daemon.AcquireDaemonLock(gitDir) + if err != nil { + t.Fatalf("simulate daemon start after planning: %v", err) + } + defer daemonLock.Release() - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true /*yes*/, true /*force*/, false, true); err != nil { - t.Fatalf("runFix --force --yes: %v\n%s", err, out.String()) + err = applyFixPlan(ctx, stateDB, &plan) + if !errors.Is(err, daemon.ErrDaemonLockHeld) { + t.Fatalf("applyFixPlan err=%v want daemon-lock refusal", err) } - got := countCaptureRowsByState(t, db) - if got[state.EventStateBlockedConflict] != 0 { - t.Fatalf("blocked rows remain after --force --yes: %v", got) + if plan.BackupPath != "" || plan.RowsChanged != 0 { + t.Fatalf("daemon-lock refusal mutated plan: %+v", plan) } - if got[state.EventStatePending] == 0 { - t.Fatalf("pending successors should remain: %v", got) + assertFixEventState(t, ctx, db, first, state.EventStateBlockedConflict) + assertFixEventState(t, ctx, db, second, state.EventStatePending) + if got := countRowsWhere(t, db, "recovery_snapshots", "1 = 1"); got != 0 { + t.Fatalf("daemon-lock refusal wrote recovery snapshot: %d", got) } } -func TestFix_ForceYesPurgesBeforeRetargetResetsBlockedRows(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - stageBarrierWithSuccessors(t, ctx, repo, db) - if err := state.MetaSet(ctx, db, "last_replay_error", "stale breadcrumb"); err != nil { - t.Fatalf("MetaSet: %v", err) - } - +func runFixJSON(t *testing.T, repo string, dryRun, yes, force, clearPause bool) fixPlan { + t.Helper() var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, true, false, true); err != nil { - t.Fatalf("runFix --force --yes: %v\n%s", err, out.String()) + if err := runFix(context.Background(), &out, repo, dryRun, yes, force, clearPause, true); err != nil { + t.Fatalf("runFix: %v\n%s", err, out.String()) } var plan fixPlan if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + t.Fatalf("unmarshal fix result: %v\n%s", err, out.String()) } - purgeIdx, retargetIdx := -1, -1 - for i, action := range plan.Actions { - switch action.Kind { - case fixActionPurgeBarrierWithSuccessors: - purgeIdx = i - if action.RowsChanged != 1 { - t.Fatalf("purge rows=%d want 1: %+v", action.RowsChanged, plan.Actions) - } - case fixActionRetargetStaleAnchor: - retargetIdx = i + return plan +} + +func findFixAction(plan fixPlan, kind string) *fixAction { + for i := range plan.Actions { + if plan.Actions[i].Kind == kind { + return &plan.Actions[i] } } - if purgeIdx < 0 || retargetIdx < 0 || purgeIdx > retargetIdx { - t.Fatalf("purge must be planned/applied before retarget: purge=%d retarget=%d actions=%+v", purgeIdx, retargetIdx, plan.Actions) - } - got := countCaptureRowsByState(t, db) - if got[state.EventStateBlockedConflict] != 0 { - t.Fatalf("blocked rows remain after order-safe force fix: %v", got) - } - if got[state.EventStatePending] != 1 { - t.Fatalf("barrier must be deleted, not reset to pending; row counts: %v", got) - } - var barrierRows int - if err := db.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM capture_events WHERE path = 'barrier.txt'`, - ).Scan(&barrierRows); err != nil { - t.Fatalf("query barrier rows: %v", err) - } - if barrierRows != 0 { - t.Fatalf("force purge must delete barrier row, got %d barrier rows", barrierRows) - } + return nil } -func TestFix_ForceYesPurgesFailedBarrierWithSuccessors(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - head, err := git.RevParse(ctx, repo, "HEAD") +func commitExternalSeedChange(t *testing.T, ctx context.Context, repo string) (parent, head, beforeOID, afterOID string) { + t.Helper() + var err error + parent, err = git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("rev-parse: %v", err) - } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "failed-barrier.txt", - Fidelity: "exact", - State: state.EventStateFailed, - Error: sql.NullString{String: "old failure", Valid: true}, - }, nil); err != nil { - t.Fatalf("seed failed barrier: %v", err) - } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "pending-behind-failed.txt", - Fidelity: "exact", - State: state.EventStatePending, - }, nil); err != nil { - t.Fatalf("seed pending successor: %v", err) + t.Fatalf("rev-parse parent: %v", err) } - - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false, true, true, false, true); err != nil { - t.Fatalf("runFix --force --yes: %v\n%s", err, out.String()) - } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) + beforeOID, err = git.LsTreeBlobOID(ctx, repo, parent, "seed.txt") + if err != nil { + t.Fatalf("ls-tree parent: %v", err) } - var purge *fixAction - for i := range plan.Actions { - if plan.Actions[i].Kind == fixActionPurgeBarrierWithSuccessors { - purge = &plan.Actions[i] - break - } + if err := os.WriteFile(filepath.Join(repo, "seed.txt"), []byte("seed\nlanded externally\n"), 0o644); err != nil { + t.Fatalf("write seed.txt: %v", err) } - if purge == nil { - t.Fatalf("plan did not purge failed barrier: %+v", plan.Actions) + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "add", "seed.txt"); err != nil { + t.Fatalf("git add: %v", err) } - if purge.State != state.EventStateFailed || purge.RowsChanged != 1 { - t.Fatalf("purge action = %+v, want failed rows=1", *purge) + if _, err := git.Run(ctx, git.RunOpts{Dir: repo}, "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", "commit", "-m", "external modify"); err != nil { + t.Fatalf("git commit: %v", err) } - got := countCaptureRowsByState(t, db) - if got[state.EventStateFailed] != 0 { - t.Fatalf("failed rows remain after --force --yes: %v", got) + head, err = git.RevParse(ctx, repo, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) } - if got[state.EventStatePending] == 0 { - t.Fatalf("pending successors should remain: %v", got) + afterOID, err = git.LsTreeBlobOID(ctx, repo, head, "seed.txt") + if err != nil { + t.Fatalf("ls-tree HEAD: %v", err) } + return parent, head, beforeOID, afterOID } -func TestFix_ForceYesReportsIncompleteWhenPlannedPurgeChangesZeroRows(t *testing.T) { - repo, stateDB, _ := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() +func stageRecoverableBarrierPair(t *testing.T, ctx context.Context, repo string, db *state.DB, branchRef string, generation int64) (int64, int64) { + t.Helper() head, err := git.RevParse(ctx, repo, "HEAD") if err != nil { t.Fatalf("rev-parse: %v", err) } - plan := fixPlan{ - Repo: repo, - StateDB: stateDB, - GitDir: filepath.Join(repo, ".git"), - CurrentBranchRef: "refs/heads/main", - CurrentHead: head, - Generation: 1, - Force: true, - Actions: []fixAction{{ - ID: fixActionPurgeBarrierWithSuccessors + ":9999", - Kind: fixActionPurgeBarrierWithSuccessors, - Seq: 9999, - BranchRef: "refs/heads/main", - BranchGeneration: 1, - RequiresForce: true, - }}, - } - if err := applyFixPlan(ctx, stateDB, &plan); err == nil { - t.Fatalf("applyFixPlan succeeded despite zero-row planned purge: %+v", plan) - } - if !plan.Incomplete || len(plan.VerifyErrors) == 0 { - t.Fatalf("plan missing zero-row incomplete verification: %+v", plan) - } -} - -// TestFix_RetargetActionLandsWhenStaleAnchorPresent mirrors the recover_test -// fixture but drives the new action via acd fix. The fixture stages a stale -// pending row (not blocked, so the older delete_obsolete_barrier path leaves -// it alone); after --yes the row must be retargeted onto refs/heads/main. -func TestFix_RetargetActionLandsWhenStaleAnchorPresent(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - head, err := git.RevParse(ctx, repo, "HEAD") + firstBlob, err := git.HashObjectStdin(ctx, repo, []byte("first captured\n")) if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("hash first: %v", err) } - if err := state.SaveDaemonState(ctx, db, state.DaemonState{ - PID: 999999, - Mode: "stopped", - BranchRef: sql.NullString{String: "refs/heads/stale", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 2, Valid: true}, - }); err != nil { - t.Fatalf("SaveDaemonState: %v", err) + secondBlob, err := git.HashObjectStdin(ctx, repo, []byte("second captured\n")) + if err != nil { + t.Fatalf("hash second: %v", err) } - if err := state.MetaSet(ctx, db, "branch.generation", "2"); err != nil { - t.Fatalf("MetaSet: %v", err) - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/stale", - BranchGeneration: 2, - BaseHead: head, - Operation: "create", - Path: "stale.txt", - Fidelity: "full", - State: state.EventStatePending, + first := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: branchRef, BranchGeneration: generation, BaseHead: head, + Operation: "create", Path: "first-recovery.txt", Fidelity: "exact", + State: state.EventStateBlockedConflict, + Error: sql.NullString{String: "before-state mismatch", Valid: true}, }, []state.CaptureOp{{ - Op: "create", - Path: "stale.txt", - Fidelity: "full", - AfterMode: sql.NullString{String: "100644", Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, + Op: "create", Path: "first-recovery.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: firstBlob, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, }}) + second := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: branchRef, BranchGeneration: generation, BaseHead: head, + Operation: "create", Path: "second-recovery.txt", Fidelity: "exact", State: state.EventStatePending, + }, []state.CaptureOp{{ + Op: "create", Path: "second-recovery.txt", Fidelity: "exact", + AfterOID: sql.NullString{String: secondBlob, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }}) + return first, second +} + +func appendFixEvent(t *testing.T, ctx context.Context, db *state.DB, ev state.CaptureEvent, ops []state.CaptureOp) int64 { + t.Helper() + seq, err := state.AppendCaptureEvent(ctx, db, ev, ops) if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) + t.Fatalf("AppendCaptureEvent(%s): %v", ev.Path, err) } + return seq +} - var out bytes.Buffer - if err := runFix(ctx, &out, repo, false /*dryRun*/, true /*yes*/, false, false, true); err != nil { - t.Fatalf("runFix --yes: %v\n%s", err, out.String()) - } - var plan fixPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) - } - if !hasFixAction(plan, fixActionRetargetStaleAnchor) { - t.Fatalf("plan missing retarget_stale_anchor: %+v", plan.Actions) - } - var branchRef, eventState string - var gen int64 - if err := db.SQL().QueryRowContext(ctx, - `SELECT branch_ref, branch_generation, state FROM capture_events WHERE seq = ?`, seq, - ).Scan(&branchRef, &gen, &eventState); err != nil { - t.Fatalf("query event: %v", err) +func assertFixEventState(t *testing.T, ctx context.Context, db *state.DB, seq int64, want string) { + t.Helper() + var got string + if err := db.SQL().QueryRowContext(ctx, `SELECT state FROM capture_events WHERE seq = ?`, seq).Scan(&got); err != nil { + t.Fatalf("query seq=%d: %v", seq, err) } - if branchRef != "refs/heads/main" || gen != 2 || eventState != state.EventStatePending { - t.Fatalf("retarget did not land: branch=%q gen=%d state=%q want main/2/pending", - branchRef, gen, eventState) + if got != want { + t.Fatalf("seq=%d state=%s want %s", seq, got, want) } } -// stageBarrierWithSuccessors seeds: 1 blocked_conflict + 1 pending at higher -// seq on same (refs/heads/main, generation=1) anchored at the current HEAD so -// the planner sees a barrier with successors WITHOUT also tripping the -// retarget_stale_anchor predicate. Used by the --force tests. -func stageBarrierWithSuccessors(t *testing.T, ctx context.Context, repo string, db *state.DB) { +func countRowsWhere(t *testing.T, db *state.DB, table, where string, args ...any) int { t.Helper() - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse: %v", err) - } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "barrier.txt", - Fidelity: "exact", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "old conflict", Valid: true}, - }, nil); err != nil { - t.Fatalf("seed blocked: %v", err) - } - if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: "modify", - Path: "successor.txt", - Fidelity: "exact", - State: state.EventStatePending, - }, nil); err != nil { - t.Fatalf("seed pending successor: %v", err) - } - // Mirror publish_state singleton so the breadcrumb-clear path is exercised. - if _, err := db.SQL().ExecContext(ctx, ` -INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, status, error, updated_ts) -VALUES (1, 1, 'refs/heads/main', 1, ?, 'blocked_conflict', 'modify before-state mismatch', 1.0) -ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error`, head); err != nil { - t.Fatalf("seed publish_state: %v", err) + var n int + query := "SELECT COUNT(*) FROM " + table + " WHERE " + where + if err := db.SQL().QueryRowContext(context.Background(), query, args...).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) } + return n } +// seedGeneratedPendingFixFixture is shared with diagnose tests so both +// surfaces describe the same protected generated-delete incident. func seedGeneratedPendingFixFixture(t *testing.T, ctx context.Context, repo string, db *state.DB) []int64 { t.Helper() write := func(rel, body string) { @@ -993,26 +1147,16 @@ func seedGeneratedPendingFixFixture(t *testing.T, ctx context.Context, repo stri } seed := func(path, op string) int64 { t.Helper() - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: head, - Operation: op, - Path: path, - Fidelity: "full", - State: state.EventStatePending, + seq := appendFixEvent(t, ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: op, Path: path, Fidelity: "full", State: state.EventStatePending, }, []state.CaptureOp{{ - Op: op, - Path: path, - Fidelity: "full", - BeforeOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: op == "delete"}, + Op: op, Path: path, Fidelity: "full", + BeforeOID: sql.NullString{String: strings.Repeat("a", 40), Valid: op == "delete"}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: op == "delete"}, - AfterOID: sql.NullString{String: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Valid: op != "delete"}, + AfterOID: sql.NullString{String: strings.Repeat("b", 40), Valid: op != "delete"}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: op != "delete"}, }}) - if err != nil { - t.Fatalf("AppendCaptureEvent(%s,%s): %v", op, path, err) - } if err := state.RecordPlannerOffer(ctx, db, seq, 123); err != nil { t.Fatalf("RecordPlannerOffer(%d): %v", seq, err) } @@ -1026,22 +1170,3 @@ func seedGeneratedPendingFixFixture(t *testing.T, ctx context.Context, repo stri seed("src/ordinary.txt", "modify") return seqs } - -func countRowsWhere(t *testing.T, db *state.DB, table, where string, args ...any) int { - t.Helper() - var n int - q := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", table, where) - if err := db.SQL().QueryRowContext(context.Background(), q, args...).Scan(&n); err != nil { - t.Fatalf("count %s where %s: %v", table, where, err) - } - return n -} - -func gitCachedNameStatus(t *testing.T, ctx context.Context, repo string) string { - t.Helper() - out, err := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--name-status") - if err != nil { - t.Fatalf("git diff --cached --name-status: %v", err) - } - return string(out) -} diff --git a/internal/cli/help.go b/internal/cli/help.go index e4ad0439..94c6b579 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -3,50 +3,37 @@ package cli const rootHelpTemplate = `{{if eq .CommandPath "acd"}}{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} {{end}}Usage: + acd [flags] acd [flags] -Common workflow: - acd start Register this session and ensure the repo daemon is running - acd status Show daemon, branch, and client state for the current repo - acd events Show recent product decisions for the current repo - acd events --watch Stream appended product decisions - acd prompt --last Inspect the last recorded AI prompt request - acd explain --path FILE Explain why ACD did or did not commit a path - acd fix --dry-run Preview safe remediation for a stuck repo (use --force dry-run for barrier purge plans) - acd repo init Explicitly initialize ACD state for this repo - acd repo disable Stop and disable a registered repo while preserving state - acd repo enable Re-enable a registered repo without starting its daemon - acd repo manage Interactive lifecycle manager for enabling and disabling repos - acd repo list List all registry rows for lifecycle management - acd repo remove --dry-run Preview registry removal and state preservation - acd list Compact dashboard on TTY (Ctrl-C to exit) - acd list --once One-shot compact table (scripts) - acd list --verbose Wide table with CLIENTS and status notes - acd list --json All repos as JSON (one-shot on TTY) - acd logs --lines 200 Tail the current repo daemon log as raw JSONL - acd logs --follow Stream appended raw JSONL daemon log lines - acd wake Refresh heartbeat and nudge replay - acd commit-all One-shot: commit every uncommitted file (daemon must be off) - acd rewrite-commits --from-nr 5 --plan-only Generate an AI-gated linear rewrite plan without prompts - acd rewrite-commits --range-nr 5-12 --review Review/edit proposed messages before applying - acd rewrite-commits --edit --plan-only Edit a saved plan revision without AI calls - acd rewrite-commits --apply --dry-run Validate a saved plan before apply - acd stop Stop the repo daemon or deregister a session - -Diagnostics and recovery: - acd diagnose Inspect replay blockers, waiting queues, and branch anchors - acd doctor Run diagnostics and optionally bundle a support zip - acd pause Pause capture and replay - acd resume Resume capture and replay - -Setup: - acd setup Print harness install snippets - acd version Print version and build info +Primary controls: + acd Show one read-only health classification and next action + acd on Enable this repo and ensure its daemon is running + acd off Durably disable this repo while preserving state + acd status Show the detailed current-repo snapshot + acd setup Print the one-time harness install snippet + +Observe: + acd list Show all active repos + acd events Follow capture, grouping, publish, and block decisions + acd explain Explain one path or commit decision + +Support and recovery: + acd diagnose Inspect replay blockers and branch anchors read-only + acd doctor Inspect installation/runtime health or create a support bundle + acd fix Preview or apply advanced recovery actions Advanced: - acd stats Show aggregate commits, events, and bytes - acd gc Prune dead or missing repo registry entries - acd touch Refresh heartbeat without waking replay + acd repo Manage explicit registration and lifecycle details + acd logs Read raw daemon logs + acd pause Pause capture and replay for repository surgery + acd resume Resume a manually paused repo + acd commit-all Capture a dirty worktree while the daemon is off + acd rewrite-commits Plan or apply an explicit local history rewrite + acd stats / gc / prompt Inspect aggregate or internal state + +Hook protocol (normally managed by acd setup): + acd start / stop / wake / touch / flush Flags: {{.Flags.FlagUsages | trimTrailingWhitespaces}} diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go index b8024122..b2499141 100644 --- a/internal/cli/help_test.go +++ b/internal/cli/help_test.go @@ -12,6 +12,8 @@ func TestMajorCommandHelpIncludesWorkflowExamples(t *testing.T) { command string want []string }{ + {"on", []string{"acd on --repo /path/to/repo", "idempotent", "State is preserved"}}, + {"off", []string{"acd off --repo /path/to/repo", "idempotent", "preserves .git/acd state"}}, {"start", []string{"acd start --repo /path/to/repo", "--session-id", "acd status"}}, {"stop", []string{"acd stop --session-id", "acd stop --all --json", "active sessions"}}, {"status", []string{"current working directory", "blocked-vs-waiting recovery state", "acd explain"}}, diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index e2fbeb61..12ebdb1a 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -3,6 +3,7 @@ package cli import ( "context" "database/sql" + "errors" "fmt" "io" "log/slog" @@ -11,6 +12,7 @@ import ( "time" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/identity" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -23,6 +25,11 @@ import ( // is no longer tracking the live pending queue. const pathQuiescenceStaleness = 30 * time.Second +const ( + plannerHealthInvalidWarning = "persisted planner health metadata is invalid; planner health details were omitted" + plannerHealthVersionWarning = "persisted planner health metadata uses an unsupported version; planner health details were omitted" +) + // pathQuiescenceSnapshotFresh returns true when the daemon is alive AND // the path_quiescence.updated_at meta value is newer than // pathQuiescenceStaleness. A missing or unparseable timestamp is treated @@ -56,44 +63,46 @@ func pathQuiescenceSnapshotFresh(ctx context.Context, conn *sql.DB) bool { } type intentStrategyReport struct { - Strategy string `json:"strategy"` - CommitFormat string `json:"commit_format"` - Active bool `json:"active"` - Window int `json:"window,omitempty"` - RecentCommits int `json:"recent_commits,omitempty"` - DeferLimit int `json:"defer_limit,omitempty"` - MinPending int `json:"min_pending,omitempty"` - SettleWindowSeconds int64 `json:"settle_window_seconds"` - MaxPendingAgeSeconds int64 `json:"max_pending_age_seconds,omitempty"` - IntentStageDiffCap int `json:"intent_stage_diff_cap,omitempty"` - VisiblePendingEvents int `json:"visible_pending_events,omitempty"` - OldestPendingEventSeq int64 `json:"oldest_pending_event_seq,omitempty"` - OldestPendingPath string `json:"oldest_pending_path,omitempty"` - OldestPendingAgeSeconds int64 `json:"oldest_pending_age_seconds,omitempty"` - NewestPendingEventSeq int64 `json:"newest_pending_event_seq,omitempty"` - NewestPendingAgeSeconds int64 `json:"newest_pending_age_seconds,omitempty"` - AgeTriggerTS int64 `json:"age_trigger_ts,omitempty"` - AgeTriggerInSeconds int64 `json:"age_trigger_in_seconds,omitempty"` - SettleTriggerTS int64 `json:"settle_trigger_ts,omitempty"` - SettleTriggerInSeconds int64 `json:"settle_trigger_in_seconds,omitempty"` - BatchWaitActive bool `json:"batch_wait_active,omitempty"` - BatchWaitReason string `json:"batch_wait_reason,omitempty"` - DeferredEvents int `json:"deferred_events,omitempty"` - MaxDeferCount int `json:"max_defer_count,omitempty"` - ForcedAgingReady int `json:"forced_aging_ready,omitempty"` - LastDeferredEventSeq int64 `json:"last_deferred_event_seq,omitempty"` - LastDeferredPath string `json:"last_deferred_path,omitempty"` - LastDeferredReason string `json:"last_deferred_reason,omitempty"` - LastPlannerErrorEventSeq int64 `json:"last_planner_error_event_seq,omitempty"` - LastPlannerErrorPath string `json:"last_planner_error_path,omitempty"` - LastPlannerError string `json:"last_planner_error,omitempty"` - MessageQualityRewriteCountRecent int `json:"message_quality_rewrite_count_recent,omitempty"` - MessageQualityFallbackCountRecent int `json:"message_quality_fallback_count_recent,omitempty"` - LastMessageQualityEventSeq int64 `json:"last_message_quality_event_seq,omitempty"` - LastMessageQualityPath string `json:"last_message_quality_path,omitempty"` - LastMessageQualityAction string `json:"last_message_quality_action,omitempty"` - LastMessageQualityReason string `json:"last_message_quality_reason,omitempty"` - LastPlannerWindow *intentPlannerWindowSummary `json:"last_planner_window,omitempty"` + Strategy string `json:"strategy"` + CommitFormat string `json:"commit_format"` + Active bool `json:"active"` + Window int `json:"window,omitempty"` + RecentCommits int `json:"recent_commits,omitempty"` + DeferLimit int `json:"defer_limit,omitempty"` + MinPending int `json:"min_pending,omitempty"` + SettleWindowSeconds int64 `json:"settle_window_seconds"` + MaxPendingAgeSeconds int64 `json:"max_pending_age_seconds,omitempty"` + IntentStageDiffCap int `json:"intent_stage_diff_cap,omitempty"` + VisiblePendingEvents int `json:"visible_pending_events,omitempty"` + OldestPendingEventSeq int64 `json:"oldest_pending_event_seq,omitempty"` + OldestPendingPath string `json:"oldest_pending_path,omitempty"` + OldestPendingAgeSeconds int64 `json:"oldest_pending_age_seconds,omitempty"` + NewestPendingEventSeq int64 `json:"newest_pending_event_seq,omitempty"` + NewestPendingAgeSeconds int64 `json:"newest_pending_age_seconds,omitempty"` + AgeTriggerTS int64 `json:"age_trigger_ts,omitempty"` + AgeTriggerInSeconds int64 `json:"age_trigger_in_seconds,omitempty"` + SettleTriggerTS int64 `json:"settle_trigger_ts,omitempty"` + SettleTriggerInSeconds int64 `json:"settle_trigger_in_seconds,omitempty"` + BatchWaitActive bool `json:"batch_wait_active,omitempty"` + BatchWaitReason string `json:"batch_wait_reason,omitempty"` + DeferredEvents int `json:"deferred_events,omitempty"` + MaxDeferCount int `json:"max_defer_count,omitempty"` + ForcedAgingReady int `json:"forced_aging_ready,omitempty"` + LastDeferredEventSeq int64 `json:"last_deferred_event_seq,omitempty"` + LastDeferredPath string `json:"last_deferred_path,omitempty"` + LastDeferredReason string `json:"last_deferred_reason,omitempty"` + LastPlannerErrorEventSeq int64 `json:"last_planner_error_event_seq,omitempty"` + LastPlannerErrorPath string `json:"last_planner_error_path,omitempty"` + LastPlannerError string `json:"last_planner_error,omitempty"` + MessageQualityRewriteCountRecent int `json:"message_quality_rewrite_count_recent,omitempty"` + MessageQualityFallbackCountRecent int `json:"message_quality_fallback_count_recent,omitempty"` + LastMessageQualityEventSeq int64 `json:"last_message_quality_event_seq,omitempty"` + LastMessageQualityPath string `json:"last_message_quality_path,omitempty"` + LastMessageQualityAction string `json:"last_message_quality_action,omitempty"` + LastMessageQualityReason string `json:"last_message_quality_reason,omitempty"` + PlannerHealth *daemon.IntentPlannerHealthSnapshot `json:"planner_health,omitempty"` + PlannerHealthWarning string `json:"planner_health_warning,omitempty"` + LastPlannerWindow *intentPlannerWindowSummary `json:"last_planner_window,omitempty"` // PlannerErrorRateRecent is the share of intent_planner_error rows in // the most recent IntentRecentDecisionWindow decisions. The denominator // is always IntentRecentDecisionWindow (default 100) regardless of how @@ -210,6 +219,24 @@ func renderIntentStrategyHuman(out io.Writer, r intentStrategyReport) { r.LastMessageQualityReason) } } + if r.PlannerHealth != nil { + health := r.PlannerHealth + fmt.Fprintf(out, "Intent planner health: %s failures=%d bypasses=%d", + valueOrUnset(string(health.State)), health.ConsecutiveFailures, health.BypassCount) + if health.NextProbeTS > 0 { + fmt.Fprintf(out, " next_probe=%s", time.Unix(int64(health.NextProbeTS), 0).UTC().Format(time.RFC3339)) + } + if health.LastFailureClass != "" { + fmt.Fprintf(out, " last_failure_class=%s", health.LastFailureClass) + } + fmt.Fprintln(out) + if health.LastError != "" { + fmt.Fprintf(out, " Last circuit failure: %s\n", health.LastError) + } + } + if r.PlannerHealthWarning != "" { + fmt.Fprintf(out, "Intent planner health warning: %s\n", r.PlannerHealthWarning) + } if r.LastPlannerWindow != nil { win := r.LastPlannerWindow fmt.Fprintf(out, "Last planner window: #%d provider=%s offered=%s selected_groups=%d deferred=%s\n", @@ -353,6 +380,12 @@ func loadIntentStrategyReport(ctx context.Context, conn *sql.DB) (intentStrategy } else if ok { report.MaxPendingAgeSeconds = parseIntentMetaDurationSeconds(v, report.MaxPendingAgeSeconds) } + if plannerHealth, warning, err := loadIntentPlannerHealth(ctx, conn); err != nil { + return report, err + } else { + report.PlannerHealth = plannerHealth + report.PlannerHealthWarning = warning + } if err := loadLastIntentPlannerError(ctx, conn, &report); err != nil { return report, err } @@ -443,6 +476,27 @@ LIMIT 1`, state.EventStatePending, state.EventStateFailed, state.EventStateBlock return report, nil } +func loadIntentPlannerHealth(ctx context.Context, conn *sql.DB) (*daemon.IntentPlannerHealthSnapshot, string, error) { + raw, ok, err := metaLookup(ctx, conn, daemon.MetaKeyIntentPlannerHealth) + if err != nil { + return nil, "", fmt.Errorf("intent planner health: %w", err) + } + if !ok { + return nil, "", nil + } + if strings.TrimSpace(raw) == "" { + return nil, plannerHealthInvalidWarning, nil + } + health, err := daemon.DecodeIntentPlannerHealthSnapshot(raw) + if errors.Is(err, daemon.ErrIntentPlannerHealthUnsupportedVersion) { + return nil, plannerHealthVersionWarning, nil + } + if err != nil { + return nil, plannerHealthInvalidWarning, nil + } + return &health, "", nil +} + func normalizeCommitFormatForReport(raw, fallback string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case string(ai.CommitFormatConventional): @@ -589,7 +643,7 @@ LIMIT 1`, state.DecisionKindIntentPlannerError).Scan(&lastErrorSeq, &lastErrorPa report.LastPlannerErrorPath = lastErrorPath.String } if lastError.Valid { - report.LastPlannerError = lastError.String + report.LastPlannerError = ai.SanitizePlannerError(lastError.String) } return nil } diff --git a/internal/cli/intent_observability_test.go b/internal/cli/intent_observability_test.go index 472f3c52..57ec2bbe 100644 --- a/internal/cli/intent_observability_test.go +++ b/internal/cli/intent_observability_test.go @@ -92,7 +92,7 @@ func TestStatus_LastPlannerWindowSummary(t *testing.T) { BranchGeneration: 1, Source: sqlNullStr("deterministic"), CommitFormat: sqlNullStr("imperative"), - ValidationFailure: sqlNullStr("planner validation failed"), + ValidationFailure: sqlNullStr(`planner validation failed {"api_key":"legacy-window-secret"}`), OfferedSeqs: []int64{4, 5}, VisibleOriginalSeqs: []int64{4, 5, 6}, HiddenSeqs: []int64{6}, @@ -118,7 +118,8 @@ func TestStatus_LastPlannerWindowSummary(t *testing.T) { } if len(win.OfferedSeqs) != 2 || win.OfferedSeqs[0] != 4 || len(win.HiddenSeqs) != 1 || win.HiddenSeqs[0] != 6 || - win.ValidationFailure != "planner validation failed" { + strings.Contains(win.ValidationFailure, "legacy-window-secret") || + !strings.Contains(win.ValidationFailure, "[REDACTED]") { t.Fatalf("last planner window seq/failure fields = %+v", win) } diff --git a/internal/cli/intent_planner_window.go b/internal/cli/intent_planner_window.go index 38a5b6ff..1eca6522 100644 --- a/internal/cli/intent_planner_window.go +++ b/internal/cli/intent_planner_window.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -172,7 +173,7 @@ func scanIntentPlannerWindowSummary(scan scannerFunc) (intentPlannerWindowSummar win.CommitFormat = commitFormat.String win.Forced = forced != 0 win.ForcedReason = forcedReason.String - win.ValidationFailure = validationFailure.String + win.ValidationFailure = ai.SanitizePlannerError(validationFailure.String) if err := json.Unmarshal([]byte(emptyJSONArray(offered)), &win.OfferedSeqs); err != nil { return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal offered seqs: %w", err) } diff --git a/internal/cli/purge.go b/internal/cli/purge.go index d785f03b..8fbf53f0 100644 --- a/internal/cli/purge.go +++ b/internal/cli/purge.go @@ -1,70 +1,27 @@ -// purge.go implements `acd purge-events` — operator-driven cleanup of -// non-published capture_events rows. -// -// Why this exists: when a parallel committer (e.g. the atomic-commit -// hook plugin) lands the same edit acd captured, replay sees the -// captured `before_oid` no longer matching HEAD and terminally settles -// the event in `blocked_conflict`. Per architecture invariant the -// blocked row forms a seq barrier — every later pending row hides -// behind it in PendingEvents until an operator intervenes. The legacy -// recovery story was raw sqlite3 surgery, which is fine for engineers -// but unhelpful for users diagnosing a stuck queue. -// -// `acd recover` already exists but does a different job: it RETARGETS -// stale rows onto the current HEAD/generation (preserving them). When -// the captured edits are already in the working tree (committed by the -// parallel committer), retarget just produces another mismatch on the -// next replay pass. Outright deletion is the right move for that case -// — hence this command. -// -// Safety scaffolding mirrors recover.go: -// - Refuse while the daemon is alive. -// - Backup state.db before mutating. -// - --yes required to apply, --dry-run prints the plan without -// touching the DB. package cli import ( "context" - "database/sql" - "encoding/json" "fmt" "io" - "sort" - "strings" - "time" "github.com/spf13/cobra" - - "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) -// purgePlan is the JSON/human shape returned by `acd purge-events`. -type purgePlan struct { - Repo string `json:"repo"` - StateDB string `json:"state_db"` - States []string `json:"states"` - StateCounts map[string]int `json:"state_counts"` - DryRun bool `json:"dry_run"` - BackupPath string `json:"backup_path,omitempty"` - RowsDeleted int64 `json:"rows_deleted"` - BarrierLift bool `json:"barrier_lift"` -} - func newPurgeEventsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "purge-events", - Short: "[DEPRECATED] Delete non-published capture_events rows (use `acd fix --force --yes`)", + Short: "[DEPRECATED] Preserve stuck events safely (use `acd fix --force --yes`)", Hidden: true, - Long: `DEPRECATED: ` + "`acd purge-events`" + ` is now a thin alias for -` + "`acd fix --force`" + `. + Long: `DEPRECATED: ` + "`acd purge-events`" + ` no longer deletes capture rows. -The recommended flow is: - acd fix --force --dry-run # preview purge_barrier_with_successors - acd fix --force --yes # apply +Use: + acd fix --force --dry-run # preview archive-only recovery + acd fix --force --yes # protect each exact pair, then settle it -Selecting only --pending or --failed is preserved here for one release -so existing scripts keep working.`, +The legacy --blocked, --pending, and --failed selectors are refused because +delegating them to whole-repository recovery can affect unrelated exact pairs. +Use --all explicitly to preview or apply archive-only recovery.`, RunE: func(c *cobra.Command, args []string) error { repo, _ := c.Flags().GetString("repo") blocked, _ := c.Flags().GetBool("blocked") @@ -79,221 +36,32 @@ so existing scripts keep working.`, repo, blocked, pending, failed, all, yes, dryRun, jsonOut) }, } - cmd.Flags().Bool("blocked", false, "(deprecated) Include rows in state=blocked_conflict") - cmd.Flags().Bool("pending", false, "(deprecated) Include rows in state=pending") - cmd.Flags().Bool("failed", false, "(deprecated) Include rows in state=failed") - cmd.Flags().Bool("all", false, "(deprecated) Shortcut for --blocked --pending --failed") - cmd.Flags().Bool("yes", false, "(deprecated) Apply the deletion") - cmd.Flags().Bool("dry-run", false, "(deprecated) Show what would be deleted") + cmd.Flags().Bool("blocked", false, "(deprecated) Unsupported selective recovery; use --all") + cmd.Flags().Bool("pending", false, "(deprecated) Unsupported selective deletion") + cmd.Flags().Bool("failed", false, "(deprecated) Unsupported selective deletion") + cmd.Flags().Bool("all", false, "(deprecated) Safely reconcile all stuck exact pairs") + cmd.Flags().Bool("yes", false, "(deprecated) Apply safe archive-only recovery") + cmd.Flags().Bool("dry-run", false, "(deprecated) Show the safe recovery plan") return cmd } -func runPurgeEvents(ctx context.Context, out io.Writer, - repo string, blocked, pending, failed, all, yes, dryRun, jsonOut bool, +func runPurgeEvents( + ctx context.Context, + out io.Writer, + repo string, + blocked, pending, failed, all, yes, dryRun, jsonOut bool, ) error { if ctx == nil { ctx = context.Background() } - states := selectPurgeStates(blocked, pending, failed, all) - if len(states) == 0 { - return fmt.Errorf("acd purge-events: pass at least one of --blocked / --pending / --failed (or --all)") + if blocked || pending || failed { + return fmt.Errorf("acd purge-events: selective --blocked/--pending/--failed recovery is no longer supported; pass --all to explicitly preserve every stuck exact pair") + } + if !all { + return fmt.Errorf("acd purge-events: pass --all to delegate whole-repository safe recovery") } if !dryRun && !yes { return fmt.Errorf("acd purge-events: refusing to mutate state without --yes (use --dry-run first)") } - - rec, err := recoverRepoRecord(repo) - if err != nil { - return err - } - - plan, err := buildPurgePlan(ctx, rec.Path, rec.StateDB, states, dryRun) - if err != nil { - return err - } - if dryRun { - return renderPurge(out, plan, jsonOut) - } - - if err := applyPurgePlan(ctx, rec.StateDB, &plan); err != nil { - return err - } - return renderPurge(out, plan, jsonOut) -} - -// selectPurgeStates resolves the flags into a deterministic, sorted -// slice of state names. Tests pin the ordering, and downstream SQL -// substitutes positional parameters — both want a stable order. -func selectPurgeStates(blocked, pending, failed, all bool) []string { - if all { - blocked = true - pending = true - failed = true - } - set := map[string]bool{} - if blocked { - set[state.EventStateBlockedConflict] = true - } - if pending { - set[state.EventStatePending] = true - } - if failed { - set[state.EventStateFailed] = true - } - out := make([]string, 0, len(set)) - for s := range set { - out = append(out, s) - } - sort.Strings(out) - return out -} - -func buildPurgePlan(ctx context.Context, repo, stateDB string, states []string, dryRun bool) (purgePlan, error) { - conn, err := openStateDBReadOnly(ctx, stateDB) - if err != nil { - return purgePlan{}, fmt.Errorf("acd purge-events: open state.db read-only: %w", err) - } - defer conn.Close() - - if err := refuseRecoverWhenDaemonAliveSQL(ctx, conn); err != nil { - // Reuse the recover-side guard verbatim; only the verb in the - // error string differs, and that's tolerable for now. - return purgePlan{}, err - } - - counts, err := countEventsByState(ctx, conn, states) - if err != nil { - return purgePlan{}, err - } - plan := purgePlan{ - Repo: repo, - StateDB: stateDB, - States: states, - StateCounts: counts, - DryRun: dryRun, - BarrierLift: counts[state.EventStateBlockedConflict] > 0, - } - return plan, nil -} - -func countEventsByState(ctx context.Context, conn *sql.DB, states []string) (map[string]int, error) { - counts := map[string]int{} - for _, s := range states { - var n int - if err := conn.QueryRowContext(ctx, - `SELECT COUNT(*) FROM capture_events WHERE state = ?`, s, - ).Scan(&n); err != nil { - return nil, fmt.Errorf("count state=%s: %w", s, err) - } - counts[s] = n - } - return counts, nil -} - -// applyPurgePlan deletes capture_events rows matching plan.States and -// clears matching publish_state + last_replay_conflict breadcrumbs so -// `acd list` / `acd status` read clean immediately after. -// -// Wrapped in a single transaction. Backup created before opening the -// writable handle so a torn write does not leave the user without a -// recovery path. -func applyPurgePlan(ctx context.Context, stateDB string, plan *purgePlan) error { - backup, err := backupStateDB(stateDB) - if err != nil { - return fmt.Errorf("acd purge-events: backup state.db: %w", err) - } - plan.BackupPath = backup - - db, err := state.Open(ctx, stateDB) - if err != nil { - return fmt.Errorf("acd purge-events: open state.db: %w", err) - } - defer func() { _ = db.Close() }() - - if err := refuseRecoverWhenDaemonAlive(ctx, db); err != nil { - return err - } - - tx, err := db.SQL().BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("acd purge-events: begin transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - // Build "state IN (?,?,…)" placeholder list deterministically. - placeholders := make([]string, len(plan.States)) - args := make([]any, len(plan.States)) - for i, s := range plan.States { - placeholders[i] = "?" - args[i] = s - } - delQuery := fmt.Sprintf(`DELETE FROM capture_events WHERE state IN (%s)`, - strings.Join(placeholders, ",")) - - res, err := tx.ExecContext(ctx, delQuery, args...) - if err != nil { - return fmt.Errorf("acd purge-events: delete capture_events: %w", err) - } - if n, rerr := res.RowsAffected(); rerr == nil { - plan.RowsDeleted = n - } - - // Lift the barrier in publish_state if we deleted a blocked_conflict. - // The singleton row is keyed at id=1; clear status + error and zero - // the conflict cursor fields so `acd status` reads "ok". - if plan.BarrierLift { - nowSec := float64(time.Now().UnixNano()) / 1e9 - if _, err := tx.ExecContext(ctx, ` -UPDATE publish_state -SET status = 'ok', error = NULL, updated_ts = ? -WHERE id = 1 AND status = 'blocked_conflict'`, nowSec); err != nil { - return fmt.Errorf("acd purge-events: clear publish_state: %w", err) - } - // Drop the human-readable breadcrumbs so `acd status` does not - // keep nagging about a conflict that no longer has a row. - for _, key := range []string{ - "last_replay_conflict", - "last_replay_conflict_legacy", - "last_replay_error", - } { - if _, err := tx.ExecContext(ctx, `DELETE FROM daemon_meta WHERE key = ?`, key); err != nil { - return fmt.Errorf("acd purge-events: clear daemon_meta %s: %w", key, err) - } - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("acd purge-events: commit: %w", err) - } - return nil -} - -func renderPurge(out io.Writer, plan purgePlan, jsonOut bool) error { - if jsonOut { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(plan) - } - mode := "planned" - if !plan.DryRun { - mode = "applied" - } - fmt.Fprintf(out, "purge-events %s for %s\n", mode, plan.Repo) - fmt.Fprintf(out, "States: %s\n", strings.Join(plan.States, ", ")) - for _, s := range plan.States { - fmt.Fprintf(out, " %s: %d rows\n", s, plan.StateCounts[s]) - } - if plan.BarrierLift { - fmt.Fprintln(out, "Will lift the blocked_conflict barrier in publish_state.") - } - if plan.BackupPath != "" { - fmt.Fprintf(out, "Backup: %s\n", plan.BackupPath) - } - if !plan.DryRun { - fmt.Fprintf(out, "Rows deleted: %d (at %s)\n", plan.RowsDeleted, - time.Now().UTC().Format(time.RFC3339)) - } else { - fmt.Fprintln(out, "(dry-run; pass --yes to apply)") - } - return nil + return runFix(ctx, out, repo, dryRun, yes, true, false, jsonOut) } diff --git a/internal/cli/purge_test.go b/internal/cli/purge_test.go index 08fa7026..7d05bfef 100644 --- a/internal/cli/purge_test.go +++ b/internal/cli/purge_test.go @@ -10,256 +10,126 @@ import ( "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) -// seedPurgeFixtureRows inserts a known mix of capture_events rows and a -// blocked_conflict publish_state row so the purge tests have something -// real to delete. Returns the *state.DB for additional assertions. -func seedPurgeFixtureRows(t *testing.T, db *state.DB) { - t.Helper() - ctx := context.Background() - mk := func(op, path, st string) { - ev := state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: 1, - BaseHead: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - Operation: op, - Path: path, - Fidelity: "exact", - State: st, - } - if _, err := state.AppendCaptureEvent(ctx, db, ev, nil); err != nil { - t.Fatalf("AppendCaptureEvent(%s,%s,%s): %v", op, path, st, err) - } - } - // 1 published, 2 pending, 1 blocked_conflict, 1 failed. - mk("create", "kept.txt", state.EventStatePublished) - mk("modify", "p1.txt", state.EventStatePending) - mk("modify", "p2.txt", state.EventStatePending) - mk("modify", "blocked.txt", state.EventStateBlockedConflict) - mk("delete", "broken.txt", state.EventStateFailed) - - // Mirror what the daemon would set when it terminally settles a - // blocked event: publish_state singleton + breadcrumb meta keys. - if _, err := db.SQL().ExecContext(ctx, ` -INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, status, error, updated_ts) -VALUES (1, 4, 'refs/heads/main', 1, 'deadbeef', 'blocked_conflict', 'modify before-state mismatch', 1.0) -ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error`); err != nil { - t.Fatalf("seed publish_state: %v", err) - } - if err := state.MetaSet(ctx, db, "last_replay_conflict", - `{"seq":4,"error_class":"before_state_mismatch","message":"x"}`); err != nil { - t.Fatalf("seed meta: %v", err) - } -} - -func countCaptureRowsByState(t *testing.T, db *state.DB) map[string]int { - t.Helper() - rows, err := db.SQL().QueryContext(context.Background(), - `SELECT state, COUNT(*) FROM capture_events GROUP BY state`) - if err != nil { - t.Fatalf("count rows: %v", err) - } - defer rows.Close() - out := map[string]int{} - for rows.Next() { - var s string - var n int - if err := rows.Scan(&s, &n); err != nil { - t.Fatalf("scan: %v", err) - } - out[s] = n - } - return out -} - -// TestPurgeEvents_RequiresAtLeastOneState ensures the CLI rejects a -// flag-less invocation. Without this guard a user typing `acd purge-events` -// could expect the command to "do nothing" but instead would still -// execute (deleting zero rows is harmless, but accepting the call masks -// the operator's likely intent). -func TestPurgeEvents_RequiresAtLeastOneState(t *testing.T) { +func TestPurgeEvents_RequiresSafeSelector(t *testing.T) { repo, _, _ := makeRegisteredGitRepoStateDB(t) var out bytes.Buffer - err := runPurgeEvents(context.Background(), &out, repo, - false, false, false, false, false, true, true) - if err == nil { - t.Fatalf("expected error when no state flag is passed; got out=%s", out.String()) - } - if !strings.Contains(err.Error(), "at least one") { - t.Fatalf("unexpected error: %v", err) + err := runPurgeEvents(context.Background(), &out, repo, false, false, false, false, false, true, true) + if err == nil || !strings.Contains(err.Error(), "pass --all") { + t.Fatalf("runPurgeEvents err=%v want safe-selector refusal", err) } } -// TestPurgeEvents_RequiresYesWhenNotDryRun guards against silent -// destructive runs. --yes must be explicit. -func TestPurgeEvents_RequiresYesWhenNotDryRun(t *testing.T) { +func TestPurgeEvents_RequiresYesWhenApplying(t *testing.T) { repo, _, _ := makeRegisteredGitRepoStateDB(t) var out bytes.Buffer - // blocked=true, dryRun=false, yes=false ⇒ refused. - err := runPurgeEvents(context.Background(), &out, repo, - true, false, false, false, false, false, true) - if err == nil { - t.Fatalf("expected error without --yes; got out=%s", out.String()) - } - if !strings.Contains(err.Error(), "--yes") { - t.Fatalf("unexpected error: %v", err) + err := runPurgeEvents(context.Background(), &out, repo, false, false, false, true, false, false, true) + if err == nil || !strings.Contains(err.Error(), "without --yes") { + t.Fatalf("runPurgeEvents err=%v want --yes refusal", err) } } -// TestPurgeEvents_DryRunCountsButDoesNotMutate validates the inspection -// path: counts surface, no rows disappear, no backup file created. -func TestPurgeEvents_DryRunCountsButDoesNotMutate(t *testing.T) { - repo, stateDB, db := makeRegisteredGitRepoStateDB(t) - seedPurgeFixtureRows(t, db) +func TestPurgeEvents_RefusesAllSelectiveRecovery(t *testing.T) { + repo, stateDB, _ := makeRegisteredGitRepoStateDB(t) before, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum before: %v", err) } - - var out bytes.Buffer - err = runPurgeEvents(context.Background(), &out, repo, - true /*blocked*/, true /*pending*/, true /*failed*/, false, false /*yes*/, true /*dryRun*/, true) - if err != nil { - t.Fatalf("runPurgeEvents dry-run: %v\n%s", err, out.String()) - } - - var plan purgePlan - if jerr := json.Unmarshal(out.Bytes(), &plan); jerr != nil { - t.Fatalf("unmarshal: %v\n%s", jerr, out.String()) - } - if !plan.DryRun { - t.Fatalf("plan.DryRun=false; want true") - } - if plan.RowsDeleted != 0 { - t.Fatalf("plan.RowsDeleted=%d; dry run must not delete", plan.RowsDeleted) - } - if got := plan.StateCounts[state.EventStatePending]; got != 2 { - t.Fatalf("pending count = %d, want 2", got) - } - if got := plan.StateCounts[state.EventStateBlockedConflict]; got != 1 { - t.Fatalf("blocked count = %d, want 1", got) - } - if got := plan.StateCounts[state.EventStateFailed]; got != 1 { - t.Fatalf("failed count = %d, want 1", got) - } - if !plan.BarrierLift { - t.Fatalf("BarrierLift=false; expected true since blocked count > 0") - } - if plan.BackupPath != "" { - t.Fatalf("dry-run wrote backup %q; should be empty", plan.BackupPath) + for _, tc := range []struct { + blocked bool + pending bool + failed bool + }{ + {blocked: true}, + {pending: true}, + {failed: true}, + } { + var out bytes.Buffer + err := runPurgeEvents(context.Background(), &out, repo, tc.blocked, tc.pending, tc.failed, false, true, false, true) + if err == nil || !strings.Contains(err.Error(), "selective --blocked/--pending/--failed recovery is no longer supported") { + t.Fatalf("runPurgeEvents blocked=%v pending=%v failed=%v err=%v", tc.blocked, tc.pending, tc.failed, err) + } } - after, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum after: %v", err) } if before != after { - t.Fatalf("dry-run mutated state.db: before=%s after=%s", before, after) + t.Fatalf("refused selective purge mutated state.db: before=%s after=%s", before, after) } } -// TestPurgeEvents_ApplyDeletesRowsAndLiftsBarrier is the happy-path -// regression. After --all --yes, blocked + pending + failed rows must -// be gone, the published row must remain, publish_state must transition -// out of blocked_conflict, and the breadcrumb meta key must be gone so -// `acd status` reads clean. -func TestPurgeEvents_ApplyDeletesRowsAndLiftsBarrier(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - seedPurgeFixtureRows(t, db) - - var out bytes.Buffer - err := runPurgeEvents(context.Background(), &out, repo, - false, false, false, true /*all*/, true /*yes*/, false /*dryRun*/, true) +func TestPurgeEvents_DryRunDelegatesArchiveOnlyFix(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + stageRecoverableBarrierPair(t, context.Background(), repo, db, "refs/heads/main", 1) + before, err := fileSHA256(stateDB) if err != nil { - t.Fatalf("runPurgeEvents: %v\n%s", err, out.String()) - } - - var plan purgePlan - if jerr := json.Unmarshal(out.Bytes(), &plan); jerr != nil { - t.Fatalf("unmarshal: %v\n%s", jerr, out.String()) - } - if plan.RowsDeleted != 4 { - t.Fatalf("RowsDeleted=%d, want 4 (2 pending + 1 blocked + 1 failed)", plan.RowsDeleted) - } - if plan.BackupPath == "" { - t.Fatalf("apply path must produce a backup file") + t.Fatalf("checksum before: %v", err) } - got := countCaptureRowsByState(t, db) - if got[state.EventStatePublished] != 1 { - t.Fatalf("published row deleted: counts=%v", got) + var out bytes.Buffer + if err := runPurgeEvents(context.Background(), &out, repo, false, false, false, true, false, true, true); err != nil { + t.Fatalf("runPurgeEvents dry-run: %v\n%s", err, out.String()) } - if got[state.EventStatePending] != 0 || got[state.EventStateBlockedConflict] != 0 || got[state.EventStateFailed] != 0 { - t.Fatalf("non-published rows remain: counts=%v", got) + var plan fixPlan + if err := json.Unmarshal(out.Bytes(), &plan); err != nil { + t.Fatalf("unmarshal delegated fix plan: %v\n%s", err, out.String()) } - - // publish_state lifted. - var status string - if err := db.SQL().QueryRowContext(context.Background(), - `SELECT status FROM publish_state WHERE id=1`).Scan(&status); err != nil { - t.Fatalf("read publish_state: %v", err) + action := findFixAction(plan, fixActionReconcileUnpublishedChain) + if !plan.DryRun || action == nil || !action.ArchiveOnly || !action.RequiresForce { + t.Fatalf("purge alias did not plan archive-only recovery: %+v", plan) } - if status == "blocked_conflict" { - t.Fatalf("publish_state.status still blocked_conflict") + after, err := fileSHA256(stateDB) + if err != nil { + t.Fatalf("checksum after: %v", err) } - - // Breadcrumb meta gone. - if _, ok, err := state.MetaGet(context.Background(), db, "last_replay_conflict"); err != nil { - t.Fatalf("MetaGet: %v", err) - } else if ok { - t.Fatalf("last_replay_conflict meta still present after purge") + if before != after { + t.Fatalf("purge dry-run mutated state.db: before=%s after=%s", before, after) } } -// TestPurgeEvents_OnlyPendingLeavesBlockedAlone verifies the state -// selector is precise. Asking only for --pending must NOT touch the -// blocked row (operators sometimes want to keep the diagnostic and -// just clear the tail). -func TestPurgeEvents_OnlyPendingLeavesBlockedAlone(t *testing.T) { +func TestPurgeEvents_AllArchivesWholePairWithoutDeletingRows(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) - seedPurgeFixtureRows(t, db) + ctx := context.Background() + first, second := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) var out bytes.Buffer - err := runPurgeEvents(context.Background(), &out, repo, - false /*blocked*/, true /*pending*/, false /*failed*/, false, true, false, true) - if err != nil { - t.Fatalf("runPurgeEvents: %v\n%s", err, out.String()) - } - - got := countCaptureRowsByState(t, db) - if got[state.EventStatePending] != 0 { - t.Fatalf("pending rows remain: %v", got) - } - if got[state.EventStateBlockedConflict] != 1 { - t.Fatalf("blocked row removed unexpectedly: %v", got) + if err := runPurgeEvents(ctx, &out, repo, false, false, false, true, true, false, true); err != nil { + t.Fatalf("runPurgeEvents apply: %v\n%s", err, out.String()) } - if got[state.EventStateFailed] != 1 { - t.Fatalf("failed row removed unexpectedly: %v", got) + assertFixEventState(t, ctx, db, first, state.EventStateRecovered) + assertFixEventState(t, ctx, db, second, state.EventStateRecovered) + if got := countRowsWhere(t, db, "capture_events", "seq IN (?, ?)", first, second); got != 2 { + t.Fatalf("purge alias deleted protected capture rows: %d", got) } - - // Barrier NOT lifted because we did not include blocked. - var status string - if err := db.SQL().QueryRowContext(context.Background(), - `SELECT status FROM publish_state WHERE id=1`).Scan(&status); err != nil { - t.Fatalf("read publish_state: %v", err) + var snapshots int + if err := db.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_snapshots WHERE event_count = 2`).Scan(&snapshots); err != nil { + t.Fatalf("count snapshots: %v", err) } - if status != "blocked_conflict" { - t.Fatalf("publish_state.status=%q, want blocked_conflict (untouched)", status) + if snapshots != 1 { + t.Fatalf("whole-pair recovery snapshots=%d want 1", snapshots) } } -// TestSelectPurgeStates_AllExpandsAndDedupes pins the flag-resolution -// helper directly so a future flag refactor doesn't quietly drop a -// state from the --all set or break alphabetical determinism (the SQL -// IN(...) generation depends on it). -func TestSelectPurgeStates_AllExpandsAndDedupes(t *testing.T) { - got := selectPurgeStates(false, false, false, true) - want := []string{state.EventStateBlockedConflict, state.EventStateFailed, state.EventStatePending} - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("--all → %v, want %v", got, want) - } - // Redundant flags + --all must not duplicate. - got = selectPurgeStates(true, true, true, true) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("redundant flags + --all → %v, want %v", got, want) +// seedPurgeFixtureRows keeps the compact read-only fixtures used by fix and +// diagnose tests. Apply tests use complete capture ops instead. +func seedPurgeFixtureRows(t *testing.T, db *state.DB) { + t.Helper() + ctx := context.Background() + for _, row := range []struct { + state string + path string + }{ + {state.EventStateBlockedConflict, "blocked.txt"}, + {state.EventStateFailed, "failed.txt"}, + {state.EventStatePending, "pending-a.txt"}, + {state.EventStatePending, "pending-b.txt"}, + } { + if _, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, + BaseHead: "fixture-head", Operation: "modify", Path: row.path, + Fidelity: "exact", State: row.state, + }, nil); err != nil { + t.Fatalf("AppendCaptureEvent(%s): %v", row.path, err) + } } } diff --git a/internal/cli/recover.go b/internal/cli/recover.go index f87a08d1..cce4e3e3 100644 --- a/internal/cli/recover.go +++ b/internal/cli/recover.go @@ -3,57 +3,31 @@ package cli import ( "context" "database/sql" - "encoding/json" "errors" "fmt" "io" - "log" "os" "path/filepath" - "strconv" + "strings" "time" "github.com/spf13/cobra" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/central" - "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" - "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/identity" - pausepkg "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/pause" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) -type recoverPlan struct { - Repo string `json:"repo"` - StateDB string `json:"state_db"` - GitDir string `json:"git_dir,omitempty"` - CurrentBranchRef string `json:"current_branch_ref"` - CurrentHead string `json:"current_head"` - Generation int64 `json:"generation"` - DryRun bool `json:"dry_run"` - BackupPath string `json:"backup_path,omitempty"` - Actions []string `json:"actions"` - RowsChanged int64 `json:"rows_changed"` - ClearPause bool `json:"clear_pause,omitempty"` - ManualMarkerRemoved bool `json:"manual_marker_removed,omitempty"` - ManualMarkerPreserved bool `json:"manual_marker_preserved,omitempty"` - ManualMarkerPath string `json:"manual_marker_path,omitempty"` - ManualMarkerRemoveError string `json:"manual_marker_remove_error,omitempty"` - LiveIndexCandidates int `json:"live_index_candidates,omitempty"` - LiveIndexApplied int `json:"live_index_applied,omitempty"` - LiveIndexSkipped int `json:"live_index_skipped,omitempty"` -} - func newRecoverCmd() *cobra.Command { cmd := &cobra.Command{ Use: "recover", - Short: "[DEPRECATED] Retarget stale replay state (use `acd fix [--clear-pause]`)", + Short: "[DEPRECATED] Run safe recovery (use `acd fix [--clear-pause]`)", Hidden: true, - Long: `DEPRECATED: ` + "`acd recover`" + ` is now a thin alias for ` + "`acd fix`" + `. + Long: `DEPRECATED: ` + "`acd recover`" + ` delegates to ` + "`acd fix`" + `. -` + "`acd fix`" + ` is the single recovery entrypoint; it plans -retarget_stale_anchor automatically and applies it with --yes. -Use ` + "`acd fix --clear-pause`" + ` to also remove a manual pause marker.`, +It never retargets captured rows across refs or generations. Each exact +unpublished pair is proven against stable HEAD or protected at a hidden +recovery ref before queue state changes.`, Example: ` acd fix --dry-run acd fix --yes acd fix --yes --clear-pause`, @@ -69,8 +43,8 @@ Use ` + "`acd fix --clear-pause`" + ` to also remove a manual pause marker.`, }, } cmd.Flags().Bool("auto", false, "(deprecated) Plan recovery automatically from current HEAD") - cmd.Flags().Bool("dry-run", false, "(deprecated) Show planned recovery without mutating state") - cmd.Flags().Bool("yes", false, "(deprecated) Apply recovery without an interactive prompt") + cmd.Flags().Bool("dry-run", false, "(deprecated) Show the safe fix plan without mutating state") + cmd.Flags().Bool("yes", false, "(deprecated) Apply safe recovery without an interactive prompt") cmd.Flags().Bool("clear-pause", false, "(deprecated) Also remove the manual pause marker") return cmd } @@ -85,24 +59,7 @@ func runRecover(ctx context.Context, out io.Writer, repo string, auto, dryRun, y if !dryRun && !yes { return fmt.Errorf("acd recover: refusing to mutate state without --yes") } - - rec, err := recoverRepoRecord(repo) - if err != nil { - return err - } - plan, err := buildRecoverPlan(ctx, rec, dryRun, clearPause) - if err != nil { - return err - } - if dryRun { - plan.DryRun = true - return renderRecover(out, plan, jsonOut) - } - - if err := applyRecoverPlan(ctx, rec.StateDB, &plan); err != nil { - return err - } - return renderRecover(out, plan, jsonOut) + return runFix(ctx, out, repo, dryRun, yes, false, clearPause, jsonOut) } func recoverRepoRecord(repo string) (central.RepoRecord, error) { @@ -116,97 +73,11 @@ func recoverRepoRecord(repo string) (central.RepoRecord, error) { return rec, nil } -func buildRecoverPlan(ctx context.Context, rec central.RepoRecord, dryRun, clearPause bool) (recoverPlan, error) { - branchRef, err := git.RunBranchRef(ctx, rec.Path) - if err != nil { - return recoverPlan{}, fmt.Errorf("acd recover: resolve HEAD branch: %w", err) - } - if branchRef == "" { - return recoverPlan{}, fmt.Errorf("acd recover: detached HEAD is not recoverable; checkout a branch first") - } - head, err := git.RevParse(ctx, rec.Path, "HEAD") - if err != nil { - return recoverPlan{}, fmt.Errorf("acd recover: resolve HEAD: %w", err) - } - gitDir, err := resolveGitDir(ctx, rec.Path) - if err != nil { - return recoverPlan{}, fmt.Errorf("acd recover: resolve git dir: %w", err) - } - markerPath := pausepkg.Path(gitDir) - - conn, err := openStateDBReadOnly(ctx, rec.StateDB) - if err != nil { - return recoverPlan{}, fmt.Errorf("acd recover: open state.db read-only: %w", err) - } - defer conn.Close() - - if err := refuseRecoverWhenDaemonAliveSQL(ctx, conn); err != nil { - return recoverPlan{}, err - } - gen := int64(1) - if raw, ok, err := metaLookup(ctx, conn, "branch.generation"); err != nil { - return recoverPlan{}, fmt.Errorf("acd recover: load branch generation: %w", err) - } else if ok { - if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 { - gen = parsed - } - } - var liveIndexPlan daemon.LiveIndexRepairSummary - if !dryRun { - liveIndexPlan, err = planLiveIndexRepair(ctx, rec.Path, rec.StateDB, head) - if err != nil { - return recoverPlan{}, err - } - } - - markerAction := "preserve manual pause marker at " + markerPath + " (use --clear-pause to remove)" - if clearPause { - markerAction = "remove manual pause marker at " + markerPath + " if present" - } - plan := recoverPlan{ - Repo: rec.Path, - StateDB: rec.StateDB, - GitDir: gitDir, - CurrentBranchRef: branchRef, - CurrentHead: head, - Generation: gen, - DryRun: dryRun, - ClearPause: clearPause, - ManualMarkerPath: markerPath, - LiveIndexCandidates: liveIndexPlan.Candidates, - LiveIndexSkipped: len(liveIndexPlan.Skipped), - Actions: []string{ - "retarget capture_events to current branch/generation/head", - "retarget shadow_paths to current branch/generation/head", - "retarget publish_state to current branch/generation/head", - "reset blocked_conflict rows to pending", - "clear stale replay/pause daemon_meta breadcrumbs", - "clear daemon_meta " + daemon.MetaKeyReplayPausedUntil + " (rewind grace)", - "repair ACD-published live-index entries when HEAD and worktree still match captured after-state", - markerAction, - }, - } - return plan, nil -} - -func planLiveIndexRepair(ctx context.Context, repo, stateDB, head string) (daemon.LiveIndexRepairSummary, error) { - db, err := state.Open(ctx, stateDB) - if err != nil { - return daemon.LiveIndexRepairSummary{}, fmt.Errorf("acd recover: open state.db for live-index repair plan: %w", err) - } - defer func() { _ = db.Close() }() - plan, err := daemon.PlanPublishedLiveIndexRepair(ctx, repo, db, head, daemon.DefaultLiveIndexRepairLimit) - if err != nil { - return daemon.LiveIndexRepairSummary{}, fmt.Errorf("acd recover: plan live-index repair: %w", err) - } - return plan, nil -} - func refuseRecoverWhenDaemonAliveSQL(ctx context.Context, conn *sql.DB) error { var pid int var mode string err := conn.QueryRowContext(ctx, `SELECT pid, mode FROM daemon_state WHERE id = 1`).Scan(&pid, &mode) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { @@ -239,176 +110,6 @@ func refuseRecoverWhenDaemonPIDAlive(ctx context.Context, pid int, mode string) return nil } -func applyRecoverPlan(ctx context.Context, stateDB string, plan *recoverPlan) error { - backup, err := backupStateDB(stateDB) - if err != nil { - return fmt.Errorf("acd recover: backup state.db: %w", err) - } - plan.BackupPath = backup - - // Preflight FS probes BEFORE opening a write transaction. FS syscalls - // (os.Lstat, parent-dir writability probe) can stall on network mounts; - // performing them inside an open tx would hold the write lock during the - // stall. If the marker is non-removable we abort here so the DB stays - // untouched. Without --clear-pause the marker is always preserved and we - // skip the removability check entirely. - markerExists := false - if plan.ManualMarkerPath != "" { - if info, err := os.Lstat(plan.ManualMarkerPath); err == nil { - markerExists = true - if plan.ClearPause { - if !info.Mode().IsRegular() { - return fmt.Errorf("acd recover: manual pause marker %s is not a regular file", plan.ManualMarkerPath) - } - if err := checkParentDirWritable(plan.ManualMarkerPath); err != nil { - return fmt.Errorf("acd recover: manual pause marker parent not writable: %w", err) - } - } - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("acd recover: stat manual pause marker %s: %w", plan.ManualMarkerPath, err) - } - } - - db, err := state.Open(ctx, stateDB) - if err != nil { - return fmt.Errorf("acd recover: open state.db: %w", err) - } - defer func() { _ = db.Close() }() - - if err := refuseRecoverWhenDaemonAlive(ctx, db); err != nil { - return err - } - - tx, err := db.SQL().BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("acd recover: begin transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - exec := func(q string, args ...any) error { - res, err := tx.ExecContext(ctx, q, args...) - if err != nil { - return err - } - n, err := res.RowsAffected() - if err == nil { - plan.RowsChanged += n - } - return nil - } - - if err := exec(`UPDATE capture_events -SET branch_ref = ?, branch_generation = ?, base_head = ? -WHERE state IN (?, ?)`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead, - state.EventStatePending, state.EventStateBlockedConflict); err != nil { - return fmt.Errorf("acd recover: retarget capture_events: %w", err) - } - if err := exec(`UPDATE capture_events -SET state = ?, published_ts = NULL, error = NULL -WHERE state = ?`, - state.EventStatePending, state.EventStateBlockedConflict); err != nil { - return fmt.Errorf("acd recover: reset blocked events: %w", err) - } - if err := exec(`DELETE FROM shadow_paths -WHERE rowid NOT IN ( - SELECT keep_rowid FROM ( - SELECT MAX(rowid) AS keep_rowid - FROM shadow_paths - GROUP BY path - ) -)`); err != nil { - return fmt.Errorf("acd recover: dedupe shadow_paths: %w", err) - } - if err := exec(`UPDATE shadow_paths -SET branch_ref = ?, branch_generation = ?, base_head = ?`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead); err != nil { - return fmt.Errorf("acd recover: retarget shadow_paths: %w", err) - } - if err := exec(`UPDATE publish_state -SET branch_ref = ?, branch_generation = ?, source_head = ?, status = 'idle', error = NULL`, - plan.CurrentBranchRef, plan.Generation, plan.CurrentHead); err != nil { - return fmt.Errorf("acd recover: retarget publish_state: %w", err) - } - if err := exec(`UPDATE daemon_state -SET branch_ref = ?, branch_generation = ?, mode = 'stopped', note = NULL`, - plan.CurrentBranchRef, plan.Generation); err != nil { - return fmt.Errorf("acd recover: retarget daemon_state: %w", err) - } - for _, key := range []string{ - "last_replay_conflict", - "last_replay_conflict_legacy", - "last_replay_error", - "detached_head_paused", - "operation_in_progress", - daemon.MetaKeyReplayPausedUntil, - } { - if err := exec(`DELETE FROM daemon_meta WHERE key = ?`, key); err != nil { - return fmt.Errorf("acd recover: clear %s: %w", key, err) - } - } - if err := exec(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch_token', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - "rev:"+plan.CurrentHead+" "+plan.CurrentBranchRef, float64(time.Now().UnixNano())/1e9); err != nil { - return fmt.Errorf("acd recover: set branch token: %w", err) - } - if err := exec(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch.generation', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - fmt.Sprintf("%d", plan.Generation), float64(time.Now().UnixNano())/1e9); err != nil { - return fmt.Errorf("acd recover: set branch generation: %w", err) - } - if err := exec(`INSERT INTO daemon_meta(key, value, updated_ts) VALUES('branch.head', ?, ?) -ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_ts = excluded.updated_ts`, - plan.CurrentHead, float64(time.Now().UnixNano())/1e9); err != nil { - return fmt.Errorf("acd recover: set branch head: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("acd recover: commit transaction: %w", err) - } - - if repaired, err := daemon.RepairPublishedLiveIndex(ctx, plan.Repo, db, plan.CurrentHead, daemon.DefaultLiveIndexRepairLimit); err != nil { - return fmt.Errorf("acd recover: repair live index: %w", err) - } else { - plan.LiveIndexCandidates = repaired.Candidates - plan.LiveIndexApplied = repaired.Applied - plan.LiveIndexSkipped = len(repaired.Skipped) - } - - // Post-commit: handle the durable manual pause marker. The marker is owned - // by `acd pause` / `acd resume` and is not stored in state.db. Without - // --clear-pause we always preserve it. With --clear-pause, attempt removal; - // if the post-commit os.Remove fails (race), demote to a warning rather - // than aborting — the DB is already retargeted and rendering must run. - if plan.ManualMarkerPath != "" { - switch { - case !plan.ClearPause: - if markerExists { - plan.ManualMarkerPreserved = true - log.Printf("acd recover: preserved manual pause marker at %s (use --clear-pause to remove)", plan.ManualMarkerPath) - } - default: - if err := os.Remove(plan.ManualMarkerPath); err != nil { - if errors.Is(err, os.ErrNotExist) { - log.Printf("acd recover: no manual pause marker present at %s", plan.ManualMarkerPath) - } else { - plan.ManualMarkerRemoveError = err.Error() - log.Printf("acd recover: WARNING: failed to remove manual pause marker %s after commit: %v", plan.ManualMarkerPath, err) - } - } else { - plan.ManualMarkerRemoved = true - log.Printf("acd recover: removed manual pause marker at %s", plan.ManualMarkerPath) - } - } - } - return nil -} - -// checkParentDirWritable verifies the parent directory of path is writable by -// the current process. Used as a preflight BEFORE db.SQL().BeginTx so a -// known-bad removability state aborts cleanly without ever opening the SQLite -// write transaction. Slow FS syscalls on network mounts must not stall the -// write lock. func checkParentDirWritable(path string) error { dir := filepath.Dir(path) info, err := os.Stat(dir) @@ -418,68 +119,52 @@ func checkParentDirWritable(path string) error { if !info.IsDir() { return fmt.Errorf("%s is not a directory", dir) } - probe, err := os.CreateTemp(dir, ".acd-recover-probe-*") + tmp, err := os.CreateTemp(dir, ".acd-write-check-*") if err != nil { return err } - probePath := probe.Name() - _ = probe.Close() - _ = os.Remove(probePath) - return nil + name := tmp.Name() + if err := tmp.Close(); err != nil { + _ = os.Remove(name) + return err + } + return os.Remove(name) } -func backupStateDB(stateDB string) (string, error) { - src, err := os.ReadFile(stateDB) - if err != nil { - return "", err +func backupStateDB(ctx context.Context, conn *sql.DB, stateDB string) (string, error) { + if conn == nil { + return "", errors.New("nil SQLite connection") + } + stamp := time.Now().UTC().Format("20060102T150405.000000000Z") + backup := stateDB + ".bak-" + stamp + if _, err := conn.ExecContext(ctx, `VACUUM INTO ?`, backup); err != nil { + return "", fmt.Errorf("VACUUM INTO: %w", err) } - backup := filepath.Join(filepath.Dir(stateDB), - fmt.Sprintf("state.db.recover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))) - if err := os.WriteFile(backup, src, 0o600); err != nil { + removeOnError := func(err error) (string, error) { + _ = os.Remove(backup) return "", err } - return backup, nil -} - -func renderRecover(out io.Writer, plan recoverPlan, jsonOut bool) error { - if jsonOut { - enc := json.NewEncoder(out) - enc.SetIndent("", " ") - return enc.Encode(plan) + if err := os.Chmod(backup, 0o600); err != nil { + return removeOnError(fmt.Errorf("chmod backup: %w", err)) } - mode := "planned" - if !plan.DryRun { - mode = "applied" + verify, err := openStateDBReadOnly(ctx, backup) + if err != nil { + return removeOnError(fmt.Errorf("open backup for verification: %w", err)) } - fmt.Fprintf(out, "Recovery %s for %s\n", mode, plan.Repo) - fmt.Fprintf(out, "Anchor: %s @ %s generation=%d\n", plan.CurrentBranchRef, plan.CurrentHead, plan.Generation) - if plan.BackupPath != "" { - fmt.Fprintf(out, "Backup: %s\n", plan.BackupPath) + var integrity string + checkErr := verify.QueryRowContext(ctx, `PRAGMA quick_check`).Scan(&integrity) + closeErr := verify.Close() + if checkErr != nil { + return removeOnError(fmt.Errorf("verify backup integrity: %w", checkErr)) } - if plan.LiveIndexCandidates > 0 || plan.LiveIndexSkipped > 0 { - fmt.Fprintf(out, "Live index repair plan: candidates=%d skipped=%d\n", - plan.LiveIndexCandidates, plan.LiveIndexSkipped) + if closeErr != nil { + return removeOnError(fmt.Errorf("close verified backup: %w", closeErr)) } - for _, action := range plan.Actions { - fmt.Fprintf(out, "- %s\n", action) + if !strings.EqualFold(strings.TrimSpace(integrity), "ok") { + return removeOnError(fmt.Errorf("verify backup integrity: %s", integrity)) } - if !plan.DryRun { - fmt.Fprintf(out, "Rows changed: %d\n", plan.RowsChanged) - switch { - case plan.ManualMarkerRemoved: - fmt.Fprintf(out, "Manual pause marker removed: %s\n", plan.ManualMarkerPath) - case plan.ManualMarkerPreserved: - fmt.Fprintf(out, "Manual pause marker: %s preserved (use --clear-pause to remove)\n", plan.ManualMarkerPath) - case plan.ManualMarkerPath != "": - fmt.Fprintf(out, "Manual pause marker: not present at %s\n", plan.ManualMarkerPath) - } - if plan.ManualMarkerRemoveError != "" { - fmt.Fprintf(out, "WARNING: manual pause marker remove failed after commit: %s\n", plan.ManualMarkerRemoveError) - } - if plan.LiveIndexCandidates > 0 || plan.LiveIndexSkipped > 0 { - fmt.Fprintf(out, "Live index repair: candidates=%d applied=%d skipped=%d\n", - plan.LiveIndexCandidates, plan.LiveIndexApplied, plan.LiveIndexSkipped) - } + if _, err := os.Stat(backup); err != nil { + return removeOnError(fmt.Errorf("stat verified backup: %w", err)) } - return nil + return backup, nil } diff --git a/internal/cli/recover_test.go b/internal/cli/recover_test.go index bbf021e3..3aa4f860 100644 --- a/internal/cli/recover_test.go +++ b/internal/cli/recover_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" pausepkg "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/pause" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" @@ -20,600 +19,161 @@ import ( func makeRegisteredGitRepoStateDB(t *testing.T) (repoDir, stateDB string, db *state.DB) { t.Helper() roots := withIsolatedHome(t) - repoDir, stateDB, db = makeRepoStateDB(t) - if err := git.Init(context.Background(), repoDir); err != nil { - t.Fatalf("git init: %v", err) - } - if _, err := git.Run(context.Background(), git.RunOpts{Dir: repoDir}, "symbolic-ref", "HEAD", "refs/heads/main"); err != nil { - t.Fatalf("symbolic-ref: %v", err) - } - if err := os.WriteFile(filepath.Join(repoDir, "seed.txt"), []byte("seed\n"), 0o644); err != nil { - t.Fatalf("write seed: %v", err) - } - if _, err := git.Run(context.Background(), git.RunOpts{Dir: repoDir}, "add", "seed.txt"); err != nil { - t.Fatalf("git add: %v", err) - } - if _, err := git.Run(context.Background(), git.RunOpts{Dir: repoDir}, "-c", "user.name=ACD Test", "-c", "user.email=acd@example.invalid", "commit", "-m", "seed"); err != nil { - t.Fatalf("git commit: %v", err) - } + repoDir, stateDB, db = makeSeededRepoStateDB(t) registerRepo(t, roots, repoDir, stateDB, "test") return repoDir, stateDB, db } -func TestRecover_DryRunNoMutation(t *testing.T) { - repo, stateDB, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - before, err := fileSHA256(stateDB) - if err != nil { - t.Fatalf("checksum before: %v", err) - } - if err := state.MetaSet(ctx, db, "last_replay_conflict", `{"seq":1,"error_class":"cas_fail"}`); err != nil { - t.Fatalf("MetaSet: %v", err) - } - fixtureChecksum, err := fileSHA256(stateDB) - if err != nil { - t.Fatalf("checksum fixture: %v", err) - } - +func TestRecover_RequiresLegacySafetyFlags(t *testing.T) { + repo, _, _ := makeRegisteredGitRepoStateDB(t) var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, true, false, true, false); err != nil { - t.Fatalf("runRecover dry-run: %v", err) - } - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal recover plan: %v\n%s", err, out.String()) - } - if !plan.DryRun || plan.CurrentBranchRef != "refs/heads/main" { - t.Fatalf("plan=%+v", plan) - } - after, err := fileSHA256(stateDB) - if err != nil { - t.Fatalf("checksum after: %v", err) + if err := runRecover(context.Background(), &out, repo, false, false, false, true, false); err == nil || !strings.Contains(err.Error(), "pass --auto") { + t.Fatalf("runRecover err=%v want --auto refusal", err) } - if fixtureChecksum != after { - t.Fatalf("dry-run mutated state.db: before=%s after=%s", fixtureChecksum, after) + if err := runRecover(context.Background(), &out, repo, true, false, false, true, false); err == nil || !strings.Contains(err.Error(), "without --yes") { + t.Fatalf("runRecover err=%v want --yes refusal", err) } - _ = before } -func TestRecover_DryRunDoesNotBootstrapSchema(t *testing.T) { +func TestRecover_DryRunDelegatesToReadOnlyFixPlan(t *testing.T) { repo, stateDB, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - if _, err := db.SQL().ExecContext(ctx, `PRAGMA user_version = 1`); err != nil { - t.Fatalf("lower user_version: %v", err) - } + stageRecoverableBarrierPair(t, context.Background(), repo, db, "refs/heads/main", 1) before, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum before: %v", err) } var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, true, false, true, false); err != nil { - t.Fatalf("runRecover dry-run: %v", err) + if err := runRecover(context.Background(), &out, repo, true, true, false, true, false); err != nil { + t.Fatalf("runRecover dry-run: %v\n%s", err, out.String()) + } + var plan fixPlan + if err := json.Unmarshal(out.Bytes(), &plan); err != nil { + t.Fatalf("unmarshal delegated fix plan: %v\n%s", err, out.String()) + } + if !plan.DryRun || findFixAction(plan, fixActionReconcileUnpublishedChain) == nil { + t.Fatalf("recover did not delegate to safe fix plan: %+v", plan) } after, err := fileSHA256(stateDB) if err != nil { t.Fatalf("checksum after: %v", err) } if before != after { - t.Fatalf("dry-run bootstrapped schema: before=%s after=%s", before, after) - } - var version int - if err := db.SQL().QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil { - t.Fatalf("query user_version: %v", err) - } - if version != 1 { - t.Fatalf("user_version=%d want 1", version) + t.Fatalf("recover dry-run mutated state.db: before=%s after=%s", before, after) } } -func TestRecover_AppliesBackupAndRetargetsIncident(t *testing.T) { - repo, stateDB, db := makeRegisteredGitRepoStateDB(t) +func TestRecover_ApplyPreservesStalePairInsteadOfRetargeting(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse: %v", err) - } - if err := state.SaveDaemonState(ctx, db, state.DaemonState{ - PID: 999999, - Mode: "stopped", - BranchRef: sql.NullString{String: "refs/heads/stale", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 2, Valid: true}, - }); err != nil { - t.Fatalf("SaveDaemonState: %v", err) - } - if err := state.MetaSet(ctx, db, "branch.generation", "2"); err != nil { - t.Fatalf("MetaSet generation: %v", err) - } - if err := state.MetaSet(ctx, db, "last_replay_conflict", `{"seq":1,"error_class":"cas_fail"}`); err != nil { - t.Fatalf("MetaSet conflict: %v", err) - } - for _, sp := range []state.ShadowPath{ - { - BranchRef: "refs/heads/stale", BranchGeneration: 2, - Path: "dup.txt", Operation: "modify", BaseHead: head, Fidelity: "exact", - Mode: sql.NullString{String: git.RegularFileMode, Valid: true}, - OID: sql.NullString{String: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Valid: true}, - }, - { - BranchRef: "refs/heads/main", BranchGeneration: 1, - Path: "dup.txt", Operation: "modify", BaseHead: head, Fidelity: "exact", - Mode: sql.NullString{String: git.RegularFileMode, Valid: true}, - OID: sql.NullString{String: "cccccccccccccccccccccccccccccccccccccccc", Valid: true}, - }, - } { - if err := state.UpsertShadowPath(ctx, db, sp); err != nil { - t.Fatalf("UpsertShadowPath: %v", err) - } - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/stale", - BranchGeneration: 2, - BaseHead: head, - Operation: "create", - Path: "blocked.txt", - Fidelity: "full", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "old conflict", Valid: true}, - }, []state.CaptureOp{{ - Op: "create", - Path: "blocked.txt", - Fidelity: "full", - AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, - }}) - if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) - } - backupBefore, err := os.ReadFile(stateDB) - if err != nil { - t.Fatalf("read state db before: %v", err) - } + first, _ := stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/stale", 9) var out bytes.Buffer if err := runRecover(ctx, &out, repo, true, false, true, true, false); err != nil { t.Fatalf("runRecover apply: %v\n%s", err, out.String()) } - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal result: %v\n%s", err, out.String()) - } - if plan.BackupPath == "" { - t.Fatalf("backup path empty") - } - gotBackup, err := os.ReadFile(plan.BackupPath) - if err != nil { - t.Fatalf("read backup: %v", err) - } - if !bytes.Equal(gotBackup, backupBefore) { - t.Fatalf("backup does not match pre-apply state.db") - } - var branchRef, eventState string - var gen int64 - var errMsg sql.NullString + var generation int64 if err := db.SQL().QueryRowContext(ctx, - `SELECT branch_ref, branch_generation, state, error FROM capture_events WHERE seq = ?`, seq, - ).Scan(&branchRef, &gen, &eventState, &errMsg); err != nil { + `SELECT branch_ref, branch_generation, state FROM capture_events WHERE seq = ?`, first, + ).Scan(&branchRef, &generation, &eventState); err != nil { t.Fatalf("query event: %v", err) } - if branchRef != "refs/heads/main" || gen != 2 || eventState != state.EventStatePending || errMsg.Valid { - t.Fatalf("event after recover branch=%q gen=%d state=%q err=%v", branchRef, gen, eventState, errMsg) - } - if _, ok, err := state.MetaGet(ctx, db, "last_replay_conflict"); err != nil { - t.Fatalf("MetaGet conflict: %v", err) - } else if ok { - t.Fatalf("last_replay_conflict was not cleared") - } - if tok, _, _ := state.MetaGet(ctx, db, "branch_token"); !strings.Contains(tok, "refs/heads/main") { - t.Fatalf("branch_token=%q want current branch", tok) - } - var shadowRows int - if err := db.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM shadow_paths WHERE branch_ref = 'refs/heads/main' AND branch_generation = 2 AND path = 'dup.txt'`, - ).Scan(&shadowRows); err != nil { - t.Fatalf("count retargeted shadow rows: %v", err) - } - if shadowRows != 1 { - t.Fatalf("retargeted shadow rows=%d want 1", shadowRows) - } -} - -func TestRecover_ClearsReplayPausedUntil(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - until := time.Now().UTC().Add(15 * time.Minute).Format(time.RFC3339) - if err := state.MetaSet(ctx, db, daemon.MetaKeyReplayPausedUntil, until); err != nil { - t.Fatalf("MetaSet replay.paused_until: %v", err) - } - - var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, false, true, false, false); err != nil { - t.Fatalf("runRecover apply: %v", err) - } - - if v, ok, err := state.MetaGet(ctx, db, daemon.MetaKeyReplayPausedUntil); err != nil { - t.Fatalf("MetaGet: %v", err) - } else if ok { - t.Fatalf("replay.paused_until still set: %q", v) + if branchRef != "refs/heads/stale" || generation != 9 || eventState != state.EventStateRecovered { + t.Fatalf("recover rewrote provenance: %s/g%d state=%s", branchRef, generation, eventState) } } -func TestRecover_RemovesManualPauseMarker(t *testing.T) { +func TestRecover_ClearPauseDelegatesExplicitRemoval(t *testing.T) { repo, _, _ := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - gitDir := filepath.Join(repo, ".git") - markerPath := pausepkg.Path(gitDir) - expiresAt := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) + markerPath := filepath.Join(repo, ".git", "acd", "paused") if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ - Reason: "manual", - SetAt: time.Now().UTC().Format(time.RFC3339), - SetBy: "test", - ExpiresAt: &expiresAt, + Reason: "maintenance", SetAt: time.Now().UTC().Format(time.RFC3339), SetBy: "test", }, true); err != nil { - t.Fatalf("pausepkg.Write: %v", err) + t.Fatalf("write marker: %v", err) } var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, false, true, true, true); err != nil { - t.Fatalf("runRecover apply: %v", err) + if err := runRecover(context.Background(), &out, repo, true, false, true, true, true); err != nil { + t.Fatalf("runRecover --clear-pause: %v\n%s", err, out.String()) } if _, err := os.Stat(markerPath); !os.IsNotExist(err) { - t.Fatalf("manual pause marker still on disk: stat err=%v", err) - } - - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal recover plan: %v\n%s", err, out.String()) - } - if !plan.ManualMarkerRemoved { - t.Fatalf("plan.ManualMarkerRemoved=false want true: %+v", plan) - } - if !strings.HasSuffix(plan.ManualMarkerPath, filepath.Join(".git", "acd", "paused")) { - t.Fatalf("plan.ManualMarkerPath=%q want suffix .git/acd/paused", plan.ManualMarkerPath) - } -} - -// TestRecover_DryRunPreservesMarkerOnDisk pins that `acd recover --dry-run` -// is purely advisory: even when a real manual pause marker is present and the -// caller passes --clear-pause, the dry run must NOT touch the marker file. -// This couples with TestRecover_DryRunNoMutation (state.db invariant) and -// closes the corresponding gap on the on-disk marker side. -func TestRecover_DryRunPreservesMarkerOnDisk(t *testing.T) { - repo, _, _ := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - gitDir := filepath.Join(repo, ".git") - markerPath := pausepkg.Path(gitDir) - expiresAt := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) - if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ - Reason: "deploy freeze", - SetAt: time.Now().UTC().Format(time.RFC3339), - SetBy: "operator", - ExpiresAt: &expiresAt, - }, true); err != nil { - t.Fatalf("pausepkg.Write: %v", err) - } - markerBefore, err := os.ReadFile(markerPath) - if err != nil { - t.Fatalf("read marker: %v", err) - } - infoBefore, err := os.Stat(markerPath) - if err != nil { - t.Fatalf("stat marker before: %v", err) - } - - // dry-run with clearPause=true. Even with the flag set, dry-run must - // remain non-mutating: the on-disk marker stays exactly as-is. - var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, true, false, true, true); err != nil { - t.Fatalf("runRecover dry-run: %v", err) - } - - infoAfter, err := os.Stat(markerPath) - if err != nil { - t.Fatalf("stat marker after dry-run (must still exist): %v", err) - } - if !os.SameFile(infoBefore, infoAfter) { - t.Fatalf("dry-run replaced marker inode: before=%v after=%v", infoBefore, infoAfter) - } - markerAfter, err := os.ReadFile(markerPath) - if err != nil { - t.Fatalf("read marker after: %v", err) - } - if !bytes.Equal(markerBefore, markerAfter) { - t.Fatalf("dry-run mutated marker bytes: before=%q after=%q", markerBefore, markerAfter) - } - - // Plan must report DryRun=true and must NOT claim the marker was removed. - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal recover plan: %v\n%s", err, out.String()) - } - if !plan.DryRun { - t.Fatalf("plan.DryRun=false in --dry-run output: %+v", plan) - } - if plan.ManualMarkerRemoved { - t.Fatalf("plan.ManualMarkerRemoved=true on dry-run: %+v", plan) - } -} - -func TestRecover_DryRun_ListsPauseStateActions(t *testing.T) { - repo, _, _ := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, true, false, true, true); err != nil { - t.Fatalf("runRecover dry-run: %v", err) - } - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal: %v\n%s", err, out.String()) - } - wantSubstrs := []string{ - "clear daemon_meta " + daemon.MetaKeyReplayPausedUntil, - "remove manual pause marker", - } - for _, want := range wantSubstrs { - found := false - for _, action := range plan.Actions { - if strings.Contains(action, want) { - found = true - break - } - } - if !found { - t.Fatalf("dry-run plan.Actions missing %q: %v", want, plan.Actions) - } - } - if plan.ManualMarkerPath == "" { - t.Fatalf("plan.ManualMarkerPath empty in dry-run output") - } - if plan.GitDir == "" { - t.Fatalf("plan.GitDir empty in dry-run output") + t.Fatalf("pause marker remains: %v", err) } } func TestRecover_RefusesWithDaemonAlive(t *testing.T) { repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - if err := state.SaveDaemonState(ctx, db, state.DaemonState{ - PID: os.Getpid(), - Mode: "running", + if err := state.SaveDaemonState(context.Background(), db, state.DaemonState{ + PID: os.Getpid(), Mode: "running", + BranchRef: sql.NullString{String: "refs/heads/main", Valid: true}, + BranchGeneration: sql.NullInt64{Int64: 1, Valid: true}, }); err != nil { t.Fatalf("SaveDaemonState: %v", err) } + var out bytes.Buffer - err := runRecover(ctx, &out, repo, true, false, true, false, false) - if err == nil || !strings.Contains(err.Error(), "refusing while daemon") { - t.Fatalf("runRecover err=%v want daemon refusal", err) + err := runRecover(context.Background(), &out, repo, true, false, true, true, false) + if err == nil || !strings.Contains(err.Error(), "unsafe conditions") { + t.Fatalf("runRecover err=%v want live-daemon refusal", err) } } -func TestRecover_PreservesMarkerWithoutClearPauseFlag(t *testing.T) { - repo, _, db := makeRegisteredGitRepoStateDB(t) - ctx := context.Background() - - // Stage a stale-branch incident so we can assert the DB was retargeted. - head, err := git.RevParse(ctx, repo, "HEAD") - if err != nil { - t.Fatalf("rev-parse: %v", err) - } - if err := state.SaveDaemonState(ctx, db, state.DaemonState{ - PID: 999999, - Mode: "stopped", - BranchRef: sql.NullString{String: "refs/heads/stale", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 2, Valid: true}, - }); err != nil { - t.Fatalf("SaveDaemonState: %v", err) - } - if err := state.MetaSet(ctx, db, "branch.generation", "2"); err != nil { - t.Fatalf("MetaSet generation: %v", err) - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/stale", - BranchGeneration: 2, - BaseHead: head, - Operation: "create", - Path: "blocked.txt", - Fidelity: "full", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "old conflict", Valid: true}, - }, []state.CaptureOp{{ - Op: "create", - Path: "blocked.txt", - Fidelity: "full", - AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, - }}) - if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) +func TestRecover_DryRunDoesNotBootstrapSchema(t *testing.T) { + repo, stateDB, db := makeRegisteredGitRepoStateDB(t) + if _, err := db.SQL().ExecContext(context.Background(), `DROP TABLE recovery_snapshots`); err != nil { + t.Fatalf("drop recovery_snapshots: %v", err) } - - // Write a manual pause marker that simulates a deploy-freeze. - gitDir := filepath.Join(repo, ".git") - markerPath := pausepkg.Path(gitDir) - expiresAt := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) - if _, err := pausepkg.Write(markerPath, pausepkg.Marker{ - Reason: "deploy freeze", - SetAt: time.Now().UTC().Format(time.RFC3339), - SetBy: "operator", - ExpiresAt: &expiresAt, - }, true); err != nil { - t.Fatalf("pausepkg.Write: %v", err) + if err := db.Close(); err != nil { + t.Fatalf("close db: %v", err) } - markerBefore, err := os.ReadFile(markerPath) + before, err := fileSHA256(stateDB) if err != nil { - t.Fatalf("read marker: %v", err) + t.Fatalf("checksum before: %v", err) } - // Apply recovery WITHOUT --clear-pause; default behavior must preserve the - // marker untouched while still retargeting the DB. var out bytes.Buffer - if err := runRecover(ctx, &out, repo, true, false, true, true, false); err != nil { - t.Fatalf("runRecover apply: %v\n%s", err, out.String()) + if err := runRecover(context.Background(), &out, repo, true, true, false, true, false); err != nil { + t.Fatalf("runRecover dry-run: %v", err) } - - // Marker must still be on disk and byte-identical to what we wrote. - markerAfter, err := os.ReadFile(markerPath) + after, err := fileSHA256(stateDB) if err != nil { - t.Fatalf("read marker after recover: %v", err) - } - if !bytes.Equal(markerBefore, markerAfter) { - t.Fatalf("manual pause marker mutated: before=%q after=%q", markerBefore, markerAfter) - } - - // Plan must reflect preservation, NOT removal. - var plan recoverPlan - if err := json.Unmarshal(out.Bytes(), &plan); err != nil { - t.Fatalf("unmarshal plan: %v\n%s", err, out.String()) - } - if plan.ManualMarkerRemoved { - t.Fatalf("plan.ManualMarkerRemoved=true want false: %+v", plan) - } - if !plan.ManualMarkerPreserved { - t.Fatalf("plan.ManualMarkerPreserved=false want true: %+v", plan) - } - if plan.ClearPause { - t.Fatalf("plan.ClearPause=true want false: %+v", plan) - } - - // At least one Actions entry must call out preservation explicitly. - foundPreserveAction := false - for _, action := range plan.Actions { - if strings.Contains(action, "preserve manual pause marker") && - strings.Contains(action, "--clear-pause") { - foundPreserveAction = true - break - } + t.Fatalf("checksum after: %v", err) } - if !foundPreserveAction { - t.Fatalf("plan.Actions missing preservation entry: %v", plan.Actions) + if before != after { + t.Fatalf("dry-run bootstrapped schema: before=%s after=%s", before, after) } - // DB must have been retargeted to the current branch despite skipping - // marker removal — this is the atomicity guarantee. - var branchRef, eventState string - var gen int64 - if err := db.SQL().QueryRowContext(ctx, - `SELECT branch_ref, branch_generation, state FROM capture_events WHERE seq = ?`, seq, - ).Scan(&branchRef, &gen, &eventState); err != nil { - t.Fatalf("query event: %v", err) + conn, err := openStateDBReadOnly(context.Background(), stateDB) + if err != nil { + t.Fatalf("open read-only: %v", err) } - if branchRef != "refs/heads/main" || gen != 2 || eventState != state.EventStatePending { - t.Fatalf("event after recover branch=%q gen=%d state=%q want main/2/pending", - branchRef, gen, eventState) + defer conn.Close() + if exists, err := sqliteTableExists(context.Background(), conn, "recovery_snapshots"); err != nil || exists { + t.Fatalf("recovery_snapshots exists=%v err=%v", exists, err) } } -// TestRecover_PreflightFailsBeforeOpeningTx pins that when --clear-pause is -// supplied but the manual pause marker fails its FS preflight (here: it is a -// directory rather than a regular file), applyRecoverPlan aborts BEFORE -// db.SQL().BeginTx is opened. The state.db file must remain byte-identical -// to its pre-call contents so we know no write transaction was started and -// then rolled back. This matches the P1 reliability requirement that slow -// or failing FS syscalls do not hold the SQLite write lock. -func TestRecover_PreflightFailsBeforeOpeningTx(t *testing.T) { - repo, stateDB, db := makeRegisteredGitRepoStateDB(t) +func TestRecover_DoesNotMutateHEADOrIndex(t *testing.T) { + repo, _, db := makeRegisteredGitRepoStateDB(t) ctx := context.Background() - - // Stage a stale-branch incident so applyRecoverPlan would otherwise have - // real work to do. If the preflight gating regresses, the UPDATE - // statements would mutate these rows and the checksum check below would - // catch it. - head, err := git.RevParse(ctx, repo, "HEAD") + stageRecoverableBarrierPair(t, ctx, repo, db, "refs/heads/main", 1) + headBefore, err := git.RevParse(ctx, repo, "HEAD") if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("HEAD before: %v", err) } - if err := state.SaveDaemonState(ctx, db, state.DaemonState{ - PID: 999999, - Mode: "stopped", - BranchRef: sql.NullString{String: "refs/heads/stale", Valid: true}, - BranchGeneration: sql.NullInt64{Int64: 2, Valid: true}, - }); err != nil { - t.Fatalf("SaveDaemonState: %v", err) - } - if err := state.MetaSet(ctx, db, "branch.generation", "2"); err != nil { - t.Fatalf("MetaSet generation: %v", err) - } - seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ - BranchRef: "refs/heads/stale", - BranchGeneration: 2, - BaseHead: head, - Operation: "create", - Path: "blocked.txt", - Fidelity: "full", - State: state.EventStateBlockedConflict, - Error: sql.NullString{String: "old conflict", Valid: true}, - }, []state.CaptureOp{{ - Op: "create", - Path: "blocked.txt", - Fidelity: "full", - AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, - }}) - if err != nil { - t.Fatalf("AppendCaptureEvent: %v", err) - } - - // Force the preflight to fail: create the marker path as a DIRECTORY, - // not a regular file. With --clear-pause the preflight insists on a - // regular file and must abort. - gitDir := filepath.Join(repo, ".git") - markerPath := pausepkg.Path(gitDir) - if err := os.MkdirAll(markerPath, 0o755); err != nil { - t.Fatalf("mkdir marker: %v", err) - } - - // Snapshot state.db contents AFTER the fixture is fully staged so we - // can prove no write tx ran during applyRecoverPlan. - if err := db.Close(); err != nil { - t.Fatalf("close state db before snapshot: %v", err) - } - dbBefore, err := os.ReadFile(stateDB) + indexBefore, err := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--name-status") if err != nil { - t.Fatalf("read state.db before: %v", err) + t.Fatalf("index before: %v", err) } var out bytes.Buffer - err = runRecover(ctx, &out, repo, true, false, true, true, true) - if err == nil { - t.Fatalf("runRecover unexpectedly succeeded; want preflight failure") - } - if !strings.Contains(err.Error(), "manual pause marker") { - t.Fatalf("err=%v want preflight failure mentioning manual pause marker", err) - } - - // state.db MUST be byte-identical to its pre-call contents — no UPDATE - // or INSERT may have run, even speculatively. This is the core - // invariant the P1 fix protects. - dbAfter, err := os.ReadFile(stateDB) - if err != nil { - t.Fatalf("read state.db after: %v", err) - } - if !bytes.Equal(dbBefore, dbAfter) { - t.Fatalf("preflight failure mutated state.db (write tx leaked); len before=%d after=%d", len(dbBefore), len(dbAfter)) - } - - // Re-open the DB and confirm the staged stale row was NOT retargeted. - reopened, err := state.Open(ctx, stateDB) - if err != nil { - t.Fatalf("reopen state.db: %v", err) - } - defer func() { _ = reopened.Close() }() - - var branchRef, eventState string - var gen int64 - if err := reopened.SQL().QueryRowContext(ctx, - `SELECT branch_ref, branch_generation, state FROM capture_events WHERE seq = ?`, seq, - ).Scan(&branchRef, &gen, &eventState); err != nil { - t.Fatalf("query event: %v", err) + if err := runRecover(ctx, &out, repo, true, false, true, true, false); err != nil { + t.Fatalf("runRecover: %v", err) } - if branchRef != "refs/heads/stale" || gen != 2 || eventState != state.EventStateBlockedConflict { - t.Fatalf("preflight failure leaked retarget: branch=%q gen=%d state=%q want stale/2/blocked_conflict", - branchRef, gen, eventState) + headAfter, _ := git.RevParse(ctx, repo, "HEAD") + indexAfter, _ := git.Run(ctx, git.RunOpts{Dir: repo}, "diff", "--cached", "--name-status") + if headAfter != headBefore || string(indexAfter) != string(indexBefore) { + t.Fatalf("recover mutated Git state: HEAD %s->%s index %q->%q", headBefore, headAfter, indexBefore, indexAfter) } } diff --git a/internal/cli/recovery_probe.go b/internal/cli/recovery_probe.go index 2a7d25f6..df77376f 100644 --- a/internal/cli/recovery_probe.go +++ b/internal/cli/recovery_probe.go @@ -280,6 +280,11 @@ type recoveryBlockerCounts struct { // blocked_conflict rows that have later pending rows on the same // (branch_ref, branch_generation). This is the force-fix barrier count. ActiveBlockedBarriersWithSuccessors int + // ActiveTerminalEvents is every blocked_conflict or failed row for the + // daemon's current (branch_ref, branch_generation), including a terminal + // row at the tail with no pending successor. Bare health uses this to avoid + // reporting a stuck active pair as healthy. + ActiveTerminalEvents int // FailedBarriersWithSuccessors is every failed terminal row that has later // pending rows on the same (branch_ref, branch_generation). FailedBarriersWithSuccessors int @@ -305,6 +310,17 @@ func loadRecoveryBlockerCounts(ctx context.Context, conn *sql.DB, activeBranchRe return c, fmt.Errorf("active blocked barriers with successors: %w", err) } c.ActiveBlockedBarriersWithSuccessors = n + if err := conn.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND state IN (?, ?)`, + activeBranchRef, activeGeneration, + state.EventStateBlockedConflict, state.EventStateFailed, + ).Scan(&c.ActiveTerminalEvents); err != nil { + return c, fmt.Errorf("active terminal events: %w", err) + } } failed, err := countTerminalBarriersWithSuccessors(ctx, conn, state.EventStateFailed, "", 0) if err != nil { diff --git a/internal/cli/recovery_probe_test.go b/internal/cli/recovery_probe_test.go index 457f4065..314c1d0c 100644 --- a/internal/cli/recovery_probe_test.go +++ b/internal/cli/recovery_probe_test.go @@ -67,6 +67,7 @@ func TestRecoveryBlockerCountsDistinguishActiveAndNonActiveRows(t *testing.T) { appendFailed("refs/heads/main", 2, "failed.go") appendPending("refs/heads/main", 2, "failed-hidden.go") appendPending("refs/heads/pending-only", 1, "visible.go") + appendFailed("refs/heads/main", 1, "active-tail-failed.go") counts, err := loadRecoveryBlockerCounts(ctx, d.SQL(), "refs/heads/main", 1) if err != nil { @@ -78,6 +79,9 @@ func TestRecoveryBlockerCountsDistinguishActiveAndNonActiveRows(t *testing.T) { if counts.ActiveBlockedBarriersWithSuccessors != 1 { t.Fatalf("ActiveBlockedBarriersWithSuccessors=%d want 1", counts.ActiveBlockedBarriersWithSuccessors) } + if counts.ActiveTerminalEvents != 2 { + t.Fatalf("ActiveTerminalEvents=%d want 2", counts.ActiveTerminalEvents) + } if counts.FailedBarriersWithSuccessors != 1 { t.Fatalf("FailedBarriersWithSuccessors=%d want 1", counts.FailedBarriersWithSuccessors) } @@ -102,7 +106,7 @@ func TestRecoveryBlockerCountsPendingOnlyRepo(t *testing.T) { if err != nil { t.Fatalf("load counts: %v", err) } - if counts.TotalBlockedConflicts != 0 || counts.ActiveBlockedBarriersWithSuccessors != 0 || counts.FailedBarriersWithSuccessors != 0 { + if counts.TotalBlockedConflicts != 0 || counts.ActiveBlockedBarriersWithSuccessors != 0 || counts.ActiveTerminalEvents != 0 || counts.FailedBarriersWithSuccessors != 0 { t.Fatalf("blocker counts = %+v, want no blockers", counts) } if counts.PendingOnlyIntentDepth != 2 { diff --git a/internal/cli/root.go b/internal/cli/root.go index b14b8e02..2c0427e7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,7 +7,8 @@ import ( "github.com/spf13/cobra" ) -// ErrNoCommand is returned when acd is invoked with no subcommand. +// ErrNoCommand is retained for source compatibility. Bare acd now renders a +// read-only health summary and no longer returns this error. var ErrNoCommand = errors.New("no command provided") // Execute builds the root command tree and runs it. @@ -31,9 +32,9 @@ func newRootCmd() *cobra.Command { Short: "Atomic Commit Daemon — per-repo, multi-harness", SilenceUsage: true, SilenceErrors: true, + Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error { - _ = c.Help() - return ErrNoCommand + return runControlStatus(c.Context(), c.OutOrStdout(), repoPath, jsonOut) }, } cmd.CompletionOptions.HiddenDefaultCmd = true @@ -46,6 +47,8 @@ func newRootCmd() *cobra.Command { pf.StringVar(&logLevel, "log-level", "info", "debug|info|warn|error") cmd.AddCommand( + newOnCmd(), + newOffCmd(), newVersionCmd(), newStartCmd(), newStopCmd(), diff --git a/internal/cli/root_help_test.go b/internal/cli/root_help_test.go index 9ef0a7d0..45084482 100644 --- a/internal/cli/root_help_test.go +++ b/internal/cli/root_help_test.go @@ -23,25 +23,24 @@ func TestRootHelpIsCompactAndWorkflowGrouped(t *testing.T) { got := out.String() for _, want := range []string{ - "Common workflow:", - "Diagnostics and recovery:", - "Setup:", + "Primary controls:", + "Observe:", + "Support and recovery:", "Advanced:", + "Hook protocol (normally managed by acd setup):", + "acd Show one read-only health classification and next action", + "acd on", + "acd off", + "acd status", + "acd setup", "acd logs", - "acd prompt --last", - "acd fix --dry-run", - "acd repo init", - "acd repo disable", - "acd repo enable", - "acd repo manage", - "acd repo list", - "acd repo remove --dry-run", - "acd list --once", + "acd fix", + "acd repo", + "acd start / stop / wake / touch / flush", "acd diagnose", "acd doctor", "acd pause", "acd resume", - "acd setup", "--repo string", "--json", "--quiet", @@ -58,6 +57,8 @@ func TestRootHelpIsCompactAndWorkflowGrouped(t *testing.T) { "acd daemon", "acd hook-stdin-extract", "acd completion", + "acd repo remove --dry-run", + "acd rewrite-commits --from-nr", // Deprecated commands must not be advertised in the hand-written // help. The cobra deprecation warnings on actual invocation still // fire (recover.go / purge.go log to stderr), but the discovery diff --git a/internal/cli/status.go b/internal/cli/status.go index ec9d3001..8ba6dff8 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -50,6 +50,7 @@ type statusReport struct { PendingEvents int `json:"pending_events"` BlockedConflicts int `json:"blocked_conflicts"` ActiveBarriers int `json:"active_barriers,omitempty"` + ActiveTerminalEvents int `json:"active_terminal_events,omitempty"` FailedEvents int `json:"failed_events"` FailedBlockingPending int `json:"failed_blocking_pending"` LastCommitOID string `json:"last_commit_oid,omitempty"` @@ -271,6 +272,7 @@ func buildStatusReport(ctx context.Context, rec central.RepoRecord, now time.Tim } report.BlockedConflicts = blockers.TotalBlockedConflicts report.ActiveBarriers = blockers.ActiveBlockedBarriersWithSuccessors + report.ActiveTerminalEvents = blockers.ActiveTerminalEvents if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events WHERE state = ?`, state.EventStateFailed).Scan(&report.FailedEvents); err != nil { @@ -495,7 +497,7 @@ func renderStatusHuman(out io.Writer, r statusReport) error { if r.BlockedConflicts > 0 { fmt.Fprintf(out, "Blocked conflicts: %d (inspect with `acd diagnose`; preview safe cleanup with `acd fix --dry-run`)\n", r.BlockedConflicts) if r.ActiveBarriers > 0 { - fmt.Fprintf(out, "Blocked barriers with pending replay: %d (force purge preview: `acd fix --force --dry-run`)\n", r.ActiveBarriers) + fmt.Fprintf(out, "Blocked barriers with pending replay: %d (archive-only recovery preview: `acd fix --force --dry-run`)\n", r.ActiveBarriers) } } if r.FailedEvents > 0 { diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 9fe3f863..89577700 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/daemon" pausepkg "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/pause" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -458,6 +459,131 @@ func TestStatus_IntentStrategyUsesDaemonMetadata(t *testing.T) { } } +func TestStatus_IntentStrategyReportsPlannerHealth(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, dbPath, d := makeRepoStateDB(t) + registerRepo(t, roots, repo, dbPath, "codex") + if err := state.MetaSet(ctx, d, "commit.strategy", "intent"); err != nil { + t.Fatalf("set commit strategy: %v", err) + } + nextProbe := time.Date(2026, 7, 13, 3, 21, 0, 0, time.UTC) + health := daemon.IntentPlannerHealthSnapshot{ + State: daemon.IntentPlannerCircuitOpen, + ProviderFingerprint: testPlannerHealthFingerprint(), + ConsecutiveFailures: 3, + BackoffLevel: 1, + NextProbeTS: float64(nextProbe.Unix()), + LastFailureClass: daemon.IntentPlannerFailureValidation, + LastError: "planner returned an invalid group", + BypassCount: 7, + } + if err := state.MetaSetJSON(ctx, d, daemon.MetaKeyIntentPlannerHealth, struct { + Version int `json:"version"` + daemon.IntentPlannerHealthSnapshot + }{Version: 1, IntentPlannerHealthSnapshot: health}); err != nil { + t.Fatalf("set planner health: %v", err) + } + + var jsonOut bytes.Buffer + if err := runStatus(ctx, &jsonOut, repo, true); err != nil { + t.Fatalf("runStatus json: %v", err) + } + var rep statusReport + if err := json.Unmarshal(jsonOut.Bytes(), &rep); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, jsonOut.String()) + } + if rep.IntentStrategy.PlannerHealth == nil || + rep.IntentStrategy.PlannerHealth.State != daemon.IntentPlannerCircuitOpen || + rep.IntentStrategy.PlannerHealth.ConsecutiveFailures != 3 || + rep.IntentStrategy.PlannerHealth.BypassCount != 7 || + rep.IntentStrategy.PlannerHealth.LastFailureClass != daemon.IntentPlannerFailureValidation { + t.Fatalf("planner health=%+v", rep.IntentStrategy.PlannerHealth) + } + if !bytes.Contains(jsonOut.Bytes(), []byte(`"planner_health"`)) || + !bytes.Contains(jsonOut.Bytes(), []byte(`"next_probe_ts"`)) { + t.Fatalf("status JSON missing planner health fields: %s", jsonOut.String()) + } + + var human bytes.Buffer + if err := runStatus(ctx, &human, repo, false); err != nil { + t.Fatalf("runStatus human: %v", err) + } + for _, want := range []string{ + "Intent planner health: open failures=3 bypasses=7", + "next_probe=2026-07-13T03:21:00Z", + "last_failure_class=validation", + "Last circuit failure: planner returned an invalid group", + } { + if !strings.Contains(human.String(), want) { + t.Fatalf("status human missing %q:\n%s", want, human.String()) + } + } +} + +func TestStatus_IntentPlannerHealthWarningIsReadOnly(t *testing.T) { + for _, tc := range []struct { + name string + raw string + warning string + }{ + {name: "empty", raw: "", warning: plannerHealthInvalidWarning}, + {name: "invalid", raw: `{"version":1,"last_error":"sk-secret`, warning: plannerHealthInvalidWarning}, + {name: "unsupported", raw: `{"version":99,"state":"open","last_error":"sk-secret"}`, warning: plannerHealthVersionWarning}, + } { + t.Run(tc.name, func(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, dbPath, d := makeRepoStateDB(t) + registerRepo(t, roots, repo, dbPath, "codex") + if err := state.MetaSet(ctx, d, daemon.MetaKeyIntentPlannerHealth, tc.raw); err != nil { + t.Fatalf("set planner health: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + before, err := fileSHA256(dbPath) + if err != nil { + t.Fatalf("checksum before: %v", err) + } + + var jsonOut bytes.Buffer + if err := runStatus(ctx, &jsonOut, repo, true); err != nil { + t.Fatalf("runStatus json: %v", err) + } + var rep statusReport + if err := json.Unmarshal(jsonOut.Bytes(), &rep); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, jsonOut.String()) + } + if rep.IntentStrategy.PlannerHealth != nil || rep.IntentStrategy.PlannerHealthWarning != tc.warning { + t.Fatalf("intent strategy=%+v", rep.IntentStrategy) + } + if strings.Contains(jsonOut.String(), "sk-secret") { + t.Fatalf("status leaked malformed metadata: %s", jsonOut.String()) + } + + var human bytes.Buffer + if err := runStatus(ctx, &human, repo, false); err != nil { + t.Fatalf("runStatus human: %v", err) + } + if !strings.Contains(human.String(), "Intent planner health warning: "+tc.warning) { + t.Fatalf("status human missing safe warning:\n%s", human.String()) + } + after, err := fileSHA256(dbPath) + if err != nil { + t.Fatalf("checksum after: %v", err) + } + if before != after { + t.Fatalf("status mutated state.db: before=%q after=%q", before, after) + } + }) + } +} + +func testPlannerHealthFingerprint() string { + return "sha256:" + strings.Repeat("0", 64) +} + func TestStatus_IntentStrategyReportsBatchWaitState(t *testing.T) { roots := withIsolatedHome(t) ctx := context.Background() @@ -590,7 +716,7 @@ func TestStatus_IntentStrategyUsesDurablePlannerErrorLedger(t *testing.T) { DecisionTS: 20, Kind: state.DecisionKindIntentPlannerError, Path: sqlNullStr("src/app.go"), - Reason: sqlNullStr("planner returned unsafe seq"), + Reason: sqlNullStr(`planner returned unsafe seq {"token":"legacy-secret"}`), EventSeq: sql.NullInt64{Int64: 42, Valid: true}, ActionTaken: sqlNullStr("planner validation failed"), UserMessage: sqlNullStr("fallback used"), @@ -611,7 +737,8 @@ func TestStatus_IntentStrategyUsesDurablePlannerErrorLedger(t *testing.T) { } if rep.IntentStrategy.LastPlannerErrorEventSeq != 42 || rep.IntentStrategy.LastPlannerErrorPath != "src/app.go" || - rep.IntentStrategy.LastPlannerError != "planner returned unsafe seq" { + strings.Contains(rep.IntentStrategy.LastPlannerError, "legacy-secret") || + !strings.Contains(rep.IntentStrategy.LastPlannerError, "[REDACTED]") { t.Fatalf("last planner error = %+v, want durable decision_records error", rep.IntentStrategy) } } diff --git a/internal/daemon/bootstrap.go b/internal/daemon/bootstrap.go index bb2b2f4d..c9112735 100644 --- a/internal/daemon/bootstrap.go +++ b/internal/daemon/bootstrap.go @@ -235,10 +235,8 @@ func ReseedShadowFromHead(ctx context.Context, repoDir string, db *state.DB, cct if cctx.BranchRef == "" { return 0, fmt.Errorf("daemon: ReseedShadowFromHead: empty branch_ref") } - if _, err := state.DeleteShadowGeneration(ctx, db, cctx.BranchRef, cctx.BranchGeneration); err != nil { - return 0, err - } - if _, err := state.MetaDelete(ctx, db, ShadowBootstrappedKey(cctx.BranchRef, cctx.BranchGeneration)); err != nil { + if err := state.InvalidateShadowGeneration(ctx, db, cctx.BranchRef, cctx.BranchGeneration, + ShadowBootstrappedKey(cctx.BranchRef, cctx.BranchGeneration)); err != nil { return 0, err } return BootstrapShadow(ctx, repoDir, db, cctx) diff --git a/internal/daemon/bootstrap_test.go b/internal/daemon/bootstrap_test.go index cd85aa1c..16d3807f 100644 --- a/internal/daemon/bootstrap_test.go +++ b/internal/daemon/bootstrap_test.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" "testing" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" @@ -25,33 +23,51 @@ func countShadowRows(t *testing.T, db *state.DB, branchRef string, gen int64) in return n } -// addLargeBootstrapFiles writes count files to dir, then commits them. Returns -// the resulting HEAD OID. The file contents are unique per index so each blob -// hashes distinctly and ls-tree returns count entries. +// addLargeBootstrapFiles commits a nested tree with count entries and returns +// the resulting HEAD OID. Git plumbing avoids materializing thousands of files +// in the worktree; the bootstrap path under test reads the committed tree. func addLargeBootstrapFiles(t *testing.T, dir string, count int) string { t.Helper() ctx := context.Background() - for i := 0; i < count; i++ { - // Spread across nested directories so we exercise sub-tree paths - // in addition to top-level entries. - sub := filepath.Join(dir, "data", fmt.Sprintf("d%03d", i/100)) - if err := os.MkdirAll(sub, 0o755); err != nil { - t.Fatalf("mkdirall: %v", err) - } - fp := filepath.Join(sub, fmt.Sprintf("f%05d.txt", i)) - if err := os.WriteFile(fp, []byte(fmt.Sprintf("payload %d\n", i)), 0o644); err != nil { - t.Fatalf("write file %d: %v", i, err) - } + parent, err := git.RevParse(ctx, dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "add", "."); err != nil { - t.Fatalf("git add: %v", err) + seedOID, err := git.RevParse(ctx, dir, "HEAD:.gitignore") + if err != nil { + t.Fatalf("rev-parse .gitignore: %v", err) } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "commit", "-q", "-m", "bulk-seed"); err != nil { - t.Fatalf("git commit: %v", err) + payloadOID, err := git.HashObjectStdin(ctx, dir, []byte("bootstrap payload\n")) + if err != nil { + t.Fatalf("hash bootstrap payload: %v", err) } - head, err := git.RevParse(ctx, dir, "HEAD") + + entries := make([]git.MktreeEntry, 0, count) + for i := 0; i < count; i++ { + entries = append(entries, git.MktreeEntry{ + Mode: git.RegularFileMode, + Type: "blob", + OID: payloadOID, + Path: fmt.Sprintf("f%05d.txt", i), + }) + } + dataTree, err := git.Mktree(ctx, dir, entries) if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) + t.Fatalf("mktree data: %v", err) + } + rootTree, err := git.Mktree(ctx, dir, []git.MktreeEntry{ + {Mode: git.RegularFileMode, Type: "blob", OID: seedOID, Path: ".gitignore"}, + {Mode: "040000", Type: "tree", OID: dataTree, Path: "data"}, + }) + if err != nil { + t.Fatalf("mktree root: %v", err) + } + head, err := git.CommitTree(ctx, dir, rootTree, "bulk-seed\n", parent) + if err != nil { + t.Fatalf("commit-tree: %v", err) + } + if err := git.UpdateRef(ctx, dir, "HEAD", head, parent); err != nil { + t.Fatalf("update-ref HEAD: %v", err) } return head } diff --git a/internal/daemon/capture_test.go b/internal/daemon/capture_test.go index c089f11f..527bb7f9 100644 --- a/internal/daemon/capture_test.go +++ b/internal/daemon/capture_test.go @@ -31,68 +31,19 @@ type captureFixture struct { func newCaptureFixture(t *testing.T) *captureFixture { t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - dir := t.TempDir() - if err := git.Init(ctx, dir); err != nil { - t.Fatalf("git init: %v", err) - } - // Force HEAD onto refs/heads/main regardless of host's init.defaultBranch - // (CI runners default to master; the fixture's CaptureContext is pinned - // to refs/heads/main). - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "symbolic-ref", "HEAD", "refs/heads/main"); err != nil { - t.Fatalf("symbolic-ref HEAD: %v", err) - } - // Configure identity so commit-tree works on hosts without git config. - for _, kv := range [][]string{ - {"user.email", "acd-test@example.com"}, - {"user.name", "ACD Test"}, - {"commit.gpgsign", "false"}, - } { - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "config", kv[0], kv[1]); err != nil { - t.Fatalf("git config %s: %v", kv[0], err) - } - } - // Initial commit so HEAD resolves to a real OID. - seedFile := filepath.Join(dir, ".gitignore") - if err := os.WriteFile(seedFile, []byte("ignored.txt\n"), 0o644); err != nil { - t.Fatalf("seed gitignore: %v", err) - } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "add", ".gitignore"); err != nil { - t.Fatalf("git add: %v", err) - } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "commit", "-q", "-m", "seed"); err != nil { - t.Fatalf("git commit: %v", err) - } - - gitDir, err := git.AbsoluteGitDir(ctx, dir) - if err != nil { - t.Fatalf("AbsoluteGitDir: %v", err) - } - dbPath := state.DBPathFromGitDir(gitDir) - db, err := state.Open(ctx, dbPath) - if err != nil { - t.Fatalf("state.Open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - head, err := git.RevParse(ctx, dir, "HEAD") - if err != nil { - t.Fatalf("rev-parse HEAD: %v", err) - } + repo := cloneDaemonTestRepo(t, captureRepoTemplate) - ig := git.NewIgnoreChecker(dir) + ig := git.NewIgnoreChecker(repo.dir) t.Cleanup(func() { _ = ig.Close() }) return &captureFixture{ - dir: dir, - gitDir: gitDir, - db: db, + dir: repo.dir, + gitDir: repo.gitDir, + db: repo.db, cctx: CaptureContext{ BranchRef: "refs/heads/main", BranchGeneration: 1, - BaseHead: head, + BaseHead: repo.head, }, ig: ig, matcher: state.NewSensitiveMatcher(), diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 51738d2a..f2d705ad 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -21,6 +21,7 @@ import ( "path/filepath" "runtime/debug" "strconv" + "sync" "time" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" @@ -176,6 +177,10 @@ type Options struct { // by ai.BuildProvider is captured automatically. MessageProviderCloser io.Closer + // providerCloseTimeout is a test-only override for the bounded provider + // shutdown wait. Zero keeps the production providerCloseTimeout. + providerCloseTimeout time.Duration + // Now lets tests inject a fake clock. Nil falls back to time.Now. Now func() time.Time @@ -221,6 +226,20 @@ type Options struct { // leaves this nil; tests inject a recorder to assert run-loop gate // behavior without involving network or subprocess providers. IntentPlanner ai.IntentPlanner + + // beforeBranchTransitionAccept is a test-only synchronization point after + // prospective shadow preparation and before the final token/pause CAS. + beforeBranchTransitionAccept func() + // afterBranchTransitionRollback fires after prospective shadow + // invalidation and in-memory context restoration. + afterBranchTransitionRollback func() + // beforeBranchTokenCheck is a test-only synchronization point immediately + // before the run loop samples the live branch token. + beforeBranchTokenCheck func() + // beforeStartupDeadBranchPairSafetyCheck is a test-only synchronization + // point immediately before a startup dead-branch pair's final safety gate. + // The sweep-owned context lets blocking tests release on daemon shutdown. + beforeStartupDeadBranchPairSafetyCheck func(context.Context, deadBranchPair) } // resolveClientTTL honors EnvClientTTLSeconds + opt. @@ -309,6 +328,10 @@ func Run(ctx context.Context, opts Options) error { // interface (subprocess plugins should expose it); otherwise we log // and proceed. Either way the caller observes a bounded shutdown. var providerCloser io.Closer + closeTimeout := opts.providerCloseTimeout + if closeTimeout <= 0 { + closeTimeout = providerCloseTimeout + } closeProviderOnce := func() { if providerCloser == nil { return @@ -322,9 +345,9 @@ func Run(ctx context.Context, opts Options) error { if err != nil { logger.Warn("close ai provider", "err", err.Error()) } - case <-time.After(providerCloseTimeout): + case <-time.After(closeTimeout): logger.Warn("close ai provider timed out; force-killing if possible", - "timeout", providerCloseTimeout.String()) + "timeout", closeTimeout.String()) if pe, ok := closer.(processExposer); ok { if proc := pe.Process(); proc != nil { if err := proc.Kill(); err != nil { @@ -373,24 +396,29 @@ func Run(ctx context.Context, opts Options) error { } msgFn := opts.MessageFn - if msgFn == nil { - provider := opts.MessageProvider - if provider == nil { - built, closer, err := ai.BuildProvider(providerCfg) - if err != nil { - logger.Warn("build ai provider; falling back to deterministic", - "err", err.Error()) - provider = ai.DeterministicProvider{CommitFormat: providerCfg.CommitFormat} - } else { - provider = built - providerCloser = closer - } - logger.Info("ai provider selected", - "provider", provider.Name(), - "mode", providerCfg.Mode) - } else if opts.MessageProviderCloser != nil { - providerCloser = opts.MessageProviderCloser + provider := opts.MessageProvider + needsProvider := msgFn == nil || (providerCfg.CommitStrategy == ai.CommitStrategyIntent && opts.IntentPlanner == nil) + if provider == nil && needsProvider { + built, closer, err := ai.BuildProvider(providerCfg) + if err != nil { + logger.Warn("build ai provider; falling back to deterministic", + "err", err.Error()) + provider = ai.DeterministicProvider{CommitFormat: providerCfg.CommitFormat} + } else { + provider = built + providerCloser = closer } + logger.Info("ai provider selected", + "provider", provider.Name(), + "mode", providerCfg.Mode) + } else if provider != nil && opts.MessageProviderCloser != nil { + providerCloser = opts.MessageProviderCloser + } + if provider == nil { + provider = ai.DeterministicProvider{CommitFormat: providerCfg.CommitFormat} + } + + if msgFn == nil { // Diff egress is OFF by default. Network-bound providers receive // only metadata (paths + op kinds + branch + timestamp) unless the // operator explicitly opts in via ACD_AI_DIFF_EGRESS. Reason: the @@ -407,6 +435,46 @@ func Run(ctx context.Context, opts Options) error { } msgFn = providerMessageFnWithPromptTrace(provider, effectiveRepoRoot, promptTracer) } + + // Build or inject the intent planner once per Run. Replay receives this + // same instance on every pass, so subprocess sessions and HTTP transports + // are reused and closed exactly once with the message provider. + runIntentPlanner := opts.IntentPlanner + if providerCfg.CommitStrategy == ai.CommitStrategyIntent && runIntentPlanner == nil { + if planner, ok := provider.(ai.IntentPlanner); ok { + runIntentPlanner = planner + } else { + logger.Warn("AI provider does not implement intent planning; falling back to deterministic", + "provider", provider.Name()) + runIntentPlanner = ai.DeterministicProvider{CommitFormat: providerCfg.CommitFormat} + } + } + var ( + intentHealth *IntentPlannerHealth + intentHealthOptions IntentPlannerHealthOptions + intentPlannerProvider string + intentPlannerModel string + intentIncludeDiffs bool + ) + if runIntentPlanner != nil { + intentPlannerProvider = ai.PrimaryProviderName(runIntentPlanner) + if intentPlannerProvider == "openai-compat" { + intentPlannerModel = providerCfg.Model + } + deterministic := intentPlannerProvider == (ai.DeterministicProvider{}).Name() + intentHealthOptions = IntentPlannerHealthOptions{ + Provider: IntentPlannerProviderIdentity{ + Provider: intentPlannerProvider, + Model: intentPlannerModel, + Endpoint: providerCfg.BaseURL, + Deterministic: deterministic, + }, + Now: now, + } + if plannerProvider, ok := runIntentPlanner.(ai.Provider); ok { + intentIncludeDiffs = ai.ProviderNeedsDiff(plannerProvider) && diffEgressOptIn() + } + } bootGrace := opts.BootGrace if bootGrace <= 0 { bootGrace = DefaultBootGrace @@ -441,6 +509,9 @@ func Run(ctx context.Context, opts Options) error { return fmt.Errorf("daemon: acquire daemon.lock: %w", err) } defer func() { _ = dlock.Release() }() + if runIntentPlanner != nil { + intentHealth = NewIntentPlannerHealth(ctx, opts.DB, intentHealthOptions) + } // 1a. Orphan flush_request sweep. Rows that sat in "acknowledged" past // OrphanFlushAckThreshold are presumed orphans from a previous daemon @@ -539,9 +610,22 @@ func Run(ctx context.Context, opts Options) error { if terr != nil { logger.Warn("seed branch token", "err", terr.Error()) currentToken = "" + } else { + // Derive startup context from the same token observation. A checkout + // can race the earlier resolveBranch call; mixing those two samples + // would accept an attached token with a detached capture context (or + // vice versa). + branchRef = tokenBranchRef(currentToken) + headOID = tokenSHA(currentToken) } branchTransitionBlocked := false - startupFastForwardResync := false + startupPreviousToken := currentToken + startupPreviousGeneration := persistedGen + startupTokenChanged := false + startupTransition := TokenTransitionUnchanged + startupChangedAt := "" + startupShadowMutated := false + startupShadowRefreshRequired := false if persistedHead != "" && currentToken != "" { prevToken := "rev:" + persistedHead if persistedToken, ok, err := state.MetaGet(ctx, opts.DB, MetaKeyBranchToken); err != nil { @@ -549,14 +633,64 @@ func Run(ctx context.Context, opts Options) error { } else if ok && persistedToken != "" { prevToken = persistedToken } + startupPreviousToken = prevToken transition, cErr := ClassifyTokenTransition(ctx, opts.RepoPath, prevToken, currentToken) + if cErr == nil { + startupTransition = transition + } if cErr != nil { logger.Warn("classify startup branch transition; will retry", "err", cErr.Error()) currentToken = prevToken branchTransitionBlocked = true - } else if transition == TokenTransitionDiverged { - prevGeneration := persistedGen + } else if transition != TokenTransitionUnchanged { + if operation, active := gitOperationInProgress(opts.GitDir); active { + logger.Info("startup branch transition deferred during git operation", + "operation", operation) + branchTransitionBlocked = true + } else if pauseStatus, perr := daemonPauseState(ctx, opts.GitDir, opts.DB); perr != nil { + logger.Warn("read pause state before startup branch reconciliation; will retry", + "err", perr.Error()) + branchTransitionBlocked = true + } else if pauseStatus.Active && pauseStatus.Source == "manual" { + logger.Info("startup branch transition deferred while manually paused", + "reason", pauseStatus.Reason) + branchTransitionBlocked = true + } else if result, rErr := reconcileTransitionPair(ctx, opts.RepoPath, opts.GitDir, + opts.DB, tokenBranchRef(prevToken), persistedGen, + tokenSHA(currentToken) == "", "", "startup_branch_transition", tracer); rErr != nil { + logger.Warn("reconcile unpublished chain before startup branch transition; will retry", + "branch_ref", tokenBranchRef(prevToken), + "generation", persistedGen, + "err", rErr.Error()) + branchTransitionBlocked = true + } else if result.Handled { + logger.Info("reconciled unpublished chain before startup branch transition", + "branch_ref", tokenBranchRef(prevToken), + "generation", persistedGen, + "outcome", result.Outcome, + "events", result.EventCount, + "recovery_ref", result.RecoveryRef) + } + if !branchTransitionBlocked { + verifiedToken, verifyErr := BranchGenerationToken(ctx, opts.RepoPath) + if verifyErr != nil { + logger.Warn("verify startup branch token after reconciliation; will retry", + "err", verifyErr.Error()) + branchTransitionBlocked = true + } else if verifiedToken != currentToken { + logger.Info("startup branch token changed during reconciliation; will retry", + "observed", currentToken, "verified", verifiedToken) + branchTransitionBlocked = true + } + } + if branchTransitionBlocked { + branchRef = tokenBranchRef(prevToken) + headOID = persistedHead + currentToken = prevToken + } + } + if !branchTransitionBlocked && transition == TokenTransitionDiverged { rewindPaused, rewindUntil, rewindErr := maybeSetRewindGrace(ctx, opts.RepoPath, opts.DB, prevToken, currentToken, now()) if rewindErr != nil { logger.Warn("detect startup rewind grace", "err", rewindErr.Error()) @@ -564,64 +698,16 @@ func Run(ctx context.Context, opts Options) error { logger.Info("replay paused after startup branch rewind", "until", rewindUntil) } persistedGen++ - droppedPending, dropErr := state.DeletePendingForGeneration(ctx, opts.DB, prevGeneration) - if dropErr != nil { - logger.Warn("drop pending events for previous branch generation at startup", - "generation", prevGeneration, "err", dropErr.Error()) - } ts := strconv.FormatFloat(float64(now().UnixNano())/1e9, 'f', -1, 64) - _ = state.MetaSet(ctx, opts.DB, MetaKeyBranchTokenChangedAt, ts) - logger.Info("branch generation bumped at startup", + startupTokenChanged = true + startupChangedAt = ts + logger.Info("branch generation prepared at startup", "old", prevToken, "new", currentToken, "generation", persistedGen, "transition", transition.String()) - recordTrace(tracer, acdtrace.Event{ - Repo: opts.RepoPath, - BranchRef: branchRef, - HeadSHA: headOID, - EventClass: "branch_token.transition", - Decision: transition.String(), - Reason: "startup token transition classified", - Input: map[string]any{"previous": prevToken, "current": currentToken}, - Output: map[string]any{ - "prev_generation": prevGeneration, - "new_generation": persistedGen, - "dropped_pending": droppedPending, - }, - Error: traceErrString(dropErr), - Generation: persistedGen, - }) - // Prune unpublished rows for the prior generation if its - // branch ref has since been deleted. Mirrors the runtime - // Diverged hook below so a daemon restart that observes a - // Diverged transition into a now-dead branch cleans up - // barriers instead of leaving them stuck forever. - // - // Honor the manual-pause marker: an operator paused mid- - // surgery does not want background hygiene to mutate - // capture_events while they investigate. A read failure on - // the pause state is logged and the prune is skipped (fail - // closed — same posture as the run-loop pause gate). - startupPruneAllowed := true - if pauseStatus, perr := daemonPauseState(ctx, opts.GitDir, opts.DB); perr != nil { - logger.Warn("read pause state before startup dead-branch prune; skipping", - "err", perr.Error()) - startupPruneAllowed = false - } else if pauseStatus.Active { - logger.Info("dead-branch prune skipped (manual pause)", - "source", pauseStatus.Source, - "reason", pauseStatus.Reason) - startupPruneAllowed = false - } - if startupPruneAllowed { - pruneDeadBranchTerminals(ctx, opts.RepoPath, opts.DB, - CaptureContext{BranchRef: branchRef, BranchGeneration: persistedGen, BaseHead: headOID}, - tokenBranchRef(prevToken), prevGeneration, - logger, tracer, - "dead branch unpublished pruned after startup Diverged") - } - } else if transition == TokenTransitionFastForward { - startupFastForwardResync = true + } else if !branchTransitionBlocked && transition == TokenTransitionFastForward { + startupTokenChanged = true + startupChangedAt = strconv.FormatFloat(float64(now().UnixNano())/1e9, 'f', -1, 64) } } cctx := CaptureContext{ @@ -629,79 +715,128 @@ func Run(ctx context.Context, opts Options) error { BranchGeneration: persistedGen, BaseHead: headOID, } - // Persist the seed observation so the next divergence has a baseline - // to compare against. Best-effort — log but do not fail on write. - if err := SaveBranchGeneration(ctx, opts.DB, cctx.BranchGeneration, headOID); err != nil { - logger.Warn("seed branch generation", "err", err.Error()) + // Seed shadow_paths from HEAD before the first capture so files + // already at HEAD don't generate spurious creates. + if !branchTransitionBlocked && cctx.BranchRef != "" && cctx.BaseHead != "" { + seedShadow := BootstrapShadow + seedReason := "startup shadow bootstrap" + if startupTokenChanged { + seedShadow = ReseedShadowFromHead + seedReason = "startup branch transition shadow reseed" + startupShadowMutated = true + } + if seeded, err := seedShadow(ctx, opts.RepoPath, opts.DB, cctx); err != nil { + logger.Warn("bootstrap shadow", "err", err.Error()) + traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) + branchTransitionBlocked = true + } else { + traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), seedReason, seeded) + if seeded > 0 { + logger.Info("shadow bootstrapped", "rows", seeded) + } + } } if !branchTransitionBlocked { - if err := state.MetaSet(ctx, opts.DB, MetaKeyBranchToken, currentToken); err != nil { - logger.Warn("seed branch token", "err", err.Error()) + verifiedToken, verifyErr := BranchGenerationToken(ctx, opts.RepoPath) + if verifyErr != nil { + logger.Warn("verify startup branch token before metadata accept; will retry", + "err", verifyErr.Error()) + branchTransitionBlocked = true + } else if verifiedToken != currentToken { + logger.Info("startup branch token changed during shadow seed; will retry", + "observed", currentToken, "verified", verifiedToken) + branchTransitionBlocked = true } } - if startupFastForwardResync && cctx.BranchRef != "" && cctx.BaseHead != "" { - startupPaused, pauseErr := daemonPauseState(ctx, opts.GitDir, opts.DB) - if pauseErr != nil { - logger.Warn("read pause state before startup fast-forward resync", - "err", pauseErr.Error()) - startupFastForwardResync = false + if !branchTransitionBlocked { + meta := map[string]string{ + MetaKeyBranchGeneration: strconv.FormatInt(cctx.BranchGeneration, 10), + MetaKeyBranchHead: cctx.BaseHead, + MetaKeyBranchToken: currentToken, + } + if startupTokenChanged { + meta[MetaKeyBranchTokenChangedAt] = startupChangedAt + } + if err := state.MetaSetMany(ctx, opts.DB, meta); err != nil { + logger.Warn("accept startup branch transition metadata; will retry", "err", err.Error()) branchTransitionBlocked = true - } else if startupPaused.Active { - logger.Info("startup fast-forward observed while paused; preserving shadow for resume", - "source", startupPaused.Source, - "reason", startupPaused.Reason) - startupFastForwardResync = false - } else if resumed, stamp := manualPauseRecentlyResumed(ctx, opts.DB, now()); resumed { - logger.Info("startup fast-forward observed after manual resume; preserving shadow for self-heal", - "resumed_at", stamp) - startupFastForwardResync = false - _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyManualPauseResumedAt) + } else if startupTokenChanged { + recordTrace(tracer, acdtrace.Event{ + Repo: opts.RepoPath, + BranchRef: cctx.BranchRef, + HeadSHA: cctx.BaseHead, + EventClass: "branch_token.transition", + Decision: startupTransition.String(), + Reason: "startup branch transition accepted", + Input: map[string]any{"previous": startupPreviousToken, "current": currentToken}, + Output: map[string]any{ + "accepted": true, + "prev_generation": startupPreviousGeneration, + "new_generation": cctx.BranchGeneration, + }, + Generation: cctx.BranchGeneration, + }) } } - if startupFastForwardResync && cctx.BranchRef != "" && cctx.BaseHead != "" { - dropped, dropErr := state.DeleteStaleUnpublishedForBranchGeneration(ctx, opts.DB, - cctx.BranchRef, cctx.BranchGeneration, cctx.BaseHead) - if dropErr != nil { - logger.Warn("drop stale unpublished events after startup fast-forward", - "branch_ref", cctx.BranchRef, - "generation", cctx.BranchGeneration, - "err", dropErr.Error()) + if branchTransitionBlocked && startupPreviousToken != "" { + if startupShadowMutated && cctx.BranchRef != "" { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + cleanupErr := state.InvalidateShadowGeneration(cleanupCtx, opts.DB, + cctx.BranchRef, cctx.BranchGeneration, + ShadowBootstrappedKey(cctx.BranchRef, cctx.BranchGeneration)) + cleanupCancel() + if cleanupErr != nil { + logger.Warn("invalidate prospective startup shadow; will force refresh", + "err", cleanupErr.Error()) + startupShadowRefreshRequired = true + } } - if seeded, err := ReseedShadowFromHead(ctx, opts.RepoPath, opts.DB, cctx); err != nil { - logger.Warn("reseed shadow after startup fast-forward", - "err", err.Error()) - traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) - } else { - traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "startup fast-forward shadow reseed", seeded) - logger.Info("shadow reseeded after startup fast-forward", - "rows", seeded, - "generation", cctx.BranchGeneration, - "dropped_unpublished", dropped) + if startupTokenChanged { + recordTrace(tracer, acdtrace.Event{ + Repo: opts.RepoPath, + BranchRef: cctx.BranchRef, + HeadSHA: cctx.BaseHead, + EventClass: "branch_token.transition", + Decision: "rolled_back", + Reason: "startup branch transition was not accepted", + Input: map[string]any{"previous": startupPreviousToken, "current": currentToken}, + Output: map[string]any{ + "accepted": false, + "prev_generation": startupPreviousGeneration, + "new_generation": cctx.BranchGeneration, + }, + Generation: cctx.BranchGeneration, + }) } + currentToken = startupPreviousToken + persistedGen = startupPreviousGeneration + branchRef = tokenBranchRef(startupPreviousToken) + headOID = tokenSHA(startupPreviousToken) + if headOID == "" { + headOID = persistedHead + } + cctx = CaptureContext{BranchRef: branchRef, BranchGeneration: persistedGen, BaseHead: headOID} } - - // Seed shadow_paths from HEAD before the first capture so files - // already at HEAD don't generate spurious creates. - if cctx.BranchRef != "" { + if !branchTransitionBlocked && cctx.BranchRef != "" { if _, ok, _ := state.MetaGet(ctx, opts.DB, MetaKeyDetachedHeadPaused); ok { _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyDetachedHeadPaused) + clearRewindGraceMeta(ctx, opts.DB, opts.RepoPath, cctx, tracer, logger, + "detached HEAD reattached before startup acceptance") } } - if cctx.BranchRef != "" && cctx.BaseHead != "" { - if seeded, err := BootstrapShadow(ctx, opts.RepoPath, opts.DB, cctx); err != nil { - logger.Warn("bootstrap shadow", "err", err.Error()) - traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) - } else { - traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "startup shadow bootstrap", seeded) - if seeded > 0 { - logger.Info("shadow bootstrapped", "rows", seeded) - } + if !branchTransitionBlocked { + if startupTokenChanged { + _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyManualPauseResumedAt) + } + if cctx.BranchRef != "" { if pruned, pErr := pruneShadowGenerations(ctx, opts.DB, cctx); pErr != nil { logger.Warn("prune old shadow generations", "err", pErr.Error()) } else if pruned > 0 { logger.Info("pruned old shadow generations", "rows", pruned) } } + } + if !branchTransitionBlocked && cctx.BranchRef != "" && cctx.BaseHead != "" { if repaired, err := RepairPublishedLiveIndex(ctx, opts.RepoPath, opts.DB, cctx.BaseHead, DefaultLiveIndexRepairLimit); err != nil { logger.Warn("repair published live index", "err", err.Error()) } else if repaired.Applied > 0 || len(repaired.Skipped) > 0 { @@ -718,7 +853,7 @@ func Run(ctx context.Context, opts Options) error { // fires synchronously so operators see the knob even on a no-op // startup. if isKeepDeadBranchBarriers() { - logger.Info("dead-branch unpublished pruning disabled by env", + logger.Info("dead-branch unpublished recovery disabled by env", "env", EnvKeepDeadBranchBarriers) } @@ -872,6 +1007,23 @@ func Run(ctx context.Context, opts Options) error { "repo", opts.RepoPath, "pid", pid, "branch", branchRef, "head", headOID, "token", currentToken) + // The startup sweep runs off the latency-sensitive startup path, but it + // remains owned by this Run invocation. Cancel and join it before graceful + // shutdown stamps stopped state, and keep the defer as a safety net for any + // later non-graceful return. Because this defer was registered after the + // daemon lock's defer, the sweep can never outlive lock ownership. + startupSweepCtx, startupSweepCancel := context.WithCancel(ctx) + var startupSweepWG sync.WaitGroup + stopStartupSweep := func() { + startupSweepCancel() + startupSweepWG.Wait() + } + defer stopStartupSweep() + gracefulWithSweep := func(reason string) error { + stopStartupSweep() + return graceful(reason) + } + // Schedule the dead-branch sweep on a one-shot goroutine, AFTER the // "daemon running" log lands, so neither the for-each-ref shell-out // nor the O(N) walk over distinct terminal pairs counts against the @@ -884,8 +1036,15 @@ func Run(ctx context.Context, opts Options) error { // sweep is skipped (fail closed — same posture as the run-loop pause // gate). startupSweepCctx := cctx + startupSweepWG.Add(1) go func(sweepCctx CaptureContext) { - if pauseStatus, perr := daemonPauseState(ctx, opts.GitDir, opts.DB); perr != nil { + defer startupSweepWG.Done() + if operation, active := gitOperationInProgress(opts.GitDir); active { + logger.Info("startup dead-branch sweep skipped during git operation", + "operation", operation) + return + } + if pauseStatus, perr := daemonPauseState(startupSweepCtx, opts.GitDir, opts.DB); perr != nil { logger.Warn("read pause state before startup dead-branch sweep; skipping", "err", perr.Error()) return @@ -895,7 +1054,10 @@ func Run(ctx context.Context, opts Options) error { "reason", pauseStatus.Reason) return } - runStartupDeadBranchSweep(ctx, opts.RepoPath, opts.DB, sweepCctx, logger, tracer) + runStartupDeadBranchSweepWithOptions(startupSweepCtx, opts.RepoPath, opts.DB, + sweepCctx, logger, tracer, startupDeadBranchSweepOptions{ + beforePairSafetyCheck: opts.beforeStartupDeadBranchPairSafetyCheck, + }) }(startupSweepCctx) // lastStampedBranchHead is the most recent value the run loop has @@ -911,14 +1073,30 @@ func Run(ctx context.Context, opts Options) error { // the very first idle tick does not re-stamp an unchanged value. lastStampedBranchHead := persistedHead var branchTransitionSettleUntil time.Time + forceShadowRefresh := startupShadowRefreshRequired processBranchTokenChange := func(logPrefix string) bool { + if opts.beforeBranchTokenCheck != nil { + opts.beforeBranchTokenCheck() + } newToken, terr := BranchGenerationToken(ctx, opts.RepoPath) if terr != nil { logger.Warn(logPrefix+" resolve failed", "err", terr.Error()) return false } if SameGeneration(currentToken, newToken) { + if forceShadowRefresh && cctx.BranchRef != "" { + if seeded, seedErr := ReseedShadowFromHead(ctx, opts.RepoPath, opts.DB, cctx); seedErr != nil { + logger.Warn(logPrefix+" refresh shadow after rolled-back transition", + "err", seedErr.Error()) + return true + } else { + forceShadowRefresh = false + traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), + "rolled-back transition shadow refresh", seeded) + return true + } + } // Cross-tick same-SHA rewind probe. // // `git reset --hard HEAD~1` followed by `git reset --hard ORIG_HEAD` @@ -1029,21 +1207,156 @@ func Run(ctx context.Context, opts Options) error { "err", cErr.Error()) return true } + pauseStatus, pauseErr := daemonPauseState(ctx, opts.GitDir, opts.DB) + if pauseErr != nil { + logger.Warn(logPrefix+" read pause state before branch reconciliation; will retry", + "err", pauseErr.Error()) + return true + } + if pauseStatus.Active && pauseStatus.Source == "manual" { + logger.Info("branch transition deferred while manually paused", + "old", currentToken, + "new", newToken, + "reason", pauseStatus.Reason) + return true + } + oldToken := currentToken + result, reconcileErr := reconcileTransitionPair(ctx, opts.RepoPath, opts.GitDir, + opts.DB, cctx.BranchRef, cctx.BranchGeneration, + tokenSHA(newToken) == "", "", "runtime_branch_transition", tracer) + if reconcileErr != nil { + logger.Warn(logPrefix+" reconcile unpublished chain before branch transition; will retry", + "branch_ref", cctx.BranchRef, + "generation", cctx.BranchGeneration, + "err", reconcileErr.Error()) + return true + } + if result.Handled { + logger.Info("reconciled unpublished chain before branch transition", + "branch_ref", cctx.BranchRef, + "generation", cctx.BranchGeneration, + "outcome", result.Outcome, + "events", result.EventCount, + "recovery_ref", result.RecoveryRef) + } + verifiedToken, verifyErr := BranchGenerationToken(ctx, opts.RepoPath) + if verifyErr != nil { + logger.Warn(logPrefix+" verify branch token after reconciliation; will retry", + "err", verifyErr.Error()) + return true + } + if verifiedToken != newToken { + logger.Info("branch token changed during reconciliation; will retry", + "observed", newToken, "verified", verifiedToken) + return true + } branchTransitionSettleUntil = now().Add(branchTransitionSettleDelay) ts := strconv.FormatFloat(float64(now().UnixNano())/1e9, 'f', -1, 64) - // Single-tx batch: changedAt + token must land together so a - // reader between the two writes never sees a stale token paired - // with the new timestamp (or vice versa). - _ = state.MetaSetMany(ctx, opts.DB, map[string]string{ - MetaKeyBranchTokenChangedAt: ts, - MetaKeyBranchToken: newToken, - }) - // Refresh HEAD for capture/replay regardless of transition kind. - branchRef, headOID = resolveBranch(ctx, opts.RepoPath, logger) + oldCctx := cctx + oldBranchRef := branchRef + oldHeadOID := headOID + prospectiveShadowMutated := false + var pendingTransitionTraces []acdtrace.Event + rollbackTraced := false + rollbackTransition := func() { + if prospectiveShadowMutated && cctx.BranchRef != "" { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + cleanupErr := state.InvalidateShadowGeneration(cleanupCtx, opts.DB, + cctx.BranchRef, cctx.BranchGeneration, + ShadowBootstrappedKey(cctx.BranchRef, cctx.BranchGeneration)) + cleanupCancel() + if cleanupErr != nil { + logger.Warn(logPrefix+" invalidate prospective shadow; will force refresh", + "err", cleanupErr.Error()) + forceShadowRefresh = true + } + } + if !rollbackTraced { + recordTrace(tracer, acdtrace.Event{ + Repo: opts.RepoPath, + BranchRef: cctx.BranchRef, + HeadSHA: cctx.BaseHead, + EventClass: "branch_token.transition", + Decision: "rolled_back", + Reason: "run-loop branch transition was not accepted", + Input: map[string]any{"previous": oldToken, "current": newToken}, + Output: map[string]any{ + "accepted": false, + "prev_generation": oldCctx.BranchGeneration, + "new_generation": cctx.BranchGeneration, + }, + Generation: cctx.BranchGeneration, + }) + rollbackTraced = true + } + currentToken = oldToken + cctx = oldCctx + branchRef = oldBranchRef + headOID = oldHeadOID + if opts.afterBranchTransitionRollback != nil { + opts.afterBranchTransitionRollback() + } + } + acceptTransition := func() bool { + if opts.beforeBranchTransitionAccept != nil { + opts.beforeBranchTransitionAccept() + } + pauseStatus, pauseErr := daemonPauseState(ctx, opts.GitDir, opts.DB) + if pauseErr != nil { + logger.Warn(logPrefix+" recheck pause before metadata accept; will retry", + "err", pauseErr.Error()) + rollbackTransition() + return false + } + if pauseStatus.Active && pauseStatus.Source == "manual" { + logger.Info("branch transition acceptance deferred while manually paused", + "reason", pauseStatus.Reason) + rollbackTransition() + return false + } + verifiedToken, verifyErr := BranchGenerationToken(ctx, opts.RepoPath) + if verifyErr != nil { + logger.Warn(logPrefix+" verify branch token before metadata accept; will retry", + "err", verifyErr.Error()) + rollbackTransition() + return false + } + if verifiedToken != newToken { + logger.Info("branch token changed during shadow seed; will retry", + "observed", newToken, "verified", verifiedToken) + rollbackTransition() + return false + } + if err := state.MetaSetMany(ctx, opts.DB, map[string]string{ + MetaKeyBranchTokenChangedAt: ts, + MetaKeyBranchToken: newToken, + MetaKeyBranchGeneration: strconv.FormatInt(cctx.BranchGeneration, 10), + MetaKeyBranchHead: cctx.BaseHead, + }); err != nil { + logger.Warn(logPrefix+" accept branch transition metadata; will retry", + "err", err.Error()) + rollbackTransition() + return false + } + lastStampedBranchHead = cctx.BaseHead + for _, event := range pendingTransitionTraces { + recordTrace(tracer, event) + } + return true + } + // Derive the prospective capture context from the exact token sample + // already classified and checked. A second resolve can race checkout + // and mix a branch/head pair from a different observation. + branchRef = tokenBranchRef(newToken) + headOID = tokenSHA(newToken) cctx.BranchRef = branchRef cctx.BaseHead = headOID - oldToken := currentToken currentToken = newToken + reattaching := false + detaching := false + clearGraceAfterAccept := false + clearManualResumeAfterAccept := false + pruneShadowAfterAccept := false if transition == TokenTransitionDiverged { prevGeneration := cctx.BranchGeneration rewindPaused, rewindUntil, rewindErr := maybeSetRewindGrace(ctx, opts.RepoPath, opts.DB, oldToken, newToken, now()) @@ -1053,16 +1366,12 @@ func Run(ctx context.Context, opts Options) error { logger.Info("replay paused after branch rewind", "until", rewindUntil) } cctx.BranchGeneration++ + pruneShadowAfterAccept = true logger.Info("branch generation bumped", "old", oldToken, "new", newToken, "generation", cctx.BranchGeneration, "transition", transition.String()) - droppedPending, dropErr := state.DeletePendingForGeneration(ctx, opts.DB, prevGeneration) - if dropErr != nil { - logger.Warn("drop pending events for previous branch generation", - "generation", prevGeneration, "err", dropErr.Error()) - } - recordTrace(tracer, acdtrace.Event{ + pendingTransitionTraces = append(pendingTransitionTraces, acdtrace.Event{ Repo: opts.RepoPath, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, @@ -1071,59 +1380,20 @@ func Run(ctx context.Context, opts Options) error { Reason: "run-loop token transition classified", Input: map[string]any{"previous": oldToken, "current": newToken}, Output: map[string]any{ - "prev_generation": prevGeneration, - "new_generation": cctx.BranchGeneration, - "dropped_pending": droppedPending, + "accepted": true, + "prev_generation": prevGeneration, + "new_generation": cctx.BranchGeneration, + "reconciled_events": result.EventCount, + "reconcile_outcome": result.Outcome, }, - Error: traceErrString(dropErr), Generation: cctx.BranchGeneration, }) - // Prune unpublished rows for the prior (branch_ref, - // generation) when the prior branch ref no longer resolves. - // Avoids phantom blocked_conflict / failed / pending barriers - // accumulating after a feature branch is merged and deleted - // upstream. - // - // Honor the manual-pause marker. processBranchTokenChange runs - // at section 4d, BEFORE the run-loop pause gate at 4f, so a - // runtime Diverged transition can otherwise fire while the - // operator has paused for surgery. Skip the prune so paused - // operators see no unsolicited capture_events mutation. - runtimePruneAllowed := true - if pauseStatus, perr := daemonPauseState(ctx, opts.GitDir, opts.DB); perr != nil { - logger.Warn("read pause state before runtime dead-branch prune; skipping", - "err", perr.Error()) - runtimePruneAllowed = false - } else if pauseStatus.Active { - logger.Info("dead-branch prune skipped (manual pause)", - "source", pauseStatus.Source, - "reason", pauseStatus.Reason) - runtimePruneAllowed = false - } - if runtimePruneAllowed { - pruneDeadBranchTerminals(ctx, opts.RepoPath, opts.DB, cctx, - tokenBranchRef(oldToken), prevGeneration, - logger, tracer, - "dead branch unpublished pruned after Diverged") - } - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist bumped branch generation", - "err", err.Error()) - } else { - // Keep the in-memory cache in sync with the persisted - // MetaKeyBranchHead so the SameGeneration "skip if equal" - // guard at the bottom of this closure does not produce a - // redundant MetaSet on the next idle tick. - lastStampedBranchHead = headOID - } // shadow_paths is keyed by (branch_ref, branch_generation). // After a divergence the new key is empty; without // reseeding from HEAD the next capture would classify every // tracked file as a phantom `create`. if cctx.BranchRef == "" { - _ = state.MetaSet(ctx, opts.DB, MetaKeyDetachedHeadPaused, ts) - logger.Warn("detached HEAD detected; capture/replay paused") + detaching = true } else { // Detached -> attached transition can land here when the // reattach branch at line 1057 races with this Diverged @@ -1137,16 +1407,15 @@ func Run(ctx context.Context, opts Options) error { // resume immediately rather than staying muted up to // ACD_REWIND_GRACE_SECONDS. if tokenBranchRef(oldToken) == "" { - if _, ok, _ := state.MetaGet(ctx, opts.DB, MetaKeyDetachedHeadPaused); ok { - _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyDetachedHeadPaused) - } - clearRewindGraceMeta(ctx, opts.DB, opts.RepoPath, cctx, tracer, logger, - "detached HEAD reattached via diverged transition") + reattaching = true } - if seeded, err := BootstrapShadow(ctx, opts.RepoPath, opts.DB, cctx); err != nil { + prospectiveShadowMutated = true + if seeded, err := ReseedShadowFromHead(ctx, opts.RepoPath, opts.DB, cctx); err != nil { logger.Warn("reseed shadow after generation bump", "err", err.Error()) traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) + rollbackTransition() + return true } else { traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "generation bump shadow reseed", seeded) if seeded > 0 { @@ -1154,11 +1423,6 @@ func Run(ctx context.Context, opts Options) error { "rows", seeded, "generation", cctx.BranchGeneration) } - if pruned, pErr := pruneShadowGenerations(ctx, opts.DB, cctx); pErr != nil { - logger.Warn("prune old shadow generations", "err", pErr.Error()) - } else if pruned > 0 { - logger.Info("pruned old shadow generations", "rows", pruned) - } } } } else { @@ -1184,11 +1448,12 @@ func Run(ctx context.Context, opts Options) error { if graceActive { prevGeneration := cctx.BranchGeneration cctx.BranchGeneration++ + pruneShadowAfterAccept = true logger.Info("fast-forward inside rewind grace; reseeding shadow", "old", oldToken, "new", newToken, "generation", cctx.BranchGeneration, "grace_until", until) - recordTrace(tracer, acdtrace.Event{ + pendingTransitionTraces = append(pendingTransitionTraces, acdtrace.Event{ Repo: opts.RepoPath, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, @@ -1197,24 +1462,21 @@ func Run(ctx context.Context, opts Options) error { Reason: "fast-forward inside rewind grace; reseeding shadow", Input: map[string]any{"previous": oldToken, "current": newToken}, Output: map[string]any{ + "accepted": true, "prev_generation": prevGeneration, "new_generation": cctx.BranchGeneration, "grace_until": until, }, Generation: cctx.BranchGeneration, }) - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist branch generation after FF-in-grace", - "err", err.Error()) - } else { - lastStampedBranchHead = headOID - } if cctx.BranchRef != "" { - if seeded, err := BootstrapShadow(ctx, opts.RepoPath, opts.DB, cctx); err != nil { + prospectiveShadowMutated = true + if seeded, err := ReseedShadowFromHead(ctx, opts.RepoPath, opts.DB, cctx); err != nil { logger.Warn("reseed shadow after FF-in-grace", "err", err.Error()) traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) + rollbackTransition() + return true } else { traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "FF-in-grace shadow reseed", seeded) if seeded > 0 { @@ -1222,109 +1484,70 @@ func Run(ctx context.Context, opts Options) error { "rows", seeded, "generation", cctx.BranchGeneration) } - if pruned, pErr := pruneShadowGenerations(ctx, opts.DB, cctx); pErr != nil { - logger.Warn("prune old shadow generations", "err", pErr.Error()) - } else if pruned > 0 { - logger.Info("pruned old shadow generations", "rows", pruned) - } } } - // Clear the rewind grace gate now that shadow is consistent - // with the current HEAD. Capture/replay can resume on the - // next tick. - clearRewindGraceMeta(ctx, opts.DB, opts.RepoPath, cctx, tracer, logger, - "fast-forward inside rewind grace") + clearGraceAfterAccept = true } else { fastForwardPaused, pauseErr := daemonPauseState(ctx, opts.GitDir, opts.DB) if pauseErr != nil { logger.Warn("read pause state before branch fast-forward resync", "err", pauseErr.Error()) + rollbackTransition() return true } - if fastForwardPaused.Active { - logger.Info("branch fast-forward observed while paused; preserving shadow for resume", - "old", oldToken, - "new", newToken, - "generation", cctx.BranchGeneration, - "source", fastForwardPaused.Source, + if fastForwardPaused.Active && fastForwardPaused.Source == "manual" { + logger.Info("branch fast-forward deferred while manually paused", + "old", oldToken, "new", newToken, "reason", fastForwardPaused.Reason) - recordTrace(tracer, acdtrace.Event{ - Repo: opts.RepoPath, - BranchRef: cctx.BranchRef, - HeadSHA: cctx.BaseHead, - EventClass: "branch_token.transition", - Decision: transition.String(), - Reason: "fast-forward observed while paused; preserving shadow for resume", - Input: map[string]any{"previous": oldToken, "current": newToken}, - Output: map[string]any{ - "generation": cctx.BranchGeneration, - "source": fastForwardPaused.Source, - }, - Generation: cctx.BranchGeneration, - }) - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist branch head", "err", err.Error()) - } + rollbackTransition() return true } if resumed, stamp := manualPauseRecentlyResumed(ctx, opts.DB, now()); resumed { - logger.Info("branch fast-forward observed after manual resume; preserving shadow for self-heal", + logger.Info("branch fast-forward observed after manual resume; reseeding shadow", "old", oldToken, "new", newToken, "generation", cctx.BranchGeneration, "resumed_at", stamp) - recordTrace(tracer, acdtrace.Event{ + pendingTransitionTraces = append(pendingTransitionTraces, acdtrace.Event{ Repo: opts.RepoPath, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, EventClass: "branch_token.transition", Decision: transition.String(), - Reason: "fast-forward observed after manual resume; preserving shadow for self-heal", + Reason: "fast-forward observed after manual resume; reseeding shadow", Input: map[string]any{"previous": oldToken, "current": newToken}, Output: map[string]any{ + "accepted": true, "generation": cctx.BranchGeneration, "resumed_at": stamp, }, Generation: cctx.BranchGeneration, }) - _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyManualPauseResumedAt) - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist branch head", "err", err.Error()) - } - return true - } - droppedUnpublished, dropErr := state.DeleteStaleUnpublishedForBranchGeneration(ctx, opts.DB, - cctx.BranchRef, cctx.BranchGeneration, cctx.BaseHead) - if dropErr != nil { - logger.Warn("drop stale unpublished events after branch fast-forward", - "branch_ref", cctx.BranchRef, - "generation", cctx.BranchGeneration, - "err", dropErr.Error()) + clearManualResumeAfterAccept = true } seeded := 0 if cctx.BranchRef != "" { var seedErr error + prospectiveShadowMutated = true seeded, seedErr = ReseedShadowFromHead(ctx, opts.RepoPath, opts.DB, cctx) if seedErr != nil { logger.Warn("reseed shadow after branch fast-forward", "err", seedErr.Error()) traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", seedErr.Error(), 0) + rollbackTransition() + return true } else { traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "fast-forward shadow reseed", seeded) logger.Info("shadow reseeded after branch fast-forward", "rows", seeded, - "generation", cctx.BranchGeneration, - "dropped_unpublished", droppedUnpublished) + "generation", cctx.BranchGeneration) } } logger.Debug("branch fast-forwarded", "old", oldToken, "new", newToken, "generation", cctx.BranchGeneration, - "dropped_unpublished", droppedUnpublished, "shadow_rows", seeded) - recordTrace(tracer, acdtrace.Event{ + pendingTransitionTraces = append(pendingTransitionTraces, acdtrace.Event{ Repo: opts.RepoPath, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, @@ -1333,16 +1556,49 @@ func Run(ctx context.Context, opts Options) error { Reason: "run-loop token transition classified", Input: map[string]any{"previous": oldToken, "current": newToken}, Output: map[string]any{ + "accepted": true, "generation": cctx.BranchGeneration, - "dropped_unpublished": droppedUnpublished, + "reconciled_events": result.EventCount, + "reconcile_outcome": result.Outcome, "shadow_rows_reseeded": seeded, }, - Error: traceErrString(dropErr), Generation: cctx.BranchGeneration, }) - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist branch head", "err", err.Error()) + } + } + if acceptTransition() { + if result.Handled && result.EventCount > 0 && oldCctx.BranchRef != "" { + exists, refErr := git.RefExists(ctx, opts.RepoPath, oldCctx.BranchRef) + if refErr != nil { + logger.Warn("recheck prior ref after branch recovery", + "ref", oldCctx.BranchRef, "err", refErr.Error()) + } else if !exists { + recordDeadBranchRecoveryMeta(ctx, opts.DB, logger, + result.EventCount, []string{oldCctx.BranchRef}) + } + } + if detaching { + _ = state.MetaSet(ctx, opts.DB, MetaKeyDetachedHeadPaused, ts) + logger.Warn("detached HEAD detected; capture/replay paused") + } + if reattaching { + if _, ok, _ := state.MetaGet(ctx, opts.DB, MetaKeyDetachedHeadPaused); ok { + _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyDetachedHeadPaused) + } + clearGraceAfterAccept = true + } + if clearGraceAfterAccept { + clearRewindGraceMeta(ctx, opts.DB, opts.RepoPath, cctx, tracer, logger, + "accepted branch transition") + } + if clearManualResumeAfterAccept { + _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyManualPauseResumedAt) + } + if pruneShadowAfterAccept { + if pruned, pErr := pruneShadowGenerations(ctx, opts.DB, cctx); pErr != nil { + logger.Warn("prune old shadow generations", "err", pErr.Error()) + } else if pruned > 0 { + logger.Info("pruned old shadow generations", "rows", pruned) } } } @@ -1358,11 +1614,11 @@ func Run(ctx context.Context, opts Options) error { // 4a/b. Honor ctx + shutdown signal. if err := ctx.Err(); err != nil { - return graceful("context canceled") + return gracefulWithSweep("context canceled") } select { case <-shutdownCh: - return graceful("signal shutdown") + return gracefulWithSweep("signal shutdown") default: } @@ -1478,46 +1734,6 @@ func Run(ctx context.Context, opts Options) error { } if !operationPaused { - if cctx.BranchRef == "" { - branchRef, headOID = resolveBranch(ctx, opts.RepoPath, logger) - if branchRef != "" { - cctx.BranchRef = branchRef - cctx.BaseHead = headOID - if err := SaveBranchGeneration(ctx, opts.DB, - cctx.BranchGeneration, headOID); err != nil { - logger.Warn("persist reattached branch head", - "err", err.Error()) - } - if _, ok, _ := state.MetaGet(ctx, opts.DB, MetaKeyDetachedHeadPaused); ok { - _, _ = state.MetaDelete(ctx, opts.DB, MetaKeyDetachedHeadPaused) - } - // Reattach is an explicit operator transition. Like the - // operation-cleared path above, a stale rewind-grace marker - // from before the detach must NOT survive the reattach — - // otherwise capture/replay stay muted up to - // ACD_REWIND_GRACE_SECONDS post-reattach. - clearRewindGraceMeta(ctx, opts.DB, opts.RepoPath, cctx, tracer, logger, - "detached HEAD reattached") - if headOID != "" { - if seeded, err := BootstrapShadow(ctx, opts.RepoPath, opts.DB, cctx); err != nil { - logger.Warn("bootstrap shadow after reattach", - "err", err.Error()) - traceBootstrapShadow(tracer, opts.RepoPath, cctx, "error", err.Error(), 0) - } else { - traceBootstrapShadow(tracer, opts.RepoPath, cctx, traceSeedDecision(seeded), "reattach shadow bootstrap", seeded) - if seeded > 0 { - logger.Info("shadow bootstrapped after reattach", - "rows", seeded) - } - if pruned, pErr := pruneShadowGenerations(ctx, opts.DB, cctx); pErr != nil { - logger.Warn("prune old shadow generations", "err", pErr.Error()) - } else if pruned > 0 { - logger.Info("pruned old shadow generations", "rows", pruned) - } - } - } - } - } if processBranchTokenChange("branch token") { branchTransitionBlocked = true } @@ -1546,7 +1762,7 @@ func Run(ctx context.Context, opts Options) error { } select { case <-shutdownCh: - return graceful("signal shutdown") + return gracefulWithSweep("signal shutdown") default: } fr, ok, err := state.ClaimNextFlushRequest(ctx, opts.DB) @@ -1718,7 +1934,11 @@ func Run(ctx context.Context, opts Options) error { IntentRecentCommits: providerCfg.IntentRecentCommits, IntentDeferLimit: providerCfg.IntentDeferLimit, IntentBypassBatchWait: flushedLogical > 0, - IntentPlanner: opts.IntentPlanner, + IntentPlanner: runIntentPlanner, + IntentHealth: intentHealth, + IntentPlannerProvider: intentPlannerProvider, + IntentPlannerModel: intentPlannerModel, + IntentIncludeDiffs: intentIncludeDiffs, }) if repErr == nil && repSum.Published > 0 { // Refresh BaseHead to the exact commit replay just wrote. @@ -1748,11 +1968,11 @@ func Run(ctx context.Context, opts Options) error { _ = state.MetaSet(ctx, opts.DB, "last_capture_error", "") } - // repSum.HasMore is true when the bounded replay pass returned with - // pending work still visible beyond DefaultReplayLimit. Treat it as - // work so the scheduler resets to the base interval and the next - // iteration drains the remainder without waiting for the idle ceiling. - hadWork := flushedTotal > 0 || capSum.EventsAppended > 0 || repSum.Published > 0 || repSum.HasMore + // A recovered active chain invalidates shadow and needs another pass to + // reseed + recapture, just as a bounded replay needs another pass to + // drain HasMore. Treat both as work so idle backoff cannot delay + // convergence. + hadWork := flushedTotal > 0 || capSum.EventsAppended > 0 || replayNeedsImmediateFollowup(repSum) // Heartbeat refresh — visible to controllers between iterations. heartbeatNow("running", "") @@ -1781,13 +2001,13 @@ func Run(ctx context.Context, opts Options) error { BootGrace: bootGrace, EmptySweepThreshold: emptyThreshold, }) { - return graceful(fmt.Sprintf("no live clients for %d sweeps", emptyCount)) + return gracefulWithSweep(fmt.Sprintf("no live clients for %d sweeps", emptyCount)) } } // 4j. Prune capture_events opportunistically. if nowTS.Sub(lastPrune) >= pruneEvery { - if n, pErr := PruneCaptureEvents(ctx, opts.DB, nowTS, eventRetention); pErr != nil { + if n, pErr := PruneCaptureEvents(ctx, opts.RepoPath, opts.DB, nowTS, eventRetention); pErr != nil { logger.Warn("prune events", "err", pErr.Error()) } else if n > 0 { logger.Info("pruned events", "rows", n) @@ -1851,10 +2071,10 @@ func Run(ctx context.Context, opts Options) error { select { case <-ctx.Done(): timer.Stop() - return graceful("context canceled") + return gracefulWithSweep("context canceled") case <-shutdownCh: timer.Stop() - return graceful("signal shutdown") + return gracefulWithSweep("signal shutdown") case <-wakeCh: timer.Stop() currentDelay = opts.Scheduler.Reset() @@ -1869,6 +2089,10 @@ func Run(ctx context.Context, opts Options) error { } } +func replayNeedsImmediateFollowup(sum ReplaySummary) bool { + return sum.Published > 0 || sum.HasMore || sum.RecaptureRequired +} + // resolveBranch returns (branchRef, headOID) for the current HEAD. A detached // HEAD returns an empty branchRef so the run loop pauses capture/replay instead // of inventing a branch target. diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 1bf9b91d..7ec95081 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -25,7 +25,17 @@ import ( func TestMain(m *testing.M) { _ = os.Setenv(ai.EnvProvider, "deterministic") - os.Exit(m.Run()) + templateRoot, err := setupDaemonTestRepoTemplates() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "daemon test template setup: %v\n", err) + os.Exit(1) + } + code := m.Run() + if err := os.RemoveAll(templateRoot); err != nil && code == 0 { + _, _ = fmt.Fprintf(os.Stderr, "daemon test template cleanup: %v\n", err) + code = 1 + } + os.Exit(code) } // daemonFixture wires up a temp git repo + open per-repo state DB so the @@ -39,54 +49,8 @@ type daemonFixture struct { func newDaemonFixture(t *testing.T) *daemonFixture { t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - dir, err := os.MkdirTemp("", "acd-daemon-test-*") - if err != nil { - t.Fatalf("mkdir temp dir: %v", err) - } - t.Cleanup(func() { removeAllWithRetry(t, dir) }) - if err := git.Init(ctx, dir); err != nil { - t.Fatalf("git init: %v", err) - } - // Force HEAD onto refs/heads/main regardless of host's init.defaultBranch - // (CI runners default to master; daemon Options pin BranchRef to main). - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "symbolic-ref", "HEAD", "refs/heads/main"); err != nil { - t.Fatalf("symbolic-ref HEAD: %v", err) - } - for _, kv := range [][]string{ - {"user.email", "acd-test@example.com"}, - {"user.name", "ACD Test"}, - {"commit.gpgsign", "false"}, - } { - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "config", kv[0], kv[1]); err != nil { - t.Fatalf("git config %s: %v", kv[0], err) - } - } - // Initial commit so HEAD resolves. - seed := filepath.Join(dir, ".gitignore") - if err := os.WriteFile(seed, []byte("# acd test seed\n"), 0o644); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "add", ".gitignore"); err != nil { - t.Fatalf("git add: %v", err) - } - if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "commit", "-q", "-m", "seed"); err != nil { - t.Fatalf("git commit: %v", err) - } - - gitDir, err := git.AbsoluteGitDir(ctx, dir) - if err != nil { - t.Fatalf("AbsoluteGitDir: %v", err) - } - dbPath := state.DBPathFromGitDir(gitDir) - db, err := state.Open(ctx, dbPath) - if err != nil { - t.Fatalf("state.Open: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - return &daemonFixture{dir: dir, gitDir: gitDir, db: db} + repo := cloneDaemonTestRepo(t, daemonRepoTemplate) + return &daemonFixture{dir: repo.dir, gitDir: repo.gitDir, db: repo.db} } func removeAllWithRetry(t *testing.T, path string) { @@ -111,6 +75,32 @@ func fastScheduler() Scheduler { } } +func oneShotBranchTokenCheckGate() (hook func(), entered <-chan struct{}, release func()) { + enteredCh := make(chan struct{}) + releaseCh := make(chan struct{}) + var enterOnce sync.Once + var releaseOnce sync.Once + hook = func() { + enterOnce.Do(func() { + close(enteredCh) + <-releaseCh + }) + } + release = func() { + releaseOnce.Do(func() { close(releaseCh) }) + } + return hook, enteredCh, release +} + +func waitForBranchTokenCheckGate(t *testing.T, entered <-chan struct{}) { + t.Helper() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("daemon did not reach branch-token check gate") + } +} + // registerLiveClient inserts a daemon_clients row keyed to the test process // itself so SweepClients sees alive>0 and the run loop does not // self-terminate during the happy-path test. @@ -529,6 +519,8 @@ func TestRun_DetachedHeadPausesCaptureReplay(t *testing.T) { } func TestRun_PauseDuringGitOperation(t *testing.T) { + t.Parallel() + tests := []struct { marker string name string @@ -543,6 +535,8 @@ func TestRun_PauseDuringGitOperation(t *testing.T) { for _, tc := range tests { t.Run(tc.marker, func(t *testing.T) { + runBoundedParallel(t) + f := newDaemonFixture(t) registerLiveClient(t, f.db) ctx := context.Background() @@ -1166,6 +1160,56 @@ func TestRun_FlockContention(t *testing.T) { } } +func TestRun_FlockContentionDoesNotResetIntentPlannerHealth(t *testing.T) { + t.Setenv(ai.EnvCommitStrategy, string(ai.CommitStrategyIntent)) + f := newDaemonFixture(t) + seed := intentPlannerHealthRecord{ + Version: intentPlannerHealthVersion, + IntentPlannerHealthSnapshot: IntentPlannerHealthSnapshot{ + State: IntentPlannerCircuitOpen, + ProviderFingerprint: IntentPlannerProviderFingerprint(openAIIntentHealthIdentity("https://previous.example/v1")), + ConsecutiveFailures: 1, + BackoffLevel: 0, + LastFailureClass: IntentPlannerFailureTransport, + LastError: "previous provider unavailable", + }, + } + if err := state.MetaSetJSON(context.Background(), f.db, MetaKeyIntentPlannerHealth, seed); err != nil { + t.Fatalf("seed intent planner health: %v", err) + } + before, ok, err := state.MetaGet(context.Background(), f.db, MetaKeyIntentPlannerHealth) + if err != nil || !ok { + t.Fatalf("read seeded intent planner health: ok=%v err=%v", ok, err) + } + + lock, err := AcquireDaemonLock(f.gitDir) + if err != nil { + t.Fatalf("AcquireDaemonLock: %v", err) + } + defer func() { _ = lock.Release() }() + + err = Run(context.Background(), Options{ + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + MessageFn: func(context.Context, EventContext) (string, error) { + return "unused", nil + }, + IntentPlanner: &recordingIntentPlanner{name: "openai-compat"}, + SkipSignals: true, + }) + if !errors.Is(err, ErrDaemonLockHeld) { + t.Fatalf("Run returned %v want ErrDaemonLockHeld", err) + } + after, ok, err := state.MetaGet(context.Background(), f.db, MetaKeyIntentPlannerHealth) + if err != nil || !ok { + t.Fatalf("read intent planner health after contention: ok=%v err=%v", ok, err) + } + if after != before { + t.Fatalf("intent planner health changed under lock contention\nbefore=%s\nafter=%s", before, after) + } +} + // TestRun_RealSIGUSR1: covers the real-OS signal path. Sends SIGUSR1 to the // current process and asserts the loop wakes and produces a commit. Skipped // on Windows (which we don't target anyway). @@ -1233,7 +1277,7 @@ func TestPruneCaptureEvents_DropsOldPublished(t *testing.T) { // Insert one old published row and one fresh pending row. old, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ BranchRef: "refs/heads/main", BranchGeneration: 1, - BaseHead: "deadbeef", Operation: "create", Path: "old.txt", + BaseHead: "old-base", Operation: "create", Path: "old.txt", Fidelity: "full", CapturedTS: 1, State: "published", }, []state.CaptureOp{{ @@ -1246,7 +1290,7 @@ func TestPruneCaptureEvents_DropsOldPublished(t *testing.T) { } if _, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ BranchRef: "refs/heads/main", BranchGeneration: 1, - BaseHead: "deadbeef", Operation: "create", Path: "fresh.txt", + BaseHead: "fresh-base", Operation: "create", Path: "fresh.txt", Fidelity: "full", // captured_ts default = now() State: "pending", @@ -1258,7 +1302,7 @@ func TestPruneCaptureEvents_DropsOldPublished(t *testing.T) { t.Fatalf("insert fresh: %v", err) } - n, err := PruneCaptureEvents(ctx, f.db, time.Now(), 1*time.Second) + n, err := PruneCaptureEvents(ctx, f.dir, f.db, time.Now(), 1*time.Second) if err != nil { t.Fatalf("PruneCaptureEvents: %v", err) } @@ -1287,7 +1331,7 @@ func TestPruneCaptureEvents_DropsOldPublished(t *testing.T) { } } -func TestPruneCaptureEvents_DropsOldTerminalRowsWhenNotBarriers(t *testing.T) { +func TestPruneCaptureEvents_PreservesUnprotectedTerminalRows(t *testing.T) { f := newDaemonFixture(t) ctx := context.Background() @@ -1320,12 +1364,12 @@ func TestPruneCaptureEvents_DropsOldTerminalRowsWhenNotBarriers(t *testing.T) { freshTS := float64(time.Now().Add(time.Hour).UnixNano()) / 1e9 freshFailed := appendEvent("fresh-failed.txt", "refs/heads/fresh", state.EventStateFailed, freshTS) - n, err := PruneCaptureEvents(ctx, f.db, time.Now(), 1*time.Second) + n, err := PruneCaptureEvents(ctx, f.dir, f.db, time.Now(), 1*time.Second) if err != nil { t.Fatalf("PruneCaptureEvents: %v", err) } - if n != 2 { - t.Fatalf("pruned=%d want 2", n) + if n != 0 { + t.Fatalf("pruned=%d want 0 without durable recovery refs", n) } rows, err := f.db.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) @@ -1344,16 +1388,147 @@ func TestPruneCaptureEvents_DropsOldTerminalRowsWhenNotBarriers(t *testing.T) { if err := rows.Err(); err != nil { t.Fatalf("iterate remaining: %v", err) } - if remaining[oldBlocked] || remaining[oldFailed] { - t.Fatalf("old terminal rows survived: remaining=%v", remaining) - } - for _, seq := range []int64{barrier, pendingBehindBarrier, freshFailed} { + for _, seq := range []int64{oldBlocked, oldFailed, barrier, pendingBehindBarrier, freshFailed} { if !remaining[seq] { t.Fatalf("seq %d should remain; remaining=%v", seq, remaining) } } } +func TestPruneCaptureEvents_VerifiesSnapshotGitRefsForEveryOutcome(t *testing.T) { + f := newDaemonFixture(t) + ctx := context.Background() + head, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + type snapshotCase struct { + name string + state string + ref string + commitOID string + createRef bool + wantRemain bool + } + cases := []snapshotCase{ + {name: "valid-recovered", state: state.EventStateRecovered, ref: "refs/acd/recovery/prune-valid-recovered", commitOID: head, createRef: true}, + {name: "missing-recovered", state: state.EventStateRecovered, ref: "refs/acd/recovery/prune-missing-recovered", commitOID: head, wantRemain: true}, + {name: "corrupt-recovered", state: state.EventStateRecovered, ref: "refs/acd/recovery/prune-corrupt-recovered", commitOID: "deadbeef", createRef: true, wantRemain: true}, + {name: "valid-published", state: state.EventStatePublished, ref: "refs/acd/recovery/prune-valid-published", commitOID: head, createRef: true}, + {name: "missing-published", state: state.EventStatePublished, ref: "refs/acd/recovery/prune-missing-published", commitOID: head, wantRemain: true}, + {name: "corrupt-published", state: state.EventStatePublished, ref: "refs/acd/recovery/prune-corrupt-published", commitOID: "deadbeef", createRef: true, wantRemain: true}, + } + seqs := make(map[string]int64, len(cases)) + for _, tc := range cases { + seq, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: tc.name + ".txt", Fidelity: "full", + CapturedTS: 1, State: tc.state, + }, []state.CaptureOp{{Op: "create", Path: tc.name + ".txt", Fidelity: "full"}}) + if err != nil { + t.Fatalf("append %s: %v", tc.name, err) + } + seqs[tc.name] = seq + res, err := f.db.SQL().ExecContext(ctx, ` +INSERT INTO recovery_snapshots( + created_ts, outcome, branch_ref, branch_generation, + first_event_seq, last_event_seq, event_count, + commit_oid, recovery_ref, reason +) VALUES (2, ?, 'refs/heads/main', 1, ?, ?, 1, ?, ?, 'prune test')`, + tc.state, seq, seq, tc.commitOID, tc.ref) + if err != nil { + t.Fatalf("insert %s snapshot: %v", tc.name, err) + } + snapshotID, _ := res.LastInsertId() + if _, err := f.db.SQL().ExecContext(ctx, + `INSERT INTO recovery_snapshot_events(snapshot_id, ord, event_seq) VALUES (?, 0, ?)`, + snapshotID, seq); err != nil { + t.Fatalf("insert %s membership: %v", tc.name, err) + } + if tc.createRef { + if err := git.UpdateRef(ctx, f.dir, tc.ref, head, ""); err != nil { + t.Fatalf("create %s ref: %v", tc.name, err) + } + } + } + + n, err := PruneCaptureEvents(ctx, f.dir, f.db, time.Now(), time.Second) + if err != nil { + t.Fatalf("PruneCaptureEvents: %v", err) + } + if n != 2 { + t.Fatalf("pruned=%d want valid recovered and published rows", n) + } + for _, tc := range cases { + var count int + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seqs[tc.name]).Scan(&count); err != nil { + t.Fatalf("count %s: %v", tc.name, err) + } + want := 0 + if tc.wantRemain { + want = 1 + } + if count != want { + t.Fatalf("%s event count=%d want %d", tc.name, count, want) + } + } + var validMembership int + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM recovery_snapshot_events WHERE event_seq = ?`, seqs["valid-recovered"]).Scan(&validMembership); err != nil { + t.Fatalf("count valid membership: %v", err) + } + if validMembership != 1 { + t.Fatalf("valid snapshot membership=%d want 1", validMembership) + } +} + +func TestPruneCaptureEvents_ReportsUnexpectedGitErrors(t *testing.T) { + f := newDaemonFixture(t) + ctx := context.Background() + head, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + seq, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + Operation: "create", Path: "retained-on-git-error.txt", Fidelity: "full", + CapturedTS: 1, State: state.EventStateRecovered, + }, []state.CaptureOp{{Op: "create", Path: "retained-on-git-error.txt", Fidelity: "full"}}) + if err != nil { + t.Fatalf("append recovered event: %v", err) + } + res, err := f.db.SQL().ExecContext(ctx, ` +INSERT INTO recovery_snapshots( + created_ts, outcome, branch_ref, branch_generation, + first_event_seq, last_event_seq, event_count, + commit_oid, recovery_ref, reason +) VALUES (2, 'recovered', 'refs/heads/main', 1, ?, ?, 1, ?, + 'refs/acd/recovery/git-error', 'git error test')`, seq, seq, head) + if err != nil { + t.Fatalf("insert recovery snapshot: %v", err) + } + snapshotID, _ := res.LastInsertId() + if _, err := f.db.SQL().ExecContext(ctx, + `INSERT INTO recovery_snapshot_events(snapshot_id, ord, event_seq) VALUES (?, 0, ?)`, + snapshotID, seq); err != nil { + t.Fatalf("insert recovery membership: %v", err) + } + + _, err = PruneCaptureEvents(ctx, filepath.Join(f.dir, "not-a-repository"), f.db, time.Now(), time.Second) + if err == nil || !strings.Contains(err.Error(), "verify protected snapshot") { + t.Fatalf("PruneCaptureEvents err=%v want surfaced Git failure", err) + } + var count int + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seq).Scan(&count); err != nil { + t.Fatalf("count recovered event: %v", err) + } + if count != 1 { + t.Fatalf("recovered event count=%d want 1 after Git failure", count) + } +} + // TestRun_RollupHookAdvancesLastDay: the daemon loop's daily rollup hook // (§8.10) fires once per RollupInterval, attributes a synthetic event to a // completed UTC day, and advances rollup.last_day. This test confirms the @@ -1477,6 +1652,11 @@ func (c *hangingCloser) Close() error { // closeProviderOnce called Close synchronously with no deadline, so a // subprocess plugin that hangs on Close would wedge the daemon. func TestRun_ShutdownCompletesWithin5sUnderHungProvider(t *testing.T) { + runBoundedParallel(t) + + if providerCloseTimeout != 5*time.Second { + t.Fatalf("providerCloseTimeout=%v want 5s", providerCloseTimeout) + } f := newDaemonFixture(t) registerLiveClient(t, f.db) @@ -1503,6 +1683,7 @@ func TestRun_ShutdownCompletesWithin5sUnderHungProvider(t *testing.T) { SkipSignals: true, MessageProvider: stub, MessageProviderCloser: closer, + providerCloseTimeout: 100 * time.Millisecond, }) }() @@ -1514,14 +1695,14 @@ func TestRun_ShutdownCompletesWithin5sUnderHungProvider(t *testing.T) { select { case <-runDone: elapsed := time.Since(start) - // 5s closer budget + slack for run-loop teardown (signals, db, - // trace writer, fs watcher). 8s is well under the wedge bound. - if elapsed > 8*time.Second { + // The production timeout is asserted above; use a shorter injected + // budget here so the same timeout path stays fast under -race. + if elapsed > 2*time.Second { t.Fatalf("Run shutdown took %v with hung provider closer; want <= %v", - elapsed, 8*time.Second) + elapsed, 2*time.Second) } - case <-time.After(15 * time.Second): - t.Fatalf("Run did not exit on cancel within 15s — hung provider regressed shutdown bound") + case <-time.After(5 * time.Second): + t.Fatalf("Run did not exit on cancel within 5s — hung provider regressed shutdown bound") } if got := closer.calls.Load(); got != 1 { @@ -1902,23 +2083,27 @@ func TestRun_ExternalFastForwardReseedsShadowWithoutCapturingUpstream(t *testing var wg sync.WaitGroup var runErr error + checkHook, checkEntered, releaseCheck := oneShotBranchTokenCheckGate() + defer releaseCheck() wg.Add(1) go func() { defer wg.Done() runErr = Run(runCtx, Options{ - RepoPath: f.dir, - GitDir: f.gitDir, - DB: f.db, - Scheduler: fastScheduler(), - BootGrace: 30 * time.Second, - WakeCh: wakeCh, - ShutdownCh: shutdownCh, - SkipSignals: true, - MessageFn: DeterministicMessage, + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + Scheduler: fastScheduler(), + BootGrace: 30 * time.Second, + WakeCh: wakeCh, + ShutdownCh: shutdownCh, + SkipSignals: true, + MessageFn: DeterministicMessage, + beforeBranchTokenCheck: checkHook, }) }() waitForMetaValue(t, f.db, MetaKeyBranchHead, seedHead, 3*time.Second) + waitForBranchTokenCheckGate(t, checkEntered) upstreamBody := []byte("from upstream\n") upstreamBlob, err := git.HashObjectStdin(ctx, f.dir, upstreamBody) @@ -1952,6 +2137,7 @@ func TestRun_ExternalFastForwardReseedsShadowWithoutCapturingUpstream(t *testing if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "checkout", "-q", upstreamHead, "--", "upstream.txt"); err != nil { t.Fatalf("checkout upstream worktree: %v", err) } + releaseCheck() for i := 0; i < 4; i++ { select { case wakeCh <- struct{}{}: @@ -1993,77 +2179,208 @@ func TestRun_ExternalFastForwardReseedsShadowWithoutCapturingUpstream(t *testing } } -func TestRun_BranchSwitchDropsPending(t *testing.T) { +func TestRun_FastForwardRollbackInvalidatesProspectiveShadow(t *testing.T) { f := newDaemonFixture(t) + registerLiveClient(t, f.db) ctx := context.Background() - baseHead, err := git.RevParse(ctx, f.dir, "HEAD") + seedHead, err := git.RevParse(ctx, f.dir, "HEAD") if err != nil { - t.Fatalf("rev-parse: %v", err) + t.Fatalf("rev-parse seed: %v", err) + } + upstreamBlob, err := git.HashObjectStdin(ctx, f.dir, []byte("upstream\n")) + if err != nil { + t.Fatalf("hash upstream: %v", err) + } + seedEntries, err := git.LsTree(ctx, f.dir, seedHead, false) + if err != nil { + t.Fatalf("ls-tree seed: %v", err) + } + entries := make([]git.MktreeEntry, 0, len(seedEntries)+1) + for _, entry := range seedEntries { + entries = append(entries, git.MktreeEntry{Mode: entry.Mode, Type: entry.Type, OID: entry.OID, Path: entry.Path}) + } + entries = append(entries, git.MktreeEntry{Mode: git.RegularFileMode, Type: "blob", OID: upstreamBlob, Path: "upstream.txt"}) + upstreamTree, err := git.Mktree(ctx, f.dir, entries) + if err != nil { + t.Fatalf("mktree upstream: %v", err) + } + upstreamHead, err := git.CommitTree(ctx, f.dir, upstreamTree, "upstream", seedHead) + if err != nil { + t.Fatalf("commit-tree upstream: %v", err) } - appendEvent := func(path string, generation int64, stateName string) int64 { - t.Helper() - seq, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ - BranchRef: "refs/heads/main", - BranchGeneration: generation, - BaseHead: baseHead, - Operation: "create", - Path: path, - Fidelity: "full", - State: stateName, - }, []state.CaptureOp{{ - Op: "create", - Path: path, - Fidelity: "full", - AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, - }}) + wakeCh := make(chan struct{}, 4) + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + hookDone := make(chan error, 1) + rollbackDone := make(chan struct{}, 1) + var hookOnce sync.Once + var rollbackOnce sync.Once + trace := &memoryTraceLogger{} + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = Run(runCtx, Options{ + RepoPath: f.dir, GitDir: f.gitDir, DB: f.db, + Scheduler: fastScheduler(), BootGrace: 30 * time.Second, + WakeCh: wakeCh, ShutdownCh: make(chan struct{}), SkipSignals: true, + Trace: trace, + beforeBranchTransitionAccept: func() { + hookOnce.Do(func() { + if err := git.UpdateRef(ctx, f.dir, "refs/heads/main", seedHead, upstreamHead); err != nil { + hookDone <- err + return + } + _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "reset", "--hard", seedHead) + hookDone <- err + }) + }, + afterBranchTransitionRollback: func() { + rollbackOnce.Do(func() { rollbackDone <- struct{}{} }) + }, + }) + }() + t.Cleanup(func() { cancel(); wg.Wait() }) + wantToken := branchTokenRev(seedHead, "refs/heads/main") + waitForMetaValue(t, f.db, MetaKeyBranchToken, wantToken, 5*time.Second) + if err := git.UpdateRef(ctx, f.dir, "refs/heads/main", upstreamHead, seedHead); err != nil { + t.Fatalf("fast-forward main: %v", err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "reset", "--hard", upstreamHead); err != nil { + t.Fatalf("reset worktree upstream: %v", err) + } + wakeCh <- struct{}{} + select { + case err := <-hookDone: if err != nil { - t.Fatalf("append %s: %v", path, err) + t.Fatalf("rollback hook: %v", err) } - return seq + case <-time.After(5 * time.Second): + t.Fatalf("branch transition hook did not run") } + select { + case <-rollbackDone: + case <-time.After(10 * time.Second): + t.Fatalf("branch transition rollback did not finish") + } + transitionEvents := traceEventsByClass(trace.Events(), "branch_token.transition") + rolledBack := 0 + for _, event := range transitionEvents { + switch event.Decision { + case "rolled_back": + rolledBack++ + case TokenTransitionFastForward.String(), TokenTransitionDiverged.String(): + t.Fatalf("trace reported unaccepted transition as successful: %+v", event) + } + } + if rolledBack != 1 { + t.Fatalf("rolled_back traces=%d want 1; events=%+v", rolledBack, transitionEvents) + } + wakeCh <- struct{}{} + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + select { + case wakeCh <- struct{}{}: + default: + } + bootstrapped, _ := IsShadowBootstrapped(ctx, f.db, "refs/heads/main", 1) + var upstreamRows int + _ = f.db.SQL().QueryRowContext(ctx, ` +SELECT COUNT(*) FROM shadow_paths +WHERE branch_ref = 'refs/heads/main' AND branch_generation = 1 AND path = 'upstream.txt'`).Scan(&upstreamRows) + if bootstrapped && upstreamRows == 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if bootstrapped, err := IsShadowBootstrapped(ctx, f.db, "refs/heads/main", 1); err != nil || !bootstrapped { + t.Fatalf("old shadow not re-established: bootstrapped=%v err=%v", bootstrapped, err) + } + var upstreamRows int + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT COUNT(*) FROM shadow_paths +WHERE branch_ref = 'refs/heads/main' AND branch_generation = 1 AND path = 'upstream.txt'`).Scan(&upstreamRows); err != nil { + t.Fatalf("count upstream shadow: %v", err) + } + if upstreamRows != 0 { + t.Fatalf("prospective upstream shadow rows=%d want 0 after rollback", upstreamRows) + } +} - prevPending := appendEvent("prev-pending.txt", 1, state.EventStatePending) - prevBlocked := appendEvent("prev-blocked.txt", 1, state.EventStateBlockedConflict) - prevPublished := appendEvent("prev-published.txt", 1, state.EventStatePublished) - nextPending := appendEvent("next-pending.txt", 2, state.EventStatePending) - - dropped, err := state.DeletePendingForGeneration(ctx, f.db, 1) +func TestRun_BranchRollbackPreservesOldShadowAtZeroRetention(t *testing.T) { + t.Setenv(EnvShadowRetentionGenerations, "0") + f := newDaemonFixture(t) + registerLiveClient(t, f.db) + ctx := context.Background() + seedHead, err := git.RevParse(ctx, f.dir, "HEAD") if err != nil { - t.Fatalf("DeletePendingForGeneration: %v", err) + t.Fatalf("rev-parse seed: %v", err) } - if dropped != 1 { - t.Fatalf("dropped=%d want 1", dropped) + if err := git.UpdateRef(ctx, f.dir, "refs/heads/feature", seedHead, ""); err != nil { + t.Fatalf("create feature: %v", err) } - rows, err := f.db.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) - if err != nil { - t.Fatalf("query events: %v", err) + wakeCh := make(chan struct{}, 4) + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + hookDone := make(chan error, 1) + var hookOnce sync.Once + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = Run(runCtx, Options{ + RepoPath: f.dir, GitDir: f.gitDir, DB: f.db, + Scheduler: fastScheduler(), BootGrace: 30 * time.Second, + WakeCh: wakeCh, ShutdownCh: make(chan struct{}), SkipSignals: true, + beforeBranchTransitionAccept: func() { + hookOnce.Do(func() { + _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", "refs/heads/main") + hookDone <- err + }) + }, + }) + }() + t.Cleanup(func() { cancel(); wg.Wait() }) + waitForMetaValue(t, f.db, MetaKeyBranchToken, + branchTokenRev(seedHead, "refs/heads/main"), 5*time.Second) + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", "refs/heads/feature"); err != nil { + t.Fatalf("switch symbolic ref: %v", err) } - defer rows.Close() - remaining := map[int64]bool{} - for rows.Next() { - var seq int64 - if err := rows.Scan(&seq); err != nil { - t.Fatalf("scan: %v", err) + wakeCh <- struct{}{} + select { + case err := <-hookDone: + if err != nil { + t.Fatalf("rollback hook: %v", err) } - remaining[seq] = true + case <-time.After(5 * time.Second): + t.Fatalf("branch transition hook did not run") } - if err := rows.Err(); err != nil { - t.Fatalf("rows: %v", err) + wakeCh <- struct{}{} + time.Sleep(300 * time.Millisecond) + gen, _, _ := state.MetaGet(ctx, f.db, MetaKeyBranchGeneration) + if gen != "1" { + t.Fatalf("branch generation=%q want 1 after rollback", gen) } - if remaining[prevPending] { - t.Fatalf("previous generation pending seq %d was not deleted", prevPending) + if bootstrapped, err := IsShadowBootstrapped(ctx, f.db, "refs/heads/main", 1); err != nil || !bootstrapped { + t.Fatalf("old shadow marker lost: bootstrapped=%v err=%v", bootstrapped, err) } - for _, seq := range []int64{prevBlocked, prevPublished, nextPending} { - if !remaining[seq] { - t.Fatalf("seq %d should be retained; remaining=%v", seq, remaining) - } + var oldRows, prospectiveRows int + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM shadow_paths WHERE branch_ref = 'refs/heads/main' AND branch_generation = 1`).Scan(&oldRows); err != nil { + t.Fatalf("count old shadow: %v", err) + } + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM shadow_paths WHERE branch_ref = 'refs/heads/feature' AND branch_generation = 2`).Scan(&prospectiveRows); err != nil { + t.Fatalf("count prospective shadow: %v", err) + } + if oldRows == 0 || prospectiveRows != 0 { + t.Fatalf("shadow rows old=%d prospective=%d", oldRows, prospectiveRows) } } -// TestRun_RuntimeDivergedPrunesDeadBranchTerminals exercises the runtime +// TestRun_RuntimeDivergedRecoversDeadBranchTerminals exercises the runtime // Diverged-hook end-to-end: the daemon boots on refs/heads/feat-x (created // + checked out before the run loop starts), accumulates a blocked_conflict // + pending capture_events row tied to that ref, the worktree is then @@ -2072,7 +2389,7 @@ func TestRun_BranchSwitchDropsPending(t *testing.T) { // prunes the dead-branch rows. This is the regression-against-"P2 #7" // coverage gap (the prior dead_branch_sweep_test.go test invoked the helper // directly rather than driving the run loop into the runtime Diverged path). -func TestRun_RuntimeDivergedPrunesDeadBranchTerminals(t *testing.T) { +func TestRun_RuntimeDivergedRecoversDeadBranchTerminals(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") f := newDaemonFixture(t) registerLiveClient(t, f.db) @@ -2104,18 +2421,21 @@ func TestRun_RuntimeDivergedPrunesDeadBranchTerminals(t *testing.T) { defer cancel() var wg sync.WaitGroup + checkHook, checkEntered, releaseCheck := oneShotBranchTokenCheckGate() + defer releaseCheck() wg.Add(1) go func() { defer wg.Done() _ = Run(runCtx, Options{ - RepoPath: f.dir, - GitDir: f.gitDir, - DB: f.db, - Scheduler: fastScheduler(), - BootGrace: 30 * time.Second, - WakeCh: wakeCh, - ShutdownCh: shutdownCh, - SkipSignals: true, + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + Scheduler: fastScheduler(), + BootGrace: 30 * time.Second, + WakeCh: wakeCh, + ShutdownCh: shutdownCh, + SkipSignals: true, + beforeBranchTokenCheck: checkHook, }) }() @@ -2124,6 +2444,7 @@ func TestRun_RuntimeDivergedPrunesDeadBranchTerminals(t *testing.T) { // is the closure variable observable through MetaKeyBranchToken meta. want := "rev:" + seedHead + " refs/heads/feat-x" waitForMetaValue(t, f.db, MetaKeyBranchToken, want, 5*time.Second) + waitForBranchTokenCheckGate(t, checkEntered) // Now switch the worktree back to refs/heads/main and delete feat-x. // The daemon's next tick classifies the change as Diverged (ref change @@ -2134,6 +2455,7 @@ func TestRun_RuntimeDivergedPrunesDeadBranchTerminals(t *testing.T) { if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "update-ref", "-d", "refs/heads/feat-x"); err != nil { t.Fatalf("delete refs/heads/feat-x: %v", err) } + releaseCheck() for i := 0; i < 4; i++ { select { case wakeCh <- struct{}{}: @@ -2163,17 +2485,18 @@ func TestRun_RuntimeDivergedPrunesDeadBranchTerminals(t *testing.T) { t.Fatalf("runtime Diverged hook did not stamp %s within 5s", MetaKeyDeadBranchPruneLastRunTS) } - // All capture_events rows for the dead ref must be gone — both the - // blocked_conflict and the pending — proving pending+terminal pruning - // (not just terminals) ran in the runtime path. + // Both rows remain as recovered provenance under a durable recovery ref. var total int if err := f.db.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events WHERE branch_ref = ?`, "refs/heads/feat-x", ).Scan(&total); err != nil { t.Fatalf("count feat-x rows: %v", err) } - if total != 0 { - t.Fatalf("feat-x rows=%d want 0 after runtime Diverged hook", total) + if total != 2 { + t.Fatalf("feat-x rows=%d want 2 retained after runtime recovery", total) + } + if recovered := countEventsByRefState(t, f.db, "refs/heads/feat-x", state.EventStateRecovered); recovered != 2 { + t.Fatalf("feat-x recovered rows=%d want 2", recovered) } cancel() @@ -2204,7 +2527,11 @@ func TestRun_StartupDivergenceBumpsGenerationAndReseedsShadow(t *testing.T) { } else if seeded == 0 { t.Fatalf("BootstrapShadow old generation seeded 0 rows") } - if _, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ + staleOID, err := git.HashObjectStdin(ctx, f.dir, []byte("stale pending\n")) + if err != nil { + t.Fatalf("hash stale pending: %v", err) + } + staleSeq, err := state.AppendCaptureEvent(ctx, f.db, state.CaptureEvent{ BranchRef: oldCtx.BranchRef, BranchGeneration: oldCtx.BranchGeneration, BaseHead: oldCtx.BaseHead, @@ -2216,8 +2543,9 @@ func TestRun_StartupDivergenceBumpsGenerationAndReseedsShadow(t *testing.T) { Path: "stale-pending.txt", Fidelity: "full", AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, - }}); err != nil { + AfterOID: sql.NullString{String: staleOID, Valid: true}, + }}) + if err != nil { t.Fatalf("AppendCaptureEvent stale pending: %v", err) } @@ -2259,6 +2587,10 @@ func TestRun_StartupDivergenceBumpsGenerationAndReseedsShadow(t *testing.T) { SkipSignals: true, }) }() + t.Cleanup(func() { + cancel() + wg.Wait() + }) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { @@ -2312,12 +2644,42 @@ func TestRun_StartupDivergenceBumpsGenerationAndReseedsShadow(t *testing.T) { t.Fatalf("old shadow generation rows=%d want 0", oldShadowRows) } time.Sleep(100 * time.Millisecond) - var events int - if err := f.db.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events`).Scan(&events); err != nil { - t.Fatalf("count capture events: %v", err) + var staleState string + var staleCommit sql.NullString + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT state, commit_oid FROM capture_events WHERE seq = ?`, staleSeq, + ).Scan(&staleState, &staleCommit); err != nil { + t.Fatalf("query preserved stale capture: %v", err) } - if events != 0 { - t.Fatalf("startup after offline reset captured %d phantom events, want 0", events) + if staleState != state.EventStateRecovered || !staleCommit.Valid || staleCommit.String == "" { + t.Fatalf("stale capture state=%q commit=%v, want durable recovered provenance", staleState, staleCommit) + } + var recoveryRef, snapshotCommit string + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT rs.recovery_ref, rs.commit_oid +FROM recovery_snapshots rs +JOIN recovery_snapshot_events rse ON rse.snapshot_id = rs.id +WHERE rse.event_seq = ?`, staleSeq).Scan(&recoveryRef, &snapshotCommit); err != nil { + t.Fatalf("query stale capture recovery snapshot: %v", err) + } + if snapshotCommit != staleCommit.String { + t.Fatalf("snapshot commit=%s want recovered commit=%s", snapshotCommit, staleCommit.String) + } + resolvedRecovery, err := git.RevParse(ctx, f.dir, recoveryRef) + if err != nil { + t.Fatalf("resolve stale capture recovery ref: %v", err) + } + if resolvedRecovery != snapshotCommit { + t.Fatalf("recovery ref commit=%s want %s", resolvedRecovery, snapshotCommit) + } + var newEvents int + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq != ?`, staleSeq, + ).Scan(&newEvents); err != nil { + t.Fatalf("count captures created after startup: %v", err) + } + if newEvents != 0 { + t.Fatalf("startup after offline reset captured %d phantom events, want 0", newEvents) } cancel() @@ -2433,7 +2795,7 @@ func TestRun_BranchGenerationStableOnAcdFastForward(t *testing.T) { } time.Sleep(50 * time.Millisecond) } - newHead := waitForCommit(t, f.dir, startHead, 5*time.Second) + newHead := waitForCommit(t, f.dir, startHead, 15*time.Second) if newHead == startHead { t.Fatalf("HEAD did not advance via daemon commit") } @@ -2571,7 +2933,7 @@ func TestRun_SameSHABranchSwitchCommitsToActiveBranch(t *testing.T) { } wakeCh <- struct{}{} - newHead := waitForCommit(t, f.dir, startHead, 5*time.Second) + newHead := waitForCommit(t, f.dir, startHead, 15*time.Second) mainHead, err := git.RevParse(ctx, f.dir, "refs/heads/main") if err != nil { t.Fatalf("rev-parse main: %v", err) @@ -2942,18 +3304,21 @@ func TestRun_SameSHARewindAcrossTicksTriggersGrace(t *testing.T) { defer cancel() var wg sync.WaitGroup + checkHook, checkEntered, releaseCheck := oneShotBranchTokenCheckGate() + defer releaseCheck() wg.Add(1) go func() { defer wg.Done() _ = Run(runCtx, Options{ - RepoPath: f.dir, - GitDir: f.gitDir, - DB: f.db, - Scheduler: manual, - BootGrace: 30 * time.Second, - WakeCh: wakeCh, - ShutdownCh: shutdownCh, - SkipSignals: true, + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + Scheduler: manual, + BootGrace: 30 * time.Second, + WakeCh: wakeCh, + ShutdownCh: shutdownCh, + SkipSignals: true, + beforeBranchTokenCheck: checkHook, }) }() t.Cleanup(func() { @@ -2965,6 +3330,9 @@ func TestRun_SameSHARewindAcrossTicksTriggersGrace(t *testing.T) { // Wait until startup has settled — persisted MetaKeyBranchHead = // seedHead, currentToken in-memory = "rev:seedHead refs/heads/main". waitForMetaValue(t, f.db, MetaKeyBranchHead, seedHead, 2*time.Second) + waitForMetaValue(t, f.db, MetaKeyBranchToken, + "rev:"+seedHead+" refs/heads/main", 2*time.Second) + waitForBranchTokenCheckGate(t, checkEntered) // Simulate an out-of-band observer (acd recover, manual sqlite edit, // or a previous-tick observation that has since been lost from @@ -2974,10 +3342,10 @@ func TestRun_SameSHARewindAcrossTicksTriggersGrace(t *testing.T) { if err := state.MetaSet(ctx, f.db, MetaKeyBranchHead, advanced); err != nil { t.Fatalf("MetaSet branch.head=advanced: %v", err) } - // Wake. The next processBranchTokenChange should observe + // Release the gated token check. processBranchTokenChange should observe // SameGeneration (live token == currentToken), enter the cross-tick // probe, and arm the grace marker. - wakeCh <- struct{}{} + releaseCheck() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { diff --git a/internal/daemon/dead_branch_sweep.go b/internal/daemon/dead_branch_sweep.go index bf5d847f..abc31918 100644 --- a/internal/daemon/dead_branch_sweep.go +++ b/internal/daemon/dead_branch_sweep.go @@ -1,15 +1,10 @@ -// dead_branch_sweep.go houses the helpers that prune unpublished +// dead_branch_sweep.go houses the helpers that recover 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. +// - the runtime Diverged transition path reconciles the prior exact pair +// before accepting the new token. pruneDeadBranchTerminals remains as a +// compatibility helper for older dead-ref rows found outside that path. // - 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 @@ -17,16 +12,10 @@ // 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. +// Both paths honor EnvKeepDeadBranchBarriers as an operator opt-out. Recovery +// composes the exact pair's immutable capture chain, anchors it under +// refs/acd/recovery, and atomically transitions every row to recovered. No +// captured row is deleted merely because its branch disappeared. package daemon import ( @@ -45,7 +34,7 @@ import ( ) // EnvKeepDeadBranchBarriers, when truthy ("1"/"true"/"yes"/"on", case-insensitive), -// disables both the runtime Diverged-hook prune and the daemon-startup sweep. +// disables both the runtime dead-ref recovery 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 EnvKeepDeadBranchBarriers = "ACD_KEEP_DEAD_BRANCH_BARRIERS" @@ -57,40 +46,41 @@ const EnvKeepDeadBranchBarriers = "ACD_KEEP_DEAD_BRANCH_BARRIERS" const deadBranchSweepRefsCap = 32 // 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 +// most recent dead-branch recovery action. The legacy key name is preserved +// for diagnose/API compatibility; no capture row is deleted. 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 +// keeping pace. No-op sweeps (zero rows recovered) 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 MetaKeyDeadBranchPruneLastRunTS = "dead_branch_prune.last_run_ts" // MetaKeyDeadBranchPruneLastCount records the total number of capture_events -// rows pruned by the most recent non-empty dead-branch prune action. Stored as +// rows recovered by the most recent non-empty dead-branch action. The legacy +// key name is preserved for compatibility. Stored as // a base-10 string. Reset to the new total on every non-empty prune; not // cumulative. const MetaKeyDeadBranchPruneLastCount = "dead_branch_prune.last_count" // 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 +// whose unpublished chains were recovered. The legacy key name is preserved. +// The slice is bounded by deadBranchSweepRefsCap so a sweep across // many stale branches does not balloon the meta payload. const MetaKeyDeadBranchPruneLastRefs = "dead_branch_prune.last_refs" -// recordDeadBranchPruneMeta stamps the three dead-branch prune meta keys in a +// recordDeadBranchRecoveryMeta stamps the three legacy dead-branch meta keys in a // single MetaSetMany transaction so `acd diagnose` reads them atomically. // Best-effort: any error is logged at warn level and the caller continues. The -// meta surface is forensic — never block prune progress on a meta write. +// meta surface is forensic — never block recovery progress on a meta write. // // rows must be > 0 (the caller guards on the no-op case so empty sweeps do not // overwrite the previous "last action that did something" snapshot). refs is -// the already-capped slice of pruned refs. -func recordDeadBranchPruneMeta(ctx context.Context, db *state.DB, logger *slog.Logger, rows int, refs []string) { +// the already-capped slice of recovered refs. +func recordDeadBranchRecoveryMeta(ctx context.Context, db *state.DB, logger *slog.Logger, rows int, refs []string) { refsJSON, err := json.Marshal(refs) if err != nil { // Marshalling a []string cannot fail under normal conditions, but // log defensively and keep going — the count + ts are still useful. - logger.Warn("dead-branch prune: marshal refs failed; recording empty refs", + logger.Warn("dead-branch recovery: marshal refs failed; recording empty refs", "err", err.Error()) refsJSON = []byte("[]") } @@ -100,7 +90,7 @@ func recordDeadBranchPruneMeta(ctx context.Context, db *state.DB, logger *slog.L MetaKeyDeadBranchPruneLastRefs: string(refsJSON), } if err := state.MetaSetMany(ctx, db, pairs); err != nil { - logger.Warn("dead-branch prune: stamp meta keys failed", + logger.Warn("dead-branch recovery: stamp meta keys failed", "err", err.Error(), "rows", rows, "refs", len(refs)) @@ -108,7 +98,7 @@ func recordDeadBranchPruneMeta(ctx context.Context, db *state.DB, logger *slog.L } // isKeepDeadBranchBarriers reports whether the operator opted out of dead-branch -// terminal pruning via EnvKeepDeadBranchBarriers. Empty / falsy / unset -> false +// recovery via EnvKeepDeadBranchBarriers. Empty / falsy / unset -> false // (default ON). Truthy -> true. Recognized truthy values (case-insensitive): // "1", "true", "yes", "on". func isKeepDeadBranchBarriers() bool { @@ -121,42 +111,48 @@ func isKeepDeadBranchBarriers() bool { } // deadBranchPair pairs a branch ref with its observed branch_generation so the -// sweep can emit a per-pair prune log line and skip the active pair. +// sweep can emit a per-pair recovery log line and skip the active pair. type deadBranchPair struct { Ref string Generation int64 } -// distinctTerminalBranchPairs reads the (branch_ref, branch_generation) pairs of -// terminal capture_events rows via the read-only handle. Routed through ReadSQL +type startupDeadBranchSweepOptions struct { + // Test-only hook used to create a pause or git-operation marker after the + // sweep's initial gate but immediately before the per-pair safety recheck. + beforePairSafetyCheck func(context.Context, deadBranchPair) +} + +// distinctUnpublishedBranchPairs reads exact (branch_ref, branch_generation) pairs of +// unpublished capture_events rows via the read-only handle. Routed through ReadSQL // so a long-running replay drain holding the serialized writer connection does // not block startup. -func distinctTerminalBranchPairs(ctx context.Context, db *state.DB) ([]deadBranchPair, error) { +func distinctUnpublishedBranchPairs(ctx context.Context, db *state.DB) ([]deadBranchPair, error) { rows, err := db.ReadSQL().QueryContext(ctx, ` SELECT DISTINCT branch_ref, branch_generation FROM capture_events - WHERE state IN (?, ?) AND branch_ref != ''`, - state.EventStateBlockedConflict, state.EventStateFailed) + WHERE state IN (?, ?, ?) AND branch_ref != ''`, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed) if err != nil { - return nil, fmt.Errorf("daemon: distinct terminal branch pairs: %w", err) + return nil, fmt.Errorf("daemon: distinct unpublished branch pairs: %w", err) } defer rows.Close() var out []deadBranchPair for rows.Next() { var p deadBranchPair if err := rows.Scan(&p.Ref, &p.Generation); err != nil { - return nil, fmt.Errorf("daemon: scan terminal branch pair: %w", err) + return nil, fmt.Errorf("daemon: scan unpublished branch pair: %w", err) } out = append(out, p) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("daemon: iterate terminal branch pairs: %w", err) + return nil, fmt.Errorf("daemon: iterate unpublished branch pairs: %w", err) } return out, nil } -// runStartupDeadBranchSweep scans terminal capture_events rows at daemon Run -// init and prunes those whose branch_ref has since been deleted. The currently +// runStartupDeadBranchSweep scans unpublished capture_events rows at daemon Run +// init and recovers those whose branch_ref has since been deleted. The currently // active (branch_ref, branch_generation) pair is always preserved — even if its // terminals exist — so an in-flight blocked_conflict the operator is about to // resolve via `acd recover` is not silently removed. @@ -170,14 +166,27 @@ func runStartupDeadBranchSweep( cctx CaptureContext, logger *slog.Logger, tracer acdtrace.Logger, +) { + runStartupDeadBranchSweepWithOptions(ctx, repoDir, db, cctx, logger, tracer, + startupDeadBranchSweepOptions{}) +} + +func runStartupDeadBranchSweepWithOptions( + ctx context.Context, + repoDir string, + db *state.DB, + cctx CaptureContext, + logger *slog.Logger, + tracer acdtrace.Logger, + opts startupDeadBranchSweepOptions, ) { if isKeepDeadBranchBarriers() { // The single startup info log already fired in Run; just no-op. return } - pairs, err := distinctTerminalBranchPairs(ctx, db) + pairs, err := distinctUnpublishedBranchPairs(ctx, db) if err != nil { - logger.Warn("startup sweep: read terminal branch pairs failed", + logger.Warn("startup sweep: read unpublished branch pairs failed", "err", err.Error()) return } @@ -185,6 +194,19 @@ func runStartupDeadBranchSweep( if scanned == 0 { return } + gitDir, err := git.AbsoluteGitDir(ctx, repoDir) + if err != nil { + logger.Warn("startup sweep: resolve git dir failed; preserving unpublished rows", + "err", err.Error()) + return + } + liveToken, err := BranchGenerationToken(ctx, repoDir) + if err != nil { + logger.Warn("startup sweep: resolve live token failed; preserving unpublished rows", + "err", err.Error()) + return + } + archiveOnly := tokenSHA(liveToken) == "" // Enumerate live branch refs once up-front instead of per-pair `git // show-ref` shell-outs. With N distinct terminal pairs this collapses N // forks into 1; on a long-lived repo with many stale branches the @@ -197,9 +219,9 @@ func runStartupDeadBranchSweep( "err", err.Error()) return } - prunedRefs := make([]string, 0, deadBranchSweepRefsCap) + recoveredRefs := make([]string, 0, deadBranchSweepRefsCap) totalRows := 0 - prunedPairs := 0 + recoveredPairs := 0 for _, p := range pairs { if p.Ref == "" { continue @@ -211,70 +233,108 @@ func runStartupDeadBranchSweep( if _, alive := liveRefs[p.Ref]; alive { continue } - rows, dErr := state.PurgeUnpublishedForDeadBranch(ctx, db, p.Ref, p.Generation) + // Recheck immediately before recovery. The batched live-ref snapshot + // is only a candidate filter; a branch may be recreated during the + // sweep and must then remain owned by normal replay. + exists, probeErr := git.RefExists(ctx, repoDir, p.Ref) + if probeErr != nil { + logger.Warn("startup sweep: recheck dead branch failed; preserving unpublished rows", + "ref", p.Ref, "generation", p.Generation, "err", probeErr.Error()) + continue + } + if exists { + continue + } + if opts.beforePairSafetyCheck != nil { + opts.beforePairSafetyCheck(ctx, p) + } + // The sweep runs asynchronously after daemon startup, so the initial + // Run-level pause/git-operation check can become stale during a long + // scan. Recheck immediately before each mutation and stop the sweep on + // any unsafe or unreadable state. + if operation, active := gitOperationInProgress(gitDir); active { + logger.Info("startup sweep: git operation began during scan; preserving remaining unpublished rows", + "operation", operation, "ref", p.Ref, "generation", p.Generation) + return + } + pauseStatus, pauseErr := daemonPauseState(ctx, gitDir, db) + if pauseErr != nil { + logger.Warn("startup sweep: recheck pause failed; preserving remaining unpublished rows", + "ref", p.Ref, "generation", p.Generation, "err", pauseErr.Error()) + return + } + if pauseStatus.Active { + logger.Info("startup sweep: pause began during scan; preserving remaining unpublished rows", + "source", pauseStatus.Source, "reason", pauseStatus.Reason, + "ref", p.Ref, "generation", p.Generation) + return + } + result, dErr := reconcileTransitionPair(ctx, repoDir, gitDir, db, + p.Ref, p.Generation, archiveOnly, p.Ref, "startup_dead_branch_sweep", tracer) if dErr != nil { - logger.Warn("startup sweep: purge dead-branch unpublished failed", + logger.Warn("startup sweep: recover dead-branch unpublished failed", "ref", p.Ref, "generation", p.Generation, "err", dErr.Error()) continue } - if rows == 0 { + if !result.Handled || result.EventCount == 0 { continue } - prunedPairs++ - totalRows += rows - if len(prunedRefs) < deadBranchSweepRefsCap { - prunedRefs = append(prunedRefs, p.Ref) + recoveredPairs++ + totalRows += result.EventCount + if len(recoveredRefs) < deadBranchSweepRefsCap { + recoveredRefs = append(recoveredRefs, p.Ref) } - logger.Info("startup sweep pruned dead-branch unpublished", + logger.Info("startup sweep recovered dead-branch unpublished", "ref", p.Ref, "generation", p.Generation, - "rows", rows) + "rows", result.EventCount, + "recovery_ref", result.RecoveryRef) } if totalRows > 0 { // Stamp the diagnose-visible meta keys before recording the trace // event so readers that wake on the trace see consistent meta. - recordDeadBranchPruneMeta(ctx, db, logger, totalRows, prunedRefs) + recordDeadBranchRecoveryMeta(ctx, db, logger, totalRows, recoveredRefs) } recordTrace(tracer, acdtrace.Event{ Repo: repoDir, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, EventClass: "daemon.dead_branch_sweep", - Decision: sweepDecision(prunedPairs), + Decision: sweepDecision(recoveredPairs), Reason: "startup sweep", Output: map[string]any{ - "scanned": scanned, - "pruned_pairs": prunedPairs, - "total_rows_pruned": totalRows, - "refs": prunedRefs, + "scanned": scanned, + "recovered_pairs": recoveredPairs, + "total_rows_recovered": totalRows, + "refs": recoveredRefs, }, Generation: cctx.BranchGeneration, }) } -// sweepDecision distinguishes a no-op sweep from one that actually removed rows. +// sweepDecision distinguishes a no-op sweep from one that recovered rows. // Kept here (not in trace.go) so trace.go does not have to know about sweep // semantics. -func sweepDecision(prunedPairs int) string { - if prunedPairs > 0 { - return "pruned" +func sweepDecision(recoveredPairs int) string { + if recoveredPairs > 0 { + return "recovered" } return "skip" } -// pruneDeadBranchTerminals is the runtime Diverged-hook helper. Called after -// state.DeletePendingForGeneration to drop terminal rows for the prior -// (branch_ref, branch_generation) when the ref has been deleted. Honors the -// EnvKeepDeadBranchBarriers opt-out. +// pruneDeadBranchTerminals is the runtime Diverged-hook helper. It preserves +// the prior exact (branch_ref, branch_generation) chain when its ref has been +// deleted. The legacy name remains for compatibility with focused tests and +// diagnose metadata. // // Inputs come from the Diverged caller's local scope: oldRef is // tokenBranchRef(oldToken); prevGeneration is the pre-bump generation; // cctx is the post-bump context (used for trace BranchRef / HeadSHA / // Generation). // -// Best-effort: errors from RefExists or PurgeUnpublishedForDeadBranch are logged +// Best-effort: errors from RefExists or reconciliation are logged // and traced (Error field) but do not propagate to the caller. func pruneDeadBranchTerminals( ctx context.Context, @@ -299,14 +359,14 @@ func pruneDeadBranchTerminals( Repo: repoDir, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, - EventClass: "branch_token.dead_branch_pruned", + EventClass: "branch_token.dead_branch_recovered", Decision: "skip", Reason: reason, Input: map[string]any{"branch_ref": oldRef, "prev_generation": prevGeneration}, Output: map[string]any{ "prev_generation": prevGeneration, "branch_ref": oldRef, - "rows_pruned": 0, + "rows_recovered": 0, }, Error: err.Error(), Generation: cctx.BranchGeneration, @@ -317,9 +377,40 @@ func pruneDeadBranchTerminals( // Live ref — no action, no log. return } - rows, err := state.PurgeUnpublishedForDeadBranch(ctx, db, oldRef, prevGeneration) + gitDir, err := git.AbsoluteGitDir(ctx, repoDir) + if err != nil { + logger.Warn("resolve git dir for dead-branch recovery failed", + "ref", oldRef, + "generation", prevGeneration, + "err", err.Error()) + recordTrace(tracer, acdtrace.Event{ + Repo: repoDir, + BranchRef: cctx.BranchRef, + HeadSHA: cctx.BaseHead, + EventClass: "branch_token.dead_branch_recovered", + Decision: "error", + Reason: reason, + Input: map[string]any{"branch_ref": oldRef, "prev_generation": prevGeneration}, + Output: map[string]any{ + "prev_generation": prevGeneration, + "branch_ref": oldRef, + "rows_recovered": 0, + }, + Error: err.Error(), + Generation: cctx.BranchGeneration, + }) + return + } + liveToken, err := BranchGenerationToken(ctx, repoDir) + if err != nil { + logger.Warn("resolve live token for dead-branch recovery failed", + "ref", oldRef, "generation", prevGeneration, "err", err.Error()) + return + } + result, err := reconcileTransitionPair(ctx, repoDir, gitDir, db, + oldRef, prevGeneration, tokenSHA(liveToken) == "", oldRef, "runtime_dead_branch_sweep", tracer) if err != nil { - logger.Warn("purge dead-branch unpublished failed", + logger.Warn("recover dead-branch unpublished failed", "ref", oldRef, "generation", prevGeneration, "err", err.Error()) @@ -327,53 +418,36 @@ func pruneDeadBranchTerminals( Repo: repoDir, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, - EventClass: "branch_token.dead_branch_pruned", + EventClass: "branch_token.dead_branch_recovered", Decision: "error", Reason: reason, Input: map[string]any{"branch_ref": oldRef, "prev_generation": prevGeneration}, Output: map[string]any{ "prev_generation": prevGeneration, "branch_ref": oldRef, - "rows_pruned": 0, + "rows_recovered": 0, }, Error: err.Error(), Generation: cctx.BranchGeneration, }) return } + rows := result.EventCount if rows > 0 { - logger.Info("pruned dead-branch terminal rows", + logger.Info("recovered dead-branch unpublished rows", "ref", oldRef, "generation", prevGeneration, - "rows", rows) - // Stamp the diagnose-visible meta keys for the operator-facing - // "last action that did something" surface. Skipped on the - // rows == 0 path (probe-was-dead-but-no-terminals-queued) so the - // previous snapshot survives. - recordDeadBranchPruneMeta(ctx, db, logger, rows, []string{oldRef}) + "rows", rows, + "recovery_ref", result.RecoveryRef) + recordDeadBranchRecoveryMeta(ctx, db, logger, rows, []string{oldRef}) } recordTrace(tracer, acdtrace.Event{ - Repo: repoDir, - BranchRef: cctx.BranchRef, - HeadSHA: cctx.BaseHead, - EventClass: "branch_token.dead_branch_pruned", - Decision: pruneDecision(rows), - Reason: reason, - Input: map[string]any{"branch_ref": oldRef, "prev_generation": prevGeneration}, - Output: map[string]any{ - "prev_generation": prevGeneration, - "branch_ref": oldRef, - "rows_pruned": rows, - }, + Repo: repoDir, BranchRef: cctx.BranchRef, HeadSHA: cctx.BaseHead, + EventClass: "branch_token.dead_branch_recovered", + Decision: sweepDecision(rows), Reason: reason, + Input: map[string]any{"branch_ref": oldRef, "prev_generation": prevGeneration}, + Output: map[string]any{"prev_generation": prevGeneration, "branch_ref": oldRef, + "rows_recovered": rows, "recovery_ref": result.RecoveryRef}, Generation: cctx.BranchGeneration, }) } - -// pruneDecision distinguishes a real prune from a probe that returned a dead -// ref but had no terminal rows queued. -func pruneDecision(rows int) string { - if rows > 0 { - return "pruned" - } - return "skip" -} diff --git a/internal/daemon/dead_branch_sweep_test.go b/internal/daemon/dead_branch_sweep_test.go index 9f0db705..346f66ac 100644 --- a/internal/daemon/dead_branch_sweep_test.go +++ b/internal/daemon/dead_branch_sweep_test.go @@ -5,12 +5,16 @@ import ( "database/sql" "encoding/json" "log/slog" + "os" + "path/filepath" "strconv" + "strings" "sync" "testing" "time" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" + pausepkg "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/pause" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -73,6 +77,12 @@ func TestIsKeepDeadBranchBarriers(t *testing.T) { func seedTerminalEvent(t *testing.T, db *state.DB, branchRef string, generation int64, baseHead, path, eventState string) int64 { t.Helper() ctx := context.Background() + gitDir := filepath.Dir(filepath.Dir(db.Path())) + repoDir := filepath.Dir(gitDir) + afterOID, err := git.HashObjectStdin(ctx, repoDir, []byte(path+"\n")) + if err != nil { + t.Fatalf("hash after blob: %v", err) + } seq, err := state.AppendCaptureEvent(ctx, db, state.CaptureEvent{ BranchRef: branchRef, BranchGeneration: generation, @@ -86,7 +96,7 @@ func seedTerminalEvent(t *testing.T, db *state.DB, branchRef string, generation Path: path, Fidelity: "full", AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, - AfterOID: sql.NullString{String: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Valid: true}, + AfterOID: sql.NullString{String: afterOID, Valid: true}, }}) if err != nil { t.Fatalf("AppendCaptureEvent: %v", err) @@ -113,13 +123,12 @@ func countEventsByRefState(t *testing.T, db *state.DB, branchRef, eventState str return n } -// TestDeadBranchSweep_PrunesDeadRefRows seeds blocked_conflict + failed + +// TestDeadBranchSweep_RecoversDeadRefRows seeds blocked_conflict + failed + // pending rows for refs/heads/old (which is NOT created in the test repo) and // terminal rows for the active refs/heads/main; runs the startup sweep; -// asserts old's pending + terminal rows are all deleted (the helper now drops -// pending rows together with terminals so the prune does not get reverted on -// the next replay tick), and main's rows are preserved. -func TestDeadBranchSweep_PrunesDeadRefRows(t *testing.T) { +// asserts old's pending + terminal rows all become recovered while their +// provenance rows remain, and main's rows are preserved. +func TestDeadBranchSweep_RecoversDeadRefRows(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") f := newDaemonFixture(t) ctx := context.Background() @@ -132,8 +141,7 @@ func TestDeadBranchSweep_PrunesDeadRefRows(t *testing.T) { const activeRef = "refs/heads/main" // Dead-ref rows: one of each (terminal + pending). All three must be - // deleted — leaving pending rows behind would let replay restamp a - // blocked_conflict on the next tick and defeat the prune. + // transitioned together so replay cannot restamp a blocked conflict. seedTerminalEvent(t, f.db, deadRef, 1, headOID, "old-blocked.txt", state.EventStateBlockedConflict) seedTerminalEvent(t, f.db, deadRef, 1, headOID, "old-failed.txt", state.EventStateFailed) seedTerminalEvent(t, f.db, deadRef, 1, headOID, "old-pending.txt", state.EventStatePending) @@ -158,10 +166,13 @@ func TestDeadBranchSweep_PrunesDeadRefRows(t *testing.T) { if got := countEventsByRefState(t, f.db, deadRef, state.EventStateFailed); got != 0 { t.Fatalf("dead-ref failed rows=%d want 0", got) } - // Pending rows for the dead pair must also be gone so PendingEvents does - // not re-expose them on the next replay tick. + // Pending lifecycle state must also be gone so PendingEvents does not + // re-expose the chain on the next replay tick. if got := countEventsByRefState(t, f.db, deadRef, state.EventStatePending); got != 0 { - t.Fatalf("dead-ref pending rows=%d want 0 (helper must drop pending+terminal together)", got) + t.Fatalf("dead-ref pending rows=%d want 0 after whole-chain recovery", got) + } + if got := countEventsByRefState(t, f.db, deadRef, state.EventStateRecovered); got != 3 { + t.Fatalf("dead-ref recovered rows=%d want 3", got) } if got := countEventsByRefState(t, f.db, activeRef, state.EventStateBlockedConflict); got != 1 { t.Fatalf("active-ref blocked_conflict rows=%d want 1 (active pair must be preserved)", got) @@ -174,10 +185,260 @@ func TestDeadBranchSweep_PrunesDeadRefRows(t *testing.T) { } } +func TestDeadBranchSweep_ArchivesWhenLiveHeadMissing(t *testing.T) { + t.Setenv(EnvKeepDeadBranchBarriers, "") + f := newDaemonFixture(t) + ctx := context.Background() + baseHead, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + const deadRef = "refs/heads/dead-before-orphan" + seedTerminalEvent(t, f.db, deadRef, 1, baseHead, "preserved.txt", state.EventStatePending) + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", "refs/heads/orphan"); err != nil { + t.Fatalf("symbolic-ref orphan: %v", err) + } + + runStartupDeadBranchSweep(ctx, f.dir, f.db, CaptureContext{ + BranchRef: "refs/heads/orphan", BranchGeneration: 2, + }, slog.Default(), nil) + + if got := countEventsByRefState(t, f.db, deadRef, state.EventStateRecovered); got != 1 { + t.Fatalf("recovered rows=%d want 1", got) + } + var recoveryRef string + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT recovery_ref FROM recovery_snapshots +WHERE branch_ref = ? AND branch_generation = ?`, deadRef, 1).Scan(&recoveryRef); err != nil { + t.Fatalf("read recovery snapshot: %v", err) + } + if !strings.HasSuffix(recoveryRef, "/archive") { + t.Fatalf("recovery_ref=%q want archive ref", recoveryRef) + } +} + +func TestDeadBranchRecoveryRefRecreationPreservesPair(t *testing.T) { + f := newDaemonFixture(t) + ctx := context.Background() + baseHead, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + const deadRef = "refs/heads/recreated-during-recovery" + seq := seedTerminalEvent(t, f.db, deadRef, 1, baseHead, "preserved.txt", state.EventStatePending) + var recreateErr error + _, err = ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, BranchRef: deadRef, BranchGeneration: 1, + FirstSeq: seq, Trigger: "dead_ref_race", ExpectedMissingRef: deadRef, + beforeStateTransition: func() { + recreateErr = git.UpdateRef(ctx, f.dir, deadRef, baseHead, "") + }, + }) + if recreateErr != nil { + t.Fatalf("recreate ref: %v", recreateErr) + } + if err == nil || !strings.Contains(err.Error(), "to remain missing") { + t.Fatalf("Reconcile error=%v want recreated-ref refusal", err) + } + if got := countEventsByRefState(t, f.db, deadRef, state.EventStatePending); got != 1 { + t.Fatalf("pending rows=%d want 1 unchanged", got) + } + if snapshots := countRecoverySnapshots(t, ctx, f.db); snapshots != 0 { + t.Fatalf("recovery snapshots=%d want 0", snapshots) + } +} + +func TestDeadBranchRecoveryLocksMissingRefThroughTransition(t *testing.T) { + f := newDaemonFixture(t) + ctx := context.Background() + baseHead, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + const deadRef = "refs/heads/recreated-after-recovery-lock" + seq := seedTerminalEvent(t, f.db, deadRef, 1, baseHead, "preserved.txt", state.EventStatePending) + + recreateDone := make(chan error, 1) + recreateStarted := make(chan struct{}) + completedWhileLocked := false + var recreateErr error + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, BranchRef: deadRef, BranchGeneration: 1, + FirstSeq: seq, Trigger: "dead_ref_atomic_race", ExpectedMissingRef: deadRef, + afterRecoveryRefLocked: func() { + go func() { + close(recreateStarted) + recreateDone <- git.UpdateRef(ctx, f.dir, deadRef, baseHead, "") + }() + <-recreateStarted + select { + case recreateErr = <-recreateDone: + completedWhileLocked = true + case <-time.After(200 * time.Millisecond): + } + }, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if completedWhileLocked && recreateErr == nil { + t.Fatal("dead ref was recreated before the recovery DB transition completed") + } + if !result.Handled || result.Outcome != state.EventStateRecovered { + t.Fatalf("result=%+v want recovered", result) + } + if !completedWhileLocked { + select { + case recreateErr = <-recreateDone: + case <-time.After(5 * time.Second): + t.Fatal("dead-ref transaction lock was not released") + } + } + if recreateErr != nil { + if err := git.UpdateRef(ctx, f.dir, deadRef, baseHead, ""); err != nil { + t.Fatalf("recreate dead ref after transition: %v", err) + } + } + if got := countEventsByRefState(t, f.db, deadRef, state.EventStateRecovered); got != 1 { + t.Fatalf("recovered rows=%d want 1", got) + } +} + +func TestStartupDeadBranchSweep_RechecksSafetyBeforePair(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*testing.T, *daemonFixture) error + }{ + { + name: "manual pause", + setup: func(t *testing.T, f *daemonFixture) error { + _, err := pausepkg.Write(pausepkg.Path(f.gitDir), pausepkg.Marker{ + Reason: "operator surgery", + SetAt: time.Now().UTC().Format(time.RFC3339), + SetBy: "test", + }, false) + return err + }, + }, + { + name: "git operation", + setup: func(t *testing.T, f *daemonFixture) error { + return os.MkdirAll(filepath.Join(f.gitDir, "rebase-merge"), 0o755) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvKeepDeadBranchBarriers, "") + f := newDaemonFixture(t) + ctx := context.Background() + head, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + const deadRef = "refs/heads/safety-race" + seedTerminalEvent(t, f.db, deadRef, 1, head, "preserved.txt", state.EventStatePending) + var setupErr error + runStartupDeadBranchSweepWithOptions(ctx, f.dir, f.db, CaptureContext{ + BranchRef: "refs/heads/main", BranchGeneration: 1, BaseHead: head, + }, slog.Default(), nil, startupDeadBranchSweepOptions{ + beforePairSafetyCheck: func(context.Context, deadBranchPair) { + setupErr = tc.setup(t, f) + }, + }) + if setupErr != nil { + t.Fatalf("setup safety gate: %v", setupErr) + } + if got := countEventsByRefState(t, f.db, deadRef, state.EventStatePending); got != 1 { + t.Fatalf("pending rows=%d want 1 preserved", got) + } + if snapshots := countRecoverySnapshots(t, ctx, f.db); snapshots != 0 { + t.Fatalf("recovery snapshots=%d want 0", snapshots) + } + }) + } +} + +// TestRun_ShutdownJoinsStartupDeadBranchSweep proves the asynchronous startup +// sweep remains owned by Run. Both shutdown mechanisms must cancel a sweep +// blocked immediately before mutation and wait for it to exit before Run +// releases daemon.lock or returns control to the DB owner. +func TestRun_ShutdownJoinsStartupDeadBranchSweep(t *testing.T) { + for _, shutdownMode := range []string{"signal", "context"} { + t.Run(shutdownMode, func(t *testing.T) { + t.Setenv(EnvKeepDeadBranchBarriers, "") + f := newDaemonFixture(t) + registerLiveClient(t, f.db) + ctx := context.Background() + head, err := git.RevParse(ctx, f.dir, "HEAD") + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + const deadRef = "refs/heads/shutdown-sweep" + seedTerminalEvent(t, f.db, deadRef, 1, head, "preserved.txt", state.EventStateBlockedConflict) + + sweepStarted := make(chan struct{}) + sweepExited := make(chan struct{}) + shutdownCh := make(chan struct{}, 1) + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + runDone := make(chan error, 1) + go func() { + runDone <- Run(runCtx, Options{ + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + Scheduler: fastScheduler(), + BootGrace: 30 * time.Second, + ShutdownCh: shutdownCh, + SkipSignals: true, + beforeStartupDeadBranchPairSafetyCheck: func(sweepCtx context.Context, _ deadBranchPair) { + close(sweepStarted) + <-sweepCtx.Done() + close(sweepExited) + }, + }) + }() + + select { + case <-sweepStarted: + case <-time.After(5 * time.Second): + t.Fatalf("startup dead-branch sweep did not reach synchronization point") + } + if shutdownMode == "signal" { + shutdownCh <- struct{}{} + } else { + cancel() + } + + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatalf("Run did not join startup dead-branch sweep") + } + select { + case <-sweepExited: + default: + t.Fatalf("Run returned before startup dead-branch sweep exited") + } + + lock, err := AcquireDaemonLock(f.gitDir) + if err != nil { + t.Fatalf("reacquire daemon.lock after Run: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatalf("release reacquired daemon.lock: %v", err) + } + }) + } +} + // TestDeadBranchSweep_RegressionPendingDoesNotLeakBarrier asserts the P1 -// regression: after deleting terminals for a dead branch, no later +// regression: after recovering a dead branch, no later // PendingEvents call must surface pending rows for the same dead pair (which -// would let replay re-stamp a blocked_conflict and defeat the prune). This +// would let replay re-stamp a blocked_conflict and defeat recovery). This // is the test against the bug cr-expert flagged. func TestDeadBranchSweep_RegressionPendingDoesNotLeakBarrier(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") @@ -210,15 +471,18 @@ func TestDeadBranchSweep_RegressionPendingDoesNotLeakBarrier(t *testing.T) { } runStartupDeadBranchSweep(ctx, f.dir, f.db, cctx, slog.Default(), nil) - // All capture_events for the dead ref must be gone. + // All capture_events remain as durable recovered provenance. var total int if err := f.db.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events WHERE branch_ref = ?`, deadRef, ).Scan(&total); err != nil { t.Fatalf("count dead-ref rows: %v", err) } - if total != 0 { - t.Fatalf("dead-ref rows=%d want 0 after sweep", total) + if total != 3 { + t.Fatalf("dead-ref rows=%d want 3 retained after sweep", total) + } + if recovered := countEventsByRefState(t, f.db, deadRef, state.EventStateRecovered); recovered != 3 { + t.Fatalf("dead-ref recovered rows=%d want 3", recovered) } // PendingEvents must not surface anything for the dead ref — this is the @@ -330,14 +594,14 @@ func TestDeadBranchSweep_LiveRefsErrorPreservesRows(t *testing.T) { } } -// TestDivergedHookPrunesDeadBranchTerminals drives a Diverged transition +// TestDivergedHookRecoversDeadBranchTerminals drives a Diverged transition // through the run loop where the previous branch ref no longer resolves. // Mirrors TestRun_BranchSwitchDropsPending but adds: // - a blocked_conflict row on refs/heads/old (which we delete before the // transition) // - assertion that after the bump, the terminal row is gone // - assertion that an analogous row on a still-live ref is preserved -func TestDivergedHookPrunesDeadBranchTerminals(t *testing.T) { +func TestDivergedHookRecoversDeadBranchTerminals(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") f := newDaemonFixture(t) registerLiveClient(t, f.db) @@ -457,10 +721,17 @@ func TestDivergedHookPrunesDeadBranchTerminals(t *testing.T) { t.Fatalf("branch.generation did not bump after sibling reset; runtime Diverged path never fired") } - // The Diverged hook above deleted pending events for generation 1 (via - // state.DeletePendingForGeneration). Our seeded terminal rows survive - // that step. Their fate hinges on whether the dead-branch prune sees a - // live or dead ref. refs/heads/old is DEAD, refs/heads/keep is ALIVE. + // Stop the run loop before invoking the compatibility helper directly. + // Resetting main to the sibling leaves the fixture worktree dirty, so an + // active daemon may capture and publish that work while reconciliation is + // proving a stable live token. Production recovery must fail closed when + // HEAD moves; this direct-helper assertion needs a quiescent repository. + cancel() + wg.Wait() + + // The Diverged hook reconciles its own prior exact pair. These separate + // fixtures exercise the compatibility sweep: refs/heads/old is dead and + // refs/heads/keep remains live. // // The runtime hook only prunes for tokenBranchRef(oldToken), i.e. the // branch the daemon was on (refs/heads/main). To exercise the dead-ref @@ -474,21 +745,22 @@ func TestDivergedHookPrunesDeadBranchTerminals(t *testing.T) { "test-direct invocation") if n := countEventsByRefState(t, f.db, "refs/heads/old", state.EventStateBlockedConflict); n != 0 { - t.Fatalf("dead-ref refs/heads/old terminal rows=%d want 0 after Diverged prune", n) + t.Fatalf("dead-ref refs/heads/old blocked rows=%d want 0 after recovery", n) + } + if n := countEventsByRefState(t, f.db, "refs/heads/old", state.EventStateRecovered); n != 1 { + t.Fatalf("dead-ref refs/heads/old recovered rows=%d want 1", n) } if n := countEventsByRefState(t, f.db, "refs/heads/keep", state.EventStateBlockedConflict); n != 1 { t.Fatalf("live-ref refs/heads/keep terminal rows=%d want 1 (must be preserved)", n) } - cancel() - wg.Wait() } -// TestDeadBranchSweep_WritesMetaKeysWhenRowsPruned asserts the startup sweep -// stamps the three operator-facing meta keys when at least one row is pruned. +// TestDeadBranchSweep_WritesMetaKeysWhenRowsRecovered asserts the startup sweep +// stamps the three operator-facing legacy meta keys when rows are recovered. // This is the input `acd diagnose --json` reads to surface stale-branch // hygiene activity. -func TestDeadBranchSweep_WritesMetaKeysWhenRowsPruned(t *testing.T) { +func TestDeadBranchSweep_WritesMetaKeysWhenRowsRecovered(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") f := newDaemonFixture(t) ctx := context.Background() @@ -557,7 +829,7 @@ func TestDeadBranchSweep_WritesMetaKeysWhenRowsPruned(t *testing.T) { } // TestDeadBranchSweep_NoMetaWhenNoOp asserts the sweep does NOT stamp the meta -// keys when no rows were pruned (only live-ref terminals exist). The previous +// keys when no rows were recovered (only live-ref terminals exist). The previous // "last action that did something" snapshot must survive a no-op pass. func TestDeadBranchSweep_NoMetaWhenNoOp(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") @@ -597,7 +869,7 @@ func TestDeadBranchSweep_NoMetaWhenNoOp(t *testing.T) { // TestDivergedHookWritesMetaKeys drives the runtime Diverged-hook helper // directly with a dead previous ref and asserts the three meta keys are -// stamped. Mirrors the integration in TestDivergedHookPrunesDeadBranchTerminals +// stamped. Mirrors the integration in TestDivergedHookRecoversDeadBranchTerminals // but isolates the meta-write contract. func TestDivergedHookWritesMetaKeys(t *testing.T) { t.Setenv(EnvKeepDeadBranchBarriers, "") diff --git a/internal/daemon/intent_health.go b/internal/daemon/intent_health.go new file mode 100644 index 00000000..8cad1c88 --- /dev/null +++ b/internal/daemon/intent_health.go @@ -0,0 +1,693 @@ +package daemon + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "net/url" + "strings" + "sync" + "time" + "unicode" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +const ( + // MetaKeyIntentPlannerHealth stores the crash-safe planner circuit state. + // The value is versioned JSON so later CLI observability can read the same + // record without adding a schema migration. + MetaKeyIntentPlannerHealth = "intent.planner.health" + + intentPlannerHealthVersion = 1 +) + +var intentPlannerCircuitBackoffs = [...]time.Duration{ + 30 * time.Second, + 2 * time.Minute, + 10 * time.Minute, +} + +var ( + // ErrIntentPlannerHealthInvalidRecord indicates that persisted planner + // health cannot be safely projected into operator output. + ErrIntentPlannerHealthInvalidRecord = errors.New("invalid intent planner health record") + // ErrIntentPlannerHealthUnsupportedVersion indicates a well-formed record + // whose schema is newer or otherwise unknown to this binary. + ErrIntentPlannerHealthUnsupportedVersion = errors.New("unsupported intent planner health record version") +) + +// IntentPlannerCircuitState is the persisted planner health state. +type IntentPlannerCircuitState string + +const ( + IntentPlannerCircuitClosed IntentPlannerCircuitState = "closed" + IntentPlannerCircuitOpen IntentPlannerCircuitState = "open" + IntentPlannerCircuitHalfOpen IntentPlannerCircuitState = "half_open" +) + +// IntentPlannerFailureKind is deliberately small: integration code must +// classify failures explicitly instead of relying on provider error strings. +type IntentPlannerFailureKind string + +const ( + IntentPlannerFailureTransport IntentPlannerFailureKind = "transport" + IntentPlannerFailureValidation IntentPlannerFailureKind = "validation" +) + +// IntentPlannerProviderIdentity contains only non-secret provider identity. +// API keys and authorization headers intentionally have no field here. +type IntentPlannerProviderIdentity struct { + Provider string + Model string + Endpoint string + Deterministic bool +} + +// IntentPlannerHealthOptions configures one process-local circuit. Now is a +// test seam; production leaves it nil. +type IntentPlannerHealthOptions struct { + Provider IntentPlannerProviderIdentity + Now func() time.Time +} + +// IntentPlannerHealthSnapshot is safe to expose through status/diagnose. It +// contains a hashed provider identity and a bounded, redacted error only. +type IntentPlannerHealthSnapshot struct { + State IntentPlannerCircuitState `json:"state"` + ProviderFingerprint string `json:"provider_fingerprint"` + ConsecutiveFailures int `json:"consecutive_failures"` + BackoffLevel int `json:"backoff_level"` + NextProbeTS float64 `json:"next_probe_ts,omitempty"` + OpenedTS float64 `json:"opened_ts,omitempty"` + LastFailureTS float64 `json:"last_failure_ts,omitempty"` + LastFailureClass IntentPlannerFailureKind `json:"last_failure_class,omitempty"` + LastError string `json:"last_error,omitempty"` + BypassCount uint64 `json:"bypass_count"` + UpdatedTS float64 `json:"updated_ts,omitempty"` +} + +type intentPlannerHealthRecord struct { + Version int `json:"version"` + IntentPlannerHealthSnapshot +} + +// DecodeIntentPlannerHealthSnapshot validates the persisted observability +// record without mutating it. It returns fixed sentinel errors so callers can +// warn without echoing malformed JSON or secret-shaped values. +func DecodeIntentPlannerHealthSnapshot(raw string) (IntentPlannerHealthSnapshot, error) { + var record intentPlannerHealthRecord + if err := json.Unmarshal([]byte(raw), &record); err != nil { + return IntentPlannerHealthSnapshot{}, ErrIntentPlannerHealthInvalidRecord + } + if record.Version != intentPlannerHealthVersion { + return IntentPlannerHealthSnapshot{}, ErrIntentPlannerHealthUnsupportedVersion + } + snapshot := record.IntentPlannerHealthSnapshot + if !validIntentPlannerHealthState(snapshot.State) || + !validIntentPlannerHealthFingerprint(snapshot.ProviderFingerprint) || + snapshot.ConsecutiveFailures < 0 || + snapshot.BackoffLevel < 0 || snapshot.BackoffLevel >= len(intentPlannerCircuitBackoffs) || + !validIntentPlannerHealthFailureClass(snapshot.LastFailureClass) || + !validIntentPlannerHealthTimestamp(snapshot.NextProbeTS) || + !validIntentPlannerHealthTimestamp(snapshot.OpenedTS) || + !validIntentPlannerHealthTimestamp(snapshot.LastFailureTS) || + !validIntentPlannerHealthTimestamp(snapshot.UpdatedTS) { + return IntentPlannerHealthSnapshot{}, ErrIntentPlannerHealthInvalidRecord + } + snapshot.LastError = ai.SanitizePlannerError(snapshot.LastError) + return snapshot, nil +} + +func validIntentPlannerHealthState(state IntentPlannerCircuitState) bool { + switch state { + case IntentPlannerCircuitClosed, IntentPlannerCircuitOpen, IntentPlannerCircuitHalfOpen: + return true + default: + return false + } +} + +func validIntentPlannerHealthFingerprint(fingerprint string) bool { + const prefix = "sha256:" + if !strings.HasPrefix(fingerprint, prefix) || len(fingerprint) != len(prefix)+sha256.Size*2 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(fingerprint, prefix)) + return err == nil +} + +func validIntentPlannerHealthFailureClass(kind IntentPlannerFailureKind) bool { + switch kind { + case "", IntentPlannerFailureTransport, IntentPlannerFailureValidation: + return true + default: + return false + } +} + +func validIntentPlannerHealthTimestamp(ts float64) bool { + return ts >= 0 && !math.IsNaN(ts) && !math.IsInf(ts, 0) +} + +// IntentPlannerCircuitOpenError tells replay to use its deterministic fallback +// without invoking the remote planner. HalfOpen is true when another caller +// already owns the single probe lease. +type IntentPlannerCircuitOpenError struct { + RetryAt time.Time + HalfOpen bool +} + +func (e *IntentPlannerCircuitOpenError) Error() string { + if e == nil { + return "" + } + if e.HalfOpen { + return "intent planner circuit half-open probe already in progress" + } + if e.RetryAt.IsZero() { + return "intent planner circuit open" + } + return fmt.Sprintf("intent planner circuit open until %s", e.RetryAt.UTC().Format(time.RFC3339Nano)) +} + +// IntentPlannerTransportFailure opens the circuit immediately. Callers should +// wrap timeouts, HTTP/subprocess failures, and other failures that occur before +// a validated plan is available. +type IntentPlannerTransportFailure struct{ Err error } + +func (e *IntentPlannerTransportFailure) Error() string { + if e == nil || e.Err == nil { + return "intent planner transport failure" + } + return e.Err.Error() +} + +func (e *IntentPlannerTransportFailure) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// IntentPlannerValidationFailure counts toward the three-failure validation +// threshold. It includes provider validation, message-quality validation, and +// daemon selection-safety validation. +type IntentPlannerValidationFailure struct{ Err error } + +func (e *IntentPlannerValidationFailure) Error() string { + if e == nil || e.Err == nil { + return "intent planner validation failure" + } + return e.Err.Error() +} + +func (e *IntentPlannerValidationFailure) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// IntentPlannerFailureTypeError means integration passed a non-nil failure +// without classifying it as transport or validation. The circuit is unchanged. +type IntentPlannerFailureTypeError struct{ Err error } + +func (e *IntentPlannerFailureTypeError) Error() string { + return "intent planner health: unclassified failure" +} + +func (e *IntentPlannerFailureTypeError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// IntentPlannerHealthPermit proves a caller passed the circuit gate. Its +// fields are private so only this package can manufacture valid permits. +type IntentPlannerHealthPermit struct { + epoch uint64 + lease uint64 + halfOpenProbe bool + deterministic bool +} + +// IntentPlannerHealth owns the process-local lease and mirrors its durable +// state to daemon_meta. The state mutex is never held during provider calls or +// SQLite writes. +type IntentPlannerHealth struct { + mu sync.Mutex + persistMu sync.Mutex + + db *state.DB + now func() time.Time + deterministic bool + fingerprint string + + state IntentPlannerCircuitState + consecutiveFailures int + backoffLevel int + retryAt time.Time + openedAt time.Time + lastFailureAt time.Time + lastFailureClass IntentPlannerFailureKind + lastError string + bypassCount uint64 + updatedAt time.Time + + epoch uint64 + nextLease uint64 +} + +// NewIntentPlannerHealth loads the persisted state best-effort. A provider +// identity change resets the circuit to closed. A persisted half-open state is +// normalized to open with an immediately claimable probe because its prior +// process-local lease cannot survive a restart. +func NewIntentPlannerHealth(ctx context.Context, db *state.DB, opts IntentPlannerHealthOptions) *IntentPlannerHealth { + now := opts.Now + if now == nil { + now = time.Now + } + h := &IntentPlannerHealth{ + db: db, + now: now, + deterministic: opts.Provider.Deterministic, + fingerprint: IntentPlannerProviderFingerprint(opts.Provider), + state: IntentPlannerCircuitClosed, + } + if db == nil || ctx == nil || ctx.Err() != nil { + return h + } + + var record intentPlannerHealthRecord + ok, err := state.MetaGetJSON(ctx, db, MetaKeyIntentPlannerHealth, &record) + if err != nil { + warnIntentPlannerHealthPersistence("load", err) + return h + } + if !ok { + h.updatedAt = now().UTC() + h.persistLatest(ctx) + return h + } + normalized := h.loadRecord(record, now().UTC()) + if normalized { + h.persistLatest(ctx) + } + return h +} + +// IntentPlannerProviderFingerprint hashes only provider, model, and a +// sanitized endpoint identity. URL userinfo, query parameters, and fragments +// are excluded before hashing; API keys are never accepted by this API. +func IntentPlannerProviderFingerprint(identity IntentPlannerProviderIdentity) string { + provider := strings.TrimSpace(identity.Provider) + model := strings.TrimSpace(identity.Model) + endpoint := sanitizeIntentPlannerEndpoint(identity.Endpoint) + mode := "remote" + if identity.Deterministic { + mode = "deterministic" + } + sum := sha256.Sum256([]byte(provider + "\x00" + model + "\x00" + endpoint + "\x00" + mode)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Acquire returns the sole half-open probe lease when cooldown has elapsed. +// Closed and deterministic circuits pass through. Cancellation is checked +// before any state mutation. +func (h *IntentPlannerHealth) Acquire(ctx context.Context) (IntentPlannerHealthPermit, error) { + if h == nil { + return IntentPlannerHealthPermit{deterministic: true}, nil + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return IntentPlannerHealthPermit{}, err + } + if h.deterministic { + return IntentPlannerHealthPermit{deterministic: true}, nil + } + + now := h.now().UTC() + h.mu.Lock() + if err := ctx.Err(); err != nil { + h.mu.Unlock() + return IntentPlannerHealthPermit{}, err + } + switch h.state { + case IntentPlannerCircuitOpen: + if now.Before(h.retryAt) { + h.bypassCount++ + h.updatedAt = now + retryAt := h.retryAt + h.mu.Unlock() + h.persistLatest(ctx) + return IntentPlannerHealthPermit{}, &IntentPlannerCircuitOpenError{RetryAt: retryAt} + } + h.state = IntentPlannerCircuitHalfOpen + h.updatedAt = now + h.nextLease++ + permit := IntentPlannerHealthPermit{ + epoch: h.epoch, + lease: h.nextLease, + halfOpenProbe: true, + } + h.mu.Unlock() + h.persistLatest(ctx) + return permit, nil + case IntentPlannerCircuitHalfOpen: + h.bypassCount++ + h.updatedAt = now + h.mu.Unlock() + h.persistLatest(ctx) + return IntentPlannerHealthPermit{}, &IntentPlannerCircuitOpenError{HalfOpen: true} + default: + permit := IntentPlannerHealthPermit{epoch: h.epoch} + h.mu.Unlock() + return permit, nil + } +} + +// Complete records a validated success when failure is nil, or a typed +// transport/validation failure otherwise. Caller cancellation wins over any +// concurrently returned provider failure and does not mutate state; a +// provider-internal cancellation with a live caller is a transport failure. +func (h *IntentPlannerHealth) Complete(ctx context.Context, permit IntentPlannerHealthPermit, failure error) error { + if h == nil || permit.deterministic || h.deterministic { + return nil + } + if ctx == nil { + ctx = context.Background() + } + if callerErr := ctx.Err(); callerErr != nil { + h.releaseCanceledHalfOpenProbe(ctx, permit) + return callerErr + } + + kind, classified := classifyIntentPlannerFailure(failure) + if failure != nil && !classified { + return &IntentPlannerFailureTypeError{Err: failure} + } + + now := h.now().UTC() + changed := false + h.mu.Lock() + if callerErr := ctx.Err(); callerErr != nil { + changed = h.releaseCanceledHalfOpenProbeLocked(now, permit) + h.mu.Unlock() + if changed { + h.persistLatest(context.WithoutCancel(ctx)) + } + return callerErr + } + if !h.permitCurrentLocked(permit) { + h.mu.Unlock() + return nil + } + if failure == nil { + h.closeLocked(now) + changed = true + } else { + changed = h.failLocked(now, kind, failure, permit.halfOpenProbe) + } + h.mu.Unlock() + if changed { + h.persistLatest(ctx) + } + return nil +} + +func (h *IntentPlannerHealth) releaseCanceledHalfOpenProbe(ctx context.Context, permit IntentPlannerHealthPermit) { + now := h.now().UTC() + h.mu.Lock() + changed := h.releaseCanceledHalfOpenProbeLocked(now, permit) + h.mu.Unlock() + if changed { + h.persistLatest(context.WithoutCancel(ctx)) + } +} + +func (h *IntentPlannerHealth) releaseCanceledHalfOpenProbeLocked(now time.Time, permit IntentPlannerHealthPermit) bool { + if !permit.halfOpenProbe || !h.permitCurrentLocked(permit) { + return false + } + h.state = IntentPlannerCircuitOpen + h.retryAt = now + h.updatedAt = now + h.epoch++ + return true +} + +// Snapshot returns an immutable copy for tests and future CLI observability. +func (h *IntentPlannerHealth) Snapshot() IntentPlannerHealthSnapshot { + if h == nil { + return IntentPlannerHealthSnapshot{State: IntentPlannerCircuitClosed} + } + h.mu.Lock() + defer h.mu.Unlock() + return h.snapshotLocked() +} + +func (h *IntentPlannerHealth) permitCurrentLocked(permit IntentPlannerHealthPermit) bool { + if permit.epoch != h.epoch { + return false + } + if permit.halfOpenProbe { + return h.state == IntentPlannerCircuitHalfOpen && permit.lease == h.nextLease + } + return h.state == IntentPlannerCircuitClosed +} + +func (h *IntentPlannerHealth) failLocked(now time.Time, kind IntentPlannerFailureKind, failure error, halfOpen bool) bool { + h.consecutiveFailures++ + h.lastFailureAt = now + h.lastFailureClass = kind + h.lastError = ai.SanitizePlannerError(failure.Error()) + h.updatedAt = now + + if halfOpen { + if h.backoffLevel < len(intentPlannerCircuitBackoffs)-1 { + h.backoffLevel++ + } + h.openLocked(now) + return true + } + if kind == IntentPlannerFailureValidation { + if h.consecutiveFailures < 3 { + return true + } + } + h.backoffLevel = 0 + h.openLocked(now) + return true +} + +func (h *IntentPlannerHealth) openLocked(now time.Time) { + h.state = IntentPlannerCircuitOpen + h.openedAt = now + h.retryAt = now.Add(intentPlannerCircuitBackoffs[h.backoffLevel]) + h.epoch++ +} + +func (h *IntentPlannerHealth) closeLocked(now time.Time) { + h.state = IntentPlannerCircuitClosed + h.consecutiveFailures = 0 + h.backoffLevel = 0 + h.retryAt = time.Time{} + h.openedAt = time.Time{} + h.lastFailureAt = time.Time{} + h.lastFailureClass = "" + h.lastError = "" + h.updatedAt = now + h.epoch++ +} + +func (h *IntentPlannerHealth) loadRecord(record intentPlannerHealthRecord, now time.Time) bool { + if record.Version != intentPlannerHealthVersion || record.ProviderFingerprint != h.fingerprint { + h.updatedAt = now + return true + } + + h.state = record.State + h.consecutiveFailures = record.ConsecutiveFailures + h.backoffLevel = record.BackoffLevel + h.retryAt = intentPlannerHealthTime(record.NextProbeTS) + h.openedAt = intentPlannerHealthTime(record.OpenedTS) + h.lastFailureAt = intentPlannerHealthTime(record.LastFailureTS) + h.lastFailureClass = record.LastFailureClass + sanitizedLastError := ai.SanitizePlannerError(record.LastError) + h.lastError = sanitizedLastError + h.bypassCount = record.BypassCount + h.updatedAt = intentPlannerHealthTime(record.UpdatedTS) + + normalized := sanitizedLastError != record.LastError + if h.backoffLevel < 0 { + h.backoffLevel = 0 + normalized = true + } else if h.backoffLevel >= len(intentPlannerCircuitBackoffs) { + h.backoffLevel = len(intentPlannerCircuitBackoffs) - 1 + normalized = true + } + if h.consecutiveFailures < 0 { + h.consecutiveFailures = 0 + normalized = true + } + switch h.state { + case IntentPlannerCircuitClosed: + if h.consecutiveFailures > 2 { + h.consecutiveFailures = 2 + normalized = true + } + if !h.retryAt.IsZero() || !h.openedAt.IsZero() || h.backoffLevel != 0 { + h.retryAt = time.Time{} + h.openedAt = time.Time{} + h.backoffLevel = 0 + normalized = true + } + case IntentPlannerCircuitHalfOpen: + h.state = IntentPlannerCircuitOpen + h.retryAt = now + h.updatedAt = now + normalized = true + case IntentPlannerCircuitOpen: + if h.retryAt.IsZero() { + h.retryAt = now + h.updatedAt = now + normalized = true + } + default: + h.state = IntentPlannerCircuitClosed + h.consecutiveFailures = 0 + h.backoffLevel = 0 + h.retryAt = time.Time{} + h.openedAt = time.Time{} + h.lastFailureAt = time.Time{} + h.lastFailureClass = "" + h.lastError = "" + h.updatedAt = now + normalized = true + } + if normalized { + h.epoch++ + } + return normalized +} + +func (h *IntentPlannerHealth) snapshotLocked() IntentPlannerHealthSnapshot { + return IntentPlannerHealthSnapshot{ + State: h.state, + ProviderFingerprint: h.fingerprint, + ConsecutiveFailures: h.consecutiveFailures, + BackoffLevel: h.backoffLevel, + NextProbeTS: intentPlannerHealthTimestamp(h.retryAt), + OpenedTS: intentPlannerHealthTimestamp(h.openedAt), + LastFailureTS: intentPlannerHealthTimestamp(h.lastFailureAt), + LastFailureClass: h.lastFailureClass, + LastError: h.lastError, + BypassCount: h.bypassCount, + UpdatedTS: intentPlannerHealthTimestamp(h.updatedAt), + } +} + +func (h *IntentPlannerHealth) persistLatest(ctx context.Context) { + if h == nil || h.db == nil || ctx == nil || ctx.Err() != nil { + return + } + // Serialize writes, then re-read the latest in-memory state. If two callers + // update concurrently, a delayed older persist therefore writes the newest + // snapshot rather than overwriting it with stale state. + h.persistMu.Lock() + defer h.persistMu.Unlock() + h.mu.Lock() + record := intentPlannerHealthRecord{ + Version: intentPlannerHealthVersion, + IntentPlannerHealthSnapshot: h.snapshotLocked(), + } + h.mu.Unlock() + if err := state.MetaSetJSON(ctx, h.db, MetaKeyIntentPlannerHealth, record); err != nil { + warnIntentPlannerHealthPersistence("save", err) + } +} + +func warnIntentPlannerHealthPersistence(operation string, err error) { + if err == nil { + return + } + slog.Warn("intent planner health persistence failed", + "operation", operation, + "error", ai.SanitizePlannerError(err.Error())) +} + +func classifyIntentPlannerFailure(err error) (IntentPlannerFailureKind, bool) { + if err == nil { + return "", true + } + var transport *IntentPlannerTransportFailure + if errors.As(err, &transport) { + return IntentPlannerFailureTransport, true + } + var validation *IntentPlannerValidationFailure + if errors.As(err, &validation) { + return IntentPlannerFailureValidation, true + } + // Providers commonly create their own child timeout context. Its + // cancellation does not cancel the caller context passed to Complete, so + // these unwrapped sentinel errors are transport failures, not user aborts. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return IntentPlannerFailureTransport, true + } + return "", false +} + +func sanitizeIntentPlannerEndpoint(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + // The value is hashed before persistence. Removing controls and obvious + // query/fragment suffixes still makes malformed identities stable without + // ever exposing the source value. + if i := strings.IndexAny(raw, "?#"); i >= 0 { + raw = raw[:i] + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, raw) + } + u.User = nil + u.RawQuery = "" + u.ForceQuery = false + u.Fragment = "" + u.RawFragment = "" + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + return u.String() +} + +func intentPlannerHealthTimestamp(t time.Time) float64 { + if t.IsZero() { + return 0 + } + return float64(t.UTC().UnixNano()) / 1e9 +} + +func intentPlannerHealthTime(ts float64) time.Time { + if ts <= 0 { + return time.Time{} + } + seconds, fraction := math.Modf(ts) + return time.Unix(int64(seconds), int64(fraction*1e9)).UTC() +} diff --git a/internal/daemon/intent_health_integration_test.go b/internal/daemon/intent_health_integration_test.go new file mode 100644 index 00000000..d0a8aa19 --- /dev/null +++ b/internal/daemon/intent_health_integration_test.go @@ -0,0 +1,498 @@ +package daemon + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/prompttrace" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +type intentHealthPromptRecorder struct { + mu sync.Mutex + records []prompttrace.Record +} + +type intentHealthRunProvider struct { + mu sync.Mutex + plans int + requests []ai.IntentPlanRequest +} + +func (p *intentHealthRunProvider) Name() string { return "openai-compat" } +func (p *intentHealthRunProvider) NeedsDiff() bool { return true } + +func (p *intentHealthRunProvider) Generate(context.Context, ai.CommitContext) (ai.Result, error) { + return ai.Result{Subject: "Generate should not serve intent planning", Source: p.Name()}, nil +} + +func (p *intentHealthRunProvider) PlanIntent(ctx context.Context, req ai.IntentPlanRequest) (ai.IntentPlan, error) { + plan, err := (ai.DeterministicProvider{}).PlanIntent(ctx, req) + if err != nil { + return ai.IntentPlan{}, err + } + plan.Subject = "Reuse run-scoped planner" + plan.GroupingReason = "run-scoped provider selected the pending capture" + plan.Source = p.Name() + p.mu.Lock() + p.plans++ + p.requests = append(p.requests, req) + p.mu.Unlock() + return plan, nil +} + +func (p *intentHealthRunProvider) snapshot() (int, []ai.IntentPlanRequest) { + p.mu.Lock() + defer p.mu.Unlock() + return p.plans, append([]ai.IntentPlanRequest(nil), p.requests...) +} + +type intentHealthRunCloser struct{ calls atomic.Int32 } + +func (c *intentHealthRunCloser) Close() error { + c.calls.Add(1) + return nil +} + +func (r *intentHealthPromptRecorder) Record(record prompttrace.Record) { + r.mu.Lock() + r.records = append(r.records, record) + r.mu.Unlock() +} + +func (r *intentHealthPromptRecorder) Close() error { return nil } +func (r *intentHealthPromptRecorder) Dropped() uint64 { return 0 } + +func (r *intentHealthPromptRecorder) Records() []prompttrace.Record { + r.mu.Lock() + defer r.mu.Unlock() + return append([]prompttrace.Record(nil), r.records...) +} + +func countIntentPlannerErrorDecisions(t *testing.T, ctx context.Context, db *state.DB, seqs []int64) int { + t.Helper() + total := 0 + for _, seq := range seqs { + rows, err := state.DecisionsForEvent(ctx, db, seq, 100) + if err != nil { + t.Fatalf("DecisionsForEvent(%d): %v", seq, err) + } + for _, row := range rows { + if row.Kind == state.DecisionKindIntentPlannerError { + total++ + } + } + } + return total +} + +func TestReplay_IntentHealthSingletonOutageUsesCircuitFallback(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "singleton-outage.txt", "offline\n") + + planner := &recordingIntentPlanner{name: "openai-compat", err: errors.New("remote unavailable")} + health := NewIntentPlannerHealth(ctx, f.db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + messageCalls := 0 + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + MessageFn: func(context.Context, EventContext) (string, error) { messageCalls++; return "must not run", nil }, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentHealth: health, + IntentPlannerProvider: "openai-compat", + IntentPlannerModel: "planner-model", + IntentWindow: 10, + IntentMinPending: 1, + IntentBypassBatchWait: true, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 1 || planner.calls != 1 || messageCalls != 0 { + t.Fatalf("summary=%+v planner_calls=%d message_calls=%d", sum, planner.calls, messageCalls) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitOpen || snap.LastFailureClass != IntentPlannerFailureTransport { + t.Fatalf("health=%+v want transport-open", snap) + } +} + +func TestReplay_IntentHealthProviderShapeFailureCountsAsValidation(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "provider-shape.txt", "invalid\n") + + planner := &recordingIntentPlanner{name: "openai-compat", err: &ai.IntentPlanValidationError{ + Code: ai.IntentPlanValidationShape, + Message: "openai-compat: no choices in response", + }} + health := NewIntentPlannerHealth(ctx, f.db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentHealth: health, + IntentPlannerProvider: "openai-compat", + IntentWindow: 10, + IntentMinPending: 1, + IntentBypassBatchWait: true, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 1 || planner.calls != 1 { + t.Fatalf("summary=%+v planner_calls=%d", sum, planner.calls) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitClosed || + snap.ConsecutiveFailures != 1 || snap.LastFailureClass != IntentPlannerFailureValidation { + t.Fatalf("health=%+v want one closed validation failure", snap) + } +} + +func TestReplay_IntentFailureSanitizesDurableObservability(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "secret-error.txt", "change\n") + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil || len(pending) != 1 { + t.Fatalf("pending=%d err=%v", len(pending), err) + } + const secret = "planner-response-secret" + planner := &recordingIntentPlanner{ + name: "openai-compat", + err: errors.New(`openai-compat: http 400: {"token":"` + secret + `"}`), + } + health := NewIntentPlannerHealth(ctx, f.db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + prompts := &intentHealthPromptRecorder{} + traces := &memoryTraceLogger{} + if _, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + Trace: traces, + PromptTrace: prompts, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentHealth: health, + IntentPlannerProvider: "openai-compat", + IntentWindow: 10, + IntentMinPending: 1, + IntentBypassBatchWait: true, + }); err != nil { + t.Fatalf("Replay: %v", err) + } + assertNoSecret := func(label, value string) { + t.Helper() + if strings.Contains(value, secret) || !strings.Contains(value, "[REDACTED]") { + t.Fatalf("%s=%q", label, value) + } + } + assertNoSecret("health last error", health.Snapshot().LastError) + decisions, err := state.DecisionsForEvent(ctx, f.db, pending[0].Seq, 20) + if err != nil { + t.Fatalf("DecisionsForEvent: %v", err) + } + foundDecision := false + for _, decision := range decisions { + if decision.Kind == state.DecisionKindIntentPlannerError { + foundDecision = true + assertNoSecret("decision reason", decision.Reason.String) + } + } + if !foundDecision { + t.Fatal("planner error decision missing") + } + windows, err := state.RecentIntentPlannerWindows(ctx, f.db, 1) + if err != nil || len(windows) != 1 { + t.Fatalf("windows=%d err=%v", len(windows), err) + } + assertNoSecret("window validation failure", windows[0].ValidationFailure.String) + foundPrompt := false + for _, record := range prompts.Records() { + if record.Stage == "fallback" && record.Response != nil { + foundPrompt = true + assertNoSecret("prompt fallback", record.Response.FallbackReason) + } + } + if !foundPrompt { + t.Fatal("prompt fallback record missing") + } + foundTrace := false + for _, event := range traces.Events() { + if event.EventClass == "intent.planner.validation_failed" { + foundTrace = true + assertNoSecret("trace error", event.Error) + } + } + if !foundTrace { + t.Fatal("planner validation trace missing") + } +} + +func TestReplay_IntentHealthOpenBypassDoesNotRepeatPlannerErrors(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "first.txt", "first\n") + captureOnePendingFile(t, ctx, f, "second.txt", "second\n") + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil { + t.Fatalf("PendingEvents: %v", err) + } + seqs := []int64{pending[0].Seq, pending[1].Seq} + + planner := &recordingIntentPlanner{name: "openai-compat", err: errors.New("gateway timeout")} + health := NewIntentPlannerHealth(ctx, f.db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + prompts := &intentHealthPromptRecorder{} + opts := ReplayOpts{ + GitDir: f.gitDir, + PromptTrace: prompts, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentHealth: health, + IntentPlannerProvider: "openai-compat", + IntentPlannerModel: "planner-model", + IntentIncludeDiffs: true, + IntentWindow: 10, + IntentMinPending: 1, + IntentBypassBatchWait: true, + } + first, err := Replay(ctx, f.dir, f.db, f.cctx, opts) + if err != nil { + t.Fatalf("first Replay: %v", err) + } + if first.Published != 1 || planner.calls != 1 { + t.Fatalf("first summary=%+v planner_calls=%d", first, planner.calls) + } + firstErrorCount := countIntentPlannerErrorDecisions(t, ctx, f.db, seqs) + if firstErrorCount != 2 { + t.Fatalf("first planner-error decisions=%d want 2", firstErrorCount) + } + + f.cctx.BaseHead = first.BaseHead + second, err := Replay(ctx, f.dir, f.db, f.cctx, opts) + if err != nil { + t.Fatalf("second Replay: %v", err) + } + if second.Published != 1 || planner.calls != 1 { + t.Fatalf("second summary=%+v planner_calls=%d want circuit bypass", second, planner.calls) + } + if got := countIntentPlannerErrorDecisions(t, ctx, f.db, seqs); got != firstErrorCount { + t.Fatalf("planner-error decisions grew on bypass: before=%d after=%d", firstErrorCount, got) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitOpen || snap.BypassCount != 1 { + t.Fatalf("health=%+v want open with one bypass", snap) + } + + windows, err := state.RecentIntentPlannerWindows(ctx, f.db, 2) + if err != nil { + t.Fatalf("RecentIntentPlannerWindows: %v", err) + } + if len(windows) != 2 { + t.Fatalf("windows=%d want 2", len(windows)) + } + if windows[0].Provider.String != "openai-compat" || windows[0].Model.String != "planner-model" || windows[0].ValidationFailure.Valid { + t.Fatalf("bypass window metadata=%+v", windows[0]) + } + if !windows[1].ValidationFailure.Valid || windows[1].ValidationFailure.String == "" { + t.Fatalf("first failure window missing validation failure: %+v", windows[1]) + } + + records := prompts.Records() + foundCircuitFallback := false + for _, record := range records { + if record.Stage != "fallback" || record.Response == nil || + !strings.Contains(record.Response.FallbackReason, "circuit bypass") { + continue + } + foundCircuitFallback = true + if record.Provider != "openai-compat" || record.Model != "planner-model" || !record.DiffIncluded { + t.Fatalf("circuit fallback metadata=%+v", record) + } + } + if !foundCircuitFallback { + t.Fatalf("prompt records missing circuit fallback: %+v", records) + } +} + +func TestReplay_IntentHealthSelectionSafetyFailureIsValidation(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + path := filepath.Join(f.dir, "same.txt") + if err := os.WriteFile(path, []byte("first\n"), 0o644); err != nil { + t.Fatalf("write first: %v", err) + } + if _, err := Capture(ctx, f.dir, f.db, f.cctx, CaptureOpts{IgnoreChecker: f.ig, SensitiveMatcher: f.matcher}); err != nil { + t.Fatalf("capture first: %v", err) + } + if err := os.WriteFile(path, []byte("second\n"), 0o644); err != nil { + t.Fatalf("write second: %v", err) + } + if _, err := Capture(ctx, f.dir, f.db, f.cctx, CaptureOpts{IgnoreChecker: f.ig, SensitiveMatcher: f.matcher}); err != nil { + t.Fatalf("capture second: %v", err) + } + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil || len(pending) != 2 { + t.Fatalf("pending=%d err=%v want 2", len(pending), err) + } + planner := &recordingIntentPlanner{name: "openai-compat", plan: ai.IntentPlan{ + SelectedSeqs: []int64{pending[1].Seq}, + DeferredSeqs: []int64{pending[0].Seq}, + DeferredReasons: []ai.DeferredReason{{Seq: pending[0].Seq, Reason: "wait"}}, + Subject: "Publish dependent edit", + GroupingReason: "incorrectly skipped the prior same-path capture", + }} + health := NewIntentPlannerHealth(ctx, f.db, IntentPlannerHealthOptions{Provider: openAIIntentHealthIdentity("https://planner.example/v1")}) + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentHealth: health, + IntentWindow: 10, + IntentMinPending: 1, + IntentBypassBatchWait: true, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 1 { + t.Fatalf("summary=%+v want deterministic fallback publish", sum) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitClosed || + snap.ConsecutiveFailures != 1 || snap.LastFailureClass != IntentPlannerFailureValidation { + t.Fatalf("health=%+v want one closed validation failure", snap) + } +} + +func TestRun_IntentHealthReusesInjectedProvider(t *testing.T) { + t.Setenv(ai.EnvCommitStrategy, string(ai.CommitStrategyIntent)) + t.Setenv(ai.EnvIntentMinPending, "1") + t.Setenv(ai.EnvIntentSettleWindow, "0") + t.Setenv("ACD_AI_DIFF_EGRESS", "1") + + f := newDaemonFixture(t) + registerLiveClient(t, f.db) + if err := os.WriteFile(filepath.Join(f.dir, "provider-reuse.txt"), []byte("reuse\n"), 0o644); err != nil { + t.Fatalf("write provider-reuse.txt: %v", err) + } + provider := &intentHealthRunProvider{} + closer := &intentHealthRunCloser{} + wakeCh := make(chan struct{}, 4) + shutdownCh := make(chan struct{}, 1) + runCtx, cancel := context.WithCancel(context.Background()) + manual := Scheduler{Base: time.Hour, IdleCeiling: time.Hour, ErrorCeiling: time.Hour} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = Run(runCtx, Options{ + RepoPath: f.dir, + GitDir: f.gitDir, + DB: f.db, + Scheduler: manual, + BootGrace: 30 * time.Second, + MessageProvider: provider, + MessageProviderCloser: closer, + WakeCh: wakeCh, + ShutdownCh: shutdownCh, + SkipSignals: true, + }) + }() + t.Cleanup(func() { + cancel() + wg.Wait() + }) + + waitFor(t, 5*time.Second, "run-scoped intent planner call", func() bool { + calls, _ := provider.snapshot() + return calls >= 1 + }) + waitFor(t, 5*time.Second, "provider-reuse capture published", func() bool { + var count int + if err := f.db.ReadSQL().QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM capture_events WHERE state = ?`, state.EventStatePublished).Scan(&count); err != nil { + return false + } + return count >= 1 + }) + if err := os.WriteFile(filepath.Join(f.dir, "provider-reuse-second.txt"), []byte("reuse again\n"), 0o644); err != nil { + t.Fatalf("write provider-reuse-second.txt: %v", err) + } + wakeCh <- struct{}{} + waitFor(t, 5*time.Second, "second run-scoped intent planner call", func() bool { + calls, _ := provider.snapshot() + return calls >= 2 + }) + waitFor(t, 5*time.Second, "second provider-reuse capture published", func() bool { + var count int + if err := f.db.ReadSQL().QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM capture_events WHERE state = ?`, state.EventStatePublished).Scan(&count); err != nil { + return false + } + return count >= 2 + }) + cancel() + wg.Wait() + + calls, requests := provider.snapshot() + if calls != 2 { + t.Fatalf("PlanIntent calls=%d want two calls on one reused provider", calls) + } + if len(requests) != 2 { + t.Fatalf("planner requests=%d want 2", len(requests)) + } + for i, request := range requests { + if len(request.OfferedCaptures) == 0 || request.OfferedCaptures[0].CapturedDiff == "" { + t.Fatalf("planner request %d=%+v want diff-bearing request", i, request) + } + } + if got := closer.calls.Load(); got != 1 { + t.Fatalf("provider Close calls=%d want 1", got) + } + var persisted intentPlannerHealthRecord + if ok, err := state.MetaGetJSON(context.Background(), f.db, MetaKeyIntentPlannerHealth, &persisted); err != nil || !ok { + t.Fatalf("load persisted intent health ok=%v err=%v", ok, err) + } + if persisted.State != IntentPlannerCircuitClosed || persisted.ProviderFingerprint == "" { + t.Fatalf("persisted health=%+v", persisted) + } +} diff --git a/internal/daemon/intent_health_test.go b/internal/daemon/intent_health_test.go new file mode 100644 index 00000000..6537a842 --- /dev/null +++ b/internal/daemon/intent_health_test.go @@ -0,0 +1,706 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "path/filepath" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + "unicode" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/ai" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +type intentHealthClock struct { + mu sync.Mutex + now time.Time +} + +func newIntentHealthClock() *intentHealthClock { + return &intentHealthClock{now: time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC)} +} + +func (c *intentHealthClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *intentHealthClock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +func openAIIntentHealthIdentity(endpoint string) IntentPlannerProviderIdentity { + return IntentPlannerProviderIdentity{ + Provider: "openai-compat", + Model: "planner-model", + Endpoint: endpoint, + } +} + +func newIntentHealthTestDB(t *testing.T) *state.DB { + t.Helper() + db, err := state.Open(context.Background(), filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func readIntentHealthRecord(t *testing.T, db *state.DB) intentPlannerHealthRecord { + t.Helper() + var record intentPlannerHealthRecord + ok, err := state.MetaGetJSON(context.Background(), db, MetaKeyIntentPlannerHealth, &record) + if err != nil { + t.Fatalf("MetaGetJSON: %v", err) + } + if !ok { + t.Fatal("intent planner health record missing") + } + return record +} + +func TestIntentPlannerHealthTransportOpensImmediatelyAndBacksOff(t *testing.T) { + db := newIntentHealthTestDB(t) + clock := newIntentHealthClock() + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + + permit, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire closed: %v", err) + } + if err := health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{ + Err: errors.New("connection refused"), + }); err != nil { + t.Fatalf("Complete transport: %v", err) + } + + snap := health.Snapshot() + if snap.State != IntentPlannerCircuitOpen || snap.BackoffLevel != 0 { + t.Fatalf("snapshot=%+v want open level 0", snap) + } + if got, want := snap.NextProbeTS, intentPlannerHealthTimestamp(clock.Now().Add(30*time.Second)); got != want { + t.Fatalf("next_probe_ts=%f want %f", got, want) + } + if _, err := health.Acquire(context.Background()); err == nil { + t.Fatal("Acquire during cooldown succeeded") + } else { + var openErr *IntentPlannerCircuitOpenError + if !errors.As(err, &openErr) || openErr.HalfOpen { + t.Fatalf("Acquire error=%T %v want cooldown open error", err, err) + } + } + if got := health.Snapshot().BypassCount; got != 1 { + t.Fatalf("bypass_count=%d want 1", got) + } + + for step, tc := range []struct { + advance time.Duration + failure error + level int + backoff time.Duration + }{ + {30 * time.Second, &IntentPlannerTransportFailure{Err: errors.New("timeout")}, 1, 2 * time.Minute}, + {2 * time.Minute, &IntentPlannerValidationFailure{Err: errors.New("invalid selection")}, 2, 10 * time.Minute}, + {10 * time.Minute, &IntentPlannerTransportFailure{Err: errors.New("still unavailable")}, 2, 10 * time.Minute}, + } { + clock.Advance(tc.advance) + probe, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("step %d Acquire probe: %v", step, err) + } + if !probe.halfOpenProbe { + t.Fatalf("step %d permit=%+v want half-open probe", step, probe) + } + if err := health.Complete(context.Background(), probe, tc.failure); err != nil { + t.Fatalf("step %d Complete: %v", step, err) + } + snap = health.Snapshot() + if snap.State != IntentPlannerCircuitOpen || snap.BackoffLevel != tc.level { + t.Fatalf("step %d snapshot=%+v want open level %d", step, snap, tc.level) + } + if got, want := snap.NextProbeTS, intentPlannerHealthTimestamp(clock.Now().Add(tc.backoff)); got != want { + t.Fatalf("step %d next_probe_ts=%f want %f", step, got, want) + } + } + + persisted := readIntentHealthRecord(t, db) + if persisted.State != IntentPlannerCircuitOpen || persisted.BackoffLevel != 2 || persisted.BypassCount != 1 { + t.Fatalf("persisted=%+v", persisted) + } +} + +func TestIntentPlannerHealthOpensAfterThreeValidationFailures(t *testing.T) { + clock := newIntentHealthClock() + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + + for attempt := 1; attempt <= 3; attempt++ { + permit, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("attempt %d Acquire: %v", attempt, err) + } + err = health.Complete(context.Background(), permit, &IntentPlannerValidationFailure{ + Err: fmt.Errorf("validation %d", attempt), + }) + if err != nil { + t.Fatalf("attempt %d Complete: %v", attempt, err) + } + snap := health.Snapshot() + if snap.ConsecutiveFailures != attempt { + t.Fatalf("attempt %d failures=%d", attempt, snap.ConsecutiveFailures) + } + wantState := IntentPlannerCircuitClosed + if attempt == 3 { + wantState = IntentPlannerCircuitOpen + } + if snap.State != wantState { + t.Fatalf("attempt %d state=%s want %s", attempt, snap.State, wantState) + } + } +} + +func TestIntentPlannerHealthSuccessResetsValidationAndClosesProbe(t *testing.T) { + clock := newIntentHealthClock() + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + + permit, _ := health.Acquire(context.Background()) + _ = health.Complete(context.Background(), permit, &IntentPlannerValidationFailure{Err: errors.New("bad plan")}) + permit, _ = health.Acquire(context.Background()) + if err := health.Complete(context.Background(), permit, nil); err != nil { + t.Fatalf("Complete success: %v", err) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitClosed || + snap.ConsecutiveFailures != 0 || snap.LastError != "" { + t.Fatalf("after success snapshot=%+v", snap) + } + + permit, _ = health.Acquire(context.Background()) + _ = health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("down")}) + clock.Advance(30 * time.Second) + probe, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire half-open: %v", err) + } + if err := health.Complete(context.Background(), probe, nil); err != nil { + t.Fatalf("Complete probe success: %v", err) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitClosed || snap.BackoffLevel != 0 || snap.NextProbeTS != 0 { + t.Fatalf("after probe success snapshot=%+v", snap) + } +} + +func TestIntentPlannerHealthExactlyOneHalfOpenLease(t *testing.T) { + clock := newIntentHealthClock() + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + permit, _ := health.Acquire(context.Background()) + _ = health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("down")}) + clock.Advance(30 * time.Second) + + const callers = 32 + start := make(chan struct{}) + var allowed atomic.Int32 + var halfOpenRejected atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := health.Acquire(context.Background()) + if err == nil { + allowed.Add(1) + return + } + var openErr *IntentPlannerCircuitOpenError + if errors.As(err, &openErr) && openErr.HalfOpen { + halfOpenRejected.Add(1) + } + }() + } + close(start) + wg.Wait() + if got := allowed.Load(); got != 1 { + t.Fatalf("allowed=%d want 1", got) + } + if got := halfOpenRejected.Load(); got != callers-1 { + t.Fatalf("half-open rejected=%d want %d", got, callers-1) + } + if got := health.Snapshot().BypassCount; got != callers-1 { + t.Fatalf("bypass_count=%d want %d", got, callers-1) + } +} + +func TestIntentPlannerHealthProviderFingerprintReset(t *testing.T) { + db := newIntentHealthTestDB(t) + clock := newIntentHealthClock() + providerA := openAIIntentHealthIdentity("https://planner-a.example/v1") + providerB := openAIIntentHealthIdentity("https://planner-b.example/v1") + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{Provider: providerA, Now: clock.Now}) + permit, _ := health.Acquire(context.Background()) + _ = health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("down")}) + + reset := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{Provider: providerB, Now: clock.Now}) + if snap := reset.Snapshot(); snap.State != IntentPlannerCircuitClosed || + snap.ProviderFingerprint != IntentPlannerProviderFingerprint(providerB) { + t.Fatalf("reset snapshot=%+v", snap) + } + persisted := readIntentHealthRecord(t, db) + if persisted.State != IntentPlannerCircuitClosed || persisted.ProviderFingerprint != IntentPlannerProviderFingerprint(providerB) { + t.Fatalf("persisted reset=%+v", persisted) + } +} + +func TestIntentPlannerHealthDeterministicBypassPersistsProviderReset(t *testing.T) { + db := newIntentHealthTestDB(t) + clock := newIntentHealthClock() + remote := openAIIntentHealthIdentity("https://planner.example/v1") + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{Provider: remote, Now: clock.Now}) + permit, _ := health.Acquire(context.Background()) + _ = health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("down")}) + deterministic := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{ + // Keep the provider label deliberately identical: changing only the + // provider mode must still reset yesterday's remote outage. + Provider: IntentPlannerProviderIdentity{Provider: "openai-compat", Deterministic: true}, + Now: clock.Now, + }) + permit, err := deterministic.Acquire(context.Background()) + if err != nil { + t.Fatalf("deterministic Acquire: %v", err) + } + if err := deterministic.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("ignored")}); err != nil { + t.Fatalf("deterministic Complete: %v", err) + } + after := readIntentHealthRecord(t, db) + if after.State != IntentPlannerCircuitClosed || after.ConsecutiveFailures != 0 || after.LastError != "" { + t.Fatalf("deterministic reset persisted=%+v", after) + } + if after.ProviderFingerprint != IntentPlannerProviderFingerprint(IntentPlannerProviderIdentity{ + Provider: "openai-compat", Deterministic: true, + }) { + t.Fatalf("deterministic fingerprint=%q", after.ProviderFingerprint) + } +} + +func TestIntentPlannerHealthRestartNormalizesHalfOpenLease(t *testing.T) { + db := newIntentHealthTestDB(t) + clock := newIntentHealthClock() + provider := openAIIntentHealthIdentity("https://planner.example/v1") + fingerprint := IntentPlannerProviderFingerprint(provider) + if err := state.MetaSetJSON(context.Background(), db, MetaKeyIntentPlannerHealth, intentPlannerHealthRecord{ + Version: intentPlannerHealthVersion, + IntentPlannerHealthSnapshot: IntentPlannerHealthSnapshot{ + State: IntentPlannerCircuitHalfOpen, + ProviderFingerprint: fingerprint, + BackoffLevel: 1, + NextProbeTS: intentPlannerHealthTimestamp(clock.Now().Add(-time.Minute)), + }, + }); err != nil { + t.Fatalf("seed half-open: %v", err) + } + + restarted := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{Provider: provider, Now: clock.Now}) + snap := restarted.Snapshot() + if snap.State != IntentPlannerCircuitOpen || snap.NextProbeTS != intentPlannerHealthTimestamp(clock.Now()) { + t.Fatalf("restart snapshot=%+v", snap) + } + first, err := restarted.Acquire(context.Background()) + if err != nil || !first.halfOpenProbe { + t.Fatalf("first Acquire permit=%+v err=%v", first, err) + } + if _, err := restarted.Acquire(context.Background()); err == nil { + t.Fatal("second Acquire unexpectedly obtained restart probe") + } else { + var openErr *IntentPlannerCircuitOpenError + if !errors.As(err, &openErr) || !openErr.HalfOpen { + t.Fatalf("second Acquire error=%T %v", err, err) + } + } + if persisted := readIntentHealthRecord(t, db); persisted.State != IntentPlannerCircuitHalfOpen { + t.Fatalf("persisted state=%s want half_open after claim", persisted.State) + } +} + +func TestIntentPlannerHealthCancellationDoesNotMutate(t *testing.T) { + clock := newIntentHealthClock() + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + permit, _ := health.Acquire(context.Background()) + before := health.Snapshot() + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if err := health.Complete(canceled, permit, context.Canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("Complete error=%v want context.Canceled", err) + } + if after := health.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("canceled Complete mutated state\nbefore=%+v\nafter=%+v", before, after) + } + if err := health.Complete(context.Background(), permit, context.Canceled); err != nil { + t.Fatalf("Complete provider-internal context.Canceled: %v", err) + } + if after := health.Snapshot(); after.State != IntentPlannerCircuitOpen || after.LastFailureClass != IntentPlannerFailureTransport { + t.Fatalf("provider-internal cancellation snapshot=%+v want transport-open", after) + } + + clock.Advance(30 * time.Second) + openBefore := health.Snapshot() + if _, err := health.Acquire(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled Acquire error=%v", err) + } + if after := health.Snapshot(); !reflect.DeepEqual(openBefore, after) { + t.Fatalf("canceled Acquire mutated state\nbefore=%+v\nafter=%+v", openBefore, after) + } +} + +func TestIntentPlannerHealthClassifiesRewriteOutcomes(t *testing.T) { + transportCause := errors.New("rewrite endpoint unavailable") + transportErr := &ai.MessageQualityError{ + Provider: "openai-compat", + Cause: transportCause, + } + classified := classifyIntentPlannerHealthFailure(transportErr, true) + var transport *IntentPlannerTransportFailure + if !errors.As(classified, &transport) || !errors.Is(classified, transportCause) { + t.Fatalf("transport classification=%T %v", classified, classified) + } + transportHealth := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, err := transportHealth.Acquire(context.Background()) + if err != nil { + t.Fatalf("transport Acquire: %v", err) + } + if err := transportHealth.Complete(context.Background(), permit, classified); err != nil { + t.Fatalf("transport Complete: %v", err) + } + if snapshot := transportHealth.Snapshot(); snapshot.State != IntentPlannerCircuitOpen || + snapshot.LastFailureClass != IntentPlannerFailureTransport { + t.Fatalf("transport snapshot=%+v want immediate open", snapshot) + } + + malformedErr := &ai.MessageQualityError{ + Provider: "openai-compat", + Cause: &ai.IntentMessageRewriteValidationError{ + Err: errors.New("malformed rewrite response"), + }, + } + classified = classifyIntentPlannerHealthFailure(malformedErr, true) + var validation *IntentPlannerValidationFailure + if !errors.As(classified, &validation) { + t.Fatalf("malformed classification=%T %v want validation", classified, classified) + } + malformedHealth := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, err = malformedHealth.Acquire(context.Background()) + if err != nil { + t.Fatalf("malformed Acquire: %v", err) + } + if err := malformedHealth.Complete(context.Background(), permit, classified); err != nil { + t.Fatalf("malformed Complete: %v", err) + } + if snapshot := malformedHealth.Snapshot(); snapshot.State != IntentPlannerCircuitClosed || + snapshot.ConsecutiveFailures != 1 || snapshot.LastFailureClass != IntentPlannerFailureValidation { + t.Fatalf("malformed snapshot=%+v want one validation failure", snapshot) + } + + qualityErr := &ai.MessageQualityError{ + Provider: "openai-compat", + Report: ai.MessageQualityReport{ + Action: ai.MessageQualityRewrite, + }, + } + classified = classifyIntentPlannerHealthFailure(qualityErr, true) + validation = nil + if !errors.As(classified, &validation) { + t.Fatalf("quality classification=%T %v want validation", classified, classified) + } + qualityHealth := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, err = qualityHealth.Acquire(context.Background()) + if err != nil { + t.Fatalf("quality Acquire: %v", err) + } + if err := qualityHealth.Complete(context.Background(), permit, classified); err != nil { + t.Fatalf("quality Complete: %v", err) + } + if snapshot := qualityHealth.Snapshot(); snapshot.State != IntentPlannerCircuitClosed || + snapshot.ConsecutiveFailures != 1 || snapshot.LastFailureClass != IntentPlannerFailureValidation { + t.Fatalf("quality snapshot=%+v want one validation failure", snapshot) + } +} + +func TestIntentPlannerHealthWrappedCallerCancellationDoesNotMutate(t *testing.T) { + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + before := health.Snapshot() + canceled, cancel := context.WithCancel(context.Background()) + cancel() + rewriteErr := &ai.MessageQualityError{ + Provider: "openai-compat", + Cause: context.Canceled, + } + classified := classifyIntentPlannerHealthFailure(rewriteErr, true) + if err := health.Complete(canceled, permit, classified); !errors.Is(err, context.Canceled) { + t.Fatalf("Complete error=%v want context.Canceled", err) + } + if after := health.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("caller cancellation mutated health\nbefore=%+v\nafter=%+v", before, after) + } +} + +func TestIntentPlannerHealthCallerCancellationWinsUnrelatedFailureWithoutMutation(t *testing.T) { + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + before := health.Snapshot() + canceled, cancel := context.WithCancel(context.Background()) + cancel() + failure := &IntentPlannerTransportFailure{Err: errors.New("provider failed independently")} + if err := health.Complete(canceled, permit, failure); !errors.Is(err, context.Canceled) { + t.Fatalf("Complete error=%v want context.Canceled", err) + } + if after := health.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("caller cancellation mutated health\nbefore=%+v\nafter=%+v", before, after) + } +} + +func TestIntentPlannerHealthCanceledHalfOpenProbeReturnsToOpen(t *testing.T) { + clock := newIntentHealthClock() + db := newIntentHealthTestDB(t) + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + Now: clock.Now, + }) + permit, err := health.Acquire(context.Background()) + if err != nil { + t.Fatalf("initial Acquire: %v", err) + } + if err := health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("offline")}); err != nil { + t.Fatalf("open circuit: %v", err) + } + before := health.Snapshot() + clock.Advance(30 * time.Second) + probe, err := health.Acquire(context.Background()) + if err != nil || !probe.halfOpenProbe { + t.Fatalf("half-open Acquire permit=%+v err=%v", probe, err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + failure := &IntentPlannerTransportFailure{Err: errors.New("provider failed independently")} + if err := health.Complete(canceled, probe, failure); !errors.Is(err, context.Canceled) { + t.Fatalf("Complete error=%v want context.Canceled", err) + } + after := health.Snapshot() + if after.State != IntentPlannerCircuitOpen || + after.ConsecutiveFailures != before.ConsecutiveFailures || + after.BackoffLevel != before.BackoffLevel || + after.LastFailureClass != before.LastFailureClass || + after.LastError != before.LastError || + after.NextProbeTS != intentPlannerHealthTimestamp(clock.Now()) { + t.Fatalf("canceled half-open snapshot=%+v before=%+v", after, before) + } + if persisted := readIntentHealthRecord(t, db); persisted.State != IntentPlannerCircuitOpen || persisted.NextProbeTS != after.NextProbeTS { + t.Fatalf("persisted canceled probe=%+v want open at next probe", persisted) + } + next, err := health.Acquire(context.Background()) + if err != nil || !next.halfOpenProbe { + t.Fatalf("next Acquire permit=%+v err=%v want new probe", next, err) + } +} + +func TestIntentPlannerHealthRejectsUntypedFailureWithoutMutation(t *testing.T) { + health := NewIntentPlannerHealth(context.Background(), nil, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, _ := health.Acquire(context.Background()) + before := health.Snapshot() + err := health.Complete(context.Background(), permit, errors.New("ambiguous")) + var typed *IntentPlannerFailureTypeError + if !errors.As(err, &typed) { + t.Fatalf("Complete error=%T %v want IntentPlannerFailureTypeError", err, err) + } + if after := health.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("untyped failure mutated state\nbefore=%+v\nafter=%+v", before, after) + } +} + +func TestIntentPlannerHealthPersistenceIsBestEffort(t *testing.T) { + db := newIntentHealthTestDB(t) + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{ + Provider: openAIIntentHealthIdentity("https://planner.example/v1"), + }) + permit, _ := health.Acquire(context.Background()) + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + if err := db.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + if err := health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New("down")}); err != nil { + t.Fatalf("Complete with unavailable persistence: %v", err) + } + if snap := health.Snapshot(); snap.State != IntentPlannerCircuitOpen { + t.Fatalf("snapshot=%+v want in-memory open", snap) + } + if !strings.Contains(logs.String(), "intent planner health persistence failed") { + t.Fatalf("persistence warning missing: %q", logs.String()) + } +} + +func TestIntentPlannerHealthFingerprintAndErrorRedaction(t *testing.T) { + const errorCap = 512 + first := IntentPlannerProviderIdentity{ + Provider: "openai-compat", + Model: "planner-model", + Endpoint: "https://alice:password-one@EXAMPLE.com/v1?api_key=query-one#fragment-one", + } + second := IntentPlannerProviderIdentity{ + Provider: "openai-compat", + Model: "planner-model", + Endpoint: "https://bob:password-two@example.com/v1?token=query-two#fragment-two", + } + fingerprint := IntentPlannerProviderFingerprint(first) + if fingerprint != IntentPlannerProviderFingerprint(second) { + t.Fatalf("fingerprints differ after secret-only endpoint changes: %q vs %q", fingerprint, IntentPlannerProviderFingerprint(second)) + } + for _, secret := range []string{"alice", "password-one", "query-one", "api_key"} { + if strings.Contains(fingerprint, secret) { + t.Fatalf("fingerprint contains secret %q: %q", secret, fingerprint) + } + } + if fingerprint == IntentPlannerProviderFingerprint(openAIIntentHealthIdentity("https://other.example/v1")) { + t.Fatal("different endpoint identity produced identical fingerprint") + } + + raw := "request https://alice:password-one@example.com/v1?api_key=query-one#fragment " + + "Authorization: Bearer bearer-secret token=token-secret api_key=key-secret " + + "sk-1234567890abcdef\x00\n" + strings.Repeat("x", errorCap+100) + clean := ai.SanitizePlannerError(raw) + for _, secret := range []string{ + "alice", "password-one", "query-one", "bearer-secret", "token-secret", + "key-secret", "sk-1234567890abcdef", + } { + if strings.Contains(clean, secret) { + t.Fatalf("sanitized error contains %q: %q", secret, clean) + } + } + if got := len([]rune(clean)); got > errorCap { + t.Fatalf("sanitized error runes=%d want <=%d", got, errorCap) + } + for _, r := range clean { + if unicode.IsControl(r) { + t.Fatalf("sanitized error contains control rune %U", r) + } + } + + db := newIntentHealthTestDB(t) + health := NewIntentPlannerHealth(context.Background(), db, IntentPlannerHealthOptions{Provider: first}) + permit, _ := health.Acquire(context.Background()) + if err := health.Complete(context.Background(), permit, &IntentPlannerTransportFailure{Err: errors.New(raw)}); err != nil { + t.Fatalf("Complete: %v", err) + } + persisted := readIntentHealthRecord(t, db) + if persisted.ProviderFingerprint != fingerprint || persisted.LastError != clean { + t.Fatalf("persisted fingerprint/error mismatch: %+v", persisted) + } + payload, err := json.Marshal(persisted) + if err != nil { + t.Fatalf("marshal persisted health: %v", err) + } + for _, field := range []string{ + `"next_probe_ts"`, `"consecutive_failures"`, `"last_failure_class"`, `"bypass_count"`, + } { + if !bytes.Contains(payload, []byte(field)) { + t.Fatalf("persisted JSON missing %s: %s", field, payload) + } + } + for _, stale := range []string{`"retry_at"`, `"consecutive_validation_failures"`, `"last_failure_kind"`} { + if bytes.Contains(payload, []byte(stale)) { + t.Fatalf("persisted JSON contains stale field %s: %s", stale, payload) + } + } +} + +func TestDecodeIntentPlannerHealthSnapshot(t *testing.T) { + fingerprint := IntentPlannerProviderFingerprint(openAIIntentHealthIdentity("https://planner.example/v1")) + raw, err := json.Marshal(intentPlannerHealthRecord{ + Version: intentPlannerHealthVersion, + IntentPlannerHealthSnapshot: IntentPlannerHealthSnapshot{ + State: IntentPlannerCircuitOpen, + ProviderFingerprint: fingerprint, + ConsecutiveFailures: 1, + BackoffLevel: 1, + LastFailureClass: IntentPlannerFailureTransport, + LastError: "Authorization: Bearer secret-value", + }, + }) + if err != nil { + t.Fatalf("marshal health: %v", err) + } + snapshot, err := DecodeIntentPlannerHealthSnapshot(string(raw)) + if err != nil { + t.Fatalf("DecodeIntentPlannerHealthSnapshot: %v", err) + } + if snapshot.State != IntentPlannerCircuitOpen || snapshot.ProviderFingerprint != fingerprint { + t.Fatalf("snapshot=%+v", snapshot) + } + if strings.Contains(snapshot.LastError, "secret-value") { + t.Fatalf("decoded snapshot leaked secret: %q", snapshot.LastError) + } + + for _, tc := range []struct { + name string + raw string + want error + }{ + {name: "malformed", raw: `{"version":1,"state":`, want: ErrIntentPlannerHealthInvalidRecord}, + {name: "unsupported", raw: `{"version":99,"state":"open"}`, want: ErrIntentPlannerHealthUnsupportedVersion}, + {name: "unsafe fingerprint", raw: `{"version":1,"state":"open","provider_fingerprint":"api-key-value"}`, want: ErrIntentPlannerHealthInvalidRecord}, + {name: "unknown state", raw: `{"version":1,"state":"confused","provider_fingerprint":"` + fingerprint + `"}`, want: ErrIntentPlannerHealthInvalidRecord}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := DecodeIntentPlannerHealthSnapshot(tc.raw); !errors.Is(err, tc.want) { + t.Fatalf("error=%v want %v", err, tc.want) + } + }) + } +} diff --git a/internal/daemon/parallel_test.go b/internal/daemon/parallel_test.go new file mode 100644 index 00000000..cf77eba0 --- /dev/null +++ b/internal/daemon/parallel_test.go @@ -0,0 +1,20 @@ +package daemon + +import "testing" + +const maxParallelDaemonTests = 4 + +var parallelDaemonTestSlots = make(chan struct{}, maxParallelDaemonTests) + +// runBoundedParallel keeps the daemon package's Git-heavy top-level tests +// parallel without allowing them to exhaust process resources under -race. +// Acquire after t.Parallel so sequential tests are never blocked by a slot. +func runBoundedParallel(t *testing.T) { + t.Helper() + t.Parallel() + + parallelDaemonTestSlots <- struct{}{} + t.Cleanup(func() { + <-parallelDaemonTestSlots + }) +} diff --git a/internal/daemon/prune.go b/internal/daemon/prune.go index ca903fe3..1f57facd 100644 --- a/internal/daemon/prune.go +++ b/internal/daemon/prune.go @@ -7,11 +7,13 @@ package daemon import ( "context" + "errors" "fmt" "os" "strconv" "time" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) @@ -38,7 +40,10 @@ func resolveEventRetention(opt time.Duration) time.Duration { // PruneCaptureEvents drops terminal capture_events older than retention. // Returns the number of rows removed. -func PruneCaptureEvents(ctx context.Context, db *state.DB, now time.Time, retention time.Duration) (int, error) { +func PruneCaptureEvents(ctx context.Context, repoDir string, db *state.DB, now time.Time, retention time.Duration) (int, error) { + if repoDir == "" { + return 0, fmt.Errorf("daemon: PruneCaptureEvents: empty repoDir") + } if db == nil { return 0, fmt.Errorf("daemon: PruneCaptureEvents: nil db") } @@ -48,9 +53,69 @@ func PruneCaptureEvents(ctx context.Context, db *state.DB, now time.Time, retent if err != nil { return 0, err } - terminal, err := state.PruneTerminalEventsBefore(ctx, db, cutoff) + protected, err := pruneVerifiedRecoverySnapshotEvents(ctx, repoDir, db, cutoff) if err != nil { return published, err } - return published + terminal, nil + terminal, err := state.PruneTerminalEventsBefore(ctx, db, cutoff) + if err != nil { + return published + protected, err + } + return published + protected + terminal, nil +} + +func pruneVerifiedRecoverySnapshotEvents(ctx context.Context, repoDir string, db *state.DB, cutoff float64) (int, error) { + rows, err := db.ReadSQL().QueryContext(ctx, ` +SELECT DISTINCT snapshot.id, snapshot.commit_oid, snapshot.recovery_ref +FROM recovery_snapshots snapshot +JOIN recovery_snapshot_events member ON member.snapshot_id = snapshot.id +JOIN capture_events event ON event.seq = member.event_seq +WHERE event.state IN ('published', 'recovered') AND event.captured_ts < ? +ORDER BY snapshot.id`, cutoff) + if err != nil { + return 0, fmt.Errorf("daemon: list protected prune candidates: %w", err) + } + type candidate struct { + id int64 + commitOID string + ref string + } + var candidates []candidate + for rows.Next() { + var item candidate + if err := rows.Scan(&item.id, &item.commitOID, &item.ref); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("daemon: scan protected prune candidate: %w", err) + } + candidates = append(candidates, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("daemon: iterate protected prune candidates: %w", err) + } + if err := rows.Close(); err != nil { + return 0, fmt.Errorf("daemon: close protected prune candidates: %w", err) + } + + pruned := 0 + for _, item := range candidates { + actual, err := git.RevParse(ctx, repoDir, item.ref) + if errors.Is(err, git.ErrRefNotFound) || (err == nil && actual != item.commitOID) { + continue + } + if err != nil { + return pruned, fmt.Errorf("daemon: verify protected snapshot %d ref %s: %w", item.id, item.ref, err) + } + var n int + err = git.WithLockedRecoveryRef(ctx, repoDir, item.ref, item.commitOID, func() error { + var pruneErr error + n, pruneErr = state.PruneRecoverySnapshotEventsBefore(ctx, db, item.id, cutoff) + return pruneErr + }) + if err != nil { + return pruned, fmt.Errorf("daemon: prune protected snapshot %d under ref %s: %w", item.id, item.ref, err) + } + pruned += n + } + return pruned, nil } diff --git a/internal/daemon/reconcile.go b/internal/daemon/reconcile.go new file mode 100644 index 00000000..59a9cc88 --- /dev/null +++ b/internal/daemon/reconcile.go @@ -0,0 +1,1098 @@ +package daemon + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" + acdtrace "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/trace" +) + +const ( + recoveryCommitIdentityName = "Auto Commit Daemon" + recoveryCommitIdentityEmail = "acd-recovery@localhost" +) + +// RecoveryReconcileOptions identifies one immutable unpublished suffix. The +// generic entrypoint deliberately accepts a pending first row: branch changes +// and dead-branch cleanup must preserve pending-only chains before changing +// generation metadata or reseeding the shadow. Replay uses the narrower +// reconcileActiveBarrierChain wrapper below. +type RecoveryReconcileOptions struct { + GitDir string + BranchRef string + BranchGeneration int64 + FirstSeq int64 + Trigger string + Trace acdtrace.Logger + // ArchiveOnly is required when a branch/dead-ref transition is already + // known to have invalidated external-publish proof. It still snapshots and + // rechecks the exact live branch token, but never compares the chain to the + // current HEAD. It is also the only mode allowed to archive while HEAD is + // attached to a missing ref. + ArchiveOnly bool + // InvalidateShadow requests crash-safe active-pair shadow invalidation when + // the outcome is recovered. It is ignored for externally published chains. + InvalidateShadow bool + // ExpectedMissingRef is set only by dead-ref sweeps. Reconciliation + // rechecks it before the final proof checks, then verifies and locks its + // absence in the same Git transaction that protects the recovery ref while + // the SQLite state transition runs. + ExpectedMissingRef string + + // Test-only synchronization points. They are per-call instead of package + // globals so race-enabled parallel tests cannot interfere with one another. + afterInitialLiveToken func() + beforeLiveTokenRecheck func() + beforeFinalHeadCheck func() + beforeStateTransition func() + afterRecoveryRefLocked func() + decisionKind string +} + +// RecoveryChainResult describes a completed all-or-none chain transition. +// Handled is false when FirstSeq no longer identifies an unpublished row. +type RecoveryChainResult struct { + Handled bool + Outcome string + SnapshotID int64 + RecoveryRef string + CommitOID string + FirstSeq int64 + LastSeq int64 + EventCount int +} + +type recoveryPathState struct { + Path string + Present bool + Mode string + OID string +} + +type recoveryLiveState struct { + token string + head string + hasHead bool +} + +// ReconcileUnpublishedChain proves or preserves the exact unpublished suffix +// beginning at opts.FirstSeq. It never mutates HEAD, the live index, or the +// worktree. The recovery tree is reconstructed from the first event's +// immutable base_head in a private index; live HEAD is used only for the +// external-publish proof and branch-token stability guards. Both published +// and archived outcomes create a deterministic hidden ref before the atomic +// DB transition, keeping the evidence reachable across the Git/SQLite gap. +func ReconcileUnpublishedChain( + ctx context.Context, + repoRoot string, + db *state.DB, + opts RecoveryReconcileOptions, +) (RecoveryChainResult, error) { + var result RecoveryChainResult + if repoRoot == "" || db == nil { + return result, fmt.Errorf("daemon: reconcile recovery chain: repoRoot and db required") + } + if opts.BranchRef == "" || opts.BranchGeneration < 1 || opts.FirstSeq < 1 { + return result, fmt.Errorf("daemon: reconcile recovery chain: invalid chain selector") + } + if err := ctx.Err(); err != nil { + return result, err + } + + chain, err := state.LoadUnpublishedRecoveryChain( + ctx, db, opts.BranchRef, opts.BranchGeneration, opts.FirstSeq, + ) + if err != nil { + return result, fmt.Errorf("daemon: reconcile recovery chain: load suffix: %w", err) + } + // LoadUnpublishedRecoveryChain intentionally returns seq >= FirstSeq. An + // exact mismatch means the selected row moved concurrently; never consume + // a later suffix by accident. + if len(chain) == 0 || chain[0].Event.Seq != opts.FirstSeq { + return result, nil + } + prefix, err := state.LoadPublishedRecoveryPrefix( + ctx, db, opts.BranchRef, opts.BranchGeneration, + chain[0].Event.BaseHead, opts.FirstSeq, + ) + if err != nil { + return result, fmt.Errorf("daemon: reconcile recovery chain: load published prefix: %w", err) + } + prefix, err = representedPublishedPrefix(ctx, repoRoot, db, prefix) + if err != nil { + return result, err + } + + gitDir := opts.GitDir + if gitDir == "" { + gitDir, err = git.AbsoluteGitDir(ctx, repoRoot) + if err != nil { + return result, fmt.Errorf("daemon: reconcile recovery chain: resolve git dir: %w", err) + } + } + + live, err := currentRecoveryLiveState(ctx, repoRoot, + opts.afterInitialLiveToken, opts.beforeLiveTokenRecheck) + if err != nil { + return result, err + } + if !live.hasHead && !opts.ArchiveOnly { + return result, fmt.Errorf("daemon: reconcile recovery chain: HEAD is missing; archive-only mode required") + } + if err := requireRecoveryRefMissing(ctx, repoRoot, opts.ExpectedMissingRef); err != nil { + return result, err + } + + baseHead := chain[0].Event.BaseHead + materialization := make([]state.RecoveryChainEvent, 0, len(prefix)+len(chain)) + materialization = append(materialization, prefix...) + materialization = append(materialization, chain...) + if err := validateRecoveryObjects(ctx, repoRoot, materialization); err != nil { + return result, err + } + treeOID, finalState, err := materializeRecoveryTree(ctx, repoRoot, gitDir, baseHead, prefix, chain) + if err != nil { + return result, err + } + if err := requireStableRecoveryLiveState(ctx, repoRoot, live); err != nil { + return result, err + } + if err := requireRecoveryRefMissing(ctx, repoRoot, opts.ExpectedMissingRef); err != nil { + return result, err + } + + matched := false + if live.hasHead && !opts.ArchiveOnly { + matched, err = recoveryChainMatchesHEAD(ctx, repoRoot, live.head, chain, finalState) + if err != nil { + return result, err + } + } + + trigger := strings.TrimSpace(opts.Trigger) + if trigger == "" { + trigger = "automatic_chain_reconciliation" + } + first := chain[0].Event + last := chain[len(chain)-1].Event + transition := state.RecoveryChainTransition{ + Expected: chain, + Reason: trigger, + } + + if matched { + ref := recoveryProofRefName(opts.BranchRef, opts.BranchGeneration, first.Seq, last.Seq, live.head) + commitOID, err := ensurePublishedProofRef(ctx, repoRoot, ref, live.head, chain, finalState) + if err != nil { + return result, err + } + transition.TargetState = state.EventStatePublished + transition.CommitOID = commitOID + transition.RecoveryRef = ref + transition.DecisionKind = opts.decisionKind + } else { + ref := recoveryRefName(opts.BranchRef, opts.BranchGeneration, first.Seq, last.Seq, baseHead, treeOID) + commitOID, err := ensureRecoveryCommit(ctx, repoRoot, ref, treeOID, baseHead, chain) + if err != nil { + return result, err + } + transition.TargetState = state.EventStateRecovered + transition.CommitOID = commitOID + transition.RecoveryRef = ref + transition.InvalidateShadow = opts.InvalidateShadow + } + + if opts.beforeFinalHeadCheck != nil { + opts.beforeFinalHeadCheck() + } + if err := requireStableRecoveryLiveState(ctx, repoRoot, live); err != nil { + return result, err + } + if err := requireRecoveryRefMissing(ctx, repoRoot, opts.ExpectedMissingRef); err != nil { + return result, err + } + if opts.beforeStateTransition != nil { + opts.beforeStateTransition() + } + if err := requireStableRecoveryLiveState(ctx, repoRoot, live); err != nil { + return result, err + } + if err := requireRecoveryRefMissing(ctx, repoRoot, opts.ExpectedMissingRef); err != nil { + return result, err + } + var snapshot state.RecoverySnapshot + protectedTransition := func() error { + if opts.afterRecoveryRefLocked != nil { + opts.afterRecoveryRefLocked() + } + var transitionErr error + snapshot, transitionErr = state.TransitionRecoveryChain(ctx, db, transition) + return transitionErr + } + if opts.ExpectedMissingRef == "" { + err = git.WithLockedRecoveryRef( + ctx, repoRoot, transition.RecoveryRef, transition.CommitOID, protectedTransition, + ) + } else { + err = git.WithLockedRecoveryRefAndAbsentRef( + ctx, repoRoot, transition.RecoveryRef, transition.CommitOID, + opts.ExpectedMissingRef, protectedTransition, + ) + } + if err != nil { + return result, fmt.Errorf("daemon: reconcile recovery chain: protected transition: %w", err) + } + + result = RecoveryChainResult{ + Handled: true, + Outcome: snapshot.Outcome, + SnapshotID: snapshot.ID, + RecoveryRef: snapshot.RecoveryRef.String, + CommitOID: snapshot.CommitOID, + FirstSeq: snapshot.FirstEventSeq, + LastSeq: snapshot.LastEventSeq, + EventCount: snapshot.EventCount, + } + traceReplay(opts.Trace, repoRoot, CaptureContext{ + BranchRef: opts.BranchRef, + BranchGeneration: opts.BranchGeneration, + BaseHead: live.head, + }, first, "replay.chain_reconcile", snapshot.Outcome, trigger, map[string]any{ + "snapshot_id": snapshot.ID, + "outcome": snapshot.Outcome, + "commit": snapshot.CommitOID, + "recovery_ref": snapshot.RecoveryRef.String, + "first_seq": snapshot.FirstEventSeq, + "last_seq": snapshot.LastEventSeq, + "event_count": snapshot.EventCount, + "source_head": baseHead, + }) + return result, nil +} + +// reconcileActiveBarrierChain uses the existence of any terminal row as its +// gate, then delegates the active pair's entire unpublished chain from the +// earliest pending/blocked/failed row. This keeps whole-pair shadow +// invalidation aligned with the exact rows transitioned out of replay. +func reconcileActiveBarrierChain( + ctx context.Context, + repoRoot string, + db *state.DB, + cctx CaptureContext, + opts ReplayOpts, +) (RecoveryChainResult, error) { + var result RecoveryChainResult + _, _, ok, err := firstRecoveryBarrier(ctx, db, cctx.BranchRef, cctx.BranchGeneration) + if err != nil || !ok { + return result, err + } + seq, ok, err := firstUnpublishedRecoverySeq(ctx, db, cctx.BranchRef, cctx.BranchGeneration) + if err != nil || !ok { + return result, err + } + return ReconcileUnpublishedChain(ctx, repoRoot, db, RecoveryReconcileOptions{ + GitDir: opts.GitDir, + BranchRef: cctx.BranchRef, + BranchGeneration: cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "replay_terminal_barrier", + Trace: opts.Trace, + InvalidateShadow: true, + }) +} + +// reconcileTransitionPair protects every unpublished row for one exact branch +// token before callers change generation metadata or reseed shadow state. A +// valid live HEAD always gets the normal exact proof; archiveOnly is reserved +// for a missing/unresolvable live HEAD. A missing pair is an idempotent no-op. +// Legacy bare-rev tokens have no branch ref, so they reconcile every exact +// unpublished pair at that generation instead of falling back to unsafe +// generation-wide cleanup. +func reconcileTransitionPair( + ctx context.Context, + repoRoot string, + gitDir string, + db *state.DB, + branchRef string, + branchGeneration int64, + archiveOnly bool, + expectedMissingRef string, + trigger string, + trace acdtrace.Logger, +) (RecoveryChainResult, error) { + var result RecoveryChainResult + if branchRef == "" && branchGeneration > 0 { + rows, err := db.ReadSQL().QueryContext(ctx, ` +SELECT DISTINCT branch_ref +FROM capture_events +WHERE branch_generation = ? + AND branch_ref != '' + AND state IN (?, ?, ?) +ORDER BY branch_ref`, branchGeneration, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed) + if err != nil { + return result, fmt.Errorf("daemon: reconcile recovery generation: list exact pairs: %w", err) + } + var refs []string + for rows.Next() { + var ref string + if err := rows.Scan(&ref); err != nil { + _ = rows.Close() + return result, fmt.Errorf("daemon: reconcile recovery generation: scan branch ref: %w", err) + } + refs = append(refs, ref) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return result, fmt.Errorf("daemon: reconcile recovery generation: iterate branch refs: %w", err) + } + if err := rows.Close(); err != nil { + return result, fmt.Errorf("daemon: reconcile recovery generation: close branch refs: %w", err) + } + for _, ref := range refs { + pair, err := reconcileTransitionPair(ctx, repoRoot, gitDir, db, ref, + branchGeneration, archiveOnly, expectedMissingRef, trigger, trace) + if err != nil { + return result, err + } + if !pair.Handled { + continue + } + if !result.Handled { + result = pair + continue + } + result.EventCount += pair.EventCount + result.LastSeq = pair.LastSeq + if result.Outcome != pair.Outcome { + result.Outcome = "mixed" + } + result.RecoveryRef = pair.RecoveryRef + } + return result, nil + } + seq, ok, err := firstUnpublishedRecoverySeq(ctx, db, branchRef, branchGeneration) + if err != nil || !ok { + return result, err + } + return ReconcileUnpublishedChain(ctx, repoRoot, db, RecoveryReconcileOptions{ + GitDir: gitDir, + BranchRef: branchRef, + BranchGeneration: branchGeneration, + FirstSeq: seq, + Trigger: trigger, + Trace: trace, + ArchiveOnly: archiveOnly, + ExpectedMissingRef: expectedMissingRef, + }) +} + +func firstUnpublishedRecoverySeq( + ctx context.Context, + db *state.DB, + branchRef string, + branchGeneration int64, +) (seq int64, ok bool, err error) { + if db == nil || branchRef == "" || branchGeneration < 1 { + return 0, false, nil + } + err = db.ReadSQL().QueryRowContext(ctx, ` +SELECT seq +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND state IN (?, ?, ?) +ORDER BY seq ASC +LIMIT 1`, branchRef, branchGeneration, + state.EventStatePending, state.EventStateBlockedConflict, state.EventStateFailed, + ).Scan(&seq) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("daemon: reconcile recovery chain: find first unpublished row: %w", err) + } + return seq, true, nil +} + +func firstRecoveryBarrier( + ctx context.Context, + db *state.DB, + branchRef string, + branchGeneration int64, +) (seq int64, eventState string, ok bool, err error) { + if db == nil || branchRef == "" || branchGeneration < 1 { + return 0, "", false, nil + } + err = db.ReadSQL().QueryRowContext(ctx, ` +SELECT seq, state +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND state IN (?, ?) +ORDER BY seq ASC +LIMIT 1`, branchRef, branchGeneration, + state.EventStateBlockedConflict, state.EventStateFailed, + ).Scan(&seq, &eventState) + if errors.Is(err, sql.ErrNoRows) { + return 0, "", false, nil + } + if err != nil { + return 0, "", false, fmt.Errorf("daemon: reconcile recovery chain: find barrier: %w", err) + } + return seq, eventState, true, nil +} + +func representedPublishedPrefix( + ctx context.Context, + repoRoot string, + db *state.DB, + prefix []state.RecoveryChainEvent, +) ([]state.RecoveryChainEvent, error) { + represented := make([]state.RecoveryChainEvent, 0, len(prefix)) + for start := 0; start < len(prefix); { + if err := ctx.Err(); err != nil { + return nil, err + } + first := prefix[start] + if !first.Event.CommitOID.Valid || first.Event.CommitOID.String == "" { + return nil, fmt.Errorf("daemon: reconcile recovery chain: published prefix seq=%d has no commit", first.Event.Seq) + } + commitOID := first.Event.CommitOID.String + end := start + 1 + for end < len(prefix) { + item := prefix[end] + if !item.Event.CommitOID.Valid || item.Event.CommitOID.String == "" { + return nil, fmt.Errorf("daemon: reconcile recovery chain: published prefix seq=%d has no commit", item.Event.Seq) + } + if item.Event.CommitOID.String != commitOID { + break + } + end++ + } + + group := make([]state.RecoveryChainEvent, 0, end-start) + for _, item := range prefix[start:end] { + decisions, err := state.DecisionsForEvent(ctx, db, item.Event.Seq, 1000) + if err != nil { + return nil, fmt.Errorf("daemon: reconcile recovery chain: load published prefix decision seq=%d: %w", item.Event.Seq, err) + } + superseded := false + for _, decision := range decisions { + if decision.Kind == state.DecisionKindSupersededExternal { + superseded = true + break + } + } + if !superseded { + group = append(group, item) + } + } + start = end + if len(group) == 0 { + continue + } + + initialState, cumulativeState := recoveryGroupBoundaryStates(group) + afterMatch, err := recoveryStatesMatchTree( + ctx, repoRoot, commitOID, cumulativeState, + ) + if err != nil { + return nil, fmt.Errorf("daemon: reconcile recovery chain: prove published prefix group %d-%d cumulative after-state: %w", + group[0].Event.Seq, group[len(group)-1].Event.Seq, err) + } + if afterMatch { + represented = append(represented, group...) + continue + } + beforeMatch, err := recoveryStatesMatchTree( + ctx, repoRoot, commitOID, initialState, + ) + if err != nil { + return nil, fmt.Errorf("daemon: reconcile recovery chain: prove published prefix group %d-%d initial before-state: %w", + group[0].Event.Seq, group[len(group)-1].Event.Seq, err) + } + if !beforeMatch { + return nil, fmt.Errorf("daemon: reconcile recovery chain: published prefix group %d-%d commit represents neither initial before nor cumulative after state", + group[0].Event.Seq, group[len(group)-1].Event.Seq) + } + // The commit still contains the group's initial before-state, so an + // external change superseded every non-explicitly-superseded member. + // Skipping the group prevents recovery from resurrecting that work. + } + return represented, nil +} + +func recoveryGroupBoundaryStates(group []state.RecoveryChainEvent) ([]recoveryPathState, []recoveryPathState) { + initialByPath := make(map[string]recoveryPathState) + cumulativeByPath := make(map[string]recoveryPathState) + for _, item := range group { + for _, op := range item.Ops { + for _, pathState := range recoveryOpStates([]state.CaptureOp{op}, false) { + if _, seen := initialByPath[pathState.Path]; !seen { + initialByPath[pathState.Path] = pathState + } + } + for _, pathState := range recoveryOpStates([]state.CaptureOp{op}, true) { + cumulativeByPath[pathState.Path] = pathState + } + } + } + return sortedRecoveryPathStates(initialByPath), sortedRecoveryPathStates(cumulativeByPath) +} + +func sortedRecoveryPathStates(byPath map[string]recoveryPathState) []recoveryPathState { + paths := make([]string, 0, len(byPath)) + for path := range byPath { + paths = append(paths, path) + } + sort.Strings(paths) + states := make([]recoveryPathState, 0, len(paths)) + for _, path := range paths { + states = append(states, byPath[path]) + } + return states +} + +func recoveryOpStates(ops []state.CaptureOp, after bool) []recoveryPathState { + byPath := make(map[string]recoveryPathState) + for _, op := range ops { + if after { + switch op.Op { + case "create", "modify", "mode": + byPath[op.Path] = recoveryPathState{Path: op.Path, Present: true, Mode: op.AfterMode.String, OID: op.AfterOID.String} + case "delete": + byPath[op.Path] = recoveryPathState{Path: op.Path} + case "rename": + byPath[op.OldPath.String] = recoveryPathState{Path: op.OldPath.String} + byPath[op.Path] = recoveryPathState{Path: op.Path, Present: true, Mode: op.AfterMode.String, OID: op.AfterOID.String} + } + continue + } + switch op.Op { + case "create": + byPath[op.Path] = recoveryPathState{Path: op.Path} + case "modify", "mode", "delete": + byPath[op.Path] = recoveryPathState{Path: op.Path, Present: true, Mode: op.BeforeMode.String, OID: op.BeforeOID.String} + case "rename": + byPath[op.OldPath.String] = recoveryPathState{Path: op.OldPath.String, Present: true, Mode: op.BeforeMode.String, OID: op.BeforeOID.String} + byPath[op.Path] = recoveryPathState{Path: op.Path} + } + } + return sortedRecoveryPathStates(byPath) +} + +func validateRecoveryObjects(ctx context.Context, repoRoot string, chain []state.RecoveryChainEvent) error { + required := make(map[string]string) + for _, item := range chain { + if err := ctx.Err(); err != nil { + return err + } + ev := item.Event + if err := addRecoveryObjectRequirement(required, ev.BaseHead, "commit"); err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: base commit seq=%d: %w", ev.Seq, err) + } + if problem := validateOps(item.Ops); problem != "" { + return fmt.Errorf("daemon: reconcile recovery chain: invalid ops seq=%d: %s", ev.Seq, problem) + } + if len(item.Ops) == 0 { + return fmt.Errorf("daemon: reconcile recovery chain: event %d has no operations", ev.Seq) + } + for _, op := range item.Ops { + if op.Op == "rename" && (!op.BeforeOID.Valid || !op.BeforeMode.Valid) { + return fmt.Errorf("daemon: reconcile recovery chain: rename %s lacks immutable before-state", op.Path) + } + for _, object := range recoveryOpObjects(op) { + kind, err := recoveryObjectKind(object.mode) + if err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: seq=%d path=%s: %w", ev.Seq, op.Path, err) + } + if err := addRecoveryObjectRequirement(required, object.oid, kind); err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: seq=%d path=%s: %w", ev.Seq, op.Path, err) + } + } + } + } + oids := make([]string, 0, len(required)) + for oid := range required { + oids = append(oids, oid) + } + sort.Strings(oids) + var input strings.Builder + for _, oid := range oids { + input.WriteString(oid) + input.WriteByte('\n') + } + out, err := git.Run(ctx, git.RunOpts{Dir: repoRoot, Stdin: strings.NewReader(input.String())}, + "cat-file", "--batch-check=%(objectname) %(objecttype)") + if err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: validate objects: %w", err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) != len(oids) { + return fmt.Errorf("daemon: reconcile recovery chain: object validation returned %d rows, want %d", len(lines), len(oids)) + } + for i, line := range lines { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == oids[i] && fields[1] == "missing" { + return fmt.Errorf("daemon: reconcile recovery chain: missing %s object %s", required[oids[i]], oids[i]) + } + if len(fields) != 2 || fields[0] != oids[i] || fields[1] != required[oids[i]] { + return fmt.Errorf("daemon: reconcile recovery chain: object %s is %q, want %s", oids[i], line, required[oids[i]]) + } + } + return nil +} + +type recoveryObject struct { + mode string + oid string +} + +func recoveryOpObjects(op state.CaptureOp) []recoveryObject { + objects := make([]recoveryObject, 0, 2) + if op.BeforeOID.Valid && op.BeforeMode.Valid { + objects = append(objects, recoveryObject{mode: op.BeforeMode.String, oid: op.BeforeOID.String}) + } + if op.AfterOID.Valid && op.AfterMode.Valid { + objects = append(objects, recoveryObject{mode: op.AfterMode.String, oid: op.AfterOID.String}) + } + return objects +} + +func recoveryObjectKind(mode string) (string, error) { + switch mode { + case git.RegularFileMode, "100755", git.SymlinkMode: + return "blob", nil + case "160000": + return "commit", nil + default: + return "", fmt.Errorf("unsupported git mode %q", mode) + } +} + +func addRecoveryObjectRequirement(required map[string]string, oid, kind string) error { + if oid == "" { + return fmt.Errorf("empty %s object oid", kind) + } + if previous, ok := required[oid]; ok && previous != kind { + return fmt.Errorf("object %s required as both %s and %s", oid, previous, kind) + } + required[oid] = kind + return nil +} + +func materializeRecoveryTree( + ctx context.Context, + repoRoot string, + gitDir string, + baseHead string, + prefix []state.RecoveryChainEvent, + chain []state.RecoveryChainEvent, +) (string, []recoveryPathState, error) { + indexParent := filepath.Join(gitDir, "acd") + if err := os.MkdirAll(indexParent, 0o700); err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: mkdir index parent: %w", err) + } + tmpDir, err := os.MkdirTemp(indexParent, "recovery-") + if err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: mkdir temp index: %w", err) + } + defer os.RemoveAll(tmpDir) + if err := os.Chmod(tmpDir, 0o700); err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: chmod temp index: %w", err) + } + indexFile := filepath.Join(tmpDir, "idx") + if err := git.ReadTree(ctx, repoRoot, indexFile, baseHead); err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: seed base tree %s: %w", baseHead, err) + } + + ordered := make([]state.RecoveryChainEvent, 0, len(prefix)+len(chain)) + ordered = append(ordered, prefix...) + ordered = append(ordered, chain...) + allTouched := make(map[string]struct{}) + chainTouched := make(map[string]struct{}) + for i, item := range ordered { + for _, path := range touchedPaths(item.Ops) { + allTouched[path] = struct{}{} + if i >= len(prefix) { + chainTouched[path] = struct{}{} + } + } + } + if len(chainTouched) == 0 { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: empty materialized chain") + } + paths := make([]string, 0, len(allTouched)) + for path := range allTouched { + paths = append(paths, path) + } + sort.Strings(paths) + entries, err := git.LsFilesIndex(ctx, repoRoot, indexFile, git.LiteralPathspecs(paths)...) + if err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: read final index: %w", err) + } + byPath := make(map[string]git.IndexEntry, len(entries)) + for _, entry := range entries { + if entry.Stage != 0 { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: unmerged final index path %s", entry.Path) + } + if _, exists := byPath[entry.Path]; exists { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: duplicate final index path %s", entry.Path) + } + byPath[entry.Path] = entry + } + for _, item := range ordered { + if err := ctx.Err(); err != nil { + return "", nil, err + } + if conflict := applyRecoveryOpsInMemory(byPath, item.Ops); conflict != "" { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: provenance mismatch seq=%d: %s", item.Event.Seq, conflict) + } + } + const zeroOID = "0000000000000000000000000000000000000000" + lines := make([]string, 0, len(paths)) + for _, path := range paths { + if _, present := byPath[path]; !present { + lines = append(lines, fmt.Sprintf("0 %s\t%s", zeroOID, path)) + } + } + for _, path := range paths { + if entry, present := byPath[path]; present { + lines = append(lines, fmt.Sprintf("%s %s\t%s", entry.Mode, entry.OID, path)) + } + } + if err := git.UpdateIndexInfo(ctx, repoRoot, indexFile, lines); err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: apply final index: %w", err) + } + treeOID, err := git.WriteTree(ctx, repoRoot, indexFile) + if err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: write recovery tree: %w", err) + } + chainPaths := make([]string, 0, len(chainTouched)) + for path := range chainTouched { + chainPaths = append(chainPaths, path) + } + sort.Strings(chainPaths) + finalState := make([]recoveryPathState, 0, len(chainPaths)) + for _, path := range chainPaths { + entry, present := byPath[path] + finalState = append(finalState, recoveryPathState{ + Path: path, Present: present, Mode: entry.Mode, OID: entry.OID, + }) + } + if matches, err := recoveryStatesMatchTree(ctx, repoRoot, treeOID, finalState); err != nil { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: verify materialized tree: %w", err) + } else if !matches { + return "", nil, fmt.Errorf("daemon: reconcile recovery chain: materialized tree failed final-state verification") + } + return treeOID, finalState, nil +} + +func applyRecoveryOpsInMemory(index map[string]git.IndexEntry, ops []state.CaptureOp) string { + for _, op := range ops { + beforeMatches := func(path string) bool { + entry, ok := index[path] + return ok && entry.Mode == op.BeforeMode.String && entry.OID == op.BeforeOID.String + } + after := git.IndexEntry{Mode: op.AfterMode.String, OID: op.AfterOID.String, Path: op.Path} + switch op.Op { + case "create": + if existing, ok := index[op.Path]; ok && (existing.Mode != after.Mode || existing.OID != after.OID) { + return fmt.Sprintf("create conflict for %s", op.Path) + } + index[op.Path] = after + case "modify", "mode": + if !beforeMatches(op.Path) { + return fmt.Sprintf("%s before-state mismatch for %s", op.Op, op.Path) + } + index[op.Path] = after + case "delete": + if !beforeMatches(op.Path) { + return fmt.Sprintf("delete before-state mismatch for %s", op.Path) + } + delete(index, op.Path) + case "rename": + oldPath := op.OldPath.String + if !beforeMatches(oldPath) { + return fmt.Sprintf("rename source mismatch for %s", oldPath) + } + if _, exists := index[op.Path]; exists { + return fmt.Sprintf("rename target already exists for %s", op.Path) + } + delete(index, oldPath) + index[op.Path] = after + default: + return fmt.Sprintf("unknown op %q", op.Op) + } + } + return "" +} + +func recoveryChainMatchesHEAD( + ctx context.Context, + repoRoot string, + head string, + chain []state.RecoveryChainEvent, + finalState []recoveryPathState, +) (bool, error) { + matched := true + seen := make(map[string]struct{}) + for _, item := range chain { + if err := ctx.Err(); err != nil { + return false, err + } + base := item.Event.BaseHead + if _, ok := seen[base]; ok { + continue + } + ancestor, err := git.IsAncestor(ctx, repoRoot, base, head) + if err != nil { + return false, fmt.Errorf("daemon: reconcile recovery chain: prove ancestry %s..%s: %w", base, head, err) + } + if !ancestor { + matched = false + } + seen[base] = struct{}{} + } + statesMatch, err := recoveryStatesMatchTree(ctx, repoRoot, head, finalState) + if err != nil { + return false, fmt.Errorf("daemon: reconcile recovery chain: prove HEAD paths: %w", err) + } + return matched && statesMatch, nil +} + +func recoveryStatesMatchTree( + ctx context.Context, + repoRoot string, + rev string, + states []recoveryPathState, +) (bool, error) { + if len(states) == 0 { + return false, fmt.Errorf("empty recovery state proof") + } + paths := make([]string, 0, len(states)) + for _, state := range states { + paths = append(paths, state.Path) + } + entries, err := git.LsTree(ctx, repoRoot, rev, false, git.LiteralPathspecs(paths)...) + if err != nil { + return false, err + } + byPath := make(map[string]git.TreeEntry, len(entries)) + for _, entry := range entries { + byPath[entry.Path] = entry + } + matched := true + for _, want := range states { + if err := ctx.Err(); err != nil { + return false, err + } + entry, present := byPath[want.Path] + if !want.Present { + if present { + matched = false + } + continue + } + wantType := "blob" + if want.Mode == "160000" { + wantType = "commit" + } + if !present || entry.Mode != want.Mode || entry.OID != want.OID || entry.Type != wantType { + matched = false + } + } + return matched, nil +} + +func ensureRecoveryCommit( + ctx context.Context, + repoRoot string, + ref string, + treeOID string, + baseHead string, + chain []state.RecoveryChainEvent, +) (string, error) { + if existing, err := reusableRecoveryCommit(ctx, repoRoot, ref, treeOID, baseHead); err == nil && existing != "" { + return existing, nil + } else if err != nil && !errors.Is(err, git.ErrRefNotFound) { + return "", err + } + + first := chain[0].Event + last := chain[len(chain)-1].Event + message := fmt.Sprintf( + "Preserve ACD recovery chain\n\nBranch: %s\nGeneration: %d\nEvents: %d-%d\n", + first.BranchRef, first.BranchGeneration, first.Seq, last.Seq, + ) + commitOID, err := git.CommitTreeWithIdentity(ctx, repoRoot, treeOID, message, + recoveryCommitIdentityName, recoveryCommitIdentityEmail, baseHead) + if err != nil { + return "", fmt.Errorf("daemon: reconcile recovery chain: create recovery commit: %w", err) + } + if _, err := git.EnsureRecoveryRef(ctx, repoRoot, ref, commitOID); err == nil { + return commitOID, nil + } else if !errors.Is(err, git.ErrRecoveryRefCollision) { + return "", fmt.Errorf("daemon: reconcile recovery chain: protect recovery commit: %w", err) + } + + // commit-tree timestamps make equivalent retries produce different commit + // OIDs. A racing winner is reusable only when both its tree and immutable + // provenance parent match this chain. + existing, reuseErr := reusableRecoveryCommit(ctx, repoRoot, ref, treeOID, baseHead) + if reuseErr != nil { + return "", reuseErr + } + return existing, nil +} + +func ensurePublishedProofRef( + ctx context.Context, + repoRoot string, + ref string, + liveHead string, + chain []state.RecoveryChainEvent, + finalState []recoveryPathState, +) (string, error) { + existing, err := git.RevParse(ctx, repoRoot, ref) + if err == nil { + matches, proofErr := recoveryChainMatchesHEAD(ctx, repoRoot, existing, chain, finalState) + if proofErr != nil { + return "", fmt.Errorf("daemon: reconcile recovery chain: verify existing published proof: %w", proofErr) + } + if !matches { + return "", fmt.Errorf("%w: published proof ref %s points at non-matching commit %s", + git.ErrRecoveryRefCollision, ref, existing) + } + return existing, nil + } + if !errors.Is(err, git.ErrRefNotFound) { + return "", fmt.Errorf("daemon: reconcile recovery chain: resolve published proof ref: %w", err) + } + if _, err := git.EnsureRecoveryRef(ctx, repoRoot, ref, liveHead); err == nil { + return liveHead, nil + } else if !errors.Is(err, git.ErrRecoveryRefCollision) { + return "", fmt.Errorf("daemon: reconcile recovery chain: protect published proof: %w", err) + } + // A racing writer may have protected an equivalent external commit. + existing, err = git.RevParse(ctx, repoRoot, ref) + if err != nil { + return "", fmt.Errorf("daemon: reconcile recovery chain: re-read published proof: %w", err) + } + matches, err := recoveryChainMatchesHEAD(ctx, repoRoot, existing, chain, finalState) + if err != nil { + return "", fmt.Errorf("daemon: reconcile recovery chain: verify raced published proof: %w", err) + } + if !matches { + return "", fmt.Errorf("%w: raced published proof ref %s points at non-matching commit %s", + git.ErrRecoveryRefCollision, ref, existing) + } + return existing, nil +} + +func reusableRecoveryCommit(ctx context.Context, repoRoot, ref, treeOID, baseHead string) (string, error) { + commitOID, err := git.RevParse(ctx, repoRoot, ref) + if err != nil { + return "", err + } + existingTree, err := git.RevParse(ctx, repoRoot, ref+"^{tree}") + if err != nil { + return "", fmt.Errorf("%w: recovery ref %s has no commit tree", git.ErrRecoveryRefCollision, ref) + } + parent, err := git.RevParse(ctx, repoRoot, ref+"^") + if err != nil { + return "", fmt.Errorf("%w: recovery ref %s has no provenance parent", git.ErrRecoveryRefCollision, ref) + } + if existingTree != treeOID || parent != baseHead { + return "", fmt.Errorf( + "%w: recovery ref %s has tree %s parent %s, want tree %s parent %s", + git.ErrRecoveryRefCollision, ref, existingTree, parent, treeOID, baseHead, + ) + } + return commitOID, nil +} + +func recoveryRefName(branchRef string, generation, firstSeq, lastSeq int64, baseHead, treeOID string) string { + token := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", branchRef, generation))) + target := sha256.Sum256([]byte(baseHead + "\x00" + treeOID)) + return fmt.Sprintf("%s%x/g%d/%d-%d-%x/archive", + git.RecoveryRefPrefix, token[:6], generation, firstSeq, lastSeq, target[:12]) +} + +func recoveryProofRefName(branchRef string, generation, firstSeq, lastSeq int64, commitOID string) string { + token := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", branchRef, generation))) + target := sha256.Sum256([]byte(commitOID)) + return fmt.Sprintf("%s%x/g%d/%d-%d-%x/published", + git.RecoveryRefPrefix, token[:6], generation, firstSeq, lastSeq, target[:12]) +} + +func currentRecoveryLiveState( + ctx context.Context, + repoRoot string, + afterInitialToken func(), + beforeTokenRecheck func(), +) (recoveryLiveState, error) { + var live recoveryLiveState + tokenBefore, err := BranchGenerationToken(ctx, repoRoot) + if err != nil { + return live, fmt.Errorf("daemon: reconcile recovery chain: resolve branch token: %w", err) + } + if afterInitialToken != nil { + afterInitialToken() + } + // The token already contains the HEAD observed by BranchGenerationToken. + // Deriving the SHA from that same sample avoids an ABA race where a separate + // RevParse reads transient B while the token reads A both before and after. + head := tokenSHA(tokenBefore) + hasHead := head != "" + if beforeTokenRecheck != nil { + beforeTokenRecheck() + } + tokenAfter, err := BranchGenerationToken(ctx, repoRoot) + if err != nil { + return live, fmt.Errorf("daemon: reconcile recovery chain: re-read branch token: %w", err) + } + if tokenAfter != tokenBefore { + return live, fmt.Errorf("daemon: reconcile recovery chain: branch token moved from %q to %q", tokenBefore, tokenAfter) + } + return recoveryLiveState{ + token: tokenBefore, + head: head, + hasHead: hasHead, + }, nil +} + +func requireStableRecoveryLiveState(ctx context.Context, repoRoot string, expected recoveryLiveState) error { + actual, err := BranchGenerationToken(ctx, repoRoot) + if err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: re-read branch token: %w", err) + } + if actual != expected.token { + return fmt.Errorf("daemon: reconcile recovery chain: branch token moved from %q to %q", expected.token, actual) + } + return nil +} + +func requireRecoveryRefMissing(ctx context.Context, repoRoot, ref string) error { + if ref == "" { + return nil + } + exists, err := git.RefExists(ctx, repoRoot, ref) + if err != nil { + return fmt.Errorf("daemon: reconcile recovery chain: recheck missing ref %s: %w", ref, err) + } + if exists { + return fmt.Errorf("daemon: reconcile recovery chain: expected ref %s to remain missing", ref) + } + return nil +} diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index dea2f1ff..324a7f89 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -187,6 +187,16 @@ type ReplayOpts struct { // and then deterministic planning if that provider is unavailable. IntentPlanner ai.IntentPlanner + // IntentHealth gates production planner calls. Nil preserves the direct + // Replay test/CLI behavior; Run supplies one process-scoped instance so + // cooldown and half-open leases survive across replay passes. + IntentHealth *IntentPlannerHealth + + // IntentPlannerProvider/Model preserve the primary provider identity when + // IntentPlanner is injected from Run instead of rebuilt from env per pass. + IntentPlannerProvider string + IntentPlannerModel string + // IntentWindow caps the normal planning window. Zero resolves from env. IntentWindow int @@ -233,8 +243,12 @@ 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 + // RecaptureRequired means an active unpublished chain was archived and + // its shadow invalidated. Persistent run loops will reseed on the next + // pass; one-shot callers must perform that pass before reporting drained. + RecaptureRequired bool + 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. @@ -348,7 +362,8 @@ func Replay(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureCont // predecessors, so clearing the barrier now lets the very next query // drain the suffix. if cctx.BranchRef != "" { - if err := probeBlockedSelfHeal(ctx, repoRoot, db, cctx, opts.Trace); err != nil { + selfHealResult, err := probeBlockedSelfHeal(ctx, repoRoot, db, cctx, opts.Trace) + if err != nil { // Self-heal is best-effort: a failure must not stop the rest // of the replay pass (the offending row simply stays // blocked). Surface it via slog so an operator running with @@ -357,6 +372,21 @@ func Replay(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureCont "branch_ref", cctx.BranchRef, "generation", cctx.BranchGeneration, "err", err.Error()) + } else if selfHealResult.Handled && selfHealResult.Outcome == state.EventStateRecovered { + sum.RecaptureRequired = true + } + recoveryResult, err := reconcileActiveBarrierChain(ctx, repoRoot, db, cctx, opts) + if err != nil { + // Chain reconciliation fails closed. The barrier and every + // successor remain byte-for-byte unchanged; a recovery ref may + // remain only when it was already fully materialized before a + // later HEAD/state race was observed. + slog.Default().Warn("daemon: recovery chain reconciliation failed", + "branch_ref", cctx.BranchRef, + "generation", cctx.BranchGeneration, + "err", err.Error()) + } else if recoveryResult.Handled && recoveryResult.Outcome == state.EventStateRecovered { + sum.RecaptureRequired = true } } @@ -797,6 +827,7 @@ func replayPendingBatchLimit(opts ReplayOpts, cfg intentReplayConfig) int { type intentReplayConfig struct { enabled bool planner ai.IntentPlanner + health *IntentPlannerHealth window int minPending int settleWindow time.Duration @@ -849,6 +880,7 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err pathQuiescence: resolvePathQuiescenceSeconds(), recentCommitAffinity: resolveRecentCommitAffinitySeconds(), commitFormat: cfg.CommitFormat, + health: opts.IntentHealth, } if opts.IntentWindow > 0 { out.window = opts.IntentWindow @@ -860,7 +892,10 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err out.settleWindow = opts.IntentSettleWindow } else if opts.IntentSettleWindow < 0 { out.settleWindow = 0 - } else if opts.IntentPlanner != nil { + } else if opts.IntentPlanner != nil && opts.IntentHealth == nil { + // Direct tests historically use an injected planner with zero settle + // delay. Production Run also injects its reused planner now, but always + // supplies IntentHealth and must retain the configured settle window. out.settleWindow = 0 } if opts.IntentMaxPendingAge > 0 { @@ -893,13 +928,17 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if opts.IntentPlanner != nil { out.planner = opts.IntentPlanner out.plannerProvider = ai.PrimaryProviderName(opts.IntentPlanner) + if strings.TrimSpace(opts.IntentPlannerProvider) != "" { + out.plannerProvider = strings.TrimSpace(opts.IntentPlannerProvider) + } + out.plannerModel = strings.TrimSpace(opts.IntentPlannerModel) return out, nil, nil } providerCfg := cfg provider, closer, err := ai.BuildProvider(providerCfg) if err != nil { - slog.Default().Warn("build intent planner; falling back to deterministic", "err", err.Error()) + slog.Default().Warn("build intent planner; falling back to deterministic", "err", ai.SanitizePlannerError(err.Error())) out.planner = ai.DeterministicProvider{CommitFormat: out.commitFormat} out.plannerProvider = out.planner.Name() return out, nil, nil @@ -912,7 +951,7 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if closer != nil { return out, func() { if err := closer.Close(); err != nil { - slog.Default().Warn("close unused intent planner provider", "err", err.Error()) + slog.Default().Warn("close unused intent planner provider", "err", ai.SanitizePlannerError(err.Error())) } }, nil } @@ -929,7 +968,7 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if closer != nil { return out, func() { if err := closer.Close(); err != nil { - slog.Default().Warn("close intent planner provider", "err", err.Error()) + slog.Default().Warn("close intent planner provider", "err", ai.SanitizePlannerError(err.Error())) } }, nil } @@ -1068,19 +1107,21 @@ func replayIntentBatch( // failure, fall through to the standard planner path which records // the error decision and degrades to the deterministic fallback. if safetyErr := validateIntentSelectionSafety(items, plan); safetyErr != nil { - recordIntentPromptFallback(ctx, cfg.planner, safetyErr.Error()) - if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, safetyErr.Error()); err != nil { + failure := ai.SanitizePlannerError(safetyErr.Error()) + recordIntentPromptFallback(ctx, cfg.planner, failure) + if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, failure); err != nil { return sum, err } - if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, safetyErr.Error(), "forced-singleton fast path validation failed", "Forced-singleton fast path produced an unsafe plan; deterministic fallback will choose a safe one-item plan."); err != nil { + if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, failure, "forced-singleton fast path validation failed", "Forced-singleton fast path produced an unsafe plan; deterministic fallback will choose a safe one-item plan."); err != nil { return sum, err } } else if planErr := ai.ValidateIntentPlan(req, plan); planErr != nil { - recordIntentPromptFallback(ctx, cfg.planner, planErr.Error()) - if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, planErr.Error()); err != nil { + failure := ai.SanitizePlannerError(planErr.Error()) + recordIntentPromptFallback(ctx, cfg.planner, failure) + if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, failure); err != nil { return sum, err } - if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, planErr.Error(), "forced-singleton fast path validation failed", "Forced-singleton fast path produced an invalid plan; deterministic fallback will choose a safe one-item plan."); err != nil { + if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, failure, "forced-singleton fast path validation failed", "Forced-singleton fast path produced an invalid plan; deterministic fallback will choose a safe one-item plan."); err != nil { return sum, err } } else { @@ -1097,25 +1138,31 @@ func replayIntentBatch( cfg.planner = ai.DeterministicProvider{CommitFormat: cfg.commitFormat} } - if !forced && isSingletonProviderFastPath(items) { + // Direct Replay callers without a circuit keep the legacy per-event message + // fast path. Production Run supplies cfg.health, so even a one-item window + // goes through the same planner gate and cannot spend a remote timeout while + // the circuit is open. + if !forced && cfg.health == nil && isSingletonProviderFastPath(items) { plan, err := planIntentSingletonMessagePath(ctx, opts.MessageFn, items[0], cfg.commitFormat) if err != nil { return sum, err } if safetyErr := validateIntentSelectionSafety(items, plan); safetyErr != nil { - recordIntentPromptFallback(ctx, cfg.planner, safetyErr.Error()) - if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, safetyErr.Error()); err != nil { + failure := ai.SanitizePlannerError(safetyErr.Error()) + recordIntentPromptFallback(ctx, cfg.planner, failure) + if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, failure); err != nil { return sum, err } - if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, safetyErr.Error(), "singleton fast path validation failed", "Singleton fast path produced an unsafe plan; deterministic fallback will choose a safe one-item plan."); err != nil { + if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, failure, "singleton fast path validation failed", "Singleton fast path produced an unsafe plan; deterministic fallback will choose a safe one-item plan."); err != nil { return sum, err } } else if planErr := ai.ValidateIntentPlan(req, plan); planErr != nil { - recordIntentPromptFallback(ctx, cfg.planner, planErr.Error()) - if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, planErr.Error()); err != nil { + failure := ai.SanitizePlannerError(planErr.Error()) + recordIntentPromptFallback(ctx, cfg.planner, failure) + if err := state.RecordPlannerError(ctx, db, items[0].event.Seq, nowSec, failure); err != nil { return sum, err } - if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, planErr.Error(), "singleton fast path validation failed", "Singleton fast path produced an invalid plan; deterministic fallback will choose a safe one-item plan."); err != nil { + if err := appendIntentPlannerDecision(ctx, db, items[0].event, activeCtx, nowSec, state.DecisionKindIntentPlannerError, failure, "singleton fast path validation failed", "Singleton fast path produced an invalid plan; deterministic fallback will choose a safe one-item plan."); err != nil { return sum, err } } else { @@ -1135,7 +1182,8 @@ func replayIntentBatch( if opts.PromptTrace != nil { plannerCtx = prompttrace.With(ctx, opts.PromptTrace, prompttrace.Metadata{ Strategy: string(ai.CommitStrategyIntent), - Provider: cfg.planner.Name(), + Provider: cfg.plannerProvider, + Model: cfg.plannerModel, OfferedSeqs: intentOfferedSeqs(req), BranchRef: activeCtx.BranchRef, Generation: activeCtx.BranchGeneration, @@ -1146,7 +1194,7 @@ func replayIntentBatch( DiffCap: ai.IntentStageDiffCap, }) } - plan, validationFailure, err := planIntentWithFallback(plannerCtx, repoRoot, db, cfg.planner, req, items, activeCtx, nowSec) + plan, validationFailure, err := planIntentWithFallback(plannerCtx, repoRoot, db, cfg.planner, cfg.health, req, items, activeCtx, nowSec) if err != nil { return sum, err } @@ -1918,17 +1966,52 @@ func singletonFallbackOp(item intentReplayItem) ai.OpItem { } } -func planIntentWithFallback(ctx context.Context, repoRoot string, db *state.DB, planner ai.IntentPlanner, req ai.IntentPlanRequest, items []intentReplayItem, cctx CaptureContext, ts float64) (ai.IntentPlan, string, error) { +func planIntentWithFallback( + ctx context.Context, + repoRoot string, + db *state.DB, + planner ai.IntentPlanner, + health *IntentPlannerHealth, + req ai.IntentPlanRequest, + items []intentReplayItem, + cctx CaptureContext, + ts float64, +) (ai.IntentPlan, string, error) { if planner == nil { planner = ai.DeterministicProvider{CommitFormat: req.CommitFormat} } + + var permit IntentPlannerHealthPermit + if health != nil { + var acquireErr error + permit, acquireErr = health.Acquire(ctx) + if acquireErr != nil { + var openErr *IntentPlannerCircuitOpenError + if !errors.As(acquireErr, &openErr) { + return ai.IntentPlan{}, "", acquireErr + } + // A circuit bypass is an expected deterministic degradation, not a + // fresh planner error. Keep validationFailure empty so no + // intent_planner_error rows or decisions are emitted on every tick. + bypassReason := "intent planner circuit bypass: open" + if openErr.HalfOpen { + bypassReason = "intent planner circuit bypass: half-open probe in progress" + } + recordIntentPromptFallback(ctx, planner, bypassReason) + plan, err := deterministicIntentFallback(ctx, repoRoot, req, items) + return plan, "", err + } + } + var validationFailure string plan, err := planner.PlanIntent(ctx, req) + plannerCallFailed := err != nil if err == nil { // Defense in depth against third-party planners that skip the helper. // Discard the dropped/synthesized lists: provider-side warns already // fired upstream; emitting another warn here would duplicate the // log entry for the same response. + plan = ai.NormalizeIntentPlanReasons(plan) plan, _, _, _ = ai.NormalizeIntentPlanDeferredReasons(plan) err = ai.ValidateIntentPlan(req, plan) } @@ -1936,15 +2019,26 @@ func planIntentWithFallback(ctx context.Context, repoRoot string, db *state.DB, err = validateIntentSelectionSafety(items, plan) } if err == nil { + if health != nil { + if healthErr := health.Complete(ctx, permit, nil); healthErr != nil { + return ai.IntentPlan{}, "", healthErr + } + } return plan, "", nil } - validationFailure = err.Error() + if health != nil { + failure := classifyIntentPlannerHealthFailure(err, plannerCallFailed) + if healthErr := health.Complete(ctx, permit, failure); healthErr != nil { + return ai.IntentPlan{}, "", healthErr + } + } + validationFailure = ai.SanitizePlannerError(err.Error()) recordIntentPromptFallback(ctx, planner, validationFailure) for _, item := range items { - if recErr := state.RecordPlannerError(ctx, db, item.event.Seq, ts, err.Error()); recErr != nil { + if recErr := state.RecordPlannerError(ctx, db, item.event.Seq, ts, validationFailure); recErr != nil { return ai.IntentPlan{}, validationFailure, recErr } - if recErr := appendIntentPlannerDecision(ctx, db, item.event, cctx, ts, state.DecisionKindIntentPlannerError, err.Error(), "planner validation failed", "Intent planner validation failed; deterministic fallback will choose a safe one-item plan."); recErr != nil { + if recErr := appendIntentPlannerDecision(ctx, db, item.event, cctx, ts, state.DecisionKindIntentPlannerError, validationFailure, "planner validation failed", "Intent planner validation failed; deterministic fallback will choose a safe one-item plan."); recErr != nil { return ai.IntentPlan{}, validationFailure, recErr } } @@ -1960,25 +2054,62 @@ func planIntentWithFallback(ctx context.Context, repoRoot string, db *state.DB, } } } + plan, err = deterministicIntentFallback(ctx, repoRoot, req, items) + return plan, validationFailure, err +} + +func classifyIntentPlannerHealthFailure(err error, plannerCallFailed bool) error { + if !plannerCallFailed { + return &IntentPlannerValidationFailure{Err: err} + } + var qualityErr *ai.MessageQualityError + if errors.As(err, &qualityErr) { + // The composed planner adds message-quality context around both rewrite + // transport failures and malformed rewrite responses. Provider boundary + // typing keeps those outcomes distinct. + if qualityErr.Cause != nil { + var rewriteValidation *ai.IntentMessageRewriteValidationError + if !errors.As(qualityErr.Cause, &rewriteValidation) { + return &IntentPlannerTransportFailure{Err: err} + } + } + return &IntentPlannerValidationFailure{Err: err} + } + var validationErr *ai.IntentPlanValidationError + if errors.As(err, &validationErr) { + return &IntentPlannerValidationFailure{Err: err} + } + return &IntentPlannerTransportFailure{Err: err} +} + +func deterministicIntentFallback( + ctx context.Context, + repoRoot string, + req ai.IntentPlanRequest, + items []intentReplayItem, +) (ai.IntentPlan, error) { + var ( + plan ai.IntentPlan + err error + ) if req.ForcedAging && len(items) == 1 { plan = planIntentSingletonFastPathHook(ctx, repoRoot, items[0], req.CommitFormat) - if err := ai.ValidateIntentPlan(req, plan); err != nil { - return ai.IntentPlan{}, validationFailure, err - } - if err := validateIntentSelectionSafety(items, plan); err != nil { - return ai.IntentPlan{}, validationFailure, err + } else { + fallback := ai.DeterministicProvider{CommitFormat: req.CommitFormat} + plan, err = fallback.PlanIntent(ctx, req) + if err != nil { + return ai.IntentPlan{}, err } - return plan, validationFailure, nil } - fallback := ai.DeterministicProvider{CommitFormat: req.CommitFormat} - plan, err = fallback.PlanIntent(ctx, req) - if err != nil { - return ai.IntentPlan{}, validationFailure, err + plan = ai.NormalizeIntentPlanReasons(plan) + plan, _, _, _ = ai.NormalizeIntentPlanDeferredReasons(plan) + if err := ai.ValidateIntentPlan(req, plan); err != nil { + return ai.IntentPlan{}, err } if err := validateIntentSelectionSafety(items, plan); err != nil { - return ai.IntentPlan{}, validationFailure, err + return ai.IntentPlan{}, err } - return plan, validationFailure, nil + return plan, nil } func recordIntentPromptFallback(ctx context.Context, planner ai.IntentPlanner, reason string) { @@ -2238,7 +2369,7 @@ func recordIntentPlannerWindow( CommitFormat: nullString(string(req.CommitFormat)), Forced: forced, ForcedReason: nullString(forcedReason), - ValidationFailure: nullString(strings.TrimSpace(validationFailure)), + ValidationFailure: nullString(ai.SanitizePlannerError(validationFailure)), OfferedSeqs: intentOfferedSeqs(req), VisibleOriginalSeqs: visibleOriginalSeqs, HiddenSeqs: hiddenSeqs, @@ -2411,7 +2542,7 @@ func traceIntentPlannerValidationFailure(logger acdtrace.Logger, repoRoot string Input: map[string]any{ "offered_seqs": intentItemSeqs(items), }, - Error: errMsg, + Error: ai.SanitizePlannerError(errMsg), Generation: cctx.BranchGeneration, }) } @@ -3166,104 +3297,75 @@ func traceReplayUpdateRef( // // Errors during the per-row probe are logged and skipped — one bad row // must not abort the rest of the self-heal pass or the replay loop. -func probeBlockedSelfHeal(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureContext, logger acdtrace.Logger) error { +func probeBlockedSelfHeal(ctx context.Context, repoRoot string, db *state.DB, cctx CaptureContext, logger acdtrace.Logger) (RecoveryChainResult, error) { + var result RecoveryChainResult if cctx.BranchRef == "" { - return nil + return result, nil } - blocked, err := state.BlockedEventsForGeneration(ctx, db, cctx.BranchRef, cctx.BranchGeneration, 0) + seq, barrierState, ok, err := firstRecoveryBarrier(ctx, db, cctx.BranchRef, cctx.BranchGeneration) if err != nil { - return fmt.Errorf("daemon: self-heal: load blocked rows: %w", err) + return result, fmt.Errorf("daemon: self-heal: find first barrier: %w", err) } - if len(blocked) == 0 { - return nil + if !ok || barrierState != state.EventStateBlockedConflict { + return result, nil } - healed := 0 - for _, ev := range blocked { - if err := ctx.Err(); err != nil { - return err - } - if !selfHealEligibleByClass(ev) { - continue - } - ops, err := state.LoadCaptureOps(ctx, db, ev.Seq) - if err != nil { - slog.Default().Warn("daemon: self-heal: load ops", - "seq", ev.Seq, "err", err.Error()) - continue - } - if !selfHealEligibleByOps(ops) { - continue - } - headOID, alreadyPublished, err := alreadyPublishedAtHEAD(ctx, repoRoot, ev.BaseHead, ops) - if err != nil { - slog.Default().Warn("daemon: self-heal: probe HEAD", - "seq", ev.Seq, "path", ev.Path, "err", err.Error()) - continue - } - if !alreadyPublished { - continue - } - nowSec := float64(time.Now().UnixNano()) / 1e9 - if err := state.TransitionBlockedToPublished(ctx, db, - ev.Seq, headOID, nowSec, - cctx.BranchRef, cctx.BranchGeneration, ev.BaseHead, - ); err != nil { - if errors.Is(err, state.ErrBlockedRowNotEligible) { - // Race: another writer moved the row out of - // blocked_conflict between our load and our update. - // Treat as a no-op — the row is no longer ours to - // self-heal. - continue - } - slog.Default().Warn("daemon: self-heal: transition row", - "seq", ev.Seq, "path", ev.Path, "err", err.Error()) - continue - } - decisionCtx := cctx - decisionCtx.BaseHead = ev.BaseHead - recordReplayDecision(ctx, db, ev, decisionCtx, nowSec, - state.DecisionKindHandledExternalAfterBlock, - "handled_external_after_block", - headOID, - ) - traceReplay(logger, repoRoot, decisionCtx, ev, - "replay.self_heal", state.DecisionKindHandledExternalAfterBlock, - "handled_external_after_block", map[string]any{ - "commit": headOID, - "head": headOID, - "source_head": ev.BaseHead, - "branch_ref": cctx.BranchRef, - "generation": cctx.BranchGeneration, - }) - healed++ + firstSeq, ok, err := firstUnpublishedRecoverySeq(ctx, db, cctx.BranchRef, cctx.BranchGeneration) + if err != nil { + return result, fmt.Errorf("daemon: self-heal: find first unpublished row: %w", err) } - if healed == 0 { - return nil + if !ok || firstSeq != seq { + return result, nil } - // Best-effort meta cleanup: only when no blocked rows remain on this - // (branch_ref, branch_generation). Other live anchors may still own - // the breadcrumbs, so we narrow the check to the active anchor before - // dropping the keys. - remaining, err := state.BlockedEventsForGeneration(ctx, db, cctx.BranchRef, cctx.BranchGeneration, 1) + chain, err := state.LoadUnpublishedRecoveryChain(ctx, db, cctx.BranchRef, cctx.BranchGeneration, firstSeq) if err != nil { - slog.Default().Warn("daemon: self-heal: count remaining blocked", - "branch_ref", cctx.BranchRef, "err", err.Error()) - return nil + return result, fmt.Errorf("daemon: self-heal: load barrier suffix: %w", err) } - if len(remaining) > 0 { - return nil + // Preserve the established single-event decision kind without peeling a + // multi-event chain one barrier at a time. Whole chains are handled by + // reconcileActiveBarrierChain immediately after this probe. + if len(chain) != 1 || chain[0].Event.Seq != seq { + return result, nil } - for _, key := range []string{ - metaKeyLastReplayConflict, - metaKeyLastReplayConflictLegacy, - "last_replay_error", - } { - if _, err := state.MetaDelete(ctx, db, key); err != nil { - slog.Default().Warn("daemon: self-heal: clear meta", - "key", key, "err", err.Error()) - } + ev := chain[0].Event + ops := chain[0].Ops + if !selfHealEligibleByClass(ev) || !selfHealEligibleByOps(ops) { + return result, nil } - return nil + headOID, alreadyPublished, err := alreadyPublishedAtHEAD(ctx, repoRoot, ev.BaseHead, ops) + if err != nil { + return result, fmt.Errorf("daemon: self-heal: probe HEAD seq=%d path=%s: %w", ev.Seq, ev.Path, err) + } + if !alreadyPublished { + return result, nil + } + result, err = ReconcileUnpublishedChain(ctx, repoRoot, db, RecoveryReconcileOptions{ + BranchRef: cctx.BranchRef, + BranchGeneration: cctx.BranchGeneration, + FirstSeq: ev.Seq, + Trigger: "handled_external_after_block", + Trace: logger, + InvalidateShadow: true, + decisionKind: state.DecisionKindHandledExternalAfterBlock, + }) + if err != nil { + return RecoveryChainResult{}, fmt.Errorf("daemon: self-heal: protected transition seq=%d path=%s: %w", ev.Seq, ev.Path, err) + } + if !result.Handled || result.Outcome != state.EventStatePublished { + return result, nil + } + decisionCtx := cctx + decisionCtx.BaseHead = ev.BaseHead + traceReplay(logger, repoRoot, decisionCtx, ev, + "replay.self_heal", state.DecisionKindHandledExternalAfterBlock, + "handled_external_after_block", map[string]any{ + "commit": result.CommitOID, + "head": headOID, + "recovery_ref": result.RecoveryRef, + "source_head": ev.BaseHead, + "branch_ref": cctx.BranchRef, + "generation": cctx.BranchGeneration, + }) + return result, nil } // selfHealEligibleByClass narrows blocked rows to the before_state_mismatch diff --git a/internal/daemon/replay_self_heal_test.go b/internal/daemon/replay_self_heal_test.go index 7a49da17..4f0eeefc 100644 --- a/internal/daemon/replay_self_heal_test.go +++ b/internal/daemon/replay_self_heal_test.go @@ -4,7 +4,12 @@ import ( "context" "database/sql" "errors" + "fmt" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" @@ -66,6 +71,162 @@ func readEventState(t *testing.T, ctx context.Context, db *state.DB, seq int64) return s, oid } +func assertRecoverySnapshot( + t *testing.T, + ctx context.Context, + f *captureFixture, + seq int64, + commitOID string, + baseHead string, + wantBlob string, +) string { + t.Helper() + var ref string + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT recovery_ref +FROM recovery_snapshots +WHERE first_event_seq = ? AND last_event_seq = ?`, seq, seq).Scan(&ref); err != nil { + t.Fatalf("query recovery snapshot: %v", err) + } + if !strings.HasPrefix(ref, git.RecoveryRefPrefix) { + t.Fatalf("recovery ref=%q want prefix %q", ref, git.RecoveryRefPrefix) + } + resolved, err := git.RevParse(ctx, f.dir, ref) + if err != nil { + t.Fatalf("resolve recovery ref: %v", err) + } + if resolved != commitOID { + t.Fatalf("recovery ref commit=%s want %s", resolved, commitOID) + } + parent, err := git.RevParse(ctx, f.dir, ref+"^") + if err != nil { + t.Fatalf("resolve recovery parent: %v", err) + } + if parent != baseHead { + t.Fatalf("recovery parent=%s want immutable base %s", parent, baseHead) + } + gotBlob, err := git.LsTreeBlobOID(ctx, f.dir, ref, "doc.md") + if err != nil { + t.Fatalf("read recovery tree: %v", err) + } + if gotBlob != wantBlob { + t.Fatalf("recovery doc.md blob=%s want %s", gotBlob, wantBlob) + } + assertReplayDecision(t, ctx, f.db, seq, + state.DecisionKindRecoveryArchived, "replay_terminal_barrier") + return ref +} + +func appendRecoveryEvent( + t *testing.T, + ctx context.Context, + f *captureFixture, + baseHead string, + op state.CaptureOp, +) int64 { + t.Helper() + if op.Fidelity == "" { + op.Fidelity = "rescan" + } + ev := state.CaptureEvent{ + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + BaseHead: baseHead, + Operation: op.Op, + Path: op.Path, + OldPath: op.OldPath, + Fidelity: op.Fidelity, + } + seq, err := state.AppendCaptureEvent(ctx, f.db, ev, []state.CaptureOp{op}) + if err != nil { + t.Fatalf("AppendCaptureEvent %s %s: %v", op.Op, op.Path, err) + } + return seq +} + +func markRecoveryBarrier(t *testing.T, ctx context.Context, f *captureFixture, seq int64, baseHead, message string) { + t.Helper() + if err := state.MarkEventBlocked(ctx, f.db, seq, message, 1.0, + sql.NullString{String: f.cctx.BranchRef, Valid: true}, + sql.NullInt64{Int64: f.cctx.BranchGeneration, Valid: true}, + sql.NullString{String: baseHead, Valid: true}, + ); err != nil { + t.Fatalf("MarkEventBlocked seq=%d: %v", seq, err) + } +} + +func commitTreeWithIndexUpdates( + t *testing.T, + ctx context.Context, + f *captureFixture, + baseHead string, + message string, + lines ...string, +) string { + t.Helper() + indexFile := filepath.Join(t.TempDir(), "idx") + if err := git.ReadTree(ctx, f.dir, indexFile, baseHead); err != nil { + t.Fatalf("ReadTree %s: %v", baseHead, err) + } + if err := git.UpdateIndexInfo(ctx, f.dir, indexFile, lines); err != nil { + t.Fatalf("UpdateIndexInfo: %v", err) + } + tree, err := git.WriteTree(ctx, f.dir, indexFile) + if err != nil { + t.Fatalf("WriteTree: %v", err) + } + commit, err := git.CommitTree(ctx, f.dir, tree, message, baseHead) + if err != nil { + t.Fatalf("CommitTree: %v", err) + } + return commit +} + +func countRecoverySnapshots(t *testing.T, ctx context.Context, db *state.DB) int { + t.Helper() + var count int + if err := db.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_snapshots`).Scan(&count); err != nil { + t.Fatalf("count recovery snapshots: %v", err) + } + return count +} + +func recoveryRefs(t *testing.T, ctx context.Context, repoDir string) []string { + t.Helper() + out, err := git.Run(ctx, git.RunOpts{Dir: repoDir}, + "for-each-ref", "--format=%(refname)", git.RecoveryRefPrefix) + if err != nil { + t.Fatalf("list recovery refs: %v", err) + } + return strings.Fields(string(out)) +} + +func gitRawOutput(t *testing.T, ctx context.Context, repoDir string, args ...string) string { + t.Helper() + out, err := git.Run(ctx, git.RunOpts{Dir: repoDir}, args...) + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return string(out) +} + +func TestReconcile_LargeChainHonorsCancellation(t *testing.T) { + chain := make([]state.RecoveryChainEvent, 20_000) + for i := range chain { + chain[i].Event.Seq = int64(i + 1) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + err := validateRecoveryObjects(ctx, t.TempDir(), chain) + if !errors.Is(err, context.Canceled) { + t.Fatalf("validateRecoveryObjects err=%v want context.Canceled", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("canceled large chain took %s", elapsed) + } +} + // TestReplay_SelfHealsBlockedWhenHeadAlreadyMatches covers the happy path: // a blocked_conflict row whose AfterOID matches HEAD's blob is promoted to // published with the handled_external_after_block decision, without minting @@ -167,11 +328,10 @@ func TestReplay_SelfHealsBlockedWhenHeadAlreadyMatches(t *testing.T) { } } -// TestReplay_SelfHealRefusesCreateOps guards the op-type narrowing: a -// blocked_conflict row whose ops include a create stays blocked even when -// HEAD's tree happens to match the after-state. Self-heal is intentionally -// scoped to modify/mode/rename-with-BeforeOID at first cut. -func TestReplay_SelfHealRefusesCreateOps(t *testing.T) { +// TestReplay_ReconcilesExactCreateChain keeps the legacy self-heal predicate +// narrow while proving the whole-chain reconciler can safely settle an exact +// create at HEAD. +func TestReplay_ReconcilesExactCreateChain(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() @@ -237,15 +397,16 @@ func TestReplay_SelfHealRefusesCreateOps(t *testing.T) { } st, _ := readEventState(t, ctx, f.db, seq) - if st != state.EventStateBlockedConflict { - t.Fatalf("state=%q want blocked_conflict (self-heal must skip create ops)", st) + if st != state.EventStatePublished { + t.Fatalf("state=%q want published", st) } + assertReplayDecision(t, ctx, f.db, seq, + state.DecisionKindRecoveryPublished, "replay_terminal_barrier") } -// TestReplay_SelfHealRefusesWhenHeadBlobDiffers guards alreadyPublishedAtHEAD's -// per-op blob check: a blocked_conflict row whose captured AfterOID does NOT -// match HEAD's blob stays blocked. -func TestReplay_SelfHealRefusesWhenHeadBlobDiffers(t *testing.T) { +// TestReplay_ArchivesWhenHeadBlobDiffers proves a non-matching but completely +// materializable chain is protected by a hidden ref before its barrier clears. +func TestReplay_ArchivesWhenHeadBlobDiffers(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() @@ -282,17 +443,17 @@ func TestReplay_SelfHealRefusesWhenHeadBlobDiffers(t *testing.T) { t.Fatalf("Replay: %v", err) } - st, _ := readEventState(t, ctx, f.db, seq) - if st != state.EventStateBlockedConflict { - t.Fatalf("state=%q want blocked_conflict (self-heal must skip when HEAD blob differs)", st) + st, commitOID := readEventState(t, ctx, f.db, seq) + if st != state.EventStateRecovered { + t.Fatalf("state=%q want recovered", st) } + assertRecoverySnapshot(t, ctx, f, seq, commitOID.String, base, afterBlob) } -// TestReplay_SelfHealRefusesWhenAncestryFails guards alreadyPublishedAtHEAD's -// ancestry probe: if the captured base_head is not an ancestor of HEAD, the -// matching tree state is coincidence (operator hard-reset to an unrelated -// branch), not a successful parallel publish. Stay blocked. -func TestReplay_SelfHealRefusesWhenAncestryFails(t *testing.T) { +// TestReplay_ArchivesWhenAncestryFails guards external-publish proof: matching +// path content on an unrelated HEAD is coincidence, so the chain is archived +// rather than marked externally published. +func TestReplay_ArchivesWhenAncestryFails(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() @@ -328,9 +489,1083 @@ func TestReplay_SelfHealRefusesWhenAncestryFails(t *testing.T) { t.Fatalf("Replay: %v", err) } - st, _ := readEventState(t, ctx, f.db, seq) - if st != state.EventStateBlockedConflict { - t.Fatalf("state=%q want blocked_conflict (ancestry guard must skip diverged HEAD)", st) + st, commitOID := readEventState(t, ctx, f.db, seq) + if st != state.EventStateRecovered { + t.Fatalf("state=%q want recovered", st) + } + assertRecoverySnapshot(t, ctx, f, seq, commitOID.String, base, afterBlob) +} + +func TestReconcile_PublishesLiteralFilenames(t *testing.T) { + runBoundedParallel(t) + for _, tc := range []struct { + name string + capturedPath string + distractorPath string + }{ + {name: "pathspec magic", capturedPath: ":(top)colon.txt", distractorPath: "colon.txt"}, + {name: "surrounding whitespace", capturedPath: " spaced.txt ", distractorPath: "spaced.txt"}, + } { + t.Run(tc.name, func(t *testing.T) { + assertReconcilePublishesLiteralFilename(t, tc.capturedPath, tc.distractorPath) + }) + } +} + +func assertReconcilePublishesLiteralFilename(t *testing.T, capturedPath, distractorPath string) { + t.Helper() + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + distractor, _ := git.HashObjectStdin(ctx, f.dir, []byte("distractor\n")) + + baseTree, err := git.Mktree(ctx, f.dir, []git.MktreeEntry{ + {Mode: git.RegularFileMode, Type: "blob", OID: before, Path: capturedPath}, + {Mode: git.RegularFileMode, Type: "blob", OID: distractor, Path: distractorPath}, + }) + if err != nil { + t.Fatalf("Mktree base: %v", err) + } + base, err := git.CommitTree(ctx, f.dir, baseTree, "base with pathspec-magic filename", f.cctx.BaseHead) + if err != nil { + t.Fatalf("CommitTree base: %v", err) + } + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + f.cctx.BaseHead = base + + seq := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: capturedPath, + BeforeOID: sql.NullString{String: before, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: after, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq, base, "modify before-state mismatch for "+capturedPath) + + externalTree, err := git.Mktree(ctx, f.dir, []git.MktreeEntry{ + {Mode: git.RegularFileMode, Type: "blob", OID: after, Path: capturedPath}, + {Mode: git.RegularFileMode, Type: "blob", OID: distractor, Path: distractorPath}, + }) + if err != nil { + t.Fatalf("Mktree external: %v", err) + } + external, err := git.CommitTree(ctx, f.dir, externalTree, "publish pathspec-magic filename", base) + if err != nil { + t.Fatalf("CommitTree external: %v", err) + } + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_literal_pathspec_recovery", + }) + if err != nil { + t.Fatalf("ReconcileUnpublishedChain: %v", err) + } + if !result.Handled || result.Outcome != state.EventStatePublished || result.CommitOID != external { + t.Fatalf("result=%+v want published at %s", result, external) + } + gotState, commitOID := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStatePublished || !commitOID.Valid || commitOID.String != external { + t.Fatalf("event state=%q commit=%v want published at %s", gotState, commitOID, external) + } + if resolved, err := git.RevParse(ctx, f.dir, result.RecoveryRef); err != nil || resolved != external { + t.Fatalf("proof ref=%s err=%v want %s", resolved, err, external) + } +} + +func TestReplay_ReconcilesWholeSquashedChain(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("A\n")) + middle, _ := git.HashObjectStdin(ctx, f.dir, []byte("B\n")) + final, _ := git.HashObjectStdin(ctx, f.dir, []byte("C\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "seed A") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + + seq1 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: before, Valid: true}, + BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: middle, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + seq2 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: middle, Valid: true}, + BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: final, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq2, base, "modify before-state mismatch for doc.md") + + external := commitSingleFileTree(t, ctx, f.dir, "doc.md", final, "squash A to C", base) + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + cctx := f.cctx + cctx.BaseHead = external + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + + for _, seq := range []int64{seq1, seq2} { + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStatePublished || !oid.Valid || oid.String != external { + t.Fatalf("seq=%d state=%q oid=%v want published at %s", seq, gotState, oid, external) + } + assertReplayDecision(t, ctx, f.db, seq, + state.DecisionKindRecoveryPublished, "replay_terminal_barrier") + } + var outcome string + var eventCount int + var recoveryRef sql.NullString + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT outcome, event_count, recovery_ref FROM recovery_snapshots`).Scan( + &outcome, &eventCount, &recoveryRef); err != nil { + t.Fatalf("query recovery snapshot: %v", err) + } + if outcome != state.EventStatePublished || eventCount != 2 || !recoveryRef.Valid { + t.Fatalf("snapshot outcome=%q count=%d ref=%v", outcome, eventCount, recoveryRef) + } + if resolved, err := git.RevParse(ctx, f.dir, recoveryRef.String); err != nil || resolved != external { + t.Fatalf("published proof ref target=%s err=%v want %s", resolved, err, external) + } + if refs := recoveryRefs(t, ctx, f.dir); len(refs) != 1 { + t.Fatalf("published proof refs=%v want one", refs) + } +} + +func TestReplay_ReconcilesBarrierAfterAgedPublishedSameBasePrefix(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + a, _ := git.HashObjectStdin(ctx, f.dir, []byte("A\n")) + b, _ := git.HashObjectStdin(ctx, f.dir, []byte("B\n")) + c, _ := git.HashObjectStdin(ctx, f.dir, []byte("C\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", a, "base A") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq1 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: a, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: b, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + commitB := commitSingleFileTree(t, ctx, f.dir, "doc.md", b, "publish B", base) + if err := state.MarkEventPublished(ctx, f.db, seq1, state.EventStatePublished, + sql.NullString{String: commitB, Valid: true}, sql.NullString{}, sql.NullString{}, 2); err != nil { + t.Fatalf("MarkEventPublished prefix: %v", err) + } + if _, err := state.AppendDecision(ctx, f.db, state.DecisionRecord{ + DecisionTS: 2, Kind: state.DecisionKindCommitted, + EventSeq: sql.NullInt64{Int64: seq1, Valid: true}, + }); err != nil { + t.Fatalf("AppendDecision prefix: %v", err) + } + seq2 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: b, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: c, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq2, base, "modify before-state mismatch for doc.md") + if _, err := f.db.SQL().ExecContext(ctx, + `UPDATE capture_events SET captured_ts = 1 WHERE seq = ?`, seq1); err != nil { + t.Fatalf("age published prefix: %v", err) + } + if pruned, err := state.PrunePublishedEventsBefore(ctx, f.db, 100); err != nil { + t.Fatalf("PrunePublishedEventsBefore: %v", err) + } else if pruned != 0 { + t.Fatalf("pruned=%d want aged materialization prefix retained", pruned) + } + commitC := commitSingleFileTree(t, ctx, f.dir, "doc.md", c, "publish C", commitB) + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, commitC, base); err != nil { + t.Fatalf("update HEAD to C: %v", err) + } + cctx := f.cctx + cctx.BaseHead = commitC + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq2) + if gotState != state.EventStatePublished || oid.String != commitC { + t.Fatalf("suffix state=%q oid=%v want published at %s", gotState, oid, commitC) + } +} + +func TestReplay_ReconcilesBarrierAfterGroupedSamePathPrefix(t *testing.T) { + // Keep this exact-outcome assertion in the serial phase: Replay deliberately + // leaves the barrier unchanged when a best-effort reconciliation command + // fails, so Git process starvation must not masquerade as a semantic failure. + f := newCaptureFixture(t) + ctx := context.Background() + a, _ := git.HashObjectStdin(ctx, f.dir, []byte("A\n")) + b, _ := git.HashObjectStdin(ctx, f.dir, []byte("B\n")) + c, _ := git.HashObjectStdin(ctx, f.dir, []byte("C\n")) + d, _ := git.HashObjectStdin(ctx, f.dir, []byte("D\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", a, "base A") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq1 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: a, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: b, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + seq2 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: b, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: c, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + groupedCommit := commitSingleFileTree(t, ctx, f.dir, "doc.md", c, "publish grouped C", base) + for _, seq := range []int64{seq1, seq2} { + if err := state.MarkEventPublished(ctx, f.db, seq, state.EventStatePublished, + sql.NullString{String: groupedCommit, Valid: true}, sql.NullString{}, sql.NullString{}, 2); err != nil { + t.Fatalf("MarkEventPublished grouped prefix seq=%d: %v", seq, err) + } + if _, err := state.AppendDecision(ctx, f.db, state.DecisionRecord{ + DecisionTS: 2, Kind: state.DecisionKindCommitted, + EventSeq: sql.NullInt64{Int64: seq, Valid: true}, + }); err != nil { + t.Fatalf("AppendDecision grouped prefix seq=%d: %v", seq, err) + } + } + seq3 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: c, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: d, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq3, base, "modify before-state mismatch for doc.md") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, groupedCommit, base); err != nil { + t.Fatalf("update HEAD to grouped commit: %v", err) + } + cctx := f.cctx + cctx.BaseHead = groupedCommit + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq3) + if gotState != state.EventStateRecovered || !oid.Valid { + t.Fatalf("suffix state=%q oid=%v want recovered", gotState, oid) + } + var recoveryRef string + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT recovery_ref FROM recovery_snapshots WHERE first_event_seq = ?`, seq3).Scan(&recoveryRef); err != nil { + t.Fatalf("query recovery ref: %v", err) + } + gotBlob, err := git.LsTreeBlobOID(ctx, f.dir, recoveryRef, "doc.md") + if err != nil || gotBlob != d { + t.Fatalf("recovery blob=%s err=%v want final D %s", gotBlob, err, d) + } +} + +func TestReplay_ExcludesSupersededPublishedPrefix(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + a, _ := git.HashObjectStdin(ctx, f.dir, []byte("A\n")) + b, _ := git.HashObjectStdin(ctx, f.dir, []byte("superseded B\n")) + c, _ := git.HashObjectStdin(ctx, f.dir, []byte("captured C\n")) + unrelated, _ := git.HashObjectStdin(ctx, f.dir, []byte("external unrelated\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", a, "base A") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq1 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: a, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: b, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + external := commitTreeWithIndexUpdates(t, ctx, f, base, "external supersedes B", + git.RegularFileMode+" "+unrelated+"\tunrelated.txt") + if err := state.MarkEventPublished(ctx, f.db, seq1, state.EventStatePublished, + sql.NullString{String: external, Valid: true}, sql.NullString{}, sql.NullString{}, 2); err != nil { + t.Fatalf("MarkEventPublished superseded prefix: %v", err) + } + if _, err := state.AppendDecision(ctx, f.db, state.DecisionRecord{ + DecisionTS: 2, Kind: state.DecisionKindSupersededExternal, + EventSeq: sql.NullInt64{Int64: seq1, Valid: true}, + }); err != nil { + t.Fatalf("AppendDecision superseded prefix: %v", err) + } + seq2 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "modify", Path: "doc.md", + BeforeOID: sql.NullString{String: a, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + AfterOID: sql.NullString{String: c, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq2, base, "modify before-state mismatch for doc.md") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external HEAD: %v", err) + } + cctx := f.cctx + cctx.BaseHead = external + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq2) + if gotState != state.EventStateRecovered { + t.Fatalf("suffix state=%q oid=%v want recovered", gotState, oid) + } + var ref string + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT recovery_ref FROM recovery_snapshots WHERE first_event_seq = ?`, seq2).Scan(&ref); err != nil { + t.Fatalf("query recovery ref: %v", err) + } + gotBlob, err := git.LsTreeBlobOID(ctx, f.dir, ref, "doc.md") + if err != nil || gotBlob != c { + t.Fatalf("recovery blob=%s err=%v want captured C %s", gotBlob, err, c) + } +} + +func TestReplay_ArchivesCompositeChainWithoutGitMutation(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + modifyBefore, _ := git.HashObjectStdin(ctx, f.dir, []byte("modify before\n")) + modifyAfter, _ := git.HashObjectStdin(ctx, f.dir, []byte("modify after\n")) + deleteBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("delete me\n")) + renameBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("rename me\n")) + modeBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("#!/bin/sh\n")) + symlinkBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("modify.txt")) + + baseTree, err := git.Mktree(ctx, f.dir, []git.MktreeEntry{ + {Mode: git.RegularFileMode, Type: "blob", OID: deleteBlob, Path: "delete.txt"}, + {Mode: git.RegularFileMode, Type: "blob", OID: modeBlob, Path: "mode.sh"}, + {Mode: git.RegularFileMode, Type: "blob", OID: modifyBefore, Path: "modify.txt"}, + {Mode: git.RegularFileMode, Type: "blob", OID: renameBlob, Path: "old.txt"}, + }) + if err != nil { + t.Fatalf("Mktree base: %v", err) + } + base, err := git.CommitTree(ctx, f.dir, baseTree, "composite base", f.cctx.BaseHead) + if err != nil { + t.Fatalf("CommitTree base: %v", err) + } + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, f.cctx.BaseHead); err != nil { + t.Fatalf("update base: %v", err) + } + f.cctx.BaseHead = base + + ops := []state.CaptureOp{ + {Op: "modify", Path: "modify.txt", BeforeOID: sql.NullString{String: modifyBefore, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, AfterOID: sql.NullString{String: modifyAfter, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}}, + {Op: "delete", Path: "delete.txt", BeforeOID: sql.NullString{String: deleteBlob, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}}, + {Op: "rename", Path: "new.txt", OldPath: sql.NullString{String: "old.txt", Valid: true}, BeforeOID: sql.NullString{String: renameBlob, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, AfterOID: sql.NullString{String: renameBlob, Valid: true}, AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}}, + {Op: "mode", Path: "mode.sh", BeforeOID: sql.NullString{String: modeBlob, Valid: true}, BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, AfterOID: sql.NullString{String: modeBlob, Valid: true}, AfterMode: sql.NullString{String: "100755", Valid: true}}, + {Op: "create", Path: "link", AfterOID: sql.NullString{String: symlinkBlob, Valid: true}, AfterMode: sql.NullString{String: git.SymlinkMode, Valid: true}}, + } + seqs := make([]int64, 0, len(ops)) + for _, op := range ops { + seqs = append(seqs, appendRecoveryEvent(t, ctx, f, base, op)) + } + markRecoveryBarrier(t, ctx, f, seqs[0], base, "modify before-state mismatch for modify.txt") + + // HEAD matches only the first final path. Proof must inspect every path + // and archive the whole chain, never partially publish it. + external := commitTreeWithIndexUpdates(t, ctx, f, base, "partial external match", + git.RegularFileMode+" "+modifyAfter+"\tmodify.txt") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + cctx := f.cctx + cctx.BaseHead = external + headBefore, _ := git.RevParse(ctx, f.dir, "HEAD") + branchBefore, _ := git.RunBranchRef(ctx, f.dir) + indexBefore := gitRawOutput(t, ctx, f.dir, "ls-files", "-s", "-z") + worktreeBefore := gitRawOutput(t, ctx, f.dir, "status", "--porcelain=v1", "-z") + + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + var ref string + var commitOID string + var eventCount int + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT recovery_ref, commit_oid, event_count FROM recovery_snapshots`).Scan( + &ref, &commitOID, &eventCount); err != nil { + t.Fatalf("query recovery snapshot: %v", err) + } + if eventCount != len(seqs) { + t.Fatalf("snapshot event_count=%d want %d", eventCount, len(seqs)) + } + for _, seq := range seqs { + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateRecovered || oid.String != commitOID { + t.Fatalf("seq=%d state=%q oid=%v want recovered at %s", seq, gotState, oid, commitOID) + } + } + parent, err := git.RevParse(ctx, f.dir, ref+"^") + if err != nil || parent != base { + t.Fatalf("recovery parent=%s err=%v want %s", parent, err, base) + } + entries, err := git.LsTree(ctx, f.dir, ref, false, + "modify.txt", "delete.txt", "old.txt", "new.txt", "mode.sh", "link") + if err != nil { + t.Fatalf("LsTree recovery ref: %v", err) + } + wantEntries := map[string]git.TreeEntry{ + "modify.txt": {Mode: git.RegularFileMode, Type: "blob", OID: modifyAfter}, + "new.txt": {Mode: git.RegularFileMode, Type: "blob", OID: renameBlob}, + "mode.sh": {Mode: "100755", Type: "blob", OID: modeBlob}, + "link": {Mode: git.SymlinkMode, Type: "blob", OID: symlinkBlob}, + } + for _, entry := range entries { + want, ok := wantEntries[entry.Path] + if !ok { + t.Fatalf("unexpected recovery path: %+v", entry) + } + if entry.Mode != want.Mode || entry.Type != want.Type || entry.OID != want.OID { + t.Fatalf("recovery entry=%+v want=%+v", entry, want) + } + delete(wantEntries, entry.Path) + } + if len(wantEntries) != 0 { + t.Fatalf("missing recovery entries: %v", wantEntries) + } + headAfter, _ := git.RevParse(ctx, f.dir, "HEAD") + branchAfter, _ := git.RunBranchRef(ctx, f.dir) + if headAfter != headBefore || branchAfter != branchBefore { + t.Fatalf("recovery mutated HEAD/ref: head %s->%s branch %s->%s", headBefore, headAfter, branchBefore, branchAfter) + } + if got := gitRawOutput(t, ctx, f.dir, "ls-files", "-s", "-z"); got != indexBefore { + t.Fatal("recovery mutated live index") + } + if got := gitRawOutput(t, ctx, f.dir, "status", "--porcelain=v1", "-z"); got != worktreeBefore { + t.Fatal("recovery mutated worktree status") + } +} + +func TestReplay_RecoveryOrdersDirectoryReplacementDeletesFirst(t *testing.T) { + runBoundedParallel(t) + + for _, tc := range []struct { + name string + basePath string + targetPath string + }{ + {name: "directory to file", basePath: "dir/file.txt", targetPath: "dir"}, + {name: "file to directory", basePath: "dir", targetPath: "dir/file.txt"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitTreeWithIndexUpdates(t, ctx, f, f.cctx.BaseHead, "replacement base", + git.RegularFileMode+" "+before+"\t"+tc.basePath) + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, f.cctx.BaseHead); err != nil { + t.Fatalf("update base: %v", err) + } + seq1 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "delete", Path: tc.basePath, + BeforeOID: sql.NullString{String: before, Valid: true}, + BeforeMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq1, base, "delete before-state mismatch for "+tc.basePath) + seq2 := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "create", Path: tc.targetPath, + AfterOID: sql.NullString{String: after, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + cctx := f.cctx + cctx.BaseHead = base + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + for _, seq := range []int64{seq1, seq2} { + if gotState, _ := readEventState(t, ctx, f.db, seq); gotState != state.EventStateRecovered { + t.Fatalf("seq=%d state=%q want recovered", seq, gotState) + } + } + var ref string + if err := f.db.SQL().QueryRowContext(ctx, + `SELECT recovery_ref FROM recovery_snapshots`).Scan(&ref); err != nil { + t.Fatalf("query recovery ref: %v", err) + } + got, err := git.LsTreeBlobOID(ctx, f.dir, ref, tc.targetPath) + if err != nil || got != after { + t.Fatalf("target %s blob=%s err=%v want %s", tc.targetPath, got, err, after) + } + if got, err := git.LsTreeBlobOID(ctx, f.dir, ref, tc.basePath); tc.basePath != tc.targetPath && (err != nil || got != "") { + t.Fatalf("base path %s still present blob=%s err=%v", tc.basePath, got, err) + } + }) + } +} + +func TestReplay_ReconcileMissingObjectLeavesChainUntouched(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + after, err := git.HashObjectStdin(ctx, f.dir, []byte("unreachable payload\n")) + if err != nil { + t.Fatalf("HashObjectStdin: %v", err) + } + seq := appendRecoveryEvent(t, ctx, f, f.cctx.BaseHead, state.CaptureOp{ + Op: "create", Path: "missing.txt", + AfterOID: sql.NullString{String: after, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq, f.cctx.BaseHead, "create conflict for missing.txt") + objectPath := filepath.Join(f.gitDir, "objects", after[:2], after[2:]) + if err := os.Remove(objectPath); err != nil { + t.Fatalf("remove captured object %s: %v", objectPath, err) + } + + if _, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay: %v", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateBlockedConflict || oid.Valid { + t.Fatalf("state=%q oid=%v want unchanged blocked row", gotState, oid) + } + if count := countRecoverySnapshots(t, ctx, f.db); count != 0 { + t.Fatalf("recovery snapshots=%d want 0", count) + } + if refs := recoveryRefs(t, ctx, f.dir); len(refs) != 0 { + t.Fatalf("recovery refs=%v want none", refs) + } +} + +func TestReconcile_StableTokenRaceRetriesSameTreeRef(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + otherRef := "refs/heads/recovery-race" + if err := git.UpdateRef(ctx, f.dir, otherRef, base, + "0000000000000000000000000000000000000000"); err != nil { + t.Fatalf("create race ref: %v", err) + } + + var switchErr error + _, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_token_race", + beforeFinalHeadCheck: func() { + _, switchErr = git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", otherRef) + }, + }) + if switchErr != nil { + t.Fatalf("switch symbolic HEAD: %v", switchErr) + } + if err == nil || !strings.Contains(err.Error(), "branch token moved") { + t.Fatalf("Reconcile error=%v want stable-token refusal", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateBlockedConflict || oid.Valid || countRecoverySnapshots(t, ctx, f.db) != 0 { + t.Fatalf("race changed DB state=%q oid=%v snapshots=%d", gotState, oid, countRecoverySnapshots(t, ctx, f.db)) + } + refs := recoveryRefs(t, ctx, f.dir) + if len(refs) != 1 { + t.Fatalf("safe evidence refs=%v want one", refs) + } + protectedCommit, err := git.RevParse(ctx, f.dir, refs[0]) + if err != nil { + t.Fatalf("resolve protected commit: %v", err) + } + + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", f.cctx.BranchRef); err != nil { + t.Fatalf("restore symbolic HEAD: %v", err) + } + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_retry", + }) + if err != nil { + t.Fatalf("retry Reconcile: %v", err) + } + if !result.Handled || result.Outcome != state.EventStateRecovered || result.CommitOID != protectedCommit { + t.Fatalf("retry result=%+v want reused recovered commit %s", result, protectedCommit) + } + if refs := recoveryRefs(t, ctx, f.dir); len(refs) != 1 { + t.Fatalf("retry created duplicate refs: %v", refs) + } +} + +func TestReconcile_ABAHeadSampleCannotFalsePublish(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + external := commitSingleFileTree(t, ctx, f.dir, "doc.md", after, "transient external", base) + otherRef := "refs/heads/recovery-aba" + if err := git.UpdateRef(ctx, f.dir, otherRef, external, + "0000000000000000000000000000000000000000"); err != nil { + t.Fatalf("create transient ref: %v", err) + } + + var switchErr, restoreErr error + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_aba_head_sample", + afterInitialLiveToken: func() { + _, switchErr = git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", otherRef) + }, + beforeLiveTokenRecheck: func() { + _, restoreErr = git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", f.cctx.BranchRef) + }, + }) + if switchErr != nil || restoreErr != nil { + t.Fatalf("ABA hooks: switch=%v restore=%v", switchErr, restoreErr) + } + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if !result.Handled || result.Outcome != state.EventStateRecovered { + t.Fatalf("ABA result=%+v want recovered, not transient published proof", result) + } + if !strings.HasSuffix(result.RecoveryRef, "/archive") { + t.Fatalf("recovery ref=%q want archive", result.RecoveryRef) + } + if head, err := git.RevParse(ctx, f.dir, "HEAD"); err != nil || head != base { + t.Fatalf("HEAD=%s err=%v want stable base %s", head, err, base) + } +} + +func TestReconcile_PublishedProofSurvivesTokenRaceAndRetries(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + external := commitSingleFileTree(t, ctx, f.dir, "doc.md", after, "external", base) + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + otherRef := "refs/heads/published-proof-race" + if err := git.UpdateRef(ctx, f.dir, otherRef, external, + "0000000000000000000000000000000000000000"); err != nil { + t.Fatalf("create other ref: %v", err) + } + + var switchErr error + _, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_published_race", + beforeStateTransition: func() { + _, switchErr = git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", otherRef) + }, + }) + if switchErr != nil { + t.Fatalf("switch symbolic HEAD: %v", switchErr) + } + if err == nil || !strings.Contains(err.Error(), "branch token moved") { + t.Fatalf("Reconcile error=%v want stable-token refusal", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateBlockedConflict || oid.Valid || countRecoverySnapshots(t, ctx, f.db) != 0 { + t.Fatalf("race changed DB state=%q oid=%v snapshots=%d", gotState, oid, countRecoverySnapshots(t, ctx, f.db)) + } + proofRef := recoveryProofRefName(f.cctx.BranchRef, f.cctx.BranchGeneration, seq, seq, external) + protected, err := git.RevParse(ctx, f.dir, proofRef) + if err != nil || protected != external { + t.Fatalf("published proof target=%s err=%v want %s", protected, err, external) + } + + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, "symbolic-ref", "HEAD", f.cctx.BranchRef); err != nil { + t.Fatalf("restore symbolic HEAD: %v", err) + } + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_published_retry", + }) + if err != nil { + t.Fatalf("retry Reconcile: %v", err) + } + if !result.Handled || result.Outcome != state.EventStatePublished || + result.CommitOID != external || result.RecoveryRef != proofRef { + t.Fatalf("retry result=%+v want protected published commit", result) + } +} + +func TestReconcile_ProtectsRecoveryRefThroughStateTransition(t *testing.T) { + runBoundedParallel(t) + + for _, mutation := range []string{"delete", "move"} { + t.Run(mutation, func(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + external := commitSingleFileTree(t, ctx, f.dir, "doc.md", after, "external", base) + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + proofRef := recoveryProofRefName(f.cctx.BranchRef, f.cctx.BranchGeneration, seq, seq, external) + + var mutationErr error + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_protected_transition_" + mutation, + afterRecoveryRefLocked: func() { + mutationCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + if mutation == "delete" { + _, mutationErr = git.Run(mutationCtx, git.RunOpts{Dir: f.dir}, + "update-ref", "-d", proofRef, external) + return + } + mutationErr = git.UpdateRef(mutationCtx, f.dir, proofRef, base, external) + }, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if mutationErr == nil { + t.Fatalf("concurrent recovery ref %s succeeded while transition lock was held", mutation) + } + if !result.Handled || result.Outcome != state.EventStatePublished || result.CommitOID != external { + t.Fatalf("result=%+v want published at %s", result, external) + } + gotState, commitOID := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStatePublished || !commitOID.Valid || commitOID.String != external { + t.Fatalf("DB state=%q commit=%v want published at %s", gotState, commitOID, external) + } + resolved, err := git.RevParse(ctx, f.dir, proofRef) + if err != nil || resolved != external { + t.Fatalf("protected proof ref=%s err=%v want reachable %s", resolved, err, external) + } + }) + } +} + +func TestReconcile_RecoveryRefCollisionLeavesChainUntouched(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + wrong, _ := git.HashObjectStdin(ctx, f.dir, []byte("wrong\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + expectedCommit := commitSingleFileTree(t, ctx, f.dir, "doc.md", after, "expected recovery", base) + expectedTree, err := git.RevParse(ctx, f.dir, expectedCommit+"^{tree}") + if err != nil { + t.Fatalf("resolve expected tree: %v", err) + } + wrongCommit := commitSingleFileTree(t, ctx, f.dir, "doc.md", wrong, "wrong recovery", base) + ref := recoveryRefName(f.cctx.BranchRef, f.cctx.BranchGeneration, seq, seq, base, expectedTree) + if _, err := git.EnsureRecoveryRef(ctx, f.dir, ref, wrongCommit); err != nil { + t.Fatalf("seed conflicting recovery ref: %v", err) + } + + _, err = ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_collision", + }) + if !errors.Is(err, git.ErrRecoveryRefCollision) { + t.Fatalf("Reconcile err=%v want ErrRecoveryRefCollision", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateBlockedConflict || oid.Valid || countRecoverySnapshots(t, ctx, f.db) != 0 { + t.Fatalf("collision changed DB state=%q oid=%v snapshots=%d", gotState, oid, countRecoverySnapshots(t, ctx, f.db)) + } + resolved, err := git.RevParse(ctx, f.dir, ref) + if err != nil || resolved != wrongCommit { + t.Fatalf("collision overwrote ref: got=%s err=%v want=%s", resolved, err, wrongCommit) + } +} + +func TestReconcile_RecoveryRefTargetDigestSurvivesStateReset(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + afterFirst, _ := git.HashObjectStdin(ctx, f.dir, []byte("after first\n")) + afterSecond, _ := git.HashObjectStdin(ctx, f.dir, []byte("after second\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + + firstSeq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, afterFirst, base) + first, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: firstSeq, + Trigger: "test_state_reset_first", + ArchiveOnly: true, + }) + if err != nil { + t.Fatalf("first Reconcile: %v", err) + } + + // Simulate replacement of a worktree-local state DB while the shared Git + // recovery namespace remains. The next capture deliberately reuses the + // same branch, generation, and sequence selector with different content. + for _, statement := range []string{ + "DELETE FROM recovery_snapshots", + "DELETE FROM capture_events", + "DELETE FROM sqlite_sequence WHERE name IN ('capture_events', 'recovery_snapshots')", + } { + if _, err := f.db.SQL().ExecContext(ctx, statement); err != nil { + t.Fatalf("reset state with %q: %v", statement, err) + } + } + secondSeq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, afterSecond, base) + if secondSeq != firstSeq { + t.Fatalf("reset sequence=%d want reused selector %d", secondSeq, firstSeq) + } + second, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: secondSeq, + Trigger: "test_state_reset_second", + ArchiveOnly: true, + }) + if err != nil { + t.Fatalf("second Reconcile: %v", err) + } + if !first.Handled || !second.Handled || first.RecoveryRef == second.RecoveryRef { + t.Fatalf("recovery refs first=%q second=%q want distinct handled snapshots", + first.RecoveryRef, second.RecoveryRef) + } + for _, result := range []RecoveryChainResult{first, second} { + if !strings.Contains(result.RecoveryRef, fmt.Sprintf("/%d-%d-", firstSeq, firstSeq)) || + !strings.HasSuffix(result.RecoveryRef, "/archive") { + t.Fatalf("recovery ref %q lost inspectable selector", result.RecoveryRef) + } + resolved, err := git.RevParse(ctx, f.dir, result.RecoveryRef) + if err != nil || resolved != result.CommitOID { + t.Fatalf("recovery ref %q=(%q,%v) want %q", + result.RecoveryRef, resolved, err, result.CommitOID) + } + } +} + +func TestReconcile_DBRaceRollsBackShadowInvalidation(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + before, _ := git.HashObjectStdin(ctx, f.dir, []byte("before\n")) + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + base := commitSingleFileTree(t, ctx, f.dir, "doc.md", before, "base") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, base, ""); err != nil { + t.Fatalf("update base: %v", err) + } + seq, _, _ := seedBlockedModify(t, ctx, f, "doc.md", before, after, base) + if err := state.UpsertShadowPath(ctx, f.db, state.ShadowPath{ + BranchRef: f.cctx.BranchRef, BranchGeneration: f.cctx.BranchGeneration, + Path: "doc.md", Operation: "modify", + Mode: sql.NullString{String: git.RegularFileMode, Valid: true}, + OID: sql.NullString{String: after, Valid: true}, + BaseHead: base, Fidelity: "rescan", + }); err != nil { + t.Fatalf("UpsertShadowPath: %v", err) + } + marker := ShadowBootstrappedKey(f.cctx.BranchRef, f.cctx.BranchGeneration) + if err := state.MetaSet(ctx, f.db, marker, "1"); err != nil { + t.Fatalf("MetaSet shadow marker: %v", err) + } + + var raceErr error + _, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_db_race", + InvalidateShadow: true, + beforeStateTransition: func() { + _, raceErr = f.db.SQL().ExecContext(ctx, + `UPDATE capture_events SET state = ? WHERE seq = ?`, state.EventStateFailed, seq) + }, + }) + if raceErr != nil { + t.Fatalf("inject DB race: %v", raceErr) + } + if !errors.Is(err, state.ErrRecoveryChainChanged) { + t.Fatalf("Reconcile err=%v want ErrRecoveryChainChanged", err) + } + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateFailed || oid.Valid || countRecoverySnapshots(t, ctx, f.db) != 0 { + t.Fatalf("reconciler clobbered race state=%q oid=%v snapshots=%d", gotState, oid, countRecoverySnapshots(t, ctx, f.db)) + } + if _, ok, err := state.GetShadowPath(ctx, f.db, f.cctx.BranchRef, f.cctx.BranchGeneration, "doc.md"); err != nil || !ok { + t.Fatalf("shadow invalidation escaped failed tx: ok=%v err=%v", ok, err) + } + if _, ok, err := state.MetaGet(ctx, f.db, marker); err != nil || !ok { + t.Fatalf("shadow marker invalidation escaped failed tx: ok=%v err=%v", ok, err) + } +} + +func TestReconcile_ArchiveOnlySupportsMissingLiveHEAD(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + after, _ := git.HashObjectStdin(ctx, f.dir, []byte("captured on old base\n")) + base := f.cctx.BaseHead + seq := appendRecoveryEvent(t, ctx, f, base, state.CaptureOp{ + Op: "create", Path: "orphaned.txt", + AfterOID: sql.NullString{String: after, Valid: true}, + AfterMode: sql.NullString{String: git.RegularFileMode, Valid: true}, + }) + markRecoveryBarrier(t, ctx, f, seq, base, "create conflict for orphaned.txt") + if _, err := git.Run(ctx, git.RunOpts{Dir: f.dir}, + "update-ref", "-d", f.cctx.BranchRef, base); err != nil { + t.Fatalf("delete live branch ref: %v", err) + } + + selector := RecoveryReconcileOptions{ + GitDir: f.gitDir, + BranchRef: f.cctx.BranchRef, + BranchGeneration: f.cctx.BranchGeneration, + FirstSeq: seq, + Trigger: "test_missing_head", + } + if _, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, selector); err == nil || + !strings.Contains(err.Error(), "archive-only mode required") { + t.Fatalf("normal reconcile err=%v want explicit archive-only refusal", err) + } + if refs := recoveryRefs(t, ctx, f.dir); len(refs) != 0 { + t.Fatalf("normal missing-HEAD attempt created refs: %v", refs) + } + selector.ArchiveOnly = true + result, err := ReconcileUnpublishedChain(ctx, f.dir, f.db, selector) + if err != nil { + t.Fatalf("archive-only reconcile: %v", err) + } + if !result.Handled || result.Outcome != state.EventStateRecovered || result.RecoveryRef == "" { + t.Fatalf("archive-only result=%+v", result) + } + parent, err := git.RevParse(ctx, f.dir, result.RecoveryRef+"^") + if err != nil || parent != base { + t.Fatalf("recovery parent=%s err=%v want %s", parent, err, base) + } +} + +func TestReplay_ArchivedWorkIsRecapturedAndPublished(t *testing.T) { + runBoundedParallel(t) + + f := newCaptureFixture(t) + ctx := context.Background() + seedTrackedFileCommit(t, ctx, f, "doc.md", "before\n") + base := f.cctx.BaseHead + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow base: %v", err) + } + if err := os.WriteFile(filepath.Join(f.dir, "doc.md"), []byte("after\n"), 0o644); err != nil { + t.Fatalf("write dirty doc: %v", err) + } + if _, err := Capture(ctx, f.dir, f.db, f.cctx, CaptureOpts{ + IgnoreChecker: f.ig, SensitiveMatcher: f.matcher, + }); err != nil { + t.Fatalf("Capture dirty doc: %v", err) + } + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil || len(pending) != 1 { + t.Fatalf("pending after capture=%v err=%v want one", pending, err) + } + oldSeq := pending[0].Seq + markRecoveryBarrier(t, ctx, f, oldSeq, base, "modify before-state mismatch for doc.md") + otherBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("external other\n")) + external := commitTreeWithIndexUpdates(t, ctx, f, base, "external different doc", + git.RegularFileMode+" "+otherBlob+"\tdoc.md") + if err := git.UpdateRef(ctx, f.dir, f.cctx.BranchRef, external, base); err != nil { + t.Fatalf("update external: %v", err) + } + cctx := f.cctx + cctx.BaseHead = external + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay archive: %v", err) + } + if gotState, _ := readEventState(t, ctx, f.db, oldSeq); gotState != state.EventStateRecovered { + t.Fatalf("old event state=%q want recovered", gotState) + } + if bootstrapped, err := IsShadowBootstrapped(ctx, f.db, cctx.BranchRef, cctx.BranchGeneration); err != nil || bootstrapped { + t.Fatalf("shadow marker after archive=%v err=%v want invalidated", bootstrapped, err) + } + var shadowRows int + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT COUNT(*) FROM shadow_paths WHERE branch_ref = ? AND branch_generation = ?`, + cctx.BranchRef, cctx.BranchGeneration).Scan(&shadowRows); err != nil { + t.Fatalf("count shadow rows: %v", err) + } + if shadowRows != 0 { + t.Fatalf("shadow rows=%d want 0 after atomic invalidation", shadowRows) + } + + // This mirrors the run loop's missing-marker pass: seed the stable HEAD, + // then the following capture observes the still-dirty worktree again. + if _, err := BootstrapShadow(ctx, f.dir, f.db, cctx); err != nil { + t.Fatalf("BootstrapShadow external HEAD: %v", err) + } + if _, err := Capture(ctx, f.dir, f.db, cctx, CaptureOpts{ + IgnoreChecker: f.ig, SensitiveMatcher: f.matcher, + }); err != nil { + t.Fatalf("recapture dirty doc: %v", err) + } + pending, err = state.PendingEvents(ctx, f.db, 0) + if err != nil || len(pending) != 1 || pending[0].Seq == oldSeq { + t.Fatalf("recaptured pending=%v err=%v", pending, err) + } + newSeq := pending[0].Seq + if _, err := Replay(ctx, f.dir, f.db, cctx, ReplayOpts{GitDir: f.gitDir}); err != nil { + t.Fatalf("Replay recaptured event: %v", err) + } + gotState, _ := readEventState(t, ctx, f.db, newSeq) + if gotState != state.EventStatePublished { + t.Fatalf("recaptured event state=%q want published", gotState) + } + wantBlob, _ := git.HashObjectStdin(ctx, f.dir, []byte("after\n")) + gotBlob, err := git.LsTreeBlobOID(ctx, f.dir, "HEAD", "doc.md") + if err != nil || gotBlob != wantBlob { + t.Fatalf("published HEAD doc blob=%s err=%v want %s", gotBlob, err, wantBlob) } } diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index adda2f0a..274945c1 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -159,6 +159,8 @@ func TestReplay_ReconcilesLiveIndexAfterPublishedCreate(t *testing.T) { } func TestReplay_ReconcilesLiveIndexForTrackedChanges(t *testing.T) { + runBoundedParallel(t) + for _, tc := range []struct { name string edit func(t *testing.T, f *captureFixture) @@ -632,6 +634,8 @@ func TestReplay_NonRegularMarkerFailOpen(t *testing.T) { // 1k-event queue at 64 events per pass would otherwise stall behind the // ~1s poll interval. func TestReplay_BoundedBatchYields(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2244,6 +2248,8 @@ func TestReplay_BatchHaltsOnBlockedConflict(t *testing.T) { // isolated GIT_INDEX_FILE from BaseHead and advances it per event, so each // event's before-state matches the prior event's after-state. func TestReplay_ModifyChain_OrderedReplay(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2404,6 +2410,8 @@ func TestReplay_EventStrategyIgnoresIntentPlanner(t *testing.T) { } func TestReplay_IntentStrategyWaitsBelowMinPendingBeforeMaxAge(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2447,6 +2455,8 @@ func TestReplay_IntentStrategyWaitsBelowMinPendingBeforeMaxAge(t *testing.T) { } func TestReplay_IntentStrategyPlansWhenMinPendingReached(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2491,6 +2501,8 @@ func TestReplay_IntentStrategyPlansWhenMinPendingReached(t *testing.T) { } func TestReplay_IntentStrategySettleWindowKeepsRapidFiveTogether(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2575,6 +2587,8 @@ func TestReplay_IntentStrategySettleWindowKeepsRapidFiveTogether(t *testing.T) { } func TestReplay_IntentStrategyFullWindowBypassesSettleWindow(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2623,6 +2637,8 @@ func TestReplay_IntentStrategyFullWindowBypassesSettleWindow(t *testing.T) { } func TestReplay_IntentStrategyBatchGateUsesVisiblePendingBeyondReplayLimit(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2678,6 +2694,8 @@ func TestReplay_IntentStrategyBatchGateUsesVisiblePendingBeyondReplayLimit(t *te } func TestReplay_IntentStrategyPlansWhenOldestPendingReachesMaxAge(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -2722,6 +2740,8 @@ func TestReplay_IntentStrategyPlansWhenOldestPendingReachesMaxAge(t *testing.T) } func TestReplay_IntentStrategyFlushBypassesBatchWait(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -3081,6 +3101,8 @@ func TestReplay_IntentStrategyBatchWaitSeesOnlyBarrierVisiblePending(t *testing. } func TestReplay_IntentStrategyPublishesSelectedCapturesAsOneCommit(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -3160,6 +3182,8 @@ func TestReplay_IntentStrategyPublishesSelectedCapturesAsOneCommit(t *testing.T) } func TestReplay_IntentStrategyPublishesPartitionGroupsSequentially(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -3228,6 +3252,8 @@ func TestReplay_IntentStrategyPublishesPartitionGroupsSequentially(t *testing.T) } func TestReplay_IntentStrategyRejectsInterleavedSamePathPartition(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -3290,6 +3316,8 @@ func TestReplay_IntentStrategyRejectsInterleavedSamePathPartition(t *testing.T) } func TestReplay_IntentStrategyRecordsDeferralsAndForcesAgingWindow(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -5383,6 +5411,8 @@ func captureSamePathEdit(t *testing.T, ctx context.Context, f *captureFixture, p // one row per original seq joined by commit_oid (so the CLI's grouped_seqs // derivation reports len 4). func TestReplay_IntentSamePathCapturesRemainPlannerVisible(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -5505,6 +5535,8 @@ func TestReplay_IntentSamePathCapturesRemainPlannerVisible(t *testing.T) { } func TestReplay_IntentSameFileIndependentFunctionsRemainPlannerVisible(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -5554,6 +5586,8 @@ func TestReplay_IntentSameFileIndependentFunctionsRemainPlannerVisible(t *testin // TestReplay_IntentPathCoalesce_PQPDoesNotCoalesce: a same-path capture // surrounded by an other-path capture stays as 3 separate offers. func TestReplay_IntentPathCoalesce_PQPDoesNotCoalesce(t *testing.T) { + runBoundedParallel(t) + f := newCaptureFixture(t) ctx := context.Background() @@ -5657,9 +5691,8 @@ func TestReplay_IntentPathCoalesce_DisabledViaEnv(t *testing.T) { } // TestReplay_IntentPathCoalesce_BarrierStopsCoalesce: a blocked_conflict -// barrier between two same-path captures keeps the suffix invisible to the -// planner (state.PendingEvents already filters past the barrier), so coalesce -// cannot fold across it. +// anywhere in the pair bypasses planning and atomically protects the entire +// unpublished chain, including earlier pending and later hidden rows. func TestReplay_IntentPathCoalesce_BarrierStopsCoalesce(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() @@ -5679,8 +5712,8 @@ func TestReplay_IntentPathCoalesce_BarrierStopsCoalesce(t *testing.T) { state.EventStateBlockedConflict, "synthetic barrier for test", seq2); err != nil { t.Fatalf("force blocked seq=%d: %v", seq2, err) } - captureSamePathEdit(t, ctx, f, "barrier.txt", "v3\n") - captureSamePathEdit(t, ctx, f, "barrier.txt", "v4\n") + seq3 := captureSamePathEdit(t, ctx, f, "barrier.txt", "v3\n") + seq4 := captureSamePathEdit(t, ctx, f, "barrier.txt", "v4\n") pending, err := state.PendingEvents(ctx, f.db, 0) if err != nil { @@ -5710,13 +5743,31 @@ func TestReplay_IntentPathCoalesce_BarrierStopsCoalesce(t *testing.T) { if err != nil { t.Fatalf("Replay: %v", err) } - if sum.Published != 1 || sum.Conflicts != 0 || sum.Failed != 0 { - t.Fatalf("summary=%+v want 1 published (barrier hides rest)", sum) + if sum.Published != 0 || sum.Conflicts != 0 || sum.Failed != 0 || !sum.RecaptureRequired { + t.Fatalf("summary=%+v want one protected chain awaiting recapture", sum) } if planner.calls != 0 { - t.Fatalf("planner calls=%d want 0 (visible pre-barrier singleton uses fast path)", planner.calls) + t.Fatalf("planner calls=%d want 0 (recovery bypasses planning)", planner.calls) + } + var snapshotCommit, recoveryRef string + var eventCount int + if err := f.db.SQL().QueryRowContext(ctx, ` +SELECT commit_oid, recovery_ref, event_count +FROM recovery_snapshots`).Scan(&snapshotCommit, &recoveryRef, &eventCount); err != nil { + t.Fatalf("query recovery snapshot: %v", err) + } + if eventCount != 4 || recoveryRef == "" { + t.Fatalf("snapshot event_count=%d ref=%q want 4 protected events", eventCount, recoveryRef) + } + for _, seq := range []int64{seq1, seq2, seq3, seq4} { + gotState, oid := readEventState(t, ctx, f.db, seq) + if gotState != state.EventStateRecovered || !oid.Valid || oid.String != snapshotCommit { + t.Fatalf("seq=%d state=%q oid=%v want recovered at %s", seq, gotState, oid, snapshotCommit) + } + } + if bootstrapped, err := IsShadowBootstrapped(ctx, f.db, f.cctx.BranchRef, f.cctx.BranchGeneration); err != nil || bootstrapped { + t.Fatalf("shadow marker after recovery=%v err=%v want invalidated", bootstrapped, err) } - assertReplayDecision(t, ctx, f.db, seq1, state.DecisionKindCommitted, "intent_group: singleton fast path") } // TestReplay_PathQuiescenceGateDefersOfferUntilWindowElapses runs the diff --git a/internal/daemon/scheduler_test.go b/internal/daemon/scheduler_test.go index 006caabc..a729bbb1 100644 --- a/internal/daemon/scheduler_test.go +++ b/internal/daemon/scheduler_test.go @@ -93,3 +93,12 @@ func TestScheduler_ZeroFieldsUseDefaults(t *testing.T) { t.Fatalf("idle ceiling=%v want %v", d, DefaultSchedulerIdleCeiling) } } + +func TestReplayRecaptureSchedulesImmediateFollowup(t *testing.T) { + if !replayNeedsImmediateFollowup(ReplaySummary{RecaptureRequired: true}) { + t.Fatal("recovered active chain would be delayed by idle scheduler backoff") + } + if replayNeedsImmediateFollowup(ReplaySummary{}) { + t.Fatal("empty replay summary unexpectedly schedules an immediate follow-up") + } +} diff --git a/internal/daemon/test_repo_template_test.go b/internal/daemon/test_repo_template_test.go new file mode 100644 index 00000000..2ffdaa54 --- /dev/null +++ b/internal/daemon/test_repo_template_test.go @@ -0,0 +1,142 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/git" + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +type daemonTestRepoTemplate struct { + dir string + head string +} + +type daemonTestRepo struct { + dir string + gitDir string + head string + db *state.DB +} + +var ( + captureRepoTemplate daemonTestRepoTemplate + daemonRepoTemplate daemonTestRepoTemplate +) + +// setupDaemonTestRepoTemplates pays the Git initialization and state schema +// bootstrap cost once per package test process. Each fixture gets a private +// filesystem copy, so tests remain free to mutate refs, objects, and state. +func setupDaemonTestRepoTemplates() (string, error) { + root, err := os.MkdirTemp("", "acd-daemon-templates-*") + if err != nil { + return "", fmt.Errorf("create template root: %w", err) + } + + cleanupOnError := func(err error) (string, error) { + _ = os.RemoveAll(root) + return "", err + } + + captureRepoTemplate, err = buildDaemonTestRepoTemplate( + root, + "capture", + []byte("ignored.txt\n"), + ) + if err != nil { + return cleanupOnError(err) + } + daemonRepoTemplate, err = buildDaemonTestRepoTemplate( + root, + "daemon", + []byte("# acd test seed\n"), + ) + if err != nil { + return cleanupOnError(err) + } + return root, nil +} + +func buildDaemonTestRepoTemplate(root, name string, gitignore []byte) (daemonTestRepoTemplate, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o700); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("create %s template: %w", name, err) + } + if err := git.Init(ctx, dir); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("git init %s template: %w", name, err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "symbolic-ref", "HEAD", "refs/heads/main"); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("set %s template HEAD: %w", name, err) + } + for _, kv := range [][]string{ + {"user.email", "acd-test@example.com"}, + {"user.name", "ACD Test"}, + {"commit.gpgsign", "false"}, + } { + if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "config", kv[0], kv[1]); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("configure %s template %s: %w", name, kv[0], err) + } + } + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), gitignore, 0o644); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("write %s template seed: %w", name, err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "add", ".gitignore"); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("stage %s template seed: %w", name, err) + } + if _, err := git.Run(ctx, git.RunOpts{Dir: dir}, "commit", "-q", "-m", "seed"); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("commit %s template seed: %w", name, err) + } + + head, err := git.RevParse(ctx, dir, "HEAD") + if err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("resolve %s template HEAD: %w", name, err) + } + db, err := state.Open(ctx, state.DBPathFromGitDir(filepath.Join(dir, ".git"))) + if err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("bootstrap %s template state: %w", name, err) + } + if _, err := db.SQL().ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + _ = db.Close() + return daemonTestRepoTemplate{}, fmt.Errorf("checkpoint %s template state: %w", name, err) + } + if err := db.Close(); err != nil { + return daemonTestRepoTemplate{}, fmt.Errorf("close %s template state: %w", name, err) + } + + return daemonTestRepoTemplate{dir: dir, head: head}, nil +} + +func cloneDaemonTestRepo(t *testing.T, template daemonTestRepoTemplate) *daemonTestRepo { + t.Helper() + if template.dir == "" || template.head == "" { + t.Fatal("daemon test repository template was not initialized") + } + + dir, err := os.MkdirTemp("", "acd-daemon-test-*") + if err != nil { + t.Fatalf("create test repository: %v", err) + } + t.Cleanup(func() { removeAllWithRetry(t, dir) }) + if err := os.CopyFS(dir, os.DirFS(template.dir)); err != nil { + t.Fatalf("clone test repository template: %v", err) + } + + gitDir := filepath.Join(dir, ".git") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + db, err := state.Open(ctx, state.DBPathFromGitDir(gitDir)) + if err != nil { + t.Fatalf("open cloned test state: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + return &daemonTestRepo{dir: dir, gitDir: gitDir, head: template.head, db: db} +} diff --git a/internal/git/pathspec.go b/internal/git/pathspec.go index 9b508b3c..b214e876 100644 --- a/internal/git/pathspec.go +++ b/internal/git/pathspec.go @@ -10,7 +10,7 @@ import ( // magic in Git pathspec syntax; callers that are proving history for one // captured path must not let those characters expand to unrelated files. func LiteralPathspec(path string) string { - path = strings.TrimSpace(filepath.ToSlash(path)) + path = filepath.ToSlash(path) path = strings.TrimPrefix(path, "./") if path == "" { return "" diff --git a/internal/git/pathspec_test.go b/internal/git/pathspec_test.go new file mode 100644 index 00000000..45836e68 --- /dev/null +++ b/internal/git/pathspec_test.go @@ -0,0 +1,11 @@ +package git + +import "testing" + +func TestLiteralPathspecPreservesLegalWhitespace(t *testing.T) { + t.Parallel() + path := " leading and trailing.txt \n" + if got, want := LiteralPathspec(path), ":(literal)"+path; got != want { + t.Fatalf("LiteralPathspec()=%q want %q", got, want) + } +} diff --git a/internal/git/refs.go b/internal/git/refs.go index 89fac0d6..bff0e072 100644 --- a/internal/git/refs.go +++ b/internal/git/refs.go @@ -1,10 +1,15 @@ package git import ( + "bufio" + "bytes" "context" "errors" "fmt" + "io" + "os/exec" "strings" + "sync" ) // ErrRefNotFound is returned by RevParse when the requested rev does not @@ -19,6 +24,42 @@ var ErrRefNotFound = errors.New("git: ref not found") // a distinct error so callers can fail loudly. var ErrRefAmbiguous = errors.New("git: ref is ambiguous") +// RecoveryRefPrefix is the private namespace used for captured-work recovery +// commits. It intentionally lives outside refs/heads so snapshots do not +// appear as user branches or affect normal branch enumeration. +const RecoveryRefPrefix = "refs/acd/recovery/" + +// ErrRecoveryRefCollision means a deterministic recovery ref already exists +// but points at a different commit. Callers must fail closed: overwriting the +// ref could make a previously preserved snapshot unreachable. +var ErrRecoveryRefCollision = errors.New("git: recovery ref collision") + +// RecoveryRefResult reports whether EnsureRecoveryRef created the ref or +// reused an identical target left by an earlier attempt. +type RecoveryRefResult struct { + Ref string + CommitOID string + Created bool + Reused bool +} + +type synchronizedBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (b *synchronizedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.Write(p) +} + +func (b *synchronizedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.String() +} + // RevParse resolves rev (any acceptable revision spec — HEAD, refs/..., // short hash, etc.) to a full SHA. Returns ErrRefNotFound when the rev does // not exist, ErrRefAmbiguous when the name maps to multiple objects, and @@ -58,18 +99,12 @@ func RevParse(ctx context.Context, repoDir, rev string) (string, error) { // Exit 0 with an "ambiguous" warning on stderr means the short name // resolved by accident — git picked the first match. Surface this so // callers don't silently consume the wrong OID. - if bytesContains(stderr, "is ambiguous") { + if bytes.Contains(stderr, []byte("is ambiguous")) { return "", fmt.Errorf("%w: %s", ErrRefAmbiguous, strings.TrimSpace(string(stderr))) } return strings.TrimSpace(string(out)), nil } -// bytesContains is a tiny helper to avoid pulling bytes into the imports -// list just for one substring probe in a hot path. -func bytesContains(b []byte, sub string) bool { - return strings.Contains(string(b), sub) -} - // ShowToplevel returns the absolute path of the worktree root. func ShowToplevel(ctx context.Context, dir string) (string, error) { out, err := Run(ctx, RunOpts{Dir: dir, Timeout: DefaultReadTimeout}, "rev-parse", "--show-toplevel") @@ -110,6 +145,244 @@ func UpdateRef(ctx context.Context, repoDir, ref, newOID, oldOID string) error { return err } +// EnsureRecoveryRef creates a private recovery ref with compare-and-swap +// semantics. An absent ref is created only when it is still absent; an +// existing ref is reusable only when it already targets commitOID. A distinct +// target returns ErrRecoveryRefCollision and is never overwritten. +func EnsureRecoveryRef(ctx context.Context, repoDir, ref, commitOID string) (RecoveryRefResult, error) { + var result RecoveryRefResult + if repoDir == "" { + return result, fmt.Errorf("git: EnsureRecoveryRef called with empty repoDir") + } + if !strings.HasPrefix(ref, RecoveryRefPrefix) || len(ref) == len(RecoveryRefPrefix) { + return result, fmt.Errorf("git: recovery ref %q must be below %s", ref, RecoveryRefPrefix) + } + if commitOID == "" { + return result, fmt.Errorf("git: EnsureRecoveryRef called with empty commitOID") + } + resolvedCommit, err := RevParse(ctx, repoDir, commitOID+"^{commit}") + if err != nil { + return result, fmt.Errorf("git: validate recovery commit %s: %w", commitOID, err) + } + if resolvedCommit != commitOID { + return result, fmt.Errorf("git: recovery commit resolved to %s, want %s", resolvedCommit, commitOID) + } + + result.Ref = ref + result.CommitOID = commitOID + existing, err := RevParse(ctx, repoDir, ref) + if err == nil { + if existing == commitOID { + result.Reused = true + return result, nil + } + return RecoveryRefResult{}, fmt.Errorf("%w: %s points at %s, want %s", + ErrRecoveryRefCollision, ref, existing, commitOID) + } + if !errors.Is(err, ErrRefNotFound) { + return RecoveryRefResult{}, fmt.Errorf("git: resolve recovery ref %s: %w", ref, err) + } + + // A forty-zero expected old value is git update-ref's create-only CAS. + const zeroOID = "0000000000000000000000000000000000000000" + if err := UpdateRef(ctx, repoDir, ref, commitOID, zeroOID); err == nil { + result.Created = true + return result, nil + } else { + // Another writer may have created the same deterministic ref between + // our read and CAS. Re-read: equal is an idempotent success; different + // is a collision and must preserve the winner. + existing, readErr := RevParse(ctx, repoDir, ref) + if readErr == nil { + if existing == commitOID { + result.Reused = true + return result, nil + } + return RecoveryRefResult{}, fmt.Errorf("%w: %s raced to %s, want %s", + ErrRecoveryRefCollision, ref, existing, commitOID) + } + if !errors.Is(readErr, ErrRefNotFound) { + return RecoveryRefResult{}, fmt.Errorf("git: re-read recovery ref %s after CAS failure: %w", ref, readErr) + } + return RecoveryRefResult{}, fmt.Errorf("git: create recovery ref %s: %w", ref, err) + } +} + +// WithLockedRecoveryRef verifies ref still targets expectedOID, then holds +// Git's update-ref transaction lock while fn runs. The lock closes the gap +// between proof-ref verification and a cross-store mutation such as pruning +// the SQLite capture rows protected by that ref. +// +// The transaction only verifies the ref; it does not change its value. Git +// nevertheless locks the ref at prepare time, so another well-behaved Git +// process cannot move or delete it before fn returns and the transaction is +// committed or aborted. +func WithLockedRecoveryRef(ctx context.Context, repoDir, ref, expectedOID string, fn func() error) error { + return withLockedRecoveryRef(ctx, repoDir, ref, expectedOID, "", fn) +} + +// WithLockedRecoveryRefAndAbsentRef verifies and locks both a recovery ref +// and an expected-missing ref in one update-ref transaction. Dead-branch +// recovery uses this to prevent branch recreation between its last absence +// check and the SQLite transition protected by fn. +func WithLockedRecoveryRefAndAbsentRef( + ctx context.Context, + repoDir string, + ref string, + expectedOID string, + expectedAbsentRef string, + fn func() error, +) error { + if !strings.HasPrefix(expectedAbsentRef, "refs/") || len(expectedAbsentRef) == len("refs/") || + strings.ContainsAny(expectedAbsentRef, " \t\r\n\x00") { + return fmt.Errorf("git: expected-absent ref %q must be a valid full ref name", expectedAbsentRef) + } + if expectedAbsentRef == ref { + return fmt.Errorf("git: expected-absent ref must differ from recovery ref %q", ref) + } + return withLockedRecoveryRef(ctx, repoDir, ref, expectedOID, expectedAbsentRef, fn) +} + +func withLockedRecoveryRef( + ctx context.Context, + repoDir string, + ref string, + expectedOID string, + expectedAbsentRef string, + fn func() error, +) error { + if repoDir == "" { + return fmt.Errorf("git: WithLockedRecoveryRef called with empty repoDir") + } + if !strings.HasPrefix(ref, RecoveryRefPrefix) || len(ref) == len(RecoveryRefPrefix) || + strings.ContainsAny(ref, " \t\r\n\x00") { + return fmt.Errorf("git: recovery ref %q must be a valid name below %s", ref, RecoveryRefPrefix) + } + if expectedOID == "" || strings.ContainsAny(expectedOID, " \t\r\n\x00") { + return fmt.Errorf("git: WithLockedRecoveryRef called with invalid expected OID") + } + if fn == nil { + return fmt.Errorf("git: WithLockedRecoveryRef called with nil callback") + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, DefaultWriteTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "update-ref", "--no-deref", "--stdin") + cmd.Dir = repoDir + cmd.Env = scrubEnv(nil) + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("git: open recovery ref transaction stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + return fmt.Errorf("git: open recovery ref transaction stdout: %w", err) + } + var stderr synchronizedBuffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + _ = stdin.Close() + return fmt.Errorf("git: start recovery ref transaction: %w", err) + } + reader := bufio.NewReader(stdout) + finished := false + defer func() { + if finished { + return + } + _ = stdin.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }() + + write := func(line string) error { + if _, err := io.WriteString(stdin, line+"\n"); err != nil { + return fmt.Errorf("write %q: %w", line, err) + } + return nil + } + expectOK := func(action string) error { + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("read %s response: %w", action, err) + } + if strings.TrimSpace(line) != action+": ok" { + return fmt.Errorf("unexpected %s response %q", action, strings.TrimSpace(line)) + } + return nil + } + fail := func(action string, err error) error { + if detail := strings.TrimSpace(stderr.String()); detail != "" { + return fmt.Errorf("git: recovery ref transaction %s: %w: %s", action, err, detail) + } + return fmt.Errorf("git: recovery ref transaction %s: %w", action, err) + } + + if err := write("start"); err != nil { + return fail("start", err) + } + if err := expectOK("start"); err != nil { + return fail("start", err) + } + if err := write("verify " + ref + " " + expectedOID); err != nil { + return fail("verify", err) + } + if expectedAbsentRef != "" { + // Git's all-zero expected value means the ref must not exist. Preparing + // the transaction acquires both locks before the callback can mutate + // cross-store state. + const zeroOID = "0000000000000000000000000000000000000000" + if err := write("verify " + expectedAbsentRef + " " + zeroOID); err != nil { + return fail("verify absent", err) + } + } + if err := write("prepare"); err != nil { + return fail("prepare", err) + } + if err := expectOK("prepare"); err != nil { + return fail("prepare", err) + } + + if callbackErr := fn(); callbackErr != nil { + abortErr := write("abort") + if abortErr == nil { + abortErr = expectOK("abort") + } + _ = stdin.Close() + waitErr := cmd.Wait() + finished = true + if abortErr != nil { + return errors.Join(callbackErr, fail("abort", abortErr)) + } + if waitErr != nil { + return errors.Join(callbackErr, fail("abort wait", waitErr)) + } + return callbackErr + } + + if err := write("commit"); err != nil { + return fail("commit", err) + } + if err := expectOK("commit"); err != nil { + return fail("commit", err) + } + if err := stdin.Close(); err != nil { + return fail("close", err) + } + waitErr := cmd.Wait() + finished = true + if waitErr != nil { + return fail("wait", waitErr) + } + return nil +} + // RunBranchRef returns the symbolic ref the worktree's HEAD points at, // e.g. "refs/heads/main". Returns ("", nil) on a detached HEAD; surfaces // any other git failure verbatim. diff --git a/internal/git/refs_test.go b/internal/git/refs_test.go index 2e529b22..af620a5d 100644 --- a/internal/git/refs_test.go +++ b/internal/git/refs_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestRevParseReturnsErrRefNotFoundForMissingRef(t *testing.T) { @@ -76,6 +77,202 @@ func TestUpdateRefCompareAndSwapRejectsStaleOld(t *testing.T) { } } +func TestEnsureRecoveryRefCreatesAndReusesExactCommit(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + commit := commitFile(t, ctx, dir, "recovery.txt", "saved", "recovery") + const ref = "refs/acd/recovery/main/g1/1-2-deadbeef" + + created, err := EnsureRecoveryRef(ctx, dir, ref, commit) + if err != nil { + t.Fatalf("EnsureRecoveryRef create: %v", err) + } + if !created.Created || created.Reused || created.Ref != ref || created.CommitOID != commit { + t.Fatalf("create result=%+v", created) + } + reused, err := EnsureRecoveryRef(ctx, dir, ref, commit) + if err != nil { + t.Fatalf("EnsureRecoveryRef reuse: %v", err) + } + if reused.Created || !reused.Reused || reused.CommitOID != commit { + t.Fatalf("reuse result=%+v", reused) + } + got, err := RevParse(ctx, dir, ref) + if err != nil || got != commit { + t.Fatalf("recovery ref=(%q,%v) want (%q,nil)", got, err, commit) + } +} + +func TestEnsureRecoveryRefRejectsCollisionWithoutOverwrite(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + first := commitFile(t, ctx, dir, "first.txt", "first", "first") + second := commitFile(t, ctx, dir, "second.txt", "second", "second", first) + const ref = "refs/acd/recovery/main/g1/3-4-cafebabe" + if _, err := EnsureRecoveryRef(ctx, dir, ref, first); err != nil { + t.Fatalf("seed recovery ref: %v", err) + } + if _, err := EnsureRecoveryRef(ctx, dir, ref, second); !errors.Is(err, ErrRecoveryRefCollision) { + t.Fatalf("collision err=%v want ErrRecoveryRefCollision", err) + } + got, err := RevParse(ctx, dir, ref) + if err != nil || got != first { + t.Fatalf("collision overwrote ref: got=(%q,%v) want (%q,nil)", got, err, first) + } +} + +func TestEnsureRecoveryRefValidatesNamespaceAndCommitType(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + commit := commitFile(t, ctx, dir, "commit.txt", "commit", "commit") + if _, err := EnsureRecoveryRef(ctx, dir, "refs/heads/not-hidden", commit); err == nil { + t.Fatal("EnsureRecoveryRef accepted a user branch ref") + } + blob, err := HashObjectStdin(ctx, dir, []byte("blob")) + if err != nil { + t.Fatalf("hash blob: %v", err) + } + if _, err := EnsureRecoveryRef(ctx, dir, "refs/acd/recovery/blob", blob); err == nil { + t.Fatal("EnsureRecoveryRef accepted a blob target") + } +} + +func TestWithLockedRecoveryRefBlocksConcurrentMutation(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + first := commitFile(t, ctx, dir, "first.txt", "first", "first") + second := commitFile(t, ctx, dir, "second.txt", "second", "second", first) + const ref = "refs/acd/recovery/prune-race" + if _, err := EnsureRecoveryRef(ctx, dir, ref, first); err != nil { + t.Fatalf("seed recovery ref: %v", err) + } + + locked := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- WithLockedRecoveryRef(ctx, dir, ref, first, func() error { + close(locked) + <-release + return nil + }) + }() + select { + case <-locked: + case <-time.After(5 * time.Second): + t.Fatal("recovery ref lock was not acquired") + } + + mutationCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + err := UpdateRef(mutationCtx, dir, ref, second, first) + cancel() + if err == nil { + t.Fatal("concurrent recovery ref mutation succeeded while proof lock was held") + } + close(release) + select { + case err := <-done: + if err != nil { + t.Fatalf("WithLockedRecoveryRef: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("recovery ref lock was not released") + } + + if err := UpdateRef(ctx, dir, ref, second, first); err != nil { + t.Fatalf("mutation after lock release: %v", err) + } +} + +func TestWithLockedRecoveryRefFailsBeforeCallbackOnMismatch(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + first := commitFile(t, ctx, dir, "first.txt", "first", "first") + second := commitFile(t, ctx, dir, "second.txt", "second", "second", first) + const ref = "refs/acd/recovery/prune-mismatch" + if _, err := EnsureRecoveryRef(ctx, dir, ref, first); err != nil { + t.Fatalf("seed recovery ref: %v", err) + } + + called := false + err := WithLockedRecoveryRef(ctx, dir, ref, second, func() error { + called = true + return nil + }) + if err == nil { + t.Fatal("mismatched proof ref unexpectedly verified") + } + if called { + t.Fatal("callback ran without an exact proof-ref match") + } +} + +func TestWithLockedRecoveryRefAbortsOnCallbackError(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + first := commitFile(t, ctx, dir, "first.txt", "first", "first") + second := commitFile(t, ctx, dir, "second.txt", "second", "second", first) + const ref = "refs/acd/recovery/prune-abort" + if _, err := EnsureRecoveryRef(ctx, dir, ref, first); err != nil { + t.Fatalf("seed recovery ref: %v", err) + } + + want := errors.New("sqlite prune failed") + if err := WithLockedRecoveryRef(ctx, dir, ref, first, func() error { return want }); !errors.Is(err, want) { + t.Fatalf("callback error=%v want %v", err, want) + } + if err := UpdateRef(ctx, dir, ref, second, first); err != nil { + t.Fatalf("mutation after callback abort: %v", err) + } +} + +func TestWithLockedRecoveryRefAndAbsentRefBlocksCreation(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + commit := commitFile(t, ctx, dir, "recovery.txt", "saved", "recovery") + const recoveryRef = "refs/acd/recovery/dead-branch-race" + const absentRef = "refs/heads/recreated" + if _, err := EnsureRecoveryRef(ctx, dir, recoveryRef, commit); err != nil { + t.Fatalf("seed recovery ref: %v", err) + } + + creationDone := make(chan error, 1) + creationStarted := make(chan struct{}) + completedWhileLocked := false + var creationErr error + err := WithLockedRecoveryRefAndAbsentRef(ctx, dir, recoveryRef, commit, absentRef, func() error { + go func() { + close(creationStarted) + creationDone <- UpdateRef(ctx, dir, absentRef, commit, "") + }() + <-creationStarted + select { + case creationErr = <-creationDone: + completedWhileLocked = true + case <-time.After(200 * time.Millisecond): + } + return nil + }) + if err != nil { + t.Fatalf("WithLockedRecoveryRefAndAbsentRef: %v", err) + } + if completedWhileLocked && creationErr == nil { + t.Fatal("expected-missing ref was created while transaction lock was held") + } + if !completedWhileLocked { + select { + case creationErr = <-creationDone: + case <-time.After(5 * time.Second): + t.Fatal("expected-missing ref lock was not released") + } + } + if creationErr != nil { + if err := UpdateRef(ctx, dir, absentRef, commit, ""); err != nil { + t.Fatalf("create expected-missing ref after lock release: %v", err) + } + } +} + func TestShowToplevelAndAbsoluteGitDir(t *testing.T) { dir := initRepo(t) ctx := context.Background() diff --git a/internal/git/tree.go b/internal/git/tree.go index 4ec477df..5726b476 100644 --- a/internal/git/tree.go +++ b/internal/git/tree.go @@ -131,16 +131,38 @@ func HashSymlinkBlob(ctx context.Context, repoDir, target string) (oid, mode str // CommitTree runs `git commit-tree -p ... -F -` (message // supplied on stdin) and returns the new commit OID. Pass an empty parent -// slice for an initial commit. +// slice for an initial commit. Git resolves the author and committer from the +// repository or user configuration. func CommitTree(ctx context.Context, repoDir, treeOID, message string, parents ...string) (string, error) { + return commitTree(ctx, repoDir, treeOID, message, nil, parents...) +} + +// CommitTreeWithIdentity creates a commit with an explicit author and +// committer identity. It is intended for ACD-owned hidden provenance commits +// that must remain writable even when the repository has no configured user. +func CommitTreeWithIdentity(ctx context.Context, repoDir, treeOID, message, name, email string, parents ...string) (string, error) { + if strings.TrimSpace(name) == "" || strings.TrimSpace(email) == "" { + return "", fmt.Errorf("git: commit identity name and email are required") + } + identityEnv := map[string]string{ + "GIT_AUTHOR_NAME": name, + "GIT_AUTHOR_EMAIL": email, + "GIT_COMMITTER_NAME": name, + "GIT_COMMITTER_EMAIL": email, + } + return commitTree(ctx, repoDir, treeOID, message, identityEnv, parents...) +} + +func commitTree(ctx context.Context, repoDir, treeOID, message string, extraEnv map[string]string, parents ...string) (string, error) { args := []string{"commit-tree", treeOID} for _, p := range parents { args = append(args, "-p", p) } args = append(args, "-F", "-") out, err := Run(ctx, RunOpts{ - Dir: repoDir, - Stdin: strings.NewReader(message), + Dir: repoDir, + Stdin: strings.NewReader(message), + ExtraEnv: extraEnv, }, args...) if err != nil { return "", err diff --git a/internal/git/tree_test.go b/internal/git/tree_test.go index 41bfc4d5..654ea860 100644 --- a/internal/git/tree_test.go +++ b/internal/git/tree_test.go @@ -169,6 +169,31 @@ func TestCommitTreeAndUpdateRefProduceValidCommit(t *testing.T) { } } +func TestCommitTreeWithIdentityOverridesRepositoryIdentity(t *testing.T) { + dir := initRepo(t) + ctx := context.Background() + tree, err := Mktree(ctx, dir, nil) + if err != nil { + t.Fatalf("mktree: %v", err) + } + commit, err := CommitTreeWithIdentity(ctx, dir, tree, "recovery snapshot", + "Auto Commit Daemon", "acd-recovery@localhost") + if err != nil { + t.Fatalf("commit-tree with identity: %v", err) + } + out, err := Run(ctx, RunOpts{Dir: dir}, "show", "-s", + "--format=%an%x00%ae%x00%cn%x00%ce", commit) + if err != nil { + t.Fatalf("show identity: %v", err) + } + got := strings.TrimSpace(string(out)) + want := "Auto Commit Daemon\x00acd-recovery@localhost\x00" + + "Auto Commit Daemon\x00acd-recovery@localhost" + if got != want { + t.Fatalf("commit identity=%q want %q", got, want) + } +} + func TestUpdateIndexInfoWithIsolatedIndex(t *testing.T) { dir := initRepo(t) ctx := context.Background() diff --git a/internal/state/decisions.go b/internal/state/decisions.go index f57a4e41..9c8e7b7c 100644 --- a/internal/state/decisions.go +++ b/internal/state/decisions.go @@ -30,6 +30,12 @@ const ( DecisionKindIntentPlannerError = "intent_planner_error" DecisionKindMessageQualityRewrite = "message_quality_rewrite" DecisionKindMessageQualityFallback = "message_quality_fallback" + // DecisionKindRecoveryPublished records one member of an unpublished chain + // whose composed final state was proven at an external commit. + DecisionKindRecoveryPublished = "recovery_published" + // DecisionKindRecoveryArchived records one member of an unpublished chain + // made reachable through a hidden recovery ref before replay was unblocked. + DecisionKindRecoveryArchived = "recovery_archived" ) const ( @@ -64,8 +70,23 @@ type DecisionCommitGroup struct { Count int } +type decisionExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + // AppendDecision inserts a decision row and returns its monotonic cursor ID. func AppendDecision(ctx context.Context, d *DB, rec DecisionRecord) (int64, error) { + if d == nil { + return 0, fmt.Errorf("state: AppendDecision: nil db") + } + return appendDecision(ctx, d.conn, rec) +} + +// appendDecision is the transaction-capable form used when a decision must +// commit atomically with another state transition. Keeping the INSERT shape in +// one helper prevents the normal replay path and recovery transactions from +// drifting as decision_records evolves. +func appendDecision(ctx context.Context, execer decisionExecer, rec DecisionRecord) (int64, error) { if rec.Kind == "" { return 0, fmt.Errorf("state: AppendDecision: empty kind") } @@ -77,7 +98,7 @@ INSERT INTO decision_records( decision_ts, kind, path, reason, event_seq, head_sha, commit_oid, branch_ref, branch_generation, action_taken, user_message ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - res, err := d.conn.ExecContext(ctx, q, + res, err := execer.ExecContext(ctx, q, rec.DecisionTS, rec.Kind, rec.Path, rec.Reason, rec.EventSeq, rec.HeadSHA, rec.CommitOID, rec.BranchRef, rec.BranchGeneration, rec.ActionTaken, rec.UserMessage, ) diff --git a/internal/state/decisions_test.go b/internal/state/decisions_test.go index f22ddbb2..2cb0f317 100644 --- a/internal/state/decisions_test.go +++ b/internal/state/decisions_test.go @@ -139,7 +139,7 @@ func TestDecisionRecordsEventSeqSurvivesPrune(t *testing.T) { seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ BranchRef: "refs/heads/main", BranchGeneration: 1, - BaseHead: "abc123", + BaseHead: fmt.Sprintf("base-%s", tc.name), Operation: "modify", Path: path, Fidelity: "exact", @@ -177,8 +177,8 @@ func TestDecisionRecordsEventSeqSurvivesPrune(t *testing.T) { if err != nil { t.Fatalf("PruneTerminalEventsBefore: %v", err) } - if terminalPruned != 2 { - t.Fatalf("terminal pruned = %d, want 2", terminalPruned) + if terminalPruned != 0 { + t.Fatalf("terminal pruned = %d, want 0 without recovery refs", terminalPruned) } for _, seq := range seqs { @@ -187,8 +187,12 @@ func TestDecisionRecordsEventSeqSurvivesPrune(t *testing.T) { `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seq).Scan(&eventCount); err != nil { t.Fatalf("count event %d: %v", seq, err) } - if eventCount != 0 { - t.Fatalf("capture event %d survived prune", seq) + wantEventCount := 1 + if seq == seqs[0] { + wantEventCount = 0 + } + if eventCount != wantEventCount { + t.Fatalf("capture event %d count=%d want %d", seq, eventCount, wantEventCount) } got, err := DecisionsForEvent(ctx, d, seq, 10) if err != nil { diff --git a/internal/state/events.go b/internal/state/events.go index 294e9695..d2879b0a 100644 --- a/internal/state/events.go +++ b/internal/state/events.go @@ -23,11 +23,15 @@ import ( // terminal — a stuck event would otherwise re-run on every poll tick and // prevent later events from making progress (they would replay on top of a // broken predecessor). +// - EventStateRecovered: an unpublished chain was preserved by a durable +// recovery snapshot/ref. It is terminal, non-pending, and never a replay +// barrier; recovery_snapshot_events retains exact chain membership. const ( EventStatePending = "pending" EventStatePublished = "published" EventStateFailed = "failed" EventStateBlockedConflict = "blocked_conflict" + EventStateRecovered = "recovered" ) // CaptureEvent is one row of capture_events (§6.1). seq is autoincrement and @@ -44,7 +48,7 @@ type CaptureEvent struct { Fidelity string CapturedTS float64 PublishedTS sql.NullFloat64 - State string // EventState* constant ("pending"|"published"|"failed"|"blocked_conflict") + State string // EventState* lifecycle constant. CommitOID sql.NullString Error sql.NullString Message sql.NullString @@ -671,14 +675,30 @@ FROM capture_ops WHERE event_seq = ? ORDER BY ord ASC` return out, nil } -// PrunePublishedEventsBefore deletes capture_events rows whose state is -// 'published' (terminal success) AND whose captured_ts is strictly older -// than cutoff. Returns the number of rows removed. +// PrunePublishedEventsBefore deletes old published rows that do not belong to +// a recovery snapshot and are not materialization context for a later +// unpublished same-base chain. Snapshot members require repo-aware Git-ref +// locking and are pruned separately by the daemon, regardless of whether +// reconciliation classified them as published or recovered. func PrunePublishedEventsBefore(ctx context.Context, d *DB, cutoff float64) (int, error) { - res, err := d.conn.ExecContext(ctx, - `DELETE FROM capture_events WHERE state = 'published' AND captured_ts < ?`, - cutoff, - ) + res, err := d.conn.ExecContext(ctx, ` +DELETE FROM capture_events +WHERE state = 'published' + AND captured_ts < ? + AND NOT EXISTS ( + SELECT 1 + FROM recovery_snapshot_events member + WHERE member.event_seq = capture_events.seq + ) + AND NOT EXISTS ( + SELECT 1 + FROM capture_events unpublished + WHERE unpublished.branch_ref = capture_events.branch_ref + AND unpublished.branch_generation = capture_events.branch_generation + AND unpublished.base_head = capture_events.base_head + AND unpublished.seq > capture_events.seq + AND unpublished.state IN (?, ?, ?) + )`, cutoff, EventStatePending, EventStateBlockedConflict, EventStateFailed) if err != nil { return 0, fmt.Errorf("state: prune published events: %w", err) } @@ -689,237 +709,45 @@ func PrunePublishedEventsBefore(ctx context.Context, d *DB, cutoff float64) (int return int(n), nil } -// DeletePendingForGeneration deletes queued, unpublished events for a stale -// branch generation after the daemon has classified a branch transition as -// Diverged. Terminal rows are intentionally retained for operator review, and -// published rows are never touched. -func DeletePendingForGeneration(ctx context.Context, d *DB, branchGeneration int64) (int, error) { - res, err := d.conn.ExecContext(ctx, - `DELETE FROM capture_events WHERE state = ? AND branch_generation = ?`, - EventStatePending, branchGeneration, - ) - if err != nil { - return 0, fmt.Errorf("state: delete pending generation %d: %w", branchGeneration, err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("state: delete pending generation rows: %w", err) - } - return int(n), nil -} - -// DeletePendingForBranchGeneration is the branch-scoped variant of -// DeletePendingForGeneration: it deletes queued, unpublished events restricted -// to (branch_ref, branch_generation). Used by `acd commit-all` to clear stale -// pending rows for the active branch before forcing a shadow reseed; the broad -// generation-only variant would also nuke rows on co-existing refs that happen -// to share the generation number. Terminal rows (`published`, `failed`, -// `blocked_conflict`) are never touched — operators inspect those. -func DeletePendingForBranchGeneration(ctx context.Context, d *DB, branchRef string, branchGeneration int64) (int, error) { - if branchRef == "" { - return 0, fmt.Errorf("state: DeletePendingForBranchGeneration: empty branch_ref") - } - res, err := d.conn.ExecContext(ctx, - `DELETE FROM capture_events WHERE state = ? AND branch_ref = ? AND branch_generation = ?`, - EventStatePending, branchRef, branchGeneration, - ) - if err != nil { - return 0, fmt.Errorf("state: delete pending branch %q generation %d: %w", branchRef, branchGeneration, err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("state: delete pending branch generation rows: %w", err) - } - return int(n), nil -} - -// DeleteStaleUnpublishedForBranchGeneration deletes queued/unpublished rows for -// the active branch generation whose base_head no longer matches the current -// branch head. This is used after an external same-branch fast-forward: the -// worktree will be reclassified against the new HEAD, so old snapshots should -// not replay or remain as barriers. -func DeleteStaleUnpublishedForBranchGeneration(ctx context.Context, d *DB, branchRef string, branchGeneration int64, currentHead string) (int, error) { - if branchRef == "" { - return 0, fmt.Errorf("state: DeleteStaleUnpublishedForBranchGeneration: empty branch_ref") +// PruneRecoverySnapshotEventsBefore removes old published or recovered rows +// belonging to one recovery snapshot. The caller must hold a Git transaction +// lock that verifies recovery_snapshots.recovery_ref still resolves exactly to +// recovery_snapshots.commit_oid for the duration of this statement. +func PruneRecoverySnapshotEventsBefore(ctx context.Context, d *DB, snapshotID int64, cutoff float64) (int, error) { + if d == nil || snapshotID < 1 { + return 0, fmt.Errorf("state: PruneRecoverySnapshotEventsBefore: invalid selector") } res, err := d.conn.ExecContext(ctx, ` DELETE FROM capture_events -WHERE branch_ref = ? - AND branch_generation = ? - AND base_head <> ? - AND state IN (?, ?)`, - branchRef, branchGeneration, currentHead, - EventStatePending, EventStateBlockedConflict) - if err != nil { - return 0, fmt.Errorf("state: delete stale unpublished branch generation: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("state: delete stale unpublished branch generation rows: %w", err) - } - return int(n), nil -} - -// PruneTerminalEventsBefore deletes stale terminal failure rows whose state is -// 'blocked_conflict' or 'failed'. Rows that still form a replay barrier are -// preserved: if a later pending event exists for the same branch ref and -// generation, deleting the terminal predecessor would let that pending event -// leapfrog a broken replay history. -// -// CASCADE on capture_ops drops matching op rows in the same transaction. -func PruneTerminalEventsBefore(ctx context.Context, d *DB, cutoff float64) (int, error) { - res, err := d.conn.ExecContext(ctx, ` -DELETE FROM capture_events -WHERE state IN ('blocked_conflict', 'failed') +WHERE state IN ('published', 'recovered') AND captured_ts < ? - AND NOT EXISTS ( + AND EXISTS ( SELECT 1 - FROM capture_events pending - WHERE pending.branch_ref = capture_events.branch_ref - AND pending.branch_generation = capture_events.branch_generation - AND pending.seq > capture_events.seq - AND pending.state = 'pending' - )`, cutoff) + FROM recovery_snapshot_events member + WHERE member.snapshot_id = ? + AND member.event_seq = capture_events.seq + )`, cutoff, snapshotID) if err != nil { - return 0, fmt.Errorf("state: prune terminal events: %w", err) + return 0, fmt.Errorf("state: prune protected recovery snapshot events: %w", err) } n, err := res.RowsAffected() if err != nil { - return 0, fmt.Errorf("state: prune terminal events rows: %w", err) + return 0, fmt.Errorf("state: prune protected recovery snapshot event rows: %w", err) } return int(n), nil } -// PurgeUnpublishedForDeadBranch deletes both pending and terminal -// (blocked_conflict, failed) capture_events rows for a (branch_ref, -// branch_generation) pair whose branch ref has since been deleted. Published -// rows are never touched. If a deleted row was referenced by the -// publish_state singleton (status='blocked_conflict'), that barrier is -// lifted and the breadcrumb meta keys (last_replay_conflict, -// last_replay_conflict_legacy, last_replay_error) are cleared in the same -// transaction so `acd status` / `acd diagnose` read clean immediately. -// -// Empty branchRef returns an error. Returns the total number of capture_events -// rows deleted (pending + terminal combined). Caller is responsible for -// confirming the ref is actually dead before invoking — the helper does no -// liveness check itself. -// -// Why pending matters here: 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 (now-irrelevant) prior -// generation, mismatches in `checkEventGeneration`, and stamps a fresh -// blocked_conflict, defeating the prune entirely. Pending + terminal must -// drop together for the dead-branch case. -func PurgeUnpublishedForDeadBranch(ctx context.Context, d *DB, branchRef string, branchGeneration int64) (int, error) { - if branchRef == "" { - return 0, fmt.Errorf("state: PurgeUnpublishedForDeadBranch: empty branch_ref") - } - tx, err := d.conn.BeginTx(ctx, nil) - if err != nil { - return 0, fmt.Errorf("state: PurgeUnpublishedForDeadBranch: begin tx: %w", err) - } - defer func() { _ = tx.Rollback() }() - - blockedSeqs := make(map[int64]struct{}) - blockedRows, err := tx.QueryContext(ctx, ` -SELECT seq -FROM capture_events -WHERE branch_ref = ? - AND branch_generation = ? - AND state = ?`, - branchRef, branchGeneration, EventStateBlockedConflict) - if err != nil { - return 0, fmt.Errorf("state: load dead-branch blocked seqs %q generation %d: %w", branchRef, branchGeneration, err) - } - for blockedRows.Next() { - var seq int64 - if err := blockedRows.Scan(&seq); err != nil { - _ = blockedRows.Close() - return 0, fmt.Errorf("state: scan dead-branch blocked seqs %q generation %d: %w", branchRef, branchGeneration, err) - } - blockedSeqs[seq] = struct{}{} - } - if err := blockedRows.Close(); err != nil { - return 0, fmt.Errorf("state: close dead-branch blocked seqs %q generation %d: %w", branchRef, branchGeneration, err) - } - if err := blockedRows.Err(); err != nil { - return 0, fmt.Errorf("state: iterate dead-branch blocked seqs %q generation %d: %w", branchRef, branchGeneration, err) - } - - res, err := tx.ExecContext(ctx, ` -DELETE FROM capture_events -WHERE branch_ref = ? - AND branch_generation = ? - AND state IN (?, ?, ?)`, - branchRef, branchGeneration, - EventStatePending, EventStateBlockedConflict, EventStateFailed, - ) - if err != nil { - return 0, fmt.Errorf("state: purge unpublished branch %q generation %d: %w", branchRef, branchGeneration, err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("state: purge unpublished branch generation rows: %w", err) - } - - // Lift the barrier in publish_state if it referenced a row we just - // deleted. The singleton row is keyed at id=1; clear status + error so - // `acd status` reads "ok" immediately and the breadcrumbs do not point - // at a row that no longer exists. Mirrors the pattern in - // internal/cli/purge.go (the operator-driven `acd purge-events`). - nowSec := nowSeconds() - var pubRows int64 - if len(blockedSeqs) > 0 { - var pubSeq sql.NullInt64 - var pubBranchRef sql.NullString - var pubBranchGeneration sql.NullInt64 - err = tx.QueryRowContext(ctx, ` -SELECT event_seq, branch_ref, branch_generation -FROM publish_state -WHERE id = 1 AND status = 'blocked_conflict'`, - ).Scan(&pubSeq, &pubBranchRef, &pubBranchGeneration) - if err != nil && err != sql.ErrNoRows { - return 0, fmt.Errorf("state: load publish_state for dead branch %q: %w", branchRef, err) - } - if err == nil && - pubSeq.Valid && - pubBranchRef.Valid && pubBranchRef.String == branchRef && - pubBranchGeneration.Valid && pubBranchGeneration.Int64 == branchGeneration { - if _, ok := blockedSeqs[pubSeq.Int64]; ok { - pubRes, err := tx.ExecContext(ctx, ` -UPDATE publish_state -SET status = 'ok', error = NULL, updated_ts = ? -WHERE id = 1 - AND status = 'blocked_conflict' - AND event_seq = ? - AND branch_ref = ? - AND branch_generation = ?`, nowSec, pubSeq.Int64, branchRef, branchGeneration) - if err != nil { - return 0, fmt.Errorf("state: clear publish_state for dead branch %q: %w", branchRef, err) - } - pubRows, _ = pubRes.RowsAffected() - } - } - } - if pubRows > 0 { - // Drop the human-readable breadcrumbs only when we actually lifted - // the singleton barrier — otherwise we'd be clearing breadcrumbs - // that belong to a different (live) branch's barrier. - for _, key := range []string{ - "last_replay_conflict", - "last_replay_conflict_legacy", - "last_replay_error", - } { - if _, err := tx.ExecContext(ctx, `DELETE FROM daemon_meta WHERE key = ?`, key); err != nil { - return 0, fmt.Errorf("state: clear daemon_meta %s for dead branch %q: %w", key, branchRef, err) - } - } - } - - if err := tx.Commit(); err != nil { - return 0, fmt.Errorf("state: PurgeUnpublishedForDeadBranch: commit: %w", err) - } - return int(n), nil +// PruneTerminalEventsBefore deliberately preserves every blocked_conflict and +// failed row. A valid recovery snapshot transition changes those rows to +// recovered/published atomically, so a terminal failure can never be both +// snapshot-protected and still terminal. Keep this compatibility entry point +// as an explicit no-op; protected-row retention belongs to the daemon's +// Git-locked recovery-snapshot pruning path. +func PruneTerminalEventsBefore(ctx context.Context, d *DB, cutoff float64) (int, error) { + _ = ctx + _ = d + _ = cutoff + return 0, nil } // LatestEventSeq returns the highest seq value present, or 0 if the table is diff --git a/internal/state/events_test.go b/internal/state/events_test.go deleted file mode 100644 index 4e9e98ea..00000000 --- a/internal/state/events_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package state - -import ( - "context" - "database/sql" - "testing" -) - -// TestPurgeUnpublishedForDeadBranch pins the behaviour of the helper that -// cleans up both pending and terminal capture_events rows for a (branch_ref, -// branch_generation) pair after the branch has been merged and deleted. -// -// Invariants: -// - pending + blocked_conflict + failed rows for the exact (branchRef, -// generation) pair are removed. -// - published rows for that same pair are untouched. -// - All rows for a different branch_ref are untouched. -// - All rows for a different branch_generation are untouched. -// - An empty branchRef returns an error and deletes nothing. -// - When publish_state.status='blocked_conflict' AT id=1 references a deleted -// blocked row for the exact pair, the helper lifts the singleton barrier -// (status->'ok', error->NULL) and clears the last_replay_conflict / -// last_replay_conflict_legacy / last_replay_error breadcrumbs in the same -// transaction. -// - When publish_state status is NOT 'blocked_conflict', the breadcrumbs -// are left intact (they may belong to a different live barrier). -// - When publish_state.status='blocked_conflict' references a different pair, -// the live barrier and breadcrumbs are left intact. -func TestPurgeUnpublishedForDeadBranch(t *testing.T) { - t.Parallel() - d, _ := openTestDB(t) - ctx := context.Background() - - appendEvent := func(branch string, generation int64, stateName, path string) int64 { - t.Helper() - seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ - BranchRef: branch, - BranchGeneration: generation, - BaseHead: "deadbeef", - Operation: "modify", - Path: path, - Fidelity: "exact", - State: stateName, - }, []CaptureOp{{Op: "modify", Path: path, Fidelity: "exact"}}) - if err != nil { - t.Fatalf("append %s: %v", path, err) - } - return seq - } - - // Target (refs/heads/dead, gen 3) — pending + terminal must be deleted. - targetBlocked := appendEvent("refs/heads/dead", 3, EventStateBlockedConflict, "dead-blocked.txt") - targetFailed := appendEvent("refs/heads/dead", 3, EventStateFailed, "dead-failed.txt") - targetPending := appendEvent("refs/heads/dead", 3, EventStatePending, "dead-pending.txt") - - // Same (branch, gen) but published — must survive. - keepPublished := appendEvent("refs/heads/dead", 3, EventStatePublished, "dead-published.txt") - - // Different branch_ref (same generation number) — must survive. - otherBranchBlocked := appendEvent("refs/heads/other", 3, EventStateBlockedConflict, "other-blocked.txt") - otherBranchFailed := appendEvent("refs/heads/other", 3, EventStateFailed, "other-failed.txt") - otherBranchPending := appendEvent("refs/heads/other", 3, EventStatePending, "other-pending.txt") - - // Same branch but different generation — must survive. - otherGenBlocked := appendEvent("refs/heads/dead", 4, EventStateBlockedConflict, "dead-gen4-blocked.txt") - otherGenFailed := appendEvent("refs/heads/dead", 4, EventStateFailed, "dead-gen4-failed.txt") - otherGenPending := appendEvent("refs/heads/dead", 4, EventStatePending, "dead-gen4-pending.txt") - - // Stamp publish_state.status='blocked_conflict' so the helper lifts the - // singleton barrier in the same tx. Also seed the breadcrumb meta keys. - if err := MarkEventBlocked(ctx, d, targetBlocked, "seeded blocker", nowSeconds(), - sql.NullString{String: "refs/heads/dead", Valid: true}, - sql.NullInt64{Int64: 3, Valid: true}, - sql.NullString{String: "deadbeef", Valid: true}); err != nil { - t.Fatalf("MarkEventBlocked: %v", err) - } - for _, key := range []string{ - "last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error", - } { - if err := MetaSet(ctx, d, key, "seeded"); err != nil { - t.Fatalf("seed meta %q: %v", key, err) - } - } - - n, err := PurgeUnpublishedForDeadBranch(ctx, d, "refs/heads/dead", 3) - if err != nil { - t.Fatalf("PurgeUnpublishedForDeadBranch: %v", err) - } - // pending + blocked_conflict + failed = 3 rows. - if n != 3 { - t.Fatalf("deleted=%d want 3 (pending + blocked_conflict + failed for target pair)", n) - } - - // Collect surviving seq values. - rows, err := d.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) - if err != nil { - t.Fatalf("query remaining: %v", err) - } - defer rows.Close() - remaining := map[int64]bool{} - for rows.Next() { - var seq int64 - if err := rows.Scan(&seq); err != nil { - t.Fatalf("scan: %v", err) - } - remaining[seq] = true - } - if err := rows.Err(); err != nil { - t.Fatalf("iter: %v", err) - } - - // Target pending + terminal rows must be gone. - for _, seq := range []int64{targetBlocked, targetFailed, targetPending} { - if remaining[seq] { - t.Fatalf("target row seq %d survived; remaining=%v", seq, remaining) - } - } - // Everything else must still be present. - for _, seq := range []int64{ - keepPublished, - otherBranchBlocked, otherBranchFailed, otherBranchPending, - otherGenBlocked, otherGenFailed, otherGenPending, - } { - if !remaining[seq] { - t.Fatalf("seq %d wrongly deleted; remaining=%v", seq, remaining) - } - } - - // publish_state.status must have been lifted to 'ok'. - var status string - var errMsg sql.NullString - if err := d.SQL().QueryRowContext(ctx, - `SELECT status, error FROM publish_state WHERE id = 1`, - ).Scan(&status, &errMsg); err != nil { - t.Fatalf("read publish_state: %v", err) - } - if status != "ok" { - t.Fatalf("publish_state.status=%q want 'ok' (barrier should be lifted)", status) - } - if errMsg.Valid { - t.Fatalf("publish_state.error=%q want NULL (barrier should be lifted)", errMsg.String) - } - - // Breadcrumb meta keys must be gone (we lifted the publish_state singleton). - for _, key := range []string{ - "last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error", - } { - if _, ok, err := MetaGet(ctx, d, key); err != nil { - t.Fatalf("MetaGet %q: %v", key, err) - } else if ok { - t.Fatalf("breadcrumb %q still present after purge; expected cleared", key) - } - } - - // Idempotent: a second call for the same pair deletes zero rows. - n2, err := PurgeUnpublishedForDeadBranch(ctx, d, "refs/heads/dead", 3) - if err != nil { - t.Fatalf("second call: %v", err) - } - if n2 != 0 { - t.Fatalf("second call deleted=%d want 0 (idempotent)", n2) - } - - // Empty branchRef must return an error and must not delete anything. - t.Run("empty_branch_ref", func(t *testing.T) { - d2, _ := openTestDB(t) - - seq2, err := AppendCaptureEvent(ctx, d2, CaptureEvent{ - BranchRef: "refs/heads/x", - BranchGeneration: 1, - BaseHead: "abc", - Operation: "modify", - Path: "x.txt", - Fidelity: "exact", - State: EventStateBlockedConflict, - }, []CaptureOp{{Op: "modify", Path: "x.txt", Fidelity: "exact"}}) - if err != nil { - t.Fatalf("seed d2: %v", err) - } - - if _, err := PurgeUnpublishedForDeadBranch(ctx, d2, "", 1); err == nil { - t.Fatalf("empty branch_ref must error") - } - - var count int - if err := d2.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seq2, - ).Scan(&count); err != nil { - t.Fatalf("count d2 row: %v", err) - } - if count != 1 { - t.Fatalf("d2 row deleted despite empty-branchRef error; count=%d want 1", count) - } - }) - - // Breadcrumbs must NOT be cleared when publish_state status is not blocked. - t.Run("breadcrumbs_preserved_when_publish_state_clean", func(t *testing.T) { - d3, _ := openTestDB(t) - seqA, err := AppendCaptureEvent(ctx, d3, CaptureEvent{ - BranchRef: "refs/heads/dead2", - BranchGeneration: 1, - BaseHead: "abc", - Operation: "modify", - Path: "p.txt", - Fidelity: "exact", - State: EventStateBlockedConflict, - }, []CaptureOp{{Op: "modify", Path: "p.txt", Fidelity: "exact"}}) - if err != nil { - t.Fatalf("append: %v", err) - } - _ = seqA - // Seed a "live" publish_state with status != blocked_conflict, plus - // breadcrumbs that belong to that live barrier. - if err := MetaSet(ctx, d3, "last_replay_conflict", "live"); err != nil { - t.Fatalf("seed live breadcrumb: %v", err) - } - - if _, err := PurgeUnpublishedForDeadBranch(ctx, d3, "refs/heads/dead2", 1); err != nil { - t.Fatalf("purge: %v", err) - } - - v, ok, err := MetaGet(ctx, d3, "last_replay_conflict") - if err != nil { - t.Fatalf("MetaGet: %v", err) - } - if !ok || v != "live" { - t.Fatalf("breadcrumb was cleared but publish_state was not in blocked_conflict; got ok=%v v=%q", ok, v) - } - }) - - // A dead-branch purge must not lift a live branch's blocked singleton just - // because publish_state happens to be blocked_conflict. - t.Run("live_blocked_publish_state_preserved", func(t *testing.T) { - d4, _ := openTestDB(t) - deadSeq, err := AppendCaptureEvent(ctx, d4, CaptureEvent{ - BranchRef: "refs/heads/dead3", - BranchGeneration: 1, - BaseHead: "abc", - Operation: "modify", - Path: "dead3.txt", - Fidelity: "exact", - State: EventStateBlockedConflict, - }, []CaptureOp{{Op: "modify", Path: "dead3.txt", Fidelity: "exact"}}) - if err != nil { - t.Fatalf("append dead: %v", err) - } - liveSeq, err := AppendCaptureEvent(ctx, d4, CaptureEvent{ - BranchRef: "refs/heads/live", - BranchGeneration: 7, - BaseHead: "def", - Operation: "modify", - Path: "live.txt", - Fidelity: "exact", - State: EventStateBlockedConflict, - }, []CaptureOp{{Op: "modify", Path: "live.txt", Fidelity: "exact"}}) - if err != nil { - t.Fatalf("append live: %v", err) - } - if err := MarkEventBlocked(ctx, d4, liveSeq, "live blocker", nowSeconds(), - sql.NullString{String: "refs/heads/live", Valid: true}, - sql.NullInt64{Int64: 7, Valid: true}, - sql.NullString{String: "def", Valid: true}); err != nil { - t.Fatalf("MarkEventBlocked live: %v", err) - } - for _, key := range []string{ - "last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error", - } { - if err := MetaSet(ctx, d4, key, "live"); err != nil { - t.Fatalf("seed live meta %q: %v", key, err) - } - } - - n, err := PurgeUnpublishedForDeadBranch(ctx, d4, "refs/heads/dead3", 1) - if err != nil { - t.Fatalf("purge: %v", err) - } - if n != 1 { - t.Fatalf("deleted=%d want 1", n) - } - var deadCount int - if err := d4.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, deadSeq, - ).Scan(&deadCount); err != nil { - t.Fatalf("count dead row: %v", err) - } - if deadCount != 0 { - t.Fatalf("dead blocked row survived; count=%d", deadCount) - } - - var status, branchRef string - var eventSeq, branchGeneration int64 - var errMsg sql.NullString - if err := d4.SQL().QueryRowContext(ctx, - `SELECT event_seq, branch_ref, branch_generation, status, error FROM publish_state WHERE id = 1`, - ).Scan(&eventSeq, &branchRef, &branchGeneration, &status, &errMsg); err != nil { - t.Fatalf("read publish_state: %v", err) - } - if eventSeq != liveSeq || branchRef != "refs/heads/live" || branchGeneration != 7 || status != "blocked_conflict" || !errMsg.Valid { - t.Fatalf("live publish_state changed: seq=%d branch=%q gen=%d status=%q err_valid=%v", - eventSeq, branchRef, branchGeneration, status, errMsg.Valid) - } - for _, key := range []string{ - "last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error", - } { - v, ok, err := MetaGet(ctx, d4, key) - if err != nil { - t.Fatalf("MetaGet %q: %v", key, err) - } - if !ok || v != "live" { - t.Fatalf("live breadcrumb %q changed: ok=%v v=%q", key, ok, v) - } - } - }) -} diff --git a/internal/state/migrate.go b/internal/state/migrate.go index 2d519f6e..774bac78 100644 --- a/internal/state/migrate.go +++ b/internal/state/migrate.go @@ -54,7 +54,10 @@ ALTER TABLE decision_records_v6 RENAME TO decision_records; // v10 adds rewrite_plans.commit_format so saved plans preserve the validation // policy used when they were generated or edited. v11 adds // intent_planner_windows and intent_planner_window_events for lossless, -// privacy-safe planner-window observability. +// privacy-safe planner-window observability. v12 adds recovery_snapshots and +// recovery_snapshot_events. v13 adds idx_capture_events_recovery_prefix for +// bounded published-prefix pruning. Both are pure DDL applied through +// schemaDDL. // Future migrations are append-only for daily_rollups (D9) — only ALTER TABLE // ADD COLUMN. Schema-changing helpers belong here, not in db.go. // @@ -63,9 +66,9 @@ ALTER TABLE decision_records_v6 RENAME TO decision_records; // and adding idempotent statements to schemaDDL is sufficient for pure-DDL // migrations (such as v2→v3). v6 uses an explicit table rebuild for only // pre-v6 databases whose decision_records table still has the old event_seq -// foreign key. v7 and v8 are pure DDL migrations through schemaDDL. Migrate is -// wired now so future phases requiring separate data backfill have a single -// entry point to extend. +// foreign key. v7, v8, v11, v12, and v13 are pure DDL migrations through schemaDDL. +// Migrate is wired now so future phases requiring separate data backfill have +// a single entry point to extend. func (d *DB) Migrate(ctx context.Context) error { cur, err := d.UserVersion(ctx) if err != nil { diff --git a/internal/state/recovery.go b/internal/state/recovery.go new file mode 100644 index 00000000..0279ecfb --- /dev/null +++ b/internal/state/recovery.go @@ -0,0 +1,615 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// RecoverySnapshot is the durable record of one all-or-none unpublished-chain +// transition. Outcome is EventStatePublished when a stable external commit was +// proven, or EventStateRecovered when a composed tree was archived. RecoveryRef +// protects CommitOID in both outcomes so a cross-store HEAD race cannot make +// the evidence unreachable. Exact ordered membership lives in +// recovery_snapshot_events. +type RecoverySnapshot struct { + ID int64 + CreatedTS float64 + Outcome string + BranchRef string + BranchGeneration int64 + FirstEventSeq int64 + LastEventSeq int64 + EventCount int + CommitOID string + RecoveryRef sql.NullString + Reason sql.NullString +} + +// RecoverySnapshotEvent is one exact ordered member of a recovery snapshot. +// EventSeq intentionally has no capture_events foreign key so membership +// survives later terminal-row pruning, like decision_records.event_seq. +type RecoverySnapshotEvent struct { + SnapshotID int64 + Ord int + EventSeq int64 +} + +// RecoveryChainEvent pairs one immutable capture event with its immutable, +// ordered operations. Callers pass the result back to TransitionRecoveryChain +// as the expected chain; the transition rechecks both layers before writing. +type RecoveryChainEvent struct { + Event CaptureEvent + Ops []CaptureOp +} + +// RecoveryChainTransition describes the only two safe terminal outcomes for a +// complete unpublished chain. Expected must be the exact same-anchor suffix +// returned by LoadUnpublishedRecoveryChain. +type RecoveryChainTransition struct { + Expected []RecoveryChainEvent + TargetState string + CommitOID string + RecoveryRef string + Reason string + ActionTaken string + UserMessage string + // DecisionKind preserves a narrower legacy classification when the + // caller has already proven it. Empty selects the outcome default. + DecisionKind string + TransitionTS float64 + // InvalidateShadow atomically removes the transitioned pair's shadow + // rows and bootstrap marker. Active replay sets this for recovered chains + // so the run loop reseeds HEAD and captures the still-dirty worktree again. + // Branch-transition callers leave it false and own their generation reseed. + InvalidateShadow bool +} + +// ErrRecoveryChainChanged means the unpublished suffix no longer exactly +// matches the caller's expected seq/state/provenance/ops. No snapshot, +// lifecycle update, planner deletion, breadcrumb cleanup, or decision is +// committed when this error is returned. +var ErrRecoveryChainChanged = errors.New("state: recovery chain changed") + +type recoveryQueryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +// LoadUnpublishedRecoveryChain returns the complete unpublished suffix for one +// (branch_ref, branch_generation), starting at firstSeq and ordered by seq. +// Only pending, blocked_conflict, and failed are unpublished; recovered is a +// terminal non-barrier state and is deliberately excluded. +func LoadUnpublishedRecoveryChain(ctx context.Context, d *DB, branchRef string, branchGeneration, firstSeq int64) ([]RecoveryChainEvent, error) { + if d == nil { + return nil, fmt.Errorf("state: LoadUnpublishedRecoveryChain: nil db") + } + return loadUnpublishedRecoveryChain(ctx, d.readSQL(), branchRef, branchGeneration, firstSeq) +} + +// LoadPublishedRecoveryPrefix returns earlier published events captured from +// the same immutable base as an unpublished suffix. The prefix is +// materialization context only: it lets recovery rebuild a later event whose +// before-state was produced by an event already published in a prior pass. +// TransitionRecoveryChain never changes these rows. +func LoadPublishedRecoveryPrefix( + ctx context.Context, + d *DB, + branchRef string, + branchGeneration int64, + baseHead string, + beforeSeq int64, +) ([]RecoveryChainEvent, error) { + if d == nil { + return nil, fmt.Errorf("state: LoadPublishedRecoveryPrefix: nil db") + } + if branchRef == "" || branchGeneration < 1 || baseHead == "" || beforeSeq < 1 { + return nil, fmt.Errorf("state: LoadPublishedRecoveryPrefix: invalid selector") + } + rows, err := d.readSQL().QueryContext(ctx, ` +SELECT seq, branch_ref, branch_generation, base_head, operation, path, old_path, + fidelity, captured_ts, published_ts, state, commit_oid, error, message +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND base_head = ? + AND seq < ? + AND state = ? +ORDER BY seq ASC`, branchRef, branchGeneration, baseHead, beforeSeq, EventStatePublished) + if err != nil { + return nil, fmt.Errorf("state: load published recovery prefix: %w", err) + } + defer rows.Close() + var prefix []RecoveryChainEvent + for rows.Next() { + var ev CaptureEvent + if err := rows.Scan(&ev.Seq, &ev.BranchRef, &ev.BranchGeneration, &ev.BaseHead, + &ev.Operation, &ev.Path, &ev.OldPath, &ev.Fidelity, + &ev.CapturedTS, &ev.PublishedTS, &ev.State, &ev.CommitOID, + &ev.Error, &ev.Message); err != nil { + return nil, fmt.Errorf("state: scan published recovery prefix: %w", err) + } + prefix = append(prefix, RecoveryChainEvent{Event: ev}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iterate published recovery prefix: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("state: close published recovery prefix: %w", err) + } + for i := range prefix { + ops, err := loadCaptureOpsWith(ctx, d.readSQL(), prefix[i].Event.Seq) + if err != nil { + return nil, err + } + prefix[i].Ops = ops + } + return prefix, nil +} + +func loadUnpublishedRecoveryChain(ctx context.Context, q recoveryQueryer, branchRef string, branchGeneration, firstSeq int64) ([]RecoveryChainEvent, error) { + if branchRef == "" { + return nil, fmt.Errorf("state: LoadUnpublishedRecoveryChain: empty branch_ref") + } + if branchGeneration < 1 { + return nil, fmt.Errorf("state: LoadUnpublishedRecoveryChain: invalid branch_generation %d", branchGeneration) + } + if firstSeq < 1 { + return nil, fmt.Errorf("state: LoadUnpublishedRecoveryChain: invalid first_seq %d", firstSeq) + } + + rows, err := q.QueryContext(ctx, ` +SELECT seq, branch_ref, branch_generation, base_head, operation, path, old_path, + fidelity, captured_ts, published_ts, state, commit_oid, error, message +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND seq >= ? + AND state IN (?, ?, ?) +ORDER BY seq ASC`, + branchRef, branchGeneration, firstSeq, + EventStatePending, EventStateBlockedConflict, EventStateFailed) + if err != nil { + return nil, fmt.Errorf("state: load unpublished recovery chain: %w", err) + } + defer rows.Close() + + var chain []RecoveryChainEvent + for rows.Next() { + var ev CaptureEvent + if err := rows.Scan(&ev.Seq, &ev.BranchRef, &ev.BranchGeneration, &ev.BaseHead, + &ev.Operation, &ev.Path, &ev.OldPath, &ev.Fidelity, + &ev.CapturedTS, &ev.PublishedTS, &ev.State, &ev.CommitOID, + &ev.Error, &ev.Message); err != nil { + return nil, fmt.Errorf("state: scan unpublished recovery event: %w", err) + } + chain = append(chain, RecoveryChainEvent{Event: ev}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iterate unpublished recovery chain: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("state: close unpublished recovery chain: %w", err) + } + + for i := range chain { + ops, err := loadCaptureOpsWith(ctx, q, chain[i].Event.Seq) + if err != nil { + return nil, err + } + chain[i].Ops = ops + } + return chain, nil +} + +func loadCaptureOpsWith(ctx context.Context, q recoveryQueryer, seq int64) ([]CaptureOp, error) { + rows, err := q.QueryContext(ctx, ` +SELECT event_seq, ord, op, path, old_path, + before_oid, before_mode, after_oid, after_mode, fidelity +FROM capture_ops WHERE event_seq = ? ORDER BY ord ASC`, seq) + if err != nil { + return nil, fmt.Errorf("state: load recovery capture ops seq=%d: %w", seq, err) + } + defer rows.Close() + var ops []CaptureOp + for rows.Next() { + var op CaptureOp + if err := rows.Scan(&op.EventSeq, &op.Ord, &op.Op, &op.Path, &op.OldPath, + &op.BeforeOID, &op.BeforeMode, &op.AfterOID, &op.AfterMode, + &op.Fidelity); err != nil { + return nil, fmt.Errorf("state: scan recovery capture op seq=%d: %w", seq, err) + } + ops = append(ops, op) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iterate recovery capture ops seq=%d: %w", seq, err) + } + return ops, nil +} + +// TransitionRecoveryChain atomically moves one exact unpublished suffix to +// published or recovered. It never changes branch_ref, branch_generation, +// base_head, operation, path, old_path, fidelity, or capture_ops. +func TransitionRecoveryChain(ctx context.Context, d *DB, req RecoveryChainTransition) (RecoverySnapshot, error) { + var zero RecoverySnapshot + if d == nil { + return zero, fmt.Errorf("state: TransitionRecoveryChain: nil db") + } + if err := validateRecoveryTransition(req); err != nil { + return zero, err + } + if req.TransitionTS == 0 { + req.TransitionTS = nowSeconds() + } + + tx, err := d.conn.BeginTx(ctx, nil) + if err != nil { + return zero, fmt.Errorf("state: begin recovery transition: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Promote the deferred transaction to a writer before the exact-chain + // reread. The write handle has one connection, and this no-op UPDATE also + // reserves SQLite's writer slot against other processes for the remainder + // of the compare-and-transition sequence. + if _, err := tx.ExecContext(ctx, + `UPDATE daemon_meta SET updated_ts = updated_ts WHERE key = '__acd_recovery_tx_guard__'`); err != nil { + return zero, fmt.Errorf("state: lock recovery transition: %w", err) + } + + first := req.Expected[0].Event + actual, err := loadUnpublishedRecoveryChain(ctx, tx, first.BranchRef, first.BranchGeneration, first.Seq) + if err != nil { + return zero, err + } + if !sameRecoveryChain(actual, req.Expected) { + return zero, ErrRecoveryChainChanged + } + + snapshot := RecoverySnapshot{ + CreatedTS: req.TransitionTS, + Outcome: req.TargetState, + BranchRef: first.BranchRef, + BranchGeneration: first.BranchGeneration, + FirstEventSeq: first.Seq, + LastEventSeq: req.Expected[len(req.Expected)-1].Event.Seq, + EventCount: len(req.Expected), + CommitOID: req.CommitOID, + RecoveryRef: sql.NullString{String: req.RecoveryRef, Valid: req.RecoveryRef != ""}, + Reason: sql.NullString{String: req.Reason, Valid: req.Reason != ""}, + } + res, err := tx.ExecContext(ctx, ` +INSERT INTO recovery_snapshots( + created_ts, outcome, branch_ref, branch_generation, + first_event_seq, last_event_seq, event_count, + commit_oid, recovery_ref, reason +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + snapshot.CreatedTS, snapshot.Outcome, snapshot.BranchRef, + snapshot.BranchGeneration, snapshot.FirstEventSeq, + snapshot.LastEventSeq, snapshot.EventCount, snapshot.CommitOID, + snapshot.RecoveryRef, snapshot.Reason) + if err != nil { + return zero, fmt.Errorf("state: insert recovery snapshot: %w", err) + } + snapshot.ID, err = res.LastInsertId() + if err != nil { + return zero, fmt.Errorf("state: recovery snapshot id: %w", err) + } + + decisionKind := req.DecisionKind + if decisionKind == "" { + decisionKind = DecisionKindRecoveryPublished + } + action := req.ActionTaken + message := req.UserMessage + if req.TargetState == EventStateRecovered { + if req.DecisionKind == "" { + decisionKind = DecisionKindRecoveryArchived + } + if action == "" { + action = "archived unpublished chain to recovery ref" + } + if message == "" { + message = fmt.Sprintf("Captured work was preserved at %s.", req.RecoveryRef) + } + } else { + if action == "" { + action = "reconciled unpublished chain as externally published" + } + if message == "" { + message = "An external commit already contains this captured work." + } + } + + members := make(map[int64]struct{}, len(req.Expected)) + for ord, expected := range req.Expected { + ev := expected.Event + if _, err := tx.ExecContext(ctx, ` +INSERT INTO recovery_snapshot_events(snapshot_id, ord, event_seq) +VALUES (?, ?, ?)`, snapshot.ID, ord, ev.Seq); err != nil { + return zero, fmt.Errorf("state: insert recovery snapshot member seq=%d: %w", ev.Seq, err) + } + + res, err := tx.ExecContext(ctx, ` +UPDATE capture_events +SET state = ?, commit_oid = ?, error = NULL, published_ts = ? +WHERE seq = ? + AND state = ? + AND branch_ref = ? + AND branch_generation = ? + AND base_head = ? + AND operation = ? + AND path = ? + AND old_path IS ? + AND fidelity = ?`, + req.TargetState, req.CommitOID, req.TransitionTS, + ev.Seq, ev.State, ev.BranchRef, ev.BranchGeneration, + ev.BaseHead, ev.Operation, ev.Path, ev.OldPath, ev.Fidelity) + if err != nil { + return zero, fmt.Errorf("state: transition recovery event seq=%d: %w", ev.Seq, err) + } + n, err := res.RowsAffected() + if err != nil { + return zero, fmt.Errorf("state: recovery transition rows seq=%d: %w", ev.Seq, err) + } + if n != 1 { + return zero, ErrRecoveryChainChanged + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM planner_state WHERE event_seq = ?`, ev.Seq); err != nil { + return zero, fmt.Errorf("state: clear recovery planner state seq=%d: %w", ev.Seq, err) + } + members[ev.Seq] = struct{}{} + + if _, err := appendDecision(ctx, tx, DecisionRecord{ + DecisionTS: req.TransitionTS, + Kind: decisionKind, + Path: sql.NullString{String: ev.Path, Valid: ev.Path != ""}, + Reason: sql.NullString{String: req.Reason, Valid: req.Reason != ""}, + EventSeq: sql.NullInt64{Int64: ev.Seq, Valid: true}, + HeadSHA: sql.NullString{String: req.CommitOID, Valid: true}, + CommitOID: sql.NullString{String: req.CommitOID, Valid: true}, + BranchRef: sql.NullString{String: ev.BranchRef, Valid: true}, + BranchGeneration: sql.NullInt64{Int64: ev.BranchGeneration, Valid: true}, + ActionTaken: sql.NullString{String: action, Valid: true}, + UserMessage: sql.NullString{String: message, Valid: true}, + }); err != nil { + return zero, fmt.Errorf("state: append recovery decision seq=%d: %w", ev.Seq, err) + } + } + + if req.InvalidateShadow { + if _, err := tx.ExecContext(ctx, ` +DELETE FROM shadow_paths +WHERE branch_ref = ? AND branch_generation = ?`, + first.BranchRef, first.BranchGeneration); err != nil { + return zero, fmt.Errorf("state: invalidate recovery shadow rows: %w", err) + } + marker := fmt.Sprintf("shadow.bootstrapped:%s:%d", first.BranchRef, first.BranchGeneration) + if _, err := tx.ExecContext(ctx, + `DELETE FROM daemon_meta WHERE key = ?`, marker); err != nil { + return zero, fmt.Errorf("state: invalidate recovery shadow marker: %w", err) + } + } + + clearedBreadcrumb, err := clearMatchingRecoveryBreadcrumb(ctx, tx, members, + first.BranchRef, first.BranchGeneration, req.TargetState, + req.CommitOID, req.TransitionTS) + if err != nil { + return zero, err + } + if clearedBreadcrumb { + for _, key := range []string{ + "last_replay_conflict", + "last_replay_conflict_legacy", + "last_replay_error", + } { + if _, err := tx.ExecContext(ctx, `DELETE FROM daemon_meta WHERE key = ?`, key); err != nil { + return zero, fmt.Errorf("state: clear recovery meta %s: %w", key, err) + } + } + } + + if err := tx.Commit(); err != nil { + return zero, fmt.Errorf("state: commit recovery transition: %w", err) + } + return snapshot, nil +} + +func validateRecoveryTransition(req RecoveryChainTransition) error { + if len(req.Expected) == 0 { + return fmt.Errorf("state: TransitionRecoveryChain: empty expected chain") + } + if req.TargetState != EventStatePublished && req.TargetState != EventStateRecovered { + return fmt.Errorf("state: TransitionRecoveryChain: invalid target state %q", req.TargetState) + } + if req.CommitOID == "" { + return fmt.Errorf("state: TransitionRecoveryChain: empty commit oid") + } + if req.RecoveryRef == "" { + return fmt.Errorf("state: TransitionRecoveryChain: protected outcome requires recovery ref") + } + if req.InvalidateShadow && req.TargetState != EventStateRecovered { + return fmt.Errorf("state: TransitionRecoveryChain: shadow invalidation requires recovered outcome") + } + + first := req.Expected[0].Event + if first.BranchRef == "" || first.BranchGeneration < 1 || first.Seq < 1 { + return fmt.Errorf("state: TransitionRecoveryChain: invalid first event provenance") + } + prevSeq := int64(0) + for i, expected := range req.Expected { + ev := expected.Event + if ev.Seq <= prevSeq { + return fmt.Errorf("state: TransitionRecoveryChain: event seqs not strictly increasing at index %d", i) + } + if ev.BranchRef != first.BranchRef || ev.BranchGeneration != first.BranchGeneration { + return fmt.Errorf("state: TransitionRecoveryChain: mixed branch provenance at seq %d", ev.Seq) + } + if ev.BaseHead == "" || ev.Operation == "" || ev.Path == "" || ev.Fidelity == "" { + return fmt.Errorf("state: TransitionRecoveryChain: incomplete provenance at seq %d", ev.Seq) + } + switch ev.State { + case EventStatePending, EventStateBlockedConflict, EventStateFailed: + default: + return fmt.Errorf("state: TransitionRecoveryChain: event %d is not unpublished: %q", ev.Seq, ev.State) + } + for ord, op := range expected.Ops { + if op.EventSeq != ev.Seq || op.Ord != ord || op.Op == "" || op.Path == "" || op.Fidelity == "" { + return fmt.Errorf("state: TransitionRecoveryChain: invalid op provenance seq=%d ord=%d", ev.Seq, ord) + } + } + prevSeq = ev.Seq + } + return nil +} + +func sameRecoveryChain(actual, expected []RecoveryChainEvent) bool { + if len(actual) != len(expected) { + return false + } + for i := range actual { + if actual[i].Event != expected[i].Event || len(actual[i].Ops) != len(expected[i].Ops) { + return false + } + for j := range actual[i].Ops { + if actual[i].Ops[j] != expected[i].Ops[j] { + return false + } + } + } + return true +} + +func clearMatchingRecoveryBreadcrumb( + ctx context.Context, + tx *sql.Tx, + members map[int64]struct{}, + branchRef string, + branchGeneration int64, + targetState string, + commitOID string, + transitionTS float64, +) (bool, error) { + var eventSeq sql.NullInt64 + var pubBranchRef sql.NullString + var pubGeneration sql.NullInt64 + err := tx.QueryRowContext(ctx, ` +SELECT event_seq, branch_ref, branch_generation +FROM publish_state +WHERE id = 1 AND status = 'blocked_conflict'`).Scan( + &eventSeq, &pubBranchRef, &pubGeneration) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("state: load recovery publish breadcrumb: %w", err) + } + if !eventSeq.Valid || !pubBranchRef.Valid || pubBranchRef.String != branchRef || + !pubGeneration.Valid || pubGeneration.Int64 != branchGeneration { + return false, nil + } + if _, ok := members[eventSeq.Int64]; !ok { + return false, nil + } + status := "ok" + targetCommit := sql.NullString{} + if targetState == EventStatePublished { + status = EventStatePublished + targetCommit = sql.NullString{String: commitOID, Valid: true} + } + res, err := tx.ExecContext(ctx, ` +UPDATE publish_state +SET event_seq = NULL, + target_commit_oid = ?, + status = ?, + error = NULL, + updated_ts = ? +WHERE id = 1 + AND status = 'blocked_conflict' + AND event_seq = ? + AND branch_ref = ? + AND branch_generation = ?`, targetCommit, status, transitionTS, + eventSeq.Int64, branchRef, branchGeneration) + if err != nil { + return false, fmt.Errorf("state: clear recovery publish breadcrumb: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("state: clear recovery publish breadcrumb rows: %w", err) + } + return n == 1, nil +} + +// RecoverySnapshotByID loads one durable snapshot by primary key. +func RecoverySnapshotByID(ctx context.Context, d *DB, id int64) (RecoverySnapshot, bool, error) { + if d == nil { + return RecoverySnapshot{}, false, fmt.Errorf("state: RecoverySnapshotByID: nil db") + } + if id < 1 { + return RecoverySnapshot{}, false, fmt.Errorf("state: RecoverySnapshotByID: invalid id %d", id) + } + return queryRecoverySnapshot(ctx, d, `WHERE id = ?`, id) +} + +// RecoverySnapshotByRef loads a snapshot by its hidden proof/recovery Git ref. +func RecoverySnapshotByRef(ctx context.Context, d *DB, recoveryRef string) (RecoverySnapshot, bool, error) { + if d == nil { + return RecoverySnapshot{}, false, fmt.Errorf("state: RecoverySnapshotByRef: nil db") + } + if recoveryRef == "" { + return RecoverySnapshot{}, false, fmt.Errorf("state: RecoverySnapshotByRef: empty recovery ref") + } + return queryRecoverySnapshot(ctx, d, `WHERE recovery_ref = ?`, recoveryRef) +} + +func queryRecoverySnapshot(ctx context.Context, d *DB, where string, arg any) (RecoverySnapshot, bool, error) { + var snapshot RecoverySnapshot + err := d.readSQL().QueryRowContext(ctx, ` +SELECT id, created_ts, outcome, branch_ref, branch_generation, + first_event_seq, last_event_seq, event_count, + commit_oid, recovery_ref, reason +FROM recovery_snapshots `+where, arg).Scan( + &snapshot.ID, &snapshot.CreatedTS, &snapshot.Outcome, + &snapshot.BranchRef, &snapshot.BranchGeneration, + &snapshot.FirstEventSeq, &snapshot.LastEventSeq, &snapshot.EventCount, + &snapshot.CommitOID, &snapshot.RecoveryRef, &snapshot.Reason) + if errors.Is(err, sql.ErrNoRows) { + return RecoverySnapshot{}, false, nil + } + if err != nil { + return RecoverySnapshot{}, false, fmt.Errorf("state: query recovery snapshot: %w", err) + } + return snapshot, true, nil +} + +// RecoverySnapshotEvents returns exact membership in original chain order. +func RecoverySnapshotEvents(ctx context.Context, d *DB, snapshotID int64) ([]RecoverySnapshotEvent, error) { + if d == nil { + return nil, fmt.Errorf("state: RecoverySnapshotEvents: nil db") + } + if snapshotID < 1 { + return nil, fmt.Errorf("state: RecoverySnapshotEvents: invalid snapshot id %d", snapshotID) + } + rows, err := d.readSQL().QueryContext(ctx, ` +SELECT snapshot_id, ord, event_seq +FROM recovery_snapshot_events +WHERE snapshot_id = ? +ORDER BY ord ASC`, snapshotID) + if err != nil { + return nil, fmt.Errorf("state: query recovery snapshot events: %w", err) + } + defer rows.Close() + var events []RecoverySnapshotEvent + for rows.Next() { + var event RecoverySnapshotEvent + if err := rows.Scan(&event.SnapshotID, &event.Ord, &event.EventSeq); err != nil { + return nil, fmt.Errorf("state: scan recovery snapshot event: %w", err) + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iterate recovery snapshot events: %w", err) + } + return events, nil +} diff --git a/internal/state/recovery_test.go b/internal/state/recovery_test.go new file mode 100644 index 00000000..aa45be0c --- /dev/null +++ b/internal/state/recovery_test.go @@ -0,0 +1,508 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "testing" +) + +func TestRecoverySnapshotSchemaV12MigrationRoundTrip(t *testing.T) { + ctx := context.Background() + dbPath := DBPathFromGitDir(filepath.Join(t.TempDir(), ".git")) + d, err := Open(ctx, dbPath) + if err != nil { + t.Fatalf("Open v12 seed: %v", err) + } + seq := appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 7, + "base-before-upgrade", "pending.txt", EventStatePending) + if _, err := AppendDecision(ctx, d, DecisionRecord{ + DecisionTS: 1, + Kind: DecisionKindCaptured, + EventSeq: sql.NullInt64{Int64: seq, Valid: true}, + }); err != nil { + t.Fatalf("AppendDecision before downgrade: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("close v12 seed: %v", err) + } + + // Model an on-disk v11 database: all pre-v12 data remains, while the v12 + // pure-DDL tables are absent and user_version requests the upgrade. + raw, err := sql.Open(driverName, buildDSN(dbPath)) + if err != nil { + t.Fatalf("open raw v11 fixture: %v", err) + } + if _, err := raw.ExecContext(ctx, ` +DROP TABLE recovery_snapshot_events; +DROP TABLE recovery_snapshots; +PRAGMA user_version = 11;`); err != nil { + _ = raw.Close() + t.Fatalf("downgrade fixture to v11: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close raw v11 fixture: %v", err) + } + + d, err = Open(ctx, dbPath) + if err != nil { + t.Fatalf("Open migrated v11 fixture: %v", err) + } + if version, err := d.UserVersion(ctx); err != nil || version != SchemaVersion { + t.Fatalf("migrated user_version=(%d,%v), want (%d,nil)", version, err, SchemaVersion) + } + var eventCount, decisionCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seq).Scan(&eventCount); err != nil { + t.Fatalf("count preserved event: %v", err) + } + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM decision_records WHERE event_seq = ?`, seq).Scan(&decisionCount); err != nil { + t.Fatalf("count preserved decision: %v", err) + } + if eventCount != 1 || decisionCount != 1 { + t.Fatalf("v11 rows not preserved: events=%d decisions=%d", eventCount, decisionCount) + } + + chain, err := LoadUnpublishedRecoveryChain(ctx, d, "refs/heads/main", 7, seq) + if err != nil { + t.Fatalf("LoadUnpublishedRecoveryChain after migration: %v", err) + } + snapshot, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStateRecovered, + CommitOID: "recovery-commit-v12", + RecoveryRef: "refs/acd/recovery/migration-round-trip", + Reason: "migration round trip", + TransitionTS: 12, + }) + if err != nil { + t.Fatalf("TransitionRecoveryChain after migration: %v", err) + } + if err := d.Close(); err != nil { + t.Fatalf("close migrated db: %v", err) + } + + d, err = Open(ctx, dbPath) + if err != nil { + t.Fatalf("reopen migrated db: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + got, ok, err := RecoverySnapshotByRef(ctx, d, "refs/acd/recovery/migration-round-trip") + if err != nil || !ok { + t.Fatalf("RecoverySnapshotByRef after reopen: ok=%v err=%v", ok, err) + } + if got.ID != snapshot.ID || got.EventCount != 1 || got.Outcome != EventStateRecovered { + t.Fatalf("snapshot after reopen=%+v want id=%d count=1 recovered", got, snapshot.ID) + } + members, err := RecoverySnapshotEvents(ctx, d, got.ID) + if err != nil { + t.Fatalf("RecoverySnapshotEvents after reopen: %v", err) + } + if len(members) != 1 || members[0].Ord != 0 || members[0].EventSeq != seq { + t.Fatalf("snapshot membership=%+v want exact seq %d", members, seq) + } +} + +func TestRecoveryChainLoadScopedOrdered(t *testing.T) { + t.Parallel() + d, _ := openTestDB(t) + ctx := context.Background() + + _ = appendRecoveryTestEvent(t, ctx, d, "refs/heads/other", 4, + "other-base", "other-before.txt", EventStatePending) + first := appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 4, + "base-one", "one.txt", EventStateBlockedConflict) + _ = appendRecoveryTestEvent(t, ctx, d, "refs/heads/other", 4, + "other-base", "other-after.txt", EventStateFailed) + second := appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 4, + "base-two", "two.txt", EventStatePending) + _ = appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 5, + "new-generation", "wrong-generation.txt", EventStatePending) + _ = appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 4, + "base-three", "already-recovered.txt", EventStateRecovered) + _ = appendRecoveryTestEvent(t, ctx, d, "refs/heads/main", 4, + "base-four", "already-published.txt", EventStatePublished) + + chain, err := LoadUnpublishedRecoveryChain(ctx, d, "refs/heads/main", 4, first) + if err != nil { + t.Fatalf("LoadUnpublishedRecoveryChain: %v", err) + } + if len(chain) != 2 || chain[0].Event.Seq != first || chain[1].Event.Seq != second { + t.Fatalf("scoped chain=%+v want ordered seqs [%d %d]", chain, first, second) + } + if chain[0].Event.BaseHead != "base-one" || chain[1].Event.BaseHead != "base-two" { + t.Fatalf("per-event base heads lost: %+v", chain) + } + for _, member := range chain { + if len(member.Ops) != 1 || member.Ops[0].EventSeq != member.Event.Seq { + t.Fatalf("ops not loaded exactly for seq %d: %+v", member.Event.Seq, member.Ops) + } + } +} + +func TestRecoveryChainTransitionPublishedAtomic(t *testing.T) { + t.Parallel() + d, chain := seedRecoveryTestChain(t) + ctx := context.Background() + + const proofRef = "refs/acd/recovery/main-g3-1-2/published" + snapshot, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStatePublished, + CommitOID: "external-head-oid", + RecoveryRef: proofRef, + Reason: "composed final state matches stable HEAD", + TransitionTS: 42, + }) + if err != nil { + t.Fatalf("TransitionRecoveryChain published: %v", err) + } + if snapshot.Outcome != EventStatePublished || !snapshot.RecoveryRef.Valid || snapshot.RecoveryRef.String != proofRef || + snapshot.EventCount != len(chain) { + t.Fatalf("published snapshot=%+v", snapshot) + } + assertRecoveryTransitionState(t, d, chain, EventStatePublished, "external-head-oid") + assertRecoveryBookkeeping(t, d, snapshot, chain, DecisionKindRecoveryPublished) +} + +func TestRecoveryChainTransitionRecoveredIsNonBarrier(t *testing.T) { + t.Parallel() + d, chain := seedRecoveryTestChain(t) + ctx := context.Background() + + const recoveryRef = "refs/acd/recovery/main-g3-1-2" + snapshot, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStateRecovered, + CommitOID: "hidden-recovery-commit", + RecoveryRef: recoveryRef, + Reason: "ambiguous chain archived before unblock", + TransitionTS: 43, + }) + if err != nil { + t.Fatalf("TransitionRecoveryChain recovered: %v", err) + } + if !snapshot.RecoveryRef.Valid || snapshot.RecoveryRef.String != recoveryRef { + t.Fatalf("recovered snapshot ref=%+v want %q", snapshot.RecoveryRef, recoveryRef) + } + assertRecoveryTransitionState(t, d, chain, EventStateRecovered, "hidden-recovery-commit") + assertRecoveryBookkeeping(t, d, snapshot, chain, DecisionKindRecoveryArchived) + + third := appendRecoveryTestEvent(t, ctx, d, chain[0].Event.BranchRef, + chain[0].Event.BranchGeneration, "base-three", "after-recovery.txt", EventStatePending) + visible, err := PendingEvents(ctx, d, 0) + if err != nil { + t.Fatalf("PendingEvents after recovered chain: %v", err) + } + if len(visible) != 1 || visible[0].Seq != third { + t.Fatalf("recovered rows formed a replay barrier: visible=%+v want seq=%d", visible, third) + } +} + +func TestRecoveryChainTransitionPreservesUnrelatedBookkeeping(t *testing.T) { + t.Parallel() + d, chain := seedRecoveryTestChain(t) + ctx := context.Background() + + // Recovery may settle only its own planner rows and publish breadcrumb. + // Seed an unrelated planner row and replace the singleton breadcrumb with + // another anchor; both must survive this chain's transition. + unrelatedSeq := appendRecoveryTestEvent(t, ctx, d, "refs/heads/other", 9, + "other-base", "other.txt", EventStatePending) + if _, err := d.SQL().ExecContext(ctx, ` +INSERT INTO planner_state(event_seq, defer_count, last_planned_ts) +VALUES (?, 2, 2)`, unrelatedSeq); err != nil { + t.Fatalf("seed unrelated planner row: %v", err) + } + if _, err := d.SQL().ExecContext(ctx, ` +UPDATE publish_state +SET event_seq = ?, branch_ref = 'refs/heads/other', branch_generation = 9, + status = 'blocked_conflict', error = 'other conflict' +WHERE id = 1`, unrelatedSeq); err != nil { + t.Fatalf("seed unrelated publish breadcrumb: %v", err) + } + + if _, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStateRecovered, + CommitOID: "recovery-with-unrelated-state", + RecoveryRef: "refs/acd/recovery/preserve-unrelated", + Reason: "preserve unrelated bookkeeping", + TransitionTS: 44, + }); err != nil { + t.Fatalf("TransitionRecoveryChain: %v", err) + } + var plannerCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM planner_state WHERE event_seq = ?`, unrelatedSeq).Scan(&plannerCount); err != nil { + t.Fatalf("count unrelated planner row: %v", err) + } + var pubSeq int64 + var pubRef, pubStatus string + if err := d.SQL().QueryRowContext(ctx, ` +SELECT event_seq, branch_ref, status FROM publish_state WHERE id = 1`).Scan( + &pubSeq, &pubRef, &pubStatus); err != nil { + t.Fatalf("load unrelated publish breadcrumb: %v", err) + } + if plannerCount != 1 || pubSeq != unrelatedSeq || pubRef != "refs/heads/other" || pubStatus != "blocked_conflict" { + t.Fatalf("unrelated bookkeeping changed: planner=%d pub=(%d,%q,%q)", + plannerCount, pubSeq, pubRef, pubStatus) + } + for _, key := range []string{"last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error"} { + if value, ok, err := MetaGet(ctx, d, key); err != nil || !ok || value != "stale" { + t.Fatalf("unrelated meta %s=(%q,%v,%v), want stale,true,nil", key, value, ok, err) + } + } +} + +func TestRecoveryChainRaceRefusesChangedSuffix(t *testing.T) { + t.Parallel() + d, chain := seedRecoveryTestChain(t) + ctx := context.Background() + + extra := appendRecoveryTestEvent(t, ctx, d, chain[0].Event.BranchRef, + chain[0].Event.BranchGeneration, "racing-base", "racing.txt", EventStatePending) + _, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStateRecovered, + CommitOID: "must-not-land", + RecoveryRef: "refs/acd/recovery/stale-expected-chain", + TransitionTS: 50, + }) + if !errors.Is(err, ErrRecoveryChainChanged) { + t.Fatalf("TransitionRecoveryChain race err=%v want ErrRecoveryChainChanged", err) + } + assertRecoveryNoPartialMutation(t, d, chain, extra) +} + +func TestRecoveryChainTransitionRollsBackOnPartialFailure(t *testing.T) { + t.Parallel() + d, chain := seedRecoveryTestChain(t) + ctx := context.Background() + secondSeq := chain[1].Event.Seq + if _, err := d.SQL().ExecContext(ctx, fmt.Sprintf(` +CREATE TRIGGER recovery_abort_second +BEFORE UPDATE OF state ON capture_events +WHEN OLD.seq = %d +BEGIN + SELECT RAISE(ABORT, 'synthetic recovery failure'); +END`, secondSeq)); err != nil { + t.Fatalf("create rollback trigger: %v", err) + } + + _, err := TransitionRecoveryChain(ctx, d, RecoveryChainTransition{ + Expected: chain, + TargetState: EventStateRecovered, + CommitOID: "must-roll-back", + RecoveryRef: "refs/acd/recovery/rollback", + TransitionTS: 51, + }) + if err == nil { + t.Fatalf("TransitionRecoveryChain unexpectedly succeeded through abort trigger") + } + assertRecoveryNoPartialMutation(t, d, chain, 0) +} + +func seedRecoveryTestChain(t *testing.T) (*DB, []RecoveryChainEvent) { + t.Helper() + d, _ := openTestDB(t) + ctx := context.Background() + const branchRef = "refs/heads/main" + const generation = int64(3) + + first := appendRecoveryTestEvent(t, ctx, d, branchRef, generation, + "base-one", "first.txt", EventStatePending) + if err := MarkEventBlocked(ctx, d, first, "before-state mismatch", 10, + sqlNullStr(branchRef), sql.NullInt64{Int64: generation, Valid: true}, + sqlNullStr("base-one")); err != nil { + t.Fatalf("MarkEventBlocked: %v", err) + } + second := appendRecoveryTestEvent(t, ctx, d, branchRef, generation, + "base-two", "second.txt", EventStatePending) + for _, seq := range []int64{first, second} { + if _, err := d.SQL().ExecContext(ctx, ` +INSERT INTO planner_state(event_seq, defer_count, last_planned_ts) +VALUES (?, 1, 1)`, seq); err != nil { + t.Fatalf("seed planner state seq=%d: %v", seq, err) + } + } + for _, key := range []string{"last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error"} { + if err := MetaSet(ctx, d, key, "stale"); err != nil { + t.Fatalf("seed meta %s: %v", key, err) + } + } + chain, err := LoadUnpublishedRecoveryChain(ctx, d, branchRef, generation, first) + if err != nil { + t.Fatalf("LoadUnpublishedRecoveryChain fixture: %v", err) + } + if len(chain) != 2 || chain[0].Event.Seq != first || chain[1].Event.Seq != second { + t.Fatalf("fixture chain=%+v", chain) + } + return d, chain +} + +func appendRecoveryTestEvent(t *testing.T, ctx context.Context, d *DB, branchRef string, generation int64, baseHead, path, eventState string) int64 { + t.Helper() + seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ + BranchRef: branchRef, + BranchGeneration: generation, + BaseHead: baseHead, + Operation: "modify", + Path: path, + Fidelity: "exact", + State: eventState, + }, []CaptureOp{{ + Op: "modify", + Path: path, + BeforeOID: sqlNullStr("before-" + path), + BeforeMode: sqlNullStr("100644"), + AfterOID: sqlNullStr("after-" + path), + AfterMode: sqlNullStr("100644"), + Fidelity: "exact", + }}) + if err != nil { + t.Fatalf("AppendCaptureEvent %s: %v", path, err) + } + return seq +} + +func assertRecoveryTransitionState(t *testing.T, d *DB, original []RecoveryChainEvent, wantState, wantCommit string) { + t.Helper() + ctx := context.Background() + for _, expected := range original { + var stateName, commitOID, branchRef, baseHead, operation, path, fidelity string + var generation int64 + var oldPath sql.NullString + if err := d.SQL().QueryRowContext(ctx, ` +SELECT state, commit_oid, branch_ref, branch_generation, base_head, + operation, path, old_path, fidelity +FROM capture_events WHERE seq = ?`, expected.Event.Seq).Scan( + &stateName, &commitOID, &branchRef, &generation, &baseHead, + &operation, &path, &oldPath, &fidelity); err != nil { + t.Fatalf("load transitioned seq=%d: %v", expected.Event.Seq, err) + } + if stateName != wantState || commitOID != wantCommit { + t.Fatalf("seq=%d lifecycle=(%q,%q) want (%q,%q)", + expected.Event.Seq, stateName, commitOID, wantState, wantCommit) + } + if branchRef != expected.Event.BranchRef || generation != expected.Event.BranchGeneration || + baseHead != expected.Event.BaseHead || operation != expected.Event.Operation || + path != expected.Event.Path || oldPath != expected.Event.OldPath || fidelity != expected.Event.Fidelity { + t.Fatalf("seq=%d provenance changed: got branch=%q gen=%d base=%q op=%q path=%q old=%+v fidelity=%q want event=%+v", + expected.Event.Seq, branchRef, generation, baseHead, operation, path, + oldPath, fidelity, expected.Event) + } + ops, err := LoadCaptureOps(ctx, d, expected.Event.Seq) + if err != nil { + t.Fatalf("LoadCaptureOps seq=%d: %v", expected.Event.Seq, err) + } + if !sameRecoveryChain( + []RecoveryChainEvent{{Event: expected.Event, Ops: ops}}, + []RecoveryChainEvent{{Event: expected.Event, Ops: expected.Ops}}, + ) { + t.Fatalf("seq=%d capture ops changed: got=%+v want=%+v", expected.Event.Seq, ops, expected.Ops) + } + } +} + +func assertRecoveryBookkeeping(t *testing.T, d *DB, snapshot RecoverySnapshot, chain []RecoveryChainEvent, decisionKind string) { + t.Helper() + ctx := context.Background() + loaded, ok, err := RecoverySnapshotByID(ctx, d, snapshot.ID) + if err != nil || !ok || loaded != snapshot { + t.Fatalf("RecoverySnapshotByID=(%+v,%v,%v) want (%+v,true,nil)", loaded, ok, err, snapshot) + } + members, err := RecoverySnapshotEvents(ctx, d, snapshot.ID) + if err != nil { + t.Fatalf("RecoverySnapshotEvents: %v", err) + } + if len(members) != len(chain) { + t.Fatalf("membership count=%d want %d", len(members), len(chain)) + } + for i := range chain { + if members[i].Ord != i || members[i].EventSeq != chain[i].Event.Seq { + t.Fatalf("membership[%d]=%+v want seq=%d", i, members[i], chain[i].Event.Seq) + } + } + var plannerCount, decisionCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM planner_state`).Scan(&plannerCount); err != nil { + t.Fatalf("count planner state: %v", err) + } + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM decision_records WHERE kind = ?`, decisionKind).Scan(&decisionCount); err != nil { + t.Fatalf("count recovery decisions: %v", err) + } + if plannerCount != 0 || decisionCount != len(chain) { + t.Fatalf("bookkeeping planner=%d decisions=%d want 0,%d", plannerCount, decisionCount, len(chain)) + } + var status string + var eventSeq sql.NullInt64 + if err := d.SQL().QueryRowContext(ctx, + `SELECT status, event_seq FROM publish_state WHERE id = 1`).Scan(&status, &eventSeq); err != nil { + t.Fatalf("load publish_state: %v", err) + } + wantStatus := "ok" + if snapshot.Outcome == EventStatePublished { + wantStatus = EventStatePublished + } + if status != wantStatus || eventSeq.Valid { + t.Fatalf("publish breadcrumb=(%q,%+v) want %s,NULL", status, eventSeq, wantStatus) + } + for _, key := range []string{"last_replay_conflict", "last_replay_conflict_legacy", "last_replay_error"} { + if _, ok, err := MetaGet(ctx, d, key); err != nil || ok { + t.Fatalf("meta %s after transition: ok=%v err=%v want absent", key, ok, err) + } + } +} + +func assertRecoveryNoPartialMutation(t *testing.T, d *DB, chain []RecoveryChainEvent, extraSeq int64) { + t.Helper() + ctx := context.Background() + for _, expected := range chain { + var gotState string + if err := d.SQL().QueryRowContext(ctx, + `SELECT state FROM capture_events WHERE seq = ?`, expected.Event.Seq).Scan(&gotState); err != nil { + t.Fatalf("load unchanged seq=%d: %v", expected.Event.Seq, err) + } + if gotState != expected.Event.State { + t.Fatalf("seq=%d state=%q want original %q", expected.Event.Seq, gotState, expected.Event.State) + } + } + if extraSeq > 0 { + var gotState string + if err := d.SQL().QueryRowContext(ctx, + `SELECT state FROM capture_events WHERE seq = ?`, extraSeq).Scan(&gotState); err != nil { + t.Fatalf("load racing seq=%d: %v", extraSeq, err) + } + if gotState != EventStatePending { + t.Fatalf("racing seq=%d state=%q want pending", extraSeq, gotState) + } + } + var snapshots, memberships, decisions, planner int + for query, dest := range map[string]*int{ + `SELECT COUNT(*) FROM recovery_snapshots`: &snapshots, + `SELECT COUNT(*) FROM recovery_snapshot_events`: &memberships, + `SELECT COUNT(*) FROM decision_records`: &decisions, + `SELECT COUNT(*) FROM planner_state`: &planner, + } { + if err := d.SQL().QueryRowContext(ctx, query).Scan(dest); err != nil { + t.Fatalf("rollback count %q: %v", query, err) + } + } + if snapshots != 0 || memberships != 0 || decisions != 0 || planner != len(chain) { + t.Fatalf("partial recovery write: snapshots=%d memberships=%d decisions=%d planner=%d want 0,0,0,%d", + snapshots, memberships, decisions, planner, len(chain)) + } + var pubStatus string + if err := d.SQL().QueryRowContext(ctx, + `SELECT status FROM publish_state WHERE id = 1`).Scan(&pubStatus); err != nil { + t.Fatalf("load unchanged publish_state: %v", err) + } + if pubStatus != "blocked_conflict" { + t.Fatalf("publish_state=%q want blocked_conflict after rollback", pubStatus) + } +} diff --git a/internal/state/schema.go b/internal/state/schema.go index ee455341..b99304e0 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -21,8 +21,11 @@ package state // for bounded intent-planner deferrals; v8 adds reusable rewrite plan storage; // v9 adds structured rewrite proposal failure storage; v10 preserves the // commit-message format used to validate rewrite plans; v11 adds durable -// intent planner-window summaries for captured-vs-offered observability. -const SchemaVersion = 11 +// intent planner-window summaries for captured-vs-offered observability; v12 +// adds immutable recovery snapshots with ordered, exact event membership; v13 +// adds an index for published-prefix retention while unresolved same-base +// recovery chains remain in the ledger. +const SchemaVersion = 13 // schemaDDL is the canonical per-repo state.db schema (§6.1). // @@ -100,6 +103,12 @@ CREATE INDEX IF NOT EXISTS idx_capture_events_branch_generation_seq_state CREATE INDEX IF NOT EXISTS idx_capture_events_barrier ON capture_events(branch_ref, branch_generation, state, seq); +-- v13: recovery-prefix pruning correlates an aged published row with later +-- unresolved rows from the same exact immutable base. Keep that minute-level +-- maintenance query bounded even when the pending ledger reaches its cap. +CREATE INDEX IF NOT EXISTS idx_capture_events_recovery_prefix + ON capture_events(branch_ref, branch_generation, base_head, state, seq); + CREATE TABLE IF NOT EXISTS capture_ops( event_seq INTEGER NOT NULL, ord INTEGER NOT NULL, @@ -249,6 +258,46 @@ CREATE INDEX IF NOT EXISTS idx_decision_records_event_seq_id CREATE INDEX IF NOT EXISTS idx_decision_records_commit_oid_id ON decision_records(commit_oid, id); +-- v12: one durable record for an all-or-none unpublished-chain transition. +-- capture event provenance remains on capture_events; this table records the +-- terminal recovery outcome and the commit that makes the chain reachable. +CREATE TABLE IF NOT EXISTS recovery_snapshots( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_ts REAL NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('published', 'recovered')), + branch_ref TEXT NOT NULL, + branch_generation INTEGER NOT NULL, + first_event_seq INTEGER NOT NULL, + last_event_seq INTEGER NOT NULL, + event_count INTEGER NOT NULL CHECK (event_count > 0), + commit_oid TEXT NOT NULL, + recovery_ref TEXT NOT NULL CHECK (recovery_ref <> ''), + reason TEXT, + CHECK (first_event_seq > 0 AND last_event_seq >= first_event_seq), + CHECK (recovery_ref <> '') +); + +CREATE INDEX IF NOT EXISTS idx_recovery_snapshots_anchor_id + ON recovery_snapshots(branch_ref, branch_generation, id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_recovery_snapshots_recovery_ref + ON recovery_snapshots(recovery_ref) + WHERE recovery_ref IS NOT NULL; + +-- event_seq is deliberately denormalized without a capture_events foreign +-- key, matching decision_records v6: snapshot membership must survive any +-- future pruning of terminal capture rows. ord preserves exact chain order. +CREATE TABLE IF NOT EXISTS recovery_snapshot_events( + snapshot_id INTEGER NOT NULL, + ord INTEGER NOT NULL CHECK (ord >= 0), + event_seq INTEGER NOT NULL, + PRIMARY KEY (snapshot_id, ord), + FOREIGN KEY (snapshot_id) REFERENCES recovery_snapshots(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_recovery_snapshot_events_event_seq + ON recovery_snapshot_events(event_seq); + CREATE TABLE IF NOT EXISTS publish_state( id INTEGER PRIMARY KEY CHECK (id = 1), event_seq INTEGER, diff --git a/internal/state/shadow.go b/internal/state/shadow.go index d33eaa8a..13dd7fef 100644 --- a/internal/state/shadow.go +++ b/internal/state/shadow.go @@ -161,6 +161,33 @@ func DeleteShadowGeneration(ctx context.Context, d *DB, branchRef string, gen in return int(n), nil } +// InvalidateShadowGeneration atomically removes one exact shadow pair and its +// bootstrap marker. Branch-transition rollback uses this when a token changes +// after a prospective reseed: leaving either rows or marker behind could make +// the next tick accept a shadow built from the wrong HEAD. +func InvalidateShadowGeneration(ctx context.Context, d *DB, branchRef string, gen int64, markerKey string) error { + if d == nil || branchRef == "" || gen < 1 || markerKey == "" { + return fmt.Errorf("state: InvalidateShadowGeneration: invalid selector") + } + tx, err := d.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("state: invalidate shadow generation: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, + `DELETE FROM shadow_paths WHERE branch_ref = ? AND branch_generation = ?`, + branchRef, gen); err != nil { + return fmt.Errorf("state: invalidate shadow generation rows: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM daemon_meta WHERE key = ?`, markerKey); err != nil { + return fmt.Errorf("state: invalidate shadow generation marker: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("state: invalidate shadow generation: commit: %w", err) + } + return nil +} + // PruneShadowGenerations deletes shadow rows for branch generations older than // the configured retention window behind currentGeneration. retainBehind is the // number of prior generations to keep in addition to the current generation. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 42790f38..ab958ad4 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -84,8 +84,8 @@ func TestOpenCreatesSchemaAndPragmas(t *testing.T) { "daemon_state", "daemon_clients", "shadow_paths", "capture_events", "capture_ops", "planner_state", "intent_planner_windows", "intent_planner_window_events", "rewrite_plans", "rewrite_plan_commits", - "flush_requests", "decision_records", "publish_state", "daemon_meta", - "daily_rollups", + "flush_requests", "decision_records", "recovery_snapshots", + "recovery_snapshot_events", "publish_state", "daemon_meta", "daily_rollups", } for _, table := range tables { var name string @@ -848,21 +848,22 @@ func TestPendingEventsStopsAfterTerminalPredecessor(t *testing.T) { } } -func TestDeleteStaleUnpublishedForBranchGeneration(t *testing.T) { +func TestPruneTerminalEventsBeforePreservesActiveBarriers(t *testing.T) { t.Parallel() d, _ := openTestDB(t) ctx := context.Background() - appendEvent := func(branch string, generation int64, baseHead, stateName, path string) int64 { + appendEvent := func(branch string, generation int64, path string, capturedTS float64, state string) int64 { t.Helper() seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ BranchRef: branch, BranchGeneration: generation, - BaseHead: baseHead, + BaseHead: "deadbeef", Operation: "modify", Path: path, Fidelity: "exact", - State: stateName, + CapturedTS: capturedTS, + State: state, }, []CaptureOp{{Op: "modify", Path: path, Fidelity: "exact"}}) if err != nil { t.Fatalf("append %s: %v", path, err) @@ -870,19 +871,18 @@ func TestDeleteStaleUnpublishedForBranchGeneration(t *testing.T) { return seq } - stalePending := appendEvent("refs/heads/main", 7, "old", EventStatePending, "stale-pending.txt") - staleBlocked := appendEvent("refs/heads/main", 7, "old", EventStateBlockedConflict, "stale-blocked.txt") - currentPending := appendEvent("refs/heads/main", 7, "new", EventStatePending, "current-pending.txt") - stalePublished := appendEvent("refs/heads/main", 7, "old", EventStatePublished, "stale-published.txt") - otherBranch := appendEvent("refs/heads/feature", 7, "old", EventStatePending, "other-branch.txt") - otherGeneration := appendEvent("refs/heads/main", 8, "old", EventStatePending, "other-generation.txt") - - n, err := DeleteStaleUnpublishedForBranchGeneration(ctx, d, "refs/heads/main", 7, "new") + prunedBlocked := appendEvent("refs/heads/main", 1, "old-blocked.txt", 10, EventStateBlockedConflict) + prunedFailed := appendEvent("refs/heads/failed", 1, "old-failed.txt", 11, EventStateFailed) + unprotected := appendEvent("refs/heads/unprotected", 1, "old-unprotected.txt", 11, EventStateFailed) + barrier := appendEvent("refs/heads/barrier", 1, "barrier.txt", 12, EventStateBlockedConflict) + pendingBehindBarrier := appendEvent("refs/heads/barrier", 1, "pending.txt", 13, EventStatePending) + freshFailed := appendEvent("refs/heads/fresh", 1, "fresh-failed.txt", 200, EventStateFailed) + n, err := PruneTerminalEventsBefore(ctx, d, 100) if err != nil { - t.Fatalf("DeleteStaleUnpublishedForBranchGeneration: %v", err) + t.Fatalf("PruneTerminalEventsBefore: %v", err) } - if n != 2 { - t.Fatalf("deleted=%d want 2", n) + if n != 0 { + t.Fatalf("pruned=%d want 0; unresolved terminal rows are never pruned", n) } rows, err := d.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) @@ -901,179 +901,150 @@ func TestDeleteStaleUnpublishedForBranchGeneration(t *testing.T) { if err := rows.Err(); err != nil { t.Fatalf("iterate remaining: %v", err) } - for _, seq := range []int64{stalePending, staleBlocked} { - if remaining[seq] { - t.Fatalf("stale unpublished seq %d survived; remaining=%v", seq, remaining) - } - } - for _, seq := range []int64{currentPending, stalePublished, otherBranch, otherGeneration} { + for _, seq := range []int64{prunedBlocked, prunedFailed, unprotected, barrier, pendingBehindBarrier, freshFailed} { if !remaining[seq] { t.Fatalf("seq %d should remain; remaining=%v", seq, remaining) } } + + var opCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_ops WHERE event_seq IN (?, ?)`, + prunedBlocked, prunedFailed).Scan(&opCount); err != nil { + t.Fatalf("count pruned ops: %v", err) + } + if opCount != 2 { + t.Fatalf("capture_ops for terminal rows=%d want 2", opCount) + } } -// TestDeletePendingForBranchGeneration pins the branch-scoped pending wipe -// used by `acd commit-all` to clear stale rows before forcing a shadow reseed. -// Must scope by (branch_ref, branch_generation) AND must never delete terminal -// rows (published, failed, blocked_conflict). -func TestDeletePendingForBranchGeneration(t *testing.T) { +func TestPrunePublishedEventsPreservesSnapshotMembers(t *testing.T) { t.Parallel() d, _ := openTestDB(t) ctx := context.Background() - - appendEvent := func(branch string, generation int64, stateName, path string) int64 { - t.Helper() + appendPublished := func(path string) int64 { seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ - BranchRef: branch, - BranchGeneration: generation, - BaseHead: "deadbeef", - Operation: "modify", - Path: path, - Fidelity: "exact", - State: stateName, - }, []CaptureOp{{Op: "modify", Path: path, Fidelity: "exact"}}) + BranchRef: "refs/heads/main", BranchGeneration: 1, + BaseHead: "deadbeef", Operation: "create", Path: path, + Fidelity: "full", CapturedTS: 10, State: EventStatePublished, + }, []CaptureOp{{Op: "create", Path: path, Fidelity: "full"}}) if err != nil { - t.Fatalf("append %s: %v", path, err) + t.Fatalf("append published %s: %v", path, err) } return seq } - - // Target rows (branch=main, gen=1, state=pending) — must be deleted. - targetA := appendEvent("refs/heads/main", 1, EventStatePending, "target-a.txt") - targetB := appendEvent("refs/heads/main", 1, EventStatePending, "target-b.txt") - - // Same branch+gen but terminal — must survive. - keepPublished := appendEvent("refs/heads/main", 1, EventStatePublished, "keep-published.txt") - keepFailed := appendEvent("refs/heads/main", 1, EventStateFailed, "keep-failed.txt") - keepBlocked := appendEvent("refs/heads/main", 1, EventStateBlockedConflict, "keep-blocked.txt") - - // Different branch (same gen number) — must survive. - otherBranch := appendEvent("refs/heads/feature", 1, EventStatePending, "other-branch.txt") - // Different gen (same branch) — must survive. - otherGen := appendEvent("refs/heads/main", 2, EventStatePending, "other-gen.txt") - - n, err := DeletePendingForBranchGeneration(ctx, d, "refs/heads/main", 1) + protected := appendPublished("protected-published.txt") + unprotected := appendPublished("unprotected-published.txt") + res, err := d.SQL().ExecContext(ctx, ` +INSERT INTO recovery_snapshots( + created_ts, outcome, branch_ref, branch_generation, + first_event_seq, last_event_seq, event_count, + commit_oid, recovery_ref, reason +) VALUES (20, ?, 'refs/heads/main', 1, ?, ?, 1, 'commit', + 'refs/acd/recovery/prune-published', 'retention test')`, + EventStatePublished, protected, protected) if err != nil { - t.Fatalf("DeletePendingForBranchGeneration: %v", err) + t.Fatalf("insert published snapshot: %v", err) } - if n != 2 { - t.Fatalf("deleted=%d want 2 (only the two target pending rows)", n) + snapshotID, err := res.LastInsertId() + if err != nil { + t.Fatalf("snapshot id: %v", err) + } + if _, err := d.SQL().ExecContext(ctx, + `INSERT INTO recovery_snapshot_events(snapshot_id, ord, event_seq) VALUES (?, 0, ?)`, + snapshotID, protected); err != nil { + t.Fatalf("insert published member: %v", err) } - // Verify the survivors and casualties. - rows, err := d.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) + n, err := PrunePublishedEventsBefore(ctx, d, 100) if err != nil { - t.Fatalf("query remaining: %v", err) + t.Fatalf("PrunePublishedEventsBefore: %v", err) } - defer rows.Close() - remaining := map[int64]bool{} - for rows.Next() { - var seq int64 - if err := rows.Scan(&seq); err != nil { - t.Fatalf("scan: %v", err) - } - remaining[seq] = true - } - if err := rows.Err(); err != nil { - t.Fatalf("iter: %v", err) + if n != 1 { + t.Fatalf("ordinary published pruned=%d want 1", n) } - for _, seq := range []int64{targetA, targetB} { - if remaining[seq] { - t.Fatalf("target seq %d survived; remaining=%v", seq, remaining) - } + var protectedCount, unprotectedCount int + if err := d.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, protected).Scan(&protectedCount); err != nil { + t.Fatalf("count protected published: %v", err) } - for _, seq := range []int64{keepPublished, keepFailed, keepBlocked, otherBranch, otherGen} { - if !remaining[seq] { - t.Fatalf("seq %d wrongly deleted; remaining=%v", seq, remaining) - } + if err := d.SQL().QueryRowContext(ctx, `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, unprotected).Scan(&unprotectedCount); err != nil { + t.Fatalf("count ordinary published: %v", err) } - - // Empty branch_ref must error rather than nuking everything. - if _, err := DeletePendingForBranchGeneration(ctx, d, "", 1); err == nil { - t.Fatalf("empty branch_ref must error") + if protectedCount != 1 || unprotectedCount != 0 { + t.Fatalf("counts protected=%d ordinary=%d", protectedCount, unprotectedCount) } - // Idempotent: a second call deletes zero. - n2, err := DeletePendingForBranchGeneration(ctx, d, "refs/heads/main", 1) + n, err = PruneRecoverySnapshotEventsBefore(ctx, d, snapshotID, 100) if err != nil { - t.Fatalf("second call: %v", err) + t.Fatalf("PruneRecoverySnapshotEventsBefore: %v", err) } - if n2 != 0 { - t.Fatalf("second call deleted=%d want 0", n2) + if n != 1 { + t.Fatalf("protected published pruned=%d want 1", n) } } -func TestPruneTerminalEventsBeforePreservesActiveBarriers(t *testing.T) { - t.Parallel() - d, _ := openTestDB(t) - ctx := context.Background() - - appendEvent := func(branch string, generation int64, path string, capturedTS float64, state string) int64 { - t.Helper() - seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ - BranchRef: branch, - BranchGeneration: generation, - BaseHead: "deadbeef", - Operation: "modify", - Path: path, - Fidelity: "exact", - CapturedTS: capturedTS, - State: state, - }, []CaptureOp{{Op: "modify", Path: path, Fidelity: "exact"}}) - if err != nil { - t.Fatalf("append %s: %v", path, err) - } - return seq - } - - prunedBlocked := appendEvent("refs/heads/main", 1, "old-blocked.txt", 10, EventStateBlockedConflict) - prunedFailed := appendEvent("refs/heads/failed", 1, "old-failed.txt", 11, EventStateFailed) - barrier := appendEvent("refs/heads/barrier", 1, "barrier.txt", 12, EventStateBlockedConflict) - pendingBehindBarrier := appendEvent("refs/heads/barrier", 1, "pending.txt", 13, EventStatePending) - freshFailed := appendEvent("refs/heads/fresh", 1, "fresh-failed.txt", 200, EventStateFailed) - - n, err := PruneTerminalEventsBefore(ctx, d, 100) - if err != nil { - t.Fatalf("PruneTerminalEventsBefore: %v", err) - } - if n != 2 { - t.Fatalf("pruned=%d want 2", n) - } +func TestPrunePublishedEventsPreservesRecoveryMaterializationPrefix(t *testing.T) { + for _, unpublishedState := range []string{ + EventStatePending, + EventStateBlockedConflict, + EventStateFailed, + } { + t.Run(unpublishedState, func(t *testing.T) { + t.Parallel() + d, _ := openTestDB(t) + ctx := context.Background() + appendEvent := func(baseHead, path, eventState string) int64 { + t.Helper() + seq, err := AppendCaptureEvent(ctx, d, CaptureEvent{ + BranchRef: "refs/heads/main", BranchGeneration: 7, + BaseHead: baseHead, Operation: "modify", Path: path, + Fidelity: "exact", CapturedTS: 10, State: eventState, + }, []CaptureOp{{ + Op: "modify", Path: path, + BeforeOID: sqlNullStr("before-" + path), BeforeMode: sqlNullStr("100644"), + AfterOID: sqlNullStr("after-" + path), AfterMode: sqlNullStr("100644"), + Fidelity: "exact", + }}) + if err != nil { + t.Fatalf("AppendCaptureEvent %s: %v", path, err) + } + return seq + } - rows, err := d.SQL().QueryContext(ctx, `SELECT seq FROM capture_events ORDER BY seq ASC`) - if err != nil { - t.Fatalf("query remaining: %v", err) - } - defer rows.Close() - remaining := map[int64]bool{} - for rows.Next() { - var seq int64 - if err := rows.Scan(&seq); err != nil { - t.Fatalf("scan remaining: %v", err) - } - remaining[seq] = true - } - if err := rows.Err(); err != nil { - t.Fatalf("iterate remaining: %v", err) - } - if remaining[prunedBlocked] || remaining[prunedFailed] { - t.Fatalf("stale terminal rows survived: remaining=%v", remaining) - } - for _, seq := range []int64{barrier, pendingBehindBarrier, freshFailed} { - if !remaining[seq] { - t.Fatalf("seq %d should remain; remaining=%v", seq, remaining) - } - } + prefix := appendEvent("shared-base", "prefix.txt", EventStatePublished) + ordinary := appendEvent("other-base", "ordinary.txt", EventStatePublished) + suffix := appendEvent("shared-base", "suffix.txt", unpublishedState) - var opCount int - if err := d.SQL().QueryRowContext(ctx, - `SELECT COUNT(*) FROM capture_ops WHERE event_seq IN (?, ?)`, - prunedBlocked, prunedFailed).Scan(&opCount); err != nil { - t.Fatalf("count pruned ops: %v", err) - } - if opCount != 0 { - t.Fatalf("capture_ops for pruned rows=%d want 0", opCount) + n, err := PrunePublishedEventsBefore(ctx, d, 100) + if err != nil { + t.Fatalf("PrunePublishedEventsBefore: %v", err) + } + if n != 1 { + t.Fatalf("pruned=%d want only ordinary published row", n) + } + for _, seq := range []int64{prefix, suffix} { + var eventCount, opCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, seq).Scan(&eventCount); err != nil { + t.Fatalf("count event seq=%d: %v", seq, err) + } + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_ops WHERE event_seq = ?`, seq).Scan(&opCount); err != nil { + t.Fatalf("count ops seq=%d: %v", seq, err) + } + if eventCount != 1 || opCount != 1 { + t.Fatalf("recovery context seq=%d event=%d ops=%d want 1,1", seq, eventCount, opCount) + } + } + var ordinaryCount int + if err := d.SQL().QueryRowContext(ctx, + `SELECT COUNT(*) FROM capture_events WHERE seq = ?`, ordinary).Scan(&ordinaryCount); err != nil { + t.Fatalf("count ordinary published: %v", err) + } + if ordinaryCount != 0 { + t.Fatalf("ordinary published row retained: count=%d", ordinaryCount) + } + }) } } @@ -1522,6 +1493,90 @@ WHERE type = 'index' AND name = 'idx_capture_events_barrier' AND tbl_name = 'cap } } +func TestState_RecoveryPrefixPruneIndexMigratesFromV12(t *testing.T) { + t.Parallel() + ctx := context.Background() + gitDir := filepath.Join(t.TempDir(), ".git") + dbPath := DBPathFromGitDir(gitDir) + + fresh, err := Open(ctx, dbPath) + if err != nil { + t.Fatalf("Open fresh: %v", err) + } + if err := fresh.Close(); err != nil { + t.Fatalf("close fresh: %v", err) + } + + raw, err := sql.Open(driverName, buildDSN(dbPath)) + if err != nil { + t.Fatalf("sql.Open raw: %v", err) + } + if _, err := raw.ExecContext(ctx, ` +DROP INDEX idx_capture_events_recovery_prefix; +PRAGMA user_version = 12;`); err != nil { + _ = raw.Close() + t.Fatalf("seed v12 schema: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close raw: %v", err) + } + + d, err := Open(ctx, dbPath) + if err != nil { + t.Fatalf("Open migrated v12: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + uv, err := d.UserVersion(ctx) + if err != nil { + t.Fatalf("UserVersion: %v", err) + } + if uv != SchemaVersion { + t.Fatalf("user_version=%d want %d", uv, SchemaVersion) + } + + var name string + if err := d.SQL().QueryRowContext(ctx, ` +SELECT name FROM sqlite_master +WHERE type = 'index' + AND name = 'idx_capture_events_recovery_prefix' + AND tbl_name = 'capture_events'`).Scan(&name); err != nil { + t.Fatalf("idx_capture_events_recovery_prefix missing after v12 migration: %v", err) + } + + rows, err := d.SQL().QueryContext(ctx, ` +EXPLAIN QUERY PLAN +SELECT 1 +FROM capture_events +WHERE branch_ref = ? + AND branch_generation = ? + AND base_head = ? + AND seq > ? + AND state IN (?, ?, ?)`, + "refs/heads/main", 1, "base", 1, + EventStatePending, EventStateBlockedConflict, EventStateFailed, + ) + if err != nil { + t.Fatalf("explain recovery-prefix lookup: %v", err) + } + defer rows.Close() + var plan strings.Builder + for rows.Next() { + var id, parent, notused int + var detail string + if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil { + t.Fatalf("scan recovery-prefix plan: %v", err) + } + plan.WriteString(detail) + plan.WriteByte('\n') + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate recovery-prefix plan: %v", err) + } + if !strings.Contains(plan.String(), "idx_capture_events_recovery_prefix") { + t.Fatalf("query plan did not select recovery-prefix index:\n%s", plan.String()) + } +} + // BenchmarkPendingEvents seeds 10k pending rows + a couple of terminal // barriers in older generations and measures PendingEvents throughput. The // benchmark protects against future regressions where someone drops the diff --git a/templates/claude-code/README.md b/templates/claude-code/README.md index dbf06f4f..28cd0f84 100644 --- a/templates/claude-code/README.md +++ b/templates/claude-code/README.md @@ -13,12 +13,9 @@ 4. Restart Claude Code -If you previously installed a snippet that predates the v2026-05-08 release, -re-run `acd setup claude-code` and replace the `hooks` block in -`~/.claude/settings.json` so the new self-heal hooks take effect. Run -`acd doctor` to check whether your installed snippet is current; it warns when -active hooks are missing `acd start` or `acd wake` and shows the remediation -command. +Run `acd doctor` after setup. It compares the installed hook with the current +template and tells you when to regenerate the ACD block with +`acd setup claude-code`. ## Verify @@ -33,8 +30,8 @@ with `acd stop --session-id`. `acd wake` refreshes the heartbeat and nudges capture/replay, but it does not bypass `ACD_INTENT_MIN_PENDING` or `ACD_INTENT_MAX_PENDING_AGE`. The `Stop` -hook uses `acd flush --logical` for the prompt-end commit boundary; re-run -`acd setup claude-code` if your installed snippet predates that hook. +hook uses `acd flush --logical` for the prompt-end commit boundary. `acd doctor` +reports template drift when that wiring is missing. Repo autodiscovery is enabled by default. If you disable it with `repo_lifecycle.autodiscovery` in `~/.config/acd/config.json` or with diff --git a/templates/codex/README.md b/templates/codex/README.md index 3feafb93..213b2ba8 100644 --- a/templates/codex/README.md +++ b/templates/codex/README.md @@ -9,43 +9,36 @@ [features] hooks = true ~~~ - Do not set `hooks = false`; that disables Codex lifecycle hooks. The older - `codex_hooks` key still works as a deprecated alias. -3. Write the snippet straight to disk: `acd setup codex --raw > ~/.codex/hooks.json`. The raw output is strict Codex-compatible JSON with only `hooks` at the top level. Codex now reads `hooks.json` before `~/.codex/config.toml`. (`acd setup codex` without `--raw` prints the same JSON wrapped in `// `-prefixed instructions; copy only the JSON block if you go that route — JSON does not allow comments.) + Do not set `hooks = false`; that disables Codex lifecycle hooks. +3. Write the snippet straight to disk: + + ~~~bash + acd setup codex --raw > ~/.codex/hooks.json + ~~~ + + Raw output is strict Codex-compatible JSON with only `hooks` at the top + level. Codex reads this file before inline hook configuration. + `acd setup codex` without `--raw` prints the same JSON with comment-prefixed + instructions, so copy only the JSON block when you use that form. JSON does + not allow comments. **Overwrite warning:** the shell redirect above replaces the entire file. If you have custom non-acd hooks in `~/.codex/hooks.json`, back up that file first and then merge the acd JSON block in manually rather than using `>`. 4. Restart Codex. -5. **Approve the hooks.** Codex flags every newly-added hook entry as "review required" and refuses to run them until you approve. On first launch you will see `5 hooks need review before they can run. Open /hooks to review them.` Run `/hooks` inside Codex and approve all five (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`). Until you approve, the daemon never starts and `acd status` will show no Codex client. - - **Re-approval on update:** Codex re-flags every hook entry as review-required - whenever `hooks.json` content changes — even if only the acd block was - re-written. After any `acd setup codex --raw > ~/.codex/hooks.json` re-run - (including migrations), open Codex and run `/hooks` again to approve the - updated entries. Until you do, the daemon will not start for new sessions. - - Run `acd doctor` to check whether your installed snippet is current; it warns - when active hooks are missing `acd start` or `acd wake` and shows the - remediation command. - -If you previously installed the legacy TOML snippet, delete the -`# acd-managed: true` block from `~/.codex/config.toml` or -`~/.config/codex/config.toml`. Codex merges every hook source it finds -(`hooks.json` and inline `[hooks]` in `config.toml` both fire), so leaving the -legacy block in place causes every event to fire twice — doubled -`acd start`/`acd wake`/`acd touch` per turn. `acd doctor` warns when the JSON -file and a legacy TOML config both contain ACD hook installs. - -If `/hooks` reports an unknown top-level `_acd_managed` field, regenerate the -schema-clean JSON with `acd setup codex --raw > ~/.codex/hooks.json`. If the -file also has custom hooks, remove only the top-level `_acd_managed` key -manually and keep the `hooks` object. - -Note: the official Codex hooks docs use `[features].hooks` as the canonical -feature key and keep `codex_hooks` only as a deprecated alias. The -`hooks.json` install carries hook bodies, not feature flags. +5. Approve the hooks. Codex marks new hook entries as "review required" and + refuses to run them until you approve. Run `/hooks` inside Codex and approve + `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop`. + Until then, `acd status` will show no Codex client. + +Codex asks for approval again whenever `hooks.json` changes. After regenerating +the file, run `/hooks` and approve the updated entries before opening a new +session. + +Run `acd doctor` after setup. If it reports ACD hooks in both `hooks.json` and +an inline `config.toml` block, remove the ACD-managed inline block. Codex loads +both sources, so keeping duplicate ACD hooks makes every event run twice. ## Wired events @@ -74,7 +67,7 @@ session. The repo path is read from the JSON `cwd` field on stdin (consumed in one pass via `acd hook-stdin-extract session_id cwd?`). When `cwd` is missing, the hook falls back to the hook process working directory; `CODEX_PROJECT_DIR` -is no longer required. +is not used. ## Verify diff --git a/templates/opencode/README.md b/templates/opencode/README.md index 8b1c31d9..404ec735 100644 --- a/templates/opencode/README.md +++ b/templates/opencode/README.md @@ -17,26 +17,14 @@ For [`KristjanPikhof/OpenCode-Hooks`](https://github.com/KristjanPikhof/OpenCode OpenCode exposes `OPENCODE_SESSION_ID` natively; no jq required. -If you previously installed a snippet that predates the v2026-05-08 release, -re-run `acd setup opencode` and replace the acd block in -`~/.config/opencode/hook/hooks.yaml` so the new self-heal hooks take effect. Run -`acd doctor` to check whether your installed snippet is current; it warns when -active hooks are missing `acd start` or `acd wake` and shows the remediation -command. +Run `acd doctor` after setup. It compares the installed hook with the current +template and tells you when to regenerate the ACD block with +`acd setup opencode`. -**Hook file detected but `acd doctor` still reports detection as `no`?** -The hook file exists but lacks the `# acd-managed: true` marker on its first -line, so `acd doctor` cannot recognise it as an acd-managed file. To fix this, -either: - -- **Prepend the marker manually** — open `~/.config/opencode/hook/hooks.yaml` in - an editor and add `# acd-managed: true` as the very first line. -- **Re-run setup and merge** — run `acd setup opencode` (no `>`), copy the - printed YAML, and merge the acd block into your existing file. - - **Do not use `>` to redirect** when you have custom hooks — `acd setup opencode - --raw > ~/.config/opencode/hook/hooks.yaml` overwrites the entire file and - destroys any existing entries. +If the hook file exists but `acd doctor` does not recognize it, check its first +line for `# acd-managed: true`. Add the marker or run `acd setup opencode` and +merge the printed YAML into the existing file. Do not redirect raw output over +a file that contains custom hooks. Tool hooks run idempotent `acd start` before `acd wake`, so later tool activity can recover if you manually ran `acd stop` while the OpenCode session stayed @@ -46,8 +34,7 @@ open. `session.deleted` still deregisters the session with `acd wake` refreshes the heartbeat and nudges capture/replay, but it does not bypass `ACD_INTENT_MIN_PENDING` or `ACD_INTENT_MAX_PENDING_AGE`. The `session.idle` hook uses `acd flush --logical` for the prompt-end commit -boundary; re-run `acd setup opencode` if your installed snippet predates that -hook. +boundary. `acd doctor` reports template drift when that wiring is missing. Repo autodiscovery is enabled by default. If you disable it with `repo_lifecycle.autodiscovery` in `~/.config/acd/config.json` or with diff --git a/templates/opencode/uninstall.md b/templates/opencode/uninstall.md index f766ecae..c06d57a4 100644 --- a/templates/opencode/uninstall.md +++ b/templates/opencode/uninstall.md @@ -1,8 +1,20 @@ # Uninstall acd from OpenCode -1. Remove the `# acd-managed: true` block (and the five `acd-*` hooks it added) from your `~/.config/opencode/hook/hooks.yaml`. +1. Remove the `# acd-managed: true` block and its five `acd-*` hooks from + `~/.config/opencode/hook/hooks.yaml`. 2. Stop any running daemons: ~~~bash acd stop --all ~~~ -3. (Optional) Remove the acd binary and state — see the top-level uninstall guide. +3. To delete captured state for a repo, do this before removing the binary: + ~~~bash + acd repo remove --repo /path/to/repo --yes --purge-state + ~~~ +4. (Optional) Remove the binary and global ACD data: + ~~~bash + rm ~/.local/bin/acd + rm -rf ~/.local/share/acd ~/.local/state/acd ~/.config/acd + # or remove the Homebrew binary with: brew uninstall acd + ~~~ + +Per-repo `.git/acd` directories remain unless you remove them explicitly. diff --git a/templates/pi/README.md b/templates/pi/README.md index 9e1a3544..f14febd4 100644 --- a/templates/pi/README.md +++ b/templates/pi/README.md @@ -15,25 +15,13 @@ For [`KristjanPikhof/Pi-YAML-Hooks`](https://github.com/KristjanPikhof/Pi-YAML-H 4. Restart Pi -If you previously installed a snippet that predates the v2026-05-08 release, -re-run `acd setup pi` and replace the acd block in `~/.pi/agent/hook/hooks.yaml` so -the new self-heal hooks take effect. Run `acd doctor` to check whether your -installed snippet is current; it warns when active hooks are missing `acd start` -or `acd wake` and shows the remediation command. - -**Hook file detected but `acd doctor` still reports detection as `no`?** -The hook file exists but lacks the `# acd-managed: true` marker on its first -line, so `acd doctor` cannot recognise it as an acd-managed file. To fix this, -either: - -- **Prepend the marker manually** — open `~/.pi/agent/hook/hooks.yaml` in an - editor and add `# acd-managed: true` as the very first line. -- **Re-run setup and merge** — run `acd setup pi` (no `>`), copy the printed - YAML, and merge the acd block into your existing file. - - **Do not use `>` to redirect** when you have custom hooks — `acd setup pi --raw - > ~/.pi/agent/hook/hooks.yaml` overwrites the entire file and destroys any - existing entries. +Run `acd doctor` after setup. It compares the installed hook with the current +template and tells you when to regenerate the ACD block with `acd setup pi`. + +If the hook file exists but `acd doctor` does not recognize it, check its first +line for `# acd-managed: true`. Add the marker or run `acd setup pi` and merge +the printed YAML into the existing file. Do not redirect raw output over a file +that contains custom hooks. Tool hooks run idempotent `acd start` before `acd wake`, so later tool activity can recover if you manually ran `acd stop` while the Pi session stayed open. @@ -42,7 +30,7 @@ can recover if you manually ran `acd stop` while the Pi session stayed open. `acd wake` refreshes the heartbeat and nudges capture/replay, but it does not bypass `ACD_INTENT_MIN_PENDING` or `ACD_INTENT_MAX_PENDING_AGE`. The `session.idle` hook uses `acd flush --logical` for the prompt-end commit -boundary; re-run `acd setup pi` if your installed snippet predates that hook. +boundary. `acd doctor` reports template drift when that wiring is missing. Repo autodiscovery is enabled by default. If you disable it with `repo_lifecycle.autodiscovery` in `~/.config/acd/config.json` or with diff --git a/templates/pi/uninstall.md b/templates/pi/uninstall.md index 2be9bc96..a6291209 100644 --- a/templates/pi/uninstall.md +++ b/templates/pi/uninstall.md @@ -5,4 +5,15 @@ ~~~bash acd stop --all ~~~ -3. (Optional) Remove the acd binary and state — see the top-level uninstall guide. +3. To delete captured state for a repo, do this before removing the binary: + ~~~bash + acd repo remove --repo /path/to/repo --yes --purge-state + ~~~ +4. (Optional) Remove the binary and global ACD data: + ~~~bash + rm ~/.local/bin/acd + rm -rf ~/.local/share/acd ~/.local/state/acd ~/.config/acd + # or remove the Homebrew binary with: brew uninstall acd + ~~~ + +Per-repo `.git/acd` directories remain unless you remove them explicitly. diff --git a/templates/shell/README.md b/templates/shell/README.md index 8ac177f5..c6752bb0 100644 --- a/templates/shell/README.md +++ b/templates/shell/README.md @@ -1,13 +1,50 @@ -# acd adapter: shell (universal fallback) +# acd adapter: shell -For tools without a hook system: rely on the shell to register/deregister. +Use the shell adapter when your coding tool does not have a native hook system. +Choose either direnv for one repo or zsh for every repo you enter. -## direnv +## Install with direnv -Append `direnv.envrc.snippet` to your repo's `.envrc`. `direnv allow` once. +Run `acd setup shell`, copy the direnv snippet into the repository's `.envrc`, +then approve it once: -## zsh +~~~bash +direnv allow +~~~ -Append `zshrc.snippet.sh` to `~/.zshrc`. The `chpwd` hook registers acd whenever you `cd` into a git repo. +The snippet registers the current shell when direnv loads the repo and +deregisters it when direnv unloads the environment. -Either approach uses `--harness shell`. PID-based liveness keeps the daemon alive only while a registered shell is running. +## Install with zsh + +Run `acd setup shell` and append the zsh snippet to `~/.zshrc`. Load the change +in the current terminal: + +~~~bash +source ~/.zshrc +~~~ + +The `chpwd` hook registers ACD whenever you enter a Git worktree. The shell PID +keeps the session alive, so ACD removes the client after that shell exits. + +## Verify + +Enter a Git repository and run: + +~~~bash +acd status +~~~ + +The client list should contain `harness=shell`. Run `acd doctor` if the daemon +does not start or the client is missing. + +## Uninstall + +Remove the ACD block from `.envrc` or `~/.zshrc`, depending on which form you +installed. Restart the shell, or stop the current repo immediately: + +~~~bash +acd stop +~~~ + +Stopping the daemon does not delete `.git/acd` state. diff --git a/test/integration/active_chain_self_heal_test.go b/test/integration/active_chain_self_heal_test.go new file mode 100644 index 00000000..046228c2 --- /dev/null +++ b/test/integration/active_chain_self_heal_test.go @@ -0,0 +1,173 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestActiveChainSelfHeal_ArchivesRecapturesAndPublishes proves the complete +// unattended recovery loop through the production CLI and daemon: an active +// blocked chain is archived without losing its dirty final worktree, shadow is +// rebuilt from HEAD, and fresh captures publish that work normally. +func TestActiveChainSelfHeal_ArchivesRecapturesAndPublishes(t *testing.T) { + requireSQLite(t) + t.Parallel() + + repo := tempRepo(t) + env := envWith(withIsolatedHome(t), + "ACD_COMMIT_STRATEGY=event", + "ACD_AI_PROVIDER=deterministic", + "ACD_PATH_QUIESCENCE_SECONDS=0", + "ACD_REWIND_GRACE_SECONDS=0", + ) + t.Cleanup(func() { stopSessionForce(t, env, repo) }) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + baseHead := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) + dbPath := initStateDBSchema(t, ctx, env, repo, "active-chain-bootstrap") + generation := sqliteScalar(t, dbPath, + "SELECT value FROM daemon_meta WHERE key = 'branch.generation'") + if generation == "" { + generation = "1" + } + + const ( + firstPath = "self-heal-a.txt" + secondPath = "self-heal-b.txt" + firstBody = "final a\n" + secondBody = "final b\n" + ) + writeFile(t, filepath.Join(repo, firstPath), firstBody) + writeFile(t, filepath.Join(repo, secondPath), secondBody) + firstOID := gitHashObjectStdin(t, repo, firstBody) + secondOID := gitHashObjectStdin(t, repo, secondBody) + + original := seedCreateRecoveryPair(t, dbPath, "refs/heads/main", generation, baseHead, + firstPath, firstOID, secondPath, secondOID) + seedSQL := fmt.Sprintf(` +BEGIN; +INSERT INTO shadow_paths(branch_ref, branch_generation, path, operation, mode, oid, base_head, fidelity, updated_ts) +VALUES ('refs/heads/main', %s, '%s', 'create', '100644', '%s', '%s', 'exact', %f) +ON CONFLICT(branch_ref, branch_generation, path) DO UPDATE SET + operation=excluded.operation, mode=excluded.mode, oid=excluded.oid, + base_head=excluded.base_head, fidelity=excluded.fidelity, updated_ts=excluded.updated_ts; +INSERT INTO shadow_paths(branch_ref, branch_generation, path, operation, mode, oid, base_head, fidelity, updated_ts) +VALUES ('refs/heads/main', %s, '%s', 'create', '100644', '%s', '%s', 'exact', %f) +ON CONFLICT(branch_ref, branch_generation, path) DO UPDATE SET + operation=excluded.operation, mode=excluded.mode, oid=excluded.oid, + base_head=excluded.base_head, fidelity=excluded.fidelity, updated_ts=excluded.updated_ts; +INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, status, error, updated_ts) +VALUES (1, %s, 'refs/heads/main', %s, '%s', 'blocked_conflict', 'integration active-chain barrier', %f) +ON CONFLICT(id) DO UPDATE SET + event_seq=excluded.event_seq, branch_ref=excluded.branch_ref, + branch_generation=excluded.branch_generation, source_head=excluded.source_head, + status=excluded.status, error=excluded.error, updated_ts=excluded.updated_ts; +COMMIT;`, + generation, firstPath, firstOID, baseHead, nowFloatSeconds(), + generation, secondPath, secondOID, baseHead, nowFloatSeconds(), + original.FirstSeq, generation, baseHead, nowFloatSeconds()) + if out, err := exec.Command("sqlite3", dbPath, seedSQL).CombinedOutput(); err != nil { + t.Fatalf("seed active-chain shadow and barrier: %v\n%s", err, out) + } + + startSession(t, ctx, env, repo, "active-chain-self-heal", "shell") + waitMode(t, repo, "running", 5*time.Second) + + deadline := time.Now().Add(25 * time.Second) + settled := false + for time.Now().Before(deadline) { + wakeSession(t, ctx, env, repo, "active-chain-self-heal") + query := fmt.Sprintf(` +SELECT + (SELECT COUNT(*) FROM capture_events WHERE seq IN (%s,%s) AND state = 'recovered') || '|' || + (SELECT COUNT(*) FROM capture_events WHERE seq NOT IN (%s,%s) AND path IN ('%s','%s') AND state = 'published') || '|' || + (SELECT COUNT(*) FROM capture_events WHERE state IN ('pending','blocked_conflict','failed'));`, + original.FirstSeq, original.SecondSeq, original.FirstSeq, original.SecondSeq, + firstPath, secondPath) + out, err := exec.Command("sqlite3", dbPath, query).CombinedOutput() + if err == nil && strings.TrimSpace(string(out)) == "2|2|0" { + settled = true + break + } + time.Sleep(150 * time.Millisecond) + } + if !settled { + dump, _ := exec.Command("sqlite3", dbPath, ` +SELECT seq,path,state,commit_oid,error FROM capture_events ORDER BY seq; +SELECT id,outcome,event_count,commit_oid,recovery_ref FROM recovery_snapshots ORDER BY id; +SELECT id,event_seq,status,error FROM publish_state;`).CombinedOutput() + t.Fatalf("active-chain self-heal did not settle\nstate:\n%s\nlog:\n%s", + dump, runGitOK(t, repo, "log", "--oneline", "--decorate", "--all")) + } + + snapshotID := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT snapshot_id FROM recovery_snapshot_events WHERE event_seq = %s", original.FirstSeq)) + if snapshotID == "" { + t.Fatal("original blocked pair has no recovery snapshot membership") + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || event_count || '|' || first_event_seq || '|' || last_event_seq +FROM recovery_snapshots WHERE id = %s`, snapshotID)); got != + fmt.Sprintf("recovered|2|%s|%s", original.FirstSeq, original.SecondSeq) { + t.Fatalf("recovery snapshot summary=%q", got) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT group_concat(event_seq, ',') +FROM (SELECT event_seq FROM recovery_snapshot_events WHERE snapshot_id = %s ORDER BY ord)`, snapshotID)); got != + original.FirstSeq+","+original.SecondSeq { + t.Fatalf("recovery snapshot membership=%q", got) + } + recoveryRef := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT recovery_ref FROM recovery_snapshots WHERE id = %s", snapshotID)) + recoveryCommit := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT commit_oid FROM recovery_snapshots WHERE id = %s", snapshotID)) + if !strings.HasPrefix(recoveryRef, "refs/acd/recovery/") || !strings.HasSuffix(recoveryRef, "/archive") { + t.Fatalf("recovery ref=%q want refs/acd/recovery/.../archive", recoveryRef) + } + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", recoveryRef)); got != recoveryCommit { + t.Fatalf("recovery ref resolves to %q want snapshot commit %q", got, recoveryCommit) + } + if got := strings.TrimSpace(runGitOK(t, repo, "rev-parse", recoveryRef+":"+firstPath)); got != firstOID { + t.Fatalf("archived %s oid=%s want %s", firstPath, got, firstOID) + } + if got := strings.TrimSpace(runGitOK(t, repo, "rev-parse", recoveryRef+":"+secondPath)); got != secondOID { + t.Fatalf("archived %s oid=%s want %s", secondPath, got, secondOID) + } + + marker := "shadow.bootstrapped:refs/heads/main:" + generation + if got := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM daemon_meta WHERE key = '%s'", marker)); got != "1" { + t.Fatalf("recaptured shadow marker count=%s want 1", got) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM shadow_paths +WHERE branch_ref = 'refs/heads/main' AND branch_generation = %s + AND ((path = '%s' AND oid = '%s') OR (path = '%s' AND oid = '%s'))`, + generation, firstPath, firstOID, secondPath, secondOID)); got != "2" { + t.Fatalf("recaptured final shadow rows=%s want 2", got) + } + + if got := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:"+firstPath)); got != firstOID { + t.Fatalf("HEAD %s oid=%s want %s", firstPath, got, firstOID) + } + if got := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:"+secondPath)); got != secondOID { + t.Fatalf("HEAD %s oid=%s want %s", secondPath, got, secondOID) + } + if status := strings.TrimSpace(runGitOK(t, repo, "status", "--porcelain=v1")); status != "" { + t.Fatalf("worktree not clean after replacement publish:\n%s", status) + } + if got := sqliteScalar(t, dbPath, + "SELECT COUNT(*) FROM capture_events WHERE state IN ('pending','blocked_conflict','failed')"); got != "0" { + t.Fatalf("terminal/pending blockers remain=%s", got) + } +} diff --git a/test/integration/commitall_test.go b/test/integration/commitall_test.go index d5166c62..c92cedf0 100644 --- a/test/integration/commitall_test.go +++ b/test/integration/commitall_test.go @@ -23,6 +23,11 @@ import ( func commitAllFixture(t *testing.T) (string, []string) { t.Helper() repo := tempRepo(t) + return repo, writeCommitAllFixture(t, repo) +} + +func writeCommitAllFixture(t *testing.T, repo string) []string { + t.Helper() files := []string{ "cmd/main.go", "docs/a.md", @@ -39,7 +44,7 @@ func commitAllFixture(t *testing.T) (string, []string) { for i, rel := range files { writeFile(t, filepath.Join(repo, rel), "// "+rel+"\n// content "+strconv.Itoa(i)+"\n") } - return repo, files + return files } // commitsTouchingPath returns commit OIDs (oldest-first) that touched path. @@ -277,17 +282,17 @@ func TestCommitAllRefusesWhenDaemonAlive(t *testing.T) { // worktree already clean" while their worktree still showed dirty files. // // This test: -// 1. Builds a dirty fixture worktree. -// 2. Runs `acd commit-all --dry-run --yes --json` once to let acd -// create the state.db schema (without committing) — the dry-run path -// exits before mutating HEAD. +// 1. Verifies `acd commit-all --dry-run --yes --json` leaves a clean repo +// without a state database, then initializes state through a real daemon +// start/stop cycle. +// 2. Builds a dirty fixture worktree. // 3. Simulates the poisoned state by hash-objecting each dirty file into // the git ODB, writing a shadow_paths row that mirrors that blob, and // stamping the bootstrap marker. // 4. Seeds a stale pending capture_events row from a "previous session". -// 5. Runs `acd commit-all --yes`. With the fix, the reseed nukes the -// poisoned shadow, the stale pending row is dropped, and Capture -// classifies the dirty files as fresh creates, which then commit. +// 5. Runs `acd commit-all --yes`. With the fix, the stale pending chain is +// preserved, the poisoned shadow is reseeded, and Capture classifies the +// dirty files as fresh creates, which then commit. // 6. Asserts the worktree ends up clean and exit code is 0. func TestCommitAllReseedsStaleShadow(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { @@ -296,7 +301,7 @@ func TestCommitAllReseedsStaleShadow(t *testing.T) { if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 binary required for poisoned-state seeding") } - repo, files := commitAllFixture(t) + repo := tempRepo(t) env := commitAllEnv(t, "event", "deterministic") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -306,17 +311,20 @@ func TestCommitAllReseedsStaleShadow(t *testing.T) { branchRef := "refs/heads/main" gen := int64(1) - // Run a dry-run first so acd creates .git/acd/state.db with the - // canonical schema. We still need to mutate it before the real run. + // Dry-run is strictly read-only and must not create state. Initialize the + // schema afterward through the production daemon lifecycle, before making + // the fixture worktree dirty. dry := runAcd(t, ctx, env, "commit-all", "--repo", repo, "--yes", "--dry-run", "--json") if dry.ExitCode != 0 { t.Fatalf("dry-run setup exit=%d\nstdout=%s\nstderr=%s", dry.ExitCode, dry.Stdout, dry.Stderr) } dbPath := filepath.Join(repo, ".git", "acd", "state.db") - if _, err := os.Stat(dbPath); err != nil { - t.Fatalf("state.db missing after dry-run: %v", err) + if _, err := os.Stat(dbPath); !os.IsNotExist(err) { + t.Fatalf("dry-run created writable state at %s: err=%v", dbPath, err) } + dbPath = initStateDBSchema(t, ctx, env, repo, "commit-all-stale-shadow-schema") + files := writeCommitAllFixture(t, repo) // Seed shadow_paths rows that mirror each dirty file's actual blob // OID so the real Capture (without the fix) would see no diff. @@ -340,16 +348,19 @@ func TestCommitAllReseedsStaleShadow(t *testing.T) { if out, err := exec.Command("sqlite3", dbPath, markerStmt).CombinedOutput(); err != nil { t.Fatalf("sqlite stamp marker: %v\n%s", err, out) } - // Seed a stale pending event from a previous session. base_head is - // deliberately bogus so a replay attempt would fail. + // Seed a valid stale pending event from a previous session. The live + // worktree does not contain its desired file, so whole-chain recovery must + // archive it before commit-all can safely reseed and recapture. + staleAfterOID := gitHashObjectStdin(t, repo, "preserve stale pending work\n") staleStmt := "INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) VALUES (" + - "'" + branchRef + "', " + strconv.FormatInt(gen, 10) + ", 'stalebase', 'modify', 'stale-pending.txt', 'full', strftime('%s','now'), 'pending');" + "'" + branchRef + "', " + strconv.FormatInt(gen, 10) + ", '" + headSHA + "', 'create', 'stale-pending.txt', 'exact', strftime('%s','now'), 'pending');" + + "INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) VALUES (last_insert_rowid(), 0, 'create', 'stale-pending.txt', '" + staleAfterOID + "', '100644', 'exact');" if out, err := exec.Command("sqlite3", dbPath, staleStmt).CombinedOutput(); err != nil { t.Fatalf("sqlite seed stale pending: %v\n%s", err, out) } - // Now run commit-all for real. With the fix it must reseed shadow, - // drop the stale pending row, capture the dirty files, and commit them. + // Now run commit-all for real. It must archive and retain the stale exact + // pair, reseed shadow, capture the dirty files, and commit them. res := runAcd(t, ctx, env, "commit-all", "--repo", repo, "--yes") if res.ExitCode != 0 { t.Fatalf("commit-all (poisoned state) exit=%d\nstdout=%s\nstderr=%s", res.ExitCode, res.Stdout, res.Stderr) diff --git a/test/integration/dead_branch_prune_test.go b/test/integration/dead_branch_prune_test.go index 8b0c8e9b..bdd8d40a 100644 --- a/test/integration/dead_branch_prune_test.go +++ b/test/integration/dead_branch_prune_test.go @@ -1,17 +1,19 @@ //go:build integration // +build integration -// dead_branch_prune_test.go drives the dead-branch terminal pruning surface +// dead_branch_prune_test.go drives the dead-branch recovery surface // end-to-end against the production `acd` binary: // -// - (a) Diverged transition with the prior branch deleted prunes the -// blocked_conflict / failed rows tied to that ref. +// - (a) Diverged transition with the prior branch deleted archives the exact +// blocked_conflict / failed pair and retains its rows as recovered +// provenance. // - (b) Diverged transition with the prior branch still alive preserves the // terminal rows. -// - (c) Daemon startup sweep removes pre-seeded terminals for refs that +// - (c) Daemon startup sweep archives pre-seeded terminals for refs that // have since been deleted. -// - (d) ACD_KEEP_DEAD_BRANCH_BARRIERS=1 is honored on both runtime and startup -// paths — terminals survive even though their ref is dead. +// - (d) ACD_KEEP_DEAD_BRANCH_BARRIERS=1 disables the startup sweep. Runtime +// branch transitions still reconcile the exact prior pair before accepting +// a new token; this is transition safety, not the optional sweep. // - (e) RefExists transient-error fail-open path. Covered by the unit-level // TestDeadBranchSweep_RefExistsErrorPreservesRows in // internal/daemon/dead_branch_sweep_test.go (the integration-level @@ -20,13 +22,15 @@ // fragile so we lean on the unit test for this case and document it // here). // - Diagnose-meta surface assertion: `acd diagnose --json` includes all -// three dead_branch_prune_* fields on the prune path; on the no-prune -// path the two int fields render as `0` (always-emit contract — zero +// three legacy dead_branch_prune_* fields on the recovery path; on the +// no-recovery path the two int fields render as `0` (always-emit +// contract — zero // is the documented "never ran" sentinel) and the refs slice is // omitted (omitempty + nil). // -// Capture rows are seeded through the sqlite3 binary against the real -// state.db, exactly as the existing populated-state and explainable-UX +// Capture rows and their immutable operations are seeded through the sqlite3 +// binary against the real state.db, exactly as the existing populated-state +// and explainable-UX // integration tests do — the integration package cannot import the internal // state package, and the daemon's own SQL drivers are ABI-compatible with // raw inserts via `sqlite3` since the schema is materialized by the binary @@ -44,15 +48,29 @@ import ( "time" ) -// seedTerminalCaptureEvent inserts one capture_events row in the requested -// terminal state for the given (branch_ref, branch_generation) tuple via the -// sqlite3 CLI. +// seedTerminalCaptureEvent inserts one capture event plus its immutable create +// operation in the requested terminal state for the given exact branch pair. +// The blob is written to the real object database so recovery has the same +// complete provenance it receives from production capture. func seedTerminalCaptureEvent(t *testing.T, dbPath, branchRef string, generation int, baseHead, path, eventState string) { t.Helper() + repo := filepath.Dir(filepath.Dir(filepath.Dir(dbPath))) + afterOID := gitHashObjectStdin(t, repo, "dead branch recovery payload: "+path+"\n") now := nowFloatSeconds() stmt := fmt.Sprintf( - "PRAGMA busy_timeout=5000; INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) VALUES (%s, %d, %s, 'create', %s, 'full', %f, %s);", - sqliteLiteral(branchRef), generation, sqliteLiteral(baseHead), sqliteLiteral(path), now, sqliteLiteral(eventState)) + `PRAGMA busy_timeout=5000; +BEGIN; +INSERT INTO capture_events( + branch_ref, branch_generation, base_head, operation, path, + fidelity, captured_ts, state +) VALUES (%s, %d, %s, 'create', %s, 'exact', %f, %s); +INSERT INTO capture_ops( + event_seq, ord, op, path, after_oid, after_mode, fidelity +) VALUES (last_insert_rowid(), 0, 'create', %s, %s, '100644', 'exact'); +COMMIT;`, + sqliteLiteral(branchRef), generation, sqliteLiteral(baseHead), + sqliteLiteral(path), now, sqliteLiteral(eventState), + sqliteLiteral(path), sqliteLiteral(afterOID)) if out, err := exec.Command("sqlite3", dbPath, stmt).CombinedOutput(); err != nil { t.Fatalf("seed capture_events ref=%s state=%s: %v\n%s", branchRef, eventState, err, out) } @@ -60,7 +78,8 @@ func seedTerminalCaptureEvent(t *testing.T, dbPath, branchRef string, generation // countTerminalsForRef returns the count of capture_events rows whose // branch_ref matches the given ref and whose state is in {blocked_conflict, -// failed}. Used as the assertion surface for "row pruned" vs "row preserved". +// failed}. A recovered pair has zero terminals while retaining every original +// capture_events row. func countTerminalsForRef(t *testing.T, dbPath, branchRef string) int { t.Helper() q := fmt.Sprintf( @@ -72,6 +91,62 @@ func countTerminalsForRef(t *testing.T, dbPath, branchRef string) int { return n } +// assertRecoveredPair verifies the no-loss dead-branch contract: all original +// rows retain their exact provenance, every row transitions to recovered, and +// one durable snapshot/ref protects the reconstructed tree. +func assertRecoveredPair( + t *testing.T, + dbPath, repo, branchRef string, + generation int, + baseHead string, + wantEvents int, +) { + t.Helper() + waitFor(t, "dead branch pair recovered", 10*time.Second, func() bool { + q := fmt.Sprintf(` +SELECT COUNT(*) FROM capture_events +WHERE branch_ref = %s AND branch_generation = %d AND base_head = %s + AND state = 'recovered'`, + sqliteLiteral(branchRef), generation, sqliteLiteral(baseHead)) + return sqliteScalar(t, dbPath, q) == fmt.Sprintf("%d", wantEvents) + }) + + if got := countTerminalsForRef(t, dbPath, branchRef); got != 0 { + t.Fatalf("dead-ref %s terminal rows=%d want 0 after recovery", branchRef, got) + } + provenanceCount := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM capture_events +WHERE branch_ref = %s AND branch_generation = %d AND base_head = %s`, + sqliteLiteral(branchRef), generation, sqliteLiteral(baseHead))) + if provenanceCount != fmt.Sprintf("%d", wantEvents) { + t.Fatalf("dead-ref %s retained provenance=%s want %d", branchRef, provenanceCount, wantEvents) + } + + snapshotID := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT id FROM recovery_snapshots +WHERE branch_ref = %s AND branch_generation = %d + AND outcome = 'recovered' AND event_count = %d`, + sqliteLiteral(branchRef), generation, wantEvents)) + if snapshotID == "" { + t.Fatalf("dead-ref %s recovery snapshot missing", branchRef) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM recovery_snapshot_events +WHERE snapshot_id = %s`, snapshotID)); got != fmt.Sprintf("%d", wantEvents) { + t.Fatalf("dead-ref %s recovery membership=%s want %d", branchRef, got, wantEvents) + } + recoveryRef := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT recovery_ref FROM recovery_snapshots WHERE id = %s", snapshotID)) + recoveryCommit := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT commit_oid FROM recovery_snapshots WHERE id = %s", snapshotID)) + if !strings.HasPrefix(recoveryRef, "refs/acd/recovery/") { + t.Fatalf("dead-ref %s recovery_ref=%q", branchRef, recoveryRef) + } + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", recoveryRef)); got != recoveryCommit { + t.Fatalf("dead-ref %s recovery ref resolves to %q want %q", branchRef, got, recoveryCommit) + } +} + func currentBranchGeneration(t *testing.T, dbPath string) int { t.Helper() genRaw := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'branch.generation'") @@ -82,11 +157,11 @@ func currentBranchGeneration(t *testing.T, dbPath string) int { return gen } -// TestDeadBranchPrune_RuntimeDivergedDeletedBranchPrunesRows covers scenario +// TestDeadBranchPrune_RuntimeDivergedDeletedBranchRecoversRows covers scenario // (a): with the daemon still running, switching away from a branch and deleting // that prior ref drives processBranchTokenChange through the runtime Diverged -// prune path. -func TestDeadBranchPrune_RuntimeDivergedDeletedBranchPrunesRows(t *testing.T) { +// recovery path. +func TestDeadBranchPrune_RuntimeDivergedDeletedBranchRecoversRows(t *testing.T) { requireSQLite(t) repo := tempRepo(t) @@ -124,18 +199,16 @@ func TestDeadBranchPrune_RuntimeDivergedDeletedBranchPrunesRows(t *testing.T) { runGitOK(t, repo, "update-ref", "-d", deadRef) wakeSession(t, ctx, env, repo, "dbp-runtime-prune") - waitFor(t, "runtime dead-ref terminals pruned", 10*time.Second, func() bool { - return countTerminalsForRef(t, dbPath, deadRef) == 0 - }) + assertRecoveredPair(t, dbPath, repo, deadRef, gen, headOID, 2) lastRefs := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'dead_branch_prune.last_refs'") if !strings.Contains(lastRefs, deadRef) { t.Fatalf("dead_branch_prune.last_refs=%q missing %q", lastRefs, deadRef) } } -// TestDeadBranchPrune_DivergedDeletedBranchPrunesRows covers the restart shape: +// TestDeadBranchPrune_DivergedDeletedBranchRecoversRows covers the restart shape: // a branch exists when terminal rows land, is deleted while the daemon is -// stopped, then startup sweep prunes the rows on the next run. +// stopped, then startup sweep recovers the rows on the next run. // // Strategy notes: // @@ -145,7 +218,7 @@ func TestDeadBranchPrune_RuntimeDivergedDeletedBranchPrunesRows(t *testing.T) { // - Scenario (c) — pure startup sweep with no prior daemon session — uses // a separate test below. This test is specifically the "ref was alive // when terminals landed, then operator merged + deleted" shape. -func TestDeadBranchPrune_DivergedDeletedBranchPrunesRows(t *testing.T) { +func TestDeadBranchPrune_DivergedDeletedBranchRecoversRows(t *testing.T) { requireSQLite(t) repo := tempRepo(t) @@ -181,17 +254,12 @@ func TestDeadBranchPrune_DivergedDeletedBranchPrunesRows(t *testing.T) { runGitOK(t, repo, "update-ref", "-d", deadRef) // Start the daemon again — startup sweep + Diverged path will observe the - // dead ref. The daemon's main loop runs runStartupDeadBranchSweep before - // any capture work, so the rows must be gone shortly after mode=running. + // dead ref. The daemon's startup sweep archives the exact pair and retains + // each capture_events row as recovered provenance. startSession(t, ctx, env, repo, "dbp-a-prune", "shell") waitMode(t, repo, "running", 10*time.Second) - waitFor(t, "dead-ref terminals pruned", 10*time.Second, func() bool { - return countTerminalsForRef(t, dbPath, deadRef) == 0 - }) - if got := countTerminalsForRef(t, dbPath, deadRef); got != 0 { - t.Fatalf("dead-ref %s terminals=%d want 0", deadRef, got) - } + assertRecoveredPair(t, dbPath, repo, deadRef, 1, headOID, 2) } // TestDeadBranchPrune_LiveBranchPreservesRows covers scenario (b): when the @@ -236,10 +304,10 @@ func TestDeadBranchPrune_LiveBranchPreservesRows(t *testing.T) { } } -// TestDeadBranchPrune_StartupSweepRemovesPreSeededTerminals covers scenario +// TestDeadBranchPrune_StartupSweepRecoversPreSeededTerminals covers scenario // (c): seed terminal rows for refs that never existed, then start the daemon. -// runStartupDeadBranchSweep observes the dead refs and prunes the rows. -func TestDeadBranchPrune_StartupSweepRemovesPreSeededTerminals(t *testing.T) { +// runStartupDeadBranchSweep observes the dead refs and archives both pairs. +func TestDeadBranchPrune_StartupSweepRecoversPreSeededTerminals(t *testing.T) { requireSQLite(t) repo := tempRepo(t) @@ -261,7 +329,7 @@ func TestDeadBranchPrune_StartupSweepRemovesPreSeededTerminals(t *testing.T) { const deadRef1 = "refs/heads/dead-1" const deadRef2 = "refs/heads/dead-2" // Neither ref ever exists in the repo. Sweep must observe RefExists=false - // for both and prune their terminals. + // for both and recover their terminals without deleting provenance. seedTerminalCaptureEvent(t, dbPath, deadRef1, 1, headOID, "dead1-blocked.txt", "blocked_conflict") seedTerminalCaptureEvent(t, dbPath, deadRef1, 1, headOID, "dead1-failed.txt", "failed") seedTerminalCaptureEvent(t, dbPath, deadRef2, 1, headOID, "dead2-blocked.txt", "blocked_conflict") @@ -273,16 +341,12 @@ func TestDeadBranchPrune_StartupSweepRemovesPreSeededTerminals(t *testing.T) { t.Fatalf("seed deadRef2: got=%d want 1", got) } - // Restart — startup sweep should prune both refs' terminals. + // Restart — startup sweep should archive both exact pairs. startSession(t, ctx, env, repo, "dbp-c-prune", "shell") waitMode(t, repo, "running", 10*time.Second) - waitFor(t, "deadRef1 terminals pruned", 10*time.Second, func() bool { - return countTerminalsForRef(t, dbPath, deadRef1) == 0 - }) - waitFor(t, "deadRef2 terminals pruned", 10*time.Second, func() bool { - return countTerminalsForRef(t, dbPath, deadRef2) == 0 - }) + assertRecoveredPair(t, dbPath, repo, deadRef1, 1, headOID, 2) + assertRecoveredPair(t, dbPath, repo, deadRef2, 1, headOID, 1) } // TestDeadBranchPrune_OptOutPreservesRows covers scenario (d) for startup @@ -327,9 +391,11 @@ func TestDeadBranchPrune_OptOutPreservesRows(t *testing.T) { } } -// TestDeadBranchPrune_RuntimeOptOutPreservesRows covers scenario (d) for the -// same-daemon runtime Diverged path. -func TestDeadBranchPrune_RuntimeOptOutPreservesRows(t *testing.T) { +// TestDeadBranchPrune_RuntimeTransitionRecoversWithSweepOptOut proves the env +// knob disables the optional sweep, not mandatory transition reconciliation. +// Accepting a new token while leaving the old pair unpublished would allow the +// new worktree to be captured against stale shadow state. +func TestDeadBranchPrune_RuntimeTransitionRecoversWithSweepOptOut(t *testing.T) { requireSQLite(t) repo := tempRepo(t) @@ -366,21 +432,19 @@ func TestDeadBranchPrune_RuntimeOptOutPreservesRows(t *testing.T) { runGitOK(t, repo, "update-ref", "-d", deadRef) wakeSession(t, ctx, env, repo, "dbp-runtime-keep") - time.Sleep(750 * time.Millisecond) - if got := countTerminalsForRef(t, dbPath, deadRef); got != 2 { - t.Fatalf("runtime opt-out: dead-ref %s terminals=%d want 2", deadRef, got) - } - if v := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM daemon_meta WHERE key = 'dead_branch_prune.last_run_ts'"); v != "0" { - t.Fatalf("runtime opt-out stamped prune meta; count=%s want 0", v) + assertRecoveredPair(t, dbPath, repo, deadRef, gen, headOID, 2) + lastRefs := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'dead_branch_prune.last_refs'") + if !strings.Contains(lastRefs, deadRef) { + t.Fatalf("runtime transition recovery refs=%q missing %q", lastRefs, deadRef) } } -// TestDeadBranchPrune_DiagnoseMetaSurfacesAfterPrune asserts the three +// TestDeadBranchPrune_DiagnoseMetaSurfacesAfterRecovery asserts the three // dead_branch_prune_* JSON fields surface from `acd diagnose --json` after a // successful sweep. Counterpart to the unit-level meta-write tests in // internal/daemon/dead_branch_sweep_test.go: this proves the full pipeline // (daemon writes meta -> CLI diagnose reads meta -> JSON fields populate). -func TestDeadBranchPrune_DiagnoseMetaSurfacesAfterPrune(t *testing.T) { +func TestDeadBranchPrune_DiagnoseMetaSurfacesAfterRecovery(t *testing.T) { requireSQLite(t) repo := tempRepo(t) @@ -404,16 +468,14 @@ func TestDeadBranchPrune_DiagnoseMetaSurfacesAfterPrune(t *testing.T) { beforeTS := time.Now().Unix() - 1 // tolerate clock skew - // Restart — the startup sweep prunes the dead-ref terminal AND stamps - // the three dead_branch_prune.* meta keys. + // Restart — the startup sweep recovers the dead-ref terminal and stamps + // the three legacy dead_branch_prune.* meta keys. startSession(t, ctx, env, repo, "dbp-meta-prune", "shell") waitMode(t, repo, "running", 10*time.Second) - waitFor(t, "dead-ref terminal pruned (precondition for meta)", 10*time.Second, func() bool { - return countTerminalsForRef(t, dbPath, deadRef) == 0 - }) - // Wait until daemon has stamped the meta keys (sweep is best-effort and - // runs slightly after row delete). + assertRecoveredPair(t, dbPath, repo, deadRef, 1, headOID, 1) + // Wait until daemon has stamped the meta keys (the best-effort sweep runs + // slightly after the rows transition to recovered). waitFor(t, "dead_branch_prune.last_run_ts present", 5*time.Second, func() bool { return sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'dead_branch_prune.last_run_ts'") != "" }) diff --git a/test/integration/explainable_ux_test.go b/test/integration/explainable_ux_test.go index ffc44b2a..d8f9a15d 100644 --- a/test/integration/explainable_ux_test.go +++ b/test/integration/explainable_ux_test.go @@ -88,6 +88,7 @@ func TestExplainableUX_DecisionLedgerDrivesEventsExplainAndFix(t *testing.T) { manualHead := gitCommitAll(t, repo, "manual external commit", "manual.txt") manualOID := strings.Fields(runGitOK(t, repo, "ls-tree", manualHead, "manual.txt"))[2] revertedAfterOID := gitHashObjectStdin(t, repo, "queued work that was later reverted\n") + obsoleteAfterOID := gitHashObjectStdin(t, repo, "obsolete blocked work\n") dbPath := filepath.Join(repo, ".git", "acd", "state.db") gen := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'branch.generation'") if gen == "" { @@ -109,14 +110,20 @@ VALUES (last_insert_rowid(), 0, 'create', 'reverted.txt', '%s', '100644', 'exact INSERT INTO decision_records(decision_ts, kind, path, reason, event_seq, commit_oid, branch_ref, branch_generation, action_taken, user_message) VALUES (%f, 'superseded_external', 'reverted.txt', 'superseded_external_current_head_matches_captured_before_state', (SELECT seq FROM capture_events WHERE path = 'reverted.txt' ORDER BY seq DESC LIMIT 1), '%s', 'refs/heads/main', %s, 'marked_published', 'Manual revert superseded queued ACD work.'); INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state, error) -VALUES ('refs/heads/main', %s, '%s', 'modify', 'obsolete-blocker.txt', 'rescan', %f, 'blocked_conflict', 'before-state mismatch'); +VALUES ('refs/heads/main', %s, '%s', 'create', 'obsolete-blocker.txt', 'exact', %f, 'blocked_conflict', 'before-state mismatch'); +INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) +VALUES (last_insert_rowid(), 0, 'create', 'obsolete-blocker.txt', '%s', '100644', 'exact'); `, gen, manualHead, now, manualOID, now+0.001, manualHead, gen, gen, manualHead, now+0.002, revertedAfterOID, now+0.003, manualHead, gen, - gen, manualHead, now+0.004) + gen, manualHead, now+0.004, obsoleteAfterOID) if out, err := exec.Command("sqlite3", dbPath, seedSQL).CombinedOutput(); err != nil { t.Fatalf("seed decision ledger: %v\n%s", err, out) } manualSeq := sqliteScalar(t, dbPath, "SELECT seq FROM capture_events WHERE path = 'manual.txt' ORDER BY seq DESC LIMIT 1") + var manualSeqNumber int64 + if _, err := fmt.Sscan(manualSeq, &manualSeqNumber); err != nil { + t.Fatalf("parse manual event seq %q: %v", manualSeq, err) + } events := runAcd(t, ctx, env, "events", "--repo", repo, "--json") if events.ExitCode != 0 { @@ -151,15 +158,18 @@ VALUES ('refs/heads/main', %s, '%s', 'modify', 'obsolete-blocker.txt', 'rescan', var plan struct { DryRun bool `json:"dry_run"` Actions []struct { - Kind string `json:"kind"` - Seq int64 `json:"seq"` + Kind string `json:"kind"` + Seq int64 `json:"seq"` + PendingCount int `json:"pending_count"` } `json:"actions"` } if err := json.Unmarshal([]byte(fixDryRun.Stdout), &plan); err != nil { t.Fatalf("decode fix dry-run: %v\n%s", err, fixDryRun.Stdout) } - if !plan.DryRun || !hasIntegrationFixAction(plan.Actions, "mark_external_published") || - !hasIntegrationFixAction(plan.Actions, "delete_obsolete_barrier") { + if !plan.DryRun || len(plan.Actions) != 1 || + plan.Actions[0].Kind != "reconcile_unpublished_chain" || + plan.Actions[0].Seq != manualSeqNumber || + plan.Actions[0].PendingCount != 3 { t.Fatalf("fix dry-run did not plan expected safe actions: %+v\n%s", plan, fixDryRun.Stdout) } @@ -167,13 +177,19 @@ VALUES ('refs/heads/main', %s, '%s', 'modify', 'obsolete-blocker.txt', 'rescan', if fixApply.ExitCode != 0 { t.Fatalf("acd fix apply exit=%d\nstdout=%s\nstderr=%s", fixApply.ExitCode, fixApply.Stdout, fixApply.Stderr) } - if state := sqliteScalar(t, dbPath, fmt.Sprintf("SELECT state FROM capture_events WHERE seq = %s", manualSeq)); state != "published" { - t.Fatalf("manual external event state=%q want published\ndry-run=%s\napply=%s", state, fixDryRun.Stdout, fixApply.Stdout) + if states := sqliteScalar(t, dbPath, "SELECT group_concat(state, ',') FROM (SELECT state FROM capture_events ORDER BY seq)"); states != "recovered,recovered,recovered" { + t.Fatalf("whole-chain states=%q want all recovered\ndry-run=%s\napply=%s", states, fixDryRun.Stdout, fixApply.Stdout) + } + if members := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM recovery_snapshot_events"); members != "3" { + t.Fatalf("recovery snapshot members=%q want 3\napply=%s", members, fixApply.Stdout) } } func TestExplainableUX_DaemonRecordsHandledExternalDecision(t *testing.T) { requireSQLite(t) + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available; slow subprocess provider requires bash") + } repo := tempRepo(t) env := withIsolatedHome(t) @@ -185,22 +201,37 @@ func TestExplainableUX_DaemonRecordsHandledExternalDecision(t *testing.T) { target := filepath.Join(repo, "external-handled.txt") writeFile(t, target, "before\n") baseHead := gitCommitAll(t, repo, "baseline handled external", "external-handled.txt") + providerStarted := filepath.Join(t.TempDir(), "provider-started") + plugDir := writePluginScript(t, "slow-handled", fmt.Sprintf(`#!/usr/bin/env bash +while IFS= read -r line; do + printf 'started\n' > %q + sleep 2 + printf '{"version":1,"subject":"slow handled race","body":"","error":""}\n' +done +`, providerStarted)) + slowEnv := envWith(env, + "ACD_AI_PROVIDER=subprocess:slow-handled", + "ACD_AI_TIMEOUT=10s", + pathPrepended(plugDir), + ) - startSession(t, ctx, env, repo, "ux-handled-daemon", "shell") + startSession(t, ctx, slowEnv, repo, "ux-handled-daemon", "shell") waitMode(t, repo, "running", 5*time.Second) dbPath := filepath.Join(repo, ".git", "acd", "state.db") - pauseReplay(t, ctx, env, repo, "handled external integration") writeFile(t, target, "same change\n") + wakeSession(t, ctx, slowEnv, repo, "ux-handled-daemon") + waitFor(t, "provider entered after replay conflict probe", 8*time.Second, func() bool { + _, err := os.Stat(providerStarted) + return err == nil + }) externalHead := gitCommitAll(t, repo, "external handled commit", "external-handled.txt") if externalHead == baseHead { t.Fatalf("external commit did not advance HEAD") } - resumeReplay(t, ctx, env, repo) - wakeSession(t, ctx, env, repo, "ux-handled-daemon") - waitForEventState(t, dbPath, "external-handled.txt", "published", 8*time.Second) - waitForDecision(t, dbPath, "external-handled.txt", "handled_external", "already_published_by_external_committer", 8*time.Second) + waitForEventState(t, dbPath, "external-handled.txt", "published", 15*time.Second) + waitForDecision(t, dbPath, "external-handled.txt", "handled_external", "already_published_after_cas_exhaustion", 15*time.Second) if got := sqliteScalar(t, dbPath, "SELECT commit_oid FROM capture_events WHERE path = 'external-handled.txt' ORDER BY seq DESC LIMIT 1"); got != externalHead { t.Fatalf("published commit_oid=%q want external HEAD %s", got, externalHead) @@ -412,18 +443,6 @@ VALUES (%f, 'blocked', 'watch.txt', 'before-state mismatch', 'blocked_conflict', } } -func hasIntegrationFixAction(actions []struct { - Kind string `json:"kind"` - Seq int64 `json:"seq"` -}, kind string) bool { - for _, action := range actions { - if action.Kind == kind { - return true - } - } - return false -} - func waitForDecision(t *testing.T, dbPath, path, kind, reason string, timeout time.Duration) { t.Helper() query := fmt.Sprintf( diff --git a/test/integration/fix_consolidated_test.go b/test/integration/fix_consolidated_test.go index da04be9c..460c1d27 100644 --- a/test/integration/fix_consolidated_test.go +++ b/test/integration/fix_consolidated_test.go @@ -15,112 +15,61 @@ import ( "time" ) -// TestFix_BarrierWithSuccessorsRequiresForce pins the SPEC LOCK rule that -// purge_barrier_with_successors is gated behind --force. Without --force, -// even with --yes, the planner must NOT include the purge action and the -// CLI must refuse to delete a blocked barrier that has pending successors. -// With --force the dry-run plan must list the action; with --force --yes -// the row is deleted and publish_state cleared. -// -// Scenario shape mirrors the real Trekoon incident: blocked_conflict row at -// seq=N hides one or more pending captures at seq>N for the same anchor. -// HEAD does NOT match the captured after_oid, so resolve_already_landed_barrier -// is intentionally OUT of the plan. -func TestFix_BarrierWithSuccessorsRequiresForce(t *testing.T) { +// TestFix_ReconcilesWholeExactPairs pins the immutable recovery contract: +// exact HEAD matches publish the full pair, while explicit --force archives a +// non-matching pair without deleting or retargeting its captured rows. +func TestFix_ReconcilesWholeExactPairs(t *testing.T) { requireSQLite(t) repo := tempRepo(t) env := withIsolatedHome(t) t.Cleanup(func() { stopSessionForce(t, env, repo) }) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // Bring schema up via a start/stop cycle so seed SQL has all tables and - // register the repo in the central registry (acd fix looks it up there). - dbPath := initStateDBSchema(t, ctx, env, repo, "fix-barrier-init") - + baseHead := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) + writeFile(t, filepath.Join(repo, "published-a.txt"), "published a\n") + writeFile(t, filepath.Join(repo, "published-b.txt"), "published b\n") + runGitOK(t, repo, "add", "published-a.txt", "published-b.txt") + runGitOK(t, repo, "commit", "-q", "-m", "publish captured pair externally") head := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) + publishedAOID := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:published-a.txt")) + publishedBOID := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:published-b.txt")) + + dbPath := initStateDBSchema(t, ctx, env, repo, "fix-barrier-init") gen := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'branch.generation'") if gen == "" { gen = "1" } - // Captured after_oid is a content hash that does NOT exist anywhere in - // HEAD's tree — guarantees alreadyPublishedAtHEAD returns false and so - // resolve_already_landed_barrier is not eligible. The blocker is keyed - // by an error string that does NOT classify as before_state_mismatch, - // so the daemon's self-heal probe stays out of the picture even if a - // daemon happened to be alive. - bogusBefore := "1111111111111111111111111111111111111111" - bogusAfter := "2222222222222222222222222222222222222222" - successorAfter := gitHashObjectStdin(t, repo, "successor body\n") - now := nowFloatSeconds() - seedSQL := fmt.Sprintf(` -INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state, error) -VALUES ('refs/heads/main', %s, '%s', 'modify', 'barrier.txt', 'rescan', %f, 'blocked_conflict', 'cas_fail: ref moved during update'); -INSERT INTO capture_ops(event_seq, ord, op, path, before_oid, before_mode, after_oid, after_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'modify', 'barrier.txt', '%s', '100644', '%s', '100644', 'rescan'); -INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) -VALUES ('refs/heads/main', %s, '%s', 'create', 'successor.txt', 'exact', %f, 'pending'); -INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'create', 'successor.txt', '%s', '100644', 'exact'); -INSERT INTO publish_state(id, event_seq, branch_ref, branch_generation, source_head, target_commit_oid, status, error, updated_ts) -VALUES (1, NULL, 'refs/heads/main', %s, '%s', NULL, 'blocked_conflict', 'cas_fail: ref moved', %f); -`, gen, head, now, bogusBefore, bogusAfter, - gen, head, now+0.001, successorAfter, - gen, head, now+0.002) - if out, err := exec.Command("sqlite3", dbPath, seedSQL).CombinedOutput(); err != nil { - t.Fatalf("seed barrier rows: %v\n%s", err, out) - } - barrierSeq := sqliteScalar(t, dbPath, - "SELECT seq FROM capture_events WHERE path = 'barrier.txt' ORDER BY seq DESC LIMIT 1") - if barrierSeq == "" { - t.Fatalf("seeded barrier seq missing") - } - - // --dry-run WITHOUT --force: plan must NOT include purge_barrier_with_successors - // and the JSON suggestions block must nudge the operator toward --force. - dryRun := runAcd(t, ctx, env, "fix", "--repo", repo, "--dry-run", "--json") - if dryRun.ExitCode != 0 { - t.Fatalf("fix --dry-run exit=%d\nstdout=%s\nstderr=%s", dryRun.ExitCode, dryRun.Stdout, dryRun.Stderr) - } - plan := decodeFixPlan(t, dryRun.Stdout) - if !plan.DryRun { - t.Fatalf("plan.dry_run=false on --dry-run invocation\n%s", dryRun.Stdout) - } - if hasFixActionKind(plan.Actions, "purge_barrier_with_successors") { - t.Fatalf("--dry-run without --force planned purge_barrier_with_successors\n%s", dryRun.Stdout) - } - if hasFixActionKind(plan.Actions, "resolve_already_landed_barrier") { - t.Fatalf("--dry-run planned resolve_already_landed_barrier; HEAD must not match captured after_oid\n%s", dryRun.Stdout) + publishedPair := seedCreateRecoveryPair(t, dbPath, "refs/heads/main", gen, baseHead, + "published-a.txt", publishedAOID, "published-b.txt", publishedBOID) + apply := runAcd(t, ctx, env, "fix", "--repo", repo, "--yes", "--json") + if apply.ExitCode != 0 { + t.Fatalf("fix --yes exit=%d\nstdout=%s\nstderr=%s", apply.ExitCode, apply.Stdout, apply.Stderr) } - if !suggestionsMentionForce(plan.Suggestions) { - t.Fatalf("--dry-run suggestions did not nudge operator toward --force:\n%v", plan.Suggestions) + publishedPlan := decodeFixPlan(t, apply.Stdout) + publishedAction := findFixActionForSeq(publishedPlan.Actions, "reconcile_unpublished_chain", publishedPair.FirstSeq) + if publishedAction == nil || !publishedAction.Applied || publishedAction.State != "published" || publishedAction.RecoveryRef == "" { + t.Fatalf("normal fix did not publish exact HEAD pair: action=%+v\n%s", publishedAction, apply.Stdout) } - - // --yes WITHOUT --force: must refuse to mint the destructive purge. - // The plan rendered must still omit purge_barrier_with_successors and - // the seeded blocked row must survive. - yesNoForce := runAcd(t, ctx, env, "fix", "--repo", repo, "--yes", "--json") - // `acd fix --yes` may exit 0 (no qualifying safe actions) or non-zero - // (refused due to a daemon-alive unsafe reason). Either way, no - // destructive purge has been applied and the blocked row must still be - // present at its seeded seq. - if remaining := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT state FROM capture_events WHERE seq = %s", barrierSeq)); remaining != "blocked_conflict" { - t.Fatalf("--yes without --force mutated blocked row to state=%q (seq=%s)\nstdout=%s\nstderr=%s", - remaining, barrierSeq, yesNoForce.Stdout, yesNoForce.Stderr) + if got := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT group_concat(value, ',') FROM (SELECT state || '|' || commit_oid || '|' || branch_ref || '|' || branch_generation AS value FROM capture_events WHERE seq IN (%s,%s) ORDER BY seq)", + publishedPair.FirstSeq, publishedPair.SecondSeq)); got != fmt.Sprintf("published|%s|refs/heads/main|%s,published|%s|refs/heads/main|%s", head, gen, head, gen) { + t.Fatalf("published pair provenance/state=%q", got) } - yesPlan := decodeFixPlan(t, yesNoForce.Stdout) - if hasFixActionKind(yesPlan.Actions, "purge_barrier_with_successors") { - // Even if Applied=false, the kind being in the plan would indicate - // the planner ignored the --force gate. - t.Fatalf("--yes without --force planned purge_barrier_with_successors:\n%s", yesNoForce.Stdout) + if rows := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM capture_events WHERE seq IN (%s,%s)", publishedPair.FirstSeq, publishedPair.SecondSeq)); rows != "2" { + t.Fatalf("normal fix deleted captured rows: %s", rows) } + runGitOK(t, repo, "show-ref", "--verify", publishedAction.RecoveryRef) - // --force --dry-run: plan now includes the purge action keyed at the - // blocked seq. Still no mutation. + archiveAOID := gitHashObjectStdin(t, repo, "archive a\n") + archiveBOID := gitHashObjectStdin(t, repo, "archive b\n") + archivePair := seedCreateRecoveryPair(t, dbPath, "refs/heads/main", gen, head, + "archive-a.txt", archiveAOID, "archive-b.txt", archiveBOID) + refsBefore := recoveryRefList(t, repo) forceDry := runAcd(t, ctx, env, "fix", "--repo", repo, "--force", "--dry-run", "--json") if forceDry.ExitCode != 0 { t.Fatalf("fix --force --dry-run exit=%d\nstdout=%s\nstderr=%s", forceDry.ExitCode, forceDry.Stdout, forceDry.Stderr) @@ -129,41 +78,47 @@ VALUES (1, NULL, 'refs/heads/main', %s, '%s', NULL, 'blocked_conflict', 'cas_fai if !forcePlan.DryRun { t.Fatalf("--force --dry-run did not flag dry_run=true\n%s", forceDry.Stdout) } - if !hasFixActionKindForSeq(forcePlan.Actions, "purge_barrier_with_successors", barrierSeq) { - t.Fatalf("--force --dry-run plan missing purge_barrier_with_successors for seq=%s\n%s", - barrierSeq, forceDry.Stdout) + forceAction := findFixActionForSeq(forcePlan.Actions, "reconcile_unpublished_chain", archivePair.FirstSeq) + if forceAction == nil || !forceAction.ArchiveOnly || !forceAction.RequiresForce || forceAction.Applied { + t.Fatalf("--force --dry-run action=%+v\n%s", forceAction, forceDry.Stdout) } - if rem := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT state FROM capture_events WHERE seq = %s", barrierSeq)); rem != "blocked_conflict" { - t.Fatalf("--force --dry-run mutated state to %q", rem) + if got := exactPairStates(t, dbPath, archivePair); got != "blocked_conflict,pending" { + t.Fatalf("--force --dry-run mutated pair states to %q", got) + } + if refsAfter := recoveryRefList(t, repo); refsAfter != refsBefore { + t.Fatalf("--force --dry-run mutated recovery refs:\nbefore=%s\nafter=%s", refsBefore, refsAfter) } - // --force --yes: row is deleted, publish_state singleton flips to ok. forceApply := runAcd(t, ctx, env, "fix", "--repo", repo, "--force", "--yes", "--json") if forceApply.ExitCode != 0 { t.Fatalf("fix --force --yes exit=%d\nstdout=%s\nstderr=%s", forceApply.ExitCode, forceApply.Stdout, forceApply.Stderr) } - if rem := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT COUNT(*) FROM capture_events WHERE seq = %s", barrierSeq)); rem != "0" { - t.Fatalf("blocked seq %s still present after --force --yes\n%s", barrierSeq, forceApply.Stdout) + appliedPlan := decodeFixPlan(t, forceApply.Stdout) + appliedAction := findFixActionForSeq(appliedPlan.Actions, "reconcile_unpublished_chain", archivePair.FirstSeq) + if appliedAction == nil || !appliedAction.Applied || appliedAction.State != "recovered" || + appliedAction.RowsChanged != 2 || !strings.HasPrefix(appliedAction.RecoveryRef, "refs/acd/recovery/") { + t.Fatalf("--force --yes archive action=%+v\n%s", appliedAction, forceApply.Stdout) + } + if got := exactPairStates(t, dbPath, archivePair); got != "recovered,recovered" { + t.Fatalf("--force --yes pair states=%q", got) } - if pubStatus := sqliteScalar(t, dbPath, "SELECT status FROM publish_state WHERE id = 1"); pubStatus != "ok" { - dump, _ := exec.Command("sqlite3", dbPath, - "SELECT id,status,event_seq,error FROM publish_state").CombinedOutput() - t.Fatalf("publish_state.status=%q want ok after purge\nrows:\n%s", pubStatus, dump) + if rows := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM capture_events WHERE seq IN (%s,%s)", archivePair.FirstSeq, archivePair.SecondSeq)); rows != "2" { + t.Fatalf("--force --yes deleted captured rows: %s", rows) } - // Successor must remain pending — purge only removes the blocked row. - if rem := sqliteScalar(t, dbPath, - "SELECT state FROM capture_events WHERE path = 'successor.txt' ORDER BY seq DESC LIMIT 1"); rem != "pending" { - t.Fatalf("successor row state=%q want pending", rem) + if ops := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM capture_ops WHERE event_seq IN (%s,%s)", archivePair.FirstSeq, archivePair.SecondSeq)); ops != "2" { + t.Fatalf("--force --yes deleted captured ops: %s", ops) } + runGitOK(t, repo, "show-ref", "--verify", appliedAction.RecoveryRef) } // TestRecoverAndPurgeDeprecationWarnings asserts the legacy `acd recover` -// and `acd purge-events` entrypoints still work for one release while -// emitting the documented deprecation stderr line. Both must forward to the -// new acd fix paths so existing scripts keep functioning. +// and `acd purge-events` entrypoints retain their safe compatibility contract +// while emitting the documented deprecation stderr line. Recover delegates to +// immutable whole-pair recovery; purge refuses ambiguous selectors and requires +// explicit --all before preserving every planned pair. func TestRecoverAndPurgeDeprecationWarnings(t *testing.T) { requireSQLite(t) @@ -183,21 +138,10 @@ func TestRecoverAndPurgeDeprecationWarnings(t *testing.T) { gen = "1" } - // Seed a stale-anchor row: branch_ref/generation differs from current - // HEAD so acd recover --auto retargets it back onto refs/heads/main. - staleAfter := gitHashObjectStdin(t, repo, "stale anchor body\n") - now := nowFloatSeconds() - staleSQL := fmt.Sprintf(` -INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state, error) -VALUES ('refs/heads/stale', 99, '%s', 'create', 'stale.txt', 'exact', %f, 'blocked_conflict', 'old anchor'); -INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'create', 'stale.txt', '%s', '100644', 'exact'); -`, head, now, staleAfter) - if out, err := exec.Command("sqlite3", dbPath, staleSQL).CombinedOutput(); err != nil { - t.Fatalf("seed stale anchor: %v\n%s", err, out) - } - staleSeq := sqliteScalar(t, dbPath, - "SELECT seq FROM capture_events WHERE path = 'stale.txt' ORDER BY seq DESC LIMIT 1") + staleAOID := gitHashObjectStdin(t, repo, "stale a\n") + staleBOID := gitHashObjectStdin(t, repo, "stale b\n") + stalePair := seedCreateRecoveryPair(t, dbPath, "refs/heads/stale", "99", head, + "stale-a.txt", staleAOID, "stale-b.txt", staleBOID) // Deprecated path: acd recover --auto --yes. recover := runAcd(t, ctx, env, "recover", "--repo", repo, "--auto", "--yes", "--json") @@ -210,38 +154,50 @@ VALUES (last_insert_rowid(), 0, 'create', 'stale.txt', '%s', '100644', 'exact'); t.Fatalf("recover stderr missing deprecation banner\nwant: %q\nstderr: %q", wantRecoverDeprec, recover.Stderr) } - // Retarget must still complete — branch_ref/generation flip back to - // the current main/gen anchor on the stale row. - got := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT branch_ref || '|' || branch_generation || '|' || state FROM capture_events WHERE seq = %s", staleSeq)) - wantPrefix := "refs/heads/main|" - if !strings.HasPrefix(got, wantPrefix) { - t.Fatalf("stale row after recover=%q want prefix %q", got, wantPrefix) + recoverPlan := decodeFixPlan(t, recover.Stdout) + recoverAction := findFixActionForSeq(recoverPlan.Actions, "reconcile_unpublished_chain", stalePair.FirstSeq) + if recoverAction == nil || !recoverAction.Applied || recoverAction.State != "recovered" || recoverAction.RecoveryRef == "" { + t.Fatalf("recover alias action=%+v\n%s", recoverAction, recover.Stdout) } - if !strings.HasSuffix(got, "|pending") { - t.Fatalf("stale row after recover=%q want suffix |pending (blocked rows reset)", got) + if got := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT group_concat(value, ',') FROM (SELECT branch_ref || '|' || branch_generation || '|' || state AS value FROM capture_events WHERE seq IN (%s,%s) ORDER BY seq)", + stalePair.FirstSeq, stalePair.SecondSeq)); got != "refs/heads/stale|99|recovered,refs/heads/stale|99|recovered" { + t.Fatalf("recover alias retargeted or split stale pair: %q", got) } + if rows := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM capture_events WHERE seq IN (%s,%s)", stalePair.FirstSeq, stalePair.SecondSeq)); rows != "2" { + t.Fatalf("recover alias deleted captured rows: %s", rows) + } + runGitOK(t, repo, "show-ref", "--verify", recoverAction.RecoveryRef) - // Seed a fresh blocked row so the purge-events alias has work to do. - blockedSeed := fmt.Sprintf(` -INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state, error) -VALUES ('refs/heads/main', %s, '%s', 'modify', 'purge-me.txt', 'rescan', %f, 'blocked_conflict', 'cas_fail'); -INSERT INTO capture_ops(event_seq, ord, op, path, before_oid, before_mode, after_oid, after_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'modify', 'purge-me.txt', '1111111111111111111111111111111111111111', '100644', '2222222222222222222222222222222222222222', '100644', 'rescan'); -`, gen, head, now+1) - if out, err := exec.Command("sqlite3", dbPath, blockedSeed).CombinedOutput(); err != nil { - t.Fatalf("seed purge target: %v\n%s", err, out) + purgeAOID := gitHashObjectStdin(t, repo, "purge alias a\n") + purgeBOID := gitHashObjectStdin(t, repo, "purge alias b\n") + purgePair := seedCreateRecoveryPair(t, dbPath, "refs/heads/main", gen, head, + "purge-a.txt", purgeAOID, "purge-b.txt", purgeBOID) + + // The old selective spelling is now fail-closed because delegating it to a + // whole-repository fix could preserve unrelated failed or stale pairs. + snapshotsBefore := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM recovery_snapshots") + purge := runAcd(t, ctx, env, "purge-events", "--repo", repo, "--blocked", "--yes", "--json") + if purge.ExitCode == 0 { + t.Fatalf("acd purge-events --blocked --yes unexpectedly succeeded\nstdout=%s\nstderr=%s", + purge.Stdout, purge.Stderr) + } + if !strings.Contains(purge.Stderr, "selective --blocked/--pending/--failed recovery is no longer supported") { + t.Fatalf("purge-events selective refusal missing\nstdout=%s\nstderr=%s", + purge.Stdout, purge.Stderr) + } + if got := exactPairStates(t, dbPath, purgePair); got != "blocked_conflict,pending" { + t.Fatalf("refused purge alias changed pair states=%q", got) } - purgeSeq := sqliteScalar(t, dbPath, - "SELECT seq FROM capture_events WHERE path = 'purge-me.txt' ORDER BY seq DESC LIMIT 1") - if purgeSeq == "" { - t.Fatalf("purge target seq missing") + if got := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM recovery_snapshots"); got != snapshotsBefore { + t.Fatalf("refused purge alias snapshots=%s want unchanged %s", got, snapshotsBefore) } - // Deprecated path: acd purge-events --blocked --yes. - purge := runAcd(t, ctx, env, "purge-events", "--repo", repo, "--blocked", "--yes", "--json") + // Explicit --all retains the deprecated safe alias for one release. + purge = runAcd(t, ctx, env, "purge-events", "--repo", repo, "--all", "--yes", "--json") if purge.ExitCode != 0 { - t.Fatalf("acd purge-events --blocked --yes exit=%d\nstdout=%s\nstderr=%s", + t.Fatalf("acd purge-events --all --yes exit=%d\nstdout=%s\nstderr=%s", purge.ExitCode, purge.Stdout, purge.Stderr) } const wantPurgeDeprec = "acd purge-events is deprecated; use acd fix --force [--yes]. See acd fix --help." @@ -249,11 +205,20 @@ VALUES (last_insert_rowid(), 0, 'modify', 'purge-me.txt', '111111111111111111111 t.Fatalf("purge-events stderr missing deprecation banner\nwant: %q\nstderr: %q", wantPurgeDeprec, purge.Stderr) } - // Purge must still complete — the seeded blocked row is gone. - if cnt := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT COUNT(*) FROM capture_events WHERE seq = %s", purgeSeq)); cnt != "0" { - t.Fatalf("purge-events left blocked seq %s in DB (count=%s)", purgeSeq, cnt) + purgePlan := decodeFixPlan(t, purge.Stdout) + purgeAction := findFixActionForSeq(purgePlan.Actions, "reconcile_unpublished_chain", purgePair.FirstSeq) + if purgeAction == nil || !purgeAction.Applied || purgeAction.State != "recovered" || + !purgeAction.ArchiveOnly || purgeAction.RecoveryRef == "" { + t.Fatalf("purge alias action=%+v\n%s", purgeAction, purge.Stdout) + } + if got := exactPairStates(t, dbPath, purgePair); got != "recovered,recovered" { + t.Fatalf("purge alias pair states=%q", got) } + if rows := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT COUNT(*) FROM capture_events WHERE seq IN (%s,%s)", purgePair.FirstSeq, purgePair.SecondSeq)); rows != "2" { + t.Fatalf("purge alias deleted captured rows: %s", rows) + } + runGitOK(t, repo, "show-ref", "--verify", purgeAction.RecoveryRef) } func TestFix_GeneratedPendingCleanupKeepsGitManual(t *testing.T) { @@ -276,6 +241,8 @@ func TestFix_GeneratedPendingCleanupKeepsGitManual(t *testing.T) { ".derivedData-provider-core/Index.noindex/a.db", ".derivedData-provider-core/Index.noindex/b.db") runGitOK(t, repo, "commit", "-q", "-m", "track generated cache files") + aOID := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:.derivedData-provider-core/Index.noindex/a.db")) + bOID := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD:.derivedData-provider-core/Index.noindex/b.db")) if err := os.RemoveAll(filepath.Join(repo, ".derivedData-provider-core")); err != nil { t.Fatalf("remove generated root: %v", err) } @@ -292,19 +259,19 @@ func TestFix_GeneratedPendingCleanupKeepsGitManual(t *testing.T) { INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) VALUES ('refs/heads/main', %s, '%s', 'delete', '.derivedData-provider-core/Index.noindex/a.db', 'rescan', %f, 'pending'); INSERT INTO capture_ops(event_seq, ord, op, path, before_oid, before_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'delete', '.derivedData-provider-core/Index.noindex/a.db', '1111111111111111111111111111111111111111', '100644', 'rescan'); +VALUES (last_insert_rowid(), 0, 'delete', '.derivedData-provider-core/Index.noindex/a.db', '%s', '100644', 'rescan'); INSERT INTO planner_state(event_seq, defer_count, last_planned_ts) VALUES ((SELECT seq FROM capture_events WHERE path = '.derivedData-provider-core/Index.noindex/a.db' ORDER BY seq DESC LIMIT 1), 0, %f); INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) VALUES ('refs/heads/main', %s, '%s', 'delete', '.derivedData-provider-core/Index.noindex/b.db', 'rescan', %f, 'pending'); INSERT INTO capture_ops(event_seq, ord, op, path, before_oid, before_mode, fidelity) -VALUES (last_insert_rowid(), 0, 'delete', '.derivedData-provider-core/Index.noindex/b.db', '2222222222222222222222222222222222222222', '100644', 'rescan'); +VALUES (last_insert_rowid(), 0, 'delete', '.derivedData-provider-core/Index.noindex/b.db', '%s', '100644', 'rescan'); INSERT INTO planner_state(event_seq, defer_count, last_planned_ts) VALUES ((SELECT seq FROM capture_events WHERE path = '.derivedData-provider-core/Index.noindex/b.db' ORDER BY seq DESC LIMIT 1), 0, %f); INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) VALUES ('refs/heads/main', %s, '%s', 'delete', 'build/output.js', 'rescan', %f, 'pending'); -`, gen, head, now, now, - gen, head, now+0.001, now+0.001, +`, gen, head, now, aOID, now, + gen, head, now+0.001, bOID, now+0.001, gen, head, now+0.002) if out, err := exec.Command("sqlite3", dbPath, seedSQL).CombinedOutput(); err != nil { t.Fatalf("seed generated pending rows: %v\n%s", err, out) @@ -366,10 +333,18 @@ type fixPlanProbe struct { } type fixActionProbe struct { - Kind string `json:"kind"` - Seq int64 `json:"seq"` - Path string `json:"path"` - RequiresForce bool `json:"requires_force"` + Kind string `json:"kind"` + Seq int64 `json:"seq"` + Path string `json:"path"` + BranchRef string `json:"branch_ref"` + BranchGeneration int64 `json:"branch_generation"` + PendingCount int `json:"pending_count"` + RequiresForce bool `json:"requires_force"` + ArchiveOnly bool `json:"archive_only"` + Applied bool `json:"applied"` + RowsChanged int64 `json:"rows_changed"` + State string `json:"state"` + RecoveryRef string `json:"recovery_ref"` } func decodeFixPlan(t *testing.T, body string) fixPlanProbe { @@ -393,23 +368,60 @@ func hasFixActionKind(actions []fixActionProbe, kind string) bool { return false } -func hasFixActionKindForSeq(actions []fixActionProbe, kind, seq string) bool { - for _, a := range actions { - if a.Kind != kind { - continue - } - if fmt.Sprintf("%d", a.Seq) == seq { - return true +func findFixActionForSeq(actions []fixActionProbe, kind, seq string) *fixActionProbe { + for i := range actions { + if actions[i].Kind == kind && fmt.Sprintf("%d", actions[i].Seq) == seq { + return &actions[i] } } - return false + return nil } -func suggestionsMentionForce(suggestions []string) bool { - for _, s := range suggestions { - if strings.Contains(s, "--force") { - return true - } +type seededRecoveryPair struct { + FirstSeq string + SecondSeq string +} + +func seedCreateRecoveryPair( + t *testing.T, + dbPath, branchRef, generation, baseHead, firstPath, firstOID, secondPath, secondOID string, +) seededRecoveryPair { + t.Helper() + now := nowFloatSeconds() + seedSQL := fmt.Sprintf(` +INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state, error) +VALUES ('%s', %s, '%s', 'create', '%s', 'exact', %f, 'blocked_conflict', 'integration recovery barrier'); +INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) +VALUES (last_insert_rowid(), 0, 'create', '%s', '%s', '100644', 'exact'); +INSERT INTO capture_events(branch_ref, branch_generation, base_head, operation, path, fidelity, captured_ts, state) +VALUES ('%s', %s, '%s', 'create', '%s', 'exact', %f, 'pending'); +INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) +VALUES (last_insert_rowid(), 0, 'create', '%s', '%s', '100644', 'exact'); +`, branchRef, generation, baseHead, firstPath, now, firstPath, firstOID, + branchRef, generation, baseHead, secondPath, now+0.001, secondPath, secondOID) + if out, err := exec.Command("sqlite3", dbPath, seedSQL).CombinedOutput(); err != nil { + t.Fatalf("seed exact recovery pair: %v\n%s", err, out) } - return false + pair := seededRecoveryPair{ + FirstSeq: sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT seq FROM capture_events WHERE path = '%s' ORDER BY seq DESC LIMIT 1", firstPath)), + SecondSeq: sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT seq FROM capture_events WHERE path = '%s' ORDER BY seq DESC LIMIT 1", secondPath)), + } + if pair.FirstSeq == "" || pair.SecondSeq == "" { + t.Fatalf("seeded recovery pair missing seqs: %+v", pair) + } + return pair +} + +func exactPairStates(t *testing.T, dbPath string, pair seededRecoveryPair) string { + t.Helper() + return sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT group_concat(state, ',') FROM (SELECT state FROM capture_events WHERE seq IN (%s,%s) ORDER BY seq)", + pair.FirstSeq, pair.SecondSeq)) +} + +func recoveryRefList(t *testing.T, repo string) string { + t.Helper() + return runGitOK(t, repo, "for-each-ref", "--format=%(refname):%(objectname)", "refs/acd/recovery/") } diff --git a/test/integration/helpers_test.go b/test/integration/helpers_test.go index 4fa97512..cb8ecbd7 100644 --- a/test/integration/helpers_test.go +++ b/test/integration/helpers_test.go @@ -15,6 +15,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -34,15 +35,22 @@ var ( acdBinary string acdBinaryDir string acdBinaryErr error + + repoTemplateOnce sync.Once + repoTemplateDir string + repoTemplateErr error ) -// TestMain owns process-wide setup/teardown — currently just removing the -// build cache directory after the suite completes so /tmp stays clean. +// TestMain removes package-scoped binary and repository fixtures after the +// suite completes so /tmp stays clean. func TestMain(m *testing.M) { code := m.Run() if acdBinaryDir != "" { _ = os.RemoveAll(acdBinaryDir) } + if repoTemplateDir != "" { + _ = os.RemoveAll(repoTemplateDir) + } os.Exit(code) } @@ -102,23 +110,95 @@ func buildAcdBinary(t *testing.T) string { // cleanup beyond t.TempDir's automatic teardown. func tempRepo(t *testing.T) string { t.Helper() + repoTemplateOnce.Do(initRepoTemplate) + if repoTemplateErr != nil { + t.Fatalf("initialize integration repo template: %v", repoTemplateErr) + } + dir := t.TempDir() - gitInit(t, dir) - // Configure a user so commits succeed without global config. - for _, kv := range [][]string{ - {"user.email", "acd-integration@example.com"}, - {"user.name", "ACD Integration"}, - {"commit.gpgsign", "false"}, + if err := copyRepoTemplate(repoTemplateDir, dir); err != nil { + t.Fatalf("materialize integration repo template: %v", err) + } + return dir +} + +func initRepoTemplate() { + dir, err := os.MkdirTemp("", "acd-integration-repo-template-*") + if err != nil { + repoTemplateErr = err + return + } + repoTemplateDir = dir + + if out, err := exec.Command("git", "init", "-q", dir).CombinedOutput(); err != nil { + repoTemplateErr = fmt.Errorf("git init: %w\n%s", err, out) + return + } + for _, args := range [][]string{ + {"symbolic-ref", "HEAD", "refs/heads/main"}, + {"config", "user.email", "acd-integration@example.com"}, + {"config", "user.name", "ACD Integration"}, + {"config", "commit.gpgsign", "false"}, } { - runGitOK(t, dir, "config", kv[0], kv[1]) + if out, err := runGit(dir, args...); err != nil { + repoTemplateErr = fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out) + return + } } - // Seed commit so HEAD exists. if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("# acd integration seed\n"), 0o644); err != nil { - t.Fatalf("seed: %v", err) + repoTemplateErr = fmt.Errorf("write seed: %w", err) + return } - runGitOK(t, dir, "add", ".gitignore") - runGitOK(t, dir, "commit", "-q", "-m", "seed") - return dir + for _, args := range [][]string{{"add", ".gitignore"}, {"commit", "-q", "-m", "seed"}} { + if out, err := runGit(dir, args...); err != nil { + repoTemplateErr = fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out) + return + } + } +} + +func copyRepoTemplate(src, dst string) error { + return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if entry.Type()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(linkTarget, target) + } + if !entry.Type().IsRegular() { + return fmt.Errorf("unsupported fixture entry %s with mode %s", rel, info.Mode()) + } + in, err := os.Open(path) + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + _ = in.Close() + return err + } + _, copyErr := io.Copy(out, in) + return errors.Join(copyErr, out.Close(), in.Close()) + }) } // gitInit runs `git init -q dir`. diff --git a/test/integration/intent_planner_normalization_test.go b/test/integration/intent_planner_normalization_test.go index c954078c..510447bc 100644 --- a/test/integration/intent_planner_normalization_test.go +++ b/test/integration/intent_planner_normalization_test.go @@ -297,7 +297,7 @@ func TestIntentStrategy_PlannerRejectsLogCapturesValidationFailure(t *testing.T) } } -func TestIntentStrategy_SingletonShortCircuitUsesMessageProvider(t *testing.T) { +func TestIntentStrategy_SingletonTransportFailureOpensCircuit(t *testing.T) { if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 binary required") } @@ -305,56 +305,14 @@ func TestIntentStrategy_SingletonShortCircuitUsesMessageProvider(t *testing.T) { env := withIsolatedHome(t) t.Cleanup(func() { stopSessionForce(t, env, repo) }) - var messageHits atomic.Int32 var plannerHits atomic.Int32 server, trustEnv := newOpenAITestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/chat/completions") { http.Error(w, "wrong path", http.StatusNotFound) return } - req := decodeIntentChatRequest(t, r) - for _, msg := range req.Messages { - if strings.HasPrefix(msg.Content, "Plan the next commit intent for these offered captures:\n") { - plannerHits.Add(1) - http.Error(w, "singleton path must not call intent planner", http.StatusInternalServerError) - return - } - } - messageHits.Add(1) - args, err := json.Marshal(map[string]string{ - "subject": "Singleton openai subject", - "body": "Generated by the per-event message provider.", - }) - if err != nil { - t.Fatalf("marshal singleton message args: %v", err) - } - resp := map[string]any{ - "id": "chatcmpl-singleton-message", - "object": "chat.completion", - "model": "gpt-5.4-mini", - "choices": []map[string]any{{ - "index": 0, - "message": map[string]any{ - "role": "assistant", - "content": "", - "tool_calls": []map[string]any{{ - "id": "call_commit_message_singleton", - "type": "function", - "function": map[string]any{ - "name": "commit_message", - "arguments": string(args), - }, - }}, - }, - "finish_reason": "tool_calls", - }}, - } - body, err := json.Marshal(resp) - if err != nil { - t.Fatalf("marshal singleton response: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) + plannerHits.Add(1) + http.Error(w, "planner temporarily unavailable", http.StatusServiceUnavailable) })) defer server.Close() @@ -367,32 +325,51 @@ func TestIntentStrategy_SingletonShortCircuitUsesMessageProvider(t *testing.T) { "ACD_INTENT_MIN_PENDING=1", "ACD_INTENT_SETTLE_WINDOW=0", "ACD_INTENT_MAX_PENDING_AGE=1h", + "ACD_INTENT_RETRY_ON_INVALID=0", "ACD_AI_PROVIDER=openai-compat", "ACD_AI_BASE_URL=" + server.URL, "ACD_AI_API_KEY=test-key", "ACD_AI_MODEL=gpt-5.4-mini", trustEnv, } - startSession(t, ctx, env, repo, "intent-singleton-shortcircuit", "shell", extra...) + startSession(t, ctx, env, repo, "intent-singleton-circuit", "shell", extra...) waitMode(t, repo, "running", 5*time.Second) + dbPath := filepath.Join(repo, ".git", "acd", "state.db") + startCount := commitCount(t, repo) headBefore := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) - writeFile(t, filepath.Join(repo, "singleton.txt"), "one\n") - wakeSession(t, ctx, envWith(env, extra...), repo, "intent-singleton-shortcircuit") + writeFile(t, filepath.Join(repo, "singleton-one.txt"), "one\n") + wakeSession(t, ctx, envWith(env, extra...), repo, "intent-singleton-circuit") waitHeadAdvances(t, repo, headBefore, 10*time.Second) - - if got := plannerHits.Load(); got != 0 { - t.Fatalf("planner hits=%d want 0 for singleton short-circuit", got) + waitForEventState(t, dbPath, "singleton-one.txt", "published", 10*time.Second) + waitFor(t, "first planner error", 10*time.Second, func() bool { + return sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM decision_records WHERE kind='intent_planner_error'") == "1" + }) + if got := plannerHits.Load(); got != 1 { + t.Fatalf("planner hits after first capture=%d want 1", got) } - if got := messageHits.Load(); got != 1 { - t.Fatalf("message hits=%d want 1", got) + + headAfterFirst := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) + writeFile(t, filepath.Join(repo, "singleton-two.txt"), "two\n") + wakeSession(t, ctx, envWith(env, extra...), repo, "intent-singleton-circuit") + waitHeadAdvances(t, repo, headAfterFirst, 10*time.Second) + waitForEventState(t, dbPath, "singleton-two.txt", "published", 10*time.Second) + + if got := plannerHits.Load(); got != 1 { + t.Fatalf("planner hits after cooldown bypass=%d want 1", got) } - if subj := headSubject(t, repo); subj != "Singleton openai subject" { - t.Fatalf("subject=%q want per-event provider subject", subj) + if got := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM decision_records WHERE kind='intent_planner_error'"); got != "1" { + t.Fatalf("planner error decisions=%s want 1 after circuit bypass", got) } - dbPath := filepath.Join(repo, ".git", "acd", "state.db") - reason := sqliteScalar(t, dbPath, "SELECT reason FROM decision_records WHERE kind='committed' ORDER BY event_seq DESC LIMIT 1") - if !strings.Contains(reason, "singleton fast path") { - t.Fatalf("decision reason=%q want singleton fast path", reason) + waitFor(t, "persisted planner circuit bypass", 10*time.Second, func() bool { + raw := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key='intent.planner.health'") + var health struct { + State string `json:"state"` + BypassCount uint64 `json:"bypass_count"` + } + return json.Unmarshal([]byte(raw), &health) == nil && health.State == "open" && health.BypassCount >= 1 + }) + if got := commitCount(t, repo); got != startCount+2 { + t.Fatalf("commit count=%d want two deterministic fallback commits after initial HEAD", got) } } diff --git a/test/integration/reliability_recover_test.go b/test/integration/reliability_recover_test.go index cc165eb6..997147b7 100644 --- a/test/integration/reliability_recover_test.go +++ b/test/integration/reliability_recover_test.go @@ -20,7 +20,7 @@ import ( "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" ) -func TestRecoverReplaysIncidentFixture(t *testing.T) { +func TestFixArchivesIncidentFixtureWithoutRetargeting(t *testing.T) { buildAcdBinary(t) repo := tempRepo(t) @@ -90,39 +90,77 @@ VALUES (last_insert_rowid(), 0, 'create', 'recover.txt', '%s', '100644', 'exact' t.Fatalf("inject incident fixture: %v\n%s", err, out) } - dry := runAcd(t, ctx, env, "recover", "--repo", repo, "--auto", "--dry-run", "--json") + refsBefore := recoveryRefList(t, repo) + dry := runAcd(t, ctx, env, "fix", "--repo", repo, "--dry-run", "--json") if dry.ExitCode != 0 { - t.Fatalf("acd recover dry-run exit=%d\nstdout=%s\nstderr=%s", dry.ExitCode, dry.Stdout, dry.Stderr) + t.Fatalf("acd fix dry-run exit=%d\nstdout=%s\nstderr=%s", dry.ExitCode, dry.Stdout, dry.Stderr) } if state := sqliteScalar(t, dbPath, "SELECT state FROM capture_events WHERE path = 'recover.txt'"); state != "blocked_conflict" { t.Fatalf("dry-run mutated event state=%q", state) } + if got := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM recovery_snapshots"); got != "0" { + t.Fatalf("dry-run created recovery snapshots=%s", got) + } + if refsAfter := recoveryRefList(t, repo); refsAfter != refsBefore { + t.Fatalf("dry-run mutated recovery refs:\nbefore=%s\nafter=%s", refsBefore, refsAfter) + } - applied := runAcd(t, ctx, env, "recover", "--repo", repo, "--auto", "--yes", "--json") + applied := runAcd(t, ctx, env, "fix", "--repo", repo, "--yes", "--json") if applied.ExitCode != 0 { - t.Fatalf("acd recover apply exit=%d\nstdout=%s\nstderr=%s", applied.ExitCode, applied.Stdout, applied.Stderr) - } - var payload struct { - BackupPath string `json:"backup_path"` + t.Fatalf("acd fix apply exit=%d\nstdout=%s\nstderr=%s", applied.ExitCode, applied.Stdout, applied.Stderr) } + var payload reliabilityFixPlan if err := json.Unmarshal([]byte(applied.Stdout), &payload); err != nil { - t.Fatalf("decode recover output: %v\n%s", err, applied.Stdout) + t.Fatalf("decode fix output: %v\n%s", err, applied.Stdout) } if payload.BackupPath == "" { - t.Fatalf("recover output missing backup path: %s", applied.Stdout) + t.Fatalf("fix output missing backup path: %s", applied.Stdout) } if _, err := os.Stat(payload.BackupPath); err != nil { t.Fatalf("backup missing: %v", err) } - if got := sqliteScalar(t, dbPath, "SELECT branch_ref || '|' || state FROM capture_events WHERE path = 'recover.txt'"); got != "refs/heads/main|pending" { - t.Fatalf("event after recover=%q want refs/heads/main|pending", got) + if len(payload.Actions) != 1 { + t.Fatalf("fix actions=%d want 1\n%s", len(payload.Actions), applied.Stdout) + } + action := payload.Actions[0] + if action.Kind != "reconcile_unpublished_chain" || !action.Applied || + action.State != "recovered" || action.RowsChanged != 1 || + !strings.HasPrefix(action.RecoveryRef, "refs/acd/recovery/") || + !strings.HasSuffix(action.RecoveryRef, "/archive") { + t.Fatalf("unexpected recovery action=%+v\n%s", action, applied.Stdout) + } + if got := sqliteScalar(t, dbPath, "SELECT branch_ref || '|' || branch_generation || '|' || state FROM capture_events WHERE path = 'recover.txt'"); got != "refs/heads/stale|3|recovered" { + t.Fatalf("event after fix=%q want refs/heads/stale|3|recovered", got) + } + seq := sqliteScalar(t, dbPath, "SELECT seq FROM capture_events WHERE path = 'recover.txt'") + snapshotID := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT snapshot_id FROM recovery_snapshot_events WHERE event_seq = %s", seq)) + if snapshotID == "" { + t.Fatal("recovered event has no protected snapshot membership") + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || event_count || '|' || first_event_seq || '|' || last_event_seq || '|' || recovery_ref +FROM recovery_snapshots WHERE id = %s`, snapshotID)); got != + fmt.Sprintf("recovered|1|%s|%s|%s", seq, seq, action.RecoveryRef) { + t.Fatalf("recovery snapshot=%q", got) + } + recoveryCommit := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT commit_oid FROM recovery_snapshots WHERE id = %s", snapshotID)) + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", action.RecoveryRef)); got != recoveryCommit { + t.Fatalf("recovery ref resolves to %q want %q", got, recoveryCommit) } - if got := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM daemon_meta WHERE key = 'last_replay_conflict'"); got != "0" { - t.Fatalf("last_replay_conflict rows=%s want 0", got) + if got := strings.TrimSpace(runGitOK(t, repo, "rev-parse", action.RecoveryRef+":recover.txt")); got != afterOID { + t.Fatalf("archived recover.txt oid=%s want %s", got, afterOID) + } + // The fixture intentionally has no matching publish_state breadcrumb. Exact + // recovery must not erase unrelated diagnostic metadata merely because its + // text mentions the recovered seq. + if got := sqliteScalar(t, dbPath, "SELECT value FROM daemon_meta WHERE key = 'last_replay_conflict'"); got != `{"seq":1,"error_class":"cas_fail"}` { + t.Fatalf("unrelated last_replay_conflict changed=%q", got) } } -func TestRecoverRepairsPublishedStaleLiveIndex(t *testing.T) { +func TestFixLeavesPublishedStaleLiveIndexUntouched(t *testing.T) { buildAcdBinary(t) repo := tempRepo(t) @@ -178,6 +216,7 @@ func TestRecoverRepairsPublishedStaleLiveIndex(t *testing.T) { if !strings.Contains(status, "D legacy.txt") || !strings.Contains(status, "?? legacy.txt") { t.Fatalf("test did not create stale live-index shape:\n%s", status) } + statusBefore := status now := nowFloatSeconds() inject := fmt.Sprintf(` @@ -192,20 +231,38 @@ VALUES (last_insert_rowid(), 0, 'create', 'legacy.txt', '%s', '100644', 'exact') t.Fatalf("inject published fixture: %v\n%s", err, out) } - applied := runAcd(t, ctx, env, "recover", "--repo", repo, "--auto", "--yes", "--json") + applied := runAcd(t, ctx, env, "fix", "--repo", repo, "--yes", "--json") if applied.ExitCode != 0 { - t.Fatalf("acd recover apply exit=%d\nstdout=%s\nstderr=%s", applied.ExitCode, applied.Stdout, applied.Stderr) - } - var payload struct { - LiveIndexApplied int `json:"live_index_applied"` + t.Fatalf("acd fix apply exit=%d\nstdout=%s\nstderr=%s", applied.ExitCode, applied.Stdout, applied.Stderr) } + var payload reliabilityFixPlan if err := json.Unmarshal([]byte(applied.Stdout), &payload); err != nil { - t.Fatalf("decode recover output: %v\n%s", err, applied.Stdout) + t.Fatalf("decode fix output: %v\n%s", err, applied.Stdout) + } + if len(payload.Actions) != 0 || payload.RowsChanged != 0 || payload.BackupPath != "" { + t.Fatalf("published-only fixture should need no fix actions: %+v\n%s", payload, applied.Stdout) + } + if statusAfter := strings.TrimSpace(runGitOK(t, repo, "status", "--porcelain")); statusAfter != statusBefore { + t.Fatalf("fix mutated the user's stale live index:\nbefore:\n%s\nafter:\n%s", statusBefore, statusAfter) } - if payload.LiveIndexApplied != 1 { - t.Fatalf("live_index_applied=%d want 1\n%s", payload.LiveIndexApplied, applied.Stdout) + if got := sqliteScalar(t, dbPath, "SELECT state || '|' || commit_oid FROM capture_events WHERE path = 'legacy.txt'"); got != "published|"+publishedHead { + t.Fatalf("published event changed=%q", got) } - if status := strings.TrimSpace(runGitOK(t, repo, "status", "--porcelain")); status != "" { - t.Fatalf("status after live-index repair not clean:\n%s", status) + if got := sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM recovery_snapshots"); got != "0" { + t.Fatalf("published-only fixture created recovery snapshots=%s", got) } } + +type reliabilityFixPlan struct { + BackupPath string `json:"backup_path"` + RowsChanged int64 `json:"rows_changed"` + Actions []reliabilityFixAction `json:"actions"` +} + +type reliabilityFixAction struct { + Kind string `json:"kind"` + Applied bool `json:"applied"` + RowsChanged int64 `json:"rows_changed"` + State string `json:"state"` + RecoveryRef string `json:"recovery_ref"` +} diff --git a/test/integration/self_heal_blocked_test.go b/test/integration/self_heal_blocked_test.go index 81bbe4f8..568b5bb1 100644 --- a/test/integration/self_heal_blocked_test.go +++ b/test/integration/self_heal_blocked_test.go @@ -14,9 +14,8 @@ import ( "time" ) -// TestSelfHeal_BlockedPromotesToPublishedOnReplay drives Wave 2's idempotent -// self-heal probe (probeBlockedSelfHeal in internal/daemon/replay.go) through -// the real daemon. Scenario: +// TestSelfHeal_BlockedPromotesToPublishedOnReplay drives exact-chain +// reconciliation through the real daemon. Scenario: // // 1. Seed a baseline commit so the tracked path exists in HEAD with // before_oid resolvable to a real blob. @@ -27,14 +26,18 @@ import ( // update-ref) that contains exactly after_oid at the same path. This // advances HEAD without going through acd, so daemon sees its captured // intent already on disk. -// 4. Wake the session. probeBlockedSelfHeal must: +// 4. Wake the session. The runtime branch-transition gate must: // * promote the row state blocked_conflict -> published with // commit_oid = HEAD, -// * append decision_records kind=handled_external_after_block, -// * upsert publish_state singleton status=published, +// * append decision_records kind=recovery_published, +// * create an exact one-event recovery snapshot and protected proof ref, // * NOT mint a new commit (HEAD unchanged after settle). +// +// The branch-transition gate intentionally runs before replay, so the legacy +// handled_external_after_block probe is not reached when HEAD has advanced. func TestSelfHeal_BlockedPromotesToPublishedOnReplay(t *testing.T) { requireSQLite(t) + t.Parallel() repo := tempRepo(t) env := withIsolatedHome(t) @@ -127,22 +130,7 @@ VALUES (last_insert_rowid(), 0, 'modify', 'self-heal.txt', '%s', '100644', '%s', t.Fatalf("published commit_oid=%q want external HEAD %q", publishedOID, externalHead) } - // decision_records: exactly one handled_external_after_block for this seq. - decisionCount := sqliteScalar(t, dbPath, - fmt.Sprintf("SELECT COUNT(*) FROM decision_records WHERE event_seq = %s AND kind = 'handled_external_after_block'", blockedSeq)) - if decisionCount != "1" { - dump, _ := exec.Command("sqlite3", dbPath, - "SELECT id,kind,event_seq,reason FROM decision_records ORDER BY id").CombinedOutput() - t.Fatalf("handled_external_after_block decision count=%s want 1\nrows:\n%s", decisionCount, dump) - } - - // publish_state singleton flips to status='published'. - pubStatus := sqliteScalar(t, dbPath, "SELECT status FROM publish_state WHERE id = 1") - if pubStatus != "published" { - dump, _ := exec.Command("sqlite3", dbPath, - "SELECT id,status,event_seq,target_commit_oid,error FROM publish_state").CombinedOutput() - t.Fatalf("publish_state.status=%q want published\nrows:\n%s", pubStatus, dump) - } + assertPublishedRecoverySnapshot(t, repo, dbPath, blockedSeq, externalHead, "runtime_branch_transition") // HEAD must NOT advance past the external commit. Self-heal cannot mint // a fresh commit. @@ -161,6 +149,45 @@ VALUES (last_insert_rowid(), 0, 'modify', 'self-heal.txt', '%s', '100644', '%s', } } +// assertPublishedRecoverySnapshot proves that an externally published event +// has both durable SQLite membership and a live hidden Git proof ref. +func assertPublishedRecoverySnapshot(t *testing.T, repo, dbPath, eventSeq, wantCommit, wantReason string) { + t.Helper() + snapshotID := sqliteScalar(t, dbPath, fmt.Sprintf( + "SELECT snapshot_id FROM recovery_snapshot_events WHERE event_seq = %s", eventSeq)) + if snapshotID == "" { + t.Fatalf("event seq=%s has no recovery snapshot membership", eventSeq) + } + wantSummary := fmt.Sprintf("published|1|%s|%s|%s|%s", eventSeq, eventSeq, wantCommit, wantReason) + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || event_count || '|' || first_event_seq || '|' || + last_event_seq || '|' || commit_oid || '|' || reason +FROM recovery_snapshots WHERE id = %s`, snapshotID)); got != wantSummary { + t.Fatalf("recovery snapshot summary=%q want %q", got, wantSummary) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM recovery_snapshot_events +WHERE snapshot_id = %s AND ord = 0 AND event_seq = %s`, snapshotID, eventSeq)); got != "1" { + t.Fatalf("recovery snapshot membership=%s want 1", got) + } + recoveryRef := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT recovery_ref FROM recovery_snapshots WHERE id = %s", snapshotID)) + if !strings.HasPrefix(recoveryRef, "refs/acd/recovery/") || !strings.HasSuffix(recoveryRef, "/published") { + t.Fatalf("recovery ref=%q want refs/acd/recovery/.../published", recoveryRef) + } + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", recoveryRef)); got != wantCommit { + t.Fatalf("recovery ref resolves to %q want external commit %q", got, wantCommit) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM decision_records +WHERE event_seq = %s AND kind = 'recovery_published' AND reason = %s`, + eventSeq, sqliteQuote(wantReason))); got != "1" { + dump, _ := exec.Command("sqlite3", dbPath, + "SELECT id,kind,event_seq,reason FROM decision_records ORDER BY id").CombinedOutput() + t.Fatalf("recovery_published decision count=%s want 1\nrows:\n%s", got, dump) + } +} + // externalCommitAtPath stages `path` with the supplied blob oid against // parent, builds a tree + commit via plumbing, and fast-forwards HEAD to it. // Returns the new HEAD oid. Used to simulate a parallel committer landing diff --git a/test/integration/self_heal_test.go b/test/integration/self_heal_test.go index bd8c25e5..e29e6e69 100644 --- a/test/integration/self_heal_test.go +++ b/test/integration/self_heal_test.go @@ -17,9 +17,20 @@ import ( func TestSelfHeal_ParallelCommitterDoesNotBlock(t *testing.T) { requireSQLite(t) + t.Parallel() repo := tempRepo(t) - env := withIsolatedHome(t) + env := envWith(withIsolatedHome(t), + "ACD_COMMIT_STRATEGY=intent", + "ACD_INTENT_WINDOW=10", + "ACD_INTENT_MIN_PENDING=2", + "ACD_INTENT_SETTLE_WINDOW=1h", + "ACD_INTENT_MAX_PENDING_AGE=1h", + "ACD_AI_PROVIDER=deterministic", + "ACD_PATH_QUIESCENCE_SECONDS=0", + "ACD_REWIND_GRACE_SECONDS=0", + "ACD_FSNOTIFY_ENABLED=0", + ) t.Cleanup(func() { stopSessionForce(t, env, repo) }) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -38,18 +49,23 @@ func TestSelfHeal_ParallelCommitterDoesNotBlock(t *testing.T) { t.Fatalf("initial HEAD=%s want baseline %s", initialHead, baselineHead) } - pauseReplay(t, ctx, env, repo, "parallel committer test") writeFile(t, target, "same change\n") - // Under the new contract, manual pause halts BOTH capture and replay. - // The worktree edit will not be captured until after resume; only then - // does the daemon diff worktree against shadow_paths and queue events. + wakeSession(t, ctx, env, repo, "selfheal-parallel") + // Intent mode's count gate holds one capture pending without pausing + // capture. This gives the external committer a real unpublished chain to + // satisfy; a manual pause would suppress capture and invalidate the test. + waitForEventState(t, dbPath, "parallel.txt", "pending", 8*time.Second) + pendingSeq := sqliteScalar(t, dbPath, + "SELECT seq FROM capture_events WHERE path = 'parallel.txt' AND state = 'pending' ORDER BY seq DESC LIMIT 1") + if pendingSeq == "" { + t.Fatal("parallel capture did not remain pending before external commit") + } externalHead := gitCommitAll(t, repo, "external parallel commit", "parallel.txt") if externalHead == initialHead { t.Fatal("external commit did not advance HEAD") } - resumeReplay(t, ctx, env, repo) wakeSession(t, ctx, env, repo, "selfheal-parallel") waitForEventState(t, dbPath, "parallel.txt", "published", 8*time.Second) @@ -58,6 +74,7 @@ func TestSelfHeal_ParallelCommitterDoesNotBlock(t *testing.T) { if publishedOID != externalHead { t.Fatalf("published commit_oid=%q want external HEAD %q", publishedOID, externalHead) } + assertPublishedRecoverySnapshot(t, repo, dbPath, pendingSeq, externalHead, "runtime_branch_transition") assertNoSelfHealTerminalRows(t, dbPath) head := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) diff --git a/test/integration/squash_backlog_recovery_test.go b/test/integration/squash_backlog_recovery_test.go new file mode 100644 index 00000000..6744ec4f --- /dev/null +++ b/test/integration/squash_backlog_recovery_test.go @@ -0,0 +1,254 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestSquashBacklogRecovery_PreservesSixtyCapturesAndControls proves the +// original failure shape at production boundaries: a feature branch leaves a +// blocked 60-event queue, its work is squash-merged to main, the feature ref is +// deleted, and a fresh daemon must reconcile the whole immutable pair before +// accepting main. Lifecycle off/on must not disturb the proof snapshot or ref. +func TestSquashBacklogRecovery_PreservesSixtyCapturesAndControls(t *testing.T) { + requireSQLite(t) + t.Parallel() + + repo := tempRepo(t) + env := envWith(withIsolatedHome(t), + "ACD_COMMIT_STRATEGY=event", + "ACD_AI_PROVIDER=deterministic", + "ACD_REWIND_GRACE_SECONDS=0", + ) + t.Cleanup(func() { stopSessionForce(t, env, repo) }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + const featureName = "feature/recovery-backlog" + const featureRef = "refs/heads/" + featureName + runGitOK(t, repo, "checkout", "-q", "-b", featureName) + baseHead := strings.TrimSpace(runGitOK(t, repo, "rev-parse", "HEAD")) + dbPath := initStateDBSchema(t, ctx, env, repo, "squash-backlog-bootstrap") + generation := sqliteScalar(t, dbPath, + "SELECT value FROM daemon_meta WHERE key = 'branch.generation'") + if generation == "" { + generation = "1" + } + + type capturedFile struct { + path string + oid string + } + paths := make([]string, 0, 60) + var seed strings.Builder + seed.WriteString("BEGIN;\n") + for i := 0; i < 60; i++ { + rel := fmt.Sprintf("backlog/file-%02d.txt", i) + body := fmt.Sprintf("squash backlog payload %02d\n", i) + writeFile(t, filepath.Join(repo, rel), body) + paths = append(paths, rel) + } + oids := strings.Fields(runGitOK(t, repo, + append([]string{"hash-object", "-w", "--"}, paths...)...)) + if len(oids) != len(paths) { + t.Fatalf("batched hash-object returned %d oids want %d", len(oids), len(paths)) + } + files := make([]capturedFile, 0, len(paths)) + for i, rel := range paths { + oid := oids[i] + files = append(files, capturedFile{path: rel, oid: oid}) + eventState := "pending" + errorValue := "NULL" + if i == 0 { + eventState = "blocked_conflict" + errorValue = "'integration squash backlog barrier'" + } + fmt.Fprintf(&seed, ` +INSERT INTO capture_events( + branch_ref, branch_generation, base_head, operation, path, + fidelity, captured_ts, state, error +) VALUES ('%s', %s, '%s', 'create', '%s', 'exact', %.6f, '%s', %s); +INSERT INTO capture_ops(event_seq, ord, op, path, after_oid, after_mode, fidelity) +VALUES (last_insert_rowid(), 0, 'create', '%s', '%s', '100644', 'exact'); +`, featureRef, generation, baseHead, rel, + nowFloatSeconds()+float64(i)/1000, eventState, errorValue, + rel, oid) + } + seed.WriteString("COMMIT;\n") + if out := sqliteExec(t, dbPath, seed.String()); strings.TrimSpace(out) != "" { + t.Fatalf("seed 60-event feature backlog returned output: %s", out) + } + firstSeq := sqliteScalar(t, dbPath, + "SELECT MIN(seq) FROM capture_events WHERE branch_ref = '"+featureRef+"' AND branch_generation = "+generation) + lastSeq := sqliteScalar(t, dbPath, + "SELECT MAX(seq) FROM capture_events WHERE branch_ref = '"+featureRef+"' AND branch_generation = "+generation) + if firstSeq == "" || lastSeq == "" { + t.Fatal("seeded backlog seq range is empty") + } + + runGitOK(t, repo, "add", "backlog") + runGitOK(t, repo, "commit", "-q", "-m", "build feature backlog") + runGitOK(t, repo, "checkout", "-q", "main") + runGitOK(t, repo, "merge", "--squash", featureName) + runGitOK(t, repo, "commit", "-q", "-m", "squash feature backlog") + runGitOK(t, repo, "branch", "-D", featureName) + if dirty := strings.TrimSpace(runGitOK(t, repo, "status", "--porcelain=v1")); dirty != "" { + t.Fatalf("worktree dirty before daemon startup:\n%s", dirty) + } + + startSession(t, ctx, env, repo, "squash-backlog-recovery", "shell") + waitMode(t, repo, "running", 5*time.Second) + waitFor(t, "60-event squash backlog reconciliation", 30*time.Second, func() bool { + wakeSession(t, ctx, env, repo, "squash-backlog-recovery") + got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT + (SELECT COUNT(*) FROM capture_events + WHERE seq BETWEEN %s AND %s AND state IN ('published','recovered')) || '|' || + (SELECT COUNT(*) FROM capture_events + WHERE seq BETWEEN %s AND %s AND state IN ('pending','blocked_conflict','failed')) || '|' || + (SELECT COUNT(*) FROM recovery_snapshots + WHERE first_event_seq = %s AND last_event_seq = %s AND event_count = 60)`, + firstSeq, lastSeq, firstSeq, lastSeq, firstSeq, lastSeq)) + return got == "60|0|1" + }) + + snapshotID := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT id FROM recovery_snapshots +WHERE first_event_seq = %s AND last_event_seq = %s AND event_count = 60`, firstSeq, lastSeq)) + if snapshotID == "" { + t.Fatal("60-event recovery snapshot is missing") + } + snapshotSig := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || branch_ref || '|' || branch_generation || '|' || + first_event_seq || '|' || last_event_seq || '|' || event_count || '|' || + commit_oid || '|' || recovery_ref +FROM recovery_snapshots WHERE id = %s`, snapshotID)) + recoveryRef := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT recovery_ref FROM recovery_snapshots WHERE id = %s", snapshotID)) + recoveryCommit := sqliteScalar(t, dbPath, + fmt.Sprintf("SELECT commit_oid FROM recovery_snapshots WHERE id = %s", snapshotID)) + if !strings.HasPrefix(recoveryRef, "refs/acd/recovery/") { + t.Fatalf("recovery ref=%q", recoveryRef) + } + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", recoveryRef)); got != recoveryCommit { + t.Fatalf("recovery ref resolves to %q want %q", got, recoveryCommit) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM recovery_snapshot_events WHERE snapshot_id = %s`, snapshotID)); got != "60" { + t.Fatalf("snapshot membership=%s want 60", got) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT COUNT(*) FROM capture_events +WHERE seq BETWEEN %s AND %s + AND branch_ref = '%s' AND branch_generation = %s AND base_head = '%s'`, + firstSeq, lastSeq, featureRef, generation, baseHead)); got != "60" { + t.Fatalf("immutable feature provenance rows=%s want 60", got) + } + headOIDs := treeBlobOIDs(t, repo, "HEAD", "backlog") + recoveryOIDs := treeBlobOIDs(t, repo, recoveryRef, "backlog") + for _, file := range files { + headOID := headOIDs[file.path] + if headOID == file.oid { + continue + } + archiveOID := recoveryOIDs[file.path] + if archiveOID != file.oid { + t.Fatalf("captured blob for %s unreachable: HEAD=%s recovery=%s want=%s", + file.path, headOID, archiveOID, file.oid) + } + } + assertSquashBacklogControlHealth(t, ctx, env, repo) + + off := runAcd(t, ctx, env, "off", "--repo", repo, "--json") + if off.ExitCode != 0 { + t.Fatalf("acd off exit=%d\nstdout=%s\nstderr=%s", off.ExitCode, off.Stdout, off.Stderr) + } + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || branch_ref || '|' || branch_generation || '|' || + first_event_seq || '|' || last_event_seq || '|' || event_count || '|' || + commit_oid || '|' || recovery_ref +FROM recovery_snapshots WHERE id = %s`, snapshotID)); got != snapshotSig { + t.Fatalf("snapshot changed across acd off:\nbefore=%s\nafter=%s", snapshotSig, got) + } + if got := strings.TrimSpace(runGitOK(t, repo, "show-ref", "--hash", "--verify", recoveryRef)); got != recoveryCommit { + t.Fatalf("recovery ref changed across acd off: %q", got) + } + + on := runAcd(t, ctx, env, "on", "--repo", repo, "--json") + if on.ExitCode != 0 { + t.Fatalf("acd on exit=%d\nstdout=%s\nstderr=%s", on.ExitCode, on.Stdout, on.Stderr) + } + waitMode(t, repo, "running", 10*time.Second) + assertSquashBacklogControlHealth(t, ctx, env, repo) + if got := sqliteScalar(t, dbPath, fmt.Sprintf(` +SELECT outcome || '|' || branch_ref || '|' || branch_generation || '|' || + first_event_seq || '|' || last_event_seq || '|' || event_count || '|' || + commit_oid || '|' || recovery_ref +FROM recovery_snapshots WHERE id = %s`, snapshotID)); got != snapshotSig { + t.Fatalf("snapshot changed across acd on:\nbefore=%s\nafter=%s", snapshotSig, got) + } +} + +func treeBlobOIDs(t *testing.T, repo, tree, path string) map[string]string { + t.Helper() + out := runGitOK(t, repo, "ls-tree", "-r", "--full-tree", tree, "--", path) + oids := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + meta, name, ok := strings.Cut(line, "\t") + if !ok { + t.Fatalf("parse ls-tree line %q", line) + } + fields := strings.Fields(meta) + if len(fields) != 3 || fields[1] != "blob" { + t.Fatalf("unexpected ls-tree entry %q", line) + } + oids[name] = fields[2] + } + return oids +} + +func assertSquashBacklogControlHealth(t *testing.T, ctx context.Context, env []string, repo string) { + t.Helper() + res := runAcd(t, ctx, env, "--repo", repo, "--json") + if res.ExitCode != 0 { + t.Fatalf("bare acd exit=%d\nstdout=%s\nstderr=%s", res.ExitCode, res.Stdout, res.Stderr) + } + var health struct { + Health string `json:"health"` + Daemon string `json:"daemon"` + Enabled bool `json:"enabled"` + PendingEvents int `json:"pending_events"` + BlockedEvents int `json:"blocked_events"` + } + if err := json.Unmarshal([]byte(res.Stdout), &health); err != nil { + t.Fatalf("decode bare acd health: %v\n%s", err, res.Stdout) + } + if !health.Enabled || health.Daemon != "running" || health.PendingEvents != 0 || health.BlockedEvents != 0 { + t.Fatalf("bare acd health=%+v", health) + } + if health.Health != "healthy" && health.Health != "degraded" { + t.Fatalf("bare acd health class=%q want healthy or degraded", health.Health) + } +} + +func sqliteExec(t *testing.T, dbPath, statement string) string { + t.Helper() + out, err := exec.Command("sqlite3", dbPath, statement).CombinedOutput() + if err != nil { + t.Fatalf("sqlite exec: %v\n%s", err, out) + } + return string(out) +}