diff --git a/docs/results/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa-results.md b/docs/results/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa-results.md new file mode 100644 index 000000000..06846fae0 --- /dev/null +++ b/docs/results/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa-results.md @@ -0,0 +1,114 @@ + +> ↩ **[Change 0465 — test_go_race times out on internal/app in CI (Go's 10m per-package limit)](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0465-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa.md)** + +# test_go_race times out on internal/app in CI (Go's 10m per-package limit) — Results + +**Human action:** Yes, after merge. Watch several release-candidate CI source-gate runs to confirm `test_go_race` is reliably green. Separately, decide whether to open a follow-up for the packages that now set the race gate's time (it still measures about 67s locally, over its 60s row). + +## Outcome + +The CI race gate (`tests/test_go_race.sh`, which runs `go test -race -count=1 ./...`) kept hitting Go's 10-minute per-package limit in `internal/app`. The cause was that about 337 of that package's default tests start a real `git` process, and together they took roughly 225s of the package's ~238s under the race detector. + +What changed: + +- **The fast test corpus can no longer run git.** A test-only guard (`internal/app/nogit_guard_test.go`, default build only) puts a fake `git` at the front of `PATH`. Any default `internal/app` test that reaches git fails, and so does the whole package, even when the test itself tolerated the error. Tagged builds (`integration`, `e2e`) install a no-op twin instead (`nogit_guard_off_test.go`). +- **All 337 offenders moved** behind `//go:build integration`, into 12 new plain (non-race) shard runners: gatecancel, gateverdict, gatefence, gatecompletion, gateepoch, gatearm, gatelifecycle, finalizeops, finalizerebaseops, evidence, recordops and contextprobe. Five tests that really race goroutines or processes went to the existing race shard (`TestRaceIntegrationAppConcurrency…`), each with a rationale comment. +- **Measured result:** `go test -race -count=1 ./internal/app/` went from about 238s to about 14s. The default `internal/app` run takes about 1.3s. +- **Readable timeout:** `tests/test_go_race.sh` now passes `-timeout 8m`, and it prints a named backstop line if any package overruns. The slowest package locally at a CI-like `-p 2` is `internal/repository/transaction` at 48.7s. The spec's data puts CI at about 2.4–3.8× slower than local, which projects that package to about 120–230s. 8m is at least twice that and still below Go's 10m default. A repoguard test pins this floor. An overrun now gives a clear failure instead of a 10-minute goroutine dump. + - The first build used 4m. Review showed that 4m left too little CI headroom and could itself trip intermittently, so it was raised. +- **Budget alerts now work across worktrees.** The suite runner's budget-state key uses the repo-relative test path instead of the absolute one. Overruns now build up across `.worktrees/` checkouts, so the serial "SERIAL CONFIRMED OVER BUDGET" confirmation can finally fire. Existing local budget records are orphaned once, which is harmless because the store is advisory. + +Departures from the spec: + +- The guard excludes both `integration` and `e2e` builds, because `finalize_e2e_test.go` legitimately runs git. +- The guard also fails the package when a test swallowed the git error. Four such tolerant tests existed. +- The finalize-ops family is split across two shards, because one runner measured 56s, which would need a 65s row. + +## Human actions and testing + +### Important — Confirm CI's race gate is stably green after merge + +The failure was intermittent and depended on the load of the shared 3-core macOS runner. A local measurement cannot prove the CI fix. If you skip this, the change is merged on local evidence alone. + +Prerequisite: this PR is merged to `main`. + +1. Open the GitHub Actions page for the release-candidate workflow and look at the next 3–5 source-gate runs, whether from new PRs or re-runs. + Expected: every run shows `test_go_race` passing, with no `panic: test timed out after 10m0s`. +2. In each run's log, find the `test_go_race` elapsed time. + Expected: well under the 8m `-timeout`, and far below the old 581–908s. +3. If a run fails with the new backstop line (a package ran past the 8m `-timeout`), look at which package it names. That package is the new hot spot. + +### Optional — Watch the guard reject a new real-git test + +This shows what a future contributor will see. + +1. In a scratch checkout of this branch, add to any default-build `internal/app/*_test.go` file: `func TestTmpGit(t *testing.T) { _ = exec.Command("git", "--version").Run() }`. Import `os/exec` if the file does not already. +2. Run `go test -count=1 -run TestTmpGit ./internal/app/`. + Expected: the package FAILs with the guard's diagnostic. It tells you to move the test behind `//go:build integration` (change 0465/0333), even though the test ignored the error. +3. Cleanup: delete the scratch test. + +## Verification performed + +- Every task was checked through the gate driver: + - The default `internal/app` package is green with the guard on, and the guard reports zero attempts. + - `tests/test_go_integration_contract.sh` is green: every moved test sits in exactly one shard and runs in the right race mode. + - All 39 `tests/test_go_integration_app_*.sh` runners returned 0. + - `go vet` is clean for the default, `integration` and `e2e` builds. +- **Guard mutation tests:** + - Stripping the `PATH` prepend turned the guard's proving tests red. + - Making the verdict ignore violations turned them red too. + - Restoring the file byte-identical turned them green. +- **Budget-key mutation:** with the call site still keyed on the absolute path, a new test that runs two checkouts against one shared store failed. Changing the call site made it pass. +- **Timeout guard mutation** (the test also asserts the floor; against the old 4m it went red): a new `internal/repoguard` test runs a copy of `test_go_race.sh` against a fake `go`. + - Removing `-timeout` turned it red. + - Deleting the backstop assert turned it red. + - Restoring turned it green. +- **Measurements:** + - Race `internal/app` at 3 CPUs went from ~238s to 13.9s. + - The whole-module race run at `-p 2` passed. Its slowest package was `internal/repository/transaction` at 48.7s. +- **Whole-branch review:** a deep review found 0 blockers, 1 important and 3 minors. It confirmed that no test was dropped: 337 moved, bodies byte-identical apart from renames, and helpers unchanged. All four findings were fixed in-branch: + - the timeout raised to 8m (commit ed887967a); + - the toolchain row reverted to 55; + - the guard header's known limits documented; + - four stale file-name comments corrected (commit 0361260bc). +- The full build-gate suite runs after this file is committed. Its result is in the PR's build-evidence block, not here. + +## Known issues and follow-ups + +### The race gate is still slightly over its 60s budget row + +`tests/test_go_race.sh` measured 66.5–67.6s solo on a loaded developer machine, and its row stays capped at 60. `internal/app` is no longer the cause. The time now comes from other packages under `-race`: + +- `internal/repository/transaction`: ~61s +- `internal/workspace`: ~57s +- `internal/gatedrive`: ~43s + +The gate is nowhere near the 8m timeout or Go's 10m limit, so CI should pass. The suite's budget report will likely print `BUDGET WATCH` for this file, and, now that keys converge, eventually `SERIAL CONFIRMED OVER BUDGET`. This is confirmed locally. Suggested next action: open a follow-up change to partition or speed up those packages' slow tests. Do not raise the row. + +### The toolchain gate is over budget on a cold test cache + +With its test cache invalidated, `tests/test_go_toolchain.sh` measured 61.7–65.8s, against its unchanged 55 row. It runs `go test ./...` without `-count=1`, so warm runs take about 3s. Expect an occasional `BUDGET WATCH` line. The same follow-up as above would cover it. + +### Three untouched shards measured over their rows under load + +This is suspected, not confirmed. During the re-measure, on a machine with load around 3: + +- `tests/test_go_integration_app_closeout.sh`: 64s (row 40) +- `tests/test_go_integration_app_rebaserecovery.sh`: 60s (row 40) +- `tests/test_go_integration_app_changeruntime.sh`: 40s (row 40) + +This branch did not change them. Suggested next action: serial-confirm on an idle machine before acting. + +### Guard covers PATH-resolved git only + +The guard shadows `git` on `PATH`. It does not catch these routes: + +- a default test that builds a client with an absolute git path (`gitcli.WithExecutable`); +- a test that replaces `PATH` wholesale; +- a detached child process that runs git after the test binary finishes. + +No default test does any of these today, and the guard file's header documents the limits. This is a known limitation, not a defect. Optional next action: a static check banning those patterns in default `internal/app` test files. + +### Pre-existing prefix overlap among repo integration shards + +`TestIntegrationRepo` is a string prefix of other `TestIntegrationRepo*` shard prefixes. This predates this change and the contract still passes. It is only noted for whoever next touches those shards. diff --git a/docs/superpowers/plans/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa.md b/docs/superpowers/plans/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa.md new file mode 100644 index 000000000..1f90a8db2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa.md @@ -0,0 +1,2919 @@ + +> ↩ **[Change 0465 — test_go_race times out on internal/app in CI (Go's 10m per-package limit)](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0465-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa.md)** + +# Default internal/app Corpus Never Runs Real Git — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. (In this repository the build runs through `docket-build`: one `### Task N` heading is one worker and one commit.) + +**Goal:** Make `tests/test_go_race.sh` reliably pass in CI without weakening the race gate. To do that, enforce change 0333's partition as an invariant: the default-tag `internal/app` test corpus never starts a real `git` process. + +**Architecture:** A default-build-only `TestMain` hook, `installNoGitGuard`, puts a refusing `git` shim at the front of `PATH` and fails the package if any exec reached it. It is the authoritative census of offenders. Driven by that census, the 337 real-git, subprocess and process-lifecycle default tests move behind `//go:build integration`, get `TestIntegration…` names, and run in eleven new prefix-scoped plain shards. Genuinely concurrent ones join the existing `TestRaceIntegrationAppConcurrency` race shard. Around the partition: `tests/test_go_race.sh` gets an explicit `-timeout` backstop with a readable overrun message, the suite runner's budget-state key becomes repo-relative so overruns accumulate across worktrees, and every touched budget row is re-measured. + +**Tech Stack:** Go 1.27 (`testing`, `os/exec`), bash test wrappers over `tests/lib/go-integration-shard.sh`, the Go suite runner (`internal/suiterunner`), `tests/runtime-budgets.tsv`. + +**Spec:** `docs/superpowers/specs/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa-design.md` (on the `docket` metadata branch; read-only copy at `/Users/homer/dev/docket/.docket/docs/superpowers/specs/2026-09-28-test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa-design.md`). + +**Feature worktree:** `/Users/homer/dev/docket/.worktrees/test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa` (branch `fix/test-go-race-times-out-on-internal-app-in-ci-go-s-10m-per-pa`). Every command below runs from this directory unless it says otherwise. + +## Global Constraints + +- Invariant: **the default-tag `internal/app` test corpus never starts a real `git` process.** Real-git, subprocess and process-lifecycle scenarios live behind `//go:build integration`. +- Never weaken the race gate. Do not drop `-race`, narrow `./...`, or skip `internal/app` in `tests/test_go_race.sh`. +- The guard is keyed on runtime behavior (the exec itself), never on spellings such as `exec.Command("git"` or helper names. +- The guard is compiled only into the **default** build. `internal/app` has two non-default tags today, `integration` and `e2e` (`internal/app/finalize_e2e_test.go` is `//go:build e2e` and runs real git by design). The guard file is `//go:build !integration && !e2e` and its no-op twin is `//go:build integration || e2e`. +- Every moved test file is `internal/app/*_integration_test.go` with line 1 exactly `//go:build integration` and line 2 blank (contract check (1) in `tests/test_go_integration_contract.sh`). +- Renaming rule for a moved test: `` + the old name with its leading `Test` removed. Example: `TestRunCancelHappyPath` becomes `TestIntegrationGateCancelRunCancelHappyPath`. A race-classified test uses the prefix `TestRaceIntegrationAppConcurrency` instead. +- Moved tests run in **plain (non-race)** shards. A test goes to the race shard only if it genuinely exercises concurrency: it starts goroutines (`go func`/`go f(`), coordinates simultaneous work with `sync.WaitGroup`, errgroup or channels, or holds two live processes/launches against shared state at once. It then gets a one-line rationale comment directly above its `func`: `// Race shard (change 0465): .` +- New shard prefixes must not be a string prefix of any other `internal/app` shard prefix, and no other prefix may be a prefix of them. The eleven new prefixes below were checked against the 27 existing ones at plan time. +- `tests/test_go_integration_contract.sh` stays green and is **not edited**. +- Every `tests/test_*.sh` needs exactly one row in `tests/runtime-budgets.tsv` (`internal/repoguard` `TestRuntimeBudgetsCorrespondence`). Row value = measured serial (solo) seconds, rounded up to the next multiple of 5, plus 5, minimum 10. Format: `parallel`. +- Never hand-list sites: derive offenders from the guard's census and name references from a whole-repo `git grep -w`. Point-in-time records keep old names: never rewrite `docs/results/**`, `docs/changes/**`, `docs/superpowers/**` or `docs/adrs/**`. +- Every run that observes a verdict defeats Go's test cache (`-count=1`). Mutation probes back up the file and copy it back. Never `git checkout --` an uncommitted edit. +- Shell rules (AGENTS.md): never pipe a producer into `grep -q`/`head`, and capture into a variable first. Write grep patterns as `grep -E -e`. Template every `mktemp` as `"${TMPDIR:-/tmp}/.XXXXXX"`. Use `mv -f`. The Bash tool's shell here is zsh, so run multi-line snippets with `bash -c '…'` or from a script file when they rely on bash word-splitting. +- Stage only the files your task names (`git add `, never `git add -A`). +- Cross-references in maintained source anchor on symbol names or quoted clauses, never on line numbers (ADR-0054). +- **Expected intermediate state:** after Task 1 the default `go test ./internal/app/` is **red** (337 offenders) and stays red until Task 13 lands. Tasks 3–13 each remove exactly their own offenders. Task 15 proves the package green. Do not "fix" another task's offenders early. + +## Review Focus + +1. **A default test that tolerates the git failure** passes even though it reached git. The census found four of these: `TestGateLaunchInvalidInput`, `TestGateLaunchOutsideGitUnchanged`, `TestResolveRepoPhaseInvalidExplicitRepoDir` and `TestResolveRepoPhaseOutsideGitIsMachineOnly`. The package must still go red. This is pinned by `TestNoGitGuardFailsTolerantTest` (Task 1), which re-execs the test binary with a test that swallows the failure and asserts a non-zero exit plus the violation listing. +2. **The `e2e` build** (`tests/test_go_finalize_e2e.sh`, `-tags e2e`) must not get the shim, and helpers moved behind `integration` must not break its compile. Pinned by Task 1's e2e runner step and by every move task's `go vet -tags e2e ./internal/app/` step. +3. **An unreadable violation log** must fail closed, not read as "no violations" (learning probe-error-is-not-clean-absence). Pinned by the `unreadable log fails` row of `TestNoGitGuardVerdict` (Task 1). +4. **Budget-state key for a target outside `RepoRoot`, or a symlink-spelled root.** A `DOCKET_RUNTESTS_TESTS_DIR` override outside the repo must keep its absolute key, never a `../` key. A sibling directory whose name merely shares a prefix (`/w/a` vs `/w/ab`) is not "under" the root. A `/var` versus `/private/var` spelling of one checkout must still converge. Pinned by `TestBudgetKeyPathIsRepoRelative` and `TestBudgetKeyPathResolvesSymlinkedRoot` (Task 2). +5. **A new shard prefix that collides with an existing one**, which leaves a test doubly matched or unmatched. Pinned by each move task's prefix-collision step plus the contract's checks (4)/(5), run in every move task. + +--- + +### Task 1: Default-build no-real-git guard in `internal/app` (the census) + +**Build profile:** premium + +The named risk is that `TestMain` also routes the supervisor and guardian re-exec roles of the test binary. A guard wired in the wrong place breaks every real `GateLaunch`/guardian test, and a guard compiled into the wrong build breaks the integration or e2e corpora. + +**Files:** +- Create: `internal/app/nogit_guard_test.go` (`//go:build !integration && !e2e`, the guard plus its proving tests) +- Create: `internal/app/nogit_guard_off_test.go` (`//go:build integration || e2e`, the no-op twin). The name deliberately does **not** end in `_integration_test.go`, because the contract's check (1) requires such files to open with exactly `//go:build integration`. +- Modify: `internal/app/gate_test.go` (`TestMain` only) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces (later tasks rely on these exact names): + - `func installNoGitGuard() func(code int) int`: default build installs the shim and returns the finisher; tagged builds return the identity. + - `func nogitVerdict(logPath string, code int, w io.Writer) int` + - constants `nogitGuardProbeArg = "docket-nogit-guard-probe"`, `nogitGuardDiagnostic = "docket nogit guard: default internal/app tests must not run real git"`, `nogitGuardExit = 97`, `nogitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE"` + - package var `nogitGuardDir string` (the shim directory; `""` when not installed) + - Census output: every default test that reaches git fails with stderr containing `docket nogit guard: default internal/app tests must not run real git`. After `m.Run` the package prints `docket nogit guard: default internal/app tests must not run real git: N real-git exec attempt(s) reached the guard shim …` followed by one `` line per attempt, and exits non-zero. + +**Audit already done at plan time (re-verify in Step 1):** production code resolves git only through `PATH`. +- `gitcli.NewClient` runs `exec.LookPath("git")` at construction when `WithExecutable` is empty. No production caller passes `WithExecutable`, and no package-level client or `LookPath` exists. +- `internal/app/rungate_store.go` `gateGitCommonDir` and `internal/gatedrive/fingerprint.go` use bare `exec.Command("git", …)`. +- No `DOCKET_*` override names a git executable. +- Default `internal/app` tests that rewrite `PATH` (`finalize_e2e_test.go` `e2eEnv`, which is e2e-only) append `os.Getenv("PATH")`, so the shim stays in front. + +A shim at the front of `PATH`, installed in `TestMain` before `m.Run`, therefore covers every path. + +- [ ] **Step 1: Re-verify the git-resolution audit** + +Run: +```bash +git grep -nE 'WithExecutable\(' -- internal cmd ':!*_test.go' +git grep -nE '^var .*(NewClient|LookPath)' -- internal +git grep -nE 'exec\.Command(Context)?\([^)]*"git"' -- internal/app internal/gatedrive internal/gitcli ':!*_test.go' +``` +Expected: the first two print only the two `func WithExecutable` definitions (gitcli, githubcli) and nothing else. The third prints `internal/app/rungate_store.go` (`gateGitCommonDir`) and `internal/gatedrive/fingerprint.go`, both PATH-resolved. If anything else appears (an absolute git path, a cached `LookPath`, an env override), extend the guard to cover it and say so in your report. Do not document it as a residual (learning residual-is-for-undetectable-not-unprobed). + +- [ ] **Step 2: Write the no-op twin and the proving tests with a deliberately inert guard (RED)** + +Create `internal/app/nogit_guard_off_test.go`: + +```go +//go:build integration || e2e + +package app + +// installNoGitGuard is the tagged builds' no-op twin of the default-build guard in +// nogit_guard_test.go (change 0465). The integration and e2e corpora exist to run +// real git, so they install no shim and the finisher returns m.Run's code as-is. +// Exactly one of the two files compiles for any tag set, so TestMain stays +// single-sourced. +func installNoGitGuard() func(code int) int { return func(code int) int { return code } } +``` + +Create `internal/app/nogit_guard_test.go` with the **inert** implementation below. It never touches `PATH` and never fails the package. This is the mutation state, and the tests must be red against it: + +```go +//go:build !integration && !e2e + +package app + +// The default-build no-real-git guard (change 0465). Change 0333 moved the slow +// real-git, subprocess, and process-lifecycle corpus behind `//go:build integration`, +// but nothing stopped new real-git tests landing in the default corpus, which +// tests/test_go_race.sh instruments. This guard makes the partition an enforced +// invariant: the default-tag internal/app test corpus never starts a real `git`. +// +// Mechanism (keyed on the exec itself, never on spellings): installNoGitGuard, called +// from TestMain before m.Run, puts a directory holding a refusing `git` shim at the +// FRONT of PATH. Every route to git (gitcli.NewClient's exec.LookPath, a bare +// exec.Command("git", …), a fixture helper, a child process inheriting PATH) resolves +// the shim. The shim exits nogitGuardExit with the nogitGuardDiagnostic on stderr AND +// appends "\t" to a violation log, so a test that tolerates the failure +// still turns the package red when nogitVerdict reads the log after m.Run. +// +// Only this guard's own proving tests may call the shim without recording a +// violation, by passing nogitGuardProbeArg as the first argument. + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const ( + nogitGuardProbeArg = "docket-nogit-guard-probe" + nogitGuardDiagnostic = "docket nogit guard: default internal/app tests must not run real git" + nogitGuardExit = 97 + nogitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE" +) + +// nogitGuardDir is the installed shim directory ("" when the guard is not installed). +var nogitGuardDir string + +// INERT (Step 2 only): replaced in Step 4. +func installNoGitGuard() func(code int) int { return func(code int) int { return code } } + +// INERT (Step 2 only): replaced in Step 4. +func nogitVerdict(logPath string, code int, w io.Writer) int { return code } + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` (gitcli.NewClient uses +// exec.LookPath) resolves the shim, not a real git. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + if nogitGuardDir == "" { + t.Fatalf("the no-real-git guard is not installed (nogitGuardDir empty); TestMain must call installNoGitGuard before m.Run") + } + p, err := exec.LookPath("git") + if err != nil { + t.Fatalf("LookPath(git): %v", err) + } + if filepath.Dir(p) != nogitGuardDir { + t.Fatalf("git resolves to %q, want the guard shim in %q", p, nogitGuardDir) + } +} + +// TestNoGitGuardRefusesBareExec pins the MECHANISM, not just "it failed": real git +// also fails on an unknown subcommand, so the assert is the guard's exit code AND +// its diagnostic (learning assert-pins-outcome-not-mechanism). +func TestNoGitGuardRefusesBareExec(t *testing.T) { + out, err := exec.Command("git", nogitGuardProbeArg).CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != nogitGuardExit { + t.Fatalf("git exec must exit %d from the guard shim, got err=%v output=%q", nogitGuardExit, err, out) + } + if !strings.Contains(string(out), nogitGuardDiagnostic) { + t.Fatalf("git exec output must carry the guard diagnostic %q, got %q", nogitGuardDiagnostic, out) + } +} + +// TestNoGitGuardVerdict covers the post-m.Run verdict over the violation log. +func TestNoGitGuardVerdict(t *testing.T) { + dir := testsupport.TempDir(t) // bare X.TempDir() is banned by internal/repoguard tempdir_fixture_test.go + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + cases := []struct { + name string + logPath string + code int + want int + wantText string + }{ + {"missing log is clean", filepath.Join(dir, "absent.log"), 0, 0, ""}, + {"empty log is clean", write("empty.log", ""), 0, 0, ""}, + {"one violation fails a green run", write("one.log", "/tmp/x\tstatus --porcelain\n"), 0, 1, "1 real-git exec attempt(s)"}, + {"violation keeps an existing failure code", write("keep.log", "/tmp/x\tlog\n"), 2, 2, "/tmp/x\tlog"}, + {"unreadable log fails closed", dir, 0, 1, "cannot read the violation log"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + got := nogitVerdict(tc.logPath, tc.code, &buf) + if got != tc.want { + t.Fatalf("nogitVerdict(%q, %d) = %d, want %d; output:\n%s", tc.logPath, tc.code, got, tc.want, buf.String()) + } + if tc.wantText == "" && buf.Len() != 0 { + t.Fatalf("clean verdict must print nothing, got:\n%s", buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), tc.wantText) { + t.Fatalf("verdict output must contain %q, got:\n%s", tc.wantText, buf.String()) + } + }) + } +} + +// TestNoGitGuardFailsTolerantTest proves a test that SWALLOWS the git failure still +// fails the package: it re-execs this test binary running only itself in child +// mode, where it runs `git status` and ignores the error, then asserts the child +// binary exits non-zero and lists the violation. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + if os.Getenv(nogitSelfProbeEnv) == "1" { + _ = exec.Command("git", "status").Run() // tolerated on purpose + return + } + cmd := exec.Command(os.Args[0], "-test.run=^TestNoGitGuardFailsTolerantTest$", "-test.count=1") + cmd.Env = append(os.Environ(), nogitSelfProbeEnv+"=1") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() == 0 { + t.Fatalf("a package whose test tolerated a git exec must exit non-zero, got err=%v output:\n%s", err, out) + } + for _, want := range []string{nogitGuardDiagnostic, "1 real-git exec attempt(s)", "\tstatus"} { + if !strings.Contains(string(out), want) { + t.Fatalf("child output must contain %q, got:\n%s", want, out) + } + } +} + +// keep imports referenced by the Step 4 implementation compiling in the inert state. +var _ = fmt.Sprintf +var _ fs.FileMode +``` + +Then change `TestMain` in `internal/app/gate_test.go`. Replace its last line `os.Exit(m.Run())` with the lines below and extend its doc comment: + +```go + // Change 0465: the default build installs the no-real-git guard (nogit_guard_test.go) + // AFTER the re-exec routing above, so the supervisor and guardian roles behave + // exactly as before; tagged builds get the no-op twin (nogit_guard_off_test.go). + finish := installNoGitGuard() + os.Exit(finish(m.Run())) +``` + +Append this sentence to the `TestMain` doc comment: `Ordinary runs then install the default-build no-real-git guard (change 0465) around m.Run.` + +- [ ] **Step 3: Run the proving tests and confirm they are RED against the inert guard** + +Run: `go test -count=1 -run '^TestNoGitGuard' ./internal/app/` +Expected: FAIL. +- `TestNoGitGuardShadowsGitOnPath` fails with "guard is not installed". +- `TestNoGitGuardRefusesBareExec` fails, because real git exits 1 with "not a git command", not 97. +- `TestNoGitGuardVerdict` fails its three non-clean rows. +- `TestNoGitGuardFailsTolerantTest` fails because the child exits 0. + +Save this output. It is the mutation evidence for both mutation arms, taken before the guard exists. + +- [ ] **Step 4: Replace the two inert functions with the real guard (GREEN)** + +In `internal/app/nogit_guard_test.go`, delete the two `// INERT` functions and the two `var _` lines, and add: + +```go +// installNoGitGuard installs the refusing git shim at the front of PATH and returns +// the finisher TestMain wraps around m.Run. Setup failure exits the binary non-zero: +// a guard that silently failed to install would certify nothing. +func installNoGitGuard() func(code int) int { + // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. + dir, err := os.MkdirTemp("", "docket-app-nogit-") + if err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + logPath := filepath.Join(dir, "violations.log") + if strings.ContainsAny(logPath, "'\n") { + fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", nogitGuardDiagnostic, logPath) + os.Exit(1) + } + shim := filepath.Join(dir, "git") + if err := os.WriteFile(shim, []byte(nogitShimScript(logPath)), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + // Explicit chmod: a create-time mode is masked by the umask. + if err := os.Chmod(shim, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + nogitGuardDir = dir + return func(code int) int { + verdict := nogitVerdict(logPath, code, os.Stderr) + _ = os.RemoveAll(dir) + return verdict + } +} + +// nogitShimScript renders the refusing git: it records every non-probe invocation +// as "\t" in logPath and always exits nogitGuardExit with the diagnostic +// and the remedy on stderr. +func nogitShimScript(logPath string) string { + return "#!/bin/sh\n" + + "if [ \"${1-}\" != '" + nogitGuardProbeArg + "' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '" + logPath + "'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' '" + + nogitGuardDiagnostic + "' \"$*\" >&2\n" + + fmt.Sprintf("exit %d\n", nogitGuardExit) +} + +// nogitVerdict folds the violation log into m.Run's exit code. A missing or empty +// log is clean and leaves code unchanged. Any recorded attempt, or a log that exists +// but cannot be read, fails the package: a probe error is never clean absence. +func nogitVerdict(logPath string, code int, w io.Writer) int { + raw, err := os.ReadFile(logPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Fprintf(w, "%s: cannot read the violation log %s: %v\n", nogitGuardDiagnostic, logPath, err) + return nogitFailCode(code) + } + var lines []string + for _, l := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) == 0 { + return code + } + fmt.Fprintf(w, "%s: %d real-git exec attempt(s) reached the guard shim (a test that tolerated the failure still counts); \\t:\n", nogitGuardDiagnostic, len(lines)) + for _, l := range lines { + fmt.Fprintf(w, " %s\n", l) + } + return nogitFailCode(code) +} + +func nogitFailCode(code int) int { + if code == 0 { + return 1 + } + return code +} +``` + +(The `\\t` inside the Go string in `nogitVerdict`'s header is intentional. It prints a literal `\t` as the legend, while the data lines carry a real tab.) + +- [ ] **Step 5: Run the proving tests GREEN** + +Run: `gofmt -l internal/app && go vet ./internal/app/ && go test -count=1 -run '^TestNoGitGuard' -v ./internal/app/` +Expected: `gofmt -l` prints nothing, vet is clean, and all four `TestNoGitGuard*` tests PASS with exit 0. The proving tests record no violation, because the probe uses `nogitGuardProbeArg`, the verdict rows use their own temp logs, and the tolerant child is a separate process. + +- [ ] **Step 6: Mutation probes (backup-copy restore, `-count=1`)** + +```bash +bash -c ' +set -uo pipefail +f=internal/app/nogit_guard_test.go +bak="$(mktemp "${TMPDIR:-/tmp}/nogit-guard.XXXXXX")"; cp "$f" "$bak" +# (a) strip the PATH prepend: shadow + bare-exec + tolerant-child must go red +perl -0pi -e "s/if err := os\.Setenv\(\"PATH\".*?\n\t\}\n//s" "$f" +grep -c -F -e "os.Setenv(\"PATH\"" "$f" # expect 0 (proves the mutation landed) +go test -count=1 -run "^TestNoGitGuard" ./internal/app/ ; echo "mutation-a rc=$?" +cp "$bak" "$f" +# (b) make the verdict ignore violations: verdict rows + tolerant-child must go red +perl -0pi -e "s/return nogitFailCode\(code\)\n\}\n\nfunc nogitFailCode/return code\n}\n\nfunc nogitFailCode/s" "$f" +go test -count=1 -run "^TestNoGitGuard" ./internal/app/ ; echo "mutation-b rc=$?" +cp "$bak" "$f"; rm -f "$bak" +go test -count=1 -run "^TestNoGitGuard" ./internal/app/ ; echo "restored rc=$?" +' +``` +Expected: +- mutation (a): `grep -c` prints `0`, the run FAILs naming at least `TestNoGitGuardShadowsGitOnPath`, `TestNoGitGuardRefusesBareExec` and `TestNoGitGuardFailsTolerantTest`, and `mutation-a rc=1`. +- mutation (b): FAILs naming `TestNoGitGuardVerdict` and `TestNoGitGuardFailsTolerantTest`, and `mutation-b rc=1`. +- `restored rc=0`. + +If a mutation leaves the run green, the guard is decoration: stop and fix it. Also confirm `git diff --stat internal/app/nogit_guard_test.go` shows your Step 4 content, not an empty or HEAD diff. Paste the three `rc=` lines and the failing test names into your report. The results file cites them. + +- [ ] **Step 7: Prove the tagged builds are untouched** + +Run: +```bash +go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +bash tests/test_go_integration_app_named.sh; echo "named rc=$?" +bash tests/test_go_finalize_e2e.sh; echo "e2e rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +``` +Expected: both vets are clean, and all three runners print only `ok - ` lines with `rc=0`. The integration and e2e corpora still reach real git, so no shim is installed there. + +- [ ] **Step 8: Take the baseline census (expected RED)** + +Run: +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +printf "top-level failures: %s\n" "$(grep -c -E -e . <<<"$fails")" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the package FAILs with about 333 top-level failures, and the guard summary line reports more attempts than that (several tests exec git more than once). The four tolerant tests show up only in the attempt listing (Review Focus 1). Record both numbers in your report as the Task 1 baseline census. **Do not fix any offender here.** Tasks 3–13 move them. + +- [ ] **Step 9: Commit** + +```bash +git add internal/app/nogit_guard_test.go internal/app/nogit_guard_off_test.go internal/app/gate_test.go +git commit -m "test(app): default-build no-real-git guard (census) for internal/app (change 0465)" +``` + +--- + +### Task 2: Budget-state key on the repo-relative target path + +**Files:** +- Modify: `internal/suiterunner/budgetstate.go` (add `budgetKeyPath`, next to `ContextKey`) +- Modify: `internal/suiterunner/run.go` (the `ContextKey(o.Target.Path, …)` call in `Run`'s budget classification loop) +- Test: `internal/suiterunner/budgetstate_test.go`, `internal/suiterunner/run_test.go` + +**Interfaces:** +- Consumes: `Config.RepoRoot` (the git toplevel `internal/cli/development_test_cmd.go` passes), `ContextKey`, and the test helpers `writeScript`/`bashPath` (`execute_test.go`), `writeDurations` (`budgetstate_test.go`) and `runCfg` (`run_test.go`). +- Produces: `func budgetKeyPath(repoRoot, path string) string`. `Run` now keys budget state on `budgetKeyPath(cfg.RepoRoot, o.Target.Path)`. `ScreenObs.Path` (the human-readable report and trailing store column) keeps the target path as given. + +Today every `.worktrees/` gets its own record, because the key leads with the absolute path. The streak never reaches the 5-overrun serial confirmation. That is why `test_go_race` at 200–558s against a 60s row was never confirmed. Existing records are orphaned once. That is acceptable: the store is advisory and fail-open, and no schema bump is needed. + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/suiterunner/budgetstate_test.go`: + +```go +// Change 0465: the budget-state key leads with the REPO-RELATIVE target path, so every +// worktree of one repository accumulates one record per target. +func TestBudgetKeyPathIsRepoRelative(t *testing.T) { + cases := []struct{ name, root, path, want string }{ + {"under the primary checkout", "/w/docket", "/w/docket/tests/test_x.sh", "tests/test_x.sh"}, + {"under a linked worktree", "/w/docket/.worktrees/fix-y", "/w/docket/.worktrees/fix-y/tests/test_x.sh", "tests/test_x.sh"}, + {"already relative", "/w/docket", "tests/test_x.sh", "tests/test_x.sh"}, + {"no repo root", "", "/w/docket/tests/test_x.sh", "/w/docket/tests/test_x.sh"}, + {"outside the root keeps its absolute key", "/w/docket", "/elsewhere/tests/test_x.sh", "/elsewhere/tests/test_x.sh"}, + {"a sibling sharing a name prefix is not under the root", "/w/a", "/w/ab/tests/test_x.sh", "/w/ab/tests/test_x.sh"}, + } + for _, tc := range cases { + if got := budgetKeyPath(tc.root, tc.path); got != tc.want { + t.Errorf("%s: budgetKeyPath(%q, %q) = %q, want %q", tc.name, tc.root, tc.path, got, tc.want) + } + } +} + +func TestContextKeySameAcrossCheckouts(t *testing.T) { + a := ContextKey(budgetKeyPath("/Users/x/docket", "/Users/x/docket/tests/test_go_race.sh"), 8, 8, "Darwin", "arm64", 60, ModeParallel) + b := ContextKey(budgetKeyPath("/Users/x/docket/.worktrees/fix-y", "/Users/x/docket/.worktrees/fix-y/tests/test_go_race.sh"), 8, 8, "Darwin", "arm64", 60, ModeParallel) + want := "tests/test_go_race.sh|j8|c8|Darwin|arm64|b60|mparallel|s1" + if a != want || b != want { + t.Fatalf("keys must converge on %q, got primary=%q worktree=%q", want, a, b) + } +} + +// A symlink-spelled root (macOS /var vs /private/var) must still converge. +func TestBudgetKeyPathResolvesSymlinkedRoot(t *testing.T) { + real := testsupport.TempDir(t) + if err := os.MkdirAll(filepath.Join(real, "tests"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(real, "tests", "test_x.sh") + if err := os.WriteFile(target, []byte("#!/usr/bin/env bash\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(testsupport.TempDir(t), "checkout-link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if got := budgetKeyPath(link, target); got != "tests/test_x.sh" { + t.Fatalf("budgetKeyPath(%q, %q) = %q, want tests/test_x.sh", link, target, got) + } +} +``` + +Append to `internal/suiterunner/run_test.go`. Add `"path/filepath"` and `"os"` only if they are not already imported there. Both are imported today. + +```go +// Change 0465: two checkouts of one repository (a primary and a .worktrees/) +// sharing one budget-state store accumulate ONE streak for the same target. Before +// the fix each absolute path minted its own record and the second run read 1/5. +func TestRunBudgetStateConvergesAcrossCheckouts(t *testing.T) { + state := filepath.Join(testsupport.TempDir(t), "state.tsv") + durations := writeDurations(t, [][3]string{{"test_slow.sh", "1000", "1"}}) + run := func(root string) string { + t.Helper() + tests := filepath.Join(root, "tests") + if err := os.MkdirAll(tests, 0o755); err != nil { + t.Fatal(err) + } + writeScript(t, tests, "slow", "# docket-suite: go\necho 'ok - slow'\n") + var out, errBuf bytes.Buffer + cfg := runCfg(t, tests, &out, &errBuf) + cfg.RepoRoot = root + cfg.StatePath = state + cfg.DurationsPath = durations + if code := Run(context.Background(), cfg); code != 0 { + t.Fatalf("run in %s exit = %d\nstdout:\n%s\nstderr:\n%s", root, code, out.String(), errBuf.String()) + } + return out.String() + } + primary := filepath.Join(testsupport.TempDir(t), "docket") + first := run(primary) + second := run(filepath.Join(primary, ".worktrees", "fix-slow")) + if !strings.Contains(first, "consecutive parallel-overrun streak 1/5") { + t.Fatalf("first checkout must open the streak at 1/5:\n%s", first) + } + if !strings.Contains(second, "consecutive parallel-overrun streak 2/5") { + t.Fatalf("second checkout must CONTINUE the same record (2/5), not start its own:\n%s", second) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 -run 'TestBudgetKeyPath|TestContextKeySameAcrossCheckouts|TestRunBudgetStateConvergesAcrossCheckouts' ./internal/suiterunner/` +Expected: a build failure, `undefined: budgetKeyPath`. Once Step 3 adds only the function, `TestRunBudgetStateConvergesAcrossCheckouts` still fails with `streak 1/5` in the second output until the `run.go` call site changes. + +- [ ] **Step 3: Implement** + +In `internal/suiterunner/budgetstate.go`, directly below `ContextKey`: + +```go +// budgetKeyPath renders a target path for the budget-state context key relative to +// the checkout root (change 0465), so every worktree of one repository accumulates +// one record per target and the screen-then-confirm streak can actually reach its +// serial confirmation. An already-relative path, an empty root, or a path outside +// the root (a DOCKET_RUNTESTS_TESTS_DIR override) is returned unchanged; a +// symlink-spelled root is compared after resolving both sides. +func budgetKeyPath(repoRoot, path string) string { + if repoRoot == "" || !filepath.IsAbs(path) { + return path + } + if rel, ok := relUnderRoot(repoRoot, path); ok { + return rel + } + rr, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + return path + } + rp, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + if rel, ok := relUnderRoot(rr, rp); ok { + return rel + } + return path +} + +// relUnderRoot reports path relative to root when it lies strictly inside root. +func relUnderRoot(root, path string) (string, bool) { + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", false + } + return filepath.ToSlash(rel), true +} +``` + +In `internal/suiterunner/run.go`, change the key line in the budget-classification loop from: + +```go + key := ContextKey(o.Target.Path, cfg.Jobs, cpus, osName, arch, ceil, o.Target.Mode) +``` +to: +```go + // Change 0465: key on the repo-relative path so worktrees share one record. + key := ContextKey(budgetKeyPath(cfg.RepoRoot, o.Target.Path), cfg.Jobs, cpus, osName, arch, ceil, o.Target.Mode) +``` + +Leave `ScreenObs{…, Path: o.Target.Path, …}` unchanged. + +Next, check whether any other `ContextKey(` caller keys on the target path. The strict path, `StrictConfirmCandidates` and `ScheduleConfirmation` all read `o.Key`/records. Run: + +```bash +git grep -n -e 'ContextKey(' -- internal ':!*_test.go' +``` +Expected: the definition plus the one `run.go` call you just changed. If there is another path-keyed call, route it through `budgetKeyPath` as well and say so in your report. + +- [ ] **Step 4: Run the tests to verify they pass, plus the package** + +Run: `gofmt -l internal/suiterunner && go test -count=1 ./internal/suiterunner/` +Expected: no gofmt output, and PASS, including the pre-existing `TestContextKeyRendersOracleFormat` and the budget-state suite. + +- [ ] **Step 5: Mutation probe** + +Back up `internal/suiterunner/run.go` to a `mktemp` copy. Revert the call site to `ContextKey(o.Target.Path, …)` and run `go test -count=1 -run TestRunBudgetStateConvergesAcrossCheckouts ./internal/suiterunner/`. Expected: FAIL (`streak 1/5` in the second output). Copy the backup back and re-run. Expected: PASS. Put both results in your report. + +- [ ] **Step 6: Commit** + +```bash +git add internal/suiterunner/budgetstate.go internal/suiterunner/run.go internal/suiterunner/budgetstate_test.go internal/suiterunner/run_test.go +git commit -m "fix(suiterunner): key budget state on the repo-relative target path (change 0465)" +``` + +--- +### Task 3: Move the run-gate cancel real-git tests into the `TestIntegrationGateCancel` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_cancel_test.go` → `internal/app/rungate_cancel_integration_test.go` +- Create: `tests/test_go_integration_app_gatecancel.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gatecancel.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gatecancel.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateCancel"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateCancel`, e.g. `TestCancelCompletesWhenLaunchObligationsSettle` → `TestIntegrationGateCancelCancelCompletesWhenLaunchObligationsSettle`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 43 offenders: the run-gate cancel, retirement and terminal-repair tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_cancel_test.go` (all 43): `TestCancelCompletesWhenLaunchObligationsSettle`, `TestCancelConcurrentReplayIsIdempotent`, `TestCancelFencesBeforeStopping`, `TestCancelInterruptedBetweenRetireAndFinalizeConverges`, `TestCancelLeavesUnlinkedEpochlessSlot`, `TestCancelNativeAdapterAbsentIsFindingNotSilence`, `TestCancelNeverChargesOrResets`, `TestCancelNeverTouchesForeignSlot`, `TestCancelPendingOnUncompletedMutation`, `TestCancelPendingWhenRetirementFails`, `TestCancelPendingWhileLaunchObligationUnresolved`, `TestCancelReconcilerUnavailableFailsClosed`, `TestCancelReleaseWriteFailureFailsClosed`, `TestCancelRemovedWorktreeEpochReachesSlotByStoredIdentity`, `TestCancelRepeatResumesCleanup`, `TestCancelRetireRaceWithSuccessorLeavesSuccessor`, `TestCancelRetiresOwnedReleasedSlot`, `TestCancelSettlesUncertainPublicationWithIdenticalRetry`, `TestCancelStaysPendingWithoutCompletedIdenticalRetry`, `TestCancelStopsLinkedEpochlessSlot`, `TestFinalizeGateAdmitsAfterRetirement`, `TestGuardianReapsButNeverRetires`, `TestRawAdmissionStoreWiresEpochSettledResolver`, `TestRawLaunchSettlesSettledEpochReleasedSlot`, `TestRawStaleEpochRefusalStillFencesBusySlot`, `TestRepairChargesNothing`, `TestRepairTerminalEpochRemovedWorktree`, `TestRetirementDoesNotUnfenceOldEpochLaunches`, `TestRetirementSitesConverge`, `TestRunCancelAlreadyCancelled`, `TestRunCancelHappyPath`, `TestRunCancelPendingOnUnprovenStop`, `TestRunCancelPublicEntry`, `TestRunCancelRefusedWrongClaim`, `TestRunCancelRefusedWrongEpoch`, `TestRunCancelRefusedWrongRepo`, `TestRunCancelRefusesCompletedEpoch`, `TestRunCancelWinsFromCompletingEpoch`, `TestTerminalRepairRefusesUnsafeHistories`, `TestTerminalRepairRetiresHistoricalStaleSlot`, `TestTerminalRepairSupersededSlot`, `TestTerminalRepairSupersededThreadsReplacementWorktree`, `TestTerminalRepairTornResumeConverges` + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task3.txt" +grep -E -e "^internal/app/(rungate_cancel)_test\.go " "${TMPDIR:-/tmp}/census-task3.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_cancel_test.go internal/app/rungate_cancel_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_cancel_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_cancel_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateCancel" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gatecancel.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gatecancel.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatecancel.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate cancel, retirement and terminal-repair tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateCancel. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateCancel" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_cancel_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gatecancel.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gatecancel.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gatecancel.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gatecancel.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate cancel real-git tests behind the integration tag (TestIntegrationGateCancel, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 4: Move the run-gate verdict real-git tests into the `TestIntegrationGateVerdict` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_verdict_test.go` → `internal/app/rungate_verdict_integration_test.go` +- Create: `tests/test_go_integration_app_gateverdict.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gateverdict.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gateverdict.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateVerdict"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateVerdict`, e.g. `TestVerdictAbsentBindingAdoptsSoleProof` → `TestIntegrationGateVerdictVerdictAbsentBindingAdoptsSoleProof`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 32 offenders: the run-gate verdict and claim-binding tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_verdict_test.go` (all 32): `TestVerdictAbsentBindingAdoptsSoleProof`, `TestVerdictAmbiguousDrivesStops`, `TestVerdictClaimReplacedStops`, `TestVerdictConfirmedBindingResolvesBoundChange`, `TestVerdictContinuationConsumesNoAttempt`, `TestVerdictContinuationDoesNotRebindScope`, `TestVerdictContinueNeverAuthorizesNewClaim`, `TestVerdictCorruptBindingFailsClosed`, `TestVerdictFreshRunBindsScopeChange`, `TestVerdictHaltPrecedenceOverBudget`, `TestVerdictIncompleteNoGrantLeavesRetryMirrorUnused`, `TestVerdictIncompleteQuiescentStillRetriesOnce`, `TestVerdictIncompleteRepeatObservationDoesNotDoubleGrant`, `TestVerdictIncompleteRespectsAttemptLimit`, `TestVerdictIncompleteWithTrackedDriveContinuesWithoutRetry`, `TestVerdictNilProofScannerFailsClosed`, `TestVerdictNoBindingNoProofIsNoAttributableClaim`, `TestVerdictObserveModeNeverTouchesOwnership`, `TestVerdictObservePathStillCannotContinue`, `TestVerdictOwnershipIgnoresBeforeSetAndEpoch`, `TestVerdictProofScanErrorFailsClosed`, `TestVerdictResumeBindingSkipsContinuity`, `TestVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess`, `TestVerdictRunCompleteCancelledEpochNeverReportsSuccess`, `TestVerdictRunCompleteClosesOutEpochOwnership`, `TestVerdictRunCompleteReportPersistFailureIsReported`, `TestVerdictRunCompleteWithoutEpochUnchanged`, `TestVerdictTakeoverHaltStops`, `TestVerdictUnconfirmedReservationRecoversFromExactReceipt`, `TestVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim`, `TestVerdictUnconfirmedReservationWithoutReceiptStops`, `TestVerdictWaitingIsNonterminalContinue` + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task4.txt" +grep -E -e "^internal/app/(rungate_verdict)_test\.go " "${TMPDIR:-/tmp}/census-task4.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_verdict_test.go internal/app/rungate_verdict_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_verdict_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_verdict_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateVerdict" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gateverdict.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gateverdict.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gateverdict.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate verdict and claim-binding tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateVerdict. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateVerdict" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_verdict_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gateverdict.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gateverdict.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gateverdict.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gateverdict.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate verdict real-git tests behind the integration tag (TestIntegrationGateVerdict, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 5: Move the run-gate fence and ownership real-git tests into the `TestIntegrationGateFence` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_fence_test.go` → `internal/app/rungate_fence_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_ownership_test.go` → `internal/app/rungate_ownership_integration_test.go` +- Create: `tests/test_go_integration_app_gatefence.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gatefence.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gatefence.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateFence"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateFence`, e.g. `TestAdmitWorkflowMutationRefusesCompletingEpoch` → `TestIntegrationGateFenceAdmitWorkflowMutationRefusesCompletingEpoch`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 30 offenders: the run-gate fencing and ownership tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_fence_test.go` (all 26): `TestAdmitWorkflowMutationRefusesCompletingEpoch`, `TestCompletedEpochExcludedFromAmbientOwnerLookup`, `TestEpochCarryingFencesUnchangedByOwnerSelection`, `TestFenceBlocksEngineMutationAfterCancel`, `TestFenceBlocksPRPublishAfterCancel`, `TestFenceBlocksWorkspacePublishAfterCancel`, `TestFenceMatchesWorktreeAcrossSymlinkAlias`, `TestFenceRefusesSupersededEpochAsStale`, `TestFreshRunClaimBindsEpochWorktreeSoFenceActs`, `TestInFlightMutationReconcilesBeforeCancelled`, `TestOwnerSelectionActiveBeatsCancelledRegardlessOfOrder`, `TestOwnerSelectionCompletedNeverOwns`, `TestOwnerSelectionSoleCancelledStillFences`, `TestOwnerSelectionTwoActiveOwnersAmbiguous`, `TestProductionUncertainThenIdenticalRetryThenCancel`, `TestProductionUnverifiedPRRetryNeverSettles`, `TestProductionUnverifiedWorkspaceRetryNeverSettles`, `TestPRPublishJournalsPublicationIdentity`, `TestSlotNamedEpochUnreadableRefusesLocally`, `TestStandaloneMutationUnfenced`, `TestUnreadableSlotRefusesLocally`, `TestVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm`, `TestVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs`, `TestVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs`, `TestWorkspacePublishJournalsPublicationIdentity`, `TestWorkspacePublishMovedHeadUnderLockIsHeadMismatch` +- `rungate_ownership_test.go` (all 4): `TestLaterVerdictCannotOverwriteBinding`, `TestReplacementClaimBlocksOldGate`, `TestTwoGatesEachVerifyOnlyTheirOwn`, `TestUnrelatedChurnDoesNotMoveOwnership` + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task5.txt" +grep -E -e "^internal/app/(rungate_fence|rungate_ownership)_test\.go " "${TMPDIR:-/tmp}/census-task5.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_fence_test.go internal/app/rungate_fence_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_fence_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_fence_integration_test.go +git mv internal/app/rungate_ownership_test.go internal/app/rungate_ownership_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_ownership_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_ownership_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateFence" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gatefence.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gatefence.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatefence.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate fencing and ownership tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateFence. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateFence" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_fence_test.go|internal/app/rungate_ownership_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gatefence.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gatefence.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gatefence.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gatefence.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate fence and ownership real-git tests behind the integration tag (TestIntegrationGateFence, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 6: Move the run-gate completion and publication real-git tests into the `TestIntegrationGateCompletion` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_complete_test.go` → `internal/app/rungate_complete_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_production_census_test.go` → `internal/app/rungate_production_census_integration_test.go` +- Split (only the 7 listed tests of 12): `internal/app/rungate_publication_test.go` → new `internal/app/rungate_publication_integration_test.go` +- Split (only the 2 listed tests of 3): `internal/app/rungate_publication_settle_paths_test.go` → new `internal/app/rungate_publication_settle_paths_integration_test.go` +- Create: `tests/test_go_integration_app_gatecompletion.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gatecompletion.sh`; re-measure the `tests/test_go_integration_app_concurrency.sh` row if a test is race-classified) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gatecompletion.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateCompletion"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateCompletion`, e.g. `TestCompleteSuccessfulRunBlocksOnEveryUnsettledObligation` → `TestIntegrationGateCompletionCompleteSuccessfulRunBlocksOnEveryUnsettledObligation`. Race-classified tests become `TestRaceIntegrationAppConcurrency` and join the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 32 offenders: the run-gate completion, production-census, and publication-settlement tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_complete_test.go` (all 20): `TestCompleteSuccessfulRunBlocksOnEveryUnsettledObligation`, `TestCompleteSuccessfulRunBlocksOnUnverifiedRetry`, `TestCompleteSuccessfulRunDoesNotDuplicateFindings`, `TestCompleteSuccessfulRunForeignSuccessorUntouched`, `TestCompleteSuccessfulRunHappyPath`, `TestCompleteSuccessfulRunIdempotentReplay`, `TestCompleteSuccessfulRunLateParticipantBlocks`, `TestCompleteSuccessfulRunNeverRelabelsCancellation`, `TestCompleteSuccessfulRunReplayAfterRetireBeforeComplete`, `TestCompleteSuccessfulRunSendsNoStops`, `TestCompleteSuccessfulRunSettlesUncertainPublication`, `TestCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot`, `TestCompleteSuccessfulRunStillBlocksWithoutRetry`, `TestCompleteThenScratchCleanupThenFinalizeAdmits`, `TestCompletionParticipantDurableProof`, `TestCompletionSlotReleasedOwnedNoReobservation`, `TestCompletionUnreleasedOwnedSlotStillBlocks`, `TestOrdinaryReleaseStillRetainsEpochBetweenDrives`, `TestReadOnlyPathsNeverSettle`, `TestStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout` +- `rungate_production_census_test.go` (all 3): `TestProductionCensusCancelResumeStartsReplacementGate`, `TestProductionCensusCancelThenFinalize`, `TestProductionCensusCompleteThenFinalize` +- `rungate_publication_test.go` (7 of 12; the rest stay in the default file): `TestAdmissionJournalsPublicationDescriptorAndLegacyDecodes`, `TestJournaledRetryOutcomeGatesSettlement`, `TestSettlementInterruptionConverges`, `TestSettlementNeverDowngradesUnderRacingCallback`, `TestSettleUncertainPublicationsDurable`, `TestSettleUncertainPublicationsFailureIsBoundedFinding`, `TestSettleUncertainPublicationsWriteFailureReportsNoSettlement` +- `rungate_publication_settle_paths_test.go` (2 of 3; the rest stay in the default file): `TestReadOnlyVerdictPathsNeverSettleSettleablePair`, `TestVerdictRunCompleteSettlesUncertainPublication` + +**Race candidates, pre-classified at plan time** (their bodies start goroutines or hold two live launches at once; confirm against the criterion in Step 2): `TestSettlementNeverDowngradesUnderRacingCallback`. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task6.txt" +grep -E -e "^internal/app/(rungate_complete|rungate_production_census|rungate_publication|rungate_publication_settle_paths)_test\.go " "${TMPDIR:-/tmp}/census-task6.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_complete_test.go internal/app/rungate_complete_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_complete_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_complete_integration_test.go +git mv internal/app/rungate_production_census_test.go internal/app/rungate_production_census_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_production_census_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_production_census_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_gatecompletion.sh (prefix ^TestIntegrationGateCompletion). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/rungate_publication_test.go` → `internal/app/rungate_publication_integration_test.go`: 7 functions (listed above). +- `internal/app/rungate_publication_settle_paths_test.go` → `internal/app/rungate_publication_settle_paths_integration_test.go`: 2 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateCompletion" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gatecompletion.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gatecompletion.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatecompletion.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate completion, production-census, and publication-settlement tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateCompletion. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateCompletion" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_complete_test.go|internal/app/rungate_production_census_test.go|internal/app/rungate_publication_test.go|internal/app/rungate_publication_settle_paths_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gatecompletion.sh; echo "shard rc=$?" +bash tests/test_go_integration_app_concurrency.sh; echo "race shard rc=$?" # only if a test was race-classified +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0` (and `race shard rc=0` when used). +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gatecompletion.sh' 2>&1 | tail -4 +bash -c 'time bash tests/test_go_integration_app_concurrency.sh' 2>&1 | tail -4 # only if a test was race-classified +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gatecompletion.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. If a race test joined `tests/test_go_integration_app_concurrency.sh`, re-measure it the same way and raise its row only if the new measurement exceeds it. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gatecompletion.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate completion and publication real-git tests behind the integration tag (TestIntegrationGateCompletion, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 7: Move the run-gate epoch and store real-git tests into the `TestIntegrationGateEpoch` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_epoch_test.go` → `internal/app/rungate_epoch_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_store_test.go` → `internal/app/rungate_store_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_gate_test.go` → `internal/app/rungate_gate_integration_test.go` +- Create: `tests/test_go_integration_app_gateepoch.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gateepoch.sh`; re-measure the `tests/test_go_integration_app_concurrency.sh` row if a test is race-classified) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gateepoch.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateEpoch"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateEpoch`, e.g. `TestCompleteEpochOnlyFromCompleting` → `TestIntegrationGateEpochCompleteEpochOnlyFromCompleting`. Race-classified tests become `TestRaceIntegrationAppConcurrency` and join the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 36 offenders: the run-gate epoch record, gate store and launch-gate tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Task-specific note.** `rungate_store_test.go` and `rungate_epoch_test.go` very likely hold fixtures that other still-default files use (for example gate-drive or status tests). Step 4 is where you find out. Move those helpers into `internal/app/rungate_helpers_test.go` (untagged) rather than leave them behind the tag. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_epoch_test.go` (all 18): `TestCompleteEpochOnlyFromCompleting`, `TestConfirmGateClaimBindsEpochChange`, `TestConfirmGateClaimNoEpochIsNoop`, `TestEpochRecordCRUD`, `TestEpochSettledResolverStates`, `TestEpochUnknownSchemaFailsClosed`, `TestFenceEpochCompletingFromActive`, `TestFenceEpochCompletingNeverRelabelsTerminalStates`, `TestFenceEpochCompletingRejectsStaleLocator`, `TestGateBeforeMintsEpoch`, `TestLoadEpochNotFound`, `TestNoAdapterReportsLifecycleUnavailable`, `TestRecordEpochParticipantTerminal`, `TestRecordEpochParticipantTerminalAllowedAfterFence`, `TestRecordEpochParticipantTerminalUnknownHandleAndBadInput`, `TestRegisterEpochParticipantRejectedOnCompletingAndCompleted`, `TestRegisterParticipantRejectsNonActive`, `TestSupersedeRefusesCompletingAndCompleted` +- `rungate_store_test.go` (all 11): `TestConfirmGateClaimMirrorsRecord`, `TestConfirmWithoutReservationFails`, `TestConsumeGateRetryLimitOne`, `TestConsumeGateRetryPerAttemptCAS`, `TestFindGateRecordByContextHash`, `TestGateRetryUsageCountsLegacyMarker`, `TestGateSchemaV2RecordFailsClosed`, `TestLoadGateClaimBindingCorruptFailsClosed`, `TestLoadGateRecordRefusesV3`, `TestReserveGateClaimIsBindOnce`, `TestSaveGateRecordRefusesUnstampedLimit` +- `rungate_gate_test.go` (all 7): `TestEpochLaunchGateAdmitsActiveBoundEpoch`, `TestEpochLaunchGatePerformsNoWrite`, `TestEpochLaunchGateRefusalMatrix`, `TestEpochLaunchGateRefusesCompletingAndCompleted`, `TestEpochLaunchGateSerializesWithFence`, `TestEpochRevokedResolverRevokesCompletingAndCompleted`, `TestFindEpochDirByID` + +**Race candidates, pre-classified at plan time** (their bodies start goroutines or hold two live launches at once; confirm against the criterion in Step 2): `TestEpochLaunchGateSerializesWithFence`. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task7.txt" +grep -E -e "^internal/app/(rungate_epoch|rungate_store|rungate_gate)_test\.go " "${TMPDIR:-/tmp}/census-task7.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_epoch_test.go internal/app/rungate_epoch_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_epoch_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_epoch_integration_test.go +git mv internal/app/rungate_store_test.go internal/app/rungate_store_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_store_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_store_integration_test.go +git mv internal/app/rungate_gate_test.go internal/app/rungate_gate_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_gate_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_gate_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateEpoch" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gateepoch.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gateepoch.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gateepoch.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate epoch record, gate store and launch-gate tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateEpoch. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateEpoch" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_epoch_test.go|internal/app/rungate_store_test.go|internal/app/rungate_gate_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gateepoch.sh; echo "shard rc=$?" +bash tests/test_go_integration_app_concurrency.sh; echo "race shard rc=$?" # only if a test was race-classified +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0` (and `race shard rc=0` when used). +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gateepoch.sh' 2>&1 | tail -4 +bash -c 'time bash tests/test_go_integration_app_concurrency.sh' 2>&1 | tail -4 # only if a test was race-classified +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gateepoch.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. If a race test joined `tests/test_go_integration_app_concurrency.sh`, re-measure it the same way and raise its row only if the new measurement exceeds it. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gateepoch.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate epoch and store real-git tests behind the integration tag (TestIntegrationGateEpoch, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 8: Move the run-gate arm, resume and claim real-git tests into the `TestIntegrationGateArm` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/rungate_before_resume_test.go` → `internal/app/rungate_before_resume_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_before_test.go` → `internal/app/rungate_before_integration_test.go` +- Move (whole file, `git mv`): `internal/app/rungate_claim_test.go` → `internal/app/rungate_claim_integration_test.go` +- Create: `tests/test_go_integration_app_gatearm.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gatearm.sh`; re-measure the `tests/test_go_integration_app_concurrency.sh` row if a test is race-classified) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gatearm.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateArm"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateArm`, e.g. `TestRepeatArmObservesReservation` → `TestIntegrationGateArmRepeatArmObservesReservation`. Race-classified tests become `TestRaceIntegrationAppConcurrency` and join the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 31 offenders: the run-gate arm (gate-before), resume, and gate-claim tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `rungate_before_resume_test.go` (all 14): `TestRepeatArmObservesReservation`, `TestResumeAfterCancelledSupersedesOnce`, `TestResumeCancellingIsPending`, `TestResumeDeniedWhileOldEpochNotQuiescent`, `TestResumeDoesNotResetSuiteBudget`, `TestResumeForeignSlotIsNeutral`, `TestResumeRefusesActiveEpochWithLocator`, `TestResumeRefusesCompletedEpochWithoutSuperseding`, `TestResumeRefusesCompletingEpochWithoutSuperseding`, `TestResumeRetiresStaleSlotThenReservesOnce`, `TestResumeSupersededBranchAccountsScopeLinkedDrives`, `TestResumeSupersededChecksReplacementSlot`, `TestResumeSupersededValidatesBeforeObserve`, `TestResumeTornReplacementConverges` +- `rungate_before_test.go` (all 7): `TestGateBeforeFreshArmSurfacesRunEpoch`, `TestGateBeforeNoTimestampGames`, `TestGateBeforePreparesOuterScope`, `TestGateBeforeResumeBindsOnlyVerifiedInProgress`, `TestGateRecordContinuationTripleRule`, `TestGateRecordSchema1FailsClosed`, `TestMintSnapshotsRunMaxAttempts` +- `rungate_claim_test.go` (all 10): `TestGateClaimCommandError`, `TestGateClaimHaltedCarriesCause`, `TestGateClaimLoadErrorFailsClosed`, `TestGateClaimMismatch`, `TestGateClaimMismatchDifferentLength`, `TestGateClaimNilSeam`, `TestGateClaimNoContinuation`, `TestGateClaimRedactsGeneration`, `TestGateClaimSingleUse`, `TestGateClaimSuccessRedeemsAndClearsTriple` + +**Race candidates, pre-classified at plan time** (their bodies start goroutines or hold two live launches at once; confirm against the criterion in Step 2): `TestResumeAfterCancelledSupersedesOnce`. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task8.txt" +grep -E -e "^internal/app/(rungate_before_resume|rungate_before|rungate_claim)_test\.go " "${TMPDIR:-/tmp}/census-task8.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/rungate_before_resume_test.go internal/app/rungate_before_resume_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_before_resume_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_before_resume_integration_test.go +git mv internal/app/rungate_before_test.go internal/app/rungate_before_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_before_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_before_integration_test.go +git mv internal/app/rungate_claim_test.go internal/app/rungate_claim_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/rungate_claim_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/rungate_claim_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateArm" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gatearm.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gatearm.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatearm.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate arm (gate-before), resume, and gate-claim tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateArm. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateArm" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/rungate_before_resume_test.go|internal/app/rungate_before_test.go|internal/app/rungate_claim_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gatearm.sh; echo "shard rc=$?" +bash tests/test_go_integration_app_concurrency.sh; echo "race shard rc=$?" # only if a test was race-classified +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0` (and `race shard rc=0` when used). +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gatearm.sh' 2>&1 | tail -4 +bash -c 'time bash tests/test_go_integration_app_concurrency.sh' 2>&1 | tail -4 # only if a test was race-classified +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gatearm.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. If a race test joined `tests/test_go_integration_app_concurrency.sh`, re-measure it the same way and raise its row only if the new measurement exceeds it. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gatearm.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move run-gate arm, resume and claim real-git tests behind the integration tag (TestIntegrationGateArm, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 9: Move the gate launch lifecycle and guardian real-git tests into the `TestIntegrationGateLifecycle` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/agent_guardian_test.go` → `internal/app/agent_guardian_integration_test.go` +- Split (only the 7 listed tests of 11): `internal/app/gate_test.go` → new `internal/app/gate_integration_test.go` +- Create: `tests/test_go_integration_app_gatelifecycle.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_gatelifecycle.sh`; re-measure the `tests/test_go_integration_app_concurrency.sh` row if a test is race-classified) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_gatelifecycle.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationGateLifecycle"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationGateLifecycle`, e.g. `TestGuardianCannotMutate` → `TestIntegrationGateLifecycleGuardianCannotMutate`. Race-classified tests become `TestRaceIntegrationAppConcurrency` and join the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 12 offenders: the real-process gate launch/stop lifecycle and death-guardian tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Task-specific constraint.** `TestMain` (and its supervisor/guardian re-exec routing plus the Task 1 `installNoGitGuard` call) **stays in the untagged `internal/app/gate_test.go`**, because both builds need it. The moved guardian tests re-exec the test binary as a detached guardian, and they rely on that routing in the integration build too. Only the seven listed test functions leave `gate_test.go`. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `agent_guardian_test.go` (all 5): `TestGuardianCannotMutate`, `TestGuardianCompletionMarkerPreventsCancel`, `TestGuardianEOFFencesEpoch`, `TestGuardianLeavesCompletingEpochForReplay`, `TestGuardianStaleMarkerDoesNotSuppressFence` +- `gate_test.go` (7 of 11; the rest stay in the default file): `TestGateLaunchInsideWorktreeReservesSlot`, `TestGateLaunchInvalidInput`, `TestGateLaunchLegacyInventoryRefusalNamesMatchedDrive`, `TestGateLaunchOutsideGitUnchanged`, `TestGateLaunchSecondRefusedWhileFirstLives`, `TestGateLaunchSettlesFinishedRawIncumbent`, `TestGateStopReleasesRawSlot` (`TestGateLaunchInvalidInput` and `TestGateLaunchOutsideGitUnchanged` *pass* under the guard, because they tolerate the git failure. They appear only in the guard's attempt listing, never as `--- FAIL`, and they still move.) + +**Race candidates, pre-classified at plan time** (their bodies start goroutines or hold two live launches at once; confirm against the criterion in Step 2): `TestGateLaunchSecondRefusedWhileFirstLives`. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task9.txt" +grep -E -e "^internal/app/(gate|agent_guardian)_test\.go " "${TMPDIR:-/tmp}/census-task9.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/agent_guardian_test.go internal/app/agent_guardian_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/agent_guardian_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/agent_guardian_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_gatelifecycle.sh (prefix ^TestIntegrationGateLifecycle). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/gate_test.go` → `internal/app/gate_integration_test.go`: 7 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationGateLifecycle" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_gatelifecycle.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_gatelifecycle.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatelifecycle.sh — Go integration shard (change 0465, extending change +# 0333's partition): the real-process gate launch/stop lifecycle and death-guardian tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateLifecycle. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateLifecycle" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/gate_test.go|internal/app/agent_guardian_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_gatelifecycle.sh; echo "shard rc=$?" +bash tests/test_go_integration_app_concurrency.sh; echo "race shard rc=$?" # only if a test was race-classified +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0` (and `race shard rc=0` when used). +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_gatelifecycle.sh' 2>&1 | tail -4 +bash -c 'time bash tests/test_go_integration_app_concurrency.sh' 2>&1 | tail -4 # only if a test was race-classified +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_gatelifecycle.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. If a race test joined `tests/test_go_integration_app_concurrency.sh`, re-measure it the same way and raise its row only if the new measurement exceeds it. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_gatelifecycle.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move gate launch lifecycle and guardian real-git tests behind the integration tag (TestIntegrationGateLifecycle, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 10: Move the finalize operations real-git tests into the `TestIntegrationFinalizeOps` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/finalize_reserve_test.go` → `internal/app/finalize_reserve_integration_test.go` +- Move (whole file, `git mv`): `internal/app/finalize_publish_test.go` → `internal/app/finalize_publish_integration_test.go` +- Split (only the 23 listed tests of 29): `internal/app/finalize_rebase_test.go` → new `internal/app/finalize_rebase_ops_integration_test.go` +- Split (only the 3 listed tests of 5): `internal/app/finalize_block_test.go` → new `internal/app/finalize_block_integration_test.go` +- Split (only the 2 listed tests of 3): `internal/app/pr_publish_test.go` → new `internal/app/pr_publish_integration_test.go` +- Create: `tests/test_go_integration_app_finalizeops.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_finalizeops.sh`; re-measure the `tests/test_go_integration_app_concurrency.sh` row if a test is race-classified) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_finalizeops.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationFinalizeOps"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationFinalizeOps`, e.g. `TestFinalizeResolverReserveExhausted` → `TestIntegrationFinalizeOpsFinalizeResolverReserveExhausted`. Race-classified tests become `TestRaceIntegrationAppConcurrency` and join the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 39 offenders: the finalize rebase-continue, resolver-reserve, block, publish, and PR-publish tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `finalize_reserve_test.go` (all 9): `TestFinalizeResolverReserveConcurrent`, `TestFinalizeResolverReserveExhausted`, `TestFinalizeResolverReserveForeignAttempt`, `TestFinalizeResolverReserveLegacy`, `TestFinalizeResolverReserveNonConflicted`, `TestFinalizeResolverReservePending`, `TestFinalizeResolverReserveReserved`, `TestFinalizeResolverReserveStoppedProbeError`, `TestFinalizeResolverReserveWriteFailureNoAdmission` +- `finalize_publish_test.go` (all 2): `TestFinalizePublishAcceptsSkippedEvidence`, `TestFinalizePublishAfterCheckpointResume` +- `finalize_rebase_test.go` (23 of 29; the rest stay in the default file): `TestFinalizeRebaseContinueLegacyRefuses`, `TestFinalizeRebaseContinueMarkerWriteFailureNoRecoveryClaim`, `TestFinalizeRebaseContinueMarksStartedBeforeStaging`, `TestFinalizeRebaseContinueNextConflictExhausted`, `TestFinalizeRebaseContinueNextConflictUnderBudget`, `TestFinalizeRebaseContinueReconcileWriteFailureNextConflict`, `TestFinalizeRebaseContinueReconcileWriteFailurePreserves`, `TestFinalizeRebaseContinueRepeatedConsumedReservation`, `TestFinalizeRebaseContinueReservationMissing`, `TestFinalizeRebaseContinueReservationStaleCommit`, `TestFinalizeRebaseContinueReservationStaleToken`, `TestFinalizeRebaseContinueStartedAdvancedRecoveryWriteFails`, `TestFinalizeRebaseContinueStartedAmbiguousRetains`, `TestFinalizeRebaseContinueStartedCompletedRecovers`, `TestFinalizeRebaseContinueStartedCompletedRecoveryWriteFails`, `TestFinalizeRebaseGateHaltCarriesAdmissionRefusal`, `TestFinalizeRebaseGateHaltGenericUnchanged`, `TestFinalizeRebaseGateOffCreatesNoReceipt`, `TestFinalizeRebaseResolverBudgetClearReloadsForward`, `TestFinalizeRebaseResolverBudgetRecoveryNoResnapshot`, `TestFinalizeRebaseResolverBudgetSnapshot`, `TestFinalizeRebaseResolverBudgetWaitingReloadsForward`, `TestMutateReceiptForAttemptSkipsSuperseded` +- `finalize_block_test.go` (3 of 5; the rest stay in the default file): `TestFinalizeBlockUnrelatedInvalidRecordProgress`, `TestFinalizeBlockUnrelatedInvalidRecordRefusals`, `TestFinalizeClearBlockUnrelatedInvalidRecordProgress` +- `pr_publish_test.go` (2 of 3; the rest stay in the default file): `TestPRPublishAcceptsSkippedEvidenceAtExactHead`, `TestPRPublishPreEffectValidationIsScoped` + +**Race candidates, pre-classified at plan time** (their bodies start goroutines or hold two live launches at once; confirm against the criterion in Step 2): `TestFinalizeResolverReserveConcurrent`. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task10.txt" +grep -E -e "^internal/app/(finalize_rebase|finalize_reserve|finalize_block|finalize_publish|pr_publish)_test\.go " "${TMPDIR:-/tmp}/census-task10.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/finalize_reserve_test.go internal/app/finalize_reserve_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/finalize_reserve_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/finalize_reserve_integration_test.go +git mv internal/app/finalize_publish_test.go internal/app/finalize_publish_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/finalize_publish_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/finalize_publish_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_finalizeops.sh (prefix ^TestIntegrationFinalizeOps). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/finalize_rebase_test.go` → `internal/app/finalize_rebase_ops_integration_test.go`: 23 functions (listed above). +- `internal/app/finalize_block_test.go` → `internal/app/finalize_block_integration_test.go`: 3 functions (listed above). +- `internal/app/pr_publish_test.go` → `internal/app/pr_publish_integration_test.go`: 2 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationFinalizeOps" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_finalizeops.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_finalizeops.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_finalizeops.sh — Go integration shard (change 0465, extending change +# 0333's partition): the finalize rebase-continue, resolver-reserve, block, publish, and PR-publish tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationFinalizeOps. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationFinalizeOps" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/finalize_rebase_test.go|internal/app/finalize_reserve_test.go|internal/app/finalize_block_test.go|internal/app/finalize_publish_test.go|internal/app/pr_publish_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_finalizeops.sh; echo "shard rc=$?" +bash tests/test_go_integration_app_concurrency.sh; echo "race shard rc=$?" # only if a test was race-classified +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0` (and `race shard rc=0` when used). +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_finalizeops.sh' 2>&1 | tail -4 +bash -c 'time bash tests/test_go_integration_app_concurrency.sh' 2>&1 | tail -4 # only if a test was race-classified +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_finalizeops.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. If a race test joined `tests/test_go_integration_app_concurrency.sh`, re-measure it the same way and raise its row only if the new measurement exceeds it. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_finalizeops.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move finalize operations real-git tests behind the integration tag (TestIntegrationFinalizeOps, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 11: Move the evidence and run verify real-git tests into the `TestIntegrationEvidence` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/evidence_recertify_test.go` → `internal/app/evidence_recertify_integration_test.go` +- Split (only the 3 listed tests of 6): `internal/app/evidence_ops_test.go` → new `internal/app/evidence_ops_integration_test.go` +- Split (only the 4 listed tests of 8): `internal/app/run_verify_test.go` → new `internal/app/run_verify_integration_test.go` +- Create: `tests/test_go_integration_app_evidence.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_evidence.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_evidence.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationEvidence"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationEvidence`, e.g. `TestBuildLocalGateFailsClosedWithoutBuildCommand` → `TestIntegrationEvidenceBuildLocalGateFailsClosedWithoutBuildCommand`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 26 offenders: the evidence record/recertify and run-verify tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `evidence_recertify_test.go` (all 19): `TestBuildLocalGateFailsClosedWithoutBuildCommand`, `TestBuildLocalGateResolvesBuildCommandOnly`, `TestEvidenceRecertifyAdvancesOneDriveAcrossWaiting`, `TestEvidenceRecertifyEditContended`, `TestEvidenceRecertifyEditFailureThenRetry`, `TestEvidenceRecertifyEditUnrecognizedDisposition`, `TestEvidenceRecertifyGateFailureAndHalt`, `TestEvidenceRecertifyGateOffRecordsSkipped`, `TestEvidenceRecertifyHappyPath`, `TestEvidenceRecertifyRefusesClosedOrMismatchedPR`, `TestEvidenceRecertifyRefusesDirtyAfterGate`, `TestEvidenceRecertifyRefusesDirtyWorkspace`, `TestEvidenceRecertifyRefusesForeignCommandEvidence`, `TestEvidenceRecertifyRefusesHeadMovedUnderGate`, `TestEvidenceRecertifyRefusesNotImplemented`, `TestEvidenceRecertifyRefusesUnconfiguredGate`, `TestEvidenceRecertifyRefusesUnpublishedFollowUp`, `TestEvidenceRecertifyRefusesWrongHeadEvidence`, `TestEvidenceRecertifyShape` +- `evidence_ops_test.go` (3 of 6; the rest stay in the default file): `TestEvidenceRecordBuildGateOffMintsSkipped`, `TestEvidenceRecordRecordsBuildCommandNotFinalize`, `TestEvidenceRecordUnconfiguredBuildCommandIsTypedSetupRefusal` +- `run_verify_test.go` (4 of 8; the rest stay in the default file): `TestRunVerifyAcceptsSkippedEvidenceAtExactHead`, `TestRunVerifyInvalidResultsContentIsUnmetConjunct`, `TestRunVerifyMissingResultsIsUnmetConjunct`, `TestRunVerifyWaitingSurvivesMissingResults` + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task11.txt" +grep -E -e "^internal/app/(evidence_recertify|evidence_ops|run_verify)_test\.go " "${TMPDIR:-/tmp}/census-task11.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/evidence_recertify_test.go internal/app/evidence_recertify_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/evidence_recertify_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/evidence_recertify_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_evidence.sh (prefix ^TestIntegrationEvidence). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/evidence_ops_test.go` → `internal/app/evidence_ops_integration_test.go`: 3 functions (listed above). +- `internal/app/run_verify_test.go` → `internal/app/run_verify_integration_test.go`: 4 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationEvidence" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_evidence.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_evidence.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_evidence.sh — Go integration shard (change 0465, extending change +# 0333's partition): the evidence record/recertify and run-verify tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationEvidence. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationEvidence" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/evidence_recertify_test.go|internal/app/evidence_ops_test.go|internal/app/run_verify_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_evidence.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_evidence.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_evidence.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_evidence.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move evidence and run verify real-git tests behind the integration tag (TestIntegrationEvidence, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 12: Move the change and ADR record operations real-git tests into the `TestIntegrationRecordOps` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/change_attach_git_test.go` → `internal/app/change_attach_git_integration_test.go` +- Move (whole file, `git mv`): `internal/app/claim_proof_git_test.go` → `internal/app/claim_proof_git_integration_test.go` +- Split (only the 10 listed tests of 14): `internal/app/change_claim_test.go` → new `internal/app/change_claim_integration_test.go` +- Split (only the 4 listed tests of 11): `internal/app/change_halt_test.go` → new `internal/app/change_halt_integration_test.go` +- Split (only the 3 listed tests of 10): `internal/app/change_repair_test.go` → new `internal/app/change_repair_integration_test.go` +- Split (only the 3 listed tests of 4): `internal/app/change_implemented_test.go` → new `internal/app/change_implemented_integration_test.go` +- Split (only the 2 listed tests of 10): `internal/app/change_reconcile_test.go` → new `internal/app/change_reconcile_integration_test.go` +- Split (only the 2 listed tests of 24): `internal/app/change_lifecycle_test.go` → new `internal/app/change_lifecycle_integration_test.go` +- Split (only the 2 listed tests of 40): `internal/app/change_groom_test.go` → new `internal/app/change_groom_integration_test.go` +- Split (only the 1 listed tests of 16): `internal/app/change_create_test.go` → new `internal/app/change_create_integration_test.go` +- Split (only the 3 listed tests of 21): `internal/app/adr_ops_test.go` → new `internal/app/adr_ops_integration_test.go` +- Create: `tests/test_go_integration_app_recordops.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_recordops.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_recordops.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationRecordOps"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationRecordOps`, e.g. `TestChangeAttachUnrelatedInvalidRecordProgress` → `TestIntegrationRecordOpsChangeAttachUnrelatedInvalidRecordProgress`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 33 offenders: the change/ADR record-operation tests over real git (unrelated-invalid-record, claim gate-context, and real-git replay families). They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `change_attach_git_test.go` (all 2): `TestChangeAttachUnrelatedInvalidRecordProgress`, `TestChangeAttachUnrelatedInvalidRecordRefusals` +- `claim_proof_git_test.go` (all 1): `TestScanClaimProofsReadsCommittedReceipt` +- `change_claim_test.go` (10 of 14; the rest stay in the default file): `TestChangeClaimUnrelatedDependentsOfBrokenProgress`, `TestChangeClaimUnrelatedInvalidRecordProgress`, `TestChangeClaimUnrelatedInvalidRecordRefusals`, `TestChangeClaimUnrelatedShapesProgress`, `TestChangeRefreshClaimUnrelatedInvalidRecordRefusals`, `TestClaimGateContextConflictRefused`, `TestClaimGateContextInvalidRefusesBeforeTransaction`, `TestClaimGateContextReservesAndConfirms`, `TestClaimTerminalGateRefused`, `TestClaimUngatedUnchanged` +- `change_halt_test.go` (4 of 11; the rest stay in the default file): `TestChangeHaltUnrelatedInvalidRecordProgress`, `TestChangeHaltUnrelatedInvalidRecordRefusals`, `TestChangeResumeHaltedUnrelatedInvalidRecordProgress`, `TestChangeResumeHaltedUnrelatedInvalidRecordRefusals` +- `change_repair_test.go` (3 of 10; the rest stay in the default file): `TestRepairAdoptPRHeadAppliesOnMalformedRecordedBranch`, `TestRepairIdentityUnrelatedInvalidRecordProgress`, `TestRepairIdentityUnrelatedInvalidRecordRefusals` +- `change_implemented_test.go` (3 of 4; the rest stay in the default file): `TestMarkImplementedAcceptsSkippedEvidence`, `TestMarkImplementedUnrelatedInvalidRecordProgress`, `TestMarkImplementedUnrelatedInvalidRecordRefusals` +- `change_reconcile_test.go` (2 of 10; the rest stay in the default file): `TestChangeReconcileUnrelatedInvalidRecordProgress`, `TestChangeReconcileUnrelatedInvalidRecordRefusals` +- `change_lifecycle_test.go` (2 of 24; the rest stay in the default file): `TestChangeLifecycleUnrelatedInvalidRecordProgress`, `TestChangeLifecycleUnrelatedInvalidRecordRefusals` +- `change_groom_test.go` (2 of 40; the rest stay in the default file): `TestChangeGroomAbstainThenRearmRealGit`, `TestChangeGroomReviseSpecVersionContendsRealGit` +- `change_create_test.go` (1 of 16; the rest stay in the default file): `TestChangeCreateNormalizedPrefixReplaysRealGit` +- `adr_ops_test.go` (3 of 21; the rest stay in the default file): `TestADRRecordIndexSurfacesUnparseableUnrelatedADR`, `TestADRUnrelatedInvalidRecordProgress`, `TestADRUnrelatedInvalidRecordRefusals` + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task12.txt" +grep -E -e "^internal/app/(change_claim|change_halt|change_repair|change_implemented|change_reconcile|change_lifecycle|change_groom|change_attach_git|change_create|claim_proof_git|adr_ops)_test\.go " "${TMPDIR:-/tmp}/census-task12.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/change_attach_git_test.go internal/app/change_attach_git_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/change_attach_git_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/change_attach_git_integration_test.go +git mv internal/app/claim_proof_git_test.go internal/app/claim_proof_git_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/claim_proof_git_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/claim_proof_git_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/change_claim_test.go` → `internal/app/change_claim_integration_test.go`: 10 functions (listed above). +- `internal/app/change_halt_test.go` → `internal/app/change_halt_integration_test.go`: 4 functions (listed above). +- `internal/app/change_repair_test.go` → `internal/app/change_repair_integration_test.go`: 3 functions (listed above). +- `internal/app/change_implemented_test.go` → `internal/app/change_implemented_integration_test.go`: 3 functions (listed above). +- `internal/app/change_reconcile_test.go` → `internal/app/change_reconcile_integration_test.go`: 2 functions (listed above). +- `internal/app/change_lifecycle_test.go` → `internal/app/change_lifecycle_integration_test.go`: 2 functions (listed above). +- `internal/app/change_groom_test.go` → `internal/app/change_groom_integration_test.go`: 2 functions (listed above). +- `internal/app/change_create_test.go` → `internal/app/change_create_integration_test.go`: 1 functions (listed above). +- `internal/app/adr_ops_test.go` → `internal/app/adr_ops_integration_test.go`: 3 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationRecordOps" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_recordops.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_recordops.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_recordops.sh — Go integration shard (change 0465, extending change +# 0333's partition): the change/ADR record-operation tests over real git (unrelated-invalid-record, claim gate-context, and real-git replay families) — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationRecordOps. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationRecordOps" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/change_claim_test.go|internal/app/change_halt_test.go|internal/app/change_repair_test.go|internal/app/change_implemented_test.go|internal/app/change_reconcile_test.go|internal/app/change_lifecycle_test.go|internal/app/change_groom_test.go|internal/app/change_attach_git_test.go|internal/app/change_create_test.go|internal/app/claim_proof_git_test.go|internal/app/adr_ops_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_recordops.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_recordops.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_recordops.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_recordops.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move change and ADR record operations real-git tests behind the integration tag (TestIntegrationRecordOps, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- + +### Task 13: Move the repo phase, operational context and sweep session real-git tests into the `TestIntegrationContextProbe` shard + +**Files:** +- Move (whole file, `git mv`): `internal/app/repophase_test.go` → `internal/app/repophase_integration_test.go` +- Move (whole file, `git mv`): `internal/app/operational_context_test.go` → `internal/app/operational_context_integration_test.go` +- Move (whole file, `git mv`): `internal/app/sweep_session_test.go` → `internal/app/sweep_session_integration_test.go` +- Split (only the 4 listed tests of 8): `internal/app/named_branch_facts_test.go` → new `internal/app/named_branch_facts_integration_test.go` +- Create: `tests/test_go_integration_app_contextprobe.sh` +- Modify: `tests/runtime-budgets.tsv` (one new row for `tests/test_go_integration_app_contextprobe.sh`) +- Possibly create: `internal/app/_helpers_test.go` (untagged). Only when a helper that a still-default file uses would otherwise end up behind the tag. +- Modify: any maintained file the Step 5 reference grep finds + +**Interfaces:** +- Consumes: Task 1's default-build guard (`installNoGitGuard`, `internal/app/nogit_guard_test.go`). Every default-build real-git exec fails with stderr `docket nogit guard: default internal/app tests must not run real git`, and the package prints a `real-git exec attempt(s) reached the guard shim` summary. Also the shard executor `tests/lib/go-integration-shard.sh` and the contract `tests/test_go_integration_contract.sh`, used unchanged. +- Produces: runner `tests/test_go_integration_app_contextprobe.sh` (`SHARD_PKG="./internal/app"`, `SHARD_PREFIX="TestIntegrationContextProbe"`, `SHARD_MODE="normal"`). Every moved normal test is renamed `TestIntegrationContextProbe`, e.g. `TestResolveRepoPhaseAbsentKeyNotAuthorized` → `TestIntegrationContextProbeResolveRepoPhaseAbsentKeyNotAuthorized`. If Step 2 race-classifies a test anyway, it becomes `TestRaceIntegrationAppConcurrency` and joins the existing race shard `tests/test_go_integration_app_concurrency.sh`. + +**Context.** Task 1 made the default `internal/app` corpus refuse real git. The package is **red** until Tasks 3–13 have all landed, and that is expected. This task removes exactly its own 23 offenders: the repo-phase resolution, operational-context, named-branch-facts, and sweep-session tests. They move behind `//go:build integration` into a new plain (non-race) shard, following change 0333's partition. Do not touch other tasks' offenders. + +**Task-specific note.** This is the last move task. After it, Step 7's census should report `guard: no attempts` and no `--- FAIL` at all for `./internal/app/`. If anything remains, name it in your report. Task 15 re-proves this, but a leftover here means an earlier task's census was incomplete. + +**Offenders in this task (census snapshot taken at plan time on `3f9813fbc` with a refusing git shim; the live census in Step 1 is authoritative):** + +- `repophase_test.go` (all 9): `TestResolveRepoPhaseAbsentKeyNotAuthorized`, `TestResolveRepoPhaseAgentsTableAloneNotAuthorized`, `TestResolveRepoPhaseDiscoversFromRootAndNestedDir`, `TestResolveRepoPhaseGlobalLayerNotAuthorized`, `TestResolveRepoPhaseInvalidExplicitRepoDir`, `TestResolveRepoPhaseOutsideGitIsMachineOnly`, `TestResolveRepoPhaseRetiresDroppedClaudeLink`, `TestResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord`, `TestResolveRepoPhaseToleratesUnknownKeys` +- `operational_context_test.go` (all 5): `TestFailClosedOrdering`, `TestOperationalGateFindingIsTheClassifierValue`, `TestOperationalGatePassesHealthy`, `TestOperationalGateRefusesLegacy`, `TestStatusInvalidConfigDiagnostics` +- `sweep_session_test.go` (all 5): `TestBoundReaderNeverFetches`, `TestPrepareFailedFetchIsErrorNeverStaleFallback`, `TestPrepareIsOneMetadataFetchZeroSetupProbes`, `TestPrepareObservesFreshMetadataTip`, `TestSessionRefusesDifferentRepository` +- `named_branch_facts_test.go` (4 of 8; the rest stay in the default file): `TestChangeClaimProbesOnlyOwnStack`, `TestFinalizeClearBlockProbesOnlyOwnStack`, `TestMergeContextProbesOnlyOwnStack`, `TestWorkspaceContextProbesOnlyOwnStack` + - In `repophase_test.go`, `TestResolveRepoPhaseInvalidExplicitRepoDir` and `TestResolveRepoPhaseOutsideGitIsMachineOnly` *pass* under the guard, because they tolerate the git failure. They appear only in the guard's attempt listing, never as `--- FAIL`, and they still move. + +**Race candidates:** none were pre-classified. Apply the Step 2 criterion anyway. + +- [ ] **Step 1: Confirm this task's live offenders from the guard's census** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; printf "%s %s\n" "$f" "$t"; done > "${TMPDIR:-/tmp}/census-task13.txt" +grep -E -e "^internal/app/(repophase|operational_context|named_branch_facts|sweep_session)_test\.go " "${TMPDIR:-/tmp}/census-task13.txt" | LC_ALL=C sort +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" +' +``` +Expected: the listed `--- FAIL` offenders for this task's files match the snapshot above. If the live census differs, the live census wins. Move every live offender in these files (a test added since the snapshot included), leave a snapshot name that no longer fails in place, and report the difference. Write down the attempt count from the summary line. Step 7 must show it smaller. + +- [ ] **Step 2: Classify each offender as normal or race** + +A test is **race** only if its body (or a helper it calls for the scenario) starts goroutines (`go func`/`go f(`), coordinates simultaneous operations with `sync.WaitGroup`, errgroup or channels, or holds two live processes or launches against shared state at once. Everything else, including sequential "replay"/"idempotent" tests, is **normal**. Race tests keep 0333's rule that race instrumentation is only for tests exercising real concurrency. Each one gets a one-line comment directly above its `func`: `// Race shard (change 0465): .` List the classification in your report. + +- [ ] **Step 3: Move the tests behind the tag** + +Whole files (every test in the file is an offender): + +```bash +bash -c ' +set -euo pipefail +git mv internal/app/repophase_test.go internal/app/repophase_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/repophase_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/repophase_integration_test.go +git mv internal/app/operational_context_test.go internal/app/operational_context_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/operational_context_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/operational_context_integration_test.go +git mv internal/app/sweep_session_test.go internal/app/sweep_session_integration_test.go +tmp="$(mktemp "${TMPDIR:-/tmp}/tagmove.XXXXXX")"; { printf "//go:build integration\n\n"; cat internal/app/sweep_session_integration_test.go; } > "$tmp" && mv -f "$tmp" internal/app/sweep_session_integration_test.go +' +``` +Then check each moved file: line 1 is `//go:build integration` and line 2 is blank (`sed -n '1,3p' `). If the original file opened with its own `//go:build` line, merge the constraints into one line-1 constraint instead of stacking two. + +Split files. Create each new file with this header, then **cut** each listed test function, together with its doc comment, out of the source file and paste it below the header. The file's non-`Test` helpers stay where they are: + +```go +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_contextprobe.sh (prefix ^TestIntegrationContextProbe). + +import ( + // exactly the imports the moved functions use; `go vet` in Step 4 names any gap +) +``` +- `internal/app/named_branch_facts_test.go` → `internal/app/named_branch_facts_integration_test.go`: 4 functions (listed above). + +There is no goimports on this machine. Fix imports by hand from the compiler's `imported and not used` / `undefined:` messages in both the source and the new file. + +- [ ] **Step 4: Keep every build compiling (helpers stay reachable)** + +An untagged `_test.go` file compiles into **every** build, and a tagged one only into `-tags integration`. So any non-`Test` identifier (func, type, var, const) now in a tagged file that an untagged or `e2e` file still references must go back into an untagged file. Put it in `internal/app/_helpers_test.go`, where `` is the source file's base name, with no build constraint. Iterate until all three builds are clean: + +```bash +gofmt -l internal/app +go vet ./internal/app/ && go vet -tags integration ./internal/app/ && go vet -tags e2e ./internal/app/ +``` +Expected: `gofmt -l` prints nothing and all three vets exit 0. + +- [ ] **Step 5: Rename the moved tests and every maintained reference** + +Derive references with a whole-repo grep, never a hand list. Point-in-time records keep the old names. Put the normal-classified names in `NORMAL` and the race-classified ones in `RACE`, both as space-separated old names: + +```bash +bash -c ' +set -uo pipefail +PREFIX="TestIntegrationContextProbe" +NORMAL="" +RACE="" +rename(){ old="$1"; new="$2" + hits="$(git grep -l -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$hits" ] || perl -pi -e "s/\\b\\Q${old}\\E\\b/${new}/g" $hits + left="$(git grep -n -w -e "$old" -- . ":!docs/results" ":!docs/changes" ":!docs/superpowers" ":!docs/adrs")" + [ -z "$left" ] || { printf "STILL REFERENCED %s:\n%s\n" "$old" "$left"; exit 1; }; } +for old in $NORMAL; do rename "$old" "${PREFIX}${old#Test}"; done +for old in $RACE; do rename "$old" "TestRaceIntegrationAppConcurrency${old#Test}"; done +' +``` +Expected: exit 0 and no `STILL REFERENCED` lines. The `\b…\b` word boundary keeps `TestFoo` from rewriting `TestFooBar`. Read `git diff --stat` and inspect every file touched **outside** `internal/app/`. A comment or doc that described the test as a default-corpus test gets its wording corrected, not only its name. + +- [ ] **Step 6: Create the shard runner** + +Create `tests/test_go_integration_app_contextprobe.sh` with exactly this content, then `chmod +x tests/test_go_integration_app_contextprobe.sh`: + +```bash +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_contextprobe.sh — Go integration shard (change 0465, extending change +# 0333's partition): the repo-phase resolution, operational-context, named-branch-facts, and sweep-session tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationContextProbe. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationContextProbe" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" +``` + +(The `assert` line is byte-identical to the canonical helper. `internal/repoguard`'s source-hygiene rule (a) allowlists it byte for byte, so copy it exactly.) + +- [ ] **Step 7: Verify: offenders gone, shard green, contract green, no prefix collision** + +```bash +bash -c ' +census_out="$(go test -count=1 -v ./internal/app/ 2>&1)" +fails="$(grep -E -e "^--- FAIL: " <<<"$census_out" | awk "{print \$3}" | LC_ALL=C sort -u)" +mine=""; for t in $fails; do f="$(grep -l -E -e "^func ${t}\(" internal/app/*_test.go)"; case "$f" in internal/app/repophase_test.go|internal/app/operational_context_test.go|internal/app/named_branch_facts_test.go|internal/app/sweep_session_test.go) mine="$mine $t";; esac; done +printf "remaining offenders in this task files:%s\n" "${mine:- none}" +grep -E -e "real-git exec attempt\(s\) reached the guard shim" <<<"$census_out" || echo "guard: no attempts" +leak="$(go test -list "^Test(Race)?Integration" ./internal/app/ 2>&1)"; grep -E -e "^Test(Race)?Integration" <<<"$leak" && echo LEAK || echo "no leak into the default corpus" +' +bash tests/test_go_integration_app_contextprobe.sh; echo "shard rc=$?" +bash tests/test_go_integration_contract.sh; echo "contract rc=$?" +bash -c 'p="$(for r in tests/test_go_integration_app_*.sh; do DOCKET_SHARD_INSPECT=1 bash "$r" | sed -n "s/^prefix=//p"; done)"; for a in $p; do for b in $p; do [ "$a" != "$b" ] && case "$b" in "$a"*) echo "COLLISION: $a is a prefix of $b";; esac; done; done; echo prefix-check-done' +``` +Expected: +- `remaining offenders in this task files: none`. +- The guard attempt count is lower than in Step 1, or `guard: no attempts` once every task has landed. +- `no leak into the default corpus`. +- Every runner prints only `ok - ` lines, with `shard rc=0` and `contract rc=0`. +- The prefix check prints only `prefix-check-done`. + +The default package as a whole stays red until Task 13. That is expected. + +- [ ] **Step 8: Measure and register the budget row** + +```bash +bash -c 'time bash tests/test_go_integration_app_contextprobe.sh' 2>&1 | tail -4 +``` +Take the `real` seconds S of the solo run. The row is S rounded up to the next multiple of 5, plus 5, with a minimum of 10. Insert `tests/test_go_integration_app_contextprobe.shparallel` into `tests/runtime-budgets.tsv` directly after the `tests/test_go_integration_app_sync.sh` row, with a literal tab, not spaces. A row above 60 would not fit the parallel lane. Do **not** register it. Return NEEDS_ESCALATION with the measurement, so the shard can be split into two runners with disjoint new prefixes. + +Then run: `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add -- internal/app tests/runtime-budgets.tsv tests/test_go_integration_app_contextprobe.sh # plus any file outside internal/app that Step 5 rewrote; list it explicitly +git status --porcelain # expect nothing unstaged or untracked that belongs to this task +git commit -m "test(app): move repo phase, operational context and sweep session real-git tests behind the integration tag (TestIntegrationContextProbe, change 0465)" +``` +(`git add -- internal/app` stages this task's renames and splits. Before committing, confirm with `git status --porcelain` that nothing under `internal/app` belongs to another task.) + +--- +### Task 14: Explicit `-timeout` backstop and readable overrun in `tests/test_go_race.sh` + +**Files:** +- Modify: `tests/test_go_race.sh` (header `PARTITION AND LANE` paragraph, a new `BACKSTOP TIMEOUT` paragraph, the `go test -race` invocation, one new assert) +- Create: `internal/repoguard/race_gate_timeout_test.go` + +**Interfaces:** +- Consumes: Tasks 3–13 (the post-partition default corpus to measure), and the `internal/repoguard` test helpers `Root()`, `writeToolScript(t, path, body)` and `readLog(t, path)` (defined in `internal/repoguard/gofmt_toolchain_test.go`, same package). +- Produces: `tests/test_go_race.sh` defines `RACE_TIMEOUT="m"` and runs `go test -race $go_conc_args -timeout "$RACE_TIMEOUT" -count=1 ./...`. On overrun it prints the assert line `NOT OK - no package ran past the m -timeout backstop` plus a stderr remedy naming `PARTITION AND LANE`. + +The backstop is not a cure. The guard and the budget row detect growth. The timeout only makes an overrun readable, instead of Go's 10m goroutine-dump panic. + +- [ ] **Step 1: Measure the post-partition worst package under the CI-equivalent cap** + +CI's derived cap on a shared 3-core runner is `-p 2` with `GOMAXPROCS=2`. + +```bash +bash -c ' +out="$(GOMAXPROCS=2 go test -race -count=1 -p 2 -json ./... 2>/dev/null)" +ranked="$(jq -r "select(.Test==null and (.Action==\"pass\" or .Action==\"fail\")) | \"\(.Elapsed)\t\(.Package)\t\(.Action)\"" <<<"$out" | sort -rn)" +sed -n "1,5p" <<<"$ranked" +' +``` +Expected: every package reports `pass`. `github.com/danielhanold/docket/internal/app` should now be well under a minute, down from ~238s at `-p 3`. Let W be the top line's seconds. Compute `RACE_TIMEOUT` as 4 × W rounded **up** to whole minutes, with a minimum of `3m`. If 4 × W exceeds 8 minutes, stop: report NEEDS_ESCALATION with the ranking, because the partition did not bring the gate into range. Record the top five lines and your chosen value in your report. + +- [ ] **Step 2: Write the failing behavioral tests** + +Create `internal/repoguard/race_gate_timeout_test.go`: + +```go +package repoguard + +// Change 0465: tests/test_go_race.sh passes an explicit -timeout backstop (below Go's +// 10m per-package default) and turns an overrun into a named, readable NOT OK line +// instead of a bare goroutine-dump panic. These are behavioral tests over a COPY of +// the real wrapper (read at test time, so nothing frozen can drift) with a fake `go` +// on PATH that logs its argv. Same pattern and helpers as gofmt_toolchain_test.go +// (writeToolScript, readLog). The asserts pin the mechanism (the argv go test +// actually received) and also prove the gate is not weakened (-race, -count=1, ./...). + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const fakeRaceGoScript = `printf 'argv:[%s]\n' "$*" >>"$GO_FAKE_LOG" +if [ "$1" = test ] && [ -n "${FAKE_GO_TIMEOUT:-}" ]; then + printf 'panic: test timed out after 4m0s\n\ngoroutine 1 [running]:\nFAIL\tfixture/slow\t240.012s\nFAIL\n' + exit 1 +fi +exit 0 +` + +type raceGateFixture struct { + root, wrapper, goLog string + env []string +} + +func newRaceGateFixture(t *testing.T) *raceGateFixture { + t.Helper() + repoRoot, err := Root() + if err != nil { + t.Fatal(err) + } + realWrapper, err := os.ReadFile(filepath.Join(repoRoot, "tests", "test_go_race.sh")) + if err != nil { + t.Fatal(err) + } + base := testsupport.TempDir(t) + f := &raceGateFixture{root: filepath.Join(base, "fixture"), goLog: filepath.Join(base, "go.log")} + f.wrapper = filepath.Join(f.root, "tests", "test_go_race.sh") + if err := os.MkdirAll(filepath.Dir(f.wrapper), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.wrapper, realWrapper, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(f.wrapper, 0o755); err != nil { + t.Fatal(err) + } + fakeBin := filepath.Join(base, "fakebin") + writeToolScript(t, filepath.Join(fakeBin, "go"), fakeRaceGoScript) + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + switch key { + case "PATH", "GOMODCACHE", "GOCACHE", "GOFLAGS", "GOMAXPROCS", + "DOCKET_GO_TEST_CONCURRENCY", "GO_FAKE_LOG", "FAKE_GO_TIMEOUT": + continue + } + f.env = append(f.env, kv) + } + // GOMODCACHE/GOCACHE pre-set so the wrapper's cache block never calls git. + f.env = append(f.env, + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + "GOMODCACHE="+filepath.Join(base, "gomodcache"), + "GOCACHE="+filepath.Join(base, "gocache"), + "GO_FAKE_LOG="+f.goLog, + ) + return f +} + +func (f *raceGateFixture) run(t *testing.T) (string, int) { + t.Helper() + cmd := exec.Command("bash", f.wrapper) + cmd.Dir = f.root + cmd.Env = f.env + b, err := cmd.CombinedOutput() + var ee *exec.ExitError + switch { + case err == nil: + return string(b), 0 + case errors.As(err, &ee): + return string(b), ee.ExitCode() + default: + t.Fatalf("running the wrapper copy: %v\n%s", err, b) + return "", -1 + } +} + +// raceTestArgv returns the argv of the fake `go test` invocation. +func raceTestArgv(t *testing.T, log string) []string { + t.Helper() + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, "argv:[test ") { + return strings.Fields(strings.TrimSuffix(strings.TrimPrefix(line, "argv:["), "]")) + } + } + t.Fatalf("the fake go never received a `go test` invocation; log:\n%s", log) + return nil +} + +func TestRaceGatePassesTimeoutBackstopBelowGoDefault(t *testing.T) { + f := newRaceGateFixture(t) + out, code := f.run(t) + if code != 0 { + t.Fatalf("a green fake run must exit 0, got %d:\n%s", code, out) + } + argv := raceTestArgv(t, readLog(t, f.goLog)) + var timeout time.Duration + found := false + for i, a := range argv { + val := "" + switch { + case a == "-timeout" && i+1 < len(argv): + val = argv[i+1] + case strings.HasPrefix(a, "-timeout="): + val = strings.TrimPrefix(a, "-timeout=") + default: + continue + } + d, err := time.ParseDuration(val) + if err != nil { + t.Fatalf("-timeout value %q does not parse as a duration: %v", val, err) + } + timeout, found = d, true + } + if !found { + t.Fatalf("go test -race must carry an explicit -timeout backstop; argv %q", argv) + } + if timeout <= 0 || timeout >= 10*time.Minute { + t.Fatalf("the backstop %s must be positive and below Go's 10m default", timeout) + } + for _, want := range []string{"-race", "-count=1", "./..."} { + if !slices.Contains(argv, want) { + t.Fatalf("the race gate must not be weakened: argv %q lacks %q", argv, want) + } + } +} + +var raceBackstopMarker = regexp.MustCompile(`(?m)^NOT OK - no package ran past the \S+ -timeout backstop$`) + +func TestRaceGateNamesTimeoutBackstopOnOverrun(t *testing.T) { + f := newRaceGateFixture(t) + f.env = append(f.env, "FAKE_GO_TIMEOUT=1") + out, code := f.run(t) + if code == 0 { + t.Fatalf("an overrun must fail the gate:\n%s", out) + } + if !raceBackstopMarker.MatchString(out) { + t.Fatalf("an overrun must print the named backstop marker, got:\n%s", out) + } + for _, want := range []string{"FAIL\tfixture/slow", "PARTITION AND LANE"} { + if !strings.Contains(out, want) { + t.Fatalf("the overrun diagnostic must contain %q, got:\n%s", want, out) + } + } +} +``` + +- [ ] **Step 3: Run them to verify they fail** + +Run: `go test -count=1 -run 'TestRaceGate' ./internal/repoguard/` +Expected: FAIL. `TestRaceGatePassesTimeoutBackstopBelowGoDefault` fails with "must carry an explicit -timeout backstop", and `TestRaceGateNamesTimeoutBackstopOnOverrun` fails with "must print the named backstop marker". + +- [ ] **Step 4: Implement in `tests/test_go_race.sh`** + +(a) Replace the whole `# PARTITION AND LANE.` paragraph of the header, from `# PARTITION AND LANE. Change 0333 partitioned` through `# tests/runtime-budgets.tsv like every other file.`, with: + +```bash +# PARTITION AND LANE. Change 0333 partitioned the slow real-git, subprocess, and +# process-lifecycle integration corpus of internal/app, internal/githubcli, and +# internal/gitcli behind the `integration` build tag — dedicated shard runners +# (tests/test_go_integration_*.sh) own it, and tests/test_go_integration_contract.sh +# proves that partition is total. This gate therefore covers the FAST default +# corpus only. Change 0465 made that an enforced invariant for internal/app, the +# package whose default corpus had regrown to ~920 tests (337 of them real-git, +# 225s of its 237s under -race): the default-tag internal/app test corpus never +# starts a real `git` process. installNoGitGuard (internal/app/nogit_guard_test.go) +# shadows git on PATH in the default build and fails the package on any attempt, +# so a new real-git test cannot land here unnoticed. With that tail gone, `go test +# -race`'s GOMAXPROCS-wide race workers do not oversubscribe the cores the other +# parallel jobs need (change 0332's reason for the serial lane, and change 0329's +# load-dependent build-gate halt), so this gate rides the PARALLEL lane under an +# ordinary row in tests/runtime-budgets.tsv like every other file. +# +# BACKSTOP TIMEOUT (change 0465). `go test` is given an explicit -timeout +# (RACE_TIMEOUT below): several times the measured post-partition worst package +# under CI's derived cap (-p 2, GOMAXPROCS=2), and below Go's 10m per-package +# default. It is NOT a growth allowance — the guard and the budget row are the +# growth detectors. It exists so an overrun fails with the named +# "no package ran past the … -timeout backstop" assert and the offending FAIL line, +# instead of a 10m goroutine-dump panic. Never raise it to make a slow package fit; +# move the slow tests behind the integration tag instead. +``` + +(b) Replace the invocation block: + +```bash +race_out="$(go test -race $go_conc_args -count=1 ./... 2>&1)" +race_rc=$? +assert "go test -race -count=1 ./... (the whole module) passes" '[ "$race_rc" -eq 0 ] || { printf "%s\n" "$race_out" >&2; false; }' +``` +with the following, using your Step 1 value in place of `m`: +```bash +# The backstop — see BACKSTOP TIMEOUT in this header (change 0465). +RACE_TIMEOUT="m" +race_out="$(go test -race $go_conc_args -timeout "$RACE_TIMEOUT" -count=1 ./... 2>&1)" +race_rc=$? +assert "go test -race -count=1 ./... (the whole module) passes" '[ "$race_rc" -eq 0 ] || { printf "%s\n" "$race_out" >&2; false; }' +assert "no package ran past the ${RACE_TIMEOUT} -timeout backstop" '! grep -q -E -e "^panic: test timed out after" <<<"$race_out" || { grep -E -e "^(FAIL[[:space:]]|panic: test timed out)" <<<"$race_out" >&2; printf "%s\n" "tests/test_go_race.sh: a package ran past the ${RACE_TIMEOUT} backstop — the fast default corpus has outgrown this gate; move real-git, subprocess, and process-lifecycle tests behind //go:build integration (see PARTITION AND LANE in this file)" >&2; false; }' +``` + +(`grep -q` reads a here-string, not a pipe, so the AGENTS.md SIGPIPE rule does not apply. The pattern leads with `^`, not `--`, so the negated assert cannot go vacuous on an option-parse error.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test -count=1 -run 'TestRaceGate' ./internal/repoguard/ && go test -count=1 ./internal/repoguard/` +Expected: PASS for both. The full repoguard package stays green, including the source-hygiene rules over the edited wrapper. + +- [ ] **Step 6: Mutation probes** + +Back up `tests/test_go_race.sh` to a `mktemp "${TMPDIR:-/tmp}/race-gate.XXXXXX"` copy. +- (a) Delete ` -timeout "$RACE_TIMEOUT"` from the invocation. Run `go test -count=1 -run TestRaceGatePassesTimeoutBackstopBelowGoDefault ./internal/repoguard/`. Expected: FAIL. +- Copy back. +- (b) Delete the second `assert "no package ran past…"` line. Run `go test -count=1 -run TestRaceGateNamesTimeoutBackstopOnOverrun ./internal/repoguard/`. Expected: FAIL. +- Copy back and re-run both. Expected: PASS. + +Report all four results. + +- [ ] **Step 7: Run the real gate once** + +Run: `bash tests/test_go_race.sh; echo "rc=$?"` +Expected: `ok - ` for all three asserts, `rc=0`. + +- [ ] **Step 8: Commit** + +```bash +git add tests/test_go_race.sh internal/repoguard/race_gate_timeout_test.go +git commit -m "test(race): explicit -timeout backstop with a readable overrun in test_go_race (change 0465)" +``` + +--- + +### Task 15: Honest budget rows and final re-measure + +**Files:** +- Modify: `tests/runtime-budgets.tsv` (the rows for `tests/test_go_race.sh`, `tests/test_go_toolchain.sh`, `tests/test_go_integration_app_concurrency.sh`, and the eleven new `tests/test_go_integration_app_*.sh` shards, only where the measurement changes the value) +- Modify: `tests/test_go_integration_app_concurrency.sh` (header comment only, when Tasks 3–13 moved race tests into it) + +**Interfaces:** +- Consumes: every earlier task. Specifically, the guard from Task 1, the eleven shard runners from Tasks 3–13 (`tests/test_go_integration_app_{gatecancel,gateverdict,gatefence,gatecompletion,gateepoch,gatearm,gatelifecycle,finalizeops,evidence,recordops,contextprobe}.sh`), and `RACE_TIMEOUT` from Task 14. +- Produces: final rows and the measurement record that the results file cites. + +- [ ] **Step 1: The whole default corpus is green with the guard on** + +Run: +```bash +bash -c ' +out="$(go test -count=1 ./internal/app/ 2>&1)"; rc=$? +printf "%s\n" "$out" | tail -5 +grep -E -e "real-git exec attempt\(s\)|docket nogit guard" <<<"$out" || echo "guard: silent" +echo "rc=$rc" +' +``` +Expected: `ok github.com/danielhanold/docket/internal/app`, `guard: silent`, `rc=0`. This is the proof that no default test reaches git. If anything fails, name it in the report and move it with the Tasks 3–13 procedure, into whichever of the eleven shards matches its family. + +- [ ] **Step 2: Contract and every app shard green** + +```bash +bash -c ' +bash tests/test_go_integration_contract.sh >/dev/null; echo "contract rc=$?" +for r in tests/test_go_integration_app_*.sh; do bash "$r" >/dev/null 2>&1; echo "$r rc=$?"; done +' +``` +Expected: every line ends `rc=0`. + +- [ ] **Step 3: Record the headline measurement** + +Run: `bash -c 'time GOMAXPROCS=3 go test -race -count=1 -p 3 ./internal/app/' 2>&1 | tail -4` +Expected: `real` of about 15–30s, against the ~238s grooming baseline on the same command. Record it in the report as `internal/app -race @3 CPUs: before ~238s, after s`. If it is above 60s, report `FINDING:` with the `-json` ranking of the slowest tests. Use the Task 14 Step 1 command narrowed to `./internal/app/`, and select `.Test != null` events instead of package events. + +- [ ] **Step 4: Re-measure and correct the rows** + +Measure each file solo (serial, uncontended) with `bash -c 'time bash ' 2>&1 | tail -4`: +- `tests/test_go_race.sh` +- `tests/test_go_toolchain.sh` (its plain `go test ./...` also lost the internal/app real-git tail) +- `tests/test_go_integration_app_concurrency.sh` +- the eleven new shard runners + +For each file, compute the rule value: `real` rounded up to the next multiple of 5, plus 5, minimum 10. Set the row to it, raising or lowering. One exception: **never write a parallel row above 60.** If `tests/test_go_race.sh` computes above 60, leave its row at `60` and add a `FINDING:` line to your report. Name the measured seconds and the dominating package from the Task 14 Step 1 ranking. Task 2's repo-relative key now lets the runner's `BUDGET WATCH` → `SERIAL CONFIRMED OVER BUDGET` path surface it across worktrees. Do not silently grant a large ceiling. Then run `go test -count=1 -run TestRuntimeBudgetsCorrespondence ./internal/repoguard/`. Expected: PASS. + +- [ ] **Step 5: Keep the race shard's header honest** + +If Tasks 3–13 moved any `TestRaceIntegrationAppConcurrency…` tests, check with `go test -tags integration -list '^TestRaceIntegrationAppConcurrency' ./internal/app/`. Then update the header comment of `tests/test_go_integration_app_concurrency.sh`. Its current description, "the concurrency-bearing app tests (concurrent planning mutations and gate-retry CAS)", becomes a description that also names the families that joined, for example "…, and the run-gate/launch/finalize-reserve concurrency tests moved out of the default corpus by change 0465". Change only the comment. `SHARD_*` lines are untouched. + +- [ ] **Step 6: Commit and report** + +```bash +git add tests/runtime-budgets.tsv tests/test_go_integration_app_concurrency.sh +git commit -m "test: re-measure budget rows after the internal/app partition (change 0465)" +``` + +Your report must carry, for the results file: +- the Task 15 Step 3 before/after numbers +- every row you changed (old → new, with the measured seconds) +- any `FINDING:` line + +Also state that **CI acceptance is a human action after merge**: several consecutive green release-candidate source-gate runs on `test_go_race`, with clear headroom under `RACE_TIMEOUT`. diff --git a/internal/app/adr_ops_integration_test.go b/internal/app/adr_ops_integration_test.go new file mode 100644 index 000000000..d1fc96835 --- /dev/null +++ b/internal/app/adr_ops_integration_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "strings" + "testing" +) + +func TestIntegrationRecordOpsADRUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + producerPath := groomPath(3, adrProducerSlug) + targetPath := adrPath("0001", "one") + rows := []struct { + name string + run func(t *testing.T, repo *gitRepo, node realNode) ADRResult + }{ + {name: "record", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { + return ADRRecordOp(context.Background(), node.deps, node.dir, validADRRecordRequest()) + }}, + {name: "record with producing change", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { + req := validADRRecordRequest() + req.Change = &ADRProducingChange{ID: 3, Path: producerPath, Version: blobVersionAt(t, repo.origin, "docket", producerPath)} + return ADRRecordOp(context.Background(), node.deps, node.dir, req) + }}, + {name: "supersede", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { + req := validADRReplaceRequest() + req.Target.Version = blobVersionAt(t, repo.origin, "docket", targetPath) + return ADRSupersede(context.Background(), node.deps, node.dir, req) + }}, + } + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + repo := newWorkingRepo(t, map[string]string{ + producerPath: lifecycleChange(3, adrProducerSlug, "in-progress"), + targetPath: fixtureADR(1, "one"), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + res := r.run(t, repo, planningDepsFor(t, repo.invocation)) + if res.Result != ResultApplied { + t.Fatalf("%s beside an unrelated unparseable record = %q (findings %v), want applied", r.name, res.Result, res.Findings) + } + assertUnrelatedBrokenIntact(t, repo) + }) + } +} + +// TestIntegrationRecordOpsADRRecordIndexSurfacesUnparseableUnrelatedADR: an ADR record beside an +// unrelated unparseable ADR applies, leaves that ADR's bytes untouched, and +// publishes an index whose repair notice names it — the index used to drop it +// silently (change 0449). +func TestIntegrationRecordOpsADRRecordIndexSurfacesUnparseableUnrelatedADR(t *testing.T) { + requireRealGit(t) + const brokenADR, brokenADRBytes = "docs/adrs/0009-broken.md", "---\nid: 9\nslug: broken\n" + repo := newWorkingRepo(t, map[string]string{ + adrPath("0001", "one"): fixtureADR(1, "one"), + brokenADR: brokenADRBytes, + }) + node := planningDepsFor(t, repo.invocation) + res := ADRRecordOp(context.Background(), node.deps, node.dir, validADRRecordRequest()) + if res.Result != ResultApplied { + t.Fatalf("adr record beside an unrelated unparseable ADR = %q (findings %v), want applied", res.Result, res.Findings) + } + if got, ok := originFile(t, repo.origin, "docket", brokenADR); !ok || got != brokenADRBytes { + t.Errorf("unrelated unparseable ADR on origin = %q (present %v), want its exact seeded bytes", got, ok) + } + index, ok := originFile(t, repo.origin, "docket", adrsIndexPath) + if !ok || !strings.Contains(index, "| `"+brokenADR+"` | unclosed-frontmatter |") { + t.Fatalf("published ADR index lacks the repair notice naming %s:\n%s", brokenADR, index) + } +} + +func TestIntegrationRecordOpsADRUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + producerPath := groomPath(3, adrProducerSlug) + cases := unrelatedRefusalCases(t, 3, producerPath, + lifecycleChange(3, adrProducerSlug, "in-progress"), lifecycleChange(3, "dupe", "in-progress")) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + req := validADRRecordRequest() + req.Change = &ADRProducingChange{ID: 3, Path: producerPath, Version: blobVersionAt(t, repo.origin, "docket", producerPath)} + res := ADRRecordOp(context.Background(), node.deps, node.dir, req) + if res.Result == ResultApplied { + t.Fatalf("adr record applied despite %s on its producing change; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, "", res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused adr record moved the metadata branch %s -> %s", tip, got) + } + }) + } +} diff --git a/internal/app/adr_ops_test.go b/internal/app/adr_ops_test.go index 581941f55..9298f90e3 100644 --- a/internal/app/adr_ops_test.go +++ b/internal/app/adr_ops_test.go @@ -580,95 +580,7 @@ func assertCanonicalADRReceipt(t *testing.T, receipt []byte, id int, path string // // Mutation check (run manually; noted in the commit): delete the `Scope:` field // from ADRRecordOp's (or adrReplace's) transaction.Request and -// `go test ./internal/app/ -run 'TestADR.*Unrelated' -count=1` reddens on that -// progress row with the before-gate refusal the bug produced. +// `go test -tags integration ./internal/app/ -run 'TestIntegrationRecordOpsADR.*Unrelated' -count=1` +// reddens on that progress row with the before-gate refusal the bug produced. const adrProducerSlug = "widget" - -func TestADRUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - producerPath := groomPath(3, adrProducerSlug) - targetPath := adrPath("0001", "one") - rows := []struct { - name string - run func(t *testing.T, repo *gitRepo, node realNode) ADRResult - }{ - {name: "record", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { - return ADRRecordOp(context.Background(), node.deps, node.dir, validADRRecordRequest()) - }}, - {name: "record with producing change", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { - req := validADRRecordRequest() - req.Change = &ADRProducingChange{ID: 3, Path: producerPath, Version: blobVersionAt(t, repo.origin, "docket", producerPath)} - return ADRRecordOp(context.Background(), node.deps, node.dir, req) - }}, - {name: "supersede", run: func(t *testing.T, repo *gitRepo, node realNode) ADRResult { - req := validADRReplaceRequest() - req.Target.Version = blobVersionAt(t, repo.origin, "docket", targetPath) - return ADRSupersede(context.Background(), node.deps, node.dir, req) - }}, - } - for _, r := range rows { - t.Run(r.name, func(t *testing.T) { - repo := newWorkingRepo(t, map[string]string{ - producerPath: lifecycleChange(3, adrProducerSlug, "in-progress"), - targetPath: fixtureADR(1, "one"), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - res := r.run(t, repo, planningDepsFor(t, repo.invocation)) - if res.Result != ResultApplied { - t.Fatalf("%s beside an unrelated unparseable record = %q (findings %v), want applied", r.name, res.Result, res.Findings) - } - assertUnrelatedBrokenIntact(t, repo) - }) - } -} - -// TestADRRecordIndexSurfacesUnparseableUnrelatedADR: an ADR record beside an -// unrelated unparseable ADR applies, leaves that ADR's bytes untouched, and -// publishes an index whose repair notice names it — the index used to drop it -// silently (change 0449). -func TestADRRecordIndexSurfacesUnparseableUnrelatedADR(t *testing.T) { - requireRealGit(t) - const brokenADR, brokenADRBytes = "docs/adrs/0009-broken.md", "---\nid: 9\nslug: broken\n" - repo := newWorkingRepo(t, map[string]string{ - adrPath("0001", "one"): fixtureADR(1, "one"), - brokenADR: brokenADRBytes, - }) - node := planningDepsFor(t, repo.invocation) - res := ADRRecordOp(context.Background(), node.deps, node.dir, validADRRecordRequest()) - if res.Result != ResultApplied { - t.Fatalf("adr record beside an unrelated unparseable ADR = %q (findings %v), want applied", res.Result, res.Findings) - } - if got, ok := originFile(t, repo.origin, "docket", brokenADR); !ok || got != brokenADRBytes { - t.Errorf("unrelated unparseable ADR on origin = %q (present %v), want its exact seeded bytes", got, ok) - } - index, ok := originFile(t, repo.origin, "docket", adrsIndexPath) - if !ok || !strings.Contains(index, "| `"+brokenADR+"` | unclosed-frontmatter |") { - t.Fatalf("published ADR index lacks the repair notice naming %s:\n%s", brokenADR, index) - } -} - -func TestADRUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - producerPath := groomPath(3, adrProducerSlug) - cases := unrelatedRefusalCases(t, 3, producerPath, - lifecycleChange(3, adrProducerSlug, "in-progress"), lifecycleChange(3, "dupe", "in-progress")) - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - req := validADRRecordRequest() - req.Change = &ADRProducingChange{ID: 3, Path: producerPath, Version: blobVersionAt(t, repo.origin, "docket", producerPath)} - res := ADRRecordOp(context.Background(), node.deps, node.dir, req) - if res.Result == ResultApplied { - t.Fatalf("adr record applied despite %s on its producing change; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, "", res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused adr record moved the metadata branch %s -> %s", tip, got) - } - }) - } -} diff --git a/internal/app/agent_guardian_test.go b/internal/app/agent_guardian_integration_test.go similarity index 90% rename from internal/app/agent_guardian_test.go rename to internal/app/agent_guardian_integration_test.go index 6f30d4cb5..f0538a17f 100644 --- a/internal/app/agent_guardian_test.go +++ b/internal/app/agent_guardian_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -48,10 +50,10 @@ func spawnTestGuardian(t *testing.T) (*GuardianHandle, EpochRecord, string, stri return handle, ep, key, repo } -// TestGuardianEOFFencesEpoch proves an abrupt owner death — the pipe closes with no +// TestIntegrationGateLifecycleGuardianEOFFencesEpoch proves an abrupt owner death — the pipe closes with no // completion marker — makes the guardian fence the run epoch to cancelling, the // durable exclusion that admits no replacement. -func TestGuardianEOFFencesEpoch(t *testing.T) { +func TestIntegrationGateLifecycleGuardianEOFFencesEpoch(t *testing.T) { handle, _, key, repo := spawnTestGuardian(t) // Simulate the owner dying: drop the pipe write end WITHOUT writing the marker. @@ -72,11 +74,11 @@ func TestGuardianEOFFencesEpoch(t *testing.T) { } } -// TestGuardianCompletionMarkerPreventsCancel proves a clean end — the owner writes +// TestIntegrationGateLifecycleGuardianCompletionMarkerPreventsCancel proves a clean end — the owner writes // the durable marker before closing the pipe — makes the guardian exit WITHOUT // fencing, so a normal return or a handoff is never mistaken for a Stop. This is the // mutation-evidence target: suppress the marker write in Complete and this reddens. -func TestGuardianCompletionMarkerPreventsCancel(t *testing.T) { +func TestIntegrationGateLifecycleGuardianCompletionMarkerPreventsCancel(t *testing.T) { handle, _, key, repo := spawnTestGuardian(t) // Complete writes the marker, closes the pipe, and reaps the guardian. @@ -91,7 +93,7 @@ func TestGuardianCompletionMarkerPreventsCancel(t *testing.T) { } } -// TestGuardianStaleMarkerDoesNotSuppressFence proves SpawnAgentGuardian clears any +// TestIntegrationGateLifecycleGuardianStaleMarkerDoesNotSuppressFence proves SpawnAgentGuardian clears any // pre-existing completion marker before it starts the guardian, so only a marker // this owner writes during THIS lifetime can suppress the fence. A stale marker // left in a reused gate-key directory (from a prior clean completion) must NOT @@ -99,7 +101,7 @@ func TestGuardianCompletionMarkerPreventsCancel(t *testing.T) { // marker and fences the epoch — the exact abrupt-death case the guardian exists to // catch. Defense in depth: gate keys are unique today, but the guardian must not // depend on that for its safety property. -func TestGuardianStaleMarkerDoesNotSuppressFence(t *testing.T) { +func TestIntegrationGateLifecycleGuardianStaleMarkerDoesNotSuppressFence(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) ep, err := MintEpochRecord(repo, key, "375") @@ -139,12 +141,12 @@ func TestGuardianStaleMarkerDoesNotSuppressFence(t *testing.T) { } } -// TestGuardianCannotMutate proves the guardian holds cancel/observe authority only: +// TestIntegrationGateLifecycleGuardianCannotMutate proves the guardian holds cancel/observe authority only: // a registered guardian participant confers nothing (a workflow mutation is still // admitted while the epoch is active), and the ONLY effect a guardian can have on // mutation admission is to FENCE the epoch on death — after which no workflow // mutation is admitted. The guardian can block, never enable. -func TestGuardianCannotMutate(t *testing.T) { +func TestIntegrationGateLifecycleGuardianCannotMutate(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) ep, err := MintEpochRecord(repo, key, "375") @@ -202,7 +204,7 @@ func TestGuardianCannotMutate(t *testing.T) { } } -// TestGuardianLeavesCompletingEpochForReplay pins the death guardian's completing +// TestIntegrationGateLifecycleGuardianLeavesCompletingEpochForReplay pins the death guardian's completing // behavior (change 0441): the guardian fences ONLY an active epoch (guardianFenceAndReap // flips active→cancelling and reaps only when the fence lands), so an abrupt owner // death over a COMPLETING epoch — a keyed verdict verified run-complete and durably @@ -210,7 +212,7 @@ func TestGuardianCannotMutate(t *testing.T) { // unreaped, so a keyed-verdict replay resumes the closeout. Success is never encoded // as cancellation. This is the mutation-evidence target: make the guardian CAS also // flip completing→cancelling and this reddens. -func TestGuardianLeavesCompletingEpochForReplay(t *testing.T) { +func TestIntegrationGateLifecycleGuardianLeavesCompletingEpochForReplay(t *testing.T) { handle, _, key, repo := spawnTestGuardian(t) // The keyed verdict fenced the epoch to completing (successful closeout in diff --git a/internal/app/change_attach_git_helpers_test.go b/internal/app/change_attach_git_helpers_test.go new file mode 100644 index 000000000..09786d7a0 --- /dev/null +++ b/internal/app/change_attach_git_helpers_test.go @@ -0,0 +1,29 @@ +package app + +// Change 0465: helpers from change_attach_git_integration_test.go that default-build +// tests (change_implemented_test.go, claim_workflow_git_test.go) still use, kept +// untagged so every build compiles them. + +import "fmt" + +// attachBacklinkBlock renders the docket:backlink block the operation expects at +// the head of an artifact, targeting change id/title at recPath. It mirrors +// render.BacklinkContent's repo-relative shape exactly (no RepoWebURL is +// configured in these fixtures), so a happy plan round-trips through verification. +func attachBacklinkBlock(id int, title, recPath string) string { + return "\n" + + fmt.Sprintf("> ↩ **Change %04d — %s** — `%s`\n", id, title, recPath) + + "\n" +} + +// attachHappyPlan renders a well-formed plan artifact: the correct backlink plus +// an authored body whose sections merely MENTION planning tokens (change 0414 +// acceptance — a plan that instructs about a token, and the human-approved +// ambiguous decision sentence, both attach; only a whole-slot bare-token filler +// refuses). Every slot here holds substantive content. +func attachHappyPlan(id int, title, recPath string) string { + return attachBacklinkBlock(id, title, recPath) + + "\n# Implementation Plan\n\n## Task 1\n\nRemove the " + tok("todo") + + " in retry.go and replace it with bounded retry logic.\n\n" + + "## Error handling\n" + tok("todo") + ": decide whether failed requests should retry or stop.\n" +} diff --git a/internal/app/change_attach_git_test.go b/internal/app/change_attach_git_integration_test.go similarity index 82% rename from internal/app/change_attach_git_test.go rename to internal/app/change_attach_git_integration_test.go index 9076b64f3..29b1cf38a 100644 --- a/internal/app/change_attach_git_test.go +++ b/internal/app/change_attach_git_integration_test.go @@ -1,8 +1,9 @@ +//go:build integration + package app import ( "context" - "fmt" "github.com/danielhanold/docket/internal/workspace" "os" "path/filepath" @@ -33,28 +34,6 @@ func symlinkRepoFile(t *testing.T, root, rel, target string) { // reason string — proof the guard reddens for the reason it names, not merely // that something failed (learning assert-pins-outcome-not-mechanism). -// attachBacklinkBlock renders the docket:backlink block the operation expects at -// the head of an artifact, targeting change id/title at recPath. It mirrors -// render.BacklinkContent's repo-relative shape exactly (no RepoWebURL is -// configured in these fixtures), so a happy plan round-trips through verification. -func attachBacklinkBlock(id int, title, recPath string) string { - return "\n" + - fmt.Sprintf("> ↩ **Change %04d — %s** — `%s`\n", id, title, recPath) + - "\n" -} - -// attachHappyPlan renders a well-formed plan artifact: the correct backlink plus -// an authored body whose sections merely MENTION planning tokens (change 0414 -// acceptance — a plan that instructs about a token, and the human-approved -// ambiguous decision sentence, both attach; only a whole-slot bare-token filler -// refuses). Every slot here holds substantive content. -func attachHappyPlan(id int, title, recPath string) string { - return attachBacklinkBlock(id, title, recPath) + - "\n# Implementation Plan\n\n## Task 1\n\nRemove the " + tok("todo") + - " in retry.go and replace it with bounded retry logic.\n\n" + - "## Error handling\n" + tok("todo") + ": decide whether failed requests should retry or stop.\n" -} - // attachSetup builds a main-mode repo with one in-progress change, prepares its // feature workspace against the resolved base, and returns everything a // verification row needs. The workspace sits on the feature ref at the base tip; @@ -159,7 +138,7 @@ func advanceDocketOrigin(t *testing.T, repo *gitRepo, files map[string]string) { runGit(t, repo.writer, "push", "-q", "origin", "docket") } -func TestChangeAttachUnrelatedInvalidRecordProgress(t *testing.T) { +func TestIntegrationRecordOpsChangeAttachUnrelatedInvalidRecordProgress(t *testing.T) { f := attachSetupWith(t, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) head := f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) @@ -177,7 +156,7 @@ func TestChangeAttachUnrelatedInvalidRecordProgress(t *testing.T) { assertUnrelatedBrokenIntact(t, f.repo) } -func TestChangeAttachUnrelatedInvalidRecordRefusals(t *testing.T) { +func TestIntegrationRecordOpsChangeAttachUnrelatedInvalidRecordRefusals(t *testing.T) { requireRealGit(t) src := lifecycleChange(3, "widget", "in-progress") cases := unrelatedRefusalCases(t, 3, groomPath(3, "widget"), src, lifecycleChange(3, "dupe", "in-progress")) diff --git a/internal/app/change_claim_integration_test.go b/internal/app/change_claim_integration_test.go new file mode 100644 index 000000000..99c457533 --- /dev/null +++ b/internal/app/change_claim_integration_test.go @@ -0,0 +1,375 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "github.com/danielhanold/docket/internal/repository/transaction" + "path" + "strings" + "testing" + "time" +) + +// TestIntegrationRecordOpsClaimGateContextInvalidRefusesBeforeTransaction: a supplied context that +// matches no armed gate record is a typed refusal that writes nothing and never +// degrades to an ungated claim (spec: "Never treat a supplied but invalid +// context as an ungated claim"). +func TestIntegrationRecordOpsClaimGateContextInvalidRefusesBeforeTransaction(t *testing.T) { + repoDir := newGateRepo(t) // no gate record armed + engine := &claimGateEngine{} + deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) + + res := ChangeClaim(context.Background(), deps, repoDir, + ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) + + if res.Result != ResultInvalidState { + t.Fatalf("result = %q, want invalid-state (findings %v)", res.Result, res.Findings) + } + if res.Disposition != ClaimDispositionGateContextInvalid { + t.Errorf("disposition = %q, want %q", res.Disposition, ClaimDispositionGateContextInvalid) + } + if len(engine.calls) != 0 { + t.Errorf("engine called %d times on an invalid gate context, want 0", len(engine.calls)) + } + for _, f := range res.Findings { + if strings.Contains(f.Message, "tok") { + t.Errorf("finding leaked the raw dispatch token: %q", f.Message) + } + } +} + +// TestIntegrationRecordOpsClaimGateContextReservesAndConfirms: a valid context reserves before the +// transaction and confirms after the applied outcome; the digest payload and +// receipt carry the context hash, never the raw token. +func TestIntegrationRecordOpsClaimGateContextReservesAndConfirms(t *testing.T) { + repoDir := newGateRepo(t) + hash := gateHashToken("tok") + key := mintGateWithHash(t, repoDir, hash, false) + + var midOK, midConfirmed bool + var midChangeID int + engine := &claimGateEngine{ + result: appliedGateResult(t, 3), + onExecute: func(_ transaction.Request) { + // The reservation must exist, unconfirmed, at Execute time. + b, ok, err := LoadGateClaimBinding(repoDir, key) + if err != nil { + t.Errorf("mid-transaction LoadGateClaimBinding: %v", err) + return + } + midOK, midConfirmed, midChangeID = ok, b.Confirmed, b.ChangeID + }, + } + deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) + + res := ChangeClaim(context.Background(), deps, repoDir, + ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied (findings %v)", res.Result, res.Findings) + } + + if !midOK || midConfirmed || midChangeID != 3 { + t.Errorf("mid-transaction binding ok=%v confirmed=%v change=%d; want reserved-unconfirmed for change 3", + midOK, midConfirmed, midChangeID) + } + + b, ok, err := LoadGateClaimBinding(repoDir, key) + if err != nil || !ok || !b.Confirmed || b.ChangeID != 3 || b.Revision != gateClaimCommit { + t.Fatalf("post-claim binding = %+v ok=%v err=%v; want confirmed change 3 @ %s", b, ok, err, gateClaimCommit) + } + + if len(engine.calls) != 1 { + t.Fatalf("engine calls = %d, want 1", len(engine.calls)) + } + gotDigest := engine.calls[0].Idempotency.Digest + withHash, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: hash}) + if err != nil { + t.Fatalf("canonicalDigest (hash): %v", err) + } + ungated, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: ""}) + if err != nil { + t.Fatalf("canonicalDigest (ungated): %v", err) + } + if gotDigest != withHash { + t.Errorf("digest = %q, want the hash-bearing digest %q", gotDigest, withHash) + } + if gotDigest == ungated { + t.Errorf("digest equals the ungated digest %q; the context hash was not folded in", ungated) + } + + op, okOp := engine.calls[0].Operation.(changeClaimOp) + if !okOp { + t.Fatalf("operation is %T, want changeClaimOp", engine.calls[0].Operation) + } + if op.gateContextHash != hash { + t.Errorf("op.gateContextHash = %q, want %q", op.gateContextHash, hash) + } + // The receipt bytes the operation hands the engine carry the hash, never the token. + plan, opRes := claimPlanFor(t, map[string]string{groomPath(3, "widget"): claimableChange(3, "widget")}, op) + if opRes.Refused { + t.Fatalf("unexpected refusal building the receipt: %v", opRes.Findings) + } + if !strings.Contains(string(plan.Receipt), `"gate_context_hash":"`+hash+`"`) { + t.Errorf("receipt missing gate_context_hash %q:\n%s", hash, plan.Receipt) + } + if strings.Contains(string(plan.Receipt), "tok") { + t.Errorf("receipt leaked the raw dispatch token:\n%s", plan.Receipt) + } +} + +// TestIntegrationRecordOpsClaimGateContextConflictRefused: a second claim for a DIFFERENT change id +// under the same context is refused gate-context-conflict before its +// transaction (criterion 3: one context cannot claim two changes). +func TestIntegrationRecordOpsClaimGateContextConflictRefused(t *testing.T) { + repoDir := newGateRepo(t) + mintGateWithHash(t, repoDir, gateHashToken("tok"), false) + corpus := []StatusBlob{ + changeBlob(3, "widget", "feat", "high", ""), + changeBlob(4, "gadget", "feat", "high", ""), + } + + engine1 := &claimGateEngine{result: appliedGateResult(t, 3)} + first := ChangeClaim(context.Background(), gateClaimDeps(t, engine1, corpus), repoDir, + ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) + if first.Result != ResultApplied { + t.Fatalf("first claim result = %q, want applied (%v)", first.Result, first.Findings) + } + + engine2 := &claimGateEngine{result: appliedGateResult(t, 4)} + second := ChangeClaim(context.Background(), gateClaimDeps(t, engine2, corpus), repoDir, + ChangeClaimRequest{ID: 4, Version: gateClaimVersion, GateContext: "tok"}) + + if second.Result != ResultInvalidState { + t.Errorf("second result = %q, want invalid-state", second.Result) + } + if second.Disposition != ClaimDispositionGateContextConflict { + t.Errorf("second disposition = %q, want %q", second.Disposition, ClaimDispositionGateContextConflict) + } + if len(engine2.calls) != 0 { + t.Errorf("second claim reached the engine %d times, want 0", len(engine2.calls)) + } +} + +// TestIntegrationRecordOpsClaimUngatedUnchanged: no GateContext → no gate lookup, no reservation, +// digest equals the empty-hash payload, receipt carries gate_context_hash "". +func TestIntegrationRecordOpsClaimUngatedUnchanged(t *testing.T) { + repoDir := newGateRepo(t) + engine := &claimGateEngine{result: appliedGateResult(t, 3)} + deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) + + res := ChangeClaim(context.Background(), deps, repoDir, + ChangeClaimRequest{ID: 3, Version: gateClaimVersion}) // no GateContext + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied (%v)", res.Result, res.Findings) + } + if len(engine.calls) != 1 { + t.Fatalf("engine calls = %d, want 1", len(engine.calls)) + } + want, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: ""}) + if err != nil { + t.Fatalf("canonicalDigest: %v", err) + } + if engine.calls[0].Idempotency.Digest != want { + t.Errorf("ungated digest = %q, want %q", engine.calls[0].Idempotency.Digest, want) + } + op := engine.calls[0].Operation.(changeClaimOp) + if op.gateContextHash != "" { + t.Errorf("ungated op.gateContextHash = %q, want empty", op.gateContextHash) + } + plan, opRes := claimPlanFor(t, map[string]string{groomPath(3, "widget"): claimableChange(3, "widget")}, op) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + if !strings.Contains(string(plan.Receipt), `"gate_context_hash":""`) { + t.Errorf("ungated receipt missing empty gate_context_hash:\n%s", plan.Receipt) + } +} + +// TestIntegrationRecordOpsClaimTerminalGateRefused: a context whose only record is Terminal is +// gate-context-invalid (the dispatch it named is already decided). +func TestIntegrationRecordOpsClaimTerminalGateRefused(t *testing.T) { + repoDir := newGateRepo(t) + mintGateWithHash(t, repoDir, gateHashToken("tok"), true) // terminal + engine := &claimGateEngine{} + deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) + + res := ChangeClaim(context.Background(), deps, repoDir, + ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) + if res.Disposition != ClaimDispositionGateContextInvalid { + t.Errorf("disposition = %q, want %q", res.Disposition, ClaimDispositionGateContextInvalid) + } + if len(engine.calls) != 0 { + t.Errorf("engine called on a terminal gate context, want 0") + } +} + +func TestIntegrationRecordOpsChangeClaimUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + repo := newWorkingRepo(t, map[string]string{ + recPath: claimableChange(id, "widget"), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + node := planningDepsFor(t, repo.invocation) + later := planningDepsForClock(t, repo.invocation, fixedClock{t: time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)}) + ctx := context.Background() + + claim := ChangeClaim(ctx, node.deps, node.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if claim.Result != ResultApplied { + t.Fatalf("claim beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", + claim.Result, claim.Disposition, claim.Findings) + } + assertAppliedSurfacesUnrelated(t, claim) + rec, _ := originFile(t, repo.origin, "docket", recPath) + if !strings.Contains(rec, "status: 'in-progress'") { + t.Errorf("claimed record on origin is not in-progress:\n%s", rec) + } + assertUnrelatedBrokenIntact(t, repo) + // The board landed in the same applied commit (change 0449 Task 8): B's + // row in its new section AND the repair notice naming the unparseable A, + // which the snapshot cannot see and the board used to drop silently. + board, ok := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md") + if !ok { + t.Fatal("claim beside an unrelated unparseable record published no board") + } + if !strings.Contains(board, "## 🟢 In progress (1)") || !strings.Contains(board, "(active/"+path.Base(recPath)+")") { + t.Errorf("board lacks B's in-progress row:\n%s", board) + } + if !strings.Contains(board, "| `"+unrelatedBrokenPath+"` | unclosed-frontmatter |") { + t.Errorf("board lacks the repair notice naming the unrelated record:\n%s", board) + } + + refresh := ChangeRefreshClaim(ctx, later.deps, later.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if refresh.Result != ResultApplied { + t.Fatalf("refresh-claim beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", + refresh.Result, refresh.Disposition, refresh.Findings) + } + assertAppliedSurfacesUnrelated(t, refresh) + assertUnrelatedBrokenIntact(t, repo) +} + +// TestIntegrationRecordOpsChangeClaimUnrelatedDependentsOfBrokenProgress is the canonical real-world +// shape of the 0449 bug one step removed: the unparseable A (id 99) has +// unrelated dependents — C depends on 99 and E is stacked on 99 — so the corpus +// also carries error-severity dangling references whose target id no parsed +// record carries. Those ids name no record, so they cannot name B's subjects; +// B's claim applies and C and E are left byte-identical. The refusal rows keep +// the converse: B itself depending on the unparseable A still refuses. +// +// Mutation check (run manually; noted in the commit): make +// transaction.resolveRef treat an absent lookup as unresolvable again and this +// test reddens with the claim refused on C's and E's dangling references. +func TestIntegrationRecordOpsChangeClaimUnrelatedDependentsOfBrokenProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + consumerPath, stackedPath := groomPath(4, "consumer"), groomPath(5, "stacked") + consumer := strings.Replace(claimableChange(4, "consumer"), "depends_on: []\n", "depends_on: [99]\n", 1) + stacked := stackedOn(claimableChange(5, "stacked"), 99) + if !strings.Contains(consumer, "depends_on: [99]") || !strings.Contains(stacked, "stacked_on: 99") { + t.Fatal("dependent fixtures did not rewrite their records; the fixture shape changed") + } + repo := newWorkingRepo(t, map[string]string{ + recPath: claimableChange(id, "widget"), + consumerPath: consumer, + stackedPath: stacked, + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + node := planningDepsFor(t, repo.invocation) + + claim := ChangeClaim(context.Background(), node.deps, node.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if claim.Result != ResultApplied { + t.Fatalf("claim beside unrelated dependents of an unparseable record = %q (disposition %q findings %v), want applied", + claim.Result, claim.Disposition, claim.Findings) + } + if rec, _ := originFile(t, repo.origin, "docket", recPath); !strings.Contains(rec, "status: 'in-progress'") { + t.Errorf("claimed record on origin is not in-progress:\n%s", rec) + } + for p, want := range map[string]string{consumerPath: consumer, stackedPath: stacked} { + if got, ok := originFile(t, repo.origin, "docket", p); !ok || got != want { + t.Errorf("unrelated dependent %s on origin changed (present %v):\n%s", p, ok, got) + } + } + assertUnrelatedBrokenIntact(t, repo) +} + +// TestIntegrationRecordOpsChangeClaimUnrelatedShapesProgress drives B's claim through the +// production loader and engine beside each unrelated-damage shape: B applies, +// the damaged records are byte-identical, and the finding is still reported. +func TestIntegrationRecordOpsChangeClaimUnrelatedShapesProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + for _, shape := range unrelatedProgressShapes(t) { + t.Run(shape.name, func(t *testing.T) { + files := map[string]string{recPath: claimableChange(id, "widget")} + for p, b := range shape.files { + files[p] = b + } + repo := newWorkingRepo(t, files) + node := planningDepsFor(t, repo.invocation) + res := ChangeClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if res.Result != ResultApplied { + t.Fatalf("claim beside %s = %q (disposition %q findings %v), want applied", shape.name, res.Result, res.Disposition, res.Findings) + } + if rec, _ := originFile(t, repo.origin, "docket", recPath); !strings.Contains(rec, "status: 'in-progress'") { + t.Errorf("claimed record on origin is not in-progress:\n%s", rec) + } + assertUnrelatedShapeIntact(t, repo, "docket", shape) + }) + } +} + +func TestIntegrationRecordOpsChangeClaimUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + for _, c := range unrelatedRefusalCases(t, id, recPath, claimableChange(id, "widget"), claimableChange(id, "dupe")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + res := ChangeClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if res.Result == ResultApplied { + t.Fatalf("claim applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused claim moved the metadata branch %s -> %s", tip, got) + } + }) + } +} + +func TestIntegrationRecordOpsChangeRefreshClaimUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + src := lifecycleChange(id, "widget", "in-progress") + for _, c := range unrelatedRefusalCases(t, id, recPath, src, lifecycleChange(id, "dupe", "in-progress")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + res := ChangeRefreshClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) + if res.Result == ResultApplied { + t.Fatalf("refresh-claim applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused refresh-claim moved the metadata branch %s -> %s", tip, got) + } + }) + } +} diff --git a/internal/app/change_claim_test.go b/internal/app/change_claim_test.go index c7be66126..1d52e3982 100644 --- a/internal/app/change_claim_test.go +++ b/internal/app/change_claim_test.go @@ -7,7 +7,6 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository/transaction" - "path" "slices" "strings" "testing" @@ -229,146 +228,6 @@ func gateClaimDeps(t *testing.T, engine *claimGateEngine, corpus []StatusBlob) P } } -// TestClaimGateContextInvalidRefusesBeforeTransaction: a supplied context that -// matches no armed gate record is a typed refusal that writes nothing and never -// degrades to an ungated claim (spec: "Never treat a supplied but invalid -// context as an ungated claim"). -func TestClaimGateContextInvalidRefusesBeforeTransaction(t *testing.T) { - repoDir := newGateRepo(t) // no gate record armed - engine := &claimGateEngine{} - deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) - - res := ChangeClaim(context.Background(), deps, repoDir, - ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) - - if res.Result != ResultInvalidState { - t.Fatalf("result = %q, want invalid-state (findings %v)", res.Result, res.Findings) - } - if res.Disposition != ClaimDispositionGateContextInvalid { - t.Errorf("disposition = %q, want %q", res.Disposition, ClaimDispositionGateContextInvalid) - } - if len(engine.calls) != 0 { - t.Errorf("engine called %d times on an invalid gate context, want 0", len(engine.calls)) - } - for _, f := range res.Findings { - if strings.Contains(f.Message, "tok") { - t.Errorf("finding leaked the raw dispatch token: %q", f.Message) - } - } -} - -// TestClaimGateContextReservesAndConfirms: a valid context reserves before the -// transaction and confirms after the applied outcome; the digest payload and -// receipt carry the context hash, never the raw token. -func TestClaimGateContextReservesAndConfirms(t *testing.T) { - repoDir := newGateRepo(t) - hash := gateHashToken("tok") - key := mintGateWithHash(t, repoDir, hash, false) - - var midOK, midConfirmed bool - var midChangeID int - engine := &claimGateEngine{ - result: appliedGateResult(t, 3), - onExecute: func(_ transaction.Request) { - // The reservation must exist, unconfirmed, at Execute time. - b, ok, err := LoadGateClaimBinding(repoDir, key) - if err != nil { - t.Errorf("mid-transaction LoadGateClaimBinding: %v", err) - return - } - midOK, midConfirmed, midChangeID = ok, b.Confirmed, b.ChangeID - }, - } - deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) - - res := ChangeClaim(context.Background(), deps, repoDir, - ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) - if res.Result != ResultApplied { - t.Fatalf("result = %q, want applied (findings %v)", res.Result, res.Findings) - } - - if !midOK || midConfirmed || midChangeID != 3 { - t.Errorf("mid-transaction binding ok=%v confirmed=%v change=%d; want reserved-unconfirmed for change 3", - midOK, midConfirmed, midChangeID) - } - - b, ok, err := LoadGateClaimBinding(repoDir, key) - if err != nil || !ok || !b.Confirmed || b.ChangeID != 3 || b.Revision != gateClaimCommit { - t.Fatalf("post-claim binding = %+v ok=%v err=%v; want confirmed change 3 @ %s", b, ok, err, gateClaimCommit) - } - - if len(engine.calls) != 1 { - t.Fatalf("engine calls = %d, want 1", len(engine.calls)) - } - gotDigest := engine.calls[0].Idempotency.Digest - withHash, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: hash}) - if err != nil { - t.Fatalf("canonicalDigest (hash): %v", err) - } - ungated, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: ""}) - if err != nil { - t.Fatalf("canonicalDigest (ungated): %v", err) - } - if gotDigest != withHash { - t.Errorf("digest = %q, want the hash-bearing digest %q", gotDigest, withHash) - } - if gotDigest == ungated { - t.Errorf("digest equals the ungated digest %q; the context hash was not folded in", ungated) - } - - op, okOp := engine.calls[0].Operation.(changeClaimOp) - if !okOp { - t.Fatalf("operation is %T, want changeClaimOp", engine.calls[0].Operation) - } - if op.gateContextHash != hash { - t.Errorf("op.gateContextHash = %q, want %q", op.gateContextHash, hash) - } - // The receipt bytes the operation hands the engine carry the hash, never the token. - plan, opRes := claimPlanFor(t, map[string]string{groomPath(3, "widget"): claimableChange(3, "widget")}, op) - if opRes.Refused { - t.Fatalf("unexpected refusal building the receipt: %v", opRes.Findings) - } - if !strings.Contains(string(plan.Receipt), `"gate_context_hash":"`+hash+`"`) { - t.Errorf("receipt missing gate_context_hash %q:\n%s", hash, plan.Receipt) - } - if strings.Contains(string(plan.Receipt), "tok") { - t.Errorf("receipt leaked the raw dispatch token:\n%s", plan.Receipt) - } -} - -// TestClaimGateContextConflictRefused: a second claim for a DIFFERENT change id -// under the same context is refused gate-context-conflict before its -// transaction (criterion 3: one context cannot claim two changes). -func TestClaimGateContextConflictRefused(t *testing.T) { - repoDir := newGateRepo(t) - mintGateWithHash(t, repoDir, gateHashToken("tok"), false) - corpus := []StatusBlob{ - changeBlob(3, "widget", "feat", "high", ""), - changeBlob(4, "gadget", "feat", "high", ""), - } - - engine1 := &claimGateEngine{result: appliedGateResult(t, 3)} - first := ChangeClaim(context.Background(), gateClaimDeps(t, engine1, corpus), repoDir, - ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) - if first.Result != ResultApplied { - t.Fatalf("first claim result = %q, want applied (%v)", first.Result, first.Findings) - } - - engine2 := &claimGateEngine{result: appliedGateResult(t, 4)} - second := ChangeClaim(context.Background(), gateClaimDeps(t, engine2, corpus), repoDir, - ChangeClaimRequest{ID: 4, Version: gateClaimVersion, GateContext: "tok"}) - - if second.Result != ResultInvalidState { - t.Errorf("second result = %q, want invalid-state", second.Result) - } - if second.Disposition != ClaimDispositionGateContextConflict { - t.Errorf("second disposition = %q, want %q", second.Disposition, ClaimDispositionGateContextConflict) - } - if len(engine2.calls) != 0 { - t.Errorf("second claim reached the engine %d times, want 0", len(engine2.calls)) - } -} - // TestClaimSameIDDifferentContextDigestDiffers: two dispatches submitting the // SAME (id, version) under different contexts must not share the idempotency // path — their digests differ while their request ids match, so the engine's @@ -395,59 +254,6 @@ func TestClaimSameIDDifferentContextDigestDiffers(t *testing.T) { } } -// TestClaimUngatedUnchanged: no GateContext → no gate lookup, no reservation, -// digest equals the empty-hash payload, receipt carries gate_context_hash "". -func TestClaimUngatedUnchanged(t *testing.T) { - repoDir := newGateRepo(t) - engine := &claimGateEngine{result: appliedGateResult(t, 3)} - deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) - - res := ChangeClaim(context.Background(), deps, repoDir, - ChangeClaimRequest{ID: 3, Version: gateClaimVersion}) // no GateContext - if res.Result != ResultApplied { - t.Fatalf("result = %q, want applied (%v)", res.Result, res.Findings) - } - if len(engine.calls) != 1 { - t.Fatalf("engine calls = %d, want 1", len(engine.calls)) - } - want, err := canonicalDigest(OperationChangeClaim, claimDigestPayload{ID: 3, Version: gateClaimVersion, GateContextHash: ""}) - if err != nil { - t.Fatalf("canonicalDigest: %v", err) - } - if engine.calls[0].Idempotency.Digest != want { - t.Errorf("ungated digest = %q, want %q", engine.calls[0].Idempotency.Digest, want) - } - op := engine.calls[0].Operation.(changeClaimOp) - if op.gateContextHash != "" { - t.Errorf("ungated op.gateContextHash = %q, want empty", op.gateContextHash) - } - plan, opRes := claimPlanFor(t, map[string]string{groomPath(3, "widget"): claimableChange(3, "widget")}, op) - if opRes.Refused { - t.Fatalf("unexpected refusal: %v", opRes.Findings) - } - if !strings.Contains(string(plan.Receipt), `"gate_context_hash":""`) { - t.Errorf("ungated receipt missing empty gate_context_hash:\n%s", plan.Receipt) - } -} - -// TestClaimTerminalGateRefused: a context whose only record is Terminal is -// gate-context-invalid (the dispatch it named is already decided). -func TestClaimTerminalGateRefused(t *testing.T) { - repoDir := newGateRepo(t) - mintGateWithHash(t, repoDir, gateHashToken("tok"), true) // terminal - engine := &claimGateEngine{} - deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) - - res := ChangeClaim(context.Background(), deps, repoDir, - ChangeClaimRequest{ID: 3, Version: gateClaimVersion, GateContext: "tok"}) - if res.Disposition != ClaimDispositionGateContextInvalid { - t.Errorf("disposition = %q, want %q", res.Disposition, ClaimDispositionGateContextInvalid) - } - if len(engine.calls) != 0 { - t.Errorf("engine called on a terminal gate context, want 0") - } -} - // claimEarlyErrOp is a minimal valid-keyed semantic operation for driving the // real engine's call-shape validation; Execute fails on the malformed // expectation before Plan can ever run. @@ -528,8 +334,8 @@ func TestClaimResultRealEngineMalformedVersion(t *testing.T) { // // Mutation check (run manually; noted in the commit): delete the `Scope:` field // from ChangeClaim's transaction.Request and -// `go test ./internal/app/ -run 'TestChangeClaim.*Unrelated' -count=1` reddens on -// the progress row with the before-gate refusal the bug produced. +// `go test -tags integration ./internal/app/ -run 'TestIntegrationRecordOpsChangeClaim.*Unrelated' -count=1` +// reddens on the progress row with the before-gate refusal the bug produced. // unrelatedBrokenPath is the unrelated change record A every 0449 row seeds; its // bytes open a frontmatter block and never close it, so document.Parse rejects @@ -605,52 +411,6 @@ func assertRefusalBeyondUnrelated(t *testing.T, reason string, findings []Status t.Errorf("refusal carries only the unrelated record's findings %+v; want a finding naming B's own defect", findings) } -func TestChangeClaimUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - repo := newWorkingRepo(t, map[string]string{ - recPath: claimableChange(id, "widget"), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - node := planningDepsFor(t, repo.invocation) - later := planningDepsForClock(t, repo.invocation, fixedClock{t: time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)}) - ctx := context.Background() - - claim := ChangeClaim(ctx, node.deps, node.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if claim.Result != ResultApplied { - t.Fatalf("claim beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", - claim.Result, claim.Disposition, claim.Findings) - } - assertAppliedSurfacesUnrelated(t, claim) - rec, _ := originFile(t, repo.origin, "docket", recPath) - if !strings.Contains(rec, "status: 'in-progress'") { - t.Errorf("claimed record on origin is not in-progress:\n%s", rec) - } - assertUnrelatedBrokenIntact(t, repo) - // The board landed in the same applied commit (change 0449 Task 8): B's - // row in its new section AND the repair notice naming the unparseable A, - // which the snapshot cannot see and the board used to drop silently. - board, ok := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md") - if !ok { - t.Fatal("claim beside an unrelated unparseable record published no board") - } - if !strings.Contains(board, "## 🟢 In progress (1)") || !strings.Contains(board, "(active/"+path.Base(recPath)+")") { - t.Errorf("board lacks B's in-progress row:\n%s", board) - } - if !strings.Contains(board, "| `"+unrelatedBrokenPath+"` | unclosed-frontmatter |") { - t.Errorf("board lacks the repair notice naming the unrelated record:\n%s", board) - } - - refresh := ChangeRefreshClaim(ctx, later.deps, later.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if refresh.Result != ResultApplied { - t.Fatalf("refresh-claim beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", - refresh.Result, refresh.Disposition, refresh.Findings) - } - assertAppliedSurfacesUnrelated(t, refresh) - assertUnrelatedBrokenIntact(t, repo) -} - // assertAppliedSurfacesUnrelated proves an applied claim beside the unrelated // broken record keeps its applied disposition AND lists A's grandfathered // error finding (spec §1 step 5): the scoped gate accepted a corpus error, so @@ -674,51 +434,6 @@ func assertAppliedSurfacesUnrelated(t *testing.T, r ChangeClaimResult) { t.Errorf("applied claim findings %+v omit the unrelated record's error finding on %s", r.Findings, unrelatedBrokenPath) } -// TestChangeClaimUnrelatedDependentsOfBrokenProgress is the canonical real-world -// shape of the 0449 bug one step removed: the unparseable A (id 99) has -// unrelated dependents — C depends on 99 and E is stacked on 99 — so the corpus -// also carries error-severity dangling references whose target id no parsed -// record carries. Those ids name no record, so they cannot name B's subjects; -// B's claim applies and C and E are left byte-identical. The refusal rows keep -// the converse: B itself depending on the unparseable A still refuses. -// -// Mutation check (run manually; noted in the commit): make -// transaction.resolveRef treat an absent lookup as unresolvable again and this -// test reddens with the claim refused on C's and E's dangling references. -func TestChangeClaimUnrelatedDependentsOfBrokenProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - consumerPath, stackedPath := groomPath(4, "consumer"), groomPath(5, "stacked") - consumer := strings.Replace(claimableChange(4, "consumer"), "depends_on: []\n", "depends_on: [99]\n", 1) - stacked := stackedOn(claimableChange(5, "stacked"), 99) - if !strings.Contains(consumer, "depends_on: [99]") || !strings.Contains(stacked, "stacked_on: 99") { - t.Fatal("dependent fixtures did not rewrite their records; the fixture shape changed") - } - repo := newWorkingRepo(t, map[string]string{ - recPath: claimableChange(id, "widget"), - consumerPath: consumer, - stackedPath: stacked, - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - node := planningDepsFor(t, repo.invocation) - - claim := ChangeClaim(context.Background(), node.deps, node.dir, ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if claim.Result != ResultApplied { - t.Fatalf("claim beside unrelated dependents of an unparseable record = %q (disposition %q findings %v), want applied", - claim.Result, claim.Disposition, claim.Findings) - } - if rec, _ := originFile(t, repo.origin, "docket", recPath); !strings.Contains(rec, "status: 'in-progress'") { - t.Errorf("claimed record on origin is not in-progress:\n%s", rec) - } - for p, want := range map[string]string{consumerPath: consumer, stackedPath: stacked} { - if got, ok := originFile(t, repo.origin, "docket", p); !ok || got != want { - t.Errorf("unrelated dependent %s on origin changed (present %v):\n%s", p, ok, got) - } - } - assertUnrelatedBrokenIntact(t, repo) -} - // unrelatedProgressShape is one spec acceptance-1 unrelated-damage shape seeded // beside B, with no record B names: files are the seeded records, and every one // of them must stay byte-identical while a finding on one of them (by path, or @@ -782,78 +497,3 @@ func assertUnrelatedShapeIntact(t *testing.T, repo *gitRepo, branch string, shap } t.Errorf("%s: status no longer reports a finding on the unrelated records; findings %+v", shape.name, st.Findings) } - -// TestChangeClaimUnrelatedShapesProgress drives B's claim through the -// production loader and engine beside each unrelated-damage shape: B applies, -// the damaged records are byte-identical, and the finding is still reported. -func TestChangeClaimUnrelatedShapesProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - for _, shape := range unrelatedProgressShapes(t) { - t.Run(shape.name, func(t *testing.T) { - files := map[string]string{recPath: claimableChange(id, "widget")} - for p, b := range shape.files { - files[p] = b - } - repo := newWorkingRepo(t, files) - node := planningDepsFor(t, repo.invocation) - res := ChangeClaim(context.Background(), node.deps, node.dir, - ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if res.Result != ResultApplied { - t.Fatalf("claim beside %s = %q (disposition %q findings %v), want applied", shape.name, res.Result, res.Disposition, res.Findings) - } - if rec, _ := originFile(t, repo.origin, "docket", recPath); !strings.Contains(rec, "status: 'in-progress'") { - t.Errorf("claimed record on origin is not in-progress:\n%s", rec) - } - assertUnrelatedShapeIntact(t, repo, "docket", shape) - }) - } -} - -func TestChangeClaimUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - for _, c := range unrelatedRefusalCases(t, id, recPath, claimableChange(id, "widget"), claimableChange(id, "dupe")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - res := ChangeClaim(context.Background(), node.deps, node.dir, - ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if res.Result == ResultApplied { - t.Fatalf("claim applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused claim moved the metadata branch %s -> %s", tip, got) - } - }) - } -} - -func TestChangeRefreshClaimUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - src := lifecycleChange(id, "widget", "in-progress") - for _, c := range unrelatedRefusalCases(t, id, recPath, src, lifecycleChange(id, "dupe", "in-progress")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - res := ChangeRefreshClaim(context.Background(), node.deps, node.dir, - ChangeClaimRequest{ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath)}) - if res.Result == ResultApplied { - t.Fatalf("refresh-claim applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused refresh-claim moved the metadata branch %s -> %s", tip, got) - } - }) - } -} diff --git a/internal/app/change_create_integration_test.go b/internal/app/change_create_integration_test.go new file mode 100644 index 000000000..564128944 --- /dev/null +++ b/internal/app/change_create_integration_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "strings" + "testing" +) + +// TestIntegrationRecordOpsChangeCreateNormalizedPrefixReplaysRealGit drives the real engine: a +// create with a messy prefix stores the normalized value, the same request_id +// retyped as the normalized spelling replays, and a differing auto_groomable +// under that id does not apply. +func TestIntegrationRecordOpsChangeCreateNormalizedPrefixReplaysRealGit(t *testing.T) { + requireRealGit(t) + repo := newWorkingRepo(t, map[string]string{ + "docs/changes/active/0001-first.md": fixtureChange(1, "first"), + }) + node := planningDepsFor(t, repo.invocation) + yes, no := true, false + + req := validChangeCreateRequest() + req.BranchPrefix, req.AutoGroomable = "Hotfix/", &yes + first := ChangeCreate(context.Background(), node.deps, node.dir, req) + if first.Result != ResultApplied || first.Replayed { + t.Fatalf("first create = %q replayed=%v (findings %v), want a fresh apply", first.Result, first.Replayed, first.Findings) + } + body, ok := originFile(t, repo.origin, "docket", first.Path) + if !ok || !strings.Contains(body, "\nbranch_prefix: 'hotfix'\n") || !strings.Contains(body, "\nauto_groomable: true\n") { + t.Fatalf("created record lacks the normalized scalars:\n%s", body) + } + tip := originTip(t, repo.origin, "docket") + + req.BranchPrefix = "hotfix" + second := ChangeCreate(context.Background(), node.deps, node.dir, req) + if second.Result != ResultApplied || !second.Replayed || second.ID != first.ID { + t.Fatalf("retyped create = %q replayed=%v id=%d (findings %v), want a replay of %d", second.Result, second.Replayed, second.ID, second.Findings, first.ID) + } + + req.AutoGroomable = &no + third := ChangeCreate(context.Background(), node.deps, node.dir, req) + if third.Result == ResultApplied { + t.Fatalf("a differing auto_groomable under the same request_id applied (replayed=%v)", third.Replayed) + } + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("replay/conflict moved the metadata branch %s -> %s", tip, got) + } +} diff --git a/internal/app/change_create_test.go b/internal/app/change_create_test.go index 84a958cf8..b43bc6db4 100644 --- a/internal/app/change_create_test.go +++ b/internal/app/change_create_test.go @@ -594,43 +594,3 @@ func TestChangeCreatePayloadOmitsUnsetDraftScalars(t *testing.T) { } } } - -// TestChangeCreateNormalizedPrefixReplaysRealGit drives the real engine: a -// create with a messy prefix stores the normalized value, the same request_id -// retyped as the normalized spelling replays, and a differing auto_groomable -// under that id does not apply. -func TestChangeCreateNormalizedPrefixReplaysRealGit(t *testing.T) { - requireRealGit(t) - repo := newWorkingRepo(t, map[string]string{ - "docs/changes/active/0001-first.md": fixtureChange(1, "first"), - }) - node := planningDepsFor(t, repo.invocation) - yes, no := true, false - - req := validChangeCreateRequest() - req.BranchPrefix, req.AutoGroomable = "Hotfix/", &yes - first := ChangeCreate(context.Background(), node.deps, node.dir, req) - if first.Result != ResultApplied || first.Replayed { - t.Fatalf("first create = %q replayed=%v (findings %v), want a fresh apply", first.Result, first.Replayed, first.Findings) - } - body, ok := originFile(t, repo.origin, "docket", first.Path) - if !ok || !strings.Contains(body, "\nbranch_prefix: 'hotfix'\n") || !strings.Contains(body, "\nauto_groomable: true\n") { - t.Fatalf("created record lacks the normalized scalars:\n%s", body) - } - tip := originTip(t, repo.origin, "docket") - - req.BranchPrefix = "hotfix" - second := ChangeCreate(context.Background(), node.deps, node.dir, req) - if second.Result != ResultApplied || !second.Replayed || second.ID != first.ID { - t.Fatalf("retyped create = %q replayed=%v id=%d (findings %v), want a replay of %d", second.Result, second.Replayed, second.ID, second.Findings, first.ID) - } - - req.AutoGroomable = &no - third := ChangeCreate(context.Background(), node.deps, node.dir, req) - if third.Result == ResultApplied { - t.Fatalf("a differing auto_groomable under the same request_id applied (replayed=%v)", third.Replayed) - } - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("replay/conflict moved the metadata branch %s -> %s", tip, got) - } -} diff --git a/internal/app/change_groom_integration_test.go b/internal/app/change_groom_integration_test.go new file mode 100644 index 000000000..037e5125c --- /dev/null +++ b/internal/app/change_groom_integration_test.go @@ -0,0 +1,115 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "slices" + "strings" + "testing" +) + +// TestIntegrationRecordOpsChangeGroomReviseSpecVersionContendsRealGit drives the finding's exact +// race through a real engine and a bare origin: two revises pinned to the SAME +// record version (a same-day spec-only revise leaves the record bytes +// unchanged) and the same spec version. The first applies; the second's spec +// pin is stale, so it contends and writes nothing instead of clobbering the +// first revise's spec body. +func TestIntegrationRecordOpsChangeGroomReviseSpecVersionContendsRealGit(t *testing.T) { + requireRealGit(t) + recPath := groomPath(2, "add-a-widget") + repo := newWorkingRepo(t, reviseFixtureFiles()) + node := planningDepsFor(t, repo.invocation) + + revise := func(body, recV, specV string) ChangeGroomResult { + req := validReviseRequest() + req.Sections = nil + req.SpecMarkdown = "# Design\n\n" + body + "\n" + req.Version, req.SpecVersion = recV, specV + return ChangeGroom(context.Background(), node.deps, node.dir, req) + } + + // Settle the record (updated: today, artifacts rendered) with a matching + // pin — the matching-version apply path. + if res := revise("Settling body.", blobVersionAt(t, repo.origin, "docket", recPath), + blobVersionAt(t, repo.origin, "docket", reviseSpecPath)); res.Result != ResultApplied { + t.Fatalf("settling revise = %q (findings %v), want applied", res.Result, res.Findings) + } + recV := blobVersionAt(t, repo.origin, "docket", recPath) + specV := blobVersionAt(t, repo.origin, "docket", reviseSpecPath) + + if res := revise("Body A.", recV, specV); res.Result != ResultApplied { + t.Fatalf("revise A = %q (findings %v), want applied", res.Result, res.Findings) + } + if got := blobVersionAt(t, repo.origin, "docket", recPath); got != recV { + t.Fatalf("precondition: a same-day spec-only revise must leave the record version unchanged (%s -> %s)", recV, got) + } + tip := originTip(t, repo.origin, "docket") + + res := revise("Body B.", recV, specV) // stale spec pin, current record pin + if res.Result != ResultContended { + t.Fatalf("revise B over a stale spec_version = %q (findings %v), want contended", res.Result, res.Findings) + } + if !hasFindingCode(res.Findings, "spec-version-mismatch") { + t.Errorf("missing finding spec-version-mismatch; got %v", res.Findings) + } + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a contended revise moved the metadata branch %s -> %s", tip, got) + } + spec, _ := originFile(t, repo.origin, "docket", reviseSpecPath) + if !strings.Contains(spec, "Body A.") || strings.Contains(spec, "Body B.") { + t.Errorf("revise A's spec body was clobbered:\n%s", spec) + } +} + +// TestIntegrationRecordOpsChangeGroomAbstainThenRearmRealGit drives both outcomes through the real +// engine and a bare origin: the abstain lands the record and BOARD.md in ONE +// commit; a re-arm pinned to the pre-abstain version contends and writes +// nothing; a re-arm at the current version restores needs-brainstorm. +func TestIntegrationRecordOpsChangeGroomAbstainThenRearmRealGit(t *testing.T) { + requireRealGit(t) + recPath := groomPath(2, "add-a-widget") + repo := newWorkingRepo(t, map[string]string{recPath: groomableChange(2, "add-a-widget")}) + node := planningDepsFor(t, repo.invocation) + + ab := abstainRequest() + ab.Version = blobVersionAt(t, repo.origin, "docket", recPath) + if res := ChangeGroom(context.Background(), node.deps, node.dir, ab); res.Result != ResultApplied { + t.Fatalf("abstain = %q (findings %v), want applied", res.Result, res.Findings) + } + tip := originTip(t, repo.origin, "docket") + paths := originCommitPaths(t, repo.origin, tip) + if !slices.Contains(paths, recPath) || !slices.Contains(paths, "docs/changes/BOARD.md") { + t.Fatalf("abstain commit paths = %v, want the record and BOARD.md in one commit", paths) + } + if board, _ := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md"); !strings.Contains(board, "auto-groom blocked — needs you") { + t.Errorf("committed board does not show the abstain:\n%s", board) + } + + stale := rearmRequest() + stale.Version = ab.Version // pre-abstain pin + if res := ChangeGroom(context.Background(), node.deps, node.dir, stale); res.Result != ResultContended { + t.Fatalf("stale re-arm = %q (findings %v), want contended", res.Result, res.Findings) + } + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Fatalf("a contended re-arm moved the metadata branch %s -> %s", tip, got) + } + + fresh := rearmRequest() + fresh.Version = blobVersionAt(t, repo.origin, "docket", recPath) + if res := ChangeGroom(context.Background(), node.deps, node.dir, fresh); res.Result != ResultApplied { + t.Fatalf("re-arm = %q (findings %v), want applied", res.Result, res.Findings) + } + rec, _ := originFile(t, repo.origin, "docket", recPath) + board, _ := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md") + if strings.Contains(rec, "## Auto-groom blocked") || !strings.Contains(rec, "\nauto_groomable: true\n") { + t.Errorf("re-armed record:\n%s", rec) + } + if strings.Contains(board, "auto-groom blocked — needs you") { + t.Errorf("committed board still shows the abstain after re-arm:\n%s", board) + } +} diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index d849370cb..95c726b53 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -6,7 +6,6 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository/transaction" - "slices" "strings" "testing" ) @@ -570,59 +569,6 @@ func TestChangeGroomSpecVersionMismatchMapsToContended(t *testing.T) { } } -// TestChangeGroomReviseSpecVersionContendsRealGit drives the finding's exact -// race through a real engine and a bare origin: two revises pinned to the SAME -// record version (a same-day spec-only revise leaves the record bytes -// unchanged) and the same spec version. The first applies; the second's spec -// pin is stale, so it contends and writes nothing instead of clobbering the -// first revise's spec body. -func TestChangeGroomReviseSpecVersionContendsRealGit(t *testing.T) { - requireRealGit(t) - recPath := groomPath(2, "add-a-widget") - repo := newWorkingRepo(t, reviseFixtureFiles()) - node := planningDepsFor(t, repo.invocation) - - revise := func(body, recV, specV string) ChangeGroomResult { - req := validReviseRequest() - req.Sections = nil - req.SpecMarkdown = "# Design\n\n" + body + "\n" - req.Version, req.SpecVersion = recV, specV - return ChangeGroom(context.Background(), node.deps, node.dir, req) - } - - // Settle the record (updated: today, artifacts rendered) with a matching - // pin — the matching-version apply path. - if res := revise("Settling body.", blobVersionAt(t, repo.origin, "docket", recPath), - blobVersionAt(t, repo.origin, "docket", reviseSpecPath)); res.Result != ResultApplied { - t.Fatalf("settling revise = %q (findings %v), want applied", res.Result, res.Findings) - } - recV := blobVersionAt(t, repo.origin, "docket", recPath) - specV := blobVersionAt(t, repo.origin, "docket", reviseSpecPath) - - if res := revise("Body A.", recV, specV); res.Result != ResultApplied { - t.Fatalf("revise A = %q (findings %v), want applied", res.Result, res.Findings) - } - if got := blobVersionAt(t, repo.origin, "docket", recPath); got != recV { - t.Fatalf("precondition: a same-day spec-only revise must leave the record version unchanged (%s -> %s)", recV, got) - } - tip := originTip(t, repo.origin, "docket") - - res := revise("Body B.", recV, specV) // stale spec pin, current record pin - if res.Result != ResultContended { - t.Fatalf("revise B over a stale spec_version = %q (findings %v), want contended", res.Result, res.Findings) - } - if !hasFindingCode(res.Findings, "spec-version-mismatch") { - t.Errorf("missing finding spec-version-mismatch; got %v", res.Findings) - } - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a contended revise moved the metadata branch %s -> %s", tip, got) - } - spec, _ := originFile(t, repo.origin, "docket", reviseSpecPath) - if !strings.Contains(spec, "Body A.") || strings.Contains(spec, "Body B.") { - t.Errorf("revise A's spec body was clobbered:\n%s", spec) - } -} - const reviseSpecPath = "docs/superpowers/specs/2026-08-01-add-a-widget-design.md" // reviseFixtureFiles is the fake tree for a revisable spec'd change: the @@ -1325,54 +1271,6 @@ func TestChangeGroomResultHumanTextRearm(t *testing.T) { } } -// TestChangeGroomAbstainThenRearmRealGit drives both outcomes through the real -// engine and a bare origin: the abstain lands the record and BOARD.md in ONE -// commit; a re-arm pinned to the pre-abstain version contends and writes -// nothing; a re-arm at the current version restores needs-brainstorm. -func TestChangeGroomAbstainThenRearmRealGit(t *testing.T) { - requireRealGit(t) - recPath := groomPath(2, "add-a-widget") - repo := newWorkingRepo(t, map[string]string{recPath: groomableChange(2, "add-a-widget")}) - node := planningDepsFor(t, repo.invocation) - - ab := abstainRequest() - ab.Version = blobVersionAt(t, repo.origin, "docket", recPath) - if res := ChangeGroom(context.Background(), node.deps, node.dir, ab); res.Result != ResultApplied { - t.Fatalf("abstain = %q (findings %v), want applied", res.Result, res.Findings) - } - tip := originTip(t, repo.origin, "docket") - paths := originCommitPaths(t, repo.origin, tip) - if !slices.Contains(paths, recPath) || !slices.Contains(paths, "docs/changes/BOARD.md") { - t.Fatalf("abstain commit paths = %v, want the record and BOARD.md in one commit", paths) - } - if board, _ := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md"); !strings.Contains(board, "auto-groom blocked — needs you") { - t.Errorf("committed board does not show the abstain:\n%s", board) - } - - stale := rearmRequest() - stale.Version = ab.Version // pre-abstain pin - if res := ChangeGroom(context.Background(), node.deps, node.dir, stale); res.Result != ResultContended { - t.Fatalf("stale re-arm = %q (findings %v), want contended", res.Result, res.Findings) - } - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Fatalf("a contended re-arm moved the metadata branch %s -> %s", tip, got) - } - - fresh := rearmRequest() - fresh.Version = blobVersionAt(t, repo.origin, "docket", recPath) - if res := ChangeGroom(context.Background(), node.deps, node.dir, fresh); res.Result != ResultApplied { - t.Fatalf("re-arm = %q (findings %v), want applied", res.Result, res.Findings) - } - rec, _ := originFile(t, repo.origin, "docket", recPath) - board, _ := originFile(t, repo.origin, "docket", "docs/changes/BOARD.md") - if strings.Contains(rec, "## Auto-groom blocked") || !strings.Contains(rec, "\nauto_groomable: true\n") { - t.Errorf("re-armed record:\n%s", rec) - } - if strings.Contains(board, "auto-groom blocked — needs you") { - t.Errorf("committed board still shows the abstain after re-arm:\n%s", board) - } -} - // followedSection is a non-final section placed after ## Auto-groom blocked so // abstain-append and rearm-removal are proven not to consume what follows. const followedSection = "## Reconcile log\n\nKept entry.\n" diff --git a/internal/app/change_halt_integration_test.go b/internal/app/change_halt_integration_test.go new file mode 100644 index 000000000..6fab9ecc3 --- /dev/null +++ b/internal/app/change_halt_integration_test.go @@ -0,0 +1,105 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "github.com/danielhanold/docket/internal/workspace" + "strings" + "testing" +) + +func TestIntegrationRecordOpsChangeHaltUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + repo := newWorkingRepo(t, map[string]string{ + recPath: lifecycleChange(id, "widget", "in-progress"), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + node := planningDepsFor(t, repo.invocation) + + res := ChangeHalt(context.Background(), node.deps, node.dir, HaltRequest{ + ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), Report: "Blocked on infra; see run 7.\n", + }) + if res.Result != ResultApplied || res.Disposition != HaltDispHalted { + t.Fatalf("halt beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied halted", + res.Result, res.Disposition, res.Reason, res.Findings) + } + rec, _ := originFile(t, repo.origin, "docket", recPath) + if !strings.Contains(rec, "## Run halted") { + t.Errorf("halted record on origin lacks the marker:\n%s", rec) + } + assertUnrelatedBrokenIntact(t, repo) +} + +func TestIntegrationRecordOpsChangeHaltUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + for _, c := range unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, "widget", "in-progress"), lifecycleChange(id, "dupe", "in-progress")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + res := ChangeHalt(context.Background(), node.deps, node.dir, HaltRequest{ + ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), Report: "Paused.\n", + }) + if res.Result == ResultApplied { + t.Fatalf("halt applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, res.Reason, res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused halt moved the metadata branch %s -> %s", tip, got) + } + }) + } +} + +func TestIntegrationRecordOpsChangeResumeHaltedUnrelatedInvalidRecordProgress(t *testing.T) { + f := setupHaltedFixture(t, planRepoModes()[0]) + advanceDocketOrigin(t, f.repo, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) + recPath := groomPath(f.id, f.slug) + + got := ChangeResumeHalted(context.Background(), f.deps, + WorkspaceDeps{Service: fakeResumeWorkspace{kind: workspace.StateReady, head: f.head}}, f.repo.invocation, + ResumeRequest{ID: f.id, Version: blobVersionAt(t, f.repo.origin, "docket", recPath), AcknowledgeQuiescent: true}) + if got.Result != ResultApplied || got.Disposition != HaltDispResumed { + t.Fatalf("resume-halted beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied resumed", + got.Result, got.Disposition, got.Reason, got.Findings) + } + rec, _ := originFile(t, f.repo.origin, "docket", recPath) + if strings.Contains(rec, "## Run halted") { + t.Errorf("marker not removed on resume:\n%s", rec) + } + assertUnrelatedBrokenIntact(t, f.repo) +} + +func TestIntegrationRecordOpsChangeResumeHaltedUnrelatedInvalidRecordRefusals(t *testing.T) { + cases := unrelatedRefusalCases(t, rebaseFixtureID, groomPath(rebaseFixtureID, rebaseFixtureSlug), + haltedRecord(rebaseFixtureID, rebaseFixtureSlug), lifecycleChange(rebaseFixtureID, "dupe", "in-progress")) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := setupHaltedFixture(t, planRepoModes()[0]) + advanceDocketOrigin(t, f.repo, c.files) + recPath := groomPath(f.id, f.slug) + tip := originTip(t, f.repo.origin, "docket") + + got := ChangeResumeHalted(context.Background(), f.deps, + WorkspaceDeps{Service: fakeResumeWorkspace{kind: workspace.StateReady, head: f.head}}, f.repo.invocation, + ResumeRequest{ID: f.id, Version: blobVersionAt(t, f.repo.origin, "docket", recPath), AcknowledgeQuiescent: true}) + if got.Result == ResultApplied { + t.Fatalf("resume-halted applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, got.Reason, got.Findings) + if now := originTip(t, f.repo.origin, "docket"); now != tip { + t.Errorf("a refused resume-halted moved the metadata branch %s -> %s", tip, now) + } + }) + } +} diff --git a/internal/app/change_halt_test.go b/internal/app/change_halt_test.go index ba2921eeb..98ba0ee49 100644 --- a/internal/app/change_halt_test.go +++ b/internal/app/change_halt_test.go @@ -238,94 +238,3 @@ func TestHaltResultFromOutcomeFailedCarriesCause(t *testing.T) { // --- 0449: unrelated invalid records never block a named halt/resume -------- // Shares the unrelated-broken-record fixtures with change_claim_test.go. Halt // and resume-halted each open their own transaction.Request; both are scoped. - -func TestChangeHaltUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - repo := newWorkingRepo(t, map[string]string{ - recPath: lifecycleChange(id, "widget", "in-progress"), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - node := planningDepsFor(t, repo.invocation) - - res := ChangeHalt(context.Background(), node.deps, node.dir, HaltRequest{ - ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), Report: "Blocked on infra; see run 7.\n", - }) - if res.Result != ResultApplied || res.Disposition != HaltDispHalted { - t.Fatalf("halt beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied halted", - res.Result, res.Disposition, res.Reason, res.Findings) - } - rec, _ := originFile(t, repo.origin, "docket", recPath) - if !strings.Contains(rec, "## Run halted") { - t.Errorf("halted record on origin lacks the marker:\n%s", rec) - } - assertUnrelatedBrokenIntact(t, repo) -} - -func TestChangeHaltUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - for _, c := range unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, "widget", "in-progress"), lifecycleChange(id, "dupe", "in-progress")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - res := ChangeHalt(context.Background(), node.deps, node.dir, HaltRequest{ - ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), Report: "Paused.\n", - }) - if res.Result == ResultApplied { - t.Fatalf("halt applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, res.Reason, res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused halt moved the metadata branch %s -> %s", tip, got) - } - }) - } -} - -func TestChangeResumeHaltedUnrelatedInvalidRecordProgress(t *testing.T) { - f := setupHaltedFixture(t, planRepoModes()[0]) - advanceDocketOrigin(t, f.repo, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) - recPath := groomPath(f.id, f.slug) - - got := ChangeResumeHalted(context.Background(), f.deps, - WorkspaceDeps{Service: fakeResumeWorkspace{kind: workspace.StateReady, head: f.head}}, f.repo.invocation, - ResumeRequest{ID: f.id, Version: blobVersionAt(t, f.repo.origin, "docket", recPath), AcknowledgeQuiescent: true}) - if got.Result != ResultApplied || got.Disposition != HaltDispResumed { - t.Fatalf("resume-halted beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied resumed", - got.Result, got.Disposition, got.Reason, got.Findings) - } - rec, _ := originFile(t, f.repo.origin, "docket", recPath) - if strings.Contains(rec, "## Run halted") { - t.Errorf("marker not removed on resume:\n%s", rec) - } - assertUnrelatedBrokenIntact(t, f.repo) -} - -func TestChangeResumeHaltedUnrelatedInvalidRecordRefusals(t *testing.T) { - cases := unrelatedRefusalCases(t, rebaseFixtureID, groomPath(rebaseFixtureID, rebaseFixtureSlug), - haltedRecord(rebaseFixtureID, rebaseFixtureSlug), lifecycleChange(rebaseFixtureID, "dupe", "in-progress")) - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - f := setupHaltedFixture(t, planRepoModes()[0]) - advanceDocketOrigin(t, f.repo, c.files) - recPath := groomPath(f.id, f.slug) - tip := originTip(t, f.repo.origin, "docket") - - got := ChangeResumeHalted(context.Background(), f.deps, - WorkspaceDeps{Service: fakeResumeWorkspace{kind: workspace.StateReady, head: f.head}}, f.repo.invocation, - ResumeRequest{ID: f.id, Version: blobVersionAt(t, f.repo.origin, "docket", recPath), AcknowledgeQuiescent: true}) - if got.Result == ResultApplied { - t.Fatalf("resume-halted applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, got.Reason, got.Findings) - if now := originTip(t, f.repo.origin, "docket"); now != tip { - t.Errorf("a refused resume-halted moved the metadata branch %s -> %s", tip, now) - } - }) - } -} diff --git a/internal/app/change_implemented_integration_test.go b/internal/app/change_implemented_integration_test.go new file mode 100644 index 000000000..95d912533 --- /dev/null +++ b/internal/app/change_implemented_integration_test.go @@ -0,0 +1,76 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "github.com/danielhanold/docket/internal/githubcli" + "testing" +) + +// TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence: a build.gate: off repository marks a +// change implemented on truthful skipped evidence certifying the exact head. The +// evidence conjunct accepts VerdictSkipped exactly as VerdictVerified; the happy +// fixture is TestIntegrationChangeRuntimeMarkImplementedAppliesEndToEnd with skipped +// evidence substituted. +func TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence(t *testing.T) { + requireRealGit(t) + repo := newWorkingRepo(t, nil) + head := miAdvanceHead(t, repo) + client := newGitClient(t) + pr := prRepo().Spec() + "#42" + + deps, wdeps, gdeps, inv, req, _ := buildMI(t, client, repo.invocation, miKit{ + reconciled: true, plan: miPlanPath(), results: miResultsPath, version: miVersion, reqVersion: miVersion, + reqHead: head, localHead: head, evidence: prSkippedEvidenceBytes(t, head), + probePRs: []githubcli.PullRequest{happyPR(head)}, reqPR: pr, + }) + + res := ChangeMarkImplemented(context.Background(), deps, wdeps, gdeps, inv, req) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied — skipped evidence at the exact head must certify implemented (findings %v)", res.Result, res.Findings) + } +} + +func TestIntegrationRecordOpsMarkImplementedUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + recPath := groomPath(3, miSlug) + repo := newWorkingRepo(t, map[string]string{ + recPath: miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + head := miAdvanceHead(t, repo) + + res := miRealRun(t, repo, recPath, head) + if res.Result != ResultApplied || res.Status != "implemented" { + t.Fatalf("mark-implemented beside an unrelated unparseable record = %q status %q (findings %v), want applied implemented", + res.Result, res.Status, res.Findings) + } + assertUnrelatedBrokenIntact(t, repo) +} + +func TestIntegrationRecordOpsMarkImplementedUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + recPath := groomPath(3, miSlug) + src := miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false) + for _, c := range unrelatedRefusalCases(t, 3, recPath, src, lifecycleChange(3, "dupe", "in-progress")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + head := miAdvanceHead(t, repo) + tip := originTip(t, repo.origin, "docket") + + res := miRealRun(t, repo, recPath, head) + if res.Result == ResultApplied { + t.Fatalf("mark-implemented applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, "", res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused mark-implemented moved the metadata branch %s -> %s", tip, got) + } + }) + } +} diff --git a/internal/app/change_implemented_test.go b/internal/app/change_implemented_test.go index 89843881b..6891a4968 100644 --- a/internal/app/change_implemented_test.go +++ b/internal/app/change_implemented_test.go @@ -243,30 +243,6 @@ func firstStatusFindingCode(findings []StatusFinding) string { return "" } -// TestMarkImplementedAcceptsSkippedEvidence: a build.gate: off repository marks a -// change implemented on truthful skipped evidence certifying the exact head. The -// evidence conjunct accepts VerdictSkipped exactly as VerdictVerified; the happy -// fixture is TestIntegrationChangeRuntimeMarkImplementedAppliesEndToEnd with skipped -// evidence substituted. -func TestMarkImplementedAcceptsSkippedEvidence(t *testing.T) { - requireRealGit(t) - repo := newWorkingRepo(t, nil) - head := miAdvanceHead(t, repo) - client := newGitClient(t) - pr := prRepo().Spec() + "#42" - - deps, wdeps, gdeps, inv, req, _ := buildMI(t, client, repo.invocation, miKit{ - reconciled: true, plan: miPlanPath(), results: miResultsPath, version: miVersion, reqVersion: miVersion, - reqHead: head, localHead: head, evidence: prSkippedEvidenceBytes(t, head), - probePRs: []githubcli.PullRequest{happyPR(head)}, reqPR: pr, - }) - - res := ChangeMarkImplemented(context.Background(), deps, wdeps, gdeps, inv, req) - if res.Result != ResultApplied { - t.Fatalf("result = %q, want applied — skipped evidence at the exact head must certify implemented (findings %v)", res.Result, res.Findings) - } -} - // --- 0449: unrelated invalid records never block a named mark-implemented --- // Shares the unrelated-broken-record fixtures with change_claim_test.go. Unlike // the fake-engine conjunct rows above, these drive the production engine and @@ -288,42 +264,3 @@ func miRealRun(t *testing.T, repo *gitRepo, recPath, head string) ChangeLifecycl } return ChangeMarkImplemented(context.Background(), node.deps, wdeps, gdeps, node.dir, req) } - -func TestMarkImplementedUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - recPath := groomPath(3, miSlug) - repo := newWorkingRepo(t, map[string]string{ - recPath: miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - head := miAdvanceHead(t, repo) - - res := miRealRun(t, repo, recPath, head) - if res.Result != ResultApplied || res.Status != "implemented" { - t.Fatalf("mark-implemented beside an unrelated unparseable record = %q status %q (findings %v), want applied implemented", - res.Result, res.Status, res.Findings) - } - assertUnrelatedBrokenIntact(t, repo) -} - -func TestMarkImplementedUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - recPath := groomPath(3, miSlug) - src := miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false) - for _, c := range unrelatedRefusalCases(t, 3, recPath, src, lifecycleChange(3, "dupe", "in-progress")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - head := miAdvanceHead(t, repo) - tip := originTip(t, repo.origin, "docket") - - res := miRealRun(t, repo, recPath, head) - if res.Result == ResultApplied { - t.Fatalf("mark-implemented applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, "", res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused mark-implemented moved the metadata branch %s -> %s", tip, got) - } - }) - } -} diff --git a/internal/app/change_integration_test.go b/internal/app/change_integration_test.go index fac3a90c1..e40d02eb9 100644 --- a/internal/app/change_integration_test.go +++ b/internal/app/change_integration_test.go @@ -1332,9 +1332,9 @@ func TestIntegrationChangeAuthoringGateRetryConsumeOnceThenFalse(t *testing.T) { // AttemptLimit-1 gate-retry-once lines — each on a distinct attempt transition — // before the terminal gate-stop. The report-line TOKENS are asserted byte-for-byte // so the counted budget never changes a parsed line, and GateRetryUsage confirms the -// on-disk marker count matches the grants. Unlike the unit-level -// TestVerdictIncompleteRespectsAttemptLimit, the limit here flows from config through -// the real arm, not a hand-stamped record. +// on-disk marker count matches the grants. Unlike the direct RunGateVerdict call in +// TestIntegrationGateVerdictVerdictIncompleteRespectsAttemptLimit, the limit here +// flows from config through the real arm, not a hand-stamped record. func TestIntegrationChangeAuthoringOuterBudgetEndToEnd(t *testing.T) { cases := []struct { limit int diff --git a/internal/app/change_lifecycle_integration_test.go b/internal/app/change_lifecycle_integration_test.go new file mode 100644 index 000000000..fd808f09f --- /dev/null +++ b/internal/app/change_lifecycle_integration_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "testing" +) + +func TestIntegrationRecordOpsChangeLifecycleUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + rows := []struct { + name, from, want string + run func(node realNode, version string) ChangeLifecycleResult + }{ + {name: "block", from: "in-progress", want: "blocked", run: func(node realNode, version string) ChangeLifecycleResult { + return ChangeBlock(context.Background(), node.deps, node.dir, + ChangeBlockRequest{ChangeID: id, Path: recPath, Version: version, Reason: "waiting on upstream"}) + }}, + {name: "defer", from: "proposed", want: "deferred", run: func(node realNode, version string) ChangeLifecycleResult { + return ChangeDefer(context.Background(), node.deps, node.dir, + ChangeDeferRequest{ChangeID: id, Path: recPath, Version: version, WhyDeferred: "Parked pending a decision.\n"}) + }}, + } + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + repo := newWorkingRepo(t, map[string]string{ + recPath: lifecycleChange(id, "widget", r.from), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + node := planningDepsFor(t, repo.invocation) + res := r.run(node, blobVersionAt(t, repo.origin, "docket", recPath)) + if res.Result != ResultApplied || res.Status != r.want { + t.Fatalf("%s beside an unrelated unparseable record = %q status %q (findings %v), want applied %q", + r.name, res.Result, res.Status, res.Findings, r.want) + } + assertUnrelatedBrokenIntact(t, repo) + }) + } +} + +func TestIntegrationRecordOpsChangeLifecycleUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + for _, c := range unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, "widget", "in-progress"), lifecycleChange(id, "dupe", "in-progress")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + res := ChangeBlock(context.Background(), node.deps, node.dir, ChangeBlockRequest{ + ChangeID: id, Path: recPath, Version: blobVersionAt(t, repo.origin, "docket", recPath), Reason: "waiting on upstream", + }) + if res.Result == ResultApplied { + t.Fatalf("block applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, "", res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused block moved the metadata branch %s -> %s", tip, got) + } + }) + } +} diff --git a/internal/app/change_lifecycle_test.go b/internal/app/change_lifecycle_test.go index bfcab4581..8d9a17bf3 100644 --- a/internal/app/change_lifecycle_test.go +++ b/internal/app/change_lifecycle_test.go @@ -750,63 +750,5 @@ func TestLifecycleResultFromOutcomeFailedCarriesCause(t *testing.T) { // // Mutation check (run manually; noted in the commit): delete the `Scope:` field // from executeChangeLifecycle's transaction.Request and -// `go test ./internal/app/ -run 'TestChangeLifecycleUnrelated' -count=1` reddens -// on the progress rows with the before-gate refusal the bug produced. - -func TestChangeLifecycleUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - rows := []struct { - name, from, want string - run func(node realNode, version string) ChangeLifecycleResult - }{ - {name: "block", from: "in-progress", want: "blocked", run: func(node realNode, version string) ChangeLifecycleResult { - return ChangeBlock(context.Background(), node.deps, node.dir, - ChangeBlockRequest{ChangeID: id, Path: recPath, Version: version, Reason: "waiting on upstream"}) - }}, - {name: "defer", from: "proposed", want: "deferred", run: func(node realNode, version string) ChangeLifecycleResult { - return ChangeDefer(context.Background(), node.deps, node.dir, - ChangeDeferRequest{ChangeID: id, Path: recPath, Version: version, WhyDeferred: "Parked pending a decision.\n"}) - }}, - } - for _, r := range rows { - t.Run(r.name, func(t *testing.T) { - repo := newWorkingRepo(t, map[string]string{ - recPath: lifecycleChange(id, "widget", r.from), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - node := planningDepsFor(t, repo.invocation) - res := r.run(node, blobVersionAt(t, repo.origin, "docket", recPath)) - if res.Result != ResultApplied || res.Status != r.want { - t.Fatalf("%s beside an unrelated unparseable record = %q status %q (findings %v), want applied %q", - r.name, res.Result, res.Status, res.Findings, r.want) - } - assertUnrelatedBrokenIntact(t, repo) - }) - } -} - -func TestChangeLifecycleUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - for _, c := range unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, "widget", "in-progress"), lifecycleChange(id, "dupe", "in-progress")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - res := ChangeBlock(context.Background(), node.deps, node.dir, ChangeBlockRequest{ - ChangeID: id, Path: recPath, Version: blobVersionAt(t, repo.origin, "docket", recPath), Reason: "waiting on upstream", - }) - if res.Result == ResultApplied { - t.Fatalf("block applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, "", res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused block moved the metadata branch %s -> %s", tip, got) - } - }) - } -} +// `go test -tags integration ./internal/app/ -run 'TestIntegrationRecordOpsChangeLifecycleUnrelated' -count=1` +// reddens on the progress rows with the before-gate refusal the bug produced. diff --git a/internal/app/change_reconcile_integration_test.go b/internal/app/change_reconcile_integration_test.go new file mode 100644 index 000000000..ad940fd44 --- /dev/null +++ b/internal/app/change_reconcile_integration_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "strings" + "testing" +) + +func TestIntegrationRecordOpsChangeReconcileUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + repo := newWorkingRepo(t, map[string]string{ + recPath: reconcilableChange(id, "widget"), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + node := planningDepsFor(t, repo.invocation) + + res := ChangeReconcile(context.Background(), node.deps, node.dir, ChangeReconcileRequest{ + ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), ReconcileLogEntry: "Reconciled against current reality.\n", + }) + if res.Result != ResultApplied { + t.Fatalf("reconcile beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", + res.Result, res.Disposition, res.Findings) + } + rec, _ := originFile(t, repo.origin, "docket", recPath) + if !strings.Contains(rec, "Reconciled against current reality.") { + t.Errorf("reconciled record on origin lacks the log entry:\n%s", rec) + } + assertUnrelatedBrokenIntact(t, repo) +} + +func TestIntegrationRecordOpsChangeReconcileUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + const id = 3 + recPath := groomPath(id, "widget") + for _, c := range unrelatedRefusalCases(t, id, recPath, reconcilableChange(id, "widget"), reconcilableChange(id, "dupe")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + node := planningDepsFor(t, repo.invocation) + tip := originTip(t, repo.origin, "docket") + + res := ChangeReconcile(context.Background(), node.deps, node.dir, ChangeReconcileRequest{ + ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), ReconcileLogEntry: "Reconciled.\n", + }) + if res.Result == ResultApplied { + t.Fatalf("reconcile applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused reconcile moved the metadata branch %s -> %s", tip, got) + } + }) + } +} diff --git a/internal/app/change_reconcile_test.go b/internal/app/change_reconcile_test.go index dbcc61c96..16e523429 100644 --- a/internal/app/change_reconcile_test.go +++ b/internal/app/change_reconcile_test.go @@ -357,51 +357,3 @@ func TestChangeReconcileRejectsBadShapeWithoutEngineCall(t *testing.T) { // --- 0449: unrelated invalid records never block a named reconcile ---------- // Shares the unrelated-broken-record fixtures with change_claim_test.go. - -func TestChangeReconcileUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - repo := newWorkingRepo(t, map[string]string{ - recPath: reconcilableChange(id, "widget"), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - node := planningDepsFor(t, repo.invocation) - - res := ChangeReconcile(context.Background(), node.deps, node.dir, ChangeReconcileRequest{ - ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), ReconcileLogEntry: "Reconciled against current reality.\n", - }) - if res.Result != ResultApplied { - t.Fatalf("reconcile beside an unrelated unparseable record = %q (disposition %q findings %v), want applied", - res.Result, res.Disposition, res.Findings) - } - rec, _ := originFile(t, repo.origin, "docket", recPath) - if !strings.Contains(rec, "Reconciled against current reality.") { - t.Errorf("reconciled record on origin lacks the log entry:\n%s", rec) - } - assertUnrelatedBrokenIntact(t, repo) -} - -func TestChangeReconcileUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - const id = 3 - recPath := groomPath(id, "widget") - for _, c := range unrelatedRefusalCases(t, id, recPath, reconcilableChange(id, "widget"), reconcilableChange(id, "dupe")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - node := planningDepsFor(t, repo.invocation) - tip := originTip(t, repo.origin, "docket") - - res := ChangeReconcile(context.Background(), node.deps, node.dir, ChangeReconcileRequest{ - ID: id, Version: blobVersionAt(t, repo.origin, "docket", recPath), ReconcileLogEntry: "Reconciled.\n", - }) - if res.Result == ResultApplied { - t.Fatalf("reconcile applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, res.Disposition, res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused reconcile moved the metadata branch %s -> %s", tip, got) - } - }) - } -} diff --git a/internal/app/change_repair_integration_test.go b/internal/app/change_repair_integration_test.go new file mode 100644 index 000000000..fbd9023c2 --- /dev/null +++ b/internal/app/change_repair_integration_test.go @@ -0,0 +1,95 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_recordops.sh (prefix ^TestIntegrationRecordOps). + +import ( + "context" + "github.com/danielhanold/docket/internal/workspace" + "strings" + "testing" +) + +func TestIntegrationRecordOpsRepairIdentityUnrelatedInvalidRecordProgress(t *testing.T) { + requireRealGit(t) + recPath := groomPath(3, "widget") + repo := newWorkingRepo(t, map[string]string{ + recPath: repairRecord(3, "widget", ""), + unrelatedBrokenPath: unrelatedBrokenBytes, + }) + repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) + + res := repairRealRun(t, repo, recPath) + if res.Result != ResultApplied || res.Branch != "feat/renamed" { + t.Fatalf("repair-identity beside an unrelated unparseable record = %q reason %q branch %q (findings %v), want applied feat/renamed", + res.Result, res.Reason, res.Branch, res.Findings) + } + rec, _ := originFile(t, repo.origin, "docket", recPath) + if !strings.Contains(rec, "branch: 'feat/renamed'") { + t.Errorf("repaired record on origin does not carry the adopted branch:\n%s", rec) + } + assertUnrelatedBrokenIntact(t, repo) +} + +func TestIntegrationRecordOpsRepairIdentityUnrelatedInvalidRecordRefusals(t *testing.T) { + requireRealGit(t) + recPath := groomPath(3, "widget") + for _, c := range unrelatedRefusalCases(t, 3, recPath, repairRecord(3, "widget", ""), repairRecord(3, "dupe", "")) { + t.Run(c.name, func(t *testing.T) { + repo := newWorkingRepo(t, c.files) + repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) + tip := originTip(t, repo.origin, "docket") + + res := repairRealRun(t, repo, recPath) + if res.Result == ResultApplied { + t.Fatalf("repair-identity applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, res.Reason, res.Findings) + if got := originTip(t, repo.origin, "docket"); got != tip { + t.Errorf("a refused repair-identity moved the metadata branch %s -> %s", tip, got) + } + }) + } +} + +// TestIntegrationRecordOpsRepairAdoptPRHeadAppliesOnMalformedRecordedBranch proves the PR-case +// remedy status prints for a branch-malformed record (change 0454) actually +// applies: adopting the PR head over a recorded branch: git would reject lands +// applied, because recordedBranch refuses the malformed name and the workspace +// gate then skips the branch-keyed inspection instead of failing inside git. +// The workspace seam is the real service, so an inspection of the malformed +// name would reach gitcli and fail there. feat/a:b is the discriminating row — +// only the delegated gitcli predicate rejects it; without the delegation the +// gate inspects it and refuses as workspace-conflict. +func TestIntegrationRecordOpsRepairAdoptPRHeadAppliesOnMalformedRecordedBranch(t *testing.T) { + requireRealGit(t) + for _, recorded := range []string{"feat/a..parent", "feat/a:b"} { + t.Run(recorded, func(t *testing.T) { + recPath := groomPath(3, "widget") + repo := newWorkingRepo(t, map[string]string{recPath: repairRecord(3, "widget", recorded)}) + repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) + + node := planningDepsFor(t, repo.invocation) + svc, err := workspace.NewService(node.deps.Client) + if err != nil { + t.Fatalf("workspace.NewService: %v", err) + } + deps := FinalizeDeps{Planning: node.deps, GitHub: repairGitHub("feat/renamed"), Workspace: svc} + res := RepairIdentity(context.Background(), deps, node.dir, RepairIdentityRequest{ + ID: 3, ExpectVersion: blobVersionAt(t, repo.origin, "docket", recPath), + AdoptPRHead: true, ExpectPRNumber: 7, ExpectHead: "feat/renamed", + }) + if res.Result != ResultApplied || res.Branch != "feat/renamed" { + t.Fatalf("adopt-pr-head over recorded branch %q = %q reason %q branch %q (msg %q, findings %v), want applied feat/renamed", + recorded, res.Result, res.Reason, res.Branch, res.Message, res.Findings) + } + rec, _ := originFile(t, repo.origin, "docket", recPath) + if !strings.Contains(rec, "branch: 'feat/renamed'") { + t.Errorf("repaired record on origin does not carry the adopted branch:\n%s", rec) + } + }) + } +} diff --git a/internal/app/change_repair_test.go b/internal/app/change_repair_test.go index 5fb905438..e01d9ec04 100644 --- a/internal/app/change_repair_test.go +++ b/internal/app/change_repair_test.go @@ -430,84 +430,3 @@ func repairRealRun(t *testing.T, repo *gitRepo, recPath string) RepairIdentityRe AdoptPRHead: true, ExpectPRNumber: 7, ExpectHead: "feat/renamed", }) } - -func TestRepairIdentityUnrelatedInvalidRecordProgress(t *testing.T) { - requireRealGit(t) - recPath := groomPath(3, "widget") - repo := newWorkingRepo(t, map[string]string{ - recPath: repairRecord(3, "widget", ""), - unrelatedBrokenPath: unrelatedBrokenBytes, - }) - repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) - - res := repairRealRun(t, repo, recPath) - if res.Result != ResultApplied || res.Branch != "feat/renamed" { - t.Fatalf("repair-identity beside an unrelated unparseable record = %q reason %q branch %q (findings %v), want applied feat/renamed", - res.Result, res.Reason, res.Branch, res.Findings) - } - rec, _ := originFile(t, repo.origin, "docket", recPath) - if !strings.Contains(rec, "branch: 'feat/renamed'") { - t.Errorf("repaired record on origin does not carry the adopted branch:\n%s", rec) - } - assertUnrelatedBrokenIntact(t, repo) -} - -func TestRepairIdentityUnrelatedInvalidRecordRefusals(t *testing.T) { - requireRealGit(t) - recPath := groomPath(3, "widget") - for _, c := range unrelatedRefusalCases(t, 3, recPath, repairRecord(3, "widget", ""), repairRecord(3, "dupe", "")) { - t.Run(c.name, func(t *testing.T) { - repo := newWorkingRepo(t, c.files) - repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) - tip := originTip(t, repo.origin, "docket") - - res := repairRealRun(t, repo, recPath) - if res.Result == ResultApplied { - t.Fatalf("repair-identity applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, res.Reason, res.Findings) - if got := originTip(t, repo.origin, "docket"); got != tip { - t.Errorf("a refused repair-identity moved the metadata branch %s -> %s", tip, got) - } - }) - } -} - -// TestRepairAdoptPRHeadAppliesOnMalformedRecordedBranch proves the PR-case -// remedy status prints for a branch-malformed record (change 0454) actually -// applies: adopting the PR head over a recorded branch: git would reject lands -// applied, because recordedBranch refuses the malformed name and the workspace -// gate then skips the branch-keyed inspection instead of failing inside git. -// The workspace seam is the real service, so an inspection of the malformed -// name would reach gitcli and fail there. feat/a:b is the discriminating row — -// only the delegated gitcli predicate rejects it; without the delegation the -// gate inspects it and refuses as workspace-conflict. -func TestRepairAdoptPRHeadAppliesOnMalformedRecordedBranch(t *testing.T) { - requireRealGit(t) - for _, recorded := range []string{"feat/a..parent", "feat/a:b"} { - t.Run(recorded, func(t *testing.T) { - recPath := groomPath(3, "widget") - repo := newWorkingRepo(t, map[string]string{recPath: repairRecord(3, "widget", recorded)}) - repo.writerAdvance(t, "feat/renamed", map[string]string{"impl.go": "package impl\n"}) - - node := planningDepsFor(t, repo.invocation) - svc, err := workspace.NewService(node.deps.Client) - if err != nil { - t.Fatalf("workspace.NewService: %v", err) - } - deps := FinalizeDeps{Planning: node.deps, GitHub: repairGitHub("feat/renamed"), Workspace: svc} - res := RepairIdentity(context.Background(), deps, node.dir, RepairIdentityRequest{ - ID: 3, ExpectVersion: blobVersionAt(t, repo.origin, "docket", recPath), - AdoptPRHead: true, ExpectPRNumber: 7, ExpectHead: "feat/renamed", - }) - if res.Result != ResultApplied || res.Branch != "feat/renamed" { - t.Fatalf("adopt-pr-head over recorded branch %q = %q reason %q branch %q (msg %q, findings %v), want applied feat/renamed", - recorded, res.Result, res.Reason, res.Branch, res.Message, res.Findings) - } - rec, _ := originFile(t, repo.origin, "docket", recPath) - if !strings.Contains(rec, "branch: 'feat/renamed'") { - t.Errorf("repaired record on origin does not carry the adopted branch:\n%s", rec) - } - }) - } -} diff --git a/internal/app/claim_proof_git_test.go b/internal/app/claim_proof_git_integration_test.go similarity index 95% rename from internal/app/claim_proof_git_test.go rename to internal/app/claim_proof_git_integration_test.go index c5038b3a7..0528c23dd 100644 --- a/internal/app/claim_proof_git_test.go +++ b/internal/app/claim_proof_git_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -53,10 +55,10 @@ func commitClaimTrailers(t *testing.T, dir, subject string, trailers ...string) return runGit(t, dir, "rev-parse", "HEAD") } -// TestScanClaimProofsReadsCommittedReceipt: a metadata-branch commit carrying +// TestIntegrationRecordOpsScanClaimProofsReadsCommittedReceipt: a metadata-branch commit carrying // the engine's trailer block for a change.claim applied receipt is returned as // one ClaimProof, newest-first, decoding gate_context_hash from the receipt. -func TestScanClaimProofsReadsCommittedReceipt(t *testing.T) { +func TestIntegrationRecordOpsScanClaimProofsReadsCommittedReceipt(t *testing.T) { repo := newGateRepo(t) // Older claim commit: change id 3, ungated (gate_context_hash ""). diff --git a/internal/app/claim_workflow_git_test.go b/internal/app/claim_workflow_git_test.go index f686d5627..772fe79ef 100644 --- a/internal/app/claim_workflow_git_test.go +++ b/internal/app/claim_workflow_git_test.go @@ -15,7 +15,7 @@ import ( // blobVersionAt/originFeatureBranches), and the invocation-clone node builder are // reused from status_git_test.go / planning_git_test.go — this file invents no // third harness. The attach fixtures (attachHappyPlan/attachBacklinkBlock) are -// reused from change_attach_git_test.go. +// reused from change_attach_git_helpers_test.go. // // The concurrency properties these tests pin cannot be faked: an independent // writer must ACTUALLY diverge the contended path on the origin between the diff --git a/internal/app/evidence_ops_integration_test.go b/internal/app/evidence_ops_integration_test.go new file mode 100644 index 000000000..d7e8f6dca --- /dev/null +++ b/internal/app/evidence_ops_integration_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_evidence.sh (prefix ^TestIntegrationEvidence). + +import ( + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// --- record: build-owned gate policy (change 0374) --------------------------- + +// TestIntegrationEvidenceEvidenceRecordBuildGateOffMintsSkipped: build.gate: off is an explicit +// no-gate policy. No run is observed (RunDir empty), the head must still be the +// current feature head, and the block is the truthful skipped record. +func TestIntegrationEvidenceEvidenceRecordBuildGateOffMintsSkipped(t *testing.T) { + res := evidenceRecordWithConfig(t, "build:\n gate: \"off\"\n", + EvidenceRecordRequest{ID: 7, Head: currentFeatureHead(t)}) + if res.Result != ResultApplied { + t.Fatalf("result = %v (%s), want applied", res.Result, res.Reason) + } + if res.Outcome != "skipped" || !strings.Contains(res.Block, "build-gate-off") { + t.Errorf("outcome/block = %q/%q, want skipped/build-gate-off", res.Outcome, res.Block) + } + if res.Command != "" { + t.Errorf("a skipped record carries no command, got %q", res.Command) + } +} + +// TestIntegrationEvidenceEvidenceRecordUnconfiguredBuildCommandIsTypedSetupRefusal: a local build +// gate with no build.test_command is a typed setup refusal that names the +// remedy command — never a fabricated empty command. +func TestIntegrationEvidenceEvidenceRecordUnconfiguredBuildCommandIsTypedSetupRefusal(t *testing.T) { + res := evidenceRecordWithConfig(t, "build:\n gate: local\n", + EvidenceRecordRequest{ID: 7, Head: currentFeatureHead(t), RunDir: testsupport.TempDir(t)}) + if res.Result != ResultUnsupportedConfig || res.Reason != ReasonEvidenceUnconfiguredGate { + t.Fatalf("result/reason = %v/%s, want unsupported-config/%s", res.Result, res.Reason, ReasonEvidenceUnconfiguredGate) + } + if !strings.Contains(res.Message, "docket repository configure-tests") { + t.Errorf("message %q must name the setup remedy", res.Message) + } +} + +// TestIntegrationEvidenceEvidenceRecordRecordsBuildCommandNotFinalize: the divergent-command +// fixture. EvidenceRecord is build-owned, so the recorded command is +// build.test_command — swapping the source to finalize.test_command reddens +// this test (the guard the divergent fixture exists for). +func TestIntegrationEvidenceEvidenceRecordRecordsBuildCommandNotFinalize(t *testing.T) { + res := evidenceRecordPassedRun(t, "build:\n gate: local\n test_command: go test ./build-only\nfinalize:\n test_command: make finalize-only\n") + if res.Result != ResultApplied { + t.Fatalf("result = %v (%s: %s), want applied", res.Result, res.Reason, res.Message) + } + if res.Command != "go test ./build-only" { + t.Errorf("recorded command = %q; evidence must record build.test_command", res.Command) + } +} diff --git a/internal/app/evidence_ops_test.go b/internal/app/evidence_ops_test.go index c6653f3e5..63b416f6a 100644 --- a/internal/app/evidence_ops_test.go +++ b/internal/app/evidence_ops_test.go @@ -5,7 +5,6 @@ import ( "github.com/danielhanold/docket/internal/testsupport" "os" "path/filepath" - "strings" "syscall" "testing" "time" @@ -211,53 +210,6 @@ func runningRunDir(t *testing.T) string { // --- record: every non-passed / mismatched / probe-error path refuses -------- -// --- record: build-owned gate policy (change 0374) --------------------------- - -// TestEvidenceRecordBuildGateOffMintsSkipped: build.gate: off is an explicit -// no-gate policy. No run is observed (RunDir empty), the head must still be the -// current feature head, and the block is the truthful skipped record. -func TestEvidenceRecordBuildGateOffMintsSkipped(t *testing.T) { - res := evidenceRecordWithConfig(t, "build:\n gate: \"off\"\n", - EvidenceRecordRequest{ID: 7, Head: currentFeatureHead(t)}) - if res.Result != ResultApplied { - t.Fatalf("result = %v (%s), want applied", res.Result, res.Reason) - } - if res.Outcome != "skipped" || !strings.Contains(res.Block, "build-gate-off") { - t.Errorf("outcome/block = %q/%q, want skipped/build-gate-off", res.Outcome, res.Block) - } - if res.Command != "" { - t.Errorf("a skipped record carries no command, got %q", res.Command) - } -} - -// TestEvidenceRecordUnconfiguredBuildCommandIsTypedSetupRefusal: a local build -// gate with no build.test_command is a typed setup refusal that names the -// remedy command — never a fabricated empty command. -func TestEvidenceRecordUnconfiguredBuildCommandIsTypedSetupRefusal(t *testing.T) { - res := evidenceRecordWithConfig(t, "build:\n gate: local\n", - EvidenceRecordRequest{ID: 7, Head: currentFeatureHead(t), RunDir: testsupport.TempDir(t)}) - if res.Result != ResultUnsupportedConfig || res.Reason != ReasonEvidenceUnconfiguredGate { - t.Fatalf("result/reason = %v/%s, want unsupported-config/%s", res.Result, res.Reason, ReasonEvidenceUnconfiguredGate) - } - if !strings.Contains(res.Message, "docket repository configure-tests") { - t.Errorf("message %q must name the setup remedy", res.Message) - } -} - -// TestEvidenceRecordRecordsBuildCommandNotFinalize: the divergent-command -// fixture. EvidenceRecord is build-owned, so the recorded command is -// build.test_command — swapping the source to finalize.test_command reddens -// this test (the guard the divergent fixture exists for). -func TestEvidenceRecordRecordsBuildCommandNotFinalize(t *testing.T) { - res := evidenceRecordPassedRun(t, "build:\n gate: local\n test_command: go test ./build-only\nfinalize:\n test_command: make finalize-only\n") - if res.Result != ResultApplied { - t.Fatalf("result = %v (%s: %s), want applied", res.Result, res.Reason, res.Message) - } - if res.Command != "go test ./build-only" { - t.Errorf("recorded command = %q; evidence must record build.test_command", res.Command) - } -} - // --- verify: the head pin is the invalidate-on-fix property ------------------ // TestEvidenceVerifyHeadPin: verify is green for the exact recorded head and red diff --git a/internal/app/evidence_recertify.go b/internal/app/evidence_recertify.go index 20105d8f1..3657ca09f 100644 --- a/internal/app/evidence_recertify.go +++ b/internal/app/evidence_recertify.go @@ -250,7 +250,7 @@ func EvidenceRecertify(ctx context.Context, deps FinalizeDeps, wdeps WorkspaceDe if facts.build.Gate.Value == "off" { // Truthful skipped evidence at the verified current head; no run, and no // PR edit — evidence.Upsert is green-only by design (see - // TestPRPublishAcceptsSkippedEvidenceAtExactHead's note), and this + // TestIntegrationFinalizeOpsPRPublishAcceptsSkippedEvidenceAtExactHead's note), and this // operation preserves evidence rendering. evd := EvidenceRecord(ctx, deps.Planning, wdeps, repoDir, EvidenceRecordRequest{ID: facts.id, Head: facts.head}) if evd.Result != ResultApplied || evd.Block == "" { diff --git a/internal/app/evidence_recertify_test.go b/internal/app/evidence_recertify_integration_test.go similarity index 86% rename from internal/app/evidence_recertify_test.go rename to internal/app/evidence_recertify_integration_test.go index 066dd5a91..96e84ffb3 100644 --- a/internal/app/evidence_recertify_test.go +++ b/internal/app/evidence_recertify_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -16,11 +18,11 @@ import ( // prove only the BUILD command runs). const buildVsFinalizeYAML = "build:\n gate: local\n test_command: go test ./build-only\nfinalize:\n test_command: make finalize-only\n" -// TestBuildLocalGateResolvesBuildCommandOnly: the BUILD-owned production gate +// TestIntegrationEvidenceBuildLocalGateResolvesBuildCommandOnly: the BUILD-owned production gate // resolves build.test_command; the finalize twin resolves finalize.test_command // from the same pin. Deleting the owner branch in buildDriveService reddens one // of the two arms. -func TestBuildLocalGateResolvesBuildCommandOnly(t *testing.T) { +func TestIntegrationEvidenceBuildLocalGateResolvesBuildCommandOnly(t *testing.T) { deps, wdeps, repoDir := evidenceDepsWithConfig(t, readyWorkspace(), buildVsFinalizeYAML) ctx := context.Background() @@ -44,11 +46,11 @@ func commandOf(svc *GateDriveService) string { return svc.command } -// TestBuildLocalGateFailsClosedWithoutBuildCommand: a config with ONLY +// TestIntegrationEvidenceBuildLocalGateFailsClosedWithoutBuildCommand: a config with ONLY // finalize.test_command set fails the build-owned gate closed (ok=false → the // caller halts, never a fabricated red) while the finalize twin still resolves. // This pins the guard's keying on the owner's OWN config key. -func TestBuildLocalGateFailsClosedWithoutBuildCommand(t *testing.T) { +func TestIntegrationEvidenceBuildLocalGateFailsClosedWithoutBuildCommand(t *testing.T) { yaml := "finalize:\n test_command: make finalize-only\n" deps, wdeps, repoDir := evidenceDepsWithConfig(t, readyWorkspace(), yaml) ctx := context.Background() @@ -88,9 +90,9 @@ func recertifyFixture(t *testing.T, gate FinalizeGate) (*rebaseFixture, *fakePub return f, gh, deps, WorkspaceDeps{Service: f.svc} } -// TestEvidenceRecertifyRefusesNotImplemented: any non-implemented status is +// TestIntegrationEvidenceEvidenceRecertifyRefusesNotImplemented: any non-implemented status is // blocked before any probe of the gate or PR edit (acceptance 3). -func TestEvidenceRecertifyRefusesNotImplemented(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesNotImplemented(t *testing.T) { f := setupRebaseFixtureStatus(t, planRepoModes()[0], "in-progress") gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} res := EvidenceRecertify(context.Background(), f.finalizeDeps(gh, &fakeGate{}), WorkspaceDeps{Service: f.svc}, @@ -103,9 +105,9 @@ func TestEvidenceRecertifyRefusesNotImplemented(t *testing.T) { } } -// TestEvidenceRecertifyRefusesDirtyWorkspace: uncommitted work blocks (never +// TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyWorkspace: uncommitted work blocks (never // gated over, never published) — acceptance 3. -func TestEvidenceRecertifyRefusesDirtyWorkspace(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyWorkspace(t *testing.T) { f, gh, deps, wdeps := recertifyFixture(t, &fakeGate{}) writeRepoFile(t, f.wp, "dirty.txt", "uncommitted\n") res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) @@ -117,11 +119,11 @@ func TestEvidenceRecertifyRefusesDirtyWorkspace(t *testing.T) { } } -// TestEvidenceRecertifyRefusesUnpublishedFollowUp: a local follow-up commit +// TestIntegrationEvidenceEvidenceRecertifyRefusesUnpublishedFollowUp: a local follow-up commit // that was never pushed disagrees with the remote feature head; the operation // refuses (publish first through the existing workflow) rather than certify a // head the PR does not hold. -func TestEvidenceRecertifyRefusesUnpublishedFollowUp(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesUnpublishedFollowUp(t *testing.T) { f, gh, deps, wdeps := recertifyFixture(t, &fakeGate{}) writeRepoFile(t, f.wp, "followup.txt", "review fix\n") runGit(t, f.wp, "add", "-A") @@ -135,10 +137,10 @@ func TestEvidenceRecertifyRefusesUnpublishedFollowUp(t *testing.T) { } } -// TestEvidenceRecertifyRefusesClosedOrMismatchedPR: no open PR for the feature +// TestIntegrationEvidenceEvidenceRecertifyRefusesClosedOrMismatchedPR: no open PR for the feature // head refuses (pr-not-open); an open PR naming a different head refuses // (head-disagreement). Neither runs the gate — acceptance 3. -func TestEvidenceRecertifyRefusesClosedOrMismatchedPR(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesClosedOrMismatchedPR(t *testing.T) { // Closed: the fake returns no open PR when State is not open. f, gh, deps, wdeps := recertifyFixture(t, &fakeGate{}) gh.pr.State = githubcli.StateClosed @@ -161,9 +163,9 @@ func TestEvidenceRecertifyRefusesClosedOrMismatchedPR(t *testing.T) { } } -// TestEvidenceRecertifyShape: a non-positive id is an invalid-input shape +// TestIntegrationEvidenceEvidenceRecertifyShape: a non-positive id is an invalid-input shape // refusal before any probe. -func TestEvidenceRecertifyShape(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyShape(t *testing.T) { f, _, deps, wdeps := recertifyFixture(t, &fakeGate{}) res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: 0}) if res.Result != ResultInvalidInput { @@ -171,10 +173,10 @@ func TestEvidenceRecertifyShape(t *testing.T) { } } -// TestEvidenceRecertifyHappyPath: stale evidence at an older head becomes +// TestIntegrationEvidenceEvidenceRecertifyHappyPath: stale evidence at an older head becomes // verified evidence for the exact current head on the SAME open PR; authored // body bytes survive; the result is applied/green (acceptance 1). -func TestEvidenceRecertifyHappyPath(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyHappyPath(t *testing.T) { gate := &fakeGate{} f, gh, deps, wdeps := recertifyFixture(t, gate) gate.result = LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenBlockFor(t, f.head), RunDir: "/run/x"} @@ -201,11 +203,11 @@ func TestEvidenceRecertifyHappyPath(t *testing.T) { } } -// TestEvidenceRecertifyAdvancesOneDriveAcrossWaiting: WAITING is nonterminal — +// TestIntegrationEvidenceEvidenceRecertifyAdvancesOneDriveAcrossWaiting: WAITING is nonterminal — // the operation re-enters the SAME drive (continuation threaded) until a // terminal, and only then publishes. Deleting the loop's continuation // threading reddens the continuation asserts. -func TestEvidenceRecertifyAdvancesOneDriveAcrossWaiting(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyAdvancesOneDriveAcrossWaiting(t *testing.T) { f0 := setupRebaseFixture(t, planRepoModes()[0]) gate := &seqGate{results: []LocalGateResult{ {Outcome: FinalizeGateWaiting, Continuation: GateContinuation{DriveID: "d1", Generation: "g1"}}, @@ -223,10 +225,10 @@ func TestEvidenceRecertifyAdvancesOneDriveAcrossWaiting(t *testing.T) { } } -// TestEvidenceRecertifyGateFailureAndHalt: a red suite is gate-failed (repair +// TestIntegrationEvidenceEvidenceRecertifyGateFailureAndHalt: a red suite is gate-failed (repair // work) and a halt is blocked — neither touches the PR (acceptance 3), and a // WAITING with no continuation fails closed instead of spinning. -func TestEvidenceRecertifyGateFailureAndHalt(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyGateFailureAndHalt(t *testing.T) { cases := []struct { name string result LocalGateResult @@ -254,11 +256,11 @@ func TestEvidenceRecertifyGateFailureAndHalt(t *testing.T) { } } -// TestEvidenceRecertifyGateOffRecordsSkipped: build.gate off mints truthful +// TestIntegrationEvidenceEvidenceRecertifyGateOffRecordsSkipped: build.gate off mints truthful // skipped evidence and completes as skipped WITHOUT editing the PR block — // evidence.Upsert is green-only by design and this change preserves evidence // rendering (acceptance 2). -func TestEvidenceRecertifyGateOffRecordsSkipped(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyGateOffRecordsSkipped(t *testing.T) { gate := &fakeGate{} f, gh, deps, wdeps := recertifyFixture(t, gate) // Config resolves from the pinned default-branch tip (origin/main), never the @@ -280,9 +282,9 @@ func TestEvidenceRecertifyGateOffRecordsSkipped(t *testing.T) { } } -// TestEvidenceRecertifyRefusesUnconfiguredGate: a local build gate with no +// TestIntegrationEvidenceEvidenceRecertifyRefusesUnconfiguredGate: a local build gate with no // build.test_command refuses; no suite, no PR edit (acceptance 2). -func TestEvidenceRecertifyRefusesUnconfiguredGate(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesUnconfiguredGate(t *testing.T) { gate := &fakeGate{} f, gh, deps, wdeps := recertifyFixture(t, gate) // Config resolves from the pinned default-branch tip (origin/main), never the @@ -315,10 +317,10 @@ func (g *movingGate) RunLocalGate(context.Context, LocalGateRequest) (LocalGateR return LocalGateResult{Outcome: FinalizeGatePassed, Evidence: g.ev, RunDir: "/run/x"}, nil } -// TestEvidenceRecertifyRefusesHeadMovedUnderGate: a HEAD that moved between +// TestIntegrationEvidenceEvidenceRecertifyRefusesHeadMovedUnderGate: a HEAD that moved between // the gate and the publish can never publish (acceptance 3). The recheck's // local-vs-remote leg catches it (the late commit is unpublished). -func TestEvidenceRecertifyRefusesHeadMovedUnderGate(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesHeadMovedUnderGate(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gate := &movingGate{f: f, ev: greenBlockFor(t, f.head)} @@ -335,10 +337,10 @@ func TestEvidenceRecertifyRefusesHeadMovedUnderGate(t *testing.T) { } } -// TestEvidenceRecertifyRefusesForeignCommandEvidence: evidence recording a +// TestIntegrationEvidenceEvidenceRecertifyRefusesForeignCommandEvidence: evidence recording a // command other than the currently resolved build.test_command cannot publish — // the changed-configuration face of acceptance 3. -func TestEvidenceRecertifyRefusesForeignCommandEvidence(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesForeignCommandEvidence(t *testing.T) { gate := &fakeGate{} f, gh, deps, wdeps := recertifyFixture(t, gate) foreign, err := evidence.NewRecord("make other-suite", f.head, time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)) @@ -355,9 +357,9 @@ func TestEvidenceRecertifyRefusesForeignCommandEvidence(t *testing.T) { } } -// TestEvidenceRecertifyRefusesWrongHeadEvidence: gate evidence naming another +// TestIntegrationEvidenceEvidenceRecertifyRefusesWrongHeadEvidence: gate evidence naming another // head is stale at verification and never published. -func TestEvidenceRecertifyRefusesWrongHeadEvidence(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesWrongHeadEvidence(t *testing.T) { gate := &fakeGate{} f, gh, deps, wdeps := recertifyFixture(t, gate) gate.result = LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenBlockFor(t, f.baseTip), RunDir: "/run/x"} @@ -399,12 +401,12 @@ func (f *dispositionEnsureGitHub) EnsurePullRequest(_ context.Context, req githu return githubcli.EnsureResult{Disposition: f.disp}, nil } -// TestEvidenceRecertifyEditContended: a PASSED gate whose PR edit comes back +// TestIntegrationEvidenceEvidenceRecertifyEditContended: a PASSED gate whose PR edit comes back // EnsureContended (the PR diverged under the update) is refused as // contended/pr-edit-contended and reports NO completion — the contended arm of // publishRecertifiedEvidence's disposition mapping. Swapping the mapped result // to applied/green reddens the assert. -func TestEvidenceRecertifyEditContended(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyEditContended(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) inner := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gh := &dispositionEnsureGitHub{fakePublishGitHub: inner, disp: githubcli.EnsureContended} @@ -422,12 +424,12 @@ func TestEvidenceRecertifyEditContended(t *testing.T) { } } -// TestEvidenceRecertifyEditUnrecognizedDisposition: a PASSED gate whose PR edit +// TestIntegrationEvidenceEvidenceRecertifyEditUnrecognizedDisposition: a PASSED gate whose PR edit // returns an unrecognized (zero-value) disposition falls to the mapping's // default arm — internal-error/status-internal-error — and reports NO // completion. Deleting the default arm (so it fell through to applied) reddens // the assert. -func TestEvidenceRecertifyEditUnrecognizedDisposition(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyEditUnrecognizedDisposition(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) inner := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gh := &dispositionEnsureGitHub{fakePublishGitHub: inner, disp: githubcli.EnsureDisposition("")} @@ -455,12 +457,12 @@ func (g *dirtyingGate) RunLocalGate(context.Context, LocalGateRequest) (LocalGat return LocalGateResult{Outcome: FinalizeGatePassed, Evidence: g.ev, RunDir: "/run/x"}, nil } -// TestEvidenceRecertifyRefusesDirtyAfterGate: an untracked, non-ignored file the +// TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyAfterGate: an untracked, non-ignored file the // build command leaves in the worktree during a PASSED gate flips the // pre-publish cleanliness recheck to workspace-dirty and refuses to publish; the // PR is never edited. Pins the whole-predicate recheck's clean-worktree leg // (documented as the clean-worktree caveat in the guide). -func TestEvidenceRecertifyRefusesDirtyAfterGate(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyAfterGate(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gate := &dirtyingGate{f: f, ev: greenBlockFor(t, f.head)} @@ -474,10 +476,10 @@ func TestEvidenceRecertifyRefusesDirtyAfterGate(t *testing.T) { } } -// TestEvidenceRecertifyEditFailureThenRetry: an uncertain PR edit is NOT +// TestIntegrationEvidenceEvidenceRecertifyEditFailureThenRetry: an uncertain PR edit is NOT // completion; a later invocation converges the SAME PR and preserves authored // content (acceptance 4). -func TestEvidenceRecertifyEditFailureThenRetry(t *testing.T) { +func TestIntegrationEvidenceEvidenceRecertifyEditFailureThenRetry(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) inner := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gh := &flakyEnsureGitHub{fakePublishGitHub: inner, unknowns: 1} diff --git a/internal/app/finalize_block_integration_test.go b/internal/app/finalize_block_integration_test.go new file mode 100644 index 000000000..9465e2da3 --- /dev/null +++ b/internal/app/finalize_block_integration_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_finalizeops.sh (prefix ^TestIntegrationFinalizeOps). + +import ( + "context" + "testing" + + "github.com/danielhanold/docket/internal/githubcli" +) + +func TestIntegrationFinalizeOpsFinalizeBlockUnrelatedInvalidRecordProgress(t *testing.T) { + f := setupRebaseFixtureStatus(t, planRepoModeDocket(), "in-progress") + f.repo.writerAdvance(t, f.branch, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) + gh := &fakeBlockGitHub{repo: retargetRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.test/c/9"} + + got := FinalizeBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, blockTestRequest(f)) + if got.Result != ResultApplied || got.Disposition != BlockDispRecorded { + t.Fatalf("finalize block beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied recorded", + got.Result, got.Disposition, got.Reason, got.Findings) + } + assertUnrelatedBrokenIntact(t, f.repo) +} + +func TestIntegrationFinalizeOpsFinalizeClearBlockUnrelatedInvalidRecordProgress(t *testing.T) { + f := setupBlockedFixture(t, planRepoModeDocket()) + f.repo.writerAdvance(t, f.branch, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) + gh := &fakeBlockGitHub{repo: retargetRepo(), + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} + + got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, + ClearBlockRequest{ID: f.id, Version: f.version, Head: f.head, PRNumber: 1}) + if got.Result != ResultApplied || got.Disposition != BlockDispCleared { + t.Fatalf("finalize clear-block beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied cleared", + got.Result, got.Disposition, got.Reason, got.Findings) + } + assertUnrelatedBrokenIntact(t, f.repo) +} + +func TestIntegrationFinalizeOpsFinalizeBlockUnrelatedInvalidRecordRefusals(t *testing.T) { + id, slug := rebaseFixtureID, rebaseFixtureSlug + recPath := groomPath(id, slug) + cases := unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, slug, "in-progress"), lifecycleChange(id, "dupe", "in-progress")) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := setupRebaseFixtureStatus(t, planRepoModeDocket(), "in-progress") + f.repo.writerAdvance(t, f.branch, c.files) + f.version = blobVersionAt(t, f.repo.origin, f.branch, recPath) + tip := originTip(t, f.repo.origin, f.branch) + gh := &fakeBlockGitHub{repo: retargetRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.test/c/9"} + + got := FinalizeBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, blockTestRequest(f)) + if got.Result == ResultApplied { + t.Fatalf("finalize block applied despite %s; want a refusal", c.name) + } + assertRefusalBeyondUnrelated(t, got.Reason, got.Findings) + if after := originTip(t, f.repo.origin, f.branch); after != tip { + t.Errorf("a refused finalize block moved the metadata branch %s -> %s", tip, after) + } + }) + } +} diff --git a/internal/app/finalize_block_test.go b/internal/app/finalize_block_test.go index d9045d50f..d0b84763f 100644 --- a/internal/app/finalize_block_test.go +++ b/internal/app/finalize_block_test.go @@ -209,55 +209,3 @@ func blockTestRequest(f *rebaseFixture) BlockRequest { return BlockRequest{ID: f.id, Version: f.version, PRNumber: 7, Attempt: "att1", Reason: "gate-repair-required", Head: f.head, Report: "The gate failed.\n", Remedy: "Fix and retry.\n"} } - -func TestFinalizeBlockUnrelatedInvalidRecordProgress(t *testing.T) { - f := setupRebaseFixtureStatus(t, planRepoModeDocket(), "in-progress") - f.repo.writerAdvance(t, f.branch, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) - gh := &fakeBlockGitHub{repo: retargetRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.test/c/9"} - - got := FinalizeBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, blockTestRequest(f)) - if got.Result != ResultApplied || got.Disposition != BlockDispRecorded { - t.Fatalf("finalize block beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied recorded", - got.Result, got.Disposition, got.Reason, got.Findings) - } - assertUnrelatedBrokenIntact(t, f.repo) -} - -func TestFinalizeClearBlockUnrelatedInvalidRecordProgress(t *testing.T) { - f := setupBlockedFixture(t, planRepoModeDocket()) - f.repo.writerAdvance(t, f.branch, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) - gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} - - got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, - ClearBlockRequest{ID: f.id, Version: f.version, Head: f.head, PRNumber: 1}) - if got.Result != ResultApplied || got.Disposition != BlockDispCleared { - t.Fatalf("finalize clear-block beside an unrelated unparseable record = %q disp %q reason %q (findings %v), want applied cleared", - got.Result, got.Disposition, got.Reason, got.Findings) - } - assertUnrelatedBrokenIntact(t, f.repo) -} - -func TestFinalizeBlockUnrelatedInvalidRecordRefusals(t *testing.T) { - id, slug := rebaseFixtureID, rebaseFixtureSlug - recPath := groomPath(id, slug) - cases := unrelatedRefusalCases(t, id, recPath, lifecycleChange(id, slug, "in-progress"), lifecycleChange(id, "dupe", "in-progress")) - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - f := setupRebaseFixtureStatus(t, planRepoModeDocket(), "in-progress") - f.repo.writerAdvance(t, f.branch, c.files) - f.version = blobVersionAt(t, f.repo.origin, f.branch, recPath) - tip := originTip(t, f.repo.origin, f.branch) - gh := &fakeBlockGitHub{repo: retargetRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.test/c/9"} - - got := FinalizeBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, blockTestRequest(f)) - if got.Result == ResultApplied { - t.Fatalf("finalize block applied despite %s; want a refusal", c.name) - } - assertRefusalBeyondUnrelated(t, got.Reason, got.Findings) - if after := originTip(t, f.repo.origin, f.branch); after != tip { - t.Errorf("a refused finalize block moved the metadata branch %s -> %s", tip, after) - } - }) - } -} diff --git a/internal/app/finalize_publish_test.go b/internal/app/finalize_publish_helpers_test.go similarity index 58% rename from internal/app/finalize_publish_test.go rename to internal/app/finalize_publish_helpers_test.go index 5bd158aec..cecca591b 100644 --- a/internal/app/finalize_publish_test.go +++ b/internal/app/finalize_publish_helpers_test.go @@ -1,23 +1,20 @@ package app +// Change 0465: the finalize publish fixtures and fakes stay in the default build — +// the untagged finalize_git_test.go still uses them — +// while the real-git finalize publish tests moved behind the integration tag +// (finalize_publish_integration_test.go). + import ( "context" "errors" - "github.com/danielhanold/docket/internal/evidence" - "github.com/danielhanold/docket/internal/githubcli" "strings" "testing" "time" -) -// This file drives `finalize publish` over a REAL feature workspace (the same -// bare-remote topology, gitcli.Client, and workspace.Service the rebase tests -// use, including its owned rebase receipt and its receipt-scoped PublishRewrite) -// plus a faithful-enough fake FinalizeGitHub that also implements the PR -// create-or-edit face. The receipt-scoped force-with-lease push and the -// loss-preserving PR body update are only meaningful against real Git and a real -// receipt, so nothing about the rewrite publication is stubbed; only the GitHub -// PR effect a hermetic suite cannot reach is injected. + "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/githubcli" +) // --- fake FinalizeGitHub + PR editor -------------------------------------- @@ -189,102 +186,3 @@ func (f *publishFixture) openPRForPublish(head, body string) githubcli.PullReque } // --- TestFinalizePublishOrder --------------------------------------------- - -// --- TestFinalizePublishCrashReplay --------------------------------------- - -// --- TestFinalizePublishUnknownStops -------------------------------------- - -// --- TestFinalizePublishRefusesForeignAttempt ----------------------------- - -// --- TestFinalizePublishShapeAndEvidenceRefusals -------------------------- - -// TestFinalizePublishAcceptsSkippedEvidence: a build.gate: off repository's -// truthful skipped evidence certifying the exact rewritten head passes -// FinalizePublish's evidence conjunct — the operation proceeds PAST it -// (VerdictSkipped is accepted exactly as VerdictVerified). Reverting the -// green-or-skipped acceptance would refuse here with ReasonPublishEvidenceUnverified, -// so this pins the verify-site change. (PR-body weaving of a skipped block is a -// separate concern: evidence.Upsert is green-only today, so the operation still -// fails later at body assembly — see the change notes.) -func TestFinalizePublishAcceptsSkippedEvidence(t *testing.T) { - requireRealGit(t) - f := setupPublishFixture(t, planRepoModes()[0]) - // Land the push out of band so publish resumes only the PR update, exactly as - // the green after-push replay case does. - runGit(t, f.wp, "push", "--force", "-q", "origin", "HEAD:refs/heads/feat/"+f.slug) - if tip := f.remoteFeatureTip(t); tip != f.rewritten { - t.Fatalf("precondition: remote tip = %q, want the rewritten head", tip) - } - skipped, err := evidence.NewSkippedRecord(f.rewritten, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) - if err != nil { - t.Fatalf("NewSkippedRecord: %v", err) - } - gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.openPRForPublish(f.rewritten, authoredPRBody(t, f.origHead))} - res := FinalizePublish(context.Background(), f.publishDeps(gh), f.repo.invocation, - FinalizePublishRequest{ID: f.id, Attempt: f.attempt, Head: f.rewritten, EvidenceRecord: []byte(evidence.Render(skipped))}) - if res.Reason == ReasonPublishEvidenceUnverified { - t.Fatalf("skipped evidence at the exact head was refused at the evidence conjunct: %q", res.Message) - } -} - -// TestFinalizePublishAfterCheckpointResume proves the reuse path end to end: -// a completed rewrite whose PASSED gate recorded a publish checkpoint, a -// denied publish (nothing pushed, PR untouched), a finalize resume that reuses -// the checkpoint WITHOUT re-running the suite, and a FinalizePublish driven by -// the reused evidence that lands the rewritten head under the exact lease and -// converges the PR body while preserving every authored byte outside the -// managed evidence block. -func TestFinalizePublishAfterCheckpointResume(t *testing.T) { - requireRealGit(t) - f := setupRebaseFixture(t, planRepoModes()[0]) - f.advanceBase(t) - rebaseGH := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} - gate := &headEvidenceGate{t: t} - deps := f.finalizeDeps(rebaseGH, gate) - - // The rebase completes, the gate passes, the checkpoint is recorded — and - // then the publish is denied: no push happens, the remote and PR still hold - // the original head. - first := FinalizeRebase(context.Background(), deps, f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if first.Disposition != RebaseDispRebased || gate.calls != 1 { - t.Fatalf("setup rebase = disp %q calls %d, want rebased/1", first.Disposition, gate.calls) - } - rewritten := f.localHead() - - // The resume reuses the checkpoint: skipped compose, evidence returned, no - // second suite run. - resume := FinalizeRebase(context.Background(), deps, f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if resume.Gate == nil || resume.Gate.Compose != gateComposeSkipped || gate.calls != 1 { - t.Fatalf("resume gate = %+v calls %d, want skipped with no re-run", resume.Gate, gate.calls) - } - - // The reused evidence drives finalize publish, exactly as the skill would. - authored := "## Summary\n\nAuthored intro prose.\n\nAuthored outro prose.\n" - pubGH := &fakePublishGitHub{repo: retargetRepo(), pr: githubcli.PullRequest{ - Number: 1, URL: "https://example.test/pr/1", State: githubcli.StateOpen, - HeadBranch: "feat/" + f.slug, HeadCommit: rewritten, BaseBranch: "main", - Title: "Add the widget", Body: authored, Version: "sha256:" + strings.Repeat("d", 64), - }} - pres := FinalizePublish(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: pubGH, Workspace: f.svc}, - f.repo.invocation, FinalizePublishRequest{ - ID: f.id, Attempt: resume.Attempt, Head: rewritten, - EvidenceRecord: []byte(resume.Gate.Evidence), - }) - if pres.Result != ResultApplied || pres.Disposition != PublishDispPublished { - t.Fatalf("publish = %q disp %q (reason %q msg %q), want applied/published", pres.Result, pres.Disposition, pres.Reason, pres.Message) - } - if tip := runGit(t, f.repo.origin, "rev-parse", "refs/heads/feat/"+f.slug); tip != rewritten { - t.Errorf("origin feature tip = %q, want the rewritten head %q", tip, rewritten) - } - // The authored bytes survived; the managed block certifies the rewritten head. - body := pubGH.lastEnsuredBody() - if !strings.Contains(body, "Authored intro prose.") || !strings.Contains(body, "Authored outro prose.") { - t.Errorf("authored PR-body bytes were not preserved:\n%s", body) - } - got, err := evidence.Extract([]byte(body)) - if err != nil || got.Head != rewritten { - t.Errorf("converged evidence head = %q err=%v, want %q", got.Head, err, rewritten) - } -} diff --git a/internal/app/finalize_publish_integration_test.go b/internal/app/finalize_publish_integration_test.go new file mode 100644 index 000000000..11465d0b5 --- /dev/null +++ b/internal/app/finalize_publish_integration_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package app + +import ( + "context" + "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/githubcli" + "strings" + "testing" + "time" +) + +// This file drives `finalize publish` over a REAL feature workspace (the same +// bare-remote topology, gitcli.Client, and workspace.Service the rebase tests +// use, including its owned rebase receipt and its receipt-scoped PublishRewrite) +// plus a faithful-enough fake FinalizeGitHub that also implements the PR +// create-or-edit face. The receipt-scoped force-with-lease push and the +// loss-preserving PR body update are only meaningful against real Git and a real +// receipt, so nothing about the rewrite publication is stubbed; only the GitHub +// PR effect a hermetic suite cannot reach is injected. + +// --- TestFinalizePublishCrashReplay --------------------------------------- + +// --- TestFinalizePublishUnknownStops -------------------------------------- + +// --- TestFinalizePublishRefusesForeignAttempt ----------------------------- + +// --- TestFinalizePublishShapeAndEvidenceRefusals -------------------------- + +// TestIntegrationFinalizeOpsFinalizePublishAcceptsSkippedEvidence: a build.gate: off repository's +// truthful skipped evidence certifying the exact rewritten head passes +// FinalizePublish's evidence conjunct — the operation proceeds PAST it +// (VerdictSkipped is accepted exactly as VerdictVerified). Reverting the +// green-or-skipped acceptance would refuse here with ReasonPublishEvidenceUnverified, +// so this pins the verify-site change. (PR-body weaving of a skipped block is a +// separate concern: evidence.Upsert is green-only today, so the operation still +// fails later at body assembly — see the change notes.) +func TestIntegrationFinalizeOpsFinalizePublishAcceptsSkippedEvidence(t *testing.T) { + requireRealGit(t) + f := setupPublishFixture(t, planRepoModes()[0]) + // Land the push out of band so publish resumes only the PR update, exactly as + // the green after-push replay case does. + runGit(t, f.wp, "push", "--force", "-q", "origin", "HEAD:refs/heads/feat/"+f.slug) + if tip := f.remoteFeatureTip(t); tip != f.rewritten { + t.Fatalf("precondition: remote tip = %q, want the rewritten head", tip) + } + skipped, err := evidence.NewSkippedRecord(f.rewritten, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + if err != nil { + t.Fatalf("NewSkippedRecord: %v", err) + } + gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.openPRForPublish(f.rewritten, authoredPRBody(t, f.origHead))} + res := FinalizePublish(context.Background(), f.publishDeps(gh), f.repo.invocation, + FinalizePublishRequest{ID: f.id, Attempt: f.attempt, Head: f.rewritten, EvidenceRecord: []byte(evidence.Render(skipped))}) + if res.Reason == ReasonPublishEvidenceUnverified { + t.Fatalf("skipped evidence at the exact head was refused at the evidence conjunct: %q", res.Message) + } +} + +// TestIntegrationFinalizeOpsFinalizePublishAfterCheckpointResume proves the reuse path end to end: +// a completed rewrite whose PASSED gate recorded a publish checkpoint, a +// denied publish (nothing pushed, PR untouched), a finalize resume that reuses +// the checkpoint WITHOUT re-running the suite, and a FinalizePublish driven by +// the reused evidence that lands the rewritten head under the exact lease and +// converges the PR body while preserving every authored byte outside the +// managed evidence block. +func TestIntegrationFinalizeOpsFinalizePublishAfterCheckpointResume(t *testing.T) { + requireRealGit(t) + f := setupRebaseFixture(t, planRepoModes()[0]) + f.advanceBase(t) + rebaseGH := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} + gate := &headEvidenceGate{t: t} + deps := f.finalizeDeps(rebaseGH, gate) + + // The rebase completes, the gate passes, the checkpoint is recorded — and + // then the publish is denied: no push happens, the remote and PR still hold + // the original head. + first := FinalizeRebase(context.Background(), deps, f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if first.Disposition != RebaseDispRebased || gate.calls != 1 { + t.Fatalf("setup rebase = disp %q calls %d, want rebased/1", first.Disposition, gate.calls) + } + rewritten := f.localHead() + + // The resume reuses the checkpoint: skipped compose, evidence returned, no + // second suite run. + resume := FinalizeRebase(context.Background(), deps, f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if resume.Gate == nil || resume.Gate.Compose != gateComposeSkipped || gate.calls != 1 { + t.Fatalf("resume gate = %+v calls %d, want skipped with no re-run", resume.Gate, gate.calls) + } + + // The reused evidence drives finalize publish, exactly as the skill would. + authored := "## Summary\n\nAuthored intro prose.\n\nAuthored outro prose.\n" + pubGH := &fakePublishGitHub{repo: retargetRepo(), pr: githubcli.PullRequest{ + Number: 1, URL: "https://example.test/pr/1", State: githubcli.StateOpen, + HeadBranch: "feat/" + f.slug, HeadCommit: rewritten, BaseBranch: "main", + Title: "Add the widget", Body: authored, Version: "sha256:" + strings.Repeat("d", 64), + }} + pres := FinalizePublish(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: pubGH, Workspace: f.svc}, + f.repo.invocation, FinalizePublishRequest{ + ID: f.id, Attempt: resume.Attempt, Head: rewritten, + EvidenceRecord: []byte(resume.Gate.Evidence), + }) + if pres.Result != ResultApplied || pres.Disposition != PublishDispPublished { + t.Fatalf("publish = %q disp %q (reason %q msg %q), want applied/published", pres.Result, pres.Disposition, pres.Reason, pres.Message) + } + if tip := runGit(t, f.repo.origin, "rev-parse", "refs/heads/feat/"+f.slug); tip != rewritten { + t.Errorf("origin feature tip = %q, want the rewritten head %q", tip, rewritten) + } + // The authored bytes survived; the managed block certifies the rewritten head. + body := pubGH.lastEnsuredBody() + if !strings.Contains(body, "Authored intro prose.") || !strings.Contains(body, "Authored outro prose.") { + t.Errorf("authored PR-body bytes were not preserved:\n%s", body) + } + got, err := evidence.Extract([]byte(body)) + if err != nil || got.Head != rewritten { + t.Errorf("converged evidence head = %q err=%v, want %q", got.Head, err, rewritten) + } +} diff --git a/internal/app/finalize_rebase_ops_integration_test.go b/internal/app/finalize_rebase_ops_integration_test.go new file mode 100644 index 000000000..c446a68d7 --- /dev/null +++ b/internal/app/finalize_rebase_ops_integration_test.go @@ -0,0 +1,781 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_finalizerebaseops.sh (prefix ^TestIntegrationFinalizeRebaseOps). + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/workspace" +) + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetSnapshot proves a fresh owned rebase snapshots +// the RESOLVED finalize.resolver_max_attempts into the receipt as a versioned +// budget group (version "1", the resolved non-default limit, used 0, no +// outstanding reservation), and that the conflicted result surfaces the counts in +// both its JSON document and its human text. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetSnapshot(t *testing.T) { + f, res, _ := beginConflictedWithLimit(t, 2) + + // The receipt carries the versioned budget group at the resolved non-default. + rec, present, err := f.svc.ReadRebaseReceipt(context.Background(), f.metaDir) + if err != nil || !present { + t.Fatalf("receipt after fresh rebase: present=%v err=%v", present, err) + } + if rec.ResolverBudgetVersion != "1" || rec.ResolverLimit != "2" || rec.ResolverUsed != "0" { + t.Fatalf("receipt budget = ver %q limit %q used %q, want 1/2/0 (the resolved non-default 2, not the built-in 3)", + rec.ResolverBudgetVersion, rec.ResolverLimit, rec.ResolverUsed) + } + if rec.ResolverReservationToken != "" || rec.ResolverReservationStopped != "" || rec.ResolverContinuationStarted != "" { + t.Errorf("fresh receipt carried a reservation: token %q stopped %q cont %q", + rec.ResolverReservationToken, rec.ResolverReservationStopped, rec.ResolverContinuationStarted) + } + + // The conflicted result carries the counts (2/0/2). + if res.ResolverLimit != 2 || res.ResolverUsed != 0 || res.ResolverRemaining != 2 { + t.Fatalf("result counts = %d/%d/%d, want 2/0/2", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) + } + buf, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var doc struct { + ResolverLimit int `json:"resolver_limit"` + ResolverRemaining int `json:"resolver_remaining"` + } + if err := json.Unmarshal(buf, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.ResolverLimit != 2 || doc.ResolverRemaining != 2 { + t.Errorf("JSON counts = limit %d remaining %d, want 2/2 (raw %s)", doc.ResolverLimit, doc.ResolverRemaining, buf) + } + if !strings.Contains(string(buf), `"resolver_limit":2`) || !strings.Contains(string(buf), `"resolver_remaining":2`) { + t.Errorf("JSON document missing resolver counts: %s", buf) + } + if h := res.HumanText(); !strings.Contains(h, "0/2") || !strings.Contains(h, "2 remaining") { + t.Errorf("HumanText does not mention the resolver counts: %q", h) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetRecoveryNoResnapshot proves recoverFromReceipt +// adopts the receipt's stored budget and NEVER re-snapshots the current config: a +// mid-attempt config change (2 -> 5) does not apply to an owned attempt. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetRecoveryNoResnapshot(t *testing.T) { + f, first, deps := beginConflictedWithLimit(t, 2) + if first.ResolverLimit != 2 { + t.Fatalf("fresh conflicted limit = %d, want 2", first.ResolverLimit) + } + // The operator raises the cap mid-attempt; the owned attempt must ignore it. + setResolverConfig(t, f, 5) + second := FinalizeRebase(context.Background(), deps, f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if second.Disposition != RebaseDispConflicted { + t.Fatalf("recovery = disp %q (reason %q), want conflicted", second.Disposition, second.Reason) + } + if second.ResolverLimit != 2 { + t.Fatalf("recovery reported limit %d; the mid-attempt config change to 5 must NOT apply — want the receipt's 2", second.ResolverLimit) + } + rec, _, _ := f.svc.ReadRebaseReceipt(context.Background(), f.metaDir) + if rec.ResolverLimit != "2" || rec.ResolverUsed != "0" { + t.Errorf("recovery re-snapshotted the receipt budget to limit %q used %q; want the owned 2/0", rec.ResolverLimit, rec.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetWaitingReloadsForward proves the WAITING +// gate-continuation write copies the resolver-budget group forward from the +// freshly reloaded on-disk receipt, not from a stale in-memory copy. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetWaitingReloadsForward(t *testing.T) { + f, gh, real := completedBudgetedReceipt(t, func(*workspace.RebaseReceipt) {}) // no gate pair + ctx := context.Background() + + // Stale = the budget rolled back to its pre-reserve state; the gate pair is + // empty so the recovery composes the gate afresh. + stale := real + stale.ResolverUsed = "0" + stale.ResolverReservationToken = "" + stale.ResolverReservationStopped = "" + wrap := &staleFirstReadWorkspace{FinalizeWorkspace: f.svc, stale: stale} + deps := FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: wrap, + Gate: &fakeGate{result: LocalGateResult{Outcome: FinalizeGateWaiting, Continuation: GateContinuation{DriveID: "drive-1", Generation: "gen-1"}}}} + + res := FinalizeRebase(ctx, deps, f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if res.Disposition != RebaseDispWaiting { + t.Fatalf("waiting slice = %q (reason %q msg %q), want waiting", res.Disposition, res.Reason, res.Message) + } + after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) + if err != nil || !present { + t.Fatalf("receipt after WAITING: present=%v err=%v", present, err) + } + if after.GateDriveID != "drive-1" || after.GateOwnerGeneration != "gen-1" { + t.Fatalf("WAITING did not set the gate pair: %q/%q", after.GateDriveID, after.GateOwnerGeneration) + } + assertResolverFieldsEqual(t, "after WAITING set", real, after) +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetClearReloadsForward proves the terminal +// gate-continuation clear copies the resolver-budget group forward from the +// freshly reloaded on-disk receipt, not from a stale in-memory copy. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseResolverBudgetClearReloadsForward(t *testing.T) { + f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { + // A recorded WAITING drive so the recovery advances and then CLEARS it. + r.PublishCheckpointHead, r.PublishCheckpointBaseHead = "", "" + r.PublishCheckpointCommand, r.PublishCheckpointGate = "", "" + r.PublishCheckpointPRNumber, r.PublishCheckpointEvidence = "", "" + r.GateDriveID = "drive-9" + r.GateOwnerGeneration = "gen-9" + }) + ctx := context.Background() + + // Stale = the budget rolled back to its pre-reserve state; the gate pair is + // retained so composeLocalGate advances the recorded drive to its terminal. + stale := real + stale.ResolverUsed = "0" + stale.ResolverReservationToken = "" + stale.ResolverReservationStopped = "" + wrap := &staleFirstReadWorkspace{FinalizeWorkspace: f.svc, stale: stale} + deps := FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: wrap, + Gate: &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}} + + res := FinalizeRebase(ctx, deps, f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if res.Result != ResultApplied || res.Gate == nil || res.Gate.Evidence == "" { + t.Fatalf("passed slice = %q gate %+v (reason %q), want applied with evidence", res.Result, res.Gate, res.Reason) + } + after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) + if err != nil || !present { + t.Fatalf("receipt after terminal: present=%v err=%v", present, err) + } + if after.GateDriveID != "" || after.GateOwnerGeneration != "" { + t.Fatalf("terminal did not clear the gate pair: %q/%q", after.GateDriveID, after.GateOwnerGeneration) + } + assertResolverFieldsEqual(t, "after terminal clear", real, after) +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateOffCreatesNoReceipt pins that finalize.gate: off skips +// the rebase entirely — no receipt, hence no resolver budget, is created. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateOffCreatesNoReceipt(t *testing.T) { + f := setupRebaseFixture(t, planRepoModes()[0]) + writeRepoFile(t, f.repo.invocation, ".docket.local.yml", "finalize:\n gate: \"off\"\n") + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} + res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, &fakeGate{}), f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if res.Result != ResultNoOp || res.Reason != ReasonRebaseGateOff { + t.Fatalf("gate off = %q reason %q, want no-op/gate-off", res.Result, res.Reason) + } + f.receiptAbsent(t) +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationMissing proves a report that carries no +// resolver_reservation is refused BEFORE staging (reservation-missing), spends +// nothing, and leaves the outstanding reservation and the receipt untouched. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationMissing(t *testing.T) { + f, deps, attempt, _, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + before := reloadReceipt(t, f) + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, ConflictedPaths: []string{"feature.txt"}} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationMissing { + t.Fatalf("continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseReservationMissing) + } + if seam.calls != 0 { + t.Errorf("a reservation-missing refusal staged %d time(s); want 0", seam.calls) + } + if after := reloadReceipt(t, f); after != before { + t.Fatalf("a reservation-missing refusal mutated the receipt:\n before %+v\n after %+v", before, after) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationStaleToken proves a report echoing a foreign +// token is refused (reservation-stale) before staging. (Mutation cell a: dropping +// the token comparison lets this continue reach staging and reddens here.) +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationStaleToken(t *testing.T) { + f, deps, attempt, _, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + // Resolve the file so that, if the guard were dropped, the continue would + // complete (applied) rather than merely erroring — a cleaner mutation signal. + writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: "not-the-reserved-token"} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationStale { + t.Fatalf("continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseReservationStale) + } + if seam.calls != 0 { + t.Errorf("a foreign-token refusal staged %d time(s); want 0", seam.calls) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationStaleCommit proves a correct token bound to +// a DIFFERENT stopped commit than the live rebase is refused (reservation-stale): +// identical conflicted paths must NOT rescue a stale reservation. (Mutation cell b: +// dropping the stopped-commit comparison lets this continue proceed and reddens.) +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReservationStaleCommit(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + // Rebind the reservation to a different (but valid) stopped commit; the live + // rebase remains stopped on the real feature commit, so the identities diverge + // while the conflicted path (feature.txt) is byte-identical. + seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { + r.ResolverReservationStopped = strings.Repeat("b", 40) + }) + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationStale { + t.Fatalf("continue = (%q, %q), want blocked/%q (identical paths must not rescue a stale reservation)", res.Result, res.Reason, ReasonRebaseReservationStale) + } + if seam.calls != 0 { + t.Errorf("a stale-commit refusal staged %d time(s); want 0", seam.calls) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueMarksStartedBeforeStaging proves the continuation-started +// marker is durably written BEFORE StageAndContinueRebase runs, and that a completed +// continue clears the reservation while preserving used. (Mutation cell c: skipping +// the continuation-started write reddens the contAtCall assertion.) +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueMarksStartedBeforeStaging(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultApplied || res.Disposition != RebaseDispRebased { + t.Fatalf("continue = (%q, %q) reason %q msg %q, want applied/rebased", res.Result, res.Disposition, res.Reason, res.Message) + } + if seam.calls != 1 { + t.Fatalf("staging calls = %d, want exactly 1", seam.calls) + } + if seam.contAtCall != "1" { + t.Fatalf("continuation-started marker at staging time = %q, want %q (the mark must be durable before the Git mutation)", seam.contAtCall, "1") + } + // The completed continue composed the gate and cleared the reservation, keeping used. + if res.Gate == nil || res.Gate.Evidence == "" { + t.Errorf("a completed continue did not compose the gate: %+v", res.Gate) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != "" || rec.ResolverReservationStopped != "" || rec.ResolverContinuationStarted != "" { + t.Errorf("a completed continue left the reservation outstanding: token %q stopped %q cont %q", + rec.ResolverReservationToken, rec.ResolverReservationStopped, rec.ResolverContinuationStarted) + } + if rec.ResolverUsed != "1" { + t.Errorf("used = %q after a completed continue, want 1 preserved", rec.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueNextConflictUnderBudget proves a continuation that +// surfaces the NEXT conflict with used < limit returns a conflicted result carrying +// the counts, with the (now-spent) reservation reconciled/cleared. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueNextConflictUnderBudget(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) // used becomes 1 + ctx := context.Background() + // Script the staged continue to surface another conflict without a real + // multi-commit fixture (the real e2e is Task 8). + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, + script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} + deps.ContinueGit = seam + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultApplied || res.Disposition != RebaseDispConflicted || res.Reason != ReasonRebaseConflicted { + t.Fatalf("continue = (%q, %q, %q), want applied/conflicted/%q", res.Result, res.Disposition, res.Reason, ReasonRebaseConflicted) + } + if res.ResolverLimit != 2 || res.ResolverUsed != 1 || res.ResolverRemaining != 1 { + t.Errorf("counts = %d/%d/%d, want 2/1/1", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" { + t.Errorf("the next-conflict outcome left the reservation outstanding: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) + } + if rec.ResolverUsed != "1" { + t.Errorf("used = %q, want 1 preserved", rec.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueNextConflictExhausted proves the last permitted +// continuation (used == limit) that surfaces another conflict routes to the +// existing rebase disposition `blocked` with reason resolver-budget-exhausted and +// carries the counts; the rebase disposition vocabulary does not grow. The +// exhaustion HumanText names finalize.resolver_max_attempts, the used/limit, and +// the next explicit finalize attempt. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueNextConflictExhausted(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 1) // used becomes 1 == limit + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, + script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} + deps.ContinueGit = seam + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Disposition != RebaseDispBlocked || res.Reason != ReasonResolverBudgetExhausted { + t.Fatalf("continue = (%q, %q, %q), want blocked/blocked/%q", res.Result, res.Disposition, res.Reason, ReasonResolverBudgetExhausted) + } + if res.ResolverLimit != 1 || res.ResolverUsed != 1 || res.ResolverRemaining != 0 { + t.Errorf("counts = %d/%d/%d, want 1/1/0", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) + } + h := res.HumanText() + if !strings.Contains(h, "finalize.resolver_max_attempts") || !strings.Contains(h, "next explicit finalize attempt") || !strings.Contains(h, "1/1") { + t.Errorf("exhaustion HumanText %q does not name finalize.resolver_max_attempts + used/limit + next explicit finalize attempt", h) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueLegacyRefuses proves a legacy receipt (no budget group) +// refuses any continue with resolver-budget-unavailable, while FinalizeRebaseAbort +// on the SAME legacy receipt still succeeds (abort is reservation-agnostic). +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueLegacyRefuses(t *testing.T) { + f, _, deps := beginConflictedWithLimit(t, 2) + ctx := context.Background() + attempt := reloadReceipt(t, f).Attempt + // Strip the whole budget group -> a legacy receipt (still valid: all six empty). + seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { + r.ResolverBudgetVersion = "" + r.ResolverLimit = "" + r.ResolverUsed = "" + r.ResolverReservationToken = "" + r.ResolverReservationStopped = "" + r.ResolverContinuationStarted = "" + }) + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, ConflictedPaths: []string{"feature.txt"}} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Reason != ReasonResolverBudgetUnavailable { + t.Fatalf("legacy continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonResolverBudgetUnavailable) + } + // Abort on the same legacy receipt still succeeds. + abort := FinalizeRebaseAbort(ctx, deps, f.repo.invocation, f.id, attempt, + ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverStuck}) + if abort.Result != ResultApplied || abort.Disposition != RebaseDispBlocked { + t.Fatalf("legacy abort = (%q, %q), want applied/blocked", abort.Result, abort.Disposition) + } + f.receiptAbsent(t) +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueRepeatedConsumedReservation proves a repeated continue +// with an already-consumed reservation cannot advance a later commit: the second +// call refuses (reservation-missing, the reservation was cleared) and stages nothing. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueRepeatedConsumedReservation(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + first := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if first.Result != ResultApplied { + t.Fatalf("first continue = %q (reason %q), want applied", first.Result, first.Reason) + } + if seam.calls != 1 { + t.Fatalf("first continue staged %d time(s), want 1", seam.calls) + } + // Replaying the same (now consumed) reservation must not stage again. + second := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if second.Result != ResultBlocked || second.Reason != ReasonRebaseReservationMissing { + t.Fatalf("repeated continue = (%q, %q), want blocked/%q", second.Result, second.Reason, ReasonRebaseReservationMissing) + } + if seam.calls != 1 { + t.Fatalf("repeated continue staged again (calls = %d); a consumed reservation cannot advance a later commit", seam.calls) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedAmbiguousRetains proves a response-lost +// continuation (continuation-started marked, live rebase still stopped on the SAME +// commit) is retained and blocked with no second continue — never blindly replayed. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedAmbiguousRetains(t *testing.T) { + f, deps, attempt, token, stopped := reserveOnConflict(t, 2) + ctx := context.Background() + // The prior continuation marked started but its response was lost; the live + // rebase is still stopped on the reserved commit (stopped == X). + before := seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { + r.ResolverReservationStopped = stopped + r.ResolverContinuationStarted = "1" + }) + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultBlocked || res.Reason != ReasonRebaseContinuationAmbiguous { + t.Fatalf("ambiguous recovery = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseContinuationAmbiguous) + } + if seam.calls != 0 { + t.Errorf("an ambiguous recovery staged %d time(s); want 0 (no second continue)", seam.calls) + } + if after := reloadReceipt(t, f); after != before { + t.Fatalf("an ambiguous recovery mutated the receipt:\n before %+v\n after %+v", before, after) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedCompletedRecovers proves a response-lost +// continuation whose rebase provably completed (RebaseState clean, head descends +// the base) is recovered WITHOUT another charge: the reservation is reconciled, the +// gate composes, and used is preserved — no second StageAndContinueRebase. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedCompletedRecovers(t *testing.T) { + f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { + r.ResolverContinuationStarted = "1" // a started continuation whose response was lost + }) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps := f.finalizeDeps(gh, &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}) + deps.ContinueGit = seam + + report := ResolverReport{ChangeID: f.id, Attempt: real.Attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: real.ResolverReservationToken} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) + if res.Result != ResultApplied || res.Gate == nil || res.Gate.Evidence == "" { + t.Fatalf("completed recovery = %q gate %+v (reason %q), want applied with gate evidence", res.Result, res.Gate, res.Reason) + } + if seam.calls != 0 { + t.Errorf("a completed recovery staged %d time(s); want 0 (recover without another continue)", seam.calls) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" { + t.Errorf("a completed recovery left the reservation outstanding: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) + } + if rec.ResolverUsed != real.ResolverUsed { + t.Errorf("used = %q after recovery, want %q preserved (no new charge)", rec.ResolverUsed, real.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReconcileWriteFailurePreserves (change 0411, AC1) +// proves a receipt-write failure injected ONLY at post-continue reservation +// reconciliation (the started marker landed durably first) keeps the unchanged +// error result/disposition/reason, emits a message naming the same-attempt +// finalize.rebase-continue remedy WITHOUT claiming the whole rebase finished, +// preserves the outstanding reservation + started marker + used count, and never +// runs the gate before reconciliation succeeds. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReconcileWriteFailurePreserves(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) // used == 1 + ctx := context.Background() + writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") // resolve so the continue completes + ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 1, fail: true} // marker write passes, reconcile write faults + deps.Workspace = ws + gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed}} + deps.Gate = gate + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultExternalFailed || res.Disposition != RebaseDispBlocked || res.Reason != ReasonRebaseReceiptWrite { + t.Fatalf("continue = (%q, %q, %q), want external-failed/blocked/%q unchanged", res.Result, res.Disposition, res.Reason, ReasonRebaseReceiptWrite) + } + if !strings.Contains(res.Message, "finalize.rebase-continue") || + !strings.Contains(res.Message, "same change id, owned attempt, and original resolved report") || + !strings.Contains(res.Message, errReconcileWrite.Error()) { + t.Errorf("message %q must name the same-attempt finalize.rebase-continue remedy and keep the write error", res.Message) + } + if strings.Contains(res.Message, "rebase completed") { + t.Errorf("message %q may not assert the whole rebase finished at the post-continue site", res.Message) + } + if gate.calls != 0 { + t.Errorf("gate ran %d time(s) before reconciliation succeeded; want 0", gate.calls) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != token || rec.ResolverContinuationStarted != "1" || rec.ResolverUsed != "1" { + t.Errorf("receipt after failed reconcile: token %q cont %q used %q, want reservation + started marker + used preserved", rec.ResolverReservationToken, rec.ResolverContinuationStarted, rec.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReconcileWriteFailureNextConflict (change 0411, AC3) +// proves the post-continue reconcile-write failure message stays honest when the +// continue surfaced ANOTHER conflict: same remedy, no completion claim, receipt +// retained with the reservation outstanding. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueReconcileWriteFailureNextConflict(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, + script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} + deps.ContinueGit = seam + ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 1, fail: true} + deps.Workspace = ws + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { + t.Fatalf("continue = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) + } + if !strings.Contains(res.Message, "finalize.rebase-continue") || strings.Contains(res.Message, "rebase completed") { + t.Errorf("message %q must name the remedy and never claim completion while a conflict remains", res.Message) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != token || rec.ResolverContinuationStarted != "1" { + t.Errorf("receipt lost the outstanding reservation: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedCompletedRecoveryWriteFails (change 0411, AC2) +// proves the completed-rebase recovery branch's failed reconciliation write emits +// the completed-specific remedy message, repeats no staging, preserves the receipt +// — and that restoring writes and retrying the SAME report recovers: reservation +// cleared only by the existing recovery, used preserved, gate composed. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedCompletedRecoveryWriteFails(t *testing.T) { + f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { + r.ResolverContinuationStarted = "1" + }) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps := f.finalizeDeps(gh, &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}) + deps.ContinueGit = seam + ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} // the recovery's one write faults + deps.Workspace = ws + + report := ResolverReport{ChangeID: f.id, Attempt: real.Attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: real.ResolverReservationToken} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) + if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { + t.Fatalf("recovery write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) + } + if !strings.Contains(res.Message, "owned rebase completed") || !strings.Contains(res.Message, "finalize.rebase-continue") || + !strings.Contains(res.Message, errReconcileWrite.Error()) { + t.Errorf("message %q must say the owned rebase completed, name the remedy, and keep the write error", res.Message) + } + if seam.calls != 0 { + t.Errorf("recovery staged %d time(s); want 0", seam.calls) + } + if rec := reloadReceipt(t, f); rec.ResolverReservationToken == "" || rec.ResolverContinuationStarted != "1" || rec.ResolverUsed != real.ResolverUsed { + t.Errorf("failed recovery mutated the receipt: %+v", rec) + } + + ws.fail = false // durable writes restored — retry the SAME report + res2 := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) + if res2.Result != ResultApplied || res2.Gate == nil { + t.Fatalf("retry = %q gate %+v (reason %q), want applied with gate", res2.Result, res2.Gate, res2.Reason) + } + if seam.calls != 0 { + t.Errorf("retry staged %d time(s); want 0 (no repeated Git continuation)", seam.calls) + } + rec := reloadReceipt(t, f) + if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" || rec.ResolverUsed != real.ResolverUsed { + t.Errorf("retry left receipt %+v; want reservation cleared, used %q preserved (no charge, no refund)", rec, real.ResolverUsed) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedAdvancedRecoveryWriteFails (change 0411, AC3) +// proves the advanced-conflict recovery branch's failed reconciliation write says +// the continuation advanced — never that the rebase completed — and a retry after +// restoring writes surfaces the next conflict without replaying the continuation. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueStartedAdvancedRecoveryWriteFails(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + // The receipt records a DIFFERENT stopped commit than the live rebase, so the + // started continuation provably advanced. + seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { + r.ResolverReservationStopped = strings.Repeat("d", 40) + r.ResolverContinuationStarted = "1" + }) + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} + deps.Workspace = ws + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { + t.Fatalf("advanced recovery write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) + } + if !strings.Contains(res.Message, "advanced to another conflict") || strings.Contains(res.Message, "rebase completed") || + !strings.Contains(res.Message, "finalize.rebase-continue") { + t.Errorf("message %q must say advanced-to-another-conflict, name the remedy, and never claim completion", res.Message) + } + if seam.calls != 0 { + t.Errorf("recovery staged %d time(s); want 0", seam.calls) + } + + ws.fail = false + res2 := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res2.Result != ResultApplied || res2.Disposition != RebaseDispConflicted { + t.Fatalf("retry = (%q, %q, reason %q), want applied/conflicted surfacing the live conflict", res2.Result, res2.Disposition, res2.Reason) + } + if seam.calls != 0 { + t.Errorf("retry replayed the continuation %d time(s); want 0", seam.calls) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueMarkerWriteFailureNoRecoveryClaim (change 0411, AC4) +// proves a write failure BEFORE Git ran (the continuation-started marker) does not +// acquire the post-completion recovery remedy: same reason, no remedy phrase. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseContinueMarkerWriteFailureNoRecoveryClaim(t *testing.T) { + f, deps, attempt, token, _ := reserveOnConflict(t, 2) + ctx := context.Background() + seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} + deps.ContinueGit = seam + deps.Workspace = &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} // the FIRST write (marker) faults + + report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, + ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} + res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) + if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { + t.Fatalf("marker write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) + } + if strings.Contains(res.Message, "finalize.rebase-continue") { + t.Errorf("pre-continue marker write failure %q must not carry the post-completion recovery remedy", res.Message) + } + if seam.calls != 0 { + t.Errorf("a failed marker write staged %d time(s); want 0", seam.calls) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateHaltCarriesAdmissionRefusal proves the composition +// carries the halt detail into GateReport (JSON) and the human line, keeping the +// blocked disposition, rebase-gate-halted reason, and unavailable halt cause. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateHaltCarriesAdmissionRefusal(t *testing.T) { + f := setupRebaseFixture(t, planRepoModes()[0]) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} + gate := &fakeGate{result: LocalGateResult{ + Outcome: FinalizeGateHalted, HaltCause: GateHaltUnavailable, + HaltReason: "worktree-busy", + HaltMessage: "a raw gate run occupies this worktree's execution slot; settle it with docket gate stop '/runs/x' --reason ", + HaltStage: stageWorktreeAdmission, + HaltLocator: "incumbent-run:0123456789abcdef0123456789abcdef", + }} + res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if gate.calls != 1 { + t.Fatalf("gate ran %d time(s); want exactly 1 (the halt must come from a run)", gate.calls) + } + if res.Result != ResultBlocked || res.Reason != ReasonRebaseGateHalted { + t.Fatalf("result/reason = %q/%q, want blocked/%q", res.Result, res.Reason, ReasonRebaseGateHalted) + } + if res.Gate == nil { + t.Fatal("no gate report on a halted composition") + } + if res.Gate.HaltCause != GateHaltUnavailable { + t.Fatalf("halt cause = %q, want %q", res.Gate.HaltCause, GateHaltUnavailable) + } + if res.Gate.Reason != "worktree-busy" || res.Gate.Stage != stageWorktreeAdmission || + res.Gate.Locator != "incumbent-run:0123456789abcdef0123456789abcdef" { + t.Fatalf("gate detail dropped: reason=%q stage=%q locator=%q", res.Gate.Reason, res.Gate.Stage, res.Gate.Locator) + } + if !strings.Contains(res.Gate.Message, "gate stop") { + t.Fatalf("gate message %q lacks the remedy", res.Gate.Message) + } + if res.Gate.RunDir != "" { + t.Fatalf("run_dir carries incumbent facts: %q", res.Gate.RunDir) + } + human := res.HumanText() + if !strings.Contains(human, "worktree-busy") || + !strings.Contains(human, "incumbent-run:0123456789abcdef0123456789abcdef") || + !strings.Contains(human, "gate stop") { + t.Fatalf("human line %q lacks reason + locator + remedy", human) + } +} + +// TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateHaltGenericUnchanged proves a detail-less halt keeps +// today's generic output exactly: Gate.Reason/Message/Stage/Locator all empty, +// the generic result message, and a HumanText without any locator fragment. +func TestIntegrationFinalizeRebaseOpsFinalizeRebaseGateHaltGenericUnchanged(t *testing.T) { + f := setupRebaseFixture(t, planRepoModes()[0]) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} + gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGateHalted, HaltCause: GateHaltUnavailable}} + res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) + if res.Result != ResultBlocked || res.Reason != ReasonRebaseGateHalted { + t.Fatalf("result/reason = %q/%q, want blocked/%q", res.Result, res.Reason, ReasonRebaseGateHalted) + } + if res.Gate == nil { + t.Fatal("no gate report on a halted composition") + } + if res.Gate.Reason != "" || res.Gate.Message != "" || res.Gate.Stage != "" || res.Gate.Locator != "" { + t.Fatalf("detail-less halt grew detail: %+v", res.Gate) + } + if res.Message != "the local gate did not reach a decidable pass/fail; retained, no red fabricated" { + t.Fatalf("generic halt message changed: %q", res.Message) + } + human := res.HumanText() + if strings.Contains(human, "[gate:") || strings.Contains(human, "incumbent-") { + t.Fatalf("generic human line carries a locator fragment: %q", human) + } +} + +// TestIntegrationFinalizeRebaseOpsMutateReceiptForAttemptSkipsSuperseded covers the attempt-identity guard +// (change 0438): a receipt writer observing attempt "A" must not modify a +// receipt that now records attempt "B" (a refresh superseded the rewrite +// mid-flight); the same helper writes when the observed attempt still matches. +func TestIntegrationFinalizeRebaseOpsMutateReceiptForAttemptSkipsSuperseded(t *testing.T) { + f := setupRebaseFixture(t, planRepoModes()[0]) + ctx := context.Background() + deps := f.finalizeDeps(nil, nil) + rc := &rebaseContext{metaDir: f.metaDir} + + recB := workspace.RebaseReceipt{ + RepoIdentity: f.gitrepo.CommonDir, + ChangeID: fmt.Sprintf("%d", f.id), + OrigHead: strings.Repeat("a", 40), + OrigRemoteHead: strings.Repeat("a", 40), + BaseRef: "refs/heads/main", + BaseHead: strings.Repeat("b", 40), + Attempt: "B", + GateDriveID: "drive-1", + GateOwnerGeneration: "gen-1", + CreatedUTC: "2026-09-20T00:00:00Z", + } + if err := f.svc.WriteRebaseReceipt(ctx, f.metaDir, recB); err != nil { + t.Fatalf("seed receipt B: %v", err) + } + + mutate := func(r *workspace.RebaseReceipt) { r.GateDriveID, r.GateOwnerGeneration = "", "" } + + // Observing attempt "A" over an on-disk attempt-"B" receipt: superseded, skip. + written, err := mutateReceiptForAttempt(ctx, deps, rc, "A", mutate) + if err != nil { + t.Fatalf("mutate for A: unexpected err %v", err) + } + if written { + t.Errorf("mutate for A reported written; the superseded write must not land") + } + after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) + if err != nil || !present { + t.Fatalf("read after A: present=%v err=%v", present, err) + } + if after != recB { + t.Errorf("the superseded write mutated the receipt:\n got %+v\nwant byte-identical %+v", after, recB) + } + + // Observing attempt "B" (the current on-disk token): the write lands, and + // only the mutated fields change. + written, err = mutateReceiptForAttempt(ctx, deps, rc, "B", mutate) + if err != nil { + t.Fatalf("mutate for B: unexpected err %v", err) + } + if !written { + t.Errorf("mutate for B did not report written") + } + got, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) + if err != nil || !present { + t.Fatalf("read after B: present=%v err=%v", present, err) + } + want := recB + want.GateDriveID, want.GateOwnerGeneration = "", "" + if got != want { + t.Errorf("mutate for B changed more than the gate pair:\n got %+v\nwant %+v", got, want) + } +} diff --git a/internal/app/finalize_rebase_test.go b/internal/app/finalize_rebase_test.go index 12f81cff9..8f00f06d0 100644 --- a/internal/app/finalize_rebase_test.go +++ b/internal/app/finalize_rebase_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "encoding/json" "errors" "fmt" "github.com/danielhanold/docket/internal/domain" @@ -543,78 +542,6 @@ func beginConflictedWithLimit(t *testing.T, limit int) (*rebaseFixture, Finalize return f, res, deps } -// TestFinalizeRebaseResolverBudgetSnapshot proves a fresh owned rebase snapshots -// the RESOLVED finalize.resolver_max_attempts into the receipt as a versioned -// budget group (version "1", the resolved non-default limit, used 0, no -// outstanding reservation), and that the conflicted result surfaces the counts in -// both its JSON document and its human text. -func TestFinalizeRebaseResolverBudgetSnapshot(t *testing.T) { - f, res, _ := beginConflictedWithLimit(t, 2) - - // The receipt carries the versioned budget group at the resolved non-default. - rec, present, err := f.svc.ReadRebaseReceipt(context.Background(), f.metaDir) - if err != nil || !present { - t.Fatalf("receipt after fresh rebase: present=%v err=%v", present, err) - } - if rec.ResolverBudgetVersion != "1" || rec.ResolverLimit != "2" || rec.ResolverUsed != "0" { - t.Fatalf("receipt budget = ver %q limit %q used %q, want 1/2/0 (the resolved non-default 2, not the built-in 3)", - rec.ResolverBudgetVersion, rec.ResolverLimit, rec.ResolverUsed) - } - if rec.ResolverReservationToken != "" || rec.ResolverReservationStopped != "" || rec.ResolverContinuationStarted != "" { - t.Errorf("fresh receipt carried a reservation: token %q stopped %q cont %q", - rec.ResolverReservationToken, rec.ResolverReservationStopped, rec.ResolverContinuationStarted) - } - - // The conflicted result carries the counts (2/0/2). - if res.ResolverLimit != 2 || res.ResolverUsed != 0 || res.ResolverRemaining != 2 { - t.Fatalf("result counts = %d/%d/%d, want 2/0/2", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) - } - buf, err := json.Marshal(res) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var doc struct { - ResolverLimit int `json:"resolver_limit"` - ResolverRemaining int `json:"resolver_remaining"` - } - if err := json.Unmarshal(buf, &doc); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if doc.ResolverLimit != 2 || doc.ResolverRemaining != 2 { - t.Errorf("JSON counts = limit %d remaining %d, want 2/2 (raw %s)", doc.ResolverLimit, doc.ResolverRemaining, buf) - } - if !strings.Contains(string(buf), `"resolver_limit":2`) || !strings.Contains(string(buf), `"resolver_remaining":2`) { - t.Errorf("JSON document missing resolver counts: %s", buf) - } - if h := res.HumanText(); !strings.Contains(h, "0/2") || !strings.Contains(h, "2 remaining") { - t.Errorf("HumanText does not mention the resolver counts: %q", h) - } -} - -// TestFinalizeRebaseResolverBudgetRecoveryNoResnapshot proves recoverFromReceipt -// adopts the receipt's stored budget and NEVER re-snapshots the current config: a -// mid-attempt config change (2 -> 5) does not apply to an owned attempt. -func TestFinalizeRebaseResolverBudgetRecoveryNoResnapshot(t *testing.T) { - f, first, deps := beginConflictedWithLimit(t, 2) - if first.ResolverLimit != 2 { - t.Fatalf("fresh conflicted limit = %d, want 2", first.ResolverLimit) - } - // The operator raises the cap mid-attempt; the owned attempt must ignore it. - setResolverConfig(t, f, 5) - second := FinalizeRebase(context.Background(), deps, f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if second.Disposition != RebaseDispConflicted { - t.Fatalf("recovery = disp %q (reason %q), want conflicted", second.Disposition, second.Reason) - } - if second.ResolverLimit != 2 { - t.Fatalf("recovery reported limit %d; the mid-attempt config change to 5 must NOT apply — want the receipt's 2", second.ResolverLimit) - } - rec, _, _ := f.svc.ReadRebaseReceipt(context.Background(), f.metaDir) - if rec.ResolverLimit != "2" || rec.ResolverUsed != "0" { - t.Errorf("recovery re-snapshotted the receipt budget to limit %q used %q; want the owned 2/0", rec.ResolverLimit, rec.ResolverUsed) - } -} - // staleFirstReadWorkspace wraps a FinalizeWorkspace to prove every // gate-continuation receipt rewrite reloads the resolver-budget group from disk // (change 0349's write-forward rule). Its FIRST ReadRebaseReceipt serves a STALE @@ -686,91 +613,6 @@ func completedBudgetedReceipt(t *testing.T, seed func(*workspace.RebaseReceipt)) return f, gh, real } -// TestFinalizeRebaseResolverBudgetWaitingReloadsForward proves the WAITING -// gate-continuation write copies the resolver-budget group forward from the -// freshly reloaded on-disk receipt, not from a stale in-memory copy. -func TestFinalizeRebaseResolverBudgetWaitingReloadsForward(t *testing.T) { - f, gh, real := completedBudgetedReceipt(t, func(*workspace.RebaseReceipt) {}) // no gate pair - ctx := context.Background() - - // Stale = the budget rolled back to its pre-reserve state; the gate pair is - // empty so the recovery composes the gate afresh. - stale := real - stale.ResolverUsed = "0" - stale.ResolverReservationToken = "" - stale.ResolverReservationStopped = "" - wrap := &staleFirstReadWorkspace{FinalizeWorkspace: f.svc, stale: stale} - deps := FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: wrap, - Gate: &fakeGate{result: LocalGateResult{Outcome: FinalizeGateWaiting, Continuation: GateContinuation{DriveID: "drive-1", Generation: "gen-1"}}}} - - res := FinalizeRebase(ctx, deps, f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if res.Disposition != RebaseDispWaiting { - t.Fatalf("waiting slice = %q (reason %q msg %q), want waiting", res.Disposition, res.Reason, res.Message) - } - after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) - if err != nil || !present { - t.Fatalf("receipt after WAITING: present=%v err=%v", present, err) - } - if after.GateDriveID != "drive-1" || after.GateOwnerGeneration != "gen-1" { - t.Fatalf("WAITING did not set the gate pair: %q/%q", after.GateDriveID, after.GateOwnerGeneration) - } - assertResolverFieldsEqual(t, "after WAITING set", real, after) -} - -// TestFinalizeRebaseResolverBudgetClearReloadsForward proves the terminal -// gate-continuation clear copies the resolver-budget group forward from the -// freshly reloaded on-disk receipt, not from a stale in-memory copy. -func TestFinalizeRebaseResolverBudgetClearReloadsForward(t *testing.T) { - f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { - // A recorded WAITING drive so the recovery advances and then CLEARS it. - r.PublishCheckpointHead, r.PublishCheckpointBaseHead = "", "" - r.PublishCheckpointCommand, r.PublishCheckpointGate = "", "" - r.PublishCheckpointPRNumber, r.PublishCheckpointEvidence = "", "" - r.GateDriveID = "drive-9" - r.GateOwnerGeneration = "gen-9" - }) - ctx := context.Background() - - // Stale = the budget rolled back to its pre-reserve state; the gate pair is - // retained so composeLocalGate advances the recorded drive to its terminal. - stale := real - stale.ResolverUsed = "0" - stale.ResolverReservationToken = "" - stale.ResolverReservationStopped = "" - wrap := &staleFirstReadWorkspace{FinalizeWorkspace: f.svc, stale: stale} - deps := FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: wrap, - Gate: &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}} - - res := FinalizeRebase(ctx, deps, f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if res.Result != ResultApplied || res.Gate == nil || res.Gate.Evidence == "" { - t.Fatalf("passed slice = %q gate %+v (reason %q), want applied with evidence", res.Result, res.Gate, res.Reason) - } - after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) - if err != nil || !present { - t.Fatalf("receipt after terminal: present=%v err=%v", present, err) - } - if after.GateDriveID != "" || after.GateOwnerGeneration != "" { - t.Fatalf("terminal did not clear the gate pair: %q/%q", after.GateDriveID, after.GateOwnerGeneration) - } - assertResolverFieldsEqual(t, "after terminal clear", real, after) -} - -// TestFinalizeRebaseGateOffCreatesNoReceipt pins that finalize.gate: off skips -// the rebase entirely — no receipt, hence no resolver budget, is created. -func TestFinalizeRebaseGateOffCreatesNoReceipt(t *testing.T) { - f := setupRebaseFixture(t, planRepoModes()[0]) - writeRepoFile(t, f.repo.invocation, ".docket.local.yml", "finalize:\n gate: \"off\"\n") - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} - res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, &fakeGate{}), f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if res.Result != ResultNoOp || res.Reason != ReasonRebaseGateOff { - t.Fatalf("gate off = %q reason %q, want no-op/gate-off", res.Result, res.Reason) - } - f.receiptAbsent(t) -} - // --- reservation-verified continue (change 0349, Task 7) ------------------- var errStageSeamBoom = errors.New("stage-and-continue seam boom") @@ -845,475 +687,6 @@ func reserveOnConflict(t *testing.T, limit int) (f *rebaseFixture, deps Finalize return f, deps, attempt, res.Reservation, stopped } -// TestFinalizeRebaseContinueReservationMissing proves a report that carries no -// resolver_reservation is refused BEFORE staging (reservation-missing), spends -// nothing, and leaves the outstanding reservation and the receipt untouched. -func TestFinalizeRebaseContinueReservationMissing(t *testing.T) { - f, deps, attempt, _, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - before := reloadReceipt(t, f) - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, ConflictedPaths: []string{"feature.txt"}} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationMissing { - t.Fatalf("continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseReservationMissing) - } - if seam.calls != 0 { - t.Errorf("a reservation-missing refusal staged %d time(s); want 0", seam.calls) - } - if after := reloadReceipt(t, f); after != before { - t.Fatalf("a reservation-missing refusal mutated the receipt:\n before %+v\n after %+v", before, after) - } -} - -// TestFinalizeRebaseContinueReservationStaleToken proves a report echoing a foreign -// token is refused (reservation-stale) before staging. (Mutation cell a: dropping -// the token comparison lets this continue reach staging and reddens here.) -func TestFinalizeRebaseContinueReservationStaleToken(t *testing.T) { - f, deps, attempt, _, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - // Resolve the file so that, if the guard were dropped, the continue would - // complete (applied) rather than merely erroring — a cleaner mutation signal. - writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: "not-the-reserved-token"} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationStale { - t.Fatalf("continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseReservationStale) - } - if seam.calls != 0 { - t.Errorf("a foreign-token refusal staged %d time(s); want 0", seam.calls) - } -} - -// TestFinalizeRebaseContinueReservationStaleCommit proves a correct token bound to -// a DIFFERENT stopped commit than the live rebase is refused (reservation-stale): -// identical conflicted paths must NOT rescue a stale reservation. (Mutation cell b: -// dropping the stopped-commit comparison lets this continue proceed and reddens.) -func TestFinalizeRebaseContinueReservationStaleCommit(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - // Rebind the reservation to a different (but valid) stopped commit; the live - // rebase remains stopped on the real feature commit, so the identities diverge - // while the conflicted path (feature.txt) is byte-identical. - seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { - r.ResolverReservationStopped = strings.Repeat("b", 40) - }) - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Reason != ReasonRebaseReservationStale { - t.Fatalf("continue = (%q, %q), want blocked/%q (identical paths must not rescue a stale reservation)", res.Result, res.Reason, ReasonRebaseReservationStale) - } - if seam.calls != 0 { - t.Errorf("a stale-commit refusal staged %d time(s); want 0", seam.calls) - } -} - -// TestFinalizeRebaseContinueMarksStartedBeforeStaging proves the continuation-started -// marker is durably written BEFORE StageAndContinueRebase runs, and that a completed -// continue clears the reservation while preserving used. (Mutation cell c: skipping -// the continuation-started write reddens the contAtCall assertion.) -func TestFinalizeRebaseContinueMarksStartedBeforeStaging(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultApplied || res.Disposition != RebaseDispRebased { - t.Fatalf("continue = (%q, %q) reason %q msg %q, want applied/rebased", res.Result, res.Disposition, res.Reason, res.Message) - } - if seam.calls != 1 { - t.Fatalf("staging calls = %d, want exactly 1", seam.calls) - } - if seam.contAtCall != "1" { - t.Fatalf("continuation-started marker at staging time = %q, want %q (the mark must be durable before the Git mutation)", seam.contAtCall, "1") - } - // The completed continue composed the gate and cleared the reservation, keeping used. - if res.Gate == nil || res.Gate.Evidence == "" { - t.Errorf("a completed continue did not compose the gate: %+v", res.Gate) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != "" || rec.ResolverReservationStopped != "" || rec.ResolverContinuationStarted != "" { - t.Errorf("a completed continue left the reservation outstanding: token %q stopped %q cont %q", - rec.ResolverReservationToken, rec.ResolverReservationStopped, rec.ResolverContinuationStarted) - } - if rec.ResolverUsed != "1" { - t.Errorf("used = %q after a completed continue, want 1 preserved", rec.ResolverUsed) - } -} - -// TestFinalizeRebaseContinueNextConflictUnderBudget proves a continuation that -// surfaces the NEXT conflict with used < limit returns a conflicted result carrying -// the counts, with the (now-spent) reservation reconciled/cleared. -func TestFinalizeRebaseContinueNextConflictUnderBudget(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) // used becomes 1 - ctx := context.Background() - // Script the staged continue to surface another conflict without a real - // multi-commit fixture (the real e2e is Task 8). - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, - script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} - deps.ContinueGit = seam - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultApplied || res.Disposition != RebaseDispConflicted || res.Reason != ReasonRebaseConflicted { - t.Fatalf("continue = (%q, %q, %q), want applied/conflicted/%q", res.Result, res.Disposition, res.Reason, ReasonRebaseConflicted) - } - if res.ResolverLimit != 2 || res.ResolverUsed != 1 || res.ResolverRemaining != 1 { - t.Errorf("counts = %d/%d/%d, want 2/1/1", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" { - t.Errorf("the next-conflict outcome left the reservation outstanding: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) - } - if rec.ResolverUsed != "1" { - t.Errorf("used = %q, want 1 preserved", rec.ResolverUsed) - } -} - -// TestFinalizeRebaseContinueNextConflictExhausted proves the last permitted -// continuation (used == limit) that surfaces another conflict routes to the -// existing rebase disposition `blocked` with reason resolver-budget-exhausted and -// carries the counts; the rebase disposition vocabulary does not grow. The -// exhaustion HumanText names finalize.resolver_max_attempts, the used/limit, and -// the next explicit finalize attempt. -func TestFinalizeRebaseContinueNextConflictExhausted(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 1) // used becomes 1 == limit - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, - script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} - deps.ContinueGit = seam - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Disposition != RebaseDispBlocked || res.Reason != ReasonResolverBudgetExhausted { - t.Fatalf("continue = (%q, %q, %q), want blocked/blocked/%q", res.Result, res.Disposition, res.Reason, ReasonResolverBudgetExhausted) - } - if res.ResolverLimit != 1 || res.ResolverUsed != 1 || res.ResolverRemaining != 0 { - t.Errorf("counts = %d/%d/%d, want 1/1/0", res.ResolverLimit, res.ResolverUsed, res.ResolverRemaining) - } - h := res.HumanText() - if !strings.Contains(h, "finalize.resolver_max_attempts") || !strings.Contains(h, "next explicit finalize attempt") || !strings.Contains(h, "1/1") { - t.Errorf("exhaustion HumanText %q does not name finalize.resolver_max_attempts + used/limit + next explicit finalize attempt", h) - } -} - -// TestFinalizeRebaseContinueLegacyRefuses proves a legacy receipt (no budget group) -// refuses any continue with resolver-budget-unavailable, while FinalizeRebaseAbort -// on the SAME legacy receipt still succeeds (abort is reservation-agnostic). -func TestFinalizeRebaseContinueLegacyRefuses(t *testing.T) { - f, _, deps := beginConflictedWithLimit(t, 2) - ctx := context.Background() - attempt := reloadReceipt(t, f).Attempt - // Strip the whole budget group -> a legacy receipt (still valid: all six empty). - seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { - r.ResolverBudgetVersion = "" - r.ResolverLimit = "" - r.ResolverUsed = "" - r.ResolverReservationToken = "" - r.ResolverReservationStopped = "" - r.ResolverContinuationStarted = "" - }) - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, ConflictedPaths: []string{"feature.txt"}} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Reason != ReasonResolverBudgetUnavailable { - t.Fatalf("legacy continue = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonResolverBudgetUnavailable) - } - // Abort on the same legacy receipt still succeeds. - abort := FinalizeRebaseAbort(ctx, deps, f.repo.invocation, f.id, attempt, - ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverStuck}) - if abort.Result != ResultApplied || abort.Disposition != RebaseDispBlocked { - t.Fatalf("legacy abort = (%q, %q), want applied/blocked", abort.Result, abort.Disposition) - } - f.receiptAbsent(t) -} - -// TestFinalizeRebaseContinueRepeatedConsumedReservation proves a repeated continue -// with an already-consumed reservation cannot advance a later commit: the second -// call refuses (reservation-missing, the reservation was cleared) and stages nothing. -func TestFinalizeRebaseContinueRepeatedConsumedReservation(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - first := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if first.Result != ResultApplied { - t.Fatalf("first continue = %q (reason %q), want applied", first.Result, first.Reason) - } - if seam.calls != 1 { - t.Fatalf("first continue staged %d time(s), want 1", seam.calls) - } - // Replaying the same (now consumed) reservation must not stage again. - second := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if second.Result != ResultBlocked || second.Reason != ReasonRebaseReservationMissing { - t.Fatalf("repeated continue = (%q, %q), want blocked/%q", second.Result, second.Reason, ReasonRebaseReservationMissing) - } - if seam.calls != 1 { - t.Fatalf("repeated continue staged again (calls = %d); a consumed reservation cannot advance a later commit", seam.calls) - } -} - -// TestFinalizeRebaseContinueStartedAmbiguousRetains proves a response-lost -// continuation (continuation-started marked, live rebase still stopped on the SAME -// commit) is retained and blocked with no second continue — never blindly replayed. -func TestFinalizeRebaseContinueStartedAmbiguousRetains(t *testing.T) { - f, deps, attempt, token, stopped := reserveOnConflict(t, 2) - ctx := context.Background() - // The prior continuation marked started but its response was lost; the live - // rebase is still stopped on the reserved commit (stopped == X). - before := seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { - r.ResolverReservationStopped = stopped - r.ResolverContinuationStarted = "1" - }) - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultBlocked || res.Reason != ReasonRebaseContinuationAmbiguous { - t.Fatalf("ambiguous recovery = (%q, %q), want blocked/%q", res.Result, res.Reason, ReasonRebaseContinuationAmbiguous) - } - if seam.calls != 0 { - t.Errorf("an ambiguous recovery staged %d time(s); want 0 (no second continue)", seam.calls) - } - if after := reloadReceipt(t, f); after != before { - t.Fatalf("an ambiguous recovery mutated the receipt:\n before %+v\n after %+v", before, after) - } -} - -// TestFinalizeRebaseContinueStartedCompletedRecovers proves a response-lost -// continuation whose rebase provably completed (RebaseState clean, head descends -// the base) is recovered WITHOUT another charge: the reservation is reconciled, the -// gate composes, and used is preserved — no second StageAndContinueRebase. -func TestFinalizeRebaseContinueStartedCompletedRecovers(t *testing.T) { - f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { - r.ResolverContinuationStarted = "1" // a started continuation whose response was lost - }) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps := f.finalizeDeps(gh, &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}) - deps.ContinueGit = seam - - report := ResolverReport{ChangeID: f.id, Attempt: real.Attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: real.ResolverReservationToken} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) - if res.Result != ResultApplied || res.Gate == nil || res.Gate.Evidence == "" { - t.Fatalf("completed recovery = %q gate %+v (reason %q), want applied with gate evidence", res.Result, res.Gate, res.Reason) - } - if seam.calls != 0 { - t.Errorf("a completed recovery staged %d time(s); want 0 (recover without another continue)", seam.calls) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" { - t.Errorf("a completed recovery left the reservation outstanding: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) - } - if rec.ResolverUsed != real.ResolverUsed { - t.Errorf("used = %q after recovery, want %q preserved (no new charge)", rec.ResolverUsed, real.ResolverUsed) - } -} - -// TestFinalizeRebaseContinueReconcileWriteFailurePreserves (change 0411, AC1) -// proves a receipt-write failure injected ONLY at post-continue reservation -// reconciliation (the started marker landed durably first) keeps the unchanged -// error result/disposition/reason, emits a message naming the same-attempt -// finalize.rebase-continue remedy WITHOUT claiming the whole rebase finished, -// preserves the outstanding reservation + started marker + used count, and never -// runs the gate before reconciliation succeeds. -func TestFinalizeRebaseContinueReconcileWriteFailurePreserves(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) // used == 1 - ctx := context.Background() - writeRepoFile(t, f.wp, "feature.txt", "reconciled content\n") // resolve so the continue completes - ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 1, fail: true} // marker write passes, reconcile write faults - deps.Workspace = ws - gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed}} - deps.Gate = gate - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultExternalFailed || res.Disposition != RebaseDispBlocked || res.Reason != ReasonRebaseReceiptWrite { - t.Fatalf("continue = (%q, %q, %q), want external-failed/blocked/%q unchanged", res.Result, res.Disposition, res.Reason, ReasonRebaseReceiptWrite) - } - if !strings.Contains(res.Message, "finalize.rebase-continue") || - !strings.Contains(res.Message, "same change id, owned attempt, and original resolved report") || - !strings.Contains(res.Message, errReconcileWrite.Error()) { - t.Errorf("message %q must name the same-attempt finalize.rebase-continue remedy and keep the write error", res.Message) - } - if strings.Contains(res.Message, "rebase completed") { - t.Errorf("message %q may not assert the whole rebase finished at the post-continue site", res.Message) - } - if gate.calls != 0 { - t.Errorf("gate ran %d time(s) before reconciliation succeeded; want 0", gate.calls) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != token || rec.ResolverContinuationStarted != "1" || rec.ResolverUsed != "1" { - t.Errorf("receipt after failed reconcile: token %q cont %q used %q, want reservation + started marker + used preserved", rec.ResolverReservationToken, rec.ResolverContinuationStarted, rec.ResolverUsed) - } -} - -// TestFinalizeRebaseContinueReconcileWriteFailureNextConflict (change 0411, AC3) -// proves the post-continue reconcile-write failure message stays honest when the -// continue surfaced ANOTHER conflict: same remedy, no completion claim, receipt -// retained with the reservation outstanding. -func TestFinalizeRebaseContinueReconcileWriteFailureNextConflict(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f, - script: &gitcli.RebaseStatus{Disposition: gitcli.RebaseConflicted, HeadOID: gitcli.ObjectID(strings.Repeat("c", 40)), UnmergedPaths: []string{"feature.txt"}}} - deps.ContinueGit = seam - ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 1, fail: true} - deps.Workspace = ws - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { - t.Fatalf("continue = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) - } - if !strings.Contains(res.Message, "finalize.rebase-continue") || strings.Contains(res.Message, "rebase completed") { - t.Errorf("message %q must name the remedy and never claim completion while a conflict remains", res.Message) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != token || rec.ResolverContinuationStarted != "1" { - t.Errorf("receipt lost the outstanding reservation: token %q cont %q", rec.ResolverReservationToken, rec.ResolverContinuationStarted) - } -} - -// TestFinalizeRebaseContinueStartedCompletedRecoveryWriteFails (change 0411, AC2) -// proves the completed-rebase recovery branch's failed reconciliation write emits -// the completed-specific remedy message, repeats no staging, preserves the receipt -// — and that restoring writes and retrying the SAME report recovers: reservation -// cleared only by the existing recovery, used preserved, gate composed. -func TestFinalizeRebaseContinueStartedCompletedRecoveryWriteFails(t *testing.T) { - f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { - r.ResolverContinuationStarted = "1" - }) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps := f.finalizeDeps(gh, &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}}) - deps.ContinueGit = seam - ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} // the recovery's one write faults - deps.Workspace = ws - - report := ResolverReport{ChangeID: f.id, Attempt: real.Attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: real.ResolverReservationToken} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) - if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { - t.Fatalf("recovery write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) - } - if !strings.Contains(res.Message, "owned rebase completed") || !strings.Contains(res.Message, "finalize.rebase-continue") || - !strings.Contains(res.Message, errReconcileWrite.Error()) { - t.Errorf("message %q must say the owned rebase completed, name the remedy, and keep the write error", res.Message) - } - if seam.calls != 0 { - t.Errorf("recovery staged %d time(s); want 0", seam.calls) - } - if rec := reloadReceipt(t, f); rec.ResolverReservationToken == "" || rec.ResolverContinuationStarted != "1" || rec.ResolverUsed != real.ResolverUsed { - t.Errorf("failed recovery mutated the receipt: %+v", rec) - } - - ws.fail = false // durable writes restored — retry the SAME report - res2 := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, real.Attempt, report) - if res2.Result != ResultApplied || res2.Gate == nil { - t.Fatalf("retry = %q gate %+v (reason %q), want applied with gate", res2.Result, res2.Gate, res2.Reason) - } - if seam.calls != 0 { - t.Errorf("retry staged %d time(s); want 0 (no repeated Git continuation)", seam.calls) - } - rec := reloadReceipt(t, f) - if rec.ResolverReservationToken != "" || rec.ResolverContinuationStarted != "" || rec.ResolverUsed != real.ResolverUsed { - t.Errorf("retry left receipt %+v; want reservation cleared, used %q preserved (no charge, no refund)", rec, real.ResolverUsed) - } -} - -// TestFinalizeRebaseContinueStartedAdvancedRecoveryWriteFails (change 0411, AC3) -// proves the advanced-conflict recovery branch's failed reconciliation write says -// the continuation advanced — never that the rebase completed — and a retry after -// restoring writes surfaces the next conflict without replaying the continuation. -func TestFinalizeRebaseContinueStartedAdvancedRecoveryWriteFails(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - // The receipt records a DIFFERENT stopped commit than the live rebase, so the - // started continuation provably advanced. - seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { - r.ResolverReservationStopped = strings.Repeat("d", 40) - r.ResolverContinuationStarted = "1" - }) - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - ws := &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} - deps.Workspace = ws - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { - t.Fatalf("advanced recovery write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) - } - if !strings.Contains(res.Message, "advanced to another conflict") || strings.Contains(res.Message, "rebase completed") || - !strings.Contains(res.Message, "finalize.rebase-continue") { - t.Errorf("message %q must say advanced-to-another-conflict, name the remedy, and never claim completion", res.Message) - } - if seam.calls != 0 { - t.Errorf("recovery staged %d time(s); want 0", seam.calls) - } - - ws.fail = false - res2 := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res2.Result != ResultApplied || res2.Disposition != RebaseDispConflicted { - t.Fatalf("retry = (%q, %q, reason %q), want applied/conflicted surfacing the live conflict", res2.Result, res2.Disposition, res2.Reason) - } - if seam.calls != 0 { - t.Errorf("retry replayed the continuation %d time(s); want 0", seam.calls) - } -} - -// TestFinalizeRebaseContinueMarkerWriteFailureNoRecoveryClaim (change 0411, AC4) -// proves a write failure BEFORE Git ran (the continuation-started marker) does not -// acquire the post-completion recovery remedy: same reason, no remedy phrase. -func TestFinalizeRebaseContinueMarkerWriteFailureNoRecoveryClaim(t *testing.T) { - f, deps, attempt, token, _ := reserveOnConflict(t, 2) - ctx := context.Background() - seam := &stageSeam{FinalizeContinueGit: f.deps.Client, f: f} - deps.ContinueGit = seam - deps.Workspace = &reconcileFailWorkspace{FinalizeWorkspace: f.svc, allow: 0, fail: true} // the FIRST write (marker) faults - - report := ResolverReport{ChangeID: f.id, Attempt: attempt, Disposition: ResolverResolved, - ConflictedPaths: []string{"feature.txt"}, ResolverReservation: token} - res := FinalizeRebaseContinue(ctx, deps, f.repo.invocation, f.id, attempt, report) - if res.Result != ResultExternalFailed || res.Reason != ReasonRebaseReceiptWrite { - t.Fatalf("marker write-fail = (%q, %q), want external-failed/%q", res.Result, res.Reason, ReasonRebaseReceiptWrite) - } - if strings.Contains(res.Message, "finalize.rebase-continue") { - t.Errorf("pre-continue marker write failure %q must not carry the post-completion recovery remedy", res.Message) - } - if seam.calls != 0 { - t.Errorf("a failed marker write staged %d time(s); want 0", seam.calls) - } -} - // TestMapDriveOutcomeCarriesRefusalDetail proves a Start/Advance command failure // that carried a typed refusal (no drive document) maps to a halted // LocalGateResult that PRESERVES reason/message/stage/locator beside the coarse @@ -1344,139 +717,3 @@ func TestMapDriveOutcomeCarriesRefusalDetail(t *testing.T) { t.Fatalf("detail-less failure grew detail: %+v", bare) } } - -// TestFinalizeRebaseGateHaltCarriesAdmissionRefusal proves the composition -// carries the halt detail into GateReport (JSON) and the human line, keeping the -// blocked disposition, rebase-gate-halted reason, and unavailable halt cause. -func TestFinalizeRebaseGateHaltCarriesAdmissionRefusal(t *testing.T) { - f := setupRebaseFixture(t, planRepoModes()[0]) - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} - gate := &fakeGate{result: LocalGateResult{ - Outcome: FinalizeGateHalted, HaltCause: GateHaltUnavailable, - HaltReason: "worktree-busy", - HaltMessage: "a raw gate run occupies this worktree's execution slot; settle it with docket gate stop '/runs/x' --reason ", - HaltStage: stageWorktreeAdmission, - HaltLocator: "incumbent-run:0123456789abcdef0123456789abcdef", - }} - res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if gate.calls != 1 { - t.Fatalf("gate ran %d time(s); want exactly 1 (the halt must come from a run)", gate.calls) - } - if res.Result != ResultBlocked || res.Reason != ReasonRebaseGateHalted { - t.Fatalf("result/reason = %q/%q, want blocked/%q", res.Result, res.Reason, ReasonRebaseGateHalted) - } - if res.Gate == nil { - t.Fatal("no gate report on a halted composition") - } - if res.Gate.HaltCause != GateHaltUnavailable { - t.Fatalf("halt cause = %q, want %q", res.Gate.HaltCause, GateHaltUnavailable) - } - if res.Gate.Reason != "worktree-busy" || res.Gate.Stage != stageWorktreeAdmission || - res.Gate.Locator != "incumbent-run:0123456789abcdef0123456789abcdef" { - t.Fatalf("gate detail dropped: reason=%q stage=%q locator=%q", res.Gate.Reason, res.Gate.Stage, res.Gate.Locator) - } - if !strings.Contains(res.Gate.Message, "gate stop") { - t.Fatalf("gate message %q lacks the remedy", res.Gate.Message) - } - if res.Gate.RunDir != "" { - t.Fatalf("run_dir carries incumbent facts: %q", res.Gate.RunDir) - } - human := res.HumanText() - if !strings.Contains(human, "worktree-busy") || - !strings.Contains(human, "incumbent-run:0123456789abcdef0123456789abcdef") || - !strings.Contains(human, "gate stop") { - t.Fatalf("human line %q lacks reason + locator + remedy", human) - } -} - -// TestFinalizeRebaseGateHaltGenericUnchanged proves a detail-less halt keeps -// today's generic output exactly: Gate.Reason/Message/Stage/Locator all empty, -// the generic result message, and a HumanText without any locator fragment. -func TestFinalizeRebaseGateHaltGenericUnchanged(t *testing.T) { - f := setupRebaseFixture(t, planRepoModes()[0]) - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} - gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGateHalted, HaltCause: GateHaltUnavailable}} - res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, - FinalizeRebaseRequest{ID: f.id, Version: f.version, Head: f.head}) - if res.Result != ResultBlocked || res.Reason != ReasonRebaseGateHalted { - t.Fatalf("result/reason = %q/%q, want blocked/%q", res.Result, res.Reason, ReasonRebaseGateHalted) - } - if res.Gate == nil { - t.Fatal("no gate report on a halted composition") - } - if res.Gate.Reason != "" || res.Gate.Message != "" || res.Gate.Stage != "" || res.Gate.Locator != "" { - t.Fatalf("detail-less halt grew detail: %+v", res.Gate) - } - if res.Message != "the local gate did not reach a decidable pass/fail; retained, no red fabricated" { - t.Fatalf("generic halt message changed: %q", res.Message) - } - human := res.HumanText() - if strings.Contains(human, "[gate:") || strings.Contains(human, "incumbent-") { - t.Fatalf("generic human line carries a locator fragment: %q", human) - } -} - -// TestMutateReceiptForAttemptSkipsSuperseded covers the attempt-identity guard -// (change 0438): a receipt writer observing attempt "A" must not modify a -// receipt that now records attempt "B" (a refresh superseded the rewrite -// mid-flight); the same helper writes when the observed attempt still matches. -func TestMutateReceiptForAttemptSkipsSuperseded(t *testing.T) { - f := setupRebaseFixture(t, planRepoModes()[0]) - ctx := context.Background() - deps := f.finalizeDeps(nil, nil) - rc := &rebaseContext{metaDir: f.metaDir} - - recB := workspace.RebaseReceipt{ - RepoIdentity: f.gitrepo.CommonDir, - ChangeID: fmt.Sprintf("%d", f.id), - OrigHead: strings.Repeat("a", 40), - OrigRemoteHead: strings.Repeat("a", 40), - BaseRef: "refs/heads/main", - BaseHead: strings.Repeat("b", 40), - Attempt: "B", - GateDriveID: "drive-1", - GateOwnerGeneration: "gen-1", - CreatedUTC: "2026-09-20T00:00:00Z", - } - if err := f.svc.WriteRebaseReceipt(ctx, f.metaDir, recB); err != nil { - t.Fatalf("seed receipt B: %v", err) - } - - mutate := func(r *workspace.RebaseReceipt) { r.GateDriveID, r.GateOwnerGeneration = "", "" } - - // Observing attempt "A" over an on-disk attempt-"B" receipt: superseded, skip. - written, err := mutateReceiptForAttempt(ctx, deps, rc, "A", mutate) - if err != nil { - t.Fatalf("mutate for A: unexpected err %v", err) - } - if written { - t.Errorf("mutate for A reported written; the superseded write must not land") - } - after, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) - if err != nil || !present { - t.Fatalf("read after A: present=%v err=%v", present, err) - } - if after != recB { - t.Errorf("the superseded write mutated the receipt:\n got %+v\nwant byte-identical %+v", after, recB) - } - - // Observing attempt "B" (the current on-disk token): the write lands, and - // only the mutated fields change. - written, err = mutateReceiptForAttempt(ctx, deps, rc, "B", mutate) - if err != nil { - t.Fatalf("mutate for B: unexpected err %v", err) - } - if !written { - t.Errorf("mutate for B did not report written") - } - got, present, err := f.svc.ReadRebaseReceipt(ctx, f.metaDir) - if err != nil || !present { - t.Fatalf("read after B: present=%v err=%v", present, err) - } - want := recB - want.GateDriveID, want.GateOwnerGeneration = "", "" - if got != want { - t.Errorf("mutate for B changed more than the gate pair:\n got %+v\nwant %+v", got, want) - } -} diff --git a/internal/app/finalize_reserve_helpers_test.go b/internal/app/finalize_reserve_helpers_test.go new file mode 100644 index 000000000..f87e82c8f --- /dev/null +++ b/internal/app/finalize_reserve_helpers_test.go @@ -0,0 +1,20 @@ +package app + +// Change 0465: liveStoppedCommit stays in the default build — the untagged +// finalize_rebase_test.go still uses it — while the real-git resolver-reserve tests +// moved behind the integration tag (finalize_reserve_integration_test.go). + +import ( + "context" + "testing" +) + +// liveStoppedCommit is the full object id the live conflicted rebase is stopped on. +func liveStoppedCommit(t *testing.T, f *rebaseFixture) string { + t.Helper() + oid, err := f.deps.Client.StoppedRebaseCommit(context.Background(), f.wp) + if err != nil { + t.Fatalf("probe live stopped commit: %v", err) + } + return string(oid) +} diff --git a/internal/app/finalize_reserve_test.go b/internal/app/finalize_reserve_integration_test.go similarity index 89% rename from internal/app/finalize_reserve_test.go rename to internal/app/finalize_reserve_integration_test.go index 998006b34..b6176410a 100644 --- a/internal/app/finalize_reserve_test.go +++ b/internal/app/finalize_reserve_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -47,16 +49,6 @@ func seedReserveReceipt(t *testing.T, f *rebaseFixture, mutate func(*workspace.R return reloadReceipt(t, f) } -// liveStoppedCommit is the full object id the live conflicted rebase is stopped on. -func liveStoppedCommit(t *testing.T, f *rebaseFixture) string { - t.Helper() - oid, err := f.deps.Client.StoppedRebaseCommit(context.Background(), f.wp) - if err != nil { - t.Fatalf("probe live stopped commit: %v", err) - } - return string(oid) -} - var errReserveProbe = errors.New("reserve probe boom") var errReserveWrite = errors.New("reserve write boom") @@ -101,11 +93,11 @@ func (w *writeFailWorkspace) WriteRebaseReceipt(ctx context.Context, dir string, // --- reserved ------------------------------------------------------------- -// TestFinalizeResolverReserveReserved proves a first reservation over a budgeted +// TestIntegrationFinalizeOpsFinalizeResolverReserveReserved proves a first reservation over a budgeted // receipt with capacity durably increments used, records the reservation token and // the live stopped commit, and returns `reserved` with the post-increment counts — // only after the receipt lands. -func TestFinalizeResolverReserveReserved(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveReserved(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() wantStopped := liveStoppedCommit(t, f) @@ -154,10 +146,10 @@ func TestFinalizeResolverReserveReserved(t *testing.T) { // --- pending -------------------------------------------------------------- -// TestFinalizeResolverReservePending proves that when a reservation is already +// TestIntegrationFinalizeOpsFinalizeResolverReservePending proves that when a reservation is already // outstanding, reserve echoes the same token as `pending` and admits nothing new // (no double admission): the used count is unchanged. -func TestFinalizeResolverReservePending(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReservePending(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 3) ctx := context.Background() stopped := liveStoppedCommit(t, f) @@ -188,10 +180,10 @@ func TestFinalizeResolverReservePending(t *testing.T) { // --- exhausted ------------------------------------------------------------ -// TestFinalizeResolverReserveExhausted proves that used == limit (no outstanding +// TestIntegrationFinalizeOpsFinalizeResolverReserveExhausted proves that used == limit (no outstanding // token) returns `exhausted` with reason resolver-budget-exhausted, the counts, // and no Git or receipt change. -func TestFinalizeResolverReserveExhausted(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveExhausted(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() before := seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { @@ -216,9 +208,9 @@ func TestFinalizeResolverReserveExhausted(t *testing.T) { // --- legacy --------------------------------------------------------------- -// TestFinalizeResolverReserveLegacy proves a legacy receipt (no budget group) is +// TestIntegrationFinalizeOpsFinalizeResolverReserveLegacy proves a legacy receipt (no budget group) is // refused with blocked/resolver-budget-unavailable and left unchanged. -func TestFinalizeResolverReserveLegacy(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveLegacy(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() before := seedReserveReceipt(t, f, func(r *workspace.RebaseReceipt) { @@ -242,10 +234,10 @@ func TestFinalizeResolverReserveLegacy(t *testing.T) { // --- foreign attempt ------------------------------------------------------ -// TestFinalizeResolverReserveForeignAttempt proves a wrong attempt token is +// TestIntegrationFinalizeOpsFinalizeResolverReserveForeignAttempt proves a wrong attempt token is // refused by the shared owned-attempt gate (blocked/attempt-token-mismatch) and // nothing is incremented. -func TestFinalizeResolverReserveForeignAttempt(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveForeignAttempt(t *testing.T) { f, _, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() @@ -261,10 +253,10 @@ func TestFinalizeResolverReserveForeignAttempt(t *testing.T) { // --- non-conflicted ------------------------------------------------------- -// TestFinalizeResolverReserveNonConflicted proves a budgeted receipt with no live +// TestIntegrationFinalizeOpsFinalizeResolverReserveNonConflicted proves a budgeted receipt with no live // conflict (the rebase already completed) is refused (blocked/no-conflict) and // left unchanged — there is nothing to reserve against. -func TestFinalizeResolverReserveNonConflicted(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveNonConflicted(t *testing.T) { f, gh, real := completedBudgetedReceipt(t, func(r *workspace.RebaseReceipt) { // A budgeted receipt with capacity and NO outstanding reservation. r.ResolverUsed = "0" @@ -286,10 +278,10 @@ func TestFinalizeResolverReserveNonConflicted(t *testing.T) { // --- stopped-commit probe error ------------------------------------------- -// TestFinalizeResolverReserveStoppedProbeError proves that if the stopped-commit +// TestIntegrationFinalizeOpsFinalizeResolverReserveStoppedProbeError proves that if the stopped-commit // probe errors (never a clean "not stopped"), reserve refuses (blocked) without // incrementing the budget. -func TestFinalizeResolverReserveStoppedProbeError(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveStoppedProbeError(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() deps.ReserveGit = &faultyReserveGit{FinalizeReserveGit: f.deps.Client, failStopped: true} @@ -310,10 +302,10 @@ func TestFinalizeResolverReserveStoppedProbeError(t *testing.T) { // --- write-failure injection ---------------------------------------------- -// TestFinalizeResolverReserveWriteFailureNoAdmission proves the no-permission- +// TestIntegrationFinalizeOpsFinalizeResolverReserveWriteFailureNoAdmission proves the no-permission- // before-durability rule: when the receipt write fails, no `reserved` disposition // is returned and the on-disk used count is unchanged. -func TestFinalizeResolverReserveWriteFailureNoAdmission(t *testing.T) { +func TestIntegrationFinalizeOpsFinalizeResolverReserveWriteFailureNoAdmission(t *testing.T) { f, begin, deps := beginConflictedWithLimit(t, 2) ctx := context.Background() before := reloadReceipt(t, f) @@ -334,11 +326,12 @@ func TestFinalizeResolverReserveWriteFailureNoAdmission(t *testing.T) { // --- concurrency ---------------------------------------------------------- -// TestFinalizeResolverReserveConcurrent proves the per-workspace operation lock +// TestRaceIntegrationAppConcurrencyFinalizeResolverReserveConcurrent proves the per-workspace operation lock // serializes two concurrent reservations: exactly one is `reserved`, the other is // `pending` (or contended), and the final used count is exactly 1 — no double // admission. -func TestFinalizeResolverReserveConcurrent(t *testing.T) { +// Race shard (change 0465): two goroutines race FinalizeResolverReserve against one workspace flock. +func TestRaceIntegrationAppConcurrencyFinalizeResolverReserveConcurrent(t *testing.T) { f, begin, _ := beginConflictedWithLimit(t, 3) // Each racing reservation gets its OWN process-like deps (own reader, service, diff --git a/internal/app/gate_integration_test.go b/internal/app/gate_integration_test.go new file mode 100644 index 000000000..233b57dd7 --- /dev/null +++ b/internal/app/gate_integration_test.go @@ -0,0 +1,266 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_gatelifecycle.sh (prefix ^TestIntegrationGateLifecycle). +// TestMain stays in the untagged gate_test.go: both builds need its supervisor and +// guardian re-exec routing. + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/gatedrive" + "github.com/danielhanold/docket/internal/testsupport" +) + +func TestIntegrationGateLifecycleGateLaunchInvalidInput(t *testing.T) { + res := GateLaunch("relative-root", "/", []string{"/bin/echo"}) + if res.Result != ResultInvalidInput { + t.Fatalf("result %s", res.Result) + } + if ExitCode(res.Result) != 2 { + t.Fatalf("exit mapping") + } +} + +// --- change 0375: raw gate.launch admits through the worktree execution slot --- + +// TestIntegrationGateLifecycleGateLaunchInsideWorktreeReservesSlot proves a raw launch whose cwd sits +// inside a registered worktree acquires the durable execution slot: after a PASSED +// start the slot is executing, carries this launch's raw run identity, is Kind +// "raw", and holds no drive id. +func TestIntegrationGateLifecycleGateLaunchInsideWorktreeReservesSlot(t *testing.T) { + requireRealGit(t) + worktree, gitDir := initGitRepo(t, "") + // A raw slot holds the worktree until a GateStop-proven teardown releases it, + // so even a fast command keeps the slot "executing" for the assertions below. + res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + t.Cleanup(func() { GateStop(res.RunDir, "test cleanup") }) + if res.Result != ResultApplied || res.RunDir == "" { + t.Fatalf("launch: result=%s reason=%q rundir=%q", res.Result, res.Reason, res.RunDir) + } + slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if got := string(slot.State); got != "executing" { + t.Fatalf("slot state = %q, want executing", got) + } + if slot.Kind != "raw" { + t.Fatalf("slot kind = %q, want raw", slot.Kind) + } + if slot.RawRunDir != res.RunDir || slot.RawRunID != res.RunID { + t.Fatalf("slot raw run = (%q,%q), want (%q,%q)", slot.RawRunID, slot.RawRunDir, res.RunID, res.RunDir) + } + if slot.DriveID != "" { + t.Fatalf("raw slot carries a drive id %q", slot.DriveID) + } +} + +// TestRaceIntegrationAppConcurrencyGateLaunchSecondRefusedWhileFirstLives proves a second raw launch into a +// worktree whose slot is live is refused worktree-busy — even from a DISTINCT run +// root — with no process spawned, and that the refusal locates the incumbent run +// (a safe locator) without leaking a reservation token. +// Race shard (change 0465): two raw launches contend for one worktree slot while the first run is still live. +func TestRaceIntegrationAppConcurrencyGateLaunchSecondRefusedWhileFirstLives(t *testing.T) { + requireRealGit(t) + worktree, _ := initGitRepo(t, "") + // The first run is genuinely LIVE (a long sleep), so the admission-boundary + // finished-incumbent reconciliation (change 0446 spec §3) has no teardown proof + // and the slot still blocks a second admission. + first := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/sleep", "60"}) + t.Cleanup(func() { GateStop(first.RunDir, "test cleanup") }) + if first.Result != ResultApplied || first.RunID == "" { + t.Fatalf("first launch: result=%s reason=%q", first.Result, first.Reason) + } + second := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if second.RunDir != "" { + GateStop(second.RunDir, "test cleanup") // never expected; avoid leaking a process + t.Fatalf("refused launch produced a run handle: %+v", second) + } + if second.Result != ResultBlocked { + t.Fatalf("second launch result = %s (reason %q), want blocked", second.Result, second.Reason) + } + if second.Reason != "worktree-busy" { + t.Fatalf("second launch reason = %q, want worktree-busy", second.Reason) + } + if !strings.Contains(second.Cause, first.RunID) { + t.Fatalf("refusal cause %q does not locate the incumbent run %q", second.Cause, first.RunID) + } +} + +// TestIntegrationGateLifecycleGateLaunchSettlesFinishedRawIncumbent (change 0446 spec §3): a COMPLETED raw +// run whose slot was never stopped no longer blocks the worktree. The next raw +// launch's normal admission proves the incumbent torn down through the process +// predicate, settles its slot, and admits — with no manual GateStop and no second +// launch attempt. +func TestIntegrationGateLifecycleGateLaunchSettlesFinishedRawIncumbent(t *testing.T) { + requireRealGit(t) + worktree, gitDir := initGitRepo(t, "") + first := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if first.Result != ResultApplied || first.RunDir == "" { + t.Fatalf("first launch: result=%s reason=%q", first.Result, first.Reason) + } + waitRawRunTornDown(t, first.RunDir) + if slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree); err != nil || string(slot.State) != "executing" { + t.Fatalf("a completed raw run keeps its slot occupied until settled: state=%q err=%v", string(slot.State), err) + } + + second := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if second.RunDir != "" { + t.Cleanup(func() { waitGateRunTerminal(t, second.RunDir); GateStop(second.RunDir, "test cleanup") }) + } + if second.Result != ResultApplied || second.RunDir == "" || second.RunDir == first.RunDir { + t.Fatalf("a proven-finished raw incumbent must not block the next launch: result=%s reason=%q cause=%q", + second.Result, second.Reason, second.Cause) + } + slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree) + if err != nil || slot.RawRunDir != second.RunDir { + t.Fatalf("the slot must now hold the second run, got %q err=%v", slot.RawRunDir, err) + } +} + +// waitRawRunTornDown polls the process predicate reconciliation consults until the +// run's supervisor has released it with a durable terminal record, so a following +// admission deterministically sees positive teardown proof. +func waitRawRunTornDown(t *testing.T, runDir string) { + t.Helper() + svc, _, reason := gateService() + if svc == nil { + t.Fatalf("gate service: %s", reason) + } + for i := 0; i < 300; i++ { + if e, err := svc.ClassifyRun(runDir, false); err == nil && e.Disposition == "terminal" { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("run never reached a torn-down terminal disposition") +} + +// TestIntegrationGateLifecycleGateLaunchOutsideGitUnchanged proves a launch whose cwd is outside any git +// worktree keeps its pre-admission contract: no slot is reserved, so a second +// launch in the same non-worktree cwd is not refused. +func TestIntegrationGateLifecycleGateLaunchOutsideGitUnchanged(t *testing.T) { + cwd := testsupport.TempDir(t) // not a git worktree + first := GateLaunch(testsupport.TempDir(t), cwd, []string{"/bin/echo", "hi"}) + if first.Result != ResultApplied || first.RunDir == "" { + t.Fatalf("first launch outside git: result=%s reason=%q", first.Result, first.Reason) + } + second := GateLaunch(testsupport.TempDir(t), cwd, []string{"/bin/echo", "hi"}) + if second.Result != ResultApplied || second.RunDir == "" { + t.Fatalf("second launch outside git: result=%s reason=%q", second.Result, second.Reason) + } +} + +// TestIntegrationGateLifecycleGateStopReleasesRawSlot proves GateStop's PROVEN teardown releases the raw +// slot the run held, so the worktree readmits. The teardown proof is the run's own +// terminal state: the launched command runs to completion (a no-op stop then +// observes it passed), which is the deterministic proof of a gone process group. +// A stop of a still-LIVE run cannot prove teardown in this supervisor-as-test-binary +// harness (the group TERM frees the live lock before a terminal record lands, so +// Stop is blocked — pre-existing behavior), and the fail-closed release correctly +// leaves such a slot untouched; this test pins the provable path Task 7 adds. +func TestIntegrationGateLifecycleGateStopReleasesRawSlot(t *testing.T) { + requireRealGit(t) + worktree, gitDir := initGitRepo(t, "") + store := gatedrive.OpenStore(gitDir) + + res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if res.Result != ResultApplied || res.RunDir == "" { + t.Fatalf("launch: result=%s reason=%q", res.Result, res.Reason) + } + // The slot is reserved and confirmed even for a fast command. + if slot, _, err := store.LoadWorktreeExecution(worktree); err != nil || string(slot.State) != "executing" { + t.Fatalf("pre-stop slot state=%q err=%v", string(slot.State), err) + } + // Let the run reach a terminal state so the stop's teardown is provable. + waitGateRunTerminal(t, res.RunDir) + + stop := GateStop(res.RunDir, "test cleanup") + if stop.Result != ResultNoOp { + t.Fatalf("stop of a terminal run result = %s (%s), want no-op", stop.Result, stop.Reason) + } + slot, _, err := store.LoadWorktreeExecution(worktree) + if err != nil { + t.Fatalf("post-stop LoadWorktreeExecution: %v", err) + } + if got := string(slot.State); got != "released" { + t.Fatalf("post-stop slot state = %q, want released", got) + } + readmit := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if readmit.Result != ResultApplied { + t.Fatalf("readmission after release: result=%s reason=%q", readmit.Result, readmit.Reason) + } +} + +// waitGateRunTerminal polls GateObserve until the run leaves the running state or a +// generous deadline elapses, so a following stop is a proven-terminal no-op. +func waitGateRunTerminal(t *testing.T, runDir string) { + t.Helper() + for i := 0; i < 300; i++ { + if GateObserve(runDir).State != "running" { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("run never became terminal") +} + +// TestIntegrationGateLifecycleGateLaunchLegacyInventoryRefusalNamesMatchedDrive (change 0446 spec §6): a +// raw launch refused by the first-admission legacy inventory — a nonterminal +// historical drive bound to THIS worktree — carries the drive's locator as its +// Cause and the inventory summary whose finding names the matched worktree, +// instead of a bare unresolved-execution with an empty cause. A second, unrelated +// worktree of the same repository is not vetoed by that record. +func TestIntegrationGateLifecycleGateLaunchLegacyInventoryRefusalNamesMatchedDrive(t *testing.T) { + requireRealGit(t) + worktree, gitDir := initGitRepo(t, "") + const id = "0446bbbbbbbbbbbbbbbbbbbbbbbbbb01" + dir := filepath.Join(gitDir, "docket", "gate-drives", "v1", id) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + record := `{"generation":"g","record":{"schema_version":2,"repo_identity":"` + gitDir + + `","worktree_path":"` + worktree + `","started_at":"2026-08-01T14:00:00Z","last_outcome":"WAITING"}}` + if err := os.WriteFile(filepath.Join(dir, "record.json"), []byte(record), 0o600); err != nil { + t.Fatal(err) + } + + res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) + if res.RunDir != "" { + GateStop(res.RunDir, "test cleanup") + t.Fatalf("refused launch produced a run handle: %+v", res) + } + if res.Result != ResultBlocked || res.Reason != string(gatedrive.ErrUnresolvedExecution) { + t.Fatalf("result/reason = %s/%q, want blocked/unresolved-execution", res.Result, res.Reason) + } + if res.Cause != "inventory-legacy-drive-"+id { + t.Fatalf("raw refusal cause = %q, want the matched drive locator", res.Cause) + } + if res.LegacyHistory == nil || len(res.LegacyHistory.Retained) != 1 || + res.LegacyHistory.Retained[0].DriveID != id || res.LegacyHistory.Retained[0].Worktree != worktree { + t.Fatalf("raw refusal must carry the matched finding naming its worktree, got %+v", res.LegacyHistory) + } + if !strings.Contains(res.HumanText(), "cause: inventory-legacy-drive-"+id) { + t.Fatalf("human text must render the locator:\n%s", res.HumanText()) + } + + // The same record never vetoes a different worktree of the same repository. + other := filepath.Join(testsupport.TempDir(t), "other") + runGit(t, worktree, "commit", "--allow-empty", "-m", "base") + runGit(t, worktree, "worktree", "add", other) + ok := GateLaunch(testsupport.TempDir(t), other, []string{"/bin/echo", "hi"}) + if ok.RunDir != "" { + t.Cleanup(func() { waitGateRunTerminal(t, ok.RunDir); GateStop(ok.RunDir, "test cleanup") }) + } + if ok.Result != ResultApplied { + t.Fatalf("an unrelated worktree must admit, got %s (%q, cause %q)", ok.Result, ok.Reason, ok.Cause) + } +} diff --git a/internal/app/gate_test.go b/internal/app/gate_test.go index 1c26f0f16..d05f7b343 100644 --- a/internal/app/gate_test.go +++ b/internal/app/gate_test.go @@ -7,27 +7,30 @@ import ( "github.com/danielhanold/docket/internal/process" "github.com/danielhanold/docket/internal/testsupport" "os" - "path/filepath" "strings" "testing" - "time" ) // TestMain routes the supervisor re-exec role of the app test binary: a real // GateLaunch re-executes this binary with the private supervisor env var set, // and it must become the supervisor rather than re-running the test suite. // Ordinary `go test` runs set neither and fall through to m.Run. +// Ordinary runs then install the default-build no-real-git guard (change 0465) around m.Run. func TestMain(m *testing.M) { if process.SupervisorRequested() { os.Exit(process.RunSupervisorFromEnv()) } - // Route the death-guardian re-exec role: an agent_guardian_test.go real-process + // Route the death-guardian re-exec role: an agent_guardian_integration_test.go real-process // test re-execs THIS binary as a detached guardian, which must run the guardian // lifetime rather than re-running the suite (change 0375 Task 13). if GuardianRequested() { os.Exit(RunAgentGuardianFromEnv()) } - os.Exit(m.Run()) + // Change 0465: the default build installs the no-real-git guard (nogit_guard_test.go) + // AFTER the re-exec routing above, so the supervisor and guardian roles behave + // exactly as before; tagged builds get the no-op twin (nogit_guard_off_test.go). + finish := installNoGitGuard() + os.Exit(finish(m.Run())) } func TestMapObservationTable(t *testing.T) { @@ -46,16 +49,6 @@ func TestMapObservationTable(t *testing.T) { } } -func TestGateLaunchInvalidInput(t *testing.T) { - res := GateLaunch("relative-root", "/", []string{"/bin/echo"}) - if res.Result != ResultInvalidInput { - t.Fatalf("result %s", res.Result) - } - if ExitCode(res.Result) != 2 { - t.Fatalf("exit mapping") - } -} - func TestGateRecoverNormalizesEmptyEntries(t *testing.T) { res := GateRecover(testsupport.TempDir(t)) if res.Result != ResultNoOp { @@ -78,189 +71,6 @@ func TestGateResultHumanTextStable(t *testing.T) { } } -// --- change 0375: raw gate.launch admits through the worktree execution slot --- - -// TestGateLaunchInsideWorktreeReservesSlot proves a raw launch whose cwd sits -// inside a registered worktree acquires the durable execution slot: after a PASSED -// start the slot is executing, carries this launch's raw run identity, is Kind -// "raw", and holds no drive id. -func TestGateLaunchInsideWorktreeReservesSlot(t *testing.T) { - requireRealGit(t) - worktree, gitDir := initGitRepo(t, "") - // A raw slot holds the worktree until a GateStop-proven teardown releases it, - // so even a fast command keeps the slot "executing" for the assertions below. - res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - t.Cleanup(func() { GateStop(res.RunDir, "test cleanup") }) - if res.Result != ResultApplied || res.RunDir == "" { - t.Fatalf("launch: result=%s reason=%q rundir=%q", res.Result, res.Reason, res.RunDir) - } - slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree) - if err != nil { - t.Fatalf("LoadWorktreeExecution: %v", err) - } - if got := string(slot.State); got != "executing" { - t.Fatalf("slot state = %q, want executing", got) - } - if slot.Kind != "raw" { - t.Fatalf("slot kind = %q, want raw", slot.Kind) - } - if slot.RawRunDir != res.RunDir || slot.RawRunID != res.RunID { - t.Fatalf("slot raw run = (%q,%q), want (%q,%q)", slot.RawRunID, slot.RawRunDir, res.RunID, res.RunDir) - } - if slot.DriveID != "" { - t.Fatalf("raw slot carries a drive id %q", slot.DriveID) - } -} - -// TestGateLaunchSecondRefusedWhileFirstLives proves a second raw launch into a -// worktree whose slot is live is refused worktree-busy — even from a DISTINCT run -// root — with no process spawned, and that the refusal locates the incumbent run -// (a safe locator) without leaking a reservation token. -func TestGateLaunchSecondRefusedWhileFirstLives(t *testing.T) { - requireRealGit(t) - worktree, _ := initGitRepo(t, "") - // The first run is genuinely LIVE (a long sleep), so the admission-boundary - // finished-incumbent reconciliation (change 0446 spec §3) has no teardown proof - // and the slot still blocks a second admission. - first := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/sleep", "60"}) - t.Cleanup(func() { GateStop(first.RunDir, "test cleanup") }) - if first.Result != ResultApplied || first.RunID == "" { - t.Fatalf("first launch: result=%s reason=%q", first.Result, first.Reason) - } - second := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if second.RunDir != "" { - GateStop(second.RunDir, "test cleanup") // never expected; avoid leaking a process - t.Fatalf("refused launch produced a run handle: %+v", second) - } - if second.Result != ResultBlocked { - t.Fatalf("second launch result = %s (reason %q), want blocked", second.Result, second.Reason) - } - if second.Reason != "worktree-busy" { - t.Fatalf("second launch reason = %q, want worktree-busy", second.Reason) - } - if !strings.Contains(second.Cause, first.RunID) { - t.Fatalf("refusal cause %q does not locate the incumbent run %q", second.Cause, first.RunID) - } -} - -// TestGateLaunchSettlesFinishedRawIncumbent (change 0446 spec §3): a COMPLETED raw -// run whose slot was never stopped no longer blocks the worktree. The next raw -// launch's normal admission proves the incumbent torn down through the process -// predicate, settles its slot, and admits — with no manual GateStop and no second -// launch attempt. -func TestGateLaunchSettlesFinishedRawIncumbent(t *testing.T) { - requireRealGit(t) - worktree, gitDir := initGitRepo(t, "") - first := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if first.Result != ResultApplied || first.RunDir == "" { - t.Fatalf("first launch: result=%s reason=%q", first.Result, first.Reason) - } - waitRawRunTornDown(t, first.RunDir) - if slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree); err != nil || string(slot.State) != "executing" { - t.Fatalf("a completed raw run keeps its slot occupied until settled: state=%q err=%v", string(slot.State), err) - } - - second := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if second.RunDir != "" { - t.Cleanup(func() { waitGateRunTerminal(t, second.RunDir); GateStop(second.RunDir, "test cleanup") }) - } - if second.Result != ResultApplied || second.RunDir == "" || second.RunDir == first.RunDir { - t.Fatalf("a proven-finished raw incumbent must not block the next launch: result=%s reason=%q cause=%q", - second.Result, second.Reason, second.Cause) - } - slot, _, err := gatedrive.OpenStore(gitDir).LoadWorktreeExecution(worktree) - if err != nil || slot.RawRunDir != second.RunDir { - t.Fatalf("the slot must now hold the second run, got %q err=%v", slot.RawRunDir, err) - } -} - -// waitRawRunTornDown polls the process predicate reconciliation consults until the -// run's supervisor has released it with a durable terminal record, so a following -// admission deterministically sees positive teardown proof. -func waitRawRunTornDown(t *testing.T, runDir string) { - t.Helper() - svc, _, reason := gateService() - if svc == nil { - t.Fatalf("gate service: %s", reason) - } - for i := 0; i < 300; i++ { - if e, err := svc.ClassifyRun(runDir, false); err == nil && e.Disposition == "terminal" { - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatal("run never reached a torn-down terminal disposition") -} - -// TestGateLaunchOutsideGitUnchanged proves a launch whose cwd is outside any git -// worktree keeps its pre-admission contract: no slot is reserved, so a second -// launch in the same non-worktree cwd is not refused. -func TestGateLaunchOutsideGitUnchanged(t *testing.T) { - cwd := testsupport.TempDir(t) // not a git worktree - first := GateLaunch(testsupport.TempDir(t), cwd, []string{"/bin/echo", "hi"}) - if first.Result != ResultApplied || first.RunDir == "" { - t.Fatalf("first launch outside git: result=%s reason=%q", first.Result, first.Reason) - } - second := GateLaunch(testsupport.TempDir(t), cwd, []string{"/bin/echo", "hi"}) - if second.Result != ResultApplied || second.RunDir == "" { - t.Fatalf("second launch outside git: result=%s reason=%q", second.Result, second.Reason) - } -} - -// TestGateStopReleasesRawSlot proves GateStop's PROVEN teardown releases the raw -// slot the run held, so the worktree readmits. The teardown proof is the run's own -// terminal state: the launched command runs to completion (a no-op stop then -// observes it passed), which is the deterministic proof of a gone process group. -// A stop of a still-LIVE run cannot prove teardown in this supervisor-as-test-binary -// harness (the group TERM frees the live lock before a terminal record lands, so -// Stop is blocked — pre-existing behavior), and the fail-closed release correctly -// leaves such a slot untouched; this test pins the provable path Task 7 adds. -func TestGateStopReleasesRawSlot(t *testing.T) { - requireRealGit(t) - worktree, gitDir := initGitRepo(t, "") - store := gatedrive.OpenStore(gitDir) - - res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if res.Result != ResultApplied || res.RunDir == "" { - t.Fatalf("launch: result=%s reason=%q", res.Result, res.Reason) - } - // The slot is reserved and confirmed even for a fast command. - if slot, _, err := store.LoadWorktreeExecution(worktree); err != nil || string(slot.State) != "executing" { - t.Fatalf("pre-stop slot state=%q err=%v", string(slot.State), err) - } - // Let the run reach a terminal state so the stop's teardown is provable. - waitGateRunTerminal(t, res.RunDir) - - stop := GateStop(res.RunDir, "test cleanup") - if stop.Result != ResultNoOp { - t.Fatalf("stop of a terminal run result = %s (%s), want no-op", stop.Result, stop.Reason) - } - slot, _, err := store.LoadWorktreeExecution(worktree) - if err != nil { - t.Fatalf("post-stop LoadWorktreeExecution: %v", err) - } - if got := string(slot.State); got != "released" { - t.Fatalf("post-stop slot state = %q, want released", got) - } - readmit := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if readmit.Result != ResultApplied { - t.Fatalf("readmission after release: result=%s reason=%q", readmit.Result, readmit.Reason) - } -} - -// waitGateRunTerminal polls GateObserve until the run leaves the running state or a -// generous deadline elapses, so a following stop is a proven-terminal no-op. -func waitGateRunTerminal(t *testing.T, runDir string) { - t.Helper() - for i := 0; i < 300; i++ { - if GateObserve(runDir).State != "running" { - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatal("run never became terminal") -} - // TestGateLaunchRefusalCauseFromSnapshot proves the admission-refusal cause is // derived from the refusal's own incumbent snapshot, not a post-refusal re-read: // a snapshot-bearing error yields its locator; a snapshot-free error yields "". @@ -278,55 +88,3 @@ func TestGateLaunchRefusalCauseFromSnapshot(t *testing.T) { t.Fatalf("non-ownership cause = %q, want empty", got) } } - -// TestGateLaunchLegacyInventoryRefusalNamesMatchedDrive (change 0446 spec §6): a -// raw launch refused by the first-admission legacy inventory — a nonterminal -// historical drive bound to THIS worktree — carries the drive's locator as its -// Cause and the inventory summary whose finding names the matched worktree, -// instead of a bare unresolved-execution with an empty cause. A second, unrelated -// worktree of the same repository is not vetoed by that record. -func TestGateLaunchLegacyInventoryRefusalNamesMatchedDrive(t *testing.T) { - requireRealGit(t) - worktree, gitDir := initGitRepo(t, "") - const id = "0446bbbbbbbbbbbbbbbbbbbbbbbbbb01" - dir := filepath.Join(gitDir, "docket", "gate-drives", "v1", id) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - record := `{"generation":"g","record":{"schema_version":2,"repo_identity":"` + gitDir + - `","worktree_path":"` + worktree + `","started_at":"2026-08-01T14:00:00Z","last_outcome":"WAITING"}}` - if err := os.WriteFile(filepath.Join(dir, "record.json"), []byte(record), 0o600); err != nil { - t.Fatal(err) - } - - res := GateLaunch(testsupport.TempDir(t), worktree, []string{"/bin/echo", "hi"}) - if res.RunDir != "" { - GateStop(res.RunDir, "test cleanup") - t.Fatalf("refused launch produced a run handle: %+v", res) - } - if res.Result != ResultBlocked || res.Reason != string(gatedrive.ErrUnresolvedExecution) { - t.Fatalf("result/reason = %s/%q, want blocked/unresolved-execution", res.Result, res.Reason) - } - if res.Cause != "inventory-legacy-drive-"+id { - t.Fatalf("raw refusal cause = %q, want the matched drive locator", res.Cause) - } - if res.LegacyHistory == nil || len(res.LegacyHistory.Retained) != 1 || - res.LegacyHistory.Retained[0].DriveID != id || res.LegacyHistory.Retained[0].Worktree != worktree { - t.Fatalf("raw refusal must carry the matched finding naming its worktree, got %+v", res.LegacyHistory) - } - if !strings.Contains(res.HumanText(), "cause: inventory-legacy-drive-"+id) { - t.Fatalf("human text must render the locator:\n%s", res.HumanText()) - } - - // The same record never vetoes a different worktree of the same repository. - other := filepath.Join(testsupport.TempDir(t), "other") - runGit(t, worktree, "commit", "--allow-empty", "-m", "base") - runGit(t, worktree, "worktree", "add", other) - ok := GateLaunch(testsupport.TempDir(t), other, []string{"/bin/echo", "hi"}) - if ok.RunDir != "" { - t.Cleanup(func() { waitGateRunTerminal(t, ok.RunDir); GateStop(ok.RunDir, "test cleanup") }) - } - if ok.Result != ResultApplied { - t.Fatalf("an unrelated worktree must admit, got %s (%q, cause %q)", ok.Result, ok.Reason, ok.Cause) - } -} diff --git a/internal/app/maintenance_traffic_integration_test.go b/internal/app/maintenance_traffic_integration_test.go index a722da2e5..8fc7bc31f 100644 --- a/internal/app/maintenance_traffic_integration_test.go +++ b/internal/app/maintenance_traffic_integration_test.go @@ -22,7 +22,7 @@ import ( // This file is Task 10: traffic accounting for `maintenance sweep` driven through // the PRODUCTION entry point app.MaintenanceSweep over REAL git and gh processes, // counted at the executable boundary. Unlike maintenance_test.go (which proves the -// orchestration over recording seams) and sweep_session_test.go (which proves the +// orchestration over recording seams) and sweep_session_integration_test.go (which proves the // one-metadata-fetch-per-attempt / bound-reader contract at the session seam), // these tests build the whole FinalizeDeps the CLI wires — a transaction engine, // status reader, workspace service, PR prober, batched PR reader, gate, and @@ -43,8 +43,8 @@ import ( // // Realism note (Task 10 realism clause): the "exactly one metadata fetch per // dispatched operation, shared by helper+operation+nested readers" property is -// proved cleanly at the session seam by sweep_session_test.go -// (TestPrepareIsOneMetadataFetchZeroSetupProbes, TestBoundReaderNeverFetches) with +// proved cleanly at the session seam by sweep_session_integration_test.go +// (TestIntegrationContextProbePrepareIsOneMetadataFetchZeroSetupProbes, TestIntegrationContextProbeBoundReaderNeverFetches) with // the same real-process counting. It is NOT re-asserted as a bare fetch count // around a live mutation here, because a dispatched reclaim's transaction engine // legitimately re-fetches the metadata branch for its own fresh-origin CAS commit @@ -59,8 +59,9 @@ import ( // not re-built here. The dispatched-operation traffic proof here uses reclaim, // which needs no gh. // - Mid-sweep source movement between prepare points (Step 4's two-phase hook) is -// proved at the session seam by sweep_session_test.go's TestPrepareObservesFresh -// MetadataTip; the movement asserted end-to-end here is the metadata-fetch +// proved at the session seam by sweep_session_integration_test.go's +// TestIntegrationContextProbePrepareObservesFreshMetadataTip; the movement +// asserted end-to-end here is the metadata-fetch // failure/deletion path (no stale fallback). // --- harness -------------------------------------------------------------- @@ -554,7 +555,7 @@ func TestIntegrationSweepImplementationScopeInspectsNoDeferredResources(t *testi // the workspace inspection and the transaction's own fresh-origin CAS re-read — so // this also witnesses that a dispatched operation adds no setup re-probe. The // bytes-level "one preparation fetch shared by nested readers" property is proved -// at the session seam (sweep_session_test.go); see this file's header note. +// at the session seam (sweep_session_integration_test.go); see this file's header note. func TestIntegrationSweepDispatchedReclaimKeepsSetupOnce(t *testing.T) { requireRealGit(t) records := map[string]string{ diff --git a/internal/app/named_branch_facts_integration_test.go b/internal/app/named_branch_facts_integration_test.go new file mode 100644 index 000000000..cdf831ff0 --- /dev/null +++ b/internal/app/named_branch_facts_integration_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_contextprobe.sh (prefix ^TestIntegrationContextProbe). + +import ( + "context" + "errors" + "strings" + "testing" +) + +// --- real-git callers: claim, workspace context, merge context, clear-block -- + +// namedFactsRepo seeds B (from bSrc, plus an optional in-progress parent 4 on +// feat/b-parent) beside an unrelated stacked pair A (20 in-progress on +// feat/a-parent, 21 stacked on it). +func namedFactsRepo(t *testing.T, bSrc string, withParent bool) *gitRepo { + t.Helper() + files := map[string]string{ + groomPath(3, "widget"): bSrc, + groomPath(20, "a-parent"): lifecycleChange(20, "a-parent", "in-progress"), + groomPath(21, "a-child"): stackedOn(lifecycleChange(21, "a-child", "proposed"), 20), + } + if withParent { + files[groomPath(4, "b-parent")] = lifecycleChange(4, "b-parent", "in-progress") + files[groomPath(3, "widget")] = stackedOn(bSrc, 4) + } + return newWorkingRepo(t, files) +} + +func assertPoisonRefusal(t *testing.T, what string, result Result, reason, msg string) { + t.Helper() + if result != ResultExternalFailed || reason != ReasonStatusExternal || !strings.Contains(msg, poisonProbe) { + t.Fatalf("%s with B's own parent unprobeable = %q (%s: %s), want the external probe failure", what, result, reason, msg) + } +} + +func TestIntegrationContextProbeChangeClaimProbesOnlyOwnStack(t *testing.T) { + requireRealGit(t) + t.Run("unrelated-poison-does-not-block", func(t *testing.T) { + repo := namedFactsRepo(t, claimableChange(3, "widget"), false) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") + res := ChangeClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget"))}) + if res.Result != ResultApplied { + t.Fatalf("claim beside an unprobeable unrelated stack = %q (disposition %q findings %v), want applied", res.Result, res.Disposition, res.Findings) + } + }) + t.Run("own-ancestor-poison-refuses", func(t *testing.T) { + repo := namedFactsRepo(t, claimableChange(3, "widget"), true) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") + res := ChangeClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget"))}) + msg := "" + for _, f := range res.Findings { + msg += f.Message + } + if res.Result != ResultExternalFailed || !strings.Contains(msg, poisonProbe) { + t.Fatalf("claim with B's own parent unprobeable = %q (findings %v), want the external probe failure", res.Result, res.Findings) + } + }) +} + +func TestIntegrationContextProbeWorkspaceContextProbesOnlyOwnStack(t *testing.T) { + requireRealGit(t) + t.Run("unrelated-poison-does-not-block", func(t *testing.T) { + repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), false) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") + if _, r := loadWorkspaceContext(context.Background(), node.deps, node.dir, 3, OperationWorkspaceInspect); r != nil { + t.Fatalf("workspace context beside an unprobeable unrelated stack refused: %s: %s", r.Reason, r.Message) + } + }) + t.Run("own-ancestor-poison-refuses", func(t *testing.T) { + repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), true) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") + _, r := loadWorkspaceContext(context.Background(), node.deps, node.dir, 3, OperationWorkspaceInspect) + if r == nil { + t.Fatal("workspace context with B's own parent unprobeable resolved; want the external probe failure") + } + assertPoisonRefusal(t, "workspace context", r.Result, r.Reason, r.Message) + }) +} + +func TestIntegrationContextProbeMergeContextProbesOnlyOwnStack(t *testing.T) { + requireRealGit(t) + t.Run("unrelated-poison-does-not-block", func(t *testing.T) { + repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), false) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") + if _, r := loadMergeContext(context.Background(), FinalizeDeps{Planning: node.deps}, node.dir, 3); r != nil { + t.Fatalf("merge context beside an unprobeable unrelated stack refused: %s: %s", r.Reason, r.Message) + } + }) + t.Run("own-ancestor-poison-refuses", func(t *testing.T) { + repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), true) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") + _, r := loadMergeContext(context.Background(), FinalizeDeps{Planning: node.deps}, node.dir, 3) + if r == nil { + t.Fatal("merge context with B's own parent unprobeable resolved; want the external probe failure") + } + assertPoisonRefusal(t, "merge context", r.Result, r.Reason, r.Message) + }) +} + +func TestIntegrationContextProbeFinalizeClearBlockProbesOnlyOwnStack(t *testing.T) { + requireRealGit(t) + probeErr := errors.New("workspace probe reached") + run := func(t *testing.T, withParent bool, poison string) BlockResult { + t.Helper() + repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), withParent) + node := planningDepsFor(t, repo.invocation) + node.deps.Reader = poisoned(node.deps.Reader, poison) + deps := FinalizeDeps{ + Planning: node.deps, + GitHub: repairGitHub("feat/widget"), + Workspace: &fakeRepairWorkspace{inspectErr: probeErr}, + } + return FinalizeClearBlock(context.Background(), deps, node.dir, ClearBlockRequest{ + ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget")), Head: prHead, PRNumber: 7, + }) + } + t.Run("unrelated-poison-does-not-block", func(t *testing.T) { + // The reprobe proceeds past branch facts to the (scripted-failing) + // workspace probe: the unrelated stack's branch was never asked for. + if r := run(t, false, "feat/a-parent"); r.Reason != ReasonBlockWorkspaceProbe { + t.Fatalf("clear-block beside an unprobeable unrelated stack = %q (%s: %s), want it to reach the workspace probe", r.Result, r.Reason, r.Message) + } + }) + t.Run("own-ancestor-poison-refuses", func(t *testing.T) { + r := run(t, true, "feat/b-parent") + assertPoisonRefusal(t, "clear-block", r.Result, r.Reason, r.Message) + }) +} diff --git a/internal/app/named_branch_facts_test.go b/internal/app/named_branch_facts_test.go index adb3b77a8..ec7cc5e48 100644 --- a/internal/app/named_branch_facts_test.go +++ b/internal/app/named_branch_facts_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "errors" "fmt" "reflect" "strings" @@ -231,131 +230,3 @@ func TestRepairWorkspaceClearProbesOnlyOwnStack(t *testing.T) { } }) } - -// --- real-git callers: claim, workspace context, merge context, clear-block -- - -// namedFactsRepo seeds B (from bSrc, plus an optional in-progress parent 4 on -// feat/b-parent) beside an unrelated stacked pair A (20 in-progress on -// feat/a-parent, 21 stacked on it). -func namedFactsRepo(t *testing.T, bSrc string, withParent bool) *gitRepo { - t.Helper() - files := map[string]string{ - groomPath(3, "widget"): bSrc, - groomPath(20, "a-parent"): lifecycleChange(20, "a-parent", "in-progress"), - groomPath(21, "a-child"): stackedOn(lifecycleChange(21, "a-child", "proposed"), 20), - } - if withParent { - files[groomPath(4, "b-parent")] = lifecycleChange(4, "b-parent", "in-progress") - files[groomPath(3, "widget")] = stackedOn(bSrc, 4) - } - return newWorkingRepo(t, files) -} - -func assertPoisonRefusal(t *testing.T, what string, result Result, reason, msg string) { - t.Helper() - if result != ResultExternalFailed || reason != ReasonStatusExternal || !strings.Contains(msg, poisonProbe) { - t.Fatalf("%s with B's own parent unprobeable = %q (%s: %s), want the external probe failure", what, result, reason, msg) - } -} - -func TestChangeClaimProbesOnlyOwnStack(t *testing.T) { - requireRealGit(t) - t.Run("unrelated-poison-does-not-block", func(t *testing.T) { - repo := namedFactsRepo(t, claimableChange(3, "widget"), false) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") - res := ChangeClaim(context.Background(), node.deps, node.dir, - ChangeClaimRequest{ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget"))}) - if res.Result != ResultApplied { - t.Fatalf("claim beside an unprobeable unrelated stack = %q (disposition %q findings %v), want applied", res.Result, res.Disposition, res.Findings) - } - }) - t.Run("own-ancestor-poison-refuses", func(t *testing.T) { - repo := namedFactsRepo(t, claimableChange(3, "widget"), true) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") - res := ChangeClaim(context.Background(), node.deps, node.dir, - ChangeClaimRequest{ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget"))}) - msg := "" - for _, f := range res.Findings { - msg += f.Message - } - if res.Result != ResultExternalFailed || !strings.Contains(msg, poisonProbe) { - t.Fatalf("claim with B's own parent unprobeable = %q (findings %v), want the external probe failure", res.Result, res.Findings) - } - }) -} - -func TestWorkspaceContextProbesOnlyOwnStack(t *testing.T) { - requireRealGit(t) - t.Run("unrelated-poison-does-not-block", func(t *testing.T) { - repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), false) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") - if _, r := loadWorkspaceContext(context.Background(), node.deps, node.dir, 3, OperationWorkspaceInspect); r != nil { - t.Fatalf("workspace context beside an unprobeable unrelated stack refused: %s: %s", r.Reason, r.Message) - } - }) - t.Run("own-ancestor-poison-refuses", func(t *testing.T) { - repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), true) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") - _, r := loadWorkspaceContext(context.Background(), node.deps, node.dir, 3, OperationWorkspaceInspect) - if r == nil { - t.Fatal("workspace context with B's own parent unprobeable resolved; want the external probe failure") - } - assertPoisonRefusal(t, "workspace context", r.Result, r.Reason, r.Message) - }) -} - -func TestMergeContextProbesOnlyOwnStack(t *testing.T) { - requireRealGit(t) - t.Run("unrelated-poison-does-not-block", func(t *testing.T) { - repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), false) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/a-parent") - if _, r := loadMergeContext(context.Background(), FinalizeDeps{Planning: node.deps}, node.dir, 3); r != nil { - t.Fatalf("merge context beside an unprobeable unrelated stack refused: %s: %s", r.Reason, r.Message) - } - }) - t.Run("own-ancestor-poison-refuses", func(t *testing.T) { - repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), true) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, "feat/b-parent") - _, r := loadMergeContext(context.Background(), FinalizeDeps{Planning: node.deps}, node.dir, 3) - if r == nil { - t.Fatal("merge context with B's own parent unprobeable resolved; want the external probe failure") - } - assertPoisonRefusal(t, "merge context", r.Result, r.Reason, r.Message) - }) -} - -func TestFinalizeClearBlockProbesOnlyOwnStack(t *testing.T) { - requireRealGit(t) - probeErr := errors.New("workspace probe reached") - run := func(t *testing.T, withParent bool, poison string) BlockResult { - t.Helper() - repo := namedFactsRepo(t, lifecycleChange(3, "widget", "in-progress"), withParent) - node := planningDepsFor(t, repo.invocation) - node.deps.Reader = poisoned(node.deps.Reader, poison) - deps := FinalizeDeps{ - Planning: node.deps, - GitHub: repairGitHub("feat/widget"), - Workspace: &fakeRepairWorkspace{inspectErr: probeErr}, - } - return FinalizeClearBlock(context.Background(), deps, node.dir, ClearBlockRequest{ - ID: 3, Version: blobVersionAt(t, repo.origin, "docket", groomPath(3, "widget")), Head: prHead, PRNumber: 7, - }) - } - t.Run("unrelated-poison-does-not-block", func(t *testing.T) { - // The reprobe proceeds past branch facts to the (scripted-failing) - // workspace probe: the unrelated stack's branch was never asked for. - if r := run(t, false, "feat/a-parent"); r.Reason != ReasonBlockWorkspaceProbe { - t.Fatalf("clear-block beside an unprobeable unrelated stack = %q (%s: %s), want it to reach the workspace probe", r.Result, r.Reason, r.Message) - } - }) - t.Run("own-ancestor-poison-refuses", func(t *testing.T) { - r := run(t, true, "feat/b-parent") - assertPoisonRefusal(t, "clear-block", r.Result, r.Reason, r.Message) - }) -} diff --git a/internal/app/nogit_guard_off_test.go b/internal/app/nogit_guard_off_test.go new file mode 100644 index 000000000..cd10e429a --- /dev/null +++ b/internal/app/nogit_guard_off_test.go @@ -0,0 +1,10 @@ +//go:build integration || e2e + +package app + +// installNoGitGuard is the tagged builds' no-op twin of the default-build guard in +// nogit_guard_test.go (change 0465). The integration and e2e corpora exist to run +// real git, so they install no shim and the finisher returns m.Run's code as-is. +// Exactly one of the two files compiles for any tag set, so TestMain stays +// single-sourced. +func installNoGitGuard() func(code int) int { return func(code int) int { return code } } diff --git a/internal/app/nogit_guard_test.go b/internal/app/nogit_guard_test.go new file mode 100644 index 000000000..0ef84bdc0 --- /dev/null +++ b/internal/app/nogit_guard_test.go @@ -0,0 +1,221 @@ +//go:build !integration && !e2e + +package app + +// The default-build no-real-git guard (change 0465). Change 0333 moved the slow +// real-git, subprocess, and process-lifecycle corpus behind `//go:build integration`, +// but nothing stopped new real-git tests landing in the default corpus, which +// tests/test_go_race.sh instruments. This guard makes the partition an enforced +// invariant: the default-tag internal/app test corpus never starts a real `git`. +// +// Mechanism (keyed on the exec itself, never on spellings): installNoGitGuard, called +// from TestMain before m.Run, puts a directory holding a refusing `git` shim at the +// FRONT of PATH. Every PATH-resolved route to git (gitcli.NewClient's exec.LookPath, +// a bare exec.Command("git", …), a fixture helper, a child process inheriting PATH) +// resolves the shim. Known limits, none used by default tests today: a client built +// with gitcli.WithExecutable(), a test that replaces PATH wholesale +// rather than prepending to it, and a detached child that runs git after m.Run +// returns (once the shim dir is removed) all bypass the shim. The shim exits nogitGuardExit with the nogitGuardDiagnostic on stderr AND +// appends "\t" to a violation log, so a test that tolerates the failure +// still turns the package red when nogitVerdict reads the log after m.Run. +// +// Only this guard's own proving tests may call the shim without recording a +// violation, by passing nogitGuardProbeArg as the first argument. + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const ( + nogitGuardProbeArg = "docket-nogit-guard-probe" + nogitGuardDiagnostic = "docket nogit guard: default internal/app tests must not run real git" + nogitGuardExit = 97 + nogitSelfProbeEnv = "DOCKET_NOGIT_GUARD_SELF_PROBE" +) + +// nogitGuardDir is the installed shim directory ("" when the guard is not installed). +var nogitGuardDir string + +// installNoGitGuard installs the refusing git shim at the front of PATH and returns +// the finisher TestMain wraps around m.Run. Setup failure exits the binary non-zero: +// a guard that silently failed to install would certify nothing. +func installNoGitGuard() func(code int) int { + // tempdir-exempt: TestMain installs the shim for the whole package run; there is no t to own a fixture dir. + dir, err := os.MkdirTemp("", "docket-app-nogit-") + if err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot create the shim directory: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + logPath := filepath.Join(dir, "violations.log") + if strings.ContainsAny(logPath, "'\n") { + fmt.Fprintf(os.Stderr, "%s: shim log path %q is not single-quote safe\n", nogitGuardDiagnostic, logPath) + os.Exit(1) + } + shim := filepath.Join(dir, "git") + if err := os.WriteFile(shim, []byte(nogitShimScript(logPath)), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot write the shim: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + // Explicit chmod: a create-time mode is masked by the umask. + if err := os.Chmod(shim, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot chmod the shim: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { + fmt.Fprintf(os.Stderr, "%s: cannot prepend the shim to PATH: %v\n", nogitGuardDiagnostic, err) + os.Exit(1) + } + nogitGuardDir = dir + return func(code int) int { + verdict := nogitVerdict(logPath, code, os.Stderr) + _ = os.RemoveAll(dir) + return verdict + } +} + +// nogitShimScript renders the refusing git: it records every non-probe invocation +// as "\t" in logPath and always exits nogitGuardExit with the diagnostic +// and the remedy on stderr. +func nogitShimScript(logPath string) string { + return "#!/bin/sh\n" + + "if [ \"${1-}\" != '" + nogitGuardProbeArg + "' ]; then\n" + + " printf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '" + logPath + "'\n" + + "fi\n" + + "printf '%s (git %s): move the test behind //go:build integration with a TestIntegration prefix and a tests/test_go_integration_app_*.sh shard (change 0465; partition from change 0333)\\n' '" + + nogitGuardDiagnostic + "' \"$*\" >&2\n" + + fmt.Sprintf("exit %d\n", nogitGuardExit) +} + +// nogitVerdict folds the violation log into m.Run's exit code. A missing or empty +// log is clean and leaves code unchanged. Any recorded attempt, or a log that exists +// but cannot be read, fails the package: a probe error is never clean absence. +func nogitVerdict(logPath string, code int, w io.Writer) int { + raw, err := os.ReadFile(logPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Fprintf(w, "%s: cannot read the violation log %s: %v\n", nogitGuardDiagnostic, logPath, err) + return nogitFailCode(code) + } + var lines []string + for _, l := range strings.Split(string(raw), "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, l) + } + } + if len(lines) == 0 { + return code + } + fmt.Fprintf(w, "%s: %d real-git exec attempt(s) reached the guard shim (a test that tolerated the failure still counts); \\t:\n", nogitGuardDiagnostic, len(lines)) + for _, l := range lines { + fmt.Fprintf(w, " %s\n", l) + } + return nogitFailCode(code) +} + +func nogitFailCode(code int) int { + if code == 0 { + return 1 + } + return code +} + +// TestNoGitGuardShadowsGitOnPath: every PATH lookup of `git` (gitcli.NewClient uses +// exec.LookPath) resolves the shim, not a real git. +func TestNoGitGuardShadowsGitOnPath(t *testing.T) { + if nogitGuardDir == "" { + t.Fatalf("the no-real-git guard is not installed (nogitGuardDir empty); TestMain must call installNoGitGuard before m.Run") + } + p, err := exec.LookPath("git") + if err != nil { + t.Fatalf("LookPath(git): %v", err) + } + if filepath.Dir(p) != nogitGuardDir { + t.Fatalf("git resolves to %q, want the guard shim in %q", p, nogitGuardDir) + } +} + +// TestNoGitGuardRefusesBareExec pins the MECHANISM, not just "it failed": real git +// also fails on an unknown subcommand, so the assert is the guard's exit code AND +// its diagnostic (learning assert-pins-outcome-not-mechanism). +func TestNoGitGuardRefusesBareExec(t *testing.T) { + out, err := exec.Command("git", nogitGuardProbeArg).CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != nogitGuardExit { + t.Fatalf("git exec must exit %d from the guard shim, got err=%v output=%q", nogitGuardExit, err, out) + } + if !strings.Contains(string(out), nogitGuardDiagnostic) { + t.Fatalf("git exec output must carry the guard diagnostic %q, got %q", nogitGuardDiagnostic, out) + } +} + +// TestNoGitGuardVerdict covers the post-m.Run verdict over the violation log. +func TestNoGitGuardVerdict(t *testing.T) { + dir := testsupport.TempDir(t) // bare X.TempDir() is banned by internal/repoguard tempdir_fixture_test.go + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + cases := []struct { + name string + logPath string + code int + want int + wantText string + }{ + {"missing log is clean", filepath.Join(dir, "absent.log"), 0, 0, ""}, + {"empty log is clean", write("empty.log", ""), 0, 0, ""}, + {"one violation fails a green run", write("one.log", "/tmp/x\tstatus --porcelain\n"), 0, 1, "1 real-git exec attempt(s)"}, + {"violation keeps an existing failure code", write("keep.log", "/tmp/x\tlog\n"), 2, 2, "/tmp/x\tlog"}, + {"unreadable log fails closed", dir, 0, 1, "cannot read the violation log"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + got := nogitVerdict(tc.logPath, tc.code, &buf) + if got != tc.want { + t.Fatalf("nogitVerdict(%q, %d) = %d, want %d; output:\n%s", tc.logPath, tc.code, got, tc.want, buf.String()) + } + if tc.wantText == "" && buf.Len() != 0 { + t.Fatalf("clean verdict must print nothing, got:\n%s", buf.String()) + } + if tc.wantText != "" && !strings.Contains(buf.String(), tc.wantText) { + t.Fatalf("verdict output must contain %q, got:\n%s", tc.wantText, buf.String()) + } + }) + } +} + +// TestNoGitGuardFailsTolerantTest proves a test that SWALLOWS the git failure still +// fails the package: it re-execs this test binary running only itself in child +// mode, where it runs `git status` and ignores the error, then asserts the child +// binary exits non-zero and lists the violation. +func TestNoGitGuardFailsTolerantTest(t *testing.T) { + if os.Getenv(nogitSelfProbeEnv) == "1" { + _ = exec.Command("git", "status").Run() // tolerated on purpose + return + } + cmd := exec.Command(os.Args[0], "-test.run=^TestNoGitGuardFailsTolerantTest$", "-test.count=1") + cmd.Env = append(os.Environ(), nogitSelfProbeEnv+"=1") + out, err := cmd.CombinedOutput() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() == 0 { + t.Fatalf("a package whose test tolerated a git exec must exit non-zero, got err=%v output:\n%s", err, out) + } + for _, want := range []string{nogitGuardDiagnostic, "1 real-git exec attempt(s)", "\tstatus"} { + if !strings.Contains(string(out), want) { + t.Fatalf("child output must contain %q, got:\n%s", want, out) + } + } +} diff --git a/internal/app/operational_context_test.go b/internal/app/operational_context_integration_test.go similarity index 91% rename from internal/app/operational_context_test.go rename to internal/app/operational_context_integration_test.go index 868f41627..80e093e25 100644 --- a/internal/app/operational_context_test.go +++ b/internal/app/operational_context_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -34,11 +36,11 @@ func originRefs(t *testing.T, origin string) string { return runGit(t, origin, "for-each-ref") } -// TestOperationalGateRefusesLegacy proves a legacy fixture makes an ordinary +// TestIntegrationContextProbeOperationalGateRefusesLegacy proves a legacy fixture makes an ordinary // command's PinContext return the shared typed refusal, rendered by the status // operation as the spec's one protocol document, and that a MUTATING ordinary // operation refused by the same gate moves no ref on the origin. -func TestOperationalGateRefusesLegacy(t *testing.T) { +func TestIntegrationContextProbeOperationalGateRefusesLegacy(t *testing.T) { repo := newLegacyRepo(t, legacyChangeRecord()) client := newGitClient(t) reader := NewGitStatusReader(client) @@ -86,10 +88,10 @@ func TestOperationalGateRefusesLegacy(t *testing.T) { } } -// TestOperationalGateFindingIsTheClassifierValue proves the refusal finding is +// TestIntegrationContextProbeOperationalGateFindingIsTheClassifierValue proves the refusal finding is // the exact typed value `repository check` reports for the same fixture — the // classifier is the single source, not a command-specific copy. -func TestOperationalGateFindingIsTheClassifierValue(t *testing.T) { +func TestIntegrationContextProbeOperationalGateFindingIsTheClassifierValue(t *testing.T) { repo := newLegacyRepo(t, legacyChangeRecord()) client := newGitClient(t) @@ -121,10 +123,10 @@ func TestOperationalGateFindingIsTheClassifierValue(t *testing.T) { } } -// TestOperationalGatePassesHealthy proves a docket-topology fixture pins +// TestIntegrationContextProbeOperationalGatePassesHealthy proves a docket-topology fixture pins // normally: integration resolved from configuration, the metadata revision // pinned from the remote docket branch, and no refusal. -func TestOperationalGatePassesHealthy(t *testing.T) { +func TestIntegrationContextProbeOperationalGatePassesHealthy(t *testing.T) { repo := newWorkingRepo(t, map[string]string{ "docs/changes/active/0002-beta.md": changeRecord(2, "beta", "Beta"), }) @@ -153,12 +155,12 @@ func TestOperationalGatePassesHealthy(t *testing.T) { } } -// TestFailClosedOrdering proves (a) an invalid configuration fails as invalid +// TestIntegrationContextProbeFailClosedOrdering proves (a) an invalid configuration fails as invalid // input BEFORE any topology classification — never the legacy remedy — and (b) // the refusal predicate fires for exactly the legacy state, so unknown or // conflicting classifications keep change 0352's own disposition and never // collapse into legacy-repository. -func TestFailClosedOrdering(t *testing.T) { +func TestIntegrationContextProbeFailClosedOrdering(t *testing.T) { t.Run("invalid config precedes classification", func(t *testing.T) { repo := newLegacyRepo(t, legacyChangeRecord()) // Corrupt the committed repository-layer configuration on the origin. @@ -198,11 +200,11 @@ func TestFailClosedOrdering(t *testing.T) { }) } -// TestStatusInvalidConfigDiagnostics: an invalid committed .docket.yml still +// TestIntegrationContextProbeStatusInvalidConfigDiagnostics: an invalid committed .docket.yml still // refuses with reason invalid-input and today's message, and now carries the // resolver's findings — code, .docket.yml: in the path slot — with the // refs in the human text (change 0403). -func TestStatusInvalidConfigDiagnostics(t *testing.T) { +func TestIntegrationContextProbeStatusInvalidConfigDiagnostics(t *testing.T) { requireRealGit(t) root := testsupport.TempDir(t) origin := filepath.Join(root, "origin.git") diff --git a/internal/app/pr_publish_integration_test.go b/internal/app/pr_publish_integration_test.go new file mode 100644 index 000000000..556d9b5c8 --- /dev/null +++ b/internal/app/pr_publish_integration_test.go @@ -0,0 +1,146 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_finalizeops.sh (prefix ^TestIntegrationFinalizeOps). + +import ( + "context" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/repository" +) + +// TestIntegrationFinalizeOpsPRPublishAcceptsSkippedEvidenceAtExactHead: a build.gate: off repository's +// truthful skipped evidence certifying the exact feature head passes PRPublish's +// evidence conjunct — the operation proceeds PAST it (VerdictSkipped is accepted +// exactly as VerdictVerified). Any later refusal is not the evidence conjunct; +// reverting the green-or-skipped acceptance would refuse here with +// ReasonPREvidenceUnverified, so this pins the verify-site change. (PR-body +// weaving of a skipped block is a separate concern: evidence.Upsert is green-only +// today, so the operation still fails later at body assembly — see the change +// notes.) +func TestIntegrationFinalizeOpsPRPublishAcceptsSkippedEvidenceAtExactHead(t *testing.T) { + repoDir := newWorkingRepo(t, nil).invocation + reader := prReader(t) + gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} + deps := workspaceDepsFor(t, reader) + res := PRPublish(context.Background(), deps, WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, + repoDir, PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: "Authored prose.\n", EvidenceRecord: prSkippedEvidenceBytes(t, prHead)}) + if res.Reason == ReasonPREvidenceUnverified { + t.Fatalf("skipped evidence at the exact head was refused at the evidence conjunct: %q", res.Message) + } +} + +// TestIntegrationFinalizeOpsPRPublishPreEffectValidationIsScoped pins change 0449's pre-effect rule +// for PR publication (spec: "B must pass relevant validation before an external +// effect, while A's unrelated findings cannot veto it"): an error on B itself or +// on a structural subject of B (a depends_on target) refuses before +// EnsurePullRequest, while unrelated records carrying errors — one parseable but +// invalid, one unparseable — never veto the publication. +func TestIntegrationFinalizeOpsPRPublishPreEffectValidationIsScoped(t *testing.T) { + requireRealGit(t) + badType := func(src string) string { + out := strings.Replace(src, "type: feat\n", "type: 'Not A Token'\n", 1) + if out == src { + t.Fatal("defect fixture did not rewrite the record's type; the fixture shape changed") + } + return out + } + b := inProgressChangeBlob(7, "widget", "v7", "") + unrelatedInvalid := StatusBlob{ + Kind: repository.KindChange, Location: repository.LocationActive, + Path: groomPath(30, "a-invalid"), Version: "v30", + Data: []byte(badType(lifecycleChange(30, "a-invalid", "proposed"))), + } + unrelatedBroken := StatusBlob{ + Kind: repository.KindChange, Location: repository.LocationActive, + Path: unrelatedBrokenPath, Version: "v99", Data: []byte(unrelatedBrokenBytes), + } + badB := b + badB.Data = []byte(badType(string(b.Data))) + depPath := "docs/changes/archive/2026-08-01-0008-dep.md" + withDep := b + withDep.Data = []byte(strings.Replace(string(b.Data), "depends_on: []\n", "depends_on: [8]\n", 1)) + if string(withDep.Data) == string(b.Data) { + t.Fatal("dependency fixture did not rewrite depends_on; the fixture shape changed") + } + badDep := StatusBlob{ + Kind: repository.KindChange, Location: repository.LocationArchive, + Path: depPath, Version: "v8", Data: []byte(badType(fixtureArchivedDone(8, "dep"))), + } + + publish := func(t *testing.T, corpus []StatusBlob) (PRPublishResult, *fakeGitHub) { + t.Helper() + reader := &fakeReader{pin: mainPin(t), corpus: corpus} + gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} + res := PRPublish(context.Background(), workspaceDepsFor(t, reader), WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, + newWorkingRepo(t, nil).invocation, + PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: "Authored prose.\n", EvidenceRecord: prEvidenceBytes(t, prHead)}) + return res, gh + } + + t.Run("unrelated-errors-do-not-veto", func(t *testing.T) { + corpus := []StatusBlob{b, unrelatedInvalid, unrelatedBroken} + // Non-vacuity: the unrelated parseable record genuinely carries an error + // finding in the built report, so only the relevance rule lets B through. + inputs, _ := parseCorpus(corpus) + build, err := repository.BuildSnapshot(repository.BuildInput{Config: mainPin(t).Config.Effective, Documents: inputs}) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + poisoned := false + for _, f := range build.Report.Findings() { + if f.Severity == domain.SeverityError && f.Entity.Path == unrelatedInvalid.Path { + poisoned = true + } + } + if !poisoned { + t.Fatalf("the unrelated record %s carries no error finding; the non-veto row would be vacuous", unrelatedInvalid.Path) + } + res, gh := publish(t, corpus) + if res.Result != ResultApplied || len(gh.ensureCalls) != 1 { + t.Fatalf("publish beside unrelated invalid records = %q (reason %q msg %q findings %+v, %d ensure calls), want applied with one ensure", + res.Result, res.Reason, res.Message, res.Findings, len(gh.ensureCalls)) + } + }) + + for _, tc := range []struct { + name string + corpus []StatusBlob + names string + }{ + {"defective-B-refuses-before-effect", []StatusBlob{badB, unrelatedInvalid, unrelatedBroken}, b.Path}, + {"defective-dependency-refuses-before-effect", []StatusBlob{withDep, badDep, unrelatedInvalid, unrelatedBroken}, depPath}, + } { + t.Run(tc.name, func(t *testing.T) { + res, gh := publish(t, tc.corpus) + if res.Result == ResultApplied || res.Result == ResultNoOp { + t.Fatalf("publish applied despite a relevant defect (%s): %q", tc.names, res.Result) + } + if res.Reason != ReasonPRRecordInvalid { + t.Fatalf("reason = %q (msg %q), want %q", res.Reason, res.Message, ReasonPRRecordInvalid) + } + if len(gh.ensureCalls) != 0 { + t.Fatalf("EnsurePullRequest invoked %d time(s) despite a relevant defect; want 0", len(gh.ensureCalls)) + } + named := false + for _, f := range res.Findings { + if f.Path == unrelatedInvalid.Path || f.Path == unrelatedBrokenPath { + t.Errorf("refusal carries an unrelated record's finding %+v", f) + } + if f.Path == tc.names { + named = true + } + } + if !named { + t.Errorf("refusal does not name the defective record %s: findings %+v", tc.names, res.Findings) + } + }) + } +} diff --git a/internal/app/pr_publish_test.go b/internal/app/pr_publish_test.go index 433693594..e3de321dd 100644 --- a/internal/app/pr_publish_test.go +++ b/internal/app/pr_publish_test.go @@ -131,27 +131,6 @@ func prSnapshotChange(t *testing.T, reader *fakeReader, id int) domain.Change { return c } -// TestPRPublishAcceptsSkippedEvidenceAtExactHead: a build.gate: off repository's -// truthful skipped evidence certifying the exact feature head passes PRPublish's -// evidence conjunct — the operation proceeds PAST it (VerdictSkipped is accepted -// exactly as VerdictVerified). Any later refusal is not the evidence conjunct; -// reverting the green-or-skipped acceptance would refuse here with -// ReasonPREvidenceUnverified, so this pins the verify-site change. (PR-body -// weaving of a skipped block is a separate concern: evidence.Upsert is green-only -// today, so the operation still fails later at body assembly — see the change -// notes.) -func TestPRPublishAcceptsSkippedEvidenceAtExactHead(t *testing.T) { - repoDir := newWorkingRepo(t, nil).invocation - reader := prReader(t) - gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} - deps := workspaceDepsFor(t, reader) - res := PRPublish(context.Background(), deps, WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, - repoDir, PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: "Authored prose.\n", EvidenceRecord: prSkippedEvidenceBytes(t, prHead)}) - if res.Reason == ReasonPREvidenceUnverified { - t.Fatalf("skipped evidence at the exact head was refused at the evidence conjunct: %q", res.Message) - } -} - // TestPRFenceRefusalMessageIsReasonAware pins the review fix (change 0441): the // human message must be accurate per fence reason. A run-completed fence is a // SUCCESSFUL closeout, so its message must NOT claim cancellation; a cancelled or @@ -180,111 +159,3 @@ func TestPRFenceRefusalMessageIsReasonAware(t *testing.T) { t.Fatalf("run-completed message is not accurate for a successful closeout: %q", completed.Message) } } - -// TestPRPublishPreEffectValidationIsScoped pins change 0449's pre-effect rule -// for PR publication (spec: "B must pass relevant validation before an external -// effect, while A's unrelated findings cannot veto it"): an error on B itself or -// on a structural subject of B (a depends_on target) refuses before -// EnsurePullRequest, while unrelated records carrying errors — one parseable but -// invalid, one unparseable — never veto the publication. -func TestPRPublishPreEffectValidationIsScoped(t *testing.T) { - requireRealGit(t) - badType := func(src string) string { - out := strings.Replace(src, "type: feat\n", "type: 'Not A Token'\n", 1) - if out == src { - t.Fatal("defect fixture did not rewrite the record's type; the fixture shape changed") - } - return out - } - b := inProgressChangeBlob(7, "widget", "v7", "") - unrelatedInvalid := StatusBlob{ - Kind: repository.KindChange, Location: repository.LocationActive, - Path: groomPath(30, "a-invalid"), Version: "v30", - Data: []byte(badType(lifecycleChange(30, "a-invalid", "proposed"))), - } - unrelatedBroken := StatusBlob{ - Kind: repository.KindChange, Location: repository.LocationActive, - Path: unrelatedBrokenPath, Version: "v99", Data: []byte(unrelatedBrokenBytes), - } - badB := b - badB.Data = []byte(badType(string(b.Data))) - depPath := "docs/changes/archive/2026-08-01-0008-dep.md" - withDep := b - withDep.Data = []byte(strings.Replace(string(b.Data), "depends_on: []\n", "depends_on: [8]\n", 1)) - if string(withDep.Data) == string(b.Data) { - t.Fatal("dependency fixture did not rewrite depends_on; the fixture shape changed") - } - badDep := StatusBlob{ - Kind: repository.KindChange, Location: repository.LocationArchive, - Path: depPath, Version: "v8", Data: []byte(badType(fixtureArchivedDone(8, "dep"))), - } - - publish := func(t *testing.T, corpus []StatusBlob) (PRPublishResult, *fakeGitHub) { - t.Helper() - reader := &fakeReader{pin: mainPin(t), corpus: corpus} - gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} - res := PRPublish(context.Background(), workspaceDepsFor(t, reader), WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, - newWorkingRepo(t, nil).invocation, - PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: "Authored prose.\n", EvidenceRecord: prEvidenceBytes(t, prHead)}) - return res, gh - } - - t.Run("unrelated-errors-do-not-veto", func(t *testing.T) { - corpus := []StatusBlob{b, unrelatedInvalid, unrelatedBroken} - // Non-vacuity: the unrelated parseable record genuinely carries an error - // finding in the built report, so only the relevance rule lets B through. - inputs, _ := parseCorpus(corpus) - build, err := repository.BuildSnapshot(repository.BuildInput{Config: mainPin(t).Config.Effective, Documents: inputs}) - if err != nil { - t.Fatalf("BuildSnapshot: %v", err) - } - poisoned := false - for _, f := range build.Report.Findings() { - if f.Severity == domain.SeverityError && f.Entity.Path == unrelatedInvalid.Path { - poisoned = true - } - } - if !poisoned { - t.Fatalf("the unrelated record %s carries no error finding; the non-veto row would be vacuous", unrelatedInvalid.Path) - } - res, gh := publish(t, corpus) - if res.Result != ResultApplied || len(gh.ensureCalls) != 1 { - t.Fatalf("publish beside unrelated invalid records = %q (reason %q msg %q findings %+v, %d ensure calls), want applied with one ensure", - res.Result, res.Reason, res.Message, res.Findings, len(gh.ensureCalls)) - } - }) - - for _, tc := range []struct { - name string - corpus []StatusBlob - names string - }{ - {"defective-B-refuses-before-effect", []StatusBlob{badB, unrelatedInvalid, unrelatedBroken}, b.Path}, - {"defective-dependency-refuses-before-effect", []StatusBlob{withDep, badDep, unrelatedInvalid, unrelatedBroken}, depPath}, - } { - t.Run(tc.name, func(t *testing.T) { - res, gh := publish(t, tc.corpus) - if res.Result == ResultApplied || res.Result == ResultNoOp { - t.Fatalf("publish applied despite a relevant defect (%s): %q", tc.names, res.Result) - } - if res.Reason != ReasonPRRecordInvalid { - t.Fatalf("reason = %q (msg %q), want %q", res.Reason, res.Message, ReasonPRRecordInvalid) - } - if len(gh.ensureCalls) != 0 { - t.Fatalf("EnsurePullRequest invoked %d time(s) despite a relevant defect; want 0", len(gh.ensureCalls)) - } - named := false - for _, f := range res.Findings { - if f.Path == unrelatedInvalid.Path || f.Path == unrelatedBrokenPath { - t.Errorf("refusal carries an unrelated record's finding %+v", f) - } - if f.Path == tc.names { - named = true - } - } - if !named { - t.Errorf("refusal does not name the defective record %s: findings %+v", tc.names, res.Findings) - } - }) - } -} diff --git a/internal/app/repophase_test.go b/internal/app/repophase_integration_test.go similarity index 90% rename from internal/app/repophase_test.go rename to internal/app/repophase_integration_test.go index ddde4aebb..8aa242d06 100644 --- a/internal/app/repophase_test.go +++ b/internal/app/repophase_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -52,7 +54,7 @@ func targetPaths(phase *install.RepoPhase) []string { return out } -func TestResolveRepoPhaseDiscoversFromRootAndNestedDir(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseDiscoversFromRootAndNestedDir(t *testing.T) { root, _ := initGitRepo(t, "agent_harnesses: [claude]\n") nested := filepath.Join(root, "docs", "changes") if err := os.MkdirAll(nested, 0o755); err != nil { @@ -77,7 +79,7 @@ func TestResolveRepoPhaseDiscoversFromRootAndNestedDir(t *testing.T) { } } -func TestResolveRepoPhaseInvalidExplicitRepoDir(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseInvalidExplicitRepoDir(t *testing.T) { git := newGitClient(t) notARepo := testsupport.TempDir(t) _, _, _, err := ResolveRepoPhase(context.Background(), git, notARepo, nil, nil, nil, config.ResolveContext{DefaultBranch: "main"}) @@ -90,7 +92,7 @@ func TestResolveRepoPhaseInvalidExplicitRepoDir(t *testing.T) { } } -func TestResolveRepoPhaseOutsideGitIsMachineOnly(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseOutsideGitIsMachineOnly(t *testing.T) { git := newGitClient(t) outside := testsupport.TempDir(t) t.Chdir(outside) @@ -105,7 +107,7 @@ func TestResolveRepoPhaseOutsideGitIsMachineOnly(t *testing.T) { } } -func TestResolveRepoPhaseAbsentKeyNotAuthorized(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseAbsentKeyNotAuthorized(t *testing.T) { root, gitDir := initGitRepo(t, "metadata_branch: main\n") git := newGitClient(t) phase, gotRoot, _, err := ResolveRepoPhase(context.Background(), git, root, nil, nil, nil, config.ResolveContext{DefaultBranch: "main"}) @@ -126,14 +128,14 @@ func TestResolveRepoPhaseAbsentKeyNotAuthorized(t *testing.T) { } } -// TestResolveRepoPhaseGlobalLayerNotAuthorized pins the provenance guard: an +// TestIntegrationContextProbeResolveRepoPhaseGlobalLayerNotAuthorized pins the provenance guard: an // agent_harnesses declaration that resolves from the GLOBAL layer is never write // authority for repository surfaces. // // MUTATION TEST: flip the guard in ResolveRepoPhase from // `ah.Explicit && isRepositoryLayer(...)` to `ah.Explicit` alone and this test // reddens — a global declaration would then authorize a repository write. -func TestResolveRepoPhaseGlobalLayerNotAuthorized(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseGlobalLayerNotAuthorized(t *testing.T) { root, _ := initGitRepo(t, "metadata_branch: main\n") // The declaration lives in the GLOBAL layer only. xdg := testsupport.TempDir(t) @@ -159,7 +161,7 @@ func TestResolveRepoPhaseGlobalLayerNotAuthorized(t *testing.T) { } } -func TestResolveRepoPhaseAgentsTableAloneNotAuthorized(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseAgentsTableAloneNotAuthorized(t *testing.T) { root, _ := initGitRepo(t, "agents:\n claude:\n build-standard:\n model: opus\n") git := newGitClient(t) phase, _, _, err := ResolveRepoPhase(context.Background(), git, root, nil, nil, nil, config.ResolveContext{DefaultBranch: "main"}) @@ -171,11 +173,11 @@ func TestResolveRepoPhaseAgentsTableAloneNotAuthorized(t *testing.T) { } } -// TestResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord is the scoped-run row: +// TestIntegrationContextProbeResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord is the scoped-run row: // opt-ins [claude codex], a prior record owning both surfaces, and a // --harness codex scope. Only codex's surface is reconciled; claude's ownership // record is carried forward unchanged. -func TestResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord(t *testing.T) { root, gitDir := initGitRepo(t, "agent_harnesses: [claude, codex]\n") git := newGitClient(t) @@ -235,7 +237,7 @@ func TestResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord(t *testing.T) { } } -// TestResolveRepoPhaseRetiresDroppedClaudeLink is the symlink-retirement row: a +// TestIntegrationContextProbeResolveRepoPhaseRetiresDroppedClaudeLink is the symlink-retirement row: a // repo that once opted into [claude codex] now opts into [codex] alone, with a // prior record owning CLAUDE.md as a claude symlink to the shared AGENTS.md. The // dropped claude link must be a provable removal — the install no longer @@ -245,7 +247,7 @@ func TestResolveRepoPhaseScopedHarnessCarriesUnrelatedRecord(t *testing.T) { // MUTATION TEST: drop the LinkTarget threading in computeRemovals (the symlink // arm that joins s.LinkTarget under root) and the LinkTarget assertion below // reddens — the removal would name no destination. -func TestResolveRepoPhaseRetiresDroppedClaudeLink(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseRetiresDroppedClaudeLink(t *testing.T) { root, gitDir := initGitRepo(t, "agent_harnesses: [codex]\n") git := newGitClient(t) @@ -300,13 +302,13 @@ func TestResolveRepoPhaseRetiresDroppedClaudeLink(t *testing.T) { } } -// TestResolveRepoPhaseToleratesUnknownKeys (change 0392): with a tolerant +// TestIntegrationContextProbeResolveRepoPhaseToleratesUnknownKeys (change 0392): with a tolerant // context, a .docket.yml carrying an unknown key plus an explicit // agent_harnesses still yields an authorized phase, and the unknown-key // warning comes back for the install result to surface. The strict control — // today's ReasonInvalidConfig refusal — pins that the CLI's context, not this // assembler, owns the decision. -func TestResolveRepoPhaseToleratesUnknownKeys(t *testing.T) { +func TestIntegrationContextProbeResolveRepoPhaseToleratesUnknownKeys(t *testing.T) { root, _ := initGitRepo(t, "agent_harnesses: [claude]\nsome_future_block: true\n") git := newGitClient(t) diff --git a/internal/app/run_verify_integration_test.go b/internal/app/run_verify_integration_test.go new file mode 100644 index 000000000..c2504e834 --- /dev/null +++ b/internal/app/run_verify_integration_test.go @@ -0,0 +1,96 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_evidence.sh (prefix ^TestIntegrationEvidence). + +import ( + "context" + "strings" + "testing" +) + +// TestIntegrationEvidenceRunVerifyMissingResultsIsUnmetConjunct: an otherwise-complete implemented +// run whose change carries no linked results artifact is NOT complete — a green +// PR plus verified evidence and a tracked plan can never certify a run with no +// durable results (change 0410, criterion 1). The missing-results conjunct +// (results-unlinked) is enumerated on run-incomplete. +func TestIntegrationEvidenceRunVerifyMissingResultsIsUnmetConjunct(t *testing.T) { + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvRecord(rvPlanPath, "", rvRecordedPR(), "feat/"+rvSlug), + rvPR(f.head, string(prEvidenceBytes(t, f.head))), + ) + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if res.Verdict != VerdictRunIncomplete { + t.Fatalf("verdict = %q, want %q (missing results must not certify complete; unmet %v)", res.Verdict, VerdictRunIncomplete, unmetReasons(res)) + } + if got := unmetReasons(res); len(got) != 1 || got[0] != ReasonRunResultsUnlinked { + t.Fatalf("unmet = %v, want exactly [%s]", got, ReasonRunResultsUnlinked) + } +} + +// TestIntegrationEvidenceRunVerifyInvalidResultsContentIsUnmetConjunct: a linked results path that +// resolves to a tracked regular file whose FINAL content contract fails (a +// whole-section filler body) is an unmet results-content-invalid conjunct whose +// Observed detail names the offending path. +func TestIntegrationEvidenceRunVerifyInvalidResultsContentIsUnmetConjunct(t *testing.T) { + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvRecord(rvPlanPath, rvResultsInvalidPath, rvRecordedPR(), "feat/"+rvSlug), + rvPR(f.head, string(prEvidenceBytes(t, f.head))), + ) + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if res.Verdict != VerdictRunIncomplete { + t.Fatalf("verdict = %q, want %q (unmet %v)", res.Verdict, VerdictRunIncomplete, unmetReasons(res)) + } + if got := unmetReasons(res); len(got) != 1 || got[0] != ReasonRunResultsInvalid { + t.Fatalf("unmet = %v, want exactly [%s]", got, ReasonRunResultsInvalid) + } + var observed string + for _, u := range res.Unmet { + if u.Reason == ReasonRunResultsInvalid { + observed = u.Observed + } + } + if !strings.HasPrefix(observed, rvResultsInvalidPath) { + t.Fatalf("observed = %q, want it to name path %q", observed, rvResultsInvalidPath) + } +} + +// TestIntegrationEvidenceRunVerifyWaitingSurvivesMissingResults: the missing-results conjunct adds +// to unmet without suppressing a valid local waiting receipt. Waiting evaluation +// runs precisely because unmet is nonempty, so an in-progress run with a +// fully-agreeing handoff and NO results still reports run-waiting (change 0410 +// preserves waiting precedence). +func TestIntegrationEvidenceRunVerifyWaitingSurvivesMissingResults(t *testing.T) { + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvInProgressRecord(rvPlanPath, "", "feat/"+rvSlug), + rvPR(f.head, string(prEvidenceBytes(t, f.head))), + ) + wdeps.Waiting = fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true} + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if res.Verdict != VerdictRunWaiting { + t.Fatalf("verdict = %q, want %q (a valid waiting receipt outranks the missing-results conjunct; unmet %v)", res.Verdict, VerdictRunWaiting, unmetReasons(res)) + } +} + +// TestIntegrationEvidenceRunVerifyAcceptsSkippedEvidenceAtExactHead: a build.gate: off repository's +// PR carries a truthful skipped (build-gate-off) block at the exact feature head. +// run verify's evidence postcondition accepts VerdictSkipped exactly as +// VerdictVerified, so the run is complete. Mirrors TestIntegrationChangeRuntimeRunVerifyComplete +// with a skipped PR-body block substituted. +func TestIntegrationEvidenceRunVerifyAcceptsSkippedEvidenceAtExactHead(t *testing.T) { + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), + rvPR(f.head, string(prSkippedEvidenceBytes(t, f.head))), + ) + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if res.Verdict != VerdictRunComplete { + t.Fatalf("verdict = %q, want %q — a skipped PR-body block at the exact head verifies (unmet %v)", res.Verdict, VerdictRunComplete, unmetReasons(res)) + } +} diff --git a/internal/app/run_verify_test.go b/internal/app/run_verify_test.go index 25bed4237..e9f97833c 100644 --- a/internal/app/run_verify_test.go +++ b/internal/app/run_verify_test.go @@ -274,54 +274,6 @@ func TestRunVerifyHaltedVerdict(t *testing.T) { } } -// TestRunVerifyMissingResultsIsUnmetConjunct: an otherwise-complete implemented -// run whose change carries no linked results artifact is NOT complete — a green -// PR plus verified evidence and a tracked plan can never certify a run with no -// durable results (change 0410, criterion 1). The missing-results conjunct -// (results-unlinked) is enumerated on run-incomplete. -func TestRunVerifyMissingResultsIsUnmetConjunct(t *testing.T) { - f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, "", rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), - ) - res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) - if res.Verdict != VerdictRunIncomplete { - t.Fatalf("verdict = %q, want %q (missing results must not certify complete; unmet %v)", res.Verdict, VerdictRunIncomplete, unmetReasons(res)) - } - if got := unmetReasons(res); len(got) != 1 || got[0] != ReasonRunResultsUnlinked { - t.Fatalf("unmet = %v, want exactly [%s]", got, ReasonRunResultsUnlinked) - } -} - -// TestRunVerifyInvalidResultsContentIsUnmetConjunct: a linked results path that -// resolves to a tracked regular file whose FINAL content contract fails (a -// whole-section filler body) is an unmet results-content-invalid conjunct whose -// Observed detail names the offending path. -func TestRunVerifyInvalidResultsContentIsUnmetConjunct(t *testing.T) { - f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsInvalidPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), - ) - res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) - if res.Verdict != VerdictRunIncomplete { - t.Fatalf("verdict = %q, want %q (unmet %v)", res.Verdict, VerdictRunIncomplete, unmetReasons(res)) - } - if got := unmetReasons(res); len(got) != 1 || got[0] != ReasonRunResultsInvalid { - t.Fatalf("unmet = %v, want exactly [%s]", got, ReasonRunResultsInvalid) - } - var observed string - for _, u := range res.Unmet { - if u.Reason == ReasonRunResultsInvalid { - observed = u.Observed - } - } - if !strings.HasPrefix(observed, rvResultsInvalidPath) { - t.Fatalf("observed = %q, want it to name path %q", observed, rvResultsInvalidPath) - } -} - // TestRunVerifyHaltedPrecedesMissingResults: a change carrying the durable // "## Run halted" marker and NO results still short-circuits to run-halted — the // missing-results conjunct (change 0410, hoisted outside the blob-read guard) @@ -347,38 +299,3 @@ func TestRunVerifyHaltedPrecedesMissingResults(t *testing.T) { t.Fatalf("halted verdict carried unmet conjuncts %v; the missing-results conjunct must not run before the halt short-circuit", unmetReasons(got)) } } - -// TestRunVerifyWaitingSurvivesMissingResults: the missing-results conjunct adds -// to unmet without suppressing a valid local waiting receipt. Waiting evaluation -// runs precisely because unmet is nonempty, so an in-progress run with a -// fully-agreeing handoff and NO results still reports run-waiting (change 0410 -// preserves waiting precedence). -func TestRunVerifyWaitingSurvivesMissingResults(t *testing.T) { - f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, "", "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), - ) - wdeps.Waiting = fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true} - res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) - if res.Verdict != VerdictRunWaiting { - t.Fatalf("verdict = %q, want %q (a valid waiting receipt outranks the missing-results conjunct; unmet %v)", res.Verdict, VerdictRunWaiting, unmetReasons(res)) - } -} - -// TestRunVerifyAcceptsSkippedEvidenceAtExactHead: a build.gate: off repository's -// PR carries a truthful skipped (build-gate-off) block at the exact feature head. -// run verify's evidence postcondition accepts VerdictSkipped exactly as -// VerdictVerified, so the run is complete. Mirrors TestIntegrationChangeRuntimeRunVerifyComplete -// with a skipped PR-body block substituted. -func TestRunVerifyAcceptsSkippedEvidenceAtExactHead(t *testing.T) { - f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prSkippedEvidenceBytes(t, f.head))), - ) - res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) - if res.Verdict != VerdictRunComplete { - t.Fatalf("verdict = %q, want %q — a skipped PR-body block at the exact head verifies (unmet %v)", res.Verdict, VerdictRunComplete, unmetReasons(res)) - } -} diff --git a/internal/app/rungate_before_test.go b/internal/app/rungate_before_integration_test.go similarity index 93% rename from internal/app/rungate_before_test.go rename to internal/app/rungate_before_integration_test.go index f2ab2624d..c38091daf 100644 --- a/internal/app/rungate_before_test.go +++ b/internal/app/rungate_before_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -122,11 +124,11 @@ func resumeInspectService(worktree string) *fakeWorkspaceService { } } -// TestGateBeforePreparesOuterScope: a non-resume arm prepares the outer scope, +// TestIntegrationGateArmGateBeforePreparesOuterScope: a non-resume arm prepares the outer scope, // carries the scope binding in the record, prints the dispatch context on the // armed line, and NEVER leaks the parent capability into the result JSON or the // human text (it lives only in the 0600 record). -func TestGateBeforePreparesOuterScope(t *testing.T) { +func TestIntegrationGateArmGateBeforePreparesOuterScope(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -185,13 +187,13 @@ func TestGateBeforePreparesOuterScope(t *testing.T) { } } -// TestGateBeforeFreshArmSurfacesRunEpoch: a fresh (non-resume) arm surfaces the +// TestIntegrationGateArmGateBeforeFreshArmSurfacesRunEpoch: a fresh (non-resume) arm surfaces the // minted run epoch's public id in the result (Epoch) and in the human report line // — the documented `run.cancel --epoch ` / `--run-epoch` value the operator and // the dispatcher thread through. Without it the primary human-Stop path names an // epoch the arm never gave (change 0375). The surfaced id must equal the id the // bound epoch record actually carries — the same value run.cancel cross-checks. -func TestGateBeforeFreshArmSurfacesRunEpoch(t *testing.T) { +func TestIntegrationGateArmGateBeforeFreshArmSurfacesRunEpoch(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -221,11 +223,11 @@ func TestGateBeforeFreshArmSurfacesRunEpoch(t *testing.T) { } } -// TestGateBeforeResumeBindsOnlyVerifiedInProgress: a --resume id pre-binds +// TestIntegrationGateArmGateBeforeResumeBindsOnlyVerifiedInProgress: a --resume id pre-binds // attribution ONLY when the id is genuinely in-progress AND WorkspaceInspect // applies; a proposed id or a failed inspect is resume-unverified and mints no // record (and never prepares a scope). -func TestGateBeforeResumeBindsOnlyVerifiedInProgress(t *testing.T) { +func TestIntegrationGateArmGateBeforeResumeBindsOnlyVerifiedInProgress(t *testing.T) { t.Run("in-progress with valid inspect binds", func(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation reader := &fakeReader{pin: mainPin(t), corpus: []StatusBlob{inProgressChangeBlob(5, "epsilon", "v5", "")}} @@ -304,11 +306,11 @@ func TestGateBeforeResumeBindsOnlyVerifiedInProgress(t *testing.T) { }) } -// TestGateBeforeNoTimestampGames: the resume path never plays a timestamp game. +// TestIntegrationGateArmGateBeforeNoTimestampGames: the resume path never plays a timestamp game. // The resumed change stays in the fresh BeforeIDs and DispatchEpoch stays // post-read — attribution is bound by verified identity, not by excluding the id // from the before-set. -func TestGateBeforeNoTimestampGames(t *testing.T) { +func TestIntegrationGateArmGateBeforeNoTimestampGames(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation reader := &fakeReader{pin: mainPin(t), corpus: []StatusBlob{inProgressChangeBlob(5, "epsilon", "v5", "")}} deps := workspaceDepsFor(t, reader) @@ -360,12 +362,12 @@ func gatePinWithRunMaxAttempts(t *testing.T, n int) StatusPin { return p } -// TestMintSnapshotsRunMaxAttempts: gate-before snapshots the authoritative +// TestIntegrationGateArmMintSnapshotsRunMaxAttempts: gate-before snapshots the authoritative // run.max_attempts into the record's AttemptLimit at mint (change 0421). A repo // configured run.max_attempts: 3 yields AttemptLimit == 3; the default yields 2; // and a later config change never rewrites an already-minted record's limit (the // snapshot rule — the load never re-reads config). -func TestMintSnapshotsRunMaxAttempts(t *testing.T) { +func TestIntegrationGateArmMintSnapshotsRunMaxAttempts(t *testing.T) { t.Run("configured value is snapshotted", func(t *testing.T) { repo := newGateRepo(t) reader := &fakeReader{pin: gatePinWithRunMaxAttempts(t, 3), corpus: gateBeforeCorpus()} @@ -413,9 +415,9 @@ func TestMintSnapshotsRunMaxAttempts(t *testing.T) { }) } -// TestGateRecordContinuationTripleRule: the store rejects a partial continuation +// TestIntegrationGateArmGateRecordContinuationTripleRule: the store rejects a partial continuation // triple on BOTH the write and the read boundary as a corrupt record. -func TestGateRecordContinuationTripleRule(t *testing.T) { +func TestIntegrationGateArmGateRecordContinuationTripleRule(t *testing.T) { repo := newGateRepo(t) // Write boundary: minting/saving a partial triple fails closed. @@ -452,9 +454,9 @@ func TestGateRecordContinuationTripleRule(t *testing.T) { } } -// TestGateRecordSchema1FailsClosed: a schema-1 record fails closed as a corrupt +// TestIntegrationGateArmGateRecordSchema1FailsClosed: a schema-1 record fails closed as a corrupt // record — the v2 store never migrates a pre-upgrade record. -func TestGateRecordSchema1FailsClosed(t *testing.T) { +func TestIntegrationGateArmGateRecordSchema1FailsClosed(t *testing.T) { repo := newGateRepo(t) key, err := MintGateRecord(repo, sampleGateRecord()) if err != nil { diff --git a/internal/app/rungate_before_resume_test.go b/internal/app/rungate_before_resume_integration_test.go similarity index 92% rename from internal/app/rungate_before_resume_test.go rename to internal/app/rungate_before_resume_integration_test.go index e8556b8e4..3eab99c11 100644 --- a/internal/app/rungate_before_resume_test.go +++ b/internal/app/rungate_before_resume_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -52,11 +54,11 @@ func seedPriorEpoch(t *testing.T, repoDir string, state epochState) (gateKey, ep return key, ep.EpochID } -// TestResumeRefusesActiveEpochWithLocator: a resume of a change whose prior epoch is +// TestIntegrationGateArmResumeRefusesActiveEpochWithLocator: a resume of a change whose prior epoch is // still ACTIVE is refused with the safe locator (public epoch id + gate key) and the // explicit cancel/continue remedy — no record minted, no scope prepared, incumbent // untouched. -func TestResumeRefusesActiveEpochWithLocator(t *testing.T) { +func TestIntegrationGateArmResumeRefusesActiveEpochWithLocator(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation deps, wdeps := resumeEpochDeps(t) priorKey, epochID := seedPriorEpoch(t, repoDir, EpochActive) @@ -88,12 +90,12 @@ func TestResumeRefusesActiveEpochWithLocator(t *testing.T) { } } -// TestResumeRefusesCompletingEpochWithoutSuperseding: a resume of a change whose prior +// TestIntegrationGateArmResumeRefusesCompletingEpochWithoutSuperseding: a resume of a change whose prior // epoch is COMPLETING (a verified successful run mid-closeout, change 0441) is refused // gate-unarmed with the run-completing reason — it names the keyed gate-verdict/cancel // remedy, never turns the closeout into a cancelled predecessor, and reserves no // replacement. -func TestResumeRefusesCompletingEpochWithoutSuperseding(t *testing.T) { +func TestIntegrationGateArmResumeRefusesCompletingEpochWithoutSuperseding(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation deps, wdeps := resumeEpochDeps(t) priorKey, epochID := seedPriorEpoch(t, repoDir, EpochCompleting) @@ -124,11 +126,11 @@ func TestResumeRefusesCompletingEpochWithoutSuperseding(t *testing.T) { } } -// TestResumeRefusesCompletedEpochWithoutSuperseding: a resume of a change whose prior +// TestIntegrationGateArmResumeRefusesCompletedEpochWithoutSuperseding: a resume of a change whose prior // epoch is COMPLETED (successful closeout finished, change 0441) is refused gate-unarmed // with the run-completed reason — there is nothing to resume; the state and any // reservation stay untouched (never quiescence-checked into a supersede). -func TestResumeRefusesCompletedEpochWithoutSuperseding(t *testing.T) { +func TestIntegrationGateArmResumeRefusesCompletedEpochWithoutSuperseding(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation deps, wdeps := resumeEpochDeps(t) priorKey, epochID := seedPriorEpoch(t, repoDir, EpochCompleted) @@ -159,9 +161,9 @@ func TestResumeRefusesCompletedEpochWithoutSuperseding(t *testing.T) { } } -// TestResumeCancellingIsPending: a resume of a change whose prior epoch is CANCELLING +// TestIntegrationGateArmResumeCancellingIsPending: a resume of a change whose prior epoch is CANCELLING // is refused cancellation-pending — cleanup is still in flight, no replacement. -func TestResumeCancellingIsPending(t *testing.T) { +func TestIntegrationGateArmResumeCancellingIsPending(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation deps, wdeps := resumeEpochDeps(t) seedPriorEpoch(t, repoDir, EpochCancelling) @@ -179,11 +181,12 @@ func TestResumeCancellingIsPending(t *testing.T) { } } -// TestResumeAfterCancelledSupersedesOnce: two concurrent resumes of a +// TestRaceIntegrationAppConcurrencyResumeAfterCancelledSupersedesOnce: two concurrent resumes of a // confirmed-cancelled run produce EXACTLY ONE winner; the loser observes the // winner's reservation. The prior epoch ends superseded with the winner's key, and // exactly one fresh replacement epoch is minted (bound to the feature worktree). -func TestResumeAfterCancelledSupersedesOnce(t *testing.T) { +// Race shard (change 0465): two RunGateBefore resume arms released by one barrier race to supersede the same cancelled epoch. +func TestRaceIntegrationAppConcurrencyResumeAfterCancelledSupersedesOnce(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, _ := seedPriorEpoch(t, repoDir, EpochCancelled) @@ -266,10 +269,10 @@ func classifyResumePair(t *testing.T, a, b RunGateBeforeResult) (armed, observed } } -// TestRepeatArmObservesReservation: after a confirmed-cancelled resume reserves a +// TestIntegrationGateArmRepeatArmObservesReservation: after a confirmed-cancelled resume reserves a // replacement, a SECOND resume of the same change returns that reserved key and // mints NO new epoch. -func TestRepeatArmObservesReservation(t *testing.T) { +func TestIntegrationGateArmRepeatArmObservesReservation(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation seedPriorEpoch(t, repoDir, EpochCancelled) @@ -354,11 +357,11 @@ func seedResumeSlot(t *testing.T, repoDir, priorKey, ownerEpoch string) (common return common, store, worktree } -// TestResumeDeniedWhileOldEpochNotQuiescent (AC6/AC7): a durably cancelled epoch +// TestIntegrationGateArmResumeDeniedWhileOldEpochNotQuiescent (AC6/AC7): a durably cancelled epoch // whose launch evidence is still unsettled cannot authorize a replacement — the arm // refuses on the existing gate-unarmed channel (ReasonGateResumeCancellationPending), // mints no record, reserves no replacement, and leaves the old epoch cancelled. -func TestResumeDeniedWhileOldEpochNotQuiescent(t *testing.T) { +func TestIntegrationGateArmResumeDeniedWhileOldEpochNotQuiescent(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, _ := seedPriorEpoch(t, repoDir, EpochCancelled) @@ -394,10 +397,10 @@ func TestResumeDeniedWhileOldEpochNotQuiescent(t *testing.T) { } } -// TestResumeRetiresStaleSlotThenReservesOnce (AC1/AC7): a cancelled epoch whose +// TestIntegrationGateArmResumeRetiresStaleSlotThenReservesOnce (AC1/AC7): a cancelled epoch whose // released slot still carries its RunEpochID is retired by the resume validation, // then EXACTLY ONE replacement is reserved; a repeat arm observes the same key. -func TestResumeRetiresStaleSlotThenReservesOnce(t *testing.T) { +func TestIntegrationGateArmResumeRetiresStaleSlotThenReservesOnce(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, epochID := seedPriorEpoch(t, repoDir, EpochCancelled) _, store, worktree := seedResumeSlot(t, repoDir, priorKey, epochID) @@ -440,10 +443,10 @@ func TestResumeRetiresStaleSlotThenReservesOnce(t *testing.T) { } } -// TestResumeSupersededValidatesBeforeObserve (AC6): re-authorizing a previously +// TestIntegrationGateArmResumeSupersededValidatesBeforeObserve (AC6): re-authorizing a previously // reserved replacement from a SUPERSEDED epoch also requires quiescence; unsettled // evidence refuses without touching the reservation. -func TestResumeSupersededValidatesBeforeObserve(t *testing.T) { +func TestIntegrationGateArmResumeSupersededValidatesBeforeObserve(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, _ := seedPriorEpoch(t, repoDir, EpochCancelled) @@ -488,10 +491,10 @@ func TestResumeSupersededValidatesBeforeObserve(t *testing.T) { } } -// TestResumeForeignSlotIsNeutral (AC7): a slot owned by a DIFFERENT epoch neither +// TestIntegrationGateArmResumeForeignSlotIsNeutral (AC7): a slot owned by a DIFFERENT epoch neither // blocks nor is touched by resume — quiescent old-epoch evidence still admits the // replacement, and the foreign slot is byte-identical after. -func TestResumeForeignSlotIsNeutral(t *testing.T) { +func TestIntegrationGateArmResumeForeignSlotIsNeutral(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, _ := seedPriorEpoch(t, repoDir, EpochCancelled) _, store, worktree := seedResumeSlot(t, repoDir, priorKey, "someone-else") @@ -523,10 +526,10 @@ func TestResumeForeignSlotIsNeutral(t *testing.T) { } } -// TestResumeDoesNotResetSuiteBudget: a confirmed-cancelled resume that arms a +// TestIntegrationGateArmResumeDoesNotResetSuiteBudget: a confirmed-cancelled resume that arms a // replacement never touches the change-owned full-suite attempt budget (spec: an // explicit human resume "never resets the change-owned full-suite repair budget"). -func TestResumeDoesNotResetSuiteBudget(t *testing.T) { +func TestIntegrationGateArmResumeDoesNotResetSuiteBudget(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation seedPriorEpoch(t, repoDir, EpochCancelled) @@ -588,13 +591,13 @@ func armSupersededPrior(t *testing.T, repoDir string) (priorKey, priorEpoch, wor return priorKey, priorEpoch, worktree } -// TestResumeSupersededBranchAccountsScopeLinkedDrives (change 0446 AC5): on the +// TestIntegrationGateArmResumeSupersededBranchAccountsScopeLinkedDrives (change 0446 AC5): on the // superseded branch the old epoch's Worktree is empty, which is not proof of // quiescence. The launch census runs with the PREDECESSOR's epoch id against the // REPLACEMENT's worktree (threaded through ReplacementReserved), and an unaccounted // scope-linked launch it reports refuses the re-authorization instead of reporting // "accounted". -func TestResumeSupersededBranchAccountsScopeLinkedDrives(t *testing.T) { +func TestIntegrationGateArmResumeSupersededBranchAccountsScopeLinkedDrives(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation priorKey, priorEpoch, worktree := armSupersededPrior(t, repoDir) reservedBefore := func() string { @@ -652,14 +655,14 @@ func tornResumePrior(t *testing.T, repoDir string, neverMinted bool) (priorKey, return priorKey, priorEpoch, replKey } -// TestResumeTornReplacementConverges (change 0446 spec "Repeated cancellation, +// TestIntegrationGateArmResumeTornReplacementConverges (change 0446 spec "Repeated cancellation, // completion, and admission after safe reconciliation converge using existing // operations"): after a torn resume a repeat `run.gate-before --resume` is not a // permanent dead end. The superseded branch addresses the request's own feature // worktree (what armResumeReplacement binds), runs the census with the predecessor's // epoch id there, and observes the single reserved key — repeatedly, minting nothing. // A corrupt replacement epoch still refuses, naming the unreadable record. -func TestResumeTornReplacementConverges(t *testing.T) { +func TestIntegrationGateArmResumeTornReplacementConverges(t *testing.T) { for _, tc := range []struct { name string neverMinted bool @@ -711,12 +714,12 @@ func TestResumeTornReplacementConverges(t *testing.T) { }) } -// TestResumeSupersededChecksReplacementSlot (change 0446 spec §4): the superseded +// TestIntegrationGateArmResumeSupersededChecksReplacementSlot (change 0446 spec §4): the superseded // branch's slot check uses the replacement's worktree slot to confirm the // predecessor's epoch no longer holds it — an unreleased predecessor-owned slot // refuses, a released one is retired through the shared retirement and then // observed, and a slot the replacement itself holds is the successor outcome. -func TestResumeSupersededChecksReplacementSlot(t *testing.T) { +func TestIntegrationGateArmResumeSupersededChecksReplacementSlot(t *testing.T) { for _, tc := range []struct { name string owner func(priorEpoch string) string diff --git a/internal/app/rungate_cancel_helpers_test.go b/internal/app/rungate_cancel_helpers_test.go new file mode 100644 index 000000000..5c372012c --- /dev/null +++ b/internal/app/rungate_cancel_helpers_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gatedrive" +) + +// Run-cancel test helpers shared with default-build (untagged) test files. The +// cancel tests themselves live behind the integration tag in +// rungate_cancel_integration_test.go (change 0465); these fixtures, fakes and probes +// stay untagged because other untagged test files still reference them. + +// fakeCancelStopper is an injectable cancelStopper: it records every run dir it was +// asked to stop, answers proven/unproven from a per-dir map, and (via onStop) lets a +// test inject a race between the fence and the stop. +type fakeCancelStopper struct { + proven map[string]bool + calls []string + onStop func(runDir string) +} + +func (f *fakeCancelStopper) stopProcess(runDir string) (bool, error) { + f.calls = append(f.calls, runDir) + if f.onStop != nil { + f.onStop(runDir) + } + return f.proven[runDir], nil +} + +// fakeNativeCanceller records the native handles it was asked to cancel and returns +// a canned error. +type fakeNativeCanceller struct { + calls []string + err error +} + +func (f *fakeNativeCanceller) cancelNativeTask(handle string) error { + f.calls = append(f.calls, handle) + return f.err +} + +// fakeLaunchReconciler is an injectable epochLaunchReconciler: it records each +// (worktree,epoch) pair it was asked to reconcile and returns a canned report/error. +type fakeLaunchReconciler struct { + report gatedrive.EpochLaunchReport + err error + calls []string +} + +func (f *fakeLaunchReconciler) reconcile(worktree, epochID string) (gatedrive.EpochLaunchReport, error) { + f.calls = append(f.calls, worktree+"|"+epochID) + return f.report, f.err +} + +// okLaunchReconciler is a permissive fake reconciler: every epoch's launch +// obligations are already accounted with no findings, so a cancel test that does not +// exercise the launch-reconciliation path behaves exactly as before the seam existed. +func okLaunchReconciler() *fakeLaunchReconciler { + return &fakeLaunchReconciler{report: gatedrive.EpochLaunchReport{Accounted: true}} +} + +// cancelFixture is one prepared cancelable run: a gate record with a parent-held +// authority, an active epoch bound to change 42 with a confirmed claim, a canonical +// feature worktree, and a confirmed worktree execution slot whose process is runDir. +type cancelFixture struct { + repo string + key string + epochID string + worktree string + runDir string + store *gatedrive.Store + common string +} + +// newCancelFixture builds a fully authorized cancelable run. slot controls whether a +// worktree execution slot is reserved+confirmed; a run with no slot exercises the +// keyless/standalone path. +func newCancelFixture(t *testing.T, slot bool) cancelFixture { + t.Helper() + repo := newGateRepo(t) + common, err := gateGitCommonDir(repo) + if err != nil { + t.Fatalf("gateGitCommonDir: %v", err) + } + key, err := MintGateRecord(repo, GateRecord{ + Target: gateBeforeStoredTarget, + AttemptLimit: 2, + Retry: RetryUnused, + Disposition: "gate-armed", + ParentCap: "parent-cap-raw", + ScopeID: "scope-1", + }) + if err != nil { + t.Fatalf("MintGateRecord: %v", err) + } + ep, err := MintEpochRecord(repo, key, "42") + if err != nil { + t.Fatalf("MintEpochRecord: %v", err) + } + if err := ReserveGateClaim(repo, key, 42, "req-1"); err != nil { + t.Fatalf("ReserveGateClaim: %v", err) + } + if err := ConfirmGateClaim(repo, key, 42, "req-1", "rev-1", ""); err != nil { + t.Fatalf("ConfirmGateClaim: %v", err) + } + + worktree := filepath.Join(repo, "feature-wt") + if err := os.MkdirAll(worktree, 0o755); err != nil { + t.Fatalf("mkdir worktree: %v", err) + } + if err := epochCAS(repo, key, func(r *EpochRecord) error { + r.Worktree = worktree + return nil + }); err != nil { + t.Fatalf("epochCAS set worktree: %v", err) + } + + fx := cancelFixture{repo: repo, key: key, epochID: ep.EpochID, worktree: worktree, common: common} + fx.store = gatedrive.OpenStore(common) + if slot { + // The slot records a real owning RunEpochID so the ownership-checked + // teardown treats it as slotOwned (change 0435) — the same teardown behavior + // the raw (epoch-less) reservation used to get, now anchored on true epoch + // ownership rather than the worktree location alone. + runDir := filepath.Join(worktree, "run-1") + token, terr := fx.store.ReserveWorktreeExecutionForEpoch(common, worktree, ep.EpochID, nil) + if terr != nil { + t.Fatalf("ReserveWorktreeExecutionForEpoch: %v", terr) + } + if cerr := fx.store.ConfirmWorktreeExecution(worktree, token, "run-1", runDir); cerr != nil { + t.Fatalf("ConfirmWorktreeExecution: %v", cerr) + } + fx.runDir = runDir + } + return fx +} + +// loadEpochState reads the epoch's current state. +func loadEpochState(t *testing.T, repo, key string) epochState { + t.Helper() + ep, _, err := LoadEpochRecord(repo, key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + return ep.State +} + +// loadSlotState reads the worktree slot's current state as a string. +func loadSlotState(t *testing.T, store *gatedrive.Store, worktree string) string { + t.Helper() + slot, _, err := store.LoadWorktreeExecution(worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + return string(slot.State) +} + +// loadSlotEpoch reads the worktree slot's current RunEpochID. +func loadSlotEpoch(t *testing.T, store *gatedrive.Store, worktree string) string { + t.Helper() + slot, _, err := store.LoadWorktreeExecution(worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + return slot.RunEpochID +} + +func hasFinding(findings []string, prefix string) bool { + for _, f := range findings { + if strings.HasPrefix(f, prefix) { + return true + } + } + return false +} + +// admissionRecordFile returns the worktree slot's record path at the documented +// storage layout (see removeAdmissionRecord): the byte-identity probe the +// retirement-convergence tests use to prove a successor's slot is untouched. +func admissionRecordFile(t *testing.T, common, worktree string) string { + t.Helper() + canon, err := filepath.EvalSymlinks(worktree) + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + sum := sha256.Sum256([]byte(canon)) + return filepath.Join(common, "docket", "gate-admission", "v1", hex.EncodeToString(sum[:]), "record.json") +} diff --git a/internal/app/rungate_cancel_test.go b/internal/app/rungate_cancel_integration_test.go similarity index 85% rename from internal/app/rungate_cancel_test.go rename to internal/app/rungate_cancel_integration_test.go index b4750779e..d003ae7b3 100644 --- a/internal/app/rungate_cancel_test.go +++ b/internal/app/rungate_cancel_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -7,7 +9,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" "github.com/danielhanold/docket/internal/gatedrive" @@ -19,161 +20,6 @@ import ( // only on full accounting. The tests drive the flow over faked stop/native seams and // a real gatedrive admission store rooted at a real temp git repo. -// fakeCancelStopper is an injectable cancelStopper: it records every run dir it was -// asked to stop, answers proven/unproven from a per-dir map, and (via onStop) lets a -// test inject a race between the fence and the stop. -type fakeCancelStopper struct { - proven map[string]bool - calls []string - onStop func(runDir string) -} - -func (f *fakeCancelStopper) stopProcess(runDir string) (bool, error) { - f.calls = append(f.calls, runDir) - if f.onStop != nil { - f.onStop(runDir) - } - return f.proven[runDir], nil -} - -// fakeNativeCanceller records the native handles it was asked to cancel and returns -// a canned error. -type fakeNativeCanceller struct { - calls []string - err error -} - -func (f *fakeNativeCanceller) cancelNativeTask(handle string) error { - f.calls = append(f.calls, handle) - return f.err -} - -// fakeLaunchReconciler is an injectable epochLaunchReconciler: it records each -// (worktree,epoch) pair it was asked to reconcile and returns a canned report/error. -type fakeLaunchReconciler struct { - report gatedrive.EpochLaunchReport - err error - calls []string -} - -func (f *fakeLaunchReconciler) reconcile(worktree, epochID string) (gatedrive.EpochLaunchReport, error) { - f.calls = append(f.calls, worktree+"|"+epochID) - return f.report, f.err -} - -// okLaunchReconciler is a permissive fake reconciler: every epoch's launch -// obligations are already accounted with no findings, so a cancel test that does not -// exercise the launch-reconciliation path behaves exactly as before the seam existed. -func okLaunchReconciler() *fakeLaunchReconciler { - return &fakeLaunchReconciler{report: gatedrive.EpochLaunchReport{Accounted: true}} -} - -// cancelFixture is one prepared cancelable run: a gate record with a parent-held -// authority, an active epoch bound to change 42 with a confirmed claim, a canonical -// feature worktree, and a confirmed worktree execution slot whose process is runDir. -type cancelFixture struct { - repo string - key string - epochID string - worktree string - runDir string - store *gatedrive.Store - common string -} - -// newCancelFixture builds a fully authorized cancelable run. slot controls whether a -// worktree execution slot is reserved+confirmed; a run with no slot exercises the -// keyless/standalone path. -func newCancelFixture(t *testing.T, slot bool) cancelFixture { - t.Helper() - repo := newGateRepo(t) - common, err := gateGitCommonDir(repo) - if err != nil { - t.Fatalf("gateGitCommonDir: %v", err) - } - key, err := MintGateRecord(repo, GateRecord{ - Target: gateBeforeStoredTarget, - AttemptLimit: 2, - Retry: RetryUnused, - Disposition: "gate-armed", - ParentCap: "parent-cap-raw", - ScopeID: "scope-1", - }) - if err != nil { - t.Fatalf("MintGateRecord: %v", err) - } - ep, err := MintEpochRecord(repo, key, "42") - if err != nil { - t.Fatalf("MintEpochRecord: %v", err) - } - if err := ReserveGateClaim(repo, key, 42, "req-1"); err != nil { - t.Fatalf("ReserveGateClaim: %v", err) - } - if err := ConfirmGateClaim(repo, key, 42, "req-1", "rev-1", ""); err != nil { - t.Fatalf("ConfirmGateClaim: %v", err) - } - - worktree := filepath.Join(repo, "feature-wt") - if err := os.MkdirAll(worktree, 0o755); err != nil { - t.Fatalf("mkdir worktree: %v", err) - } - if err := epochCAS(repo, key, func(r *EpochRecord) error { - r.Worktree = worktree - return nil - }); err != nil { - t.Fatalf("epochCAS set worktree: %v", err) - } - - fx := cancelFixture{repo: repo, key: key, epochID: ep.EpochID, worktree: worktree, common: common} - fx.store = gatedrive.OpenStore(common) - if slot { - // The slot records a real owning RunEpochID so the ownership-checked - // teardown treats it as slotOwned (change 0435) — the same teardown behavior - // the raw (epoch-less) reservation used to get, now anchored on true epoch - // ownership rather than the worktree location alone. - runDir := filepath.Join(worktree, "run-1") - token, terr := fx.store.ReserveWorktreeExecutionForEpoch(common, worktree, ep.EpochID, nil) - if terr != nil { - t.Fatalf("ReserveWorktreeExecutionForEpoch: %v", terr) - } - if cerr := fx.store.ConfirmWorktreeExecution(worktree, token, "run-1", runDir); cerr != nil { - t.Fatalf("ConfirmWorktreeExecution: %v", cerr) - } - fx.runDir = runDir - } - return fx -} - -// loadEpochState reads the epoch's current state. -func loadEpochState(t *testing.T, repo, key string) epochState { - t.Helper() - ep, _, err := LoadEpochRecord(repo, key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - return ep.State -} - -// loadSlotState reads the worktree slot's current state as a string. -func loadSlotState(t *testing.T, store *gatedrive.Store, worktree string) string { - t.Helper() - slot, _, err := store.LoadWorktreeExecution(worktree) - if err != nil { - t.Fatalf("LoadWorktreeExecution: %v", err) - } - return string(slot.State) -} - -// loadSlotEpoch reads the worktree slot's current RunEpochID. -func loadSlotEpoch(t *testing.T, store *gatedrive.Store, worktree string) string { - t.Helper() - slot, _, err := store.LoadWorktreeExecution(worktree) - if err != nil { - t.Fatalf("LoadWorktreeExecution: %v", err) - } - return slot.RunEpochID -} - // removeAdmissionRecord deletes the worktree slot's record file so the next slot // write fails typed (ErrNotFound) — a deterministic durable-write failure. The // path shape is the documented storage layout in admission.go's file header: @@ -197,18 +43,9 @@ func removeAdmissionRecord(t *testing.T, common, worktree string) { } } -func hasFinding(findings []string, prefix string) bool { - for _, f := range findings { - if strings.HasPrefix(f, prefix) { - return true - } - } - return false -} - -// TestRunCancelHappyPath: an active epoch with a proven slot teardown cancels +// TestIntegrationGateCancelRunCancelHappyPath: an active epoch with a proven slot teardown cancels // cleanly — disposition cancelled, epoch cancelled, slot released. -func TestRunCancelHappyPath(t *testing.T) { +func TestIntegrationGateCancelRunCancelHappyPath(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} res := runCancel(cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()}, fx.repo, fx.key, fx.epochID, "human stop") @@ -233,9 +70,9 @@ func TestRunCancelHappyPath(t *testing.T) { } } -// TestRunCancelPendingOnUnprovenStop: an unproven slot teardown fences the epoch but +// TestIntegrationGateCancelRunCancelPendingOnUnprovenStop: an unproven slot teardown fences the epoch but // leaves it cancelling — disposition cancellation-pending, slot stopping. -func TestRunCancelPendingOnUnprovenStop(t *testing.T) { +func TestIntegrationGateCancelRunCancelPendingOnUnprovenStop(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: false}} res := runCancel(cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()}, fx.repo, fx.key, fx.epochID, "human stop") @@ -254,9 +91,9 @@ func TestRunCancelPendingOnUnprovenStop(t *testing.T) { } } -// TestRunCancelAlreadyCancelled: a repeat against a cancelled epoch is idempotent +// TestIntegrationGateCancelRunCancelAlreadyCancelled: a repeat against a cancelled epoch is idempotent // already-cancelled, touching nothing. -func TestRunCancelAlreadyCancelled(t *testing.T) { +func TestIntegrationGateCancelRunCancelAlreadyCancelled(t *testing.T) { fx := newCancelFixture(t, false) if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { r.State = EpochCancelled @@ -281,8 +118,8 @@ func TestRunCancelAlreadyCancelled(t *testing.T) { } } -// TestRunCancelRefusedWrongEpoch: a stale epoch locator is refused with no fence. -func TestRunCancelRefusedWrongEpoch(t *testing.T) { +// TestIntegrationGateCancelRunCancelRefusedWrongEpoch: a stale epoch locator is refused with no fence. +func TestIntegrationGateCancelRunCancelRefusedWrongEpoch(t *testing.T) { fx := newCancelFixture(t, true) res := runCancel(cancelSeams{store: fx.store, stopper: &fakeCancelStopper{}}, fx.repo, fx.key, "not-the-epoch", "human stop") if res.Disposition != CancelDispositionRefused { @@ -296,9 +133,9 @@ func TestRunCancelRefusedWrongEpoch(t *testing.T) { } } -// TestRunCancelRefusedWrongClaim: an unconfirmed (here, absent) claim binding is +// TestIntegrationGateCancelRunCancelRefusedWrongClaim: an unconfirmed (here, absent) claim binding is // refused — the run is not a genuinely claimed run. -func TestRunCancelRefusedWrongClaim(t *testing.T) { +func TestIntegrationGateCancelRunCancelRefusedWrongClaim(t *testing.T) { // Build a fixture WITHOUT confirming a claim. repo := newGateRepo(t) common, _ := gateGitCommonDir(repo) @@ -325,9 +162,9 @@ func TestRunCancelRefusedWrongClaim(t *testing.T) { } } -// TestRunCancelRefusedWrongRepo: a key that does not locate a record in this +// TestIntegrationGateCancelRunCancelRefusedWrongRepo: a key that does not locate a record in this // repository is refused (the repository/locator authority fails closed). -func TestRunCancelRefusedWrongRepo(t *testing.T) { +func TestIntegrationGateCancelRunCancelRefusedWrongRepo(t *testing.T) { fx := newCancelFixture(t, false) other := newGateRepo(t) otherCommon, _ := gateGitCommonDir(other) @@ -341,10 +178,10 @@ func TestRunCancelRefusedWrongRepo(t *testing.T) { } } -// TestCancelFencesBeforeStopping: a participant that registers between the fence and +// TestIntegrationGateCancelCancelFencesBeforeStopping: a participant that registers between the fence and // the stop (a launch admitted before the fence won) is caught by the post-stop // re-enumeration, keeping the cancellation pending. -func TestCancelFencesBeforeStopping(t *testing.T) { +func TestIntegrationGateCancelCancelFencesBeforeStopping(t *testing.T) { fx := newCancelFixture(t, true) // A raw-run participant present at entry; stopping it proves teardown. if err := RegisterEpochParticipant(fx.repo, fx.key, fx.epochID, EpochParticipant{Kind: "raw-run", NativeHandle: "R1"}); err != nil { @@ -376,10 +213,10 @@ func TestCancelFencesBeforeStopping(t *testing.T) { } } -// TestCancelRepeatResumesCleanup: a first cancel fences and leaves the run pending +// TestIntegrationGateCancelCancelRepeatResumesCleanup: a first cancel fences and leaves the run pending // on an unproven stop; a repeat against the cancelling epoch resumes cleanup (no // re-fence, no authority restore) and completes to cancelled when the stop proves. -func TestCancelRepeatResumesCleanup(t *testing.T) { +func TestIntegrationGateCancelCancelRepeatResumesCleanup(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: false}} @@ -408,10 +245,10 @@ func TestCancelRepeatResumesCleanup(t *testing.T) { } } -// TestCancelPendingOnUncompletedMutation: an admitted-not-completed mutation keeps +// TestIntegrationGateCancelCancelPendingOnUncompletedMutation: an admitted-not-completed mutation keeps // the cancellation pending even when every process teardown proves — no premature // cancelled. -func TestCancelPendingOnUncompletedMutation(t *testing.T) { +func TestIntegrationGateCancelCancelPendingOnUncompletedMutation(t *testing.T) { fx := newCancelFixture(t, true) if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { r.AdmittedMutations = []AdmittedMutation{{OpKey: "pr.publish", Status: "admitted"}} @@ -433,12 +270,12 @@ func TestCancelPendingOnUncompletedMutation(t *testing.T) { } } -// TestCancelSettlesUncertainPublicationWithIdenticalRetry (change 0444 acceptance +// TestIntegrationGateCancelCancelSettlesUncertainPublicationWithIdenticalRetry (change 0444 acceptance // 1): an uncertain PR publication plus a later completed identical retry — with // every process teardown proven — lets cancellation durably complete the original // entry and report cancelled; the terminal epoch is then quiescent for resume and // SupersedeCancelledEpoch admits exactly one replacement. -func TestCancelSettlesUncertainPublicationWithIdenticalRetry(t *testing.T) { +func TestIntegrationGateCancelCancelSettlesUncertainPublicationWithIdenticalRetry(t *testing.T) { fx := newCancelFixture(t, true) desc := MutationPublication{ RepoHost: "github.com", RepoOwner: "o", RepoName: "r", @@ -481,12 +318,12 @@ func TestCancelSettlesUncertainPublicationWithIdenticalRetry(t *testing.T) { } } -// TestCancelStaysPendingWithoutCompletedIdenticalRetry (change 0444 acceptance 2): +// TestIntegrationGateCancelCancelStaysPendingWithoutCompletedIdenticalRetry (change 0444 acceptance 2): // a workspace publication settles analogously, and an uncertain entry with NO // completed identical retry keeps cancellation-pending — then a subsequent // identical completed retry lets the SAME pending cancellation finish (acceptance // 4 tail). -func TestCancelStaysPendingWithoutCompletedIdenticalRetry(t *testing.T) { +func TestIntegrationGateCancelCancelStaysPendingWithoutCompletedIdenticalRetry(t *testing.T) { fx := newCancelFixture(t, true) desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} @@ -531,10 +368,10 @@ func TestCancelStaysPendingWithoutCompletedIdenticalRetry(t *testing.T) { } } -// TestCancelNativeAdapterAbsentIsFindingNotSilence: with no native adapter wired, a +// TestIntegrationGateCancelCancelNativeAdapterAbsentIsFindingNotSilence: with no native adapter wired, a // native participant yields an explicit finding while the process teardown still // accounts the run to cancelled. -func TestCancelNativeAdapterAbsentIsFindingNotSilence(t *testing.T) { +func TestIntegrationGateCancelCancelNativeAdapterAbsentIsFindingNotSilence(t *testing.T) { fx := newCancelFixture(t, true) if err := RegisterEpochParticipant(fx.repo, fx.key, fx.epochID, EpochParticipant{Kind: "coordinator", NativeHandle: "turn-1"}); err != nil { t.Fatalf("RegisterEpochParticipant: %v", err) @@ -550,9 +387,9 @@ func TestCancelNativeAdapterAbsentIsFindingNotSilence(t *testing.T) { } } -// TestCancelNeverChargesOrResets: cancellation touches neither the change-owned +// TestIntegrationGateCancelCancelNeverChargesOrResets: cancellation touches neither the change-owned // suite budget nor the gate retry markers, and resets no gate-record retry state. -func TestCancelNeverChargesOrResets(t *testing.T) { +func TestIntegrationGateCancelCancelNeverChargesOrResets(t *testing.T) { fx := newCancelFixture(t, true) // Seed a consumed retry marker and a reserved suite attempt. @@ -604,11 +441,11 @@ func TestCancelNeverChargesOrResets(t *testing.T) { } } -// TestCancelPendingWhileLaunchObligationUnresolved: even with every process teardown +// TestIntegrationGateCancelCancelPendingWhileLaunchObligationUnresolved: even with every process teardown // proven, an epoch-linked launch obligation the reconciler reports unsettled keeps // the cancellation pending (a completed replacement must never first appear after a // completed cancellation), surfacing the reconciler's findings. -func TestCancelPendingWhileLaunchObligationUnresolved(t *testing.T) { +func TestIntegrationGateCancelCancelPendingWhileLaunchObligationUnresolved(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} recon := &fakeLaunchReconciler{report: gatedrive.EpochLaunchReport{ @@ -631,10 +468,10 @@ func TestCancelPendingWhileLaunchObligationUnresolved(t *testing.T) { } } -// TestCancelCompletesWhenLaunchObligationsSettle: with the reconciler reporting every +// TestIntegrationGateCancelCancelCompletesWhenLaunchObligationsSettle: with the reconciler reporting every // launch obligation accounted and the rest of the accounting green, cancellation // completes to cancelled. -func TestCancelCompletesWhenLaunchObligationsSettle(t *testing.T) { +func TestIntegrationGateCancelCancelCompletesWhenLaunchObligationsSettle(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} recon := okLaunchReconciler() @@ -651,9 +488,9 @@ func TestCancelCompletesWhenLaunchObligationsSettle(t *testing.T) { } } -// TestCancelReconcilerUnavailableFailsClosed: a nil launch reconciler is not silence +// TestIntegrationGateCancelCancelReconcilerUnavailableFailsClosed: a nil launch reconciler is not silence // — it is a finding and a fail-closed pending, mirroring the nil-stopper rule. -func TestCancelReconcilerUnavailableFailsClosed(t *testing.T) { +func TestIntegrationGateCancelCancelReconcilerUnavailableFailsClosed(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} res := runCancel(cancelSeams{store: fx.store, stopper: stopper, launches: nil}, fx.repo, fx.key, fx.epochID, "human stop") @@ -669,10 +506,10 @@ func TestCancelReconcilerUnavailableFailsClosed(t *testing.T) { } } -// TestRunCancelPublicEntry: the public RunCancel composes production seams and, over +// TestIntegrationGateCancelRunCancelPublicEntry: the public RunCancel composes production seams and, over // a run with no worktree slot and no native adapter, refuses cleanly when authority // is wrong (here a wrong epoch) — proving the public signature is wired. -func TestRunCancelPublicEntry(t *testing.T) { +func TestIntegrationGateCancelRunCancelPublicEntry(t *testing.T) { fx := newCancelFixture(t, false) res := RunCancel(context.Background(), PlanningDeps{}, WorkspaceDeps{}, fx.repo, fx.key, "wrong-epoch", "human stop") if res.Disposition != CancelDispositionRefused { @@ -683,10 +520,10 @@ func TestRunCancelPublicEntry(t *testing.T) { } } -// TestCancelNeverTouchesForeignSlot (AC4): a slot the worktree carries for a +// TestIntegrationGateCancelCancelNeverTouchesForeignSlot (AC4): a slot the worktree carries for a // DIFFERENT epoch is never marked, stopped, or released by this epoch's cancel — a // different nonempty RunEpochID is a foreign owner, surfaced informationally. -func TestCancelNeverTouchesForeignSlot(t *testing.T) { +func TestIntegrationGateCancelCancelNeverTouchesForeignSlot(t *testing.T) { fx := newCancelFixture(t, false) // Occupy the worktree with a FOREIGN epoch's executing slot. ftoken, err := fx.store.ReserveWorktreeExecutionForEpoch(fx.common, fx.worktree, "foreign-epoch", nil) @@ -715,10 +552,10 @@ func TestCancelNeverTouchesForeignSlot(t *testing.T) { } } -// TestCancelLeavesUnlinkedEpochlessSlot (AC4): an epoch-less slot whose execution is +// TestIntegrationGateCancelCancelLeavesUnlinkedEpochlessSlot (AC4): an epoch-less slot whose execution is // NOT independently linked to this epoch's registered participants is left // untouched, with an unresolved-ownership finding; cancellation still completes. -func TestCancelLeavesUnlinkedEpochlessSlot(t *testing.T) { +func TestIntegrationGateCancelCancelLeavesUnlinkedEpochlessSlot(t *testing.T) { fx := newCancelFixture(t, false) rtoken, err := fx.store.ReserveRawWorktreeExecution(fx.common, fx.worktree, nil) if err != nil { @@ -743,10 +580,10 @@ func TestCancelLeavesUnlinkedEpochlessSlot(t *testing.T) { } } -// TestCancelStopsLinkedEpochlessSlot (AC4): an epoch-less slot IS torn down when its +// TestIntegrationGateCancelCancelStopsLinkedEpochlessSlot (AC4): an epoch-less slot IS torn down when its // exact execution (RawRunDir) is independently linked to a registered execution // participant of this epoch. -func TestCancelStopsLinkedEpochlessSlot(t *testing.T) { +func TestIntegrationGateCancelCancelStopsLinkedEpochlessSlot(t *testing.T) { fx := newCancelFixture(t, false) runDir := filepath.Join(fx.worktree, "run-L") rtoken, err := fx.store.ReserveRawWorktreeExecution(fx.common, fx.worktree, nil) @@ -769,11 +606,11 @@ func TestCancelStopsLinkedEpochlessSlot(t *testing.T) { } } -// TestCancelReleaseWriteFailureFailsClosed (AC5): a release whose durable write +// TestIntegrationGateCancelCancelReleaseWriteFailureFailsClosed (AC5): a release whose durable write // fails (the record vanishes between the proven stop and the release) keeps the // cancellation pending — a successful process stop never proves the release was // recorded. -func TestCancelReleaseWriteFailureFailsClosed(t *testing.T) { +func TestIntegrationGateCancelCancelReleaseWriteFailureFailsClosed(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} stopper.onStop = func(runDir string) { @@ -792,9 +629,9 @@ func TestCancelReleaseWriteFailureFailsClosed(t *testing.T) { } } -// TestCancelRetiresOwnedReleasedSlot (AC1/AC2 app half): completed cancellation +// TestIntegrationGateCancelCancelRetiresOwnedReleasedSlot (AC1/AC2 app half): completed cancellation // releases AND detaches the slot — RunEpochID cleared, historical fields preserved. -func TestCancelRetiresOwnedReleasedSlot(t *testing.T) { +func TestIntegrationGateCancelCancelRetiresOwnedReleasedSlot(t *testing.T) { fx := newCancelFixture(t, true) before, _, err := fx.store.LoadWorktreeExecution(fx.worktree) if err != nil { @@ -822,9 +659,9 @@ func TestCancelRetiresOwnedReleasedSlot(t *testing.T) { } } -// TestCancelPendingWhenRetirementFails (AC5): a retirement write failure keeps the +// TestIntegrationGateCancelCancelPendingWhenRetirementFails (AC5): a retirement write failure keeps the // epoch cancelling and the disposition pending — never a false cancelled. -func TestCancelPendingWhenRetirementFails(t *testing.T) { +func TestIntegrationGateCancelCancelPendingWhenRetirementFails(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} seams := cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler(), @@ -844,11 +681,11 @@ func TestCancelPendingWhenRetirementFails(t *testing.T) { } } -// TestCancelInterruptedBetweenRetireAndFinalizeConverges (AC5): retirement landed +// TestIntegrationGateCancelCancelInterruptedBetweenRetireAndFinalizeConverges (AC5): retirement landed // but cancelled was never persisted (simulated crash between the two writes); the // retry revalidates, accepts the already-detached slot, and finishes the epoch // transition — without touching a successor. -func TestCancelInterruptedBetweenRetireAndFinalizeConverges(t *testing.T) { +func TestIntegrationGateCancelCancelInterruptedBetweenRetireAndFinalizeConverges(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} // Reconstruct the crash state directly: the epoch record CAS has no seam, so @@ -881,12 +718,12 @@ func TestCancelInterruptedBetweenRetireAndFinalizeConverges(t *testing.T) { } } -// TestCancelRetireRaceWithSuccessorLeavesSuccessor (AC4): the slot is replaced by a +// TestIntegrationGateCancelCancelRetireRaceWithSuccessorLeavesSuccessor (AC4): the slot is replaced by a // successor between the cancel's load and its retire CAS — the retire refuses on // the changed reservation, the re-read classifies the successor as foreign, and // cancellation completes WITHOUT touching it (never retried with the successor's // token). -func TestCancelRetireRaceWithSuccessorLeavesSuccessor(t *testing.T) { +func TestIntegrationGateCancelCancelRetireRaceWithSuccessorLeavesSuccessor(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} var raced bool @@ -917,10 +754,10 @@ func TestCancelRetireRaceWithSuccessorLeavesSuccessor(t *testing.T) { } } -// TestCancelConcurrentReplayIsIdempotent (AC4): two sequential replays of a +// TestIntegrationGateCancelCancelConcurrentReplayIsIdempotent (AC4): two sequential replays of a // completed cancellation are no-ops (already-cancelled) leaving slot and epoch // byte-stable. -func TestCancelConcurrentReplayIsIdempotent(t *testing.T) { +func TestIntegrationGateCancelCancelConcurrentReplayIsIdempotent(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} seams := cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()} @@ -938,12 +775,12 @@ func TestCancelConcurrentReplayIsIdempotent(t *testing.T) { } } -// TestTerminalRepairRetiresHistoricalStaleSlot (AC6): a durably CANCELLED epoch +// TestIntegrationGateCancelTerminalRepairRetiresHistoricalStaleSlot (AC6): a durably CANCELLED epoch // whose released slot still carries its RunEpochID (the recorded incident shape: // a pre-0435 cancel released but never retired) is repaired by an authorized repeat // cancel — disposition cancelled/applied, slot detached, epoch state // untouched-terminal — and a second repair is an idempotent no-op. -func TestTerminalRepairRetiresHistoricalStaleSlot(t *testing.T) { +func TestIntegrationGateCancelTerminalRepairRetiresHistoricalStaleSlot(t *testing.T) { fx := newCancelFixture(t, true) // Manufacture the historical defect: release WITHOUT retirement, then force the // epoch terminal (what the pre-0435 cancel produced). @@ -977,9 +814,9 @@ func TestTerminalRepairRetiresHistoricalStaleSlot(t *testing.T) { } } -// TestTerminalRepairSupersededSlot (AC6): the same repair works for a SUPERSEDED +// TestIntegrationGateCancelTerminalRepairSupersededSlot (AC6): the same repair works for a SUPERSEDED // epoch's stale released slot, and never regresses the superseded state. -func TestTerminalRepairSupersededSlot(t *testing.T) { +func TestIntegrationGateCancelTerminalRepairSupersededSlot(t *testing.T) { fx := newCancelFixture(t, true) slot, _, err := fx.store.LoadWorktreeExecution(fx.worktree) if err != nil { @@ -1003,10 +840,10 @@ func TestTerminalRepairSupersededSlot(t *testing.T) { } } -// TestTerminalRepairRefusesUnsafeHistories (AC6): each unsafe terminal history is +// TestIntegrationGateCancelTerminalRepairRefusesUnsafeHistories (AC6): each unsafe terminal history is // refused with a specific finding, with NO slot or epoch mutation, and never // cancellation-pending over durable terminal state. -func TestTerminalRepairRefusesUnsafeHistories(t *testing.T) { +func TestIntegrationGateCancelTerminalRepairRefusesUnsafeHistories(t *testing.T) { cases := []struct { name string arrange func(t *testing.T, fx cancelFixture) cancelSeams @@ -1068,14 +905,14 @@ func TestTerminalRepairRefusesUnsafeHistories(t *testing.T) { } } -// TestGuardianReapsButNeverRetires: the death guardian's fence+reap releases the +// TestIntegrationGateCancelGuardianReapsButNeverRetires: the death guardian's fence+reap releases the // proven-stopped slot but RETAINS RunEpochID and leaves the epoch CANCELLING — // only authorized run.cancel completion retires (spec "The death guardian may // perform teardown but never retires epoch ownership"). The contract is proven // against the shared reconcileEpochTeardown, which the guardian composes and which // performs no retirement; retirement stays exclusively in runCancel's post-accounted // completion block and repairTerminalEpoch, neither of which the guardian reaches. -func TestGuardianReapsButNeverRetires(t *testing.T) { +func TestIntegrationGateCancelGuardianReapsButNeverRetires(t *testing.T) { fx := newCancelFixture(t, true) // guardianFenceAndReap composes productionCancelSeams, whose stopper/reconciler // reach the real process service — unavailable here. Drive its exact sequence @@ -1121,12 +958,12 @@ func TestGuardianReapsButNeverRetires(t *testing.T) { } } -// TestFinalizeGateAdmitsAfterRetirement (AC1): before retirement the epoch-owned +// TestIntegrationGateCancelFinalizeGateAdmitsAfterRetirement (AC1): before retirement the epoch-owned // released slot blocks an epoch-less raw/finalize launch (rawStaleEpochRefusal's // stale-run-epoch) and a different-epoch reservation (reserveWorktreeExecution's // between-drives fence); after authorized cancellation retires the ownership, both // admit again — the released slot is genuinely reusable. -func TestFinalizeGateAdmitsAfterRetirement(t *testing.T) { +func TestIntegrationGateCancelFinalizeGateAdmitsAfterRetirement(t *testing.T) { fx := newCancelFixture(t, true) slot, _, err := fx.store.LoadWorktreeExecution(fx.worktree) if err != nil { @@ -1160,11 +997,11 @@ func TestFinalizeGateAdmitsAfterRetirement(t *testing.T) { } } -// TestRetirementDoesNotUnfenceOldEpochLaunches (AC8): after retirement the OLD +// TestIntegrationGateCancelRetirementDoesNotUnfenceOldEpochLaunches (AC8): after retirement the OLD // epoch's fresh start is still refused by the 437 launch gate (epochLaunchGate) — // clearing slot ownership never revives the cancelled epoch's launch authority, and // the refused gate never runs the reservation body. -func TestRetirementDoesNotUnfenceOldEpochLaunches(t *testing.T) { +func TestIntegrationGateCancelRetirementDoesNotUnfenceOldEpochLaunches(t *testing.T) { fx := newCancelFixture(t, true) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} if res := runCancel(cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()}, fx.repo, fx.key, fx.epochID, "human stop"); res.Disposition != CancelDispositionCancelled { @@ -1181,13 +1018,13 @@ func TestRetirementDoesNotUnfenceOldEpochLaunches(t *testing.T) { } } -// TestRepairChargesNothing (AC8): terminal repair — like cancellation — touches +// TestIntegrationGateCancelRepairChargesNothing (AC8): terminal repair — like cancellation — touches // neither the suite budget nor the gate retry markers. This mirrors -// TestCancelNeverChargesOrResets (same seeding and asserts) with the historical -// stale-slot repair arrangement of TestTerminalRepairRetiresHistoricalStaleSlot +// TestIntegrationGateCancelCancelNeverChargesOrResets (same seeding and asserts) with the historical +// stale-slot repair arrangement of TestIntegrationGateCancelTerminalRepairRetiresHistoricalStaleSlot // (release WITHOUT retirement + epoch forced cancelled) placed between the seeding // and the accounting-neutrality asserts. -func TestRepairChargesNothing(t *testing.T) { +func TestIntegrationGateCancelRepairChargesNothing(t *testing.T) { fx := newCancelFixture(t, true) // Seed a consumed retry marker and a reserved suite attempt. @@ -1251,12 +1088,12 @@ func TestRepairChargesNothing(t *testing.T) { } } -// TestRunCancelWinsFromCompletingEpoch: an explicit human cancellation WINS from a +// TestIntegrationGateCancelRunCancelWinsFromCompletingEpoch: an explicit human cancellation WINS from a // completing (successful, mid-closeout) epoch — the fence flips completing→cancelling // and the ordinary teardown/accounting runs to a proven cancellation, never a // completing/completed relabelling. Completion then loses (change 0441): its // completing→completed CAS refuses once this fence lands. -func TestRunCancelWinsFromCompletingEpoch(t *testing.T) { +func TestIntegrationGateCancelRunCancelWinsFromCompletingEpoch(t *testing.T) { fx := newCancelFixture(t, true) forceEpochState(t, fx.repo, fx.key, EpochCompleting) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} @@ -1271,11 +1108,11 @@ func TestRunCancelWinsFromCompletingEpoch(t *testing.T) { } } -// TestRunCancelRefusesCompletedEpoch: a completed (successfully closed-out) run +// TestIntegrationGateCancelRunCancelRefusesCompletedEpoch: a completed (successfully closed-out) run // cannot be cancelled — a no-op refusal carrying the completed-run explanation, never // a state regression and never cancellation-pending over durable terminal state // (change 0441). -func TestRunCancelRefusesCompletedEpoch(t *testing.T) { +func TestIntegrationGateCancelRunCancelRefusesCompletedEpoch(t *testing.T) { fx := newCancelFixture(t, true) forceEpochState(t, fx.repo, fx.key, EpochCompleted) stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} @@ -1301,7 +1138,7 @@ func isGateOwnership(err error, kind gatedrive.OwnershipErrorKind) bool { return ok && oe.Kind == kind } -// TestRawLaunchSettlesSettledEpochReleasedSlot (change 0446 spec §§2, 5): a +// TestIntegrationGateCancelRawLaunchSettlesSettledEpochReleasedSlot (change 0446 spec §§2, 5): a // released slot whose leftover RunEpochID names a COMPLETED or confirmed-CANCELLED // epoch no longer blocks an epoch-less raw/finalize launch — the raw pre-check // defers the released slot to the reserve, which settles the epoch through the @@ -1309,7 +1146,7 @@ func isGateOwnership(err error, kind gatedrive.OwnershipErrorKind) bool { // completed run is never asked to be cancelled. An active, cancelling, or // completing epoch still owns its worktree: the reserve refuses stale-run-epoch and // the slot is left untouched. -func TestRawLaunchSettlesSettledEpochReleasedSlot(t *testing.T) { +func TestIntegrationGateCancelRawLaunchSettlesSettledEpochReleasedSlot(t *testing.T) { cases := []struct { state epochState settled bool @@ -1361,9 +1198,9 @@ func TestRawLaunchSettlesSettledEpochReleasedSlot(t *testing.T) { } } -// TestRawStaleEpochRefusalStillFencesBusySlot: the raw pre-check keeps refusing a +// TestIntegrationGateCancelRawStaleEpochRefusalStillFencesBusySlot: the raw pre-check keeps refusing a // BUSY slot another epoch owns — only a released slot defers to the reserve. -func TestRawStaleEpochRefusalStillFencesBusySlot(t *testing.T) { +func TestIntegrationGateCancelRawStaleEpochRefusalStillFencesBusySlot(t *testing.T) { fx := newCancelFixture(t, true) fx.store.SetEpochSettledResolver(epochSettledResolver(fx.common)) if _, refused := rawStaleEpochRefusal(fx.store, fx.worktree); !refused { @@ -1371,10 +1208,10 @@ func TestRawStaleEpochRefusalStillFencesBusySlot(t *testing.T) { } } -// TestRawAdmissionStoreWiresEpochSettledResolver: the raw launch path's admission +// TestIntegrationGateCancelRawAdmissionStoreWiresEpochSettledResolver: the raw launch path's admission // store carries the production settlement read (change 0446) — without it a raw // reserve over a completed run's released slot would refuse stale-run-epoch. -func TestRawAdmissionStoreWiresEpochSettledResolver(t *testing.T) { +func TestIntegrationGateCancelRawAdmissionStoreWiresEpochSettledResolver(t *testing.T) { repo := newGateRepo(t) _, _, store, ok := resolveWorktreeAdmission(repo) if !ok { @@ -1385,19 +1222,6 @@ func TestRawAdmissionStoreWiresEpochSettledResolver(t *testing.T) { } } -// admissionRecordFile returns the worktree slot's record path at the documented -// storage layout (see removeAdmissionRecord): the byte-identity probe the -// retirement-convergence tests use to prove a successor's slot is untouched. -func admissionRecordFile(t *testing.T, common, worktree string) string { - t.Helper() - canon, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatalf("EvalSymlinks: %v", err) - } - sum := sha256.Sum256([]byte(canon)) - return filepath.Join(common, "docket", "gate-admission", "v1", hex.EncodeToString(sum[:]), "record.json") -} - // readAdmissionRecord reads the slot's raw record bytes. func readAdmissionRecord(t *testing.T, common, worktree string) []byte { t.Helper() @@ -1439,14 +1263,14 @@ func installSuccessor(t *testing.T, fx cancelFixture, token string) { } } -// TestRetirementSitesConverge (change 0446 spec §4, AC5): the three slot-retirement +// TestIntegrationGateCancelRetirementSitesConverge (change 0446 spec §4, AC5): the three slot-retirement // sites — runCancel's completion, repairTerminalEpoch, and validateResumeQuiescence — // share ONE retirement implementation, so a successor holding the slot (whether it // already held it or won the retirement CAS race) yields one defined outcome at // every site: the slot-replaced-by-successor finding, the operation still accounted // (cancelled / already-cancelled / quiescent), the successor's slot byte-identical, // and the epoch never regressed. -func TestRetirementSitesConverge(t *testing.T) { +func TestIntegrationGateCancelRetirementSitesConverge(t *testing.T) { type site struct { name string epochState epochState // the state the epoch is in when the site runs @@ -1530,11 +1354,11 @@ func TestRetirementSitesConverge(t *testing.T) { } } -// TestRepairTerminalEpochRemovedWorktree (change 0446 spec §5, AC2): a terminal +// TestIntegrationGateCancelRepairTerminalEpochRemovedWorktree (change 0446 spec §5, AC2): a terminal // epoch whose feature worktree directory was REMOVED still has its stale released // slot retired by terminal repair — the slot is reached through its stored identity, // never reported slot-unreadable — and a repeat repair is the idempotent no-op. -func TestRepairTerminalEpochRemovedWorktree(t *testing.T) { +func TestIntegrationGateCancelRepairTerminalEpochRemovedWorktree(t *testing.T) { fx := newCancelFixture(t, true) releaseFixtureSlot(t, fx) if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { r.State = EpochCancelled; return nil }); err != nil { @@ -1593,12 +1417,12 @@ func supersedeFixtureEpoch(t *testing.T, fx cancelFixture, replacementWorktree s return replKey } -// TestTerminalRepairSupersededThreadsReplacementWorktree (change 0446 spec §4, AC5): +// TestIntegrationGateCancelTerminalRepairSupersededThreadsReplacementWorktree (change 0446 spec §4, AC5): // a SUPERSEDED epoch has an empty Worktree, which is not proof of quiescence. Terminal // repair resolves the replacement epoch's worktree through ReplacementReserved, runs // the launch census with the predecessor's epoch id against THAT worktree, and — when // the replacement's slot still carries the predecessor's RunEpochID — retires it. -func TestTerminalRepairSupersededThreadsReplacementWorktree(t *testing.T) { +func TestIntegrationGateCancelTerminalRepairSupersededThreadsReplacementWorktree(t *testing.T) { t.Run("stale-released-slot-retired", func(t *testing.T) { fx := newCancelFixture(t, true) releaseFixtureSlot(t, fx) @@ -1678,7 +1502,7 @@ func tornResumeFixture(t *testing.T, fx cancelFixture, neverMinted bool) string return replKey } -// TestTerminalRepairTornResumeConverges (change 0446 spec "Repeated cancellation, +// TestIntegrationGateCancelTerminalRepairTornResumeConverges (change 0446 spec "Repeated cancellation, // completion, and admission after safe reconciliation converge using existing // operations"): a torn resume — the predecessor superseded, the replacement epoch never // minted or never bound — is not a permanent dead end. A repeat run.cancel against the @@ -1686,7 +1510,7 @@ func tornResumeFixture(t *testing.T, fx cancelFixture, neverMinted bool) string // armResumeReplacement prepared), runs the census with the predecessor's epoch id // there, retires a stale released slot, and a further repeat is the idempotent no-op. // A genuinely corrupt or cyclic replacement chain still fails closed. -func TestTerminalRepairTornResumeConverges(t *testing.T) { +func TestIntegrationGateCancelTerminalRepairTornResumeConverges(t *testing.T) { for _, tc := range []struct { name string neverMinted bool @@ -1755,14 +1579,14 @@ func TestTerminalRepairTornResumeConverges(t *testing.T) { }) } -// TestCancelRemovedWorktreeEpochReachesSlotByStoredIdentity (change 0446 spec AC2, +// TestIntegrationGateCancelCancelRemovedWorktreeEpochReachesSlotByStoredIdentity (change 0446 spec AC2, // Task 10): cancelling an ACTIVE epoch whose feature worktree directory was removed // — with its slot still executing (the stop proves teardown) or already released // between drives — reaches the slot through its stored identity rather than // re-canonicalizing the missing path: the cancel completes, the slot is released // and detached from the epoch, a repeat is the idempotent no-op, and once the path // is recreated a replacement epoch's reservation admits over the same slot. -func TestCancelRemovedWorktreeEpochReachesSlotByStoredIdentity(t *testing.T) { +func TestIntegrationGateCancelCancelRemovedWorktreeEpochReachesSlotByStoredIdentity(t *testing.T) { for _, releasedFirst := range []bool{false, true} { t.Run(map[bool]string{false: "executing-slot", true: "released-slot"}[releasedFirst], func(t *testing.T) { fx := newCancelFixture(t, true) diff --git a/internal/app/rungate_claim_test.go b/internal/app/rungate_claim_integration_test.go similarity index 84% rename from internal/app/rungate_claim_test.go rename to internal/app/rungate_claim_integration_test.go index 4ea08fb46..3b0e4629d 100644 --- a/internal/app/rungate_claim_test.go +++ b/internal/app/rungate_claim_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -50,11 +52,11 @@ func gateMintWithContinuation(t *testing.T, repoDir, cid, drive, handoff string) return key } -// TestGateClaimSuccessRedeemsAndClearsTriple: a matching continuation id claims +// TestIntegrationGateArmGateClaimSuccessRedeemsAndClearsTriple: a matching continuation id claims // the recovered drive, clears the triple (single-use at the record layer), and // returns the fresh owner generation in JSON. The seam is called with the exact // drive id + handoff token from the triple. -func TestGateClaimSuccessRedeemsAndClearsTriple(t *testing.T) { +func TestIntegrationGateArmGateClaimSuccessRedeemsAndClearsTriple(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") seam := &fakeClaimSeam{out: GateClaimOutcome{Generation: "freshgen", Phase: "build", Outcome: "WAITING"}} @@ -84,9 +86,9 @@ func TestGateClaimSuccessRedeemsAndClearsTriple(t *testing.T) { } } -// TestGateClaimRedactsGeneration: the generation travels only in the JSON +// TestIntegrationGateArmGateClaimRedactsGeneration: the generation travels only in the JSON // document — HumanText names the drive id and outcome, never the generation. -func TestGateClaimRedactsGeneration(t *testing.T) { +func TestIntegrationGateArmGateClaimRedactsGeneration(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") seam := &fakeClaimSeam{out: GateClaimOutcome{Generation: "secretgen", Phase: "build", Outcome: "WAITING"}} @@ -102,9 +104,9 @@ func TestGateClaimRedactsGeneration(t *testing.T) { } } -// TestGateClaimSingleUse: a second claim after a successful redemption finds no +// TestIntegrationGateArmGateClaimSingleUse: a second claim after a successful redemption finds no // continuation (the triple was cleared) and fails closed to no-continuation. -func TestGateClaimSingleUse(t *testing.T) { +func TestIntegrationGateArmGateClaimSingleUse(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") seam := &fakeClaimSeam{out: GateClaimOutcome{Generation: "freshgen", Phase: "build", Outcome: "WAITING"}} @@ -121,9 +123,9 @@ func TestGateClaimSingleUse(t *testing.T) { } } -// TestGateClaimNoContinuation: a record with no continuation triple fails closed +// TestIntegrationGateArmGateClaimNoContinuation: a record with no continuation triple fails closed // to no-continuation and never touches the drive layer. -func TestGateClaimNoContinuation(t *testing.T) { +func TestIntegrationGateArmGateClaimNoContinuation(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "") // armed, no triple seam := &fakeClaimSeam{} @@ -137,9 +139,9 @@ func TestGateClaimNoContinuation(t *testing.T) { } } -// TestGateClaimMismatch: a wrong continuation id fails closed to +// TestIntegrationGateArmGateClaimMismatch: a wrong continuation id fails closed to // continuation-mismatch and leaves the triple intact for a legitimate retry. -func TestGateClaimMismatch(t *testing.T) { +func TestIntegrationGateArmGateClaimMismatch(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-right", "d0opaque", "h0token") seam := &fakeClaimSeam{} @@ -160,9 +162,9 @@ func TestGateClaimMismatch(t *testing.T) { } } -// TestGateClaimMismatchDifferentLength: a length-differing id also fails closed +// TestIntegrationGateArmGateClaimMismatchDifferentLength: a length-differing id also fails closed // (crypto/subtle returns 0 on unequal lengths) rather than panicking or matching. -func TestGateClaimMismatchDifferentLength(t *testing.T) { +func TestIntegrationGateArmGateClaimMismatchDifferentLength(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") res := RunGateClaim(repo, key, "cid-abc-longer", &fakeClaimSeam{}) @@ -171,10 +173,10 @@ func TestGateClaimMismatchDifferentLength(t *testing.T) { } } -// TestGateClaimHaltedCarriesCause: a HALTED drive-layer claim (unsafe ownership) +// TestIntegrationGateArmGateClaimHaltedCarriesCause: a HALTED drive-layer claim (unsafe ownership) // fails closed to halted-claim carrying the driver's cause, and leaves the triple // intact. -func TestGateClaimHaltedCarriesCause(t *testing.T) { +func TestIntegrationGateArmGateClaimHaltedCarriesCause(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") seam := &fakeClaimSeam{out: GateClaimOutcome{Halted: true, Cause: "fingerprint-mismatch", Outcome: "HALTED"}} @@ -192,9 +194,9 @@ func TestGateClaimHaltedCarriesCause(t *testing.T) { } } -// TestGateClaimCommandError: a command fault from the drive layer fails closed to +// TestIntegrationGateArmGateClaimCommandError: a command fault from the drive layer fails closed to // claim-error and leaves the triple intact. -func TestGateClaimCommandError(t *testing.T) { +func TestIntegrationGateArmGateClaimCommandError(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") seam := &fakeClaimSeam{err: errFake} @@ -209,9 +211,9 @@ func TestGateClaimCommandError(t *testing.T) { } } -// TestGateClaimNilSeam: an unwired seam fails closed to claim-unavailable without +// TestIntegrationGateArmGateClaimNilSeam: an unwired seam fails closed to claim-unavailable without // clearing the triple. -func TestGateClaimNilSeam(t *testing.T) { +func TestIntegrationGateArmGateClaimNilSeam(t *testing.T) { repo := newGateRepo(t) key := gateMintWithContinuation(t, repo, "cid-abc", "d0opaque", "h0token") @@ -225,9 +227,9 @@ func TestGateClaimNilSeam(t *testing.T) { } } -// TestGateClaimLoadErrorFailsClosed: a malformed key never touches the filesystem +// TestIntegrationGateArmGateClaimLoadErrorFailsClosed: a malformed key never touches the filesystem // and fails closed to a gate-stop carrying the store's typed reason token. -func TestGateClaimLoadErrorFailsClosed(t *testing.T) { +func TestIntegrationGateArmGateClaimLoadErrorFailsClosed(t *testing.T) { repo := newGateRepo(t) res := RunGateClaim(repo, "Bad/Key", "cid-abc", &fakeClaimSeam{}) if res.Decision != GateDecisionStop { diff --git a/internal/app/rungate_complete_helpers_test.go b/internal/app/rungate_complete_helpers_test.go new file mode 100644 index 000000000..2e8e16d67 --- /dev/null +++ b/internal/app/rungate_complete_helpers_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "github.com/danielhanold/docket/internal/gatedrive" +) + +// Run-gate completion test helpers shared with default-build (untagged) test files. +// The completion tests themselves live behind the integration tag in +// rungate_complete_integration_test.go (change 0465); these fakes stay untagged +// because other untagged test files still reference them. + +// fakeProcessObserver is an injectable processObserver: it answers proven/unproven +// per run dir (falling back to defaultProven), can return a canned error, records +// every handle it observed, and — like the cancel tests' onStop barrier — can inject a +// race via onObserve. It stops nothing. +type fakeProcessObserver struct { + proven map[string]bool + defaultProven bool + err error + calls []string + onObserve func(handle string) +} + +func (f *fakeProcessObserver) observeProcessTerminal(runDir string) (bool, error) { + f.calls = append(f.calls, runDir) + if f.onObserve != nil { + f.onObserve(runDir) + } + if f.err != nil { + return false, f.err + } + if f.proven != nil { + if v, ok := f.proven[runDir]; ok { + return v, nil + } + } + return f.defaultProven, nil +} + +// fakeLaunchObserver is an injectable epochLaunchObserver: it records each +// (worktree,epoch) pair, returns a canned report/error, and can inject a race via +// onObserve (a late participant registered after the accounting snapshot but before +// re-enumeration). It settles nothing. +type fakeLaunchObserver struct { + report gatedrive.EpochLaunchReport + err error + calls []string + onObserve func() +} + +func (f *fakeLaunchObserver) observe(worktree, epochID string) (gatedrive.EpochLaunchReport, error) { + f.calls = append(f.calls, worktree+"|"+epochID) + if f.onObserve != nil { + f.onObserve() + } + return f.report, f.err +} diff --git a/internal/app/rungate_complete_test.go b/internal/app/rungate_complete_integration_test.go similarity index 90% rename from internal/app/rungate_complete_test.go rename to internal/app/rungate_complete_integration_test.go index e34a80700..dfcb9156c 100644 --- a/internal/app/rungate_complete_test.go +++ b/internal/app/rungate_complete_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -18,54 +20,7 @@ import ( // and CASes completing→completed — failing closed on any unsettled obligation and // never relabelling a cancelled/superseded run successful. The tests drive the flow // over faked observation seams and a real gatedrive admission store, reusing -// rungate_cancel_test.go's fixtures. - -// fakeProcessObserver is an injectable processObserver: it answers proven/unproven -// per run dir (falling back to defaultProven), can return a canned error, records -// every handle it observed, and — like the cancel tests' onStop barrier — can inject a -// race via onObserve. It stops nothing. -type fakeProcessObserver struct { - proven map[string]bool - defaultProven bool - err error - calls []string - onObserve func(handle string) -} - -func (f *fakeProcessObserver) observeProcessTerminal(runDir string) (bool, error) { - f.calls = append(f.calls, runDir) - if f.onObserve != nil { - f.onObserve(runDir) - } - if f.err != nil { - return false, f.err - } - if f.proven != nil { - if v, ok := f.proven[runDir]; ok { - return v, nil - } - } - return f.defaultProven, nil -} - -// fakeLaunchObserver is an injectable epochLaunchObserver: it records each -// (worktree,epoch) pair, returns a canned report/error, and can inject a race via -// onObserve (a late participant registered after the accounting snapshot but before -// re-enumeration). It settles nothing. -type fakeLaunchObserver struct { - report gatedrive.EpochLaunchReport - err error - calls []string - onObserve func() -} - -func (f *fakeLaunchObserver) observe(worktree, epochID string) (gatedrive.EpochLaunchReport, error) { - f.calls = append(f.calls, worktree+"|"+epochID) - if f.onObserve != nil { - f.onObserve() - } - return f.report, f.err -} +// the run-cancel fixtures in rungate_cancel_helpers_test.go. // completionFixture is one prepared successfully-finished run: a fully authorized // active epoch bound to a worktree whose epoch-owned slot is RELEASED (its drives are @@ -117,10 +72,10 @@ func slotReservationToken(t *testing.T, store *gatedrive.Store, worktree string) return slot.ReservationToken } -// TestCompleteSuccessfulRunHappyPath: a fully settled run closes out — ok, epoch +// TestIntegrationGateCompletionCompleteSuccessfulRunHappyPath: a fully settled run closes out — ok, epoch // EpochCompleted, the owned released slot detached (RunEpochID cleared) with its state // still released and every history field intact (AC2 history preservation). -func TestCompleteSuccessfulRunHappyPath(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunHappyPath(t *testing.T) { fx := newCompletionFixture(t) before, _, err := fx.store.LoadWorktreeExecution(fx.worktree) if err != nil { @@ -150,9 +105,9 @@ func TestCompleteSuccessfulRunHappyPath(t *testing.T) { } } -// TestCompleteSuccessfulRunIdempotentReplay: a second closeout of a completed epoch is +// TestIntegrationGateCompletionCompleteSuccessfulRunIdempotentReplay: a second closeout of a completed epoch is // a no-op success — the stored epoch generation is byte-stable across the replay. -func TestCompleteSuccessfulRunIdempotentReplay(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunIdempotentReplay(t *testing.T) { fx := newCompletionFixture(t) if ok, reason, findings := completeSuccessfulRun(fx.seams(), fx.repo, fx.key); !ok { t.Fatalf("first closeout ok=false reason=%q findings=%v", reason, findings) @@ -173,10 +128,10 @@ func TestCompleteSuccessfulRunIdempotentReplay(t *testing.T) { } } -// TestCompleteSuccessfulRunNeverRelabelsCancellation: a cancelling/cancelled run is +// TestIntegrationGateCompletionCompleteSuccessfulRunNeverRelabelsCancellation: a cancelling/cancelled run is // run-cancelled, a superseded run is stale-run-epoch, and the epoch state is never // rewritten to a successful one. -func TestCompleteSuccessfulRunNeverRelabelsCancellation(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunNeverRelabelsCancellation(t *testing.T) { cases := []struct { state epochState reason string @@ -203,10 +158,10 @@ func TestCompleteSuccessfulRunNeverRelabelsCancellation(t *testing.T) { } } -// TestCompleteSuccessfulRunBlocksOnEveryUnsettledObligation (AC3): each unsettled +// TestIntegrationGateCompletionCompleteSuccessfulRunBlocksOnEveryUnsettledObligation (AC3): each unsettled // obligation fails closed — ok false, reason completion-unaccounted, the named // finding present, the epoch left durably completing, and the slot untouched. -func TestCompleteSuccessfulRunBlocksOnEveryUnsettledObligation(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunBlocksOnEveryUnsettledObligation(t *testing.T) { // each build returns the seams and the located run; the epoch begins active so the // closeout drives the real completing fence before it blocks. type row struct { @@ -308,13 +263,13 @@ func TestCompleteSuccessfulRunBlocksOnEveryUnsettledObligation(t *testing.T) { } } -// TestCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot isolates step (3)'s +// TestIntegrationGateCompletionCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot isolates step (3)'s // slot-released guard from retirement's own slot-not-released check (defense in // depth): a non-released owned slot is refused BEFORE its process is observed, so a // live slot is never probed as if it were settled. Retirement independently refuses a // non-released owned slot with the same finding, so this observation-order assertion // is what pins the EARLY guard as load-bearing rather than decoration. -func TestCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot(t *testing.T) { base := newCancelFixture(t, true) // epoch-owned slot left EXECUTING (not released) must(t, RegisterEpochParticipant(base.repo, base.key, base.epochID, EpochParticipant{Kind: "coordinator", NativeHandle: "turn-1"})) @@ -337,10 +292,10 @@ func TestCompleteSuccessfulRunSkipsObservingNonReleasedOwnedSlot(t *testing.T) { } } -// TestCompleteSuccessfulRunSendsNoStops (AC3): closeout observes only — it never calls +// TestIntegrationGateCompletionCompleteSuccessfulRunSendsNoStops (AC3): closeout observes only — it never calls // the stop-capable stopper, native-canceller, or reconcile (stop) seam, on either the // happy path or a blocked path. -func TestCompleteSuccessfulRunSendsNoStops(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunSendsNoStops(t *testing.T) { assertNoStops := func(t *testing.T, stopper *fakeCancelStopper, native *fakeNativeCanceller, recon *fakeLaunchReconciler) { t.Helper() if len(stopper.calls) != 0 { @@ -379,10 +334,10 @@ func TestCompleteSuccessfulRunSendsNoStops(t *testing.T) { assertNoStops(t, stopper2, native2, recon2) } -// TestCompleteSuccessfulRunLateParticipantBlocks (AC3 "late participant"): a +// TestIntegrationGateCompletionCompleteSuccessfulRunLateParticipantBlocks (AC3 "late participant"): a // participant appended after the accounting snapshot but before retirement is caught // by re-enumeration, blocking the closeout. -func TestCompleteSuccessfulRunLateParticipantBlocks(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunLateParticipantBlocks(t *testing.T) { fx := newCompletionFixture(t) // The observer proves the slot's own run terminal but nothing else. fx.observer.defaultProven = false @@ -411,11 +366,11 @@ func TestCompleteSuccessfulRunLateParticipantBlocks(t *testing.T) { } } -// TestCompleteSuccessfulRunReplayAfterRetireBeforeComplete (AC6 "interruption +// TestIntegrationGateCompletionCompleteSuccessfulRunReplayAfterRetireBeforeComplete (AC6 "interruption // before/after slot retirement"): a crash between the slot retirement and the // completing→completed CAS leaves the slot already detached and the epoch still // completing; a replay accepts the safe prior detachment and finishes to completed. -func TestCompleteSuccessfulRunReplayAfterRetireBeforeComplete(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunReplayAfterRetireBeforeComplete(t *testing.T) { fx := newCompletionFixture(t) token := slotReservationToken(t, fx.store, fx.worktree) if err := fx.store.RetireWorktreeExecutionEpoch(fx.worktree, fx.epochID, token); err != nil { @@ -434,10 +389,10 @@ func TestCompleteSuccessfulRunReplayAfterRetireBeforeComplete(t *testing.T) { } } -// TestCompleteSuccessfulRunForeignSuccessorUntouched (AC5/AC6 successor protection): a +// TestIntegrationGateCompletionCompleteSuccessfulRunForeignSuccessorUntouched (AC5/AC6 successor protection): a // slot carrying a DIFFERENT nonempty RunEpochID (a successor that reserved after safe // detachment) is left untouched, and the closeout still completes. -func TestCompleteSuccessfulRunForeignSuccessorUntouched(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunForeignSuccessorUntouched(t *testing.T) { base := newCancelFixture(t, false) // no epoch-owned slot; worktree bound if _, err := base.store.ReserveWorktreeExecutionForEpoch(base.common, base.worktree, "successor-epoch", nil); err != nil { t.Fatalf("successor reserve: %v", err) @@ -462,7 +417,7 @@ func TestCompleteSuccessfulRunForeignSuccessorUntouched(t *testing.T) { } } -// TestStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout is AC1/AC2's +// TestIntegrationGateCompletionStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout is AC1/AC2's // end-to-end integration pin: a standalone finalize gate's worktree admission is // REFUSED before the successful closeout and ADMITTED after it, at the exact // admission shape the finalize path composes (GateLaunch reserves via the store's @@ -474,7 +429,7 @@ func TestCompleteSuccessfulRunForeignSuccessorUntouched(t *testing.T) { // a following admitWorkflowMutation on the worktree is unfenced (a usable done // callback) — proving later workflow mutations on that worktree are not trapped by // the retired epoch. -func TestStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout(t *testing.T) { +func TestIntegrationGateCompletionStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout(t *testing.T) { fx := newCompletionFixture(t) // BEFORE closeout: the standalone finalize gate's admission shape presents an @@ -514,13 +469,13 @@ func TestStandaloneFinalizeAdmissionBlockedThenAdmittedAroundCloseout(t *testing done(mutationStatusCompleted, false) } -// TestOrdinaryReleaseStillRetainsEpochBetweenDrives is AC8's ordinary-release fence +// TestIntegrationGateCompletionOrdinaryReleaseStillRetainsEpochBetweenDrives is AC8's ordinary-release fence // probe: ReleaseWorktreeExecution on an epoch-owned slot leaves RunEpochID intact, so // a foreign/epoch-less reserve BETWEEN drives is still refused ErrStaleRunEpoch. Only // the attributed successful closeout (or an explicit cancellation) detaches the epoch; // a plain between-drives release never does. This pins the fence the change must NOT // weaken. -func TestOrdinaryReleaseStillRetainsEpochBetweenDrives(t *testing.T) { +func TestIntegrationGateCompletionOrdinaryReleaseStillRetainsEpochBetweenDrives(t *testing.T) { fx := newCancelFixture(t, true) // confirmed epoch-owned slot token := slotReservationToken(t, fx.store, fx.worktree) if err := fx.store.ReleaseWorktreeExecution(fx.worktree, token); err != nil { @@ -552,12 +507,12 @@ func countFinding(findings []string, token string) int { return n } -// TestCompleteSuccessfulRunDoesNotDuplicateFindings pins the diagnostic-noise fix +// TestIntegrationGateCompletionCompleteSuccessfulRunDoesNotDuplicateFindings pins the diagnostic-noise fix // (change 0441 review finding): a participant or mutation that stays unsettled across // step (3)'s fenced-record accounting and step (4)'s reload re-enumeration must appear // exactly ONCE in the operator-facing CompletionFindings, not twice — while the // closeout still fails closed (ok=false, completion-unaccounted). -func TestCompleteSuccessfulRunDoesNotDuplicateFindings(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunDoesNotDuplicateFindings(t *testing.T) { t.Run("participant", func(t *testing.T) { fx := newCompletionFixture(t) // A registered native participant with no terminal evidence: unsettled on both reads. @@ -639,13 +594,13 @@ func seedDriveRecord(t *testing.T, common, id, worktree, runDir string, outcome } } -// TestCompletionSlotReleasedOwnedNoReobservation: a RELEASED slot this epoch owns is +// TestIntegrationGateCompletionCompletionSlotReleasedOwnedNoReobservation: a RELEASED slot this epoch owns is // itself the durable proof of that slot's execution. Its run directory has been // deleted (scratch cleanup), so re-observing the process could only fail — and the // closeout must not reopen it: the slot leg is accounted and the observer is never // asked about the slot's run. Before the fix the re-observation turned the deleted // scratch into a process-unobserved blocker. -func TestCompletionSlotReleasedOwnedNoReobservation(t *testing.T) { +func TestIntegrationGateCompletionCompletionSlotReleasedOwnedNoReobservation(t *testing.T) { fx := newCompletionFixture(t) if _, err := os.Stat(fx.runDir); !os.IsNotExist(err) { t.Fatalf("precondition: the slot's run dir %q must be absent (err=%v)", fx.runDir, err) @@ -671,10 +626,10 @@ func TestCompletionSlotReleasedOwnedNoReobservation(t *testing.T) { } } -// TestCompletionUnreleasedOwnedSlotStillBlocks: an owned slot still EXECUTING is a +// TestIntegrationGateCompletionCompletionUnreleasedOwnedSlotStillBlocks: an owned slot still EXECUTING is a // live obligation — no durable release exists, so the slot leg blocks // slot-not-released exactly as before (unchanged safety). -func TestCompletionUnreleasedOwnedSlotStillBlocks(t *testing.T) { +func TestIntegrationGateCompletionCompletionUnreleasedOwnedSlotStillBlocks(t *testing.T) { base := newCancelFixture(t, true) // epoch-owned slot left executing seams := cancelSeams{store: base.store, observer: &scratchObserver{}, launchObserver: &fakeLaunchObserver{report: gatedrive.EpochLaunchReport{Accounted: true}}} @@ -684,13 +639,13 @@ func TestCompletionUnreleasedOwnedSlotStillBlocks(t *testing.T) { } } -// TestCompletionParticipantDurableProof: an execution participant whose direct +// TestIntegrationGateCompletionCompletionParticipantDurableProof: an execution participant whose direct // observation fails because its scratch is gone is accounted by an EXACT matching // durable record — the released slot recording that run, or the one persisted // PASSED/FAILED drive naming it. HALTED, a missing record, an ambiguous record, a // released slot recording a different run, an unreleased slot, and a live // observation all keep it blocking. -func TestCompletionParticipantDurableProof(t *testing.T) { +func TestIntegrationGateCompletionCompletionParticipantDurableProof(t *testing.T) { const ( idA = "0446cccccccccccccccccccccccccc01" idB = "0446cccccccccccccccccccccccccc02" @@ -776,14 +731,14 @@ func TestCompletionParticipantDurableProof(t *testing.T) { }) } -// TestCompleteThenScratchCleanupThenFinalizeAdmits (AC6): a successful run whose +// TestIntegrationGateCompletionCompleteThenScratchCleanupThenFinalizeAdmits (AC6): a successful run whose // first closeout was held by an in-flight mutation has its optional scratch removed // before the closeout is repeated; the repeat still completes on the durable release // facts. Then — with a cancelled, never-superseded predecessor epoch bound to the // same path in a directory that sorts first, and unrelated damaged drive and epoch // history present — the finalize gate's admission on that worktree, composed exactly // as GateLaunch composes it, admits. -func TestCompleteThenScratchCleanupThenFinalizeAdmits(t *testing.T) { +func TestIntegrationGateCompletionCompleteThenScratchCleanupThenFinalizeAdmits(t *testing.T) { fx := newCompletionFixture(t) if err := os.MkdirAll(fx.runDir, 0o755); err != nil { t.Fatalf("create the run's scratch: %v", err) @@ -844,13 +799,13 @@ func TestCompleteThenScratchCleanupThenFinalizeAdmits(t *testing.T) { } } -// TestCompleteSuccessfulRunSettlesUncertainPublication (change 0444 acceptance 3): the +// TestIntegrationGateCompletionCompleteSuccessfulRunSettlesUncertainPublication (change 0444 acceptance 3): the // REAL attributed closeout path settles an uncertain publication proven by a later // completed identical retry, then completes the epoch — surfacing the settlement token // in the returned findings, and sending no stop, cancelling no native task, and never // driving the stop-capable launch seam (the same no-stop proof as -// TestCompleteSuccessfulRunSendsNoStops). -func TestCompleteSuccessfulRunSettlesUncertainPublication(t *testing.T) { +// TestIntegrationGateCompletionCompleteSuccessfulRunSendsNoStops). +func TestIntegrationGateCompletionCompleteSuccessfulRunSettlesUncertainPublication(t *testing.T) { fx := newCompletionFixture(t) desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} @@ -895,11 +850,11 @@ func TestCompleteSuccessfulRunSettlesUncertainPublication(t *testing.T) { } } -// TestCompleteSuccessfulRunStillBlocksWithoutRetry (change 0444): an uncertain +// TestIntegrationGateCompletionCompleteSuccessfulRunStillBlocksWithoutRetry (change 0444): an uncertain // publication with no completed identical retry keeps the closeout blocked // (completion-unaccounted) and the entry uncertain — change 0441's fail-closed // accounting is not weakened by settlement. -func TestCompleteSuccessfulRunStillBlocksWithoutRetry(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunStillBlocksWithoutRetry(t *testing.T) { fx := newCompletionFixture(t) desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} @@ -930,11 +885,11 @@ func TestCompleteSuccessfulRunStillBlocksWithoutRetry(t *testing.T) { } } -// TestCompleteSuccessfulRunBlocksOnUnverifiedRetry (change 0444 review blocker): +// TestIntegrationGateCompletionCompleteSuccessfulRunBlocksOnUnverifiedRetry (change 0444 review blocker): // an identical retry that completed WITHOUT verifying its postcondition (contended, // refused, internally failed — journaled completed, verified false) is no evidence, // so the attributed closeout stays blocked and the original stays uncertain. -func TestCompleteSuccessfulRunBlocksOnUnverifiedRetry(t *testing.T) { +func TestIntegrationGateCompletionCompleteSuccessfulRunBlocksOnUnverifiedRetry(t *testing.T) { fx := newCompletionFixture(t) desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} @@ -963,12 +918,12 @@ func TestCompleteSuccessfulRunBlocksOnUnverifiedRetry(t *testing.T) { } } -// TestReadOnlyPathsNeverSettle (change 0444): the read-only verification predicates +// TestIntegrationGateCompletionReadOnlyPathsNeverSettle (change 0444): the read-only verification predicates // report the pending truth of a settleable pair but write NOTHING — the durable // record is byte-identical after they run (RunVerify and unattributed verdicts consume // these same predicates). Settlement is a write, and only cancellation and the // attributed keyed closeout may write. -func TestReadOnlyPathsNeverSettle(t *testing.T) { +func TestIntegrationGateCompletionReadOnlyPathsNeverSettle(t *testing.T) { fx := newCancelFixture(t, false) desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} diff --git a/internal/app/rungate_epoch_helpers_test.go b/internal/app/rungate_epoch_helpers_test.go new file mode 100644 index 000000000..8af0b8445 --- /dev/null +++ b/internal/app/rungate_epoch_helpers_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "testing" +) + +// Run-epoch test helpers shared with default-build (untagged) test files. The +// run-epoch registry tests themselves live behind the integration tag in +// rungate_epoch_integration_test.go (change 0465); these fixtures stay untagged +// because other untagged test files still reference them. + +// mintTestGateKey mints a minimal valid gate record and returns its key, so an +// epoch test has a real key directory (the epoch store requires one) without +// arming the whole gate. AttemptLimit is floored at 1 so the v4 write guard +// accepts it. +func mintTestGateKey(t *testing.T, repo string) string { + t.Helper() + key, err := MintGateRecord(repo, GateRecord{ + Target: gateBeforeStoredTarget, + AttemptLimit: 1, + Retry: RetryUnused, + Disposition: "gate-armed", + }) + if err != nil { + t.Fatalf("MintGateRecord: %v", err) + } + return key +} + +// forceEpochState drives the epoch record to state s through the CAS, standing in +// for the durable transitions other tasks own so a lifecycle guard can be exercised +// against an arbitrary state. +func forceEpochState(t *testing.T, repo, key string, s epochState) { + t.Helper() + if err := epochCAS(repo, key, func(r *EpochRecord) error { + r.State = s + return nil + }); err != nil { + t.Fatalf("forceEpochState %q: %v", s, err) + } +} + +// must fails the test immediately when err is non-nil, so a fixture setup step +// whose failure is not the assertion under test reads as one line. +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/app/rungate_epoch_test.go b/internal/app/rungate_epoch_integration_test.go similarity index 83% rename from internal/app/rungate_epoch_test.go rename to internal/app/rungate_epoch_integration_test.go index 62850520a..754c4c592 100644 --- a/internal/app/rungate_epoch_test.go +++ b/internal/app/rungate_epoch_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -16,6 +18,8 @@ import ( // gate key locates both. A fresh gate-before arm mints an active epoch; claim // confirmation binds its change; participant registration is gated on the active // state and fails closed on an unknown schema. +// The fixtures untagged test files share (mintTestGateKey, forceEpochState, +// must) live in rungate_epoch_helpers_test.go (change 0465). // isEpochKind reports whether err carries an *EpochError of the given kind. func isEpochKind(err error, kind EpochErrorKind) bool { @@ -23,30 +27,12 @@ func isEpochKind(err error, kind EpochErrorKind) bool { return ok && ee.Kind == kind } -// mintTestGateKey mints a minimal valid gate record and returns its key, so an -// epoch test has a real key directory (the epoch store requires one) without -// arming the whole gate. AttemptLimit is floored at 1 so the v4 write guard -// accepts it. -func mintTestGateKey(t *testing.T, repo string) string { - t.Helper() - key, err := MintGateRecord(repo, GateRecord{ - Target: gateBeforeStoredTarget, - AttemptLimit: 1, - Retry: RetryUnused, - Disposition: "gate-armed", - }) - if err != nil { - t.Fatalf("MintGateRecord: %v", err) - } - return key -} - -// TestEpochRecordCRUD proves mint → load → register round-trips: mint yields an +// TestIntegrationGateEpochEpochRecordCRUD proves mint → load → register round-trips: mint yields an // active record with a non-empty public EpochID keyed by the gate key, load // returns a generation, a second mint is refused bind-once, a participant is // appended with a stamped RegisteredAt under a rotated generation, and a stale // expected-epoch locator confers no registration authority. -func TestEpochRecordCRUD(t *testing.T) { +func TestIntegrationGateEpochEpochRecordCRUD(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) @@ -107,10 +93,10 @@ func TestEpochRecordCRUD(t *testing.T) { } } -// TestRegisterParticipantRejectsNonActive proves a fenced (non-active) epoch admits +// TestIntegrationGateEpochRegisterParticipantRejectsNonActive proves a fenced (non-active) epoch admits // no new participant: after the epoch flips active→cancelling, registration fails // closed ErrEpochNotActive. -func TestRegisterParticipantRejectsNonActive(t *testing.T) { +func TestIntegrationGateEpochRegisterParticipantRejectsNonActive(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) rec, err := MintEpochRecord(repo, key, "") @@ -139,9 +125,9 @@ func TestRegisterParticipantRejectsNonActive(t *testing.T) { } } -// TestLoadEpochNotFound proves a load before any mint fails closed ErrEpochNotFound +// TestIntegrationGateEpochLoadEpochNotFound proves a load before any mint fails closed ErrEpochNotFound // rather than fabricating a live epoch. -func TestLoadEpochNotFound(t *testing.T) { +func TestIntegrationGateEpochLoadEpochNotFound(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) if _, _, err := LoadEpochRecord(repo, key); !isEpochKind(err, ErrEpochNotFound) { @@ -149,10 +135,10 @@ func TestLoadEpochNotFound(t *testing.T) { } } -// TestEpochUnknownSchemaFailsClosed proves a record carrying an unknown schema +// TestIntegrationGateEpochEpochUnknownSchemaFailsClosed proves a record carrying an unknown schema // version fails closed ErrEpochCorrupt on load — a record the store cannot read is // never a live epoch (premium: fail-closed schema versioning). -func TestEpochUnknownSchemaFailsClosed(t *testing.T) { +func TestIntegrationGateEpochEpochUnknownSchemaFailsClosed(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) if _, err := MintEpochRecord(repo, key, "375"); err != nil { @@ -172,10 +158,10 @@ func TestEpochUnknownSchemaFailsClosed(t *testing.T) { } } -// TestGateBeforeMintsEpoch proves a fresh (non-resume) arm binds a new run epoch +// TestIntegrationGateEpochGateBeforeMintsEpoch proves a fresh (non-resume) arm binds a new run epoch // beside the gate record, keyed by the gate key: active, with a public EpochID and // no change bound yet. -func TestGateBeforeMintsEpoch(t *testing.T) { +func TestIntegrationGateEpochGateBeforeMintsEpoch(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -203,14 +189,14 @@ func TestGateBeforeMintsEpoch(t *testing.T) { } } -// TestConfirmGateClaimBindsEpochChange proves the claim confirmation binds the +// TestIntegrationGateEpochConfirmGateClaimBindsEpochChange proves the claim confirmation binds the // epoch to the confirmed change instance — the readable locator a later // resume/cancel resolves the run by. -// TestNoAdapterReportsLifecycleUnavailable proves an armed gate reports the honest +// TestIntegrationGateEpochNoAdapterReportsLifecycleUnavailable proves an armed gate reports the honest // owner-lifecycle limitation (change 0375 Task 13): the default dispatch route has // no automatic Stop/owner-death cancellation, so a Stop is the explicit run.cancel // operation. The field is a standing caveat, never a refusal — the gate still arms. -func TestNoAdapterReportsLifecycleUnavailable(t *testing.T) { +func TestIntegrationGateEpochNoAdapterReportsLifecycleUnavailable(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -227,7 +213,7 @@ func TestNoAdapterReportsLifecycleUnavailable(t *testing.T) { } } -func TestConfirmGateClaimBindsEpochChange(t *testing.T) { +func TestIntegrationGateEpochConfirmGateClaimBindsEpochChange(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -250,10 +236,10 @@ func TestConfirmGateClaimBindsEpochChange(t *testing.T) { } } -// TestConfirmGateClaimNoEpochIsNoop proves a claim over a dispatch with NO epoch +// TestIntegrationGateEpochConfirmGateClaimNoEpochIsNoop proves a claim over a dispatch with NO epoch // (a standalone gate record) is unaffected: the confirm succeeds and no epoch is // fabricated. This guards the existing claim path against the epoch bind. -func TestConfirmGateClaimNoEpochIsNoop(t *testing.T) { +func TestIntegrationGateEpochConfirmGateClaimNoEpochIsNoop(t *testing.T) { repo := newGateRepo(t) key := mintTestGateKey(t, repo) // a gate record with no epoch minted beside it if err := ReserveGateClaim(repo, key, 7, "req-x"); err != nil { @@ -280,29 +266,7 @@ func mintEpochFixture(t *testing.T) (repo, key string) { return repo, key } -// forceEpochState drives the epoch record to state s through the CAS, standing in -// for the durable transitions other tasks own so a lifecycle guard can be exercised -// against an arbitrary state. -func forceEpochState(t *testing.T, repo, key string, s epochState) { - t.Helper() - if err := epochCAS(repo, key, func(r *EpochRecord) error { - r.State = s - return nil - }); err != nil { - t.Fatalf("forceEpochState %q: %v", s, err) - } -} - -// must fails the test immediately when err is non-nil, so a fixture setup step -// whose failure is not the assertion under test reads as one line. -func must(t *testing.T, err error) { - t.Helper() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFenceEpochCompletingFromActive(t *testing.T) { +func TestIntegrationGateEpochFenceEpochCompletingFromActive(t *testing.T) { repo, key := mintEpochFixture(t) // reuse/extract the file's existing mint helper; changeID "441" st, err := FenceEpochCompleting(repo, key, "") if err != nil || st != EpochCompleting { @@ -318,7 +282,7 @@ func TestFenceEpochCompletingFromActive(t *testing.T) { } } -func TestFenceEpochCompletingNeverRelabelsTerminalStates(t *testing.T) { +func TestIntegrationGateEpochFenceEpochCompletingNeverRelabelsTerminalStates(t *testing.T) { for _, s := range []epochState{EpochCancelling, EpochCancelled, EpochSuperseded, epochState("garbage")} { repo, key := mintEpochFixture(t) forceEpochState(t, repo, key, s) // helper: epochCAS setting rec.State = s @@ -334,7 +298,7 @@ func TestFenceEpochCompletingNeverRelabelsTerminalStates(t *testing.T) { } } -func TestFenceEpochCompletingRejectsStaleLocator(t *testing.T) { +func TestIntegrationGateEpochFenceEpochCompletingRejectsStaleLocator(t *testing.T) { repo, key := mintEpochFixture(t) _, err := FenceEpochCompleting(repo, key, "not-the-epoch-id") if ee, ok := AsEpochError(err); !ok || ee.Kind != ErrEpochMismatch { @@ -342,7 +306,7 @@ func TestFenceEpochCompletingRejectsStaleLocator(t *testing.T) { } } -func TestCompleteEpochOnlyFromCompleting(t *testing.T) { +func TestIntegrationGateEpochCompleteEpochOnlyFromCompleting(t *testing.T) { repo, key := mintEpochFixture(t) if err := CompleteEpoch(repo, key); err == nil { t.Fatal("completed from active") // never a shortcut past the fence @@ -363,7 +327,7 @@ func TestCompleteEpochOnlyFromCompleting(t *testing.T) { } } -func TestRegisterEpochParticipantRejectedOnCompletingAndCompleted(t *testing.T) { +func TestIntegrationGateEpochRegisterEpochParticipantRejectedOnCompletingAndCompleted(t *testing.T) { for _, s := range []epochState{EpochCompleting, EpochCompleted} { repo, key := mintEpochFixture(t) forceEpochState(t, repo, key, s) @@ -374,7 +338,7 @@ func TestRegisterEpochParticipantRejectedOnCompletingAndCompleted(t *testing.T) } } -func TestSupersedeRefusesCompletingAndCompleted(t *testing.T) { +func TestIntegrationGateEpochSupersedeRefusesCompletingAndCompleted(t *testing.T) { for _, s := range []epochState{EpochCompleting, EpochCompleted} { repo, key := mintEpochFixture(t) forceEpochState(t, repo, key, s) @@ -385,7 +349,7 @@ func TestSupersedeRefusesCompletingAndCompleted(t *testing.T) { } } -func TestRecordEpochParticipantTerminal(t *testing.T) { +func TestIntegrationGateEpochRecordEpochParticipantTerminal(t *testing.T) { repo, key := mintEpochFixture(t) must(t, RegisterEpochParticipant(repo, key, "", EpochParticipant{Kind: "coordinator", NativeHandle: "thread-1"})) must(t, RecordEpochParticipantTerminal(repo, key, "", "thread-1", "turn-9", ParticipantTerminalCompleted)) @@ -404,7 +368,7 @@ func TestRecordEpochParticipantTerminal(t *testing.T) { } } -func TestRecordEpochParticipantTerminalUnknownHandleAndBadInput(t *testing.T) { +func TestIntegrationGateEpochRecordEpochParticipantTerminalUnknownHandleAndBadInput(t *testing.T) { repo, key := mintEpochFixture(t) err := RecordEpochParticipantTerminal(repo, key, "", "ghost", "t", ParticipantTerminalCompleted) if ee, ok := AsEpochError(err); !ok || ee.Kind != ErrEpochParticipantUnknown { @@ -417,7 +381,7 @@ func TestRecordEpochParticipantTerminalUnknownHandleAndBadInput(t *testing.T) { } } -func TestRecordEpochParticipantTerminalAllowedAfterFence(t *testing.T) { +func TestIntegrationGateEpochRecordEpochParticipantTerminalAllowedAfterFence(t *testing.T) { // "Completion of an existing participant is allowed after the completing // fence; registering or reopening work is not." for _, s := range []epochState{EpochCompleting, EpochCancelling} { @@ -428,12 +392,12 @@ func TestRecordEpochParticipantTerminalAllowedAfterFence(t *testing.T) { } } -// TestEpochSettledResolverStates (change 0446): the admission settlement read +// TestIntegrationGateEpochEpochSettledResolverStates (change 0446): the admission settlement read // reports settled only for an epoch whose record is terminal with its accounting // done — completed, cancelled, superseded. Active, cancelling, and completing // epochs still own their worktree, and an unknown epoch id is an unresolved owner, // never settlement. -func TestEpochSettledResolverStates(t *testing.T) { +func TestIntegrationGateEpochEpochSettledResolverStates(t *testing.T) { cases := []struct { state epochState settled bool diff --git a/internal/app/rungate_fence_helpers_test.go b/internal/app/rungate_fence_helpers_test.go new file mode 100644 index 000000000..b25497399 --- /dev/null +++ b/internal/app/rungate_fence_helpers_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +// Run-gate fence test helpers shared with default-build (untagged) test files. +// The fence tests themselves live behind the integration tag in +// rungate_fence_integration_test.go (change 0465); these epoch seeders stay +// untagged because other untagged test files still reference them. + +// seedPendingEpochMutation journals one admitted-not-completed workflow mutation on +// the epoch at key: a genuinely owned in-flight effect, which keeps the successful-run +// closeout (change 0441) fail-closed with mutation-pending. The two verdict recovery +// tests in rungate_fence_integration_test.go use it to hold their epoch at +// completing so a later explicit cancellation is meaningful. They formerly relied on the ABSENT feature directory +// making the slot unreadable; change 0446 (spec §2) addresses a slot through its +// stored identity, so a never-reserved slot now reads as truly absent (safely +// detached) and the closeout would legitimately complete — an absent directory is not +// an obligation, an owned pending mutation is. +func seedPendingEpochMutation(t *testing.T, repo, key string) { + t.Helper() + if err := epochCAS(repo, key, func(r *EpochRecord) error { + r.AdmittedMutations = append(r.AdmittedMutations, AdmittedMutation{ + OpKey: OperationPRPublish, + Status: mutationStatusAdmitted, + }) + return nil + }); err != nil { + t.Fatalf("journal a pending mutation: %v", err) + } +} + +// reconcilePendingEpochMutations marks every journaled mutation on the epoch at key +// completed — the in-flight effect resolved — so an explicit cancellation can account +// it and reach cancelled. +func reconcilePendingEpochMutations(t *testing.T, repo, key string) { + t.Helper() + if err := epochCAS(repo, key, func(r *EpochRecord) error { + for i := range r.AdmittedMutations { + r.AdmittedMutations[i].Status = mutationStatusCompleted + } + return nil + }); err != nil { + t.Fatalf("reconcile pending mutations: %v", err) + } +} + +// seedNamedEpoch writes an epoch record for state bound to worktree under a gate-key +// directory whose NAME the test chooses, so the directory order os.ReadDir yields is +// controlled (a first-match selector would pick the lexically first key). It writes +// the record through the store's own atomic writer and needs no gate record. +func seedNamedEpoch(t *testing.T, repo, key, worktree string, state epochState) EpochRecord { + t.Helper() + common, err := gateGitCommonDir(repo) + if err != nil { + t.Fatalf("gateGitCommonDir: %v", err) + } + dir := filepath.Join(common, "docket", "rungate", key) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir gate-key dir: %v", err) + } + id, err := epochToken() + if err != nil { + t.Fatalf("epochToken: %v", err) + } + gen, err := epochToken() + if err != nil { + t.Fatalf("epochToken: %v", err) + } + rec := EpochRecord{ + SchemaVersion: epochSchemaVersion, + GateKey: key, + ChangeID: "7", + State: state, + EpochID: id, + Worktree: worktree, + } + if err := writeEpochAtomic(dir, storedEpoch{Generation: gen, Record: rec}); err != nil { + t.Fatalf("writeEpochAtomic: %v", err) + } + return rec +} diff --git a/internal/app/rungate_fence_test.go b/internal/app/rungate_fence_integration_test.go similarity index 90% rename from internal/app/rungate_fence_test.go rename to internal/app/rungate_fence_integration_test.go index 620f38de8..f4c225111 100644 --- a/internal/app/rungate_fence_test.go +++ b/internal/app/rungate_fence_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -81,11 +83,11 @@ func (fenceStubLoader) ValidateEvolution(_, _ transaction.LoadedState) []domain. return nil } -// TestFenceBlocksEngineMutationAfterCancel: a change.mark-implemented-shaped engine +// TestIntegrationGateFenceFenceBlocksEngineMutationAfterCancel: a change.mark-implemented-shaped engine // mutation, driven through an engine wired with the production AdmissionHook against a // cancelled epoch that owns the worktree, is refused at StageAdmission — before any // fetch, allocation, plan, or push — so nothing is mutated. -func TestFenceBlocksEngineMutationAfterCancel(t *testing.T) { +func TestIntegrationGateFenceFenceBlocksEngineMutationAfterCancel(t *testing.T) { repoDir := newGateRepo(t) mintFenceEpoch(t, repoDir, repoDir, EpochCancelling) @@ -121,10 +123,10 @@ func TestFenceBlocksEngineMutationAfterCancel(t *testing.T) { } } -// TestFenceBlocksPRPublishAfterCancel: with every PRPublish pre-check satisfied, a +// TestIntegrationGateFenceFenceBlocksPRPublishAfterCancel: with every PRPublish pre-check satisfied, a // cancelled epoch owning the worktree blocks publication with the run-cancelled // reason and gh (EnsurePullRequest) is never invoked. -func TestFenceBlocksPRPublishAfterCancel(t *testing.T) { +func TestIntegrationGateFenceFenceBlocksPRPublishAfterCancel(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation mintFenceEpoch(t, repoDir, repoDir, EpochCancelling) @@ -146,10 +148,10 @@ func TestFenceBlocksPRPublishAfterCancel(t *testing.T) { } } -// TestFenceBlocksWorkspacePublishAfterCancel: with the workspace head matching, a +// TestIntegrationGateFenceFenceBlocksWorkspacePublishAfterCancel: with the workspace head matching, a // cancelled epoch blocks the publish with the run-cancelled reason and PublishHead is // never invoked. -func TestFenceBlocksWorkspacePublishAfterCancel(t *testing.T) { +func TestIntegrationGateFenceFenceBlocksWorkspacePublishAfterCancel(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation mintFenceEpoch(t, repoDir, repoDir, EpochCancelling) @@ -174,12 +176,12 @@ func TestFenceBlocksWorkspacePublishAfterCancel(t *testing.T) { } } -// TestInFlightMutationReconcilesBeforeCancelled: an admitted-not-completed mutation +// TestIntegrationGateFenceInFlightMutationReconcilesBeforeCancelled: an admitted-not-completed mutation // keeps a cancellation PENDING; once it is journaled completed, a repeated cancel // reconciles it and reports cancelled. This is the "already-admitted external actions // are reconciled, and cancelled is not reported while an unresolved effect remains" // property. -func TestInFlightMutationReconcilesBeforeCancelled(t *testing.T) { +func TestIntegrationGateFenceInFlightMutationReconcilesBeforeCancelled(t *testing.T) { fx := newCancelFixture(t, false) // active epoch + authority, no slot to reconcile // A workflow mutation is admitted (in flight) but not yet completed. @@ -214,10 +216,10 @@ func TestInFlightMutationReconcilesBeforeCancelled(t *testing.T) { } } -// TestStandaloneMutationUnfenced: a worktree no epoch owns admits every mutation +// TestIntegrationGateFenceStandaloneMutationUnfenced: a worktree no epoch owns admits every mutation // unfenced (the completion callback is a no-op), and an epoch owning a DIFFERENT // worktree never fences this one. -func TestStandaloneMutationUnfenced(t *testing.T) { +func TestIntegrationGateFenceStandaloneMutationUnfenced(t *testing.T) { repoDir := newGateRepo(t) // (a) No epoch at all. @@ -242,9 +244,9 @@ func TestStandaloneMutationUnfenced(t *testing.T) { done2(mutationStatusCompleted, false) } -// TestFenceRefusesSupersededEpochAsStale: a superseded epoch (a resume replaced it) +// TestIntegrationGateFenceFenceRefusesSupersededEpochAsStale: a superseded epoch (a resume replaced it) // refuses the mutation with the stale-run-epoch reason, distinct from run-cancelled. -func TestFenceRefusesSupersededEpochAsStale(t *testing.T) { +func TestIntegrationGateFenceFenceRefusesSupersededEpochAsStale(t *testing.T) { repoDir := newGateRepo(t) mintFenceEpoch(t, repoDir, repoDir, EpochSuperseded) @@ -258,11 +260,11 @@ func TestFenceRefusesSupersededEpochAsStale(t *testing.T) { } } -// TestFenceMatchesWorktreeAcrossSymlinkAlias: the fence canonicalizes both the +// TestIntegrationGateFenceFenceMatchesWorktreeAcrossSymlinkAlias: the fence canonicalizes both the // caller's worktree and the epoch's stored Worktree, so a `/tmp`→`/private/tmp`-style // alias cannot dodge it. Here the epoch stores a symlink spelling of the worktree and // the mutation runs with the canonical spelling; the fence still matches. -func TestFenceMatchesWorktreeAcrossSymlinkAlias(t *testing.T) { +func TestIntegrationGateFenceFenceMatchesWorktreeAcrossSymlinkAlias(t *testing.T) { repoDir := newGateRepo(t) canonRepo, err := canonicalWorktree(repoDir) if err != nil { @@ -284,11 +286,11 @@ func TestFenceMatchesWorktreeAcrossSymlinkAlias(t *testing.T) { } } -// TestAdmitWorkflowMutationRefusesCompletingEpoch: a completing epoch (a verified +// TestIntegrationGateFenceAdmitWorkflowMutationRefusesCompletingEpoch: a completing epoch (a verified // successful closeout is mid-flight, change 0441) still owns its worktree and refuses // a new mutation with the distinct run-completed reason — never relabelled as a // cancellation. -func TestAdmitWorkflowMutationRefusesCompletingEpoch(t *testing.T) { +func TestIntegrationGateFenceAdmitWorkflowMutationRefusesCompletingEpoch(t *testing.T) { repoDir := newGateRepo(t) mintFenceEpoch(t, repoDir, repoDir, EpochCompleting) @@ -299,11 +301,11 @@ func TestAdmitWorkflowMutationRefusesCompletingEpoch(t *testing.T) { } } -// TestCompletedEpochExcludedFromAmbientOwnerLookup: a fully completed epoch (change +// TestIntegrationGateFenceCompletedEpochExcludedFromAmbientOwnerLookup: a fully completed epoch (change // 0441) no longer owns the worktree for ambient lookup, so a standalone mutation on // that worktree is admitted UNFENCED and findEpochByWorktree no longer names it. A // COMPLETING epoch, in contrast, is still the owner (its closeout has not finished). -func TestCompletedEpochExcludedFromAmbientOwnerLookup(t *testing.T) { +func TestIntegrationGateFenceCompletedEpochExcludedFromAmbientOwnerLookup(t *testing.T) { repoDir := newGateRepo(t) key := mintFenceEpoch(t, repoDir, repoDir, EpochCompleted) @@ -328,7 +330,7 @@ func TestCompletedEpochExcludedFromAmbientOwnerLookup(t *testing.T) { } } -// TestFreshRunClaimBindsEpochWorktreeSoFenceActs is the BLOCKER regression (change +// TestIntegrationGateFenceFreshRunClaimBindsEpochWorktreeSoFenceActs is the BLOCKER regression (change // 0375): a FRESH (non-resume) run's claim confirmation must bind the epoch's Worktree // so the mutation fence locates the epoch. It drives the REAL arm→reserve→confirm // production path (RunGateBefore mints the fresh epoch with Worktree == ""; the claim @@ -338,7 +340,7 @@ func TestCompletedEpochExcludedFromAmbientOwnerLookup(t *testing.T) { // the fresh epoch kept Worktree == "", findEpochByWorktree skipped it, and the mutation // was admitted UNFENCED even after the epoch was cancelling — the fence and run.cancel // teardown were both inert for the common first-dispatch case. -func TestFreshRunClaimBindsEpochWorktreeSoFenceActs(t *testing.T) { +func TestIntegrationGateFenceFreshRunClaimBindsEpochWorktreeSoFenceActs(t *testing.T) { repo := newGateRepo(t) deps := PlanningDeps{Reader: gateBeforeReader(t, gateBeforeCorpus(), nil, nil), Clock: testClock()} sp := &fakeScopePrep{grant: sampleScopeGrant()} @@ -383,44 +385,7 @@ func TestFreshRunClaimBindsEpochWorktreeSoFenceActs(t *testing.T) { } } -// seedPendingEpochMutation journals one admitted-not-completed workflow mutation on -// the epoch at key: a genuinely owned in-flight effect, which keeps the successful-run -// closeout (change 0441) fail-closed with mutation-pending. The two verdict recovery -// tests below use it to hold their epoch at completing so a later explicit -// cancellation is meaningful. They formerly relied on the ABSENT feature directory -// making the slot unreadable; change 0446 (spec §2) addresses a slot through its -// stored identity, so a never-reserved slot now reads as truly absent (safely -// detached) and the closeout would legitimately complete — an absent directory is not -// an obligation, an owned pending mutation is. -func seedPendingEpochMutation(t *testing.T, repo, key string) { - t.Helper() - if err := epochCAS(repo, key, func(r *EpochRecord) error { - r.AdmittedMutations = append(r.AdmittedMutations, AdmittedMutation{ - OpKey: OperationPRPublish, - Status: mutationStatusAdmitted, - }) - return nil - }); err != nil { - t.Fatalf("journal a pending mutation: %v", err) - } -} - -// reconcilePendingEpochMutations marks every journaled mutation on the epoch at key -// completed — the in-flight effect resolved — so an explicit cancellation can account -// it and reach cancelled. -func reconcilePendingEpochMutations(t *testing.T, repo, key string) { - t.Helper() - if err := epochCAS(repo, key, func(r *EpochRecord) error { - for i := range r.AdmittedMutations { - r.AdmittedMutations[i].Status = mutationStatusCompleted - } - return nil - }); err != nil { - t.Fatalf("reconcile pending mutations: %v", err) - } -} - -// TestVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs is the change-0427 +// TestIntegrationGateFenceVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs is the change-0427 // regression for the unconfirmed-reservation recovery leg: a fresh epoch whose // Worktree is empty (as gate-before mints it — neither claim confirmation nor // fixture setup pre-binds it), a reservation whose confirm was interrupted, and @@ -430,7 +395,7 @@ func reconcilePendingEpochMutations(t *testing.T, repo, key string) { // that worktree is refused specifically run-cancelled. Restoring the empty // worktree argument at the unconfirmed-reservation ConfirmGateClaim call reddens // both halves. -func TestVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs(t *testing.T) { +func TestIntegrationGateFenceVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -523,7 +488,7 @@ func TestVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs(t *testing.T) { } } -// TestVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs is the change-0427 +// TestIntegrationGateFenceVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs is the change-0427 // regression for the absent-binding recovery leg: a fresh epoch with an empty // Worktree and NO binding file, with exactly one committed proof carrying the // record's context hash. Adoption must reserve + confirm WITH the change's @@ -531,7 +496,7 @@ func TestVerdictUnconfirmedRecoveryBindsEpochWorktreeSoFenceActs(t *testing.T) { // RunCancel a workflow mutation from that worktree is refused run-cancelled. // Restoring the empty worktree argument at the sole-proof ConfirmGateClaim call // reddens both halves. -func TestVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs(t *testing.T) { +func TestIntegrationGateFenceVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -613,13 +578,13 @@ func TestVerdictSoleProofAdoptionBindsEpochWorktreeSoFenceActs(t *testing.T) { } } -// TestVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm: when either recovery +// TestIntegrationGateFenceVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm: when either recovery // leg cannot resolve repository/change identity (here: empty PlanningDeps — no // reader, no client), the verdict refuses gate-stop gate-unavailable // proof-unavailable BEFORE any confirm — it never substitutes an empty worktree. // The unconfirmed reservation stays intact-unconfirmed; the sole-proof leg // writes NO reservation at all (resolution precedes ReserveGateClaim). -func TestVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm(t *testing.T) { +func TestIntegrationGateFenceVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm(t *testing.T) { t.Run("unconfirmed reservation leg", func(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") @@ -676,42 +641,6 @@ func TestVerdictRecoveryUnresolvedIdentityStopsBeforeConfirm(t *testing.T) { // --- change 0446 Task 7: deterministic worktree owner selection and the // slot-named-epoch rule (spec §§1, 5; AC3, AC6). --- -// seedNamedEpoch writes an epoch record for state bound to worktree under a gate-key -// directory whose NAME the test chooses, so the directory order os.ReadDir yields is -// controlled (a first-match selector would pick the lexically first key). It writes -// the record through the store's own atomic writer and needs no gate record. -func seedNamedEpoch(t *testing.T, repo, key, worktree string, state epochState) EpochRecord { - t.Helper() - common, err := gateGitCommonDir(repo) - if err != nil { - t.Fatalf("gateGitCommonDir: %v", err) - } - dir := filepath.Join(common, "docket", "rungate", key) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir gate-key dir: %v", err) - } - id, err := epochToken() - if err != nil { - t.Fatalf("epochToken: %v", err) - } - gen, err := epochToken() - if err != nil { - t.Fatalf("epochToken: %v", err) - } - rec := EpochRecord{ - SchemaVersion: epochSchemaVersion, - GateKey: key, - ChangeID: "7", - State: state, - EpochID: id, - Worktree: worktree, - } - if err := writeEpochAtomic(dir, storedEpoch{Generation: gen, Record: rec}); err != nil { - t.Fatalf("writeEpochAtomic: %v", err) - } - return rec -} - // epochRecordPath is the epoch.json path for key under repo's rungate root. func epochRecordPath(t *testing.T, repo, key string) string { t.Helper() @@ -731,13 +660,13 @@ func mustCanon(t *testing.T, path string) string { return c } -// TestOwnerSelectionActiveBeatsCancelledRegardlessOfOrder: a cancelled (and a +// TestIntegrationGateFenceOwnerSelectionActiveBeatsCancelledRegardlessOfOrder: a cancelled (and a // cancelling) never-superseded epoch bound to the same path as a fresh ACTIVE run is // not the ambient owner, whichever sorts first. The fence admits the active run's // mutation and journals it on the ACTIVE epoch. Before the fix, first-match selection // returned the cancelled record in the "fenced-first" ordering and refused the live // run with run-cancelled. -func TestOwnerSelectionActiveBeatsCancelledRegardlessOfOrder(t *testing.T) { +func TestIntegrationGateFenceOwnerSelectionActiveBeatsCancelledRegardlessOfOrder(t *testing.T) { for _, tc := range []struct { name string cancelled, cancelling string @@ -772,12 +701,12 @@ func TestOwnerSelectionActiveBeatsCancelledRegardlessOfOrder(t *testing.T) { } } -// TestOwnerSelectionSoleCancelledStillFences: with no active owner, a cancelled or +// TestIntegrationGateFenceOwnerSelectionSoleCancelledStillFences: with no active owner, a cancelled or // cancelling non-superseded epoch bound to the path is still returned, so the fence // keeps refusing run-cancelled (dropping every terminal epoch from the lookup is not // a substitute). Several fenced records resolve deterministically to the lexically // first key. -func TestOwnerSelectionSoleCancelledStillFences(t *testing.T) { +func TestIntegrationGateFenceOwnerSelectionSoleCancelledStillFences(t *testing.T) { for _, state := range []epochState{EpochCancelled, EpochCancelling} { t.Run(string(state), func(t *testing.T) { repo := newGateRepo(t) @@ -804,11 +733,11 @@ func TestOwnerSelectionSoleCancelledStillFences(t *testing.T) { }) } -// TestOwnerSelectionTwoActiveOwnersAmbiguous: two active (or active + completing) +// TestIntegrationGateFenceOwnerSelectionTwoActiveOwnersAmbiguous: two active (or active + completing) // epochs bound to one canonical path are a contradiction — a typed // ErrEpochOwnerAmbiguous naming the worktree, and the mutation is refused, never // silently admitted against one of them. -func TestOwnerSelectionTwoActiveOwnersAmbiguous(t *testing.T) { +func TestIntegrationGateFenceOwnerSelectionTwoActiveOwnersAmbiguous(t *testing.T) { for _, second := range []epochState{EpochActive, EpochCompleting} { t.Run(string(second), func(t *testing.T) { repo := newGateRepo(t) @@ -840,10 +769,10 @@ func TestOwnerSelectionTwoActiveOwnersAmbiguous(t *testing.T) { } } -// TestOwnerSelectionCompletedNeverOwns: a completed epoch is never the ambient +// TestIntegrationGateFenceOwnerSelectionCompletedNeverOwns: a completed epoch is never the ambient // owner — alone it leaves the path unfenced, and beside a cancelled epoch the // cancelled one (not the completed one) is returned. -func TestOwnerSelectionCompletedNeverOwns(t *testing.T) { +func TestIntegrationGateFenceOwnerSelectionCompletedNeverOwns(t *testing.T) { repo := newGateRepo(t) canon := mustCanon(t, repo) seedNamedEpoch(t, repo, "aaaa-completed", repo, EpochCompleted) @@ -859,12 +788,12 @@ func TestOwnerSelectionCompletedNeverOwns(t *testing.T) { } } -// TestSlotNamedEpochUnreadableRefusesLocally (AC3): the worktree's execution slot +// TestIntegrationGateFenceSlotNamedEpochUnreadableRefusesLocally (AC3): the worktree's execution slot // names run epoch E. When no readable epoch record carries E — the record is corrupt, // I/O-unreadable, or gone — the path fence refuses locally with E and the worktree in // the error instead of admitting unfenced. The same damage to an epoch record NO slot // names stays diagnostic, and a companion unrelated worktree keeps admitting. -func TestSlotNamedEpochUnreadableRefusesLocally(t *testing.T) { +func TestIntegrationGateFenceSlotNamedEpochUnreadableRefusesLocally(t *testing.T) { damage := map[string]func(t *testing.T, path string){ "corrupt": func(t *testing.T, path string) { if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { @@ -943,12 +872,12 @@ func TestSlotNamedEpochUnreadableRefusesLocally(t *testing.T) { } } -// TestUnreadableSlotRefusesLocally (review fix): with no readable ambient owner, a +// TestIntegrationGateFenceUnreadableSlotRefusesLocally (review fix): with no readable ambient owner, a // worktree whose execution slot the store cannot READ (corrupt or I/O-unreadable) // is not evidence that the slot names no epoch — the path fence refuses with // ErrEpochOwnerUnresolved naming the worktree instead of admitting unfenced. An // ABSENT slot still admits unfenced (the standalone contract). -func TestUnreadableSlotRefusesLocally(t *testing.T) { +func TestIntegrationGateFenceUnreadableSlotRefusesLocally(t *testing.T) { damage := map[string]func(t *testing.T, path string){ "corrupt": func(t *testing.T, path string) { if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { @@ -999,12 +928,12 @@ func TestUnreadableSlotRefusesLocally(t *testing.T) { }) } -// TestEpochCarryingFencesUnchangedByOwnerSelection (AC6, separate proof): owner +// TestIntegrationGateFenceEpochCarryingFencesUnchangedByOwnerSelection (AC6, separate proof): owner // selection answers only "who owns this path now". After a NEW active owner binds the // path, the stale epoch's own epoch-carrying fences still refuse it — the launch gate // (by id) and the takeover revocation resolver — for a cancelled, superseded, and // completed stale epoch alike, while ambient lookup names the new owner. -func TestEpochCarryingFencesUnchangedByOwnerSelection(t *testing.T) { +func TestIntegrationGateFenceEpochCarryingFencesUnchangedByOwnerSelection(t *testing.T) { for _, tc := range []struct { name string stale func(t *testing.T, repo, key string) @@ -1046,11 +975,11 @@ func TestEpochCarryingFencesUnchangedByOwnerSelection(t *testing.T) { } } -// TestPRPublishJournalsPublicationIdentity: a fenced (active-epoch) PR publish +// TestIntegrationGateFencePRPublishJournalsPublicationIdentity: a fenced (active-epoch) PR publish // journals a VALID descriptor carrying the resolved repo identity, exact head // branch + full commit, base branch, and title/body digests — and the journal // bytes never contain the raw title or body (digests only). (change 0444) -func TestPRPublishJournalsPublicationIdentity(t *testing.T) { +func TestIntegrationGateFencePRPublishJournalsPublicationIdentity(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation key := mintFenceEpoch(t, repoDir, repoDir, EpochActive) @@ -1122,10 +1051,10 @@ func TestPRPublishJournalsPublicationIdentity(t *testing.T) { } } -// TestWorkspacePublishJournalsPublicationIdentity: an active-epoch workspace +// TestIntegrationGateFenceWorkspacePublishJournalsPublicationIdentity: an active-epoch workspace // publish journals a VALID workspace descriptor: canonical repo identity, remote // name, exact feature ref, and the full intended commit. (change 0444) -func TestWorkspacePublishJournalsPublicationIdentity(t *testing.T) { +func TestIntegrationGateFenceWorkspacePublishJournalsPublicationIdentity(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation key := mintFenceEpoch(t, repoDir, repoDir, EpochActive) @@ -1180,14 +1109,14 @@ func TestWorkspacePublishJournalsPublicationIdentity(t *testing.T) { } } -// TestWorkspacePublishMovedHeadUnderLockIsHeadMismatch (change 0451 review +// TestIntegrationGateFenceWorkspacePublishMovedHeadUnderLockIsHeadMismatch (change 0451 review // finding): a head that moves AFTER WorkspacePublish's own Inspect is refused by // PublishHead under its lock (an invalid-state Failure carrying the moved local // head). That refusal must surface the same head-mismatch shape as the pre-lock // check — never the generic invalid-state reason a dirty or non-ready workspace // carries — and its journal entry still resolves completed and unverified. An // invalid-state refusal that names no moved head keeps the generic mapping. -func TestWorkspacePublishMovedHeadUnderLockIsHeadMismatch(t *testing.T) { +func TestIntegrationGateFenceWorkspacePublishMovedHeadUnderLockIsHeadMismatch(t *testing.T) { const head = "abcdef0000000000000000000000000000000000" const movedHead = "fedcba0000000000000000000000000000000000" ready := workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(head)} @@ -1233,14 +1162,14 @@ func TestWorkspacePublishMovedHeadUnderLockIsHeadMismatch(t *testing.T) { } } -// TestProductionUncertainThenIdenticalRetryThenCancel (change 0444 acceptance 7 +// TestIntegrationGateFenceProductionUncertainThenIdenticalRetryThenCancel (change 0444 acceptance 7 // and 8): descriptors journaled by the REAL PRPublish boundary — an uncertain first // attempt (an external/transport adapter failure), then an identical successful // retry, both in one run epoch — are matched by the REAL cancel path, which reaches // cancelled while issuing NO GitHub call (the capture adapters' counters do not // move during cancellation) and leaking no title/body bytes into the durable // journal or the cancel findings. -func TestProductionUncertainThenIdenticalRetryThenCancel(t *testing.T) { +func TestIntegrationGateFenceProductionUncertainThenIdenticalRetryThenCancel(t *testing.T) { fx := newCancelFixture(t, true) // The fixture epoch owns fx.worktree (a real directory inside the fixture's git // repository), so PRPublish invoked at that worktree resolves the admission @@ -1367,12 +1296,12 @@ func assertUnverifiedRetryLeavesOriginalPending(t *testing.T, fx cancelFixture, } } -// TestProductionUnverifiedPRRetryNeverSettles (change 0444 review blocker): an +// TestIntegrationGateFenceProductionUnverifiedPRRetryNeverSettles (change 0444 review blocker): an // identical pr.publish retry admitted after an uncertain first attempt, which then // resolves contended, refused (invalid-state / invalid-input), or with an internal // error, verified no postcondition — so it never settles the original. The applied -// positive control lives in TestProductionUncertainThenIdenticalRetryThenCancel. -func TestProductionUnverifiedPRRetryNeverSettles(t *testing.T) { +// positive control lives in TestIntegrationGateFenceProductionUncertainThenIdenticalRetryThenCancel. +func TestIntegrationGateFenceProductionUnverifiedPRRetryNeverSettles(t *testing.T) { cases := []struct { name string retry *fakeGitHub @@ -1407,13 +1336,13 @@ func TestProductionUnverifiedPRRetryNeverSettles(t *testing.T) { } } -// TestProductionUnverifiedWorkspaceRetryNeverSettles (change 0444 review +// TestIntegrationGateFenceProductionUnverifiedWorkspaceRetryNeverSettles (change 0444 review // blocker): the workspace.publish analog. An identical retry that PublishHead // resolves contended, refuses locally (invalid-state "workspace is not in a ready // phase" from its reinspection), fails with an internal error, or reports a head // other than the journaled one never settles the uncertain original; an applied retry (the positive control) does, proving the // fixture journals through the real fence. -func TestProductionUnverifiedWorkspaceRetryNeverSettles(t *testing.T) { +func TestIntegrationGateFenceProductionUnverifiedWorkspaceRetryNeverSettles(t *testing.T) { const head = "abcdef0000000000000000000000000000000000" const movedHead = "fedcba0000000000000000000000000000000000" ready := workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(head)} diff --git a/internal/app/rungate_gate_test.go b/internal/app/rungate_gate_integration_test.go similarity index 90% rename from internal/app/rungate_gate_test.go rename to internal/app/rungate_gate_integration_test.go index 79cfbbac9..e56392449 100644 --- a/internal/app/rungate_gate_test.go +++ b/internal/app/rungate_gate_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -71,9 +73,9 @@ func epochLockHeld(t *testing.T, rungateRoot, key string) bool { return false } -// TestEpochLaunchGateAdmitsActiveBoundEpoch proves the gate runs reserve exactly +// TestIntegrationGateEpochEpochLaunchGateAdmitsActiveBoundEpoch proves the gate runs reserve exactly // once, with a nil error, for an active epoch bound to the worktree the start names. -func TestEpochLaunchGateAdmitsActiveBoundEpoch(t *testing.T) { +func TestIntegrationGateEpochEpochLaunchGateAdmitsActiveBoundEpoch(t *testing.T) { _, common, _, epochID, worktree := epochGateFixture(t) gate := epochLaunchGate(common) @@ -90,10 +92,10 @@ func TestEpochLaunchGateAdmitsActiveBoundEpoch(t *testing.T) { } } -// TestEpochLaunchGateRefusalMatrix proves every fail-closed refusal: reserve is +// TestIntegrationGateEpochEpochLaunchGateRefusalMatrix proves every fail-closed refusal: reserve is // NEVER called and the error carries the mapped fence token (or the typed // EpochError for a location fault). -func TestEpochLaunchGateRefusalMatrix(t *testing.T) { +func TestIntegrationGateEpochEpochLaunchGateRefusalMatrix(t *testing.T) { type wantKind int const ( wantCancelled wantKind = iota @@ -230,11 +232,11 @@ func TestEpochLaunchGateRefusalMatrix(t *testing.T) { } } -// TestEpochLaunchGateRefusesCompletingAndCompleted: the launch gate refuses a start +// TestIntegrationGateEpochEpochLaunchGateRefusesCompletingAndCompleted: the launch gate refuses a start // (or a delayed-ticket relaunch) on a completing or completed epoch (change 0441) with // the distinct ErrRunCompleted token — its refusal is what later settles a pre-fence // never-launched ticket terminal — and never runs reserve. -func TestEpochLaunchGateRefusesCompletingAndCompleted(t *testing.T) { +func TestIntegrationGateEpochEpochLaunchGateRefusesCompletingAndCompleted(t *testing.T) { for _, s := range []epochState{EpochCompleting, EpochCompleted} { t.Run(string(s), func(t *testing.T) { repo, common, key, epochID, worktree := epochGateFixture(t) @@ -256,11 +258,11 @@ func TestEpochLaunchGateRefusesCompletingAndCompleted(t *testing.T) { } } -// TestEpochRevokedResolverRevokesCompletingAndCompleted: the takeover revocation +// TestIntegrationGateEpochEpochRevokedResolverRevokesCompletingAndCompleted: the takeover revocation // resolver reports revoked for a completing or completed epoch (change 0441), mirroring // the cancelled/superseded cases — a takeover of a completing/completed run refuses, // and explicit references to a completed epoch remain revoked. -func TestEpochRevokedResolverRevokesCompletingAndCompleted(t *testing.T) { +func TestIntegrationGateEpochEpochRevokedResolverRevokesCompletingAndCompleted(t *testing.T) { for _, s := range []epochState{EpochCompleting, EpochCompleted} { t.Run(string(s), func(t *testing.T) { repo, common, key, epochID, _ := epochGateFixture(t) @@ -288,10 +290,10 @@ func fenceEpoch(t *testing.T, repo, key string, state epochState) { } } -// TestEpochLaunchGatePerformsNoWrite proves the gate never mutates the epoch record: +// TestIntegrationGateEpochEpochLaunchGatePerformsNoWrite proves the gate never mutates the epoch record: // its bytes and physical generation are byte-identical before and after both an // admitted call and a refused call (spec AC6). -func TestEpochLaunchGatePerformsNoWrite(t *testing.T) { +func TestIntegrationGateEpochEpochLaunchGatePerformsNoWrite(t *testing.T) { repo, common, key, epochID, worktree := epochGateFixture(t) gate := epochLaunchGate(common) path := filepath.Join(rungateRootOf(common), key, epochRecordFileName) @@ -330,11 +332,12 @@ func TestEpochLaunchGatePerformsNoWrite(t *testing.T) { } } -// TestEpochLaunchGateSerializesWithFence proves the gate holds the epoch lock across +// TestRaceIntegrationAppConcurrencyEpochLaunchGateSerializesWithFence proves the gate holds the epoch lock across // reserve so a concurrent active→cancelling fence serializes against it, and that a // fence that lands FIRST makes the gate refuse. Ordering is proven by channels and a // direct non-blocking lock probe — never a timing sleep. -func TestEpochLaunchGateSerializesWithFence(t *testing.T) { +// Race shard (change 0465): a launch-gate reserve and an epoch fence CAS run in two goroutines against one epoch lock. +func TestRaceIntegrationAppConcurrencyEpochLaunchGateSerializesWithFence(t *testing.T) { repo, common, key, epochID, worktree := epochGateFixture(t) rungateRoot := rungateRootOf(common) gate := epochLaunchGate(common) @@ -394,10 +397,10 @@ func TestEpochLaunchGateSerializesWithFence(t *testing.T) { } } -// TestFindEpochDirByID proves the unique-match locator: a unique match returns the +// TestIntegrationGateEpochFindEpochDirByID proves the unique-match locator: a unique match returns the // directory and record, zero matches is ErrEpochNotFound, and two matching dirs are // ErrEpochAmbiguous. -func TestFindEpochDirByID(t *testing.T) { +func TestIntegrationGateEpochFindEpochDirByID(t *testing.T) { repo, common, key, epochID, worktree := epochGateFixture(t) rungateRoot := rungateRootOf(common) diff --git a/internal/app/rungate_ownership_test.go b/internal/app/rungate_ownership_integration_test.go similarity index 94% rename from internal/app/rungate_ownership_test.go rename to internal/app/rungate_ownership_integration_test.go index 34f89b6c5..b1cc74e53 100644 --- a/internal/app/rungate_ownership_test.go +++ b/internal/app/rungate_ownership_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -15,7 +17,7 @@ import ( // This is the deterministic two-gate ownership interleaving matrix (change 0407, // spec acceptance items 1, 2, 6, 7). It exercises the acceptance criteria the unit -// tests in rungate_verdict_test.go prove one branch at a time as WHOLE +// tests in rungate_verdict_integration_test.go prove one branch at a time as WHOLE // interleavings: two gates A and B, each armed before either claim, each bound to // its own change through the store binding + committed-proof seams Task 3 produces, // verify only their own change and never each other's — under every ordering of @@ -139,11 +141,11 @@ func mxAssertDisposition(t *testing.T, res RunGateVerdictResult, disp string) { } } -// TestTwoGatesEachVerifyOnlyTheirOwn — spec acceptance item 1, all four orderings: +// TestIntegrationGateFenceTwoGatesEachVerifyOnlyTheirOwn — spec acceptance item 1, all four orderings: // (complete A, verdict A, verdict B), (verdict B, complete A, verdict A), (both // in-progress), (both complete). Gate A must always report on A's id and gate B on // B's id; neither line may carry the sibling id. -func TestTwoGatesEachVerifyOnlyTheirOwn(t *testing.T) { +func TestIntegrationGateFenceTwoGatesEachVerifyOnlyTheirOwn(t *testing.T) { const ( idA = 3 slugA = "widget" @@ -205,13 +207,13 @@ func TestTwoGatesEachVerifyOnlyTheirOwn(t *testing.T) { } } -// TestUnrelatedChurnDoesNotMoveOwnership — spec acceptance item 2 / item 6 first +// TestIntegrationGateFenceUnrelatedChurnDoesNotMoveOwnership — spec acceptance item 2 / item 6 first // half: after binding A→3, mutating the corpus (sibling in-progress claims, a // refreshed claimed_at on 3, a priority edit) and adding sibling proofs under other // context hashes leaves the verdict unchanged — same id, same run-complete outcome. // Ownership rests on the confirmed binding + the exact committed proof, never on the // current claim set. -func TestUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { +func TestIntegrationGateFenceUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { f := newRunVerifyFixture(t, true) key := gateMintArmed(t, f.repo.invocation, nil, 1, "ha") mxBind(t, f.repo.invocation, key, 3, "claim-3-v", "rA") @@ -260,13 +262,13 @@ func TestUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { mxAssertOwnIDOnly(t, res2, key, 3, 10) } -// TestReplacementClaimBlocksOldGate — spec acceptance item 6 second half: after A +// TestIntegrationGateFenceReplacementClaimBlocksOldGate — spec acceptance item 6 second half: after A // is confirmed-bound to change 3 at claim-3-v1, a NEWER committed proof for change 3 // under a different request id means the change was reclaimed and re-claimed by // another run. A's verdict stops gate-unavailable claim-replaced, never spends the // retry, and a subsequent verdict still refuses — the old gate never takes over the // replacement run. -func TestReplacementClaimBlocksOldGate(t *testing.T) { +func TestIntegrationGateFenceReplacementClaimBlocksOldGate(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") mxBind(t, repo, key, 3, "claim-3-v1", "r1") @@ -301,11 +303,11 @@ func TestReplacementClaimBlocksOldGate(t *testing.T) { } } -// TestLaterVerdictCannotOverwriteBinding — spec acceptance item 7: after A's verdict +// TestIntegrationGateFenceLaterVerdictCannotOverwriteBinding — spec acceptance item 7: after A's verdict // bound and reported change 3 at revision r1, a later confirm carrying a DIFFERENT // revision is refused binding-conflict, and re-running the verdict resolves the same // bound id with the binding intact. -func TestLaterVerdictCannotOverwriteBinding(t *testing.T) { +func TestIntegrationGateFenceLaterVerdictCannotOverwriteBinding(t *testing.T) { f := newRunVerifyFixture(t, true) key := gateMintArmed(t, f.repo.invocation, nil, 1, "ha") mxBind(t, f.repo.invocation, key, 3, "claim-3-v", "r1") diff --git a/internal/app/rungate_production_census_test.go b/internal/app/rungate_production_census_integration_test.go similarity index 95% rename from internal/app/rungate_production_census_test.go rename to internal/app/rungate_production_census_integration_test.go index 05901f727..4f0dc2ada 100644 --- a/internal/app/rungate_production_census_test.go +++ b/internal/app/rungate_production_census_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -191,10 +193,10 @@ func requireProcessSupervisorHere(t *testing.T) { } } -// TestProductionCensusCompleteThenFinalize (AC6): the successful closeout runs the +// TestIntegrationGateCompletionProductionCensusCompleteThenFinalize (AC6): the successful closeout runs the // production observation census with unrelated damaged history present, the run's // scratch is gone, and finalize then enters through its real gate-drive Start. -func TestProductionCensusCompleteThenFinalize(t *testing.T) { +func TestIntegrationGateCompletionProductionCensusCompleteThenFinalize(t *testing.T) { fx := prepareQuiescentRun(t) must(t, RegisterEpochParticipant(fx.repo, fx.key, fx.epochID, EpochParticipant{Kind: "coordinator", NativeHandle: "turn-1"})) @@ -219,11 +221,11 @@ func TestProductionCensusCompleteThenFinalize(t *testing.T) { startFinalizeGate(t, fx) } -// TestProductionCensusCancelThenFinalize (AC5/AC6): an otherwise quiescent epoch +// TestIntegrationGateCompletionProductionCensusCancelThenFinalize (AC5/AC6): an otherwise quiescent epoch // cancels through the production reconciliation census with unrelated damaged // history present — repeated cancellation converges — and finalize then enters // through its real gate-drive Start. -func TestProductionCensusCancelThenFinalize(t *testing.T) { +func TestIntegrationGateCompletionProductionCensusCancelThenFinalize(t *testing.T) { fx := prepareQuiescentRun(t) res := runCancel(productionCancelSeams(fx.repo), fx.repo, fx.key, fx.epochID, "human stop") if res.Disposition != CancelDispositionCancelled { @@ -235,11 +237,11 @@ func TestProductionCensusCancelThenFinalize(t *testing.T) { startFinalizeGate(t, fx) } -// TestProductionCensusCancelResumeStartsReplacementGate (AC5): after a production +// TestIntegrationGateCompletionProductionCensusCancelResumeStartsReplacementGate (AC5): after a production // cancellation, NEW unrelated history lands, then resume re-proves quiescence through // the production census, reserves exactly one replacement, and the replacement's // build gate — carrying its run epoch — starts through the real service and passes. -func TestProductionCensusCancelResumeStartsReplacementGate(t *testing.T) { +func TestIntegrationGateCompletionProductionCensusCancelResumeStartsReplacementGate(t *testing.T) { fx := prepareQuiescentRun(t) if res := runCancel(productionCancelSeams(fx.repo), fx.repo, fx.key, fx.epochID, "human stop"); res.Disposition != CancelDispositionCancelled { t.Fatalf("production cancel = %q, want cancelled (findings=%v)", res.Disposition, res.Findings) diff --git a/internal/app/rungate_publication_integration_test.go b/internal/app/rungate_publication_integration_test.go new file mode 100644 index 000000000..4174357c1 --- /dev/null +++ b/internal/app/rungate_publication_integration_test.go @@ -0,0 +1,597 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_gatecompletion.sh (prefix ^TestIntegrationGateCompletion); +// the race-classified settlement test runs in tests/test_go_integration_app_concurrency.sh +// (prefix ^TestRaceIntegrationAppConcurrency). + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +// TestIntegrationGateCompletionAdmissionJournalsPublicationDescriptorAndLegacyDecodes: an admission carrying a +// descriptor persists it verbatim in the journal entry (schema v1, additive field); +// an existing entry WITHOUT the field still decodes (legacy compatibility). +func TestIntegrationGateCompletionAdmissionJournalsPublicationDescriptorAndLegacyDecodes(t *testing.T) { + fx := newCancelFixture(t, false) // active epoch bound to the fixture worktree + pub := &MutationPublication{ + RepoHost: "github.com", RepoOwner: "o", RepoName: "r", + HeadRef: "fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + BaseBranch: "main", + TitleDigest: publicationDigest("pr-title", "t"), + BodyDigest: publicationDigest("pr-body", "b"), + } + done, err := admitWorkflowMutation(fx.worktree, OperationPRPublish, pub) + if err != nil { + t.Fatalf("admitWorkflowMutation: %v", err) + } + done(mutationStatusUncertain, false) + + ep, _, lerr := LoadEpochRecord(fx.repo, fx.key) + if lerr != nil { + t.Fatalf("LoadEpochRecord: %v", lerr) + } + if len(ep.AdmittedMutations) != 1 { + t.Fatalf("journal length = %d, want 1", len(ep.AdmittedMutations)) + } + got := ep.AdmittedMutations[0] + if got.Status != mutationStatusUncertain || got.OpKey != OperationPRPublish { + t.Fatalf("entry = %+v, want uncertain pr.publish", got) + } + if got.Publication == nil || *got.Publication != *pub { + t.Fatalf("persisted descriptor = %+v, want %+v", got.Publication, pub) + } + + // Legacy shape: an entry with no publication field decodes and stays usable. + if cerr := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.AdmittedMutations = append(r.AdmittedMutations, + AdmittedMutation{OpKey: OperationPRPublish, Status: mutationStatusCompleted}) + return nil + }); cerr != nil { + t.Fatalf("epochCAS append legacy: %v", cerr) + } + ep2, _, lerr2 := LoadEpochRecord(fx.repo, fx.key) + if lerr2 != nil { + t.Fatalf("LoadEpochRecord after legacy append: %v", lerr2) + } + if ep2.AdmittedMutations[1].Publication != nil { + t.Fatal("legacy entry must decode with a nil descriptor") + } +} + +// TestIntegrationGateCompletionJournaledRetryOutcomeGatesSettlement (change 0444 review blocker): through the +// REAL admission + completion callback, an identical retry settles the uncertain +// original ONLY when its final Result verified the postcondition. A retry resolved +// contended, invalid-state, invalid-input, or internal-error persists completed +// but unverified and leaves the original pending; a verified flag handed to an +// uncertain completion is never persisted; and a legacy completed entry decoded +// without the field is unverified. +func TestIntegrationGateCompletionJournaledRetryOutcomeGatesSettlement(t *testing.T) { + fx := newCancelFixture(t, false) + cases := []struct { + r Result + settle bool + }{ + {ResultApplied, true}, + {ResultNoOp, true}, + {ResultContended, false}, + {ResultInvalidState, false}, + {ResultInvalidInput, false}, + {ResultInternalError, false}, + } + for n, tc := range cases { + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/outcome", HeadCommit: fmt.Sprintf("%040x", n+1)} + od, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) + if err != nil { + t.Fatalf("%s: admit original: %v", tc.r, err) + } + od(mutationJournalOutcome(ResultExternalFailed)) + rd, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) + if err != nil { + t.Fatalf("%s: admit retry: %v", tc.r, err) + } + rd(mutationJournalOutcome(tc.r)) + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("%s: LoadEpochRecord: %v", tc.r, err) + } + orig, retry := len(ep.AdmittedMutations)-2, len(ep.AdmittedMutations)-1 + if ep.AdmittedMutations[orig].Status != mutationStatusUncertain || ep.AdmittedMutations[orig].Verified { + t.Fatalf("%s: original = %+v, want uncertain and unverified", tc.r, ep.AdmittedMutations[orig]) + } + if ep.AdmittedMutations[retry].Status != mutationStatusCompleted || ep.AdmittedMutations[retry].Verified != tc.settle { + t.Fatalf("%s: retry = %+v, want completed with verified=%v", tc.r, ep.AdmittedMutations[retry], tc.settle) + } + if got := publicationRetryMatch(ep, orig); got != tc.settle { + t.Fatalf("%s: publicationRetryMatch = %v, want %v", tc.r, got, tc.settle) + } + } + + // A verified flag handed alongside an uncertain completion is never persisted. + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/uncertain-verified", HeadCommit: fmt.Sprintf("%040x", 99)} + ud, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) + if err != nil { + t.Fatalf("admit: %v", err) + } + ud(mutationStatusUncertain, true) + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if last := ep.AdmittedMutations[len(ep.AdmittedMutations)-1]; last.Verified { + t.Fatalf("uncertain entry persisted verified: %+v", last) + } + + // Legacy decode: a completed entry written before the field existed is + // unverified and never settles an identical uncertain original. + var legacy AdmittedMutation + if err := json.Unmarshal([]byte(`{"op_key":"workspace.publish","status":"completed",`+ + `"publication":{"repo_dir":"/repo/.git","remote":"origin","head_ref":"refs/heads/fix/legacy",`+ + `"head_commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}`), &legacy); err != nil { + t.Fatalf("decode legacy entry: %v", err) + } + if legacy.Verified || !validPublication(OperationWorkspacePublish, legacy.Publication) { + t.Fatalf("legacy entry = %+v, want a valid descriptor and verified=false", legacy) + } + orig := AdmittedMutation{OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: legacy.Publication} + if publicationRetryMatch(EpochRecord{AdmittedMutations: []AdmittedMutation{orig, legacy}}, 0) { + t.Fatal("a legacy completed entry with no verified flag must never settle") + } +} + +// TestIntegrationGateCompletionSettleUncertainPublicationsDurable: settlement re-derives matches under the +// epoch lock, flips ONLY matched originals uncertain→completed, is idempotent, and +// never touches unmatched entries, participants, or epoch state. +func TestIntegrationGateCompletionSettleUncertainPublicationsDurable(t *testing.T) { + fx := newCancelFixture(t, false) + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + participant := EpochParticipant{Kind: "task", NativeHandle: "handle-1", RegisteredAt: "2026-09-23T00:00:00Z"} + if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.State = EpochCancelling // settlement is observation of fact; it works on a fenced epoch + r.Participants = []EpochParticipant{participant} + r.AdmittedMutations = []AdmittedMutation{ + {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, + {OpKey: OperationPRPublish, Status: mutationStatusUncertain}, // legacy: stays pending + {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, + } + return nil + }); err != nil { + t.Fatalf("seed journal: %v", err) + } + before, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord (before): %v", err) + } + + settled, findings := settleUncertainPublications(fx.repo, fx.key) + if len(findings) != 0 { + t.Fatalf("findings = %v, want none", findings) + } + if len(settled) != 1 || settled[0] != "mutation-settled:"+OperationWorkspacePublish { + t.Fatalf("settled = %v, want [mutation-settled:workspace.publish]", settled) + } + + ep, gen, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if len(ep.AdmittedMutations) != 3 { + t.Fatalf("journal length = %d, want 3 (settlement never appends or drops entries)", len(ep.AdmittedMutations)) + } + if ep.AdmittedMutations[0].Status != mutationStatusCompleted { + t.Fatal("matched original must be durably completed — assert the RECORD changed, not a result string") + } + if ep.AdmittedMutations[0].OpKey != OperationWorkspacePublish || + ep.AdmittedMutations[0].Publication == nil || *ep.AdmittedMutations[0].Publication != desc { + t.Fatal("settlement must preserve the entry's identity") + } + if ep.AdmittedMutations[1].Status != mutationStatusUncertain || ep.AdmittedMutations[1].Publication != nil { + t.Fatal("legacy descriptor-less entry must remain pending and untouched") + } + if ep.AdmittedMutations[2].Status != mutationStatusCompleted || + ep.AdmittedMutations[2].Publication == nil || *ep.AdmittedMutations[2].Publication != desc { + t.Fatal("the settling retry entry must be untouched") + } + if ep.State != EpochCancelling { + t.Fatalf("epoch state = %q; settlement must never transition the epoch", ep.State) + } + if ep.EpochID != before.EpochID || ep.ChangeID != before.ChangeID || ep.Worktree != before.Worktree { + t.Fatal("settlement must never touch epoch identity fields") + } + if len(ep.Participants) != 1 || ep.Participants[0] != participant { + t.Fatalf("participants = %+v; settlement must never touch participants", ep.Participants) + } + + // Idempotent replay: nothing left to settle, no findings, and NO write — the + // physical generation does not rotate. + settled2, findings2 := settleUncertainPublications(fx.repo, fx.key) + if len(settled2) != 0 || len(findings2) != 0 { + t.Fatalf("replay settled=%v findings=%v, want none", settled2, findings2) + } + _, gen2, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord (replay): %v", err) + } + if gen2 != gen { + t.Fatalf("replay rotated generation %q -> %q; a no-match pass must write nothing", gen, gen2) + } +} + +// TestIntegrationGateCompletionSettleUncertainPublicationsFailureIsBoundedFinding: an unreadable epoch is a +// bounded finding, never a panic and never a fabricated settlement. +func TestIntegrationGateCompletionSettleUncertainPublicationsFailureIsBoundedFinding(t *testing.T) { + fx := newCancelFixture(t, false) + // Corrupt the record so the CAS read fails closed. + dir := filepath.Join(fx.common, "docket", "rungate", fx.key) + if err := os.WriteFile(filepath.Join(dir, epochRecordFileName), []byte("{not json"), 0o600); err != nil { + t.Fatalf("corrupt record: %v", err) + } + settled, findings := settleUncertainPublications(fx.repo, fx.key) + if len(settled) != 0 { + t.Fatalf("settled = %v, want none on failure", settled) + } + if len(findings) != 1 || findings[0] != "mutation-settle-failed" { + t.Fatalf("findings = %v, want [mutation-settle-failed]", findings) + } +} + +// TestIntegrationGateCompletionSettleUncertainPublicationsWriteFailureReportsNoSettlement: when matches are +// found under the lock but the atomic write cannot land, the writer reports the +// bounded finding and NO settled tokens (the closure's accumulated tokens are +// discarded), and the durable entry stays uncertain — exclusion is retained. +func TestIntegrationGateCompletionSettleUncertainPublicationsWriteFailureReportsNoSettlement(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory write permission") + } + fx := newCancelFixture(t, false) + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.AdmittedMutations = []AdmittedMutation{ + {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, + {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, + } + return nil + }); err != nil { + t.Fatalf("seed journal: %v", err) + } + // The lock file already exists (the seed CAS created it); a read-only key dir + // still lets the CAS lock and read, but the same-directory temp file cannot be + // created, so the write fails AFTER the match closure ran. + dir := filepath.Join(fx.common, "docket", "rungate", fx.key) + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatalf("chmod key dir: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + settled, findings := settleUncertainPublications(fx.repo, fx.key) + if len(settled) != 0 { + t.Fatalf("settled = %v, want none when the write never landed", settled) + } + if len(findings) != 1 || findings[0] != "mutation-settle-failed" { + t.Fatalf("findings = %v, want [mutation-settle-failed]", findings) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatalf("restore key dir: %v", err) + } + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if ep.AdmittedMutations[0].Status != mutationStatusUncertain { + t.Fatal("a failed settlement write must leave the original entry uncertain") + } +} + +// TestRaceIntegrationAppConcurrencySettlementNeverDowngradesUnderRacingCallback (change 0444 acceptance 6): a +// completion callback racing the settlement (both under the epoch CAS) can never +// regress completed→uncertain or lose its own completed write, and an unrelated +// entry appended between match and write is never cleared — the settlement +// re-derives its matches from the fresh record under the lock. +// Race shard (change 0465): eight settlements race the retry completion callback and four fresh admissions per round. +func TestRaceIntegrationAppConcurrencySettlementNeverDowngradesUnderRacingCallback(t *testing.T) { + fx := newCancelFixture(t, false) + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + other := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/other", HeadCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.AdmittedMutations = []AdmittedMutation{ + {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, + {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, + } + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + // An unrelated admission lands right before settlement runs. + if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.AdmittedMutations = append(r.AdmittedMutations, + AdmittedMutation{OpKey: OperationWorkspacePublish, Status: mutationStatusAdmitted, Publication: &other}) + return nil + }); err != nil { + t.Fatalf("append racer: %v", err) + } + settled, findings := settleUncertainPublications(fx.repo, fx.key) + if len(findings) != 0 || len(settled) != 1 { + t.Fatalf("settled=%v findings=%v, want one settlement and no finding", settled, findings) + } + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if ep.AdmittedMutations[2].Status != mutationStatusAdmitted { + t.Fatal("the racing unrelated admission must be untouched") + } + if ep.AdmittedMutations[0].Status != mutationStatusCompleted || ep.AdmittedMutations[1].Status != mutationStatusCompleted { + t.Fatal("the matched original must be settled and a completed entry must never be downgraded") + } + + // Deterministic ordering: settlement BEFORE the retry's completion callback + // lands settles nothing (the retry is still admitted, so it is no evidence); + // the callback then lands completed and a repeat settlement converges. + d2 := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w2", HeadCommit: "cccccccccccccccccccccccccccccccccccccccc"} + origDone, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &d2) + if err != nil { + t.Fatalf("admit original: %v", err) + } + origDone(mutationStatusUncertain, false) + retryDone, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &d2) + if err != nil { + t.Fatalf("admit retry: %v", err) + } + if s, f := settleUncertainPublications(fx.repo, fx.key); len(s) != 0 || len(f) != 0 { + t.Fatalf("settled=%v findings=%v before the retry completed, want none", s, f) + } + retryDone(mutationStatusCompleted, true) + if s, f := settleUncertainPublications(fx.repo, fx.key); len(s) != 1 || len(f) != 0 { + t.Fatalf("settled=%v findings=%v after the retry completed, want one settlement", s, f) + } + ep, _, err = LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord (ordering): %v", err) + } + for i := 3; i <= 4; i++ { + if ep.AdmittedMutations[i].Status != mutationStatusCompleted { + t.Fatalf("entry %d = %q after ordered callback + settlement, want completed", i, ep.AdmittedMutations[i].Status) + } + } + + // Real parallelism: each round journals an uncertain original, an in-flight + // identical retry, and an unrelated in-flight admission through the REAL + // admission gate, then races eight settlements against the retry's completion + // callback and four fresh unrelated admissions. The flock-serialized CAS must + // keep every invariant in every round: the callback's completed write is never + // lost or downgraded, no racing admission is dropped by a settlement write, + // the unrelated admissions and every earlier entry are untouched, and the + // original is only ever uncertain or completed. + for round := range 12 { + rd := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/round", HeadCommit: fmt.Sprintf("%040x", round+1)} + ru := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/unrelated", HeadCommit: fmt.Sprintf("%040x", round+1)} + before, _, lerr := LoadEpochRecord(fx.repo, fx.key) + if lerr != nil { + t.Fatalf("round %d: load: %v", round, lerr) + } + base := len(before.AdmittedMutations) + od, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &rd) + if aerr != nil { + t.Fatalf("round %d: admit original: %v", round, aerr) + } + od(mutationStatusUncertain, false) + rdone, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &rd) + if aerr != nil { + t.Fatalf("round %d: admit retry: %v", round, aerr) + } + if _, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &ru); aerr != nil { + t.Fatalf("round %d: admit unrelated: %v", round, aerr) + } + + start := make(chan struct{}) + var ( + wg sync.WaitGroup + mu sync.Mutex + raceFind []string + ) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, f := settleUncertainPublications(fx.repo, fx.key) + mu.Lock() + raceFind = append(raceFind, f...) + mu.Unlock() + }() + } + wg.Add(1) + go func() { + defer wg.Done() + <-start + rdone(mutationStatusCompleted, true) + }() + // Unrelated admissions APPENDED during the race: a settlement that wrote a + // record matched outside the lock (a stale snapshot) would silently drop them. + const racers = 4 + var admitErrs []error + for k := range racers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + cp := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: fmt.Sprintf("refs/heads/fix/racer-%d", k), HeadCommit: rd.HeadCommit} + if _, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &cp); err != nil { + mu.Lock() + admitErrs = append(admitErrs, err) + mu.Unlock() + } + }() + } + close(start) + wg.Wait() + if len(raceFind) != 0 { + t.Fatalf("round %d: concurrent settlement findings = %v, want none (the lock serializes, never fails)", round, raceFind) + } + if len(admitErrs) != 0 { + t.Fatalf("round %d: racing admissions failed: %v", round, admitErrs) + } + + got, _, lerr := LoadEpochRecord(fx.repo, fx.key) + if lerr != nil { + t.Fatalf("round %d: load after race: %v", round, lerr) + } + if len(got.AdmittedMutations) != base+3+racers { + t.Fatalf("round %d: journal length = %d, want %d (a racing admission was lost)", round, len(got.AdmittedMutations), base+3+racers) + } + seen := map[string]bool{} + for _, m := range got.AdmittedMutations[base+3:] { + if m.Status != mutationStatusAdmitted || m.Publication == nil { + t.Fatalf("round %d: racing admission = %+v, want an untouched admitted entry", round, m) + } + seen[m.Publication.HeadRef] = true + } + if len(seen) != racers { + t.Fatalf("round %d: racing admissions present = %v, want %d distinct", round, seen, racers) + } + for i := range base { + if got.AdmittedMutations[i].Status != before.AdmittedMutations[i].Status { + t.Fatalf("round %d: earlier entry %d changed %q -> %q", round, i, + before.AdmittedMutations[i].Status, got.AdmittedMutations[i].Status) + } + } + if s := got.AdmittedMutations[base].Status; s != mutationStatusUncertain && s != mutationStatusCompleted { + t.Fatalf("round %d: original = %q, want uncertain or completed", round, s) + } + if s := got.AdmittedMutations[base+1].Status; s != mutationStatusCompleted { + t.Fatalf("round %d: retry = %q after its completion callback, want completed (a lost or downgraded callback write)", round, s) + } + if s := got.AdmittedMutations[base+2].Status; s != mutationStatusAdmitted { + t.Fatalf("round %d: unrelated admission = %q, want admitted (untouched)", round, s) + } + + // Convergence: whatever the interleaving, one more settlement settles it. + if _, f := settleUncertainPublications(fx.repo, fx.key); len(f) != 0 { + t.Fatalf("round %d: convergence findings = %v", round, f) + } + conv, _, lerr := LoadEpochRecord(fx.repo, fx.key) + if lerr != nil { + t.Fatalf("round %d: load after convergence: %v", round, lerr) + } + if s := conv.AdmittedMutations[base].Status; s != mutationStatusCompleted { + t.Fatalf("round %d: original = %q after convergence, want completed", round, s) + } + } +} + +// TestIntegrationGateCompletionSettlementInterruptionConverges (change 0444 acceptance 6): a settlement +// whose durable write cannot land never lets cancellation claim `cancelled` — the +// entry stays uncertain, exclusion is retained, and the bounded finding names the +// failure — and once the record is writable again, repeating the SAME cancel +// converges. (An interruption AFTER a successful write is a harmless idempotent +// replay, proven by TestIntegrationGateCompletionSettleUncertainPublicationsDurable's replay assert.) +func TestIntegrationGateCompletionSettlementInterruptionConverges(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory write permission") + } + fx := newCancelFixture(t, true) + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { + r.AdmittedMutations = []AdmittedMutation{ + {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, + {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, + } + return nil + }); err != nil { + t.Fatalf("seed: %v", err) + } + // A read-only key dir still lets the CAS lock and read, but the same-directory + // temp file cannot be created, so every epoch write fails. + dir := filepath.Join(fx.common, "docket", "rungate", fx.key) + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + originalStatus := func(when string) string { + t.Helper() + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord (%s): %v", when, err) + } + return ep.AdmittedMutations[0].Status + } + + // (a) Unwritable before the cancel: the fence itself cannot land, so the + // cancel refuses — never cancelled — and the entry and epoch are untouched. + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatalf("chmod: %v", err) + } + stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} + seams := cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()} + pre := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") + if pre.Disposition == CancelDispositionCancelled { + t.Fatalf("disposition = cancelled with an unwritable epoch; findings=%v", pre.Findings) + } + if s := originalStatus("unwritable fence"); s != mutationStatusUncertain { + t.Fatalf("original = %q after a failed fence, want uncertain", s) + } + if st := loadEpochState(t, fx.repo, fx.key); st != EpochActive { + t.Fatalf("epoch state = %q after a failed fence, want active (nothing landed)", st) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatalf("chmod back: %v", err) + } + + // (b) Interrupted between the fence and the settlement: the fence lands, then + // the store turns unwritable during teardown (the process stop), so ONLY the + // settlement write fails. Cancellation must stay pending with the bounded + // finding, report no settlement, and leave the entry uncertain. + stopper.onStop = func(string) { + if err := os.Chmod(dir, 0o500); err != nil { + t.Errorf("chmod mid-teardown: %v", err) + } + } + res := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") + if res.Disposition != CancelDispositionPending { + t.Fatalf("disposition = %q with an unpersistable settlement (findings %v), want cancellation-pending", res.Disposition, res.Findings) + } + if !hasFinding(res.Findings, "mutation-settle-failed") { + t.Fatalf("findings = %v, want mutation-settle-failed", res.Findings) + } + if !hasFinding(res.Findings, "mutation-pending:"+OperationWorkspacePublish) { + t.Fatalf("findings = %v, want mutation-pending:workspace.publish (exclusion retained)", res.Findings) + } + if hasFinding(res.Findings, "mutation-settled") { + t.Fatalf("findings = %v; a settlement that never landed must not be reported", res.Findings) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatalf("chmod back: %v", err) + } + if s := originalStatus("interrupted settlement"); s != mutationStatusUncertain { + t.Fatalf("original = %q after a failed settlement write, want uncertain", s) + } + if st := loadEpochState(t, fx.repo, fx.key); st != EpochCancelling { + t.Fatalf("epoch state = %q, want cancelling (the fence is durably held)", st) + } + + // (c) Writable again: the SAME repeat cancel converges. + stopper.onStop = nil + res2 := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") + if res2.Disposition != CancelDispositionCancelled { + t.Fatalf("repeat disposition = %q (findings %v), want cancelled", res2.Disposition, res2.Findings) + } + if s := originalStatus("repeat cancel"); s != mutationStatusCompleted { + t.Fatalf("original = %q after the converged repeat cancel, want completed", s) + } +} diff --git a/internal/app/rungate_publication_settle_paths_integration_test.go b/internal/app/rungate_publication_settle_paths_integration_test.go new file mode 100644 index 000000000..be0420042 --- /dev/null +++ b/internal/app/rungate_publication_settle_paths_integration_test.go @@ -0,0 +1,118 @@ +//go:build integration + +package app + +// Change 0465: real-git tests moved out of the default internal/app corpus, which +// must never start real git (see nogit_guard_test.go); run by +// tests/test_go_integration_app_gatecompletion.sh (prefix ^TestIntegrationGateCompletion). + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +// seedSettleablePair journals a settleable pair on the fixture's epoch: an uncertain +// workspace publish followed by a verified completed identical retry. +func seedSettleablePair(t *testing.T, repo, key string) { + t.Helper() + desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", + HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := epochCAS(repo, key, func(r *EpochRecord) error { + r.AdmittedMutations = []AdmittedMutation{ + {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, + {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, + } + return nil + }); err != nil { + t.Fatalf("seed journal: %v", err) + } +} + +// epochRecordBytes reads the durable epoch record file verbatim. +func epochRecordBytes(t *testing.T, repo, key string) []byte { + t.Helper() + common, err := gateGitCommonDir(repo) + if err != nil { + t.Fatalf("gateGitCommonDir: %v", err) + } + b, err := os.ReadFile(filepath.Join(common, "docket", "rungate", key, epochRecordFileName)) + if err != nil { + t.Fatalf("read epoch record: %v", err) + } + return b +} + +// TestIntegrationGateCompletionVerdictRunCompleteSettlesUncertainPublication (change 0444 acceptance 3): the +// REAL keyed verdict over a settleable pair settles the original through the +// attributed closeout and ends in gate-done run-complete, with the original durably +// completed and the epoch completed. Without settlement the uncertain original would +// block the closeout (completion-unaccounted), so gate-done proves the settle ran on +// this path. +func TestIntegrationGateCompletionVerdictRunCompleteSettlesUncertainPublication(t *testing.T) { + fx := newVerdictCompletionFixture(t) + seedSettleablePair(t, fx.repo, fx.key) + + res := RunGateVerdict(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, fx.key) + if got, want := res.HumanText(), "gate-done "+fx.key+" run-complete 3"; got != want { + t.Fatalf("HumanText = %q, want %q (findings %v)", got, want, res.CompletionFindings) + } + if countFinding(res.CompletionFindings, "mutation-settled:"+OperationWorkspacePublish) != 1 { + t.Fatalf("CompletionFindings = %v, want exactly one mutation-settled:%s", + res.CompletionFindings, OperationWorkspacePublish) + } + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if ep.State != EpochCompleted { + t.Fatalf("epoch state = %q, want completed", ep.State) + } + if ep.AdmittedMutations[0].Status != mutationStatusCompleted { + t.Fatalf("original status = %q, want durably completed by the keyed verdict", ep.AdmittedMutations[0].Status) + } +} + +// TestIntegrationGateCompletionReadOnlyVerdictPathsNeverSettleSettleablePair (change 0444 acceptance 3): the +// unattributed observe verdict and RunVerify, run over the same settleable pair on an +// ACTIVE and on a COMPLETING epoch, leave the epoch record byte-identical — neither +// is an authorized settlement writer. +func TestIntegrationGateCompletionReadOnlyVerdictPathsNeverSettleSettleablePair(t *testing.T) { + for _, state := range []string{"active", "completing"} { + t.Run(state, func(t *testing.T) { + fx := newVerdictCompletionFixture(t) + seedSettleablePair(t, fx.repo, fx.key) + if state == "completing" { + if _, err := FenceEpochCompleting(fx.repo, fx.key, ""); err != nil { + t.Fatalf("FenceEpochCompleting: %v", err) + } + } + before := epochRecordBytes(t, fx.repo, fx.key) + + obs := RunGateVerdictObserve(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, []string{"3"}) + if got, want := obs.HumanText(), "gate-observe run-complete 3"; got != want { + t.Fatalf("observe HumanText = %q, want %q", got, want) + } + if after := epochRecordBytes(t, fx.repo, fx.key); !bytes.Equal(before, after) { + t.Fatal("the unattributed observe verdict wrote the epoch record") + } + + v := RunVerify(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, RunVerifyRequest{ID: 3}) + if v.Verdict != VerdictRunComplete { + t.Fatalf("RunVerify verdict = %q, want %q", v.Verdict, VerdictRunComplete) + } + if after := epochRecordBytes(t, fx.repo, fx.key); !bytes.Equal(before, after) { + t.Fatal("RunVerify wrote the epoch record") + } + ep, _, err := LoadEpochRecord(fx.repo, fx.key) + if err != nil { + t.Fatalf("LoadEpochRecord: %v", err) + } + if ep.AdmittedMutations[0].Status != mutationStatusUncertain { + t.Fatalf("original status = %q, want still uncertain", ep.AdmittedMutations[0].Status) + } + }) + } +} diff --git a/internal/app/rungate_publication_settle_paths_test.go b/internal/app/rungate_publication_settle_paths_test.go index 3f194d44e..9c739f09d 100644 --- a/internal/app/rungate_publication_settle_paths_test.go +++ b/internal/app/rungate_publication_settle_paths_test.go @@ -1,13 +1,10 @@ package app import ( - "bytes" - "context" "go/ast" "go/parser" "go/token" "os" - "path/filepath" "sort" "strings" "testing" @@ -19,110 +16,9 @@ import ( // teardown ONLY; the unattributed observe verdict and RunVerify over the very same // settleable pair write nothing. TestSettleUncertainPublicationsAuthorizedCallers // pins the write to its two authorized callers by deriving every reference from -// source. - -// seedSettleablePair journals a settleable pair on the fixture's epoch: an uncertain -// workspace publish followed by a verified completed identical retry. -func seedSettleablePair(t *testing.T, repo, key string) { - t.Helper() - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - if err := epochCAS(repo, key, func(r *EpochRecord) error { - r.AdmittedMutations = []AdmittedMutation{ - {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, - {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, - } - return nil - }); err != nil { - t.Fatalf("seed journal: %v", err) - } -} - -// epochRecordBytes reads the durable epoch record file verbatim. -func epochRecordBytes(t *testing.T, repo, key string) []byte { - t.Helper() - common, err := gateGitCommonDir(repo) - if err != nil { - t.Fatalf("gateGitCommonDir: %v", err) - } - b, err := os.ReadFile(filepath.Join(common, "docket", "rungate", key, epochRecordFileName)) - if err != nil { - t.Fatalf("read epoch record: %v", err) - } - return b -} - -// TestVerdictRunCompleteSettlesUncertainPublication (change 0444 acceptance 3): the -// REAL keyed verdict over a settleable pair settles the original through the -// attributed closeout and ends in gate-done run-complete, with the original durably -// completed and the epoch completed. Without settlement the uncertain original would -// block the closeout (completion-unaccounted), so gate-done proves the settle ran on -// this path. -func TestVerdictRunCompleteSettlesUncertainPublication(t *testing.T) { - fx := newVerdictCompletionFixture(t) - seedSettleablePair(t, fx.repo, fx.key) - - res := RunGateVerdict(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, fx.key) - if got, want := res.HumanText(), "gate-done "+fx.key+" run-complete 3"; got != want { - t.Fatalf("HumanText = %q, want %q (findings %v)", got, want, res.CompletionFindings) - } - if countFinding(res.CompletionFindings, "mutation-settled:"+OperationWorkspacePublish) != 1 { - t.Fatalf("CompletionFindings = %v, want exactly one mutation-settled:%s", - res.CompletionFindings, OperationWorkspacePublish) - } - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if ep.State != EpochCompleted { - t.Fatalf("epoch state = %q, want completed", ep.State) - } - if ep.AdmittedMutations[0].Status != mutationStatusCompleted { - t.Fatalf("original status = %q, want durably completed by the keyed verdict", ep.AdmittedMutations[0].Status) - } -} - -// TestReadOnlyVerdictPathsNeverSettleSettleablePair (change 0444 acceptance 3): the -// unattributed observe verdict and RunVerify, run over the same settleable pair on an -// ACTIVE and on a COMPLETING epoch, leave the epoch record byte-identical — neither -// is an authorized settlement writer. -func TestReadOnlyVerdictPathsNeverSettleSettleablePair(t *testing.T) { - for _, state := range []string{"active", "completing"} { - t.Run(state, func(t *testing.T) { - fx := newVerdictCompletionFixture(t) - seedSettleablePair(t, fx.repo, fx.key) - if state == "completing" { - if _, err := FenceEpochCompleting(fx.repo, fx.key, ""); err != nil { - t.Fatalf("FenceEpochCompleting: %v", err) - } - } - before := epochRecordBytes(t, fx.repo, fx.key) - - obs := RunGateVerdictObserve(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, []string{"3"}) - if got, want := obs.HumanText(), "gate-observe run-complete 3"; got != want { - t.Fatalf("observe HumanText = %q, want %q", got, want) - } - if after := epochRecordBytes(t, fx.repo, fx.key); !bytes.Equal(before, after) { - t.Fatal("the unattributed observe verdict wrote the epoch record") - } - - v := RunVerify(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, RunVerifyRequest{ID: 3}) - if v.Verdict != VerdictRunComplete { - t.Fatalf("RunVerify verdict = %q, want %q", v.Verdict, VerdictRunComplete) - } - if after := epochRecordBytes(t, fx.repo, fx.key); !bytes.Equal(before, after) { - t.Fatal("RunVerify wrote the epoch record") - } - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if ep.AdmittedMutations[0].Status != mutationStatusUncertain { - t.Fatalf("original status = %q, want still uncertain", ep.AdmittedMutations[0].Status) - } - }) - } -} +// source. The two real-entry-point tests run real git, so they live behind the +// integration tag in rungate_publication_settle_paths_integration_test.go (change +// 0465); only the source-derived shape guard stays in the default corpus. // TestSettleUncertainPublicationsAuthorizedCallers is change 0444's shape guard: the // settlement WRITE may be reached only from cancellation teardown diff --git a/internal/app/rungate_publication_test.go b/internal/app/rungate_publication_test.go index ab6a25bf7..b3df67312 100644 --- a/internal/app/rungate_publication_test.go +++ b/internal/app/rungate_publication_test.go @@ -1,11 +1,6 @@ package app import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" "testing" ) @@ -98,56 +93,6 @@ func TestValidPublicationPerOp(t *testing.T) { } } -// TestAdmissionJournalsPublicationDescriptorAndLegacyDecodes: an admission carrying a -// descriptor persists it verbatim in the journal entry (schema v1, additive field); -// an existing entry WITHOUT the field still decodes (legacy compatibility). -func TestAdmissionJournalsPublicationDescriptorAndLegacyDecodes(t *testing.T) { - fx := newCancelFixture(t, false) // active epoch bound to the fixture worktree - pub := &MutationPublication{ - RepoHost: "github.com", RepoOwner: "o", RepoName: "r", - HeadRef: "fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - BaseBranch: "main", - TitleDigest: publicationDigest("pr-title", "t"), - BodyDigest: publicationDigest("pr-body", "b"), - } - done, err := admitWorkflowMutation(fx.worktree, OperationPRPublish, pub) - if err != nil { - t.Fatalf("admitWorkflowMutation: %v", err) - } - done(mutationStatusUncertain, false) - - ep, _, lerr := LoadEpochRecord(fx.repo, fx.key) - if lerr != nil { - t.Fatalf("LoadEpochRecord: %v", lerr) - } - if len(ep.AdmittedMutations) != 1 { - t.Fatalf("journal length = %d, want 1", len(ep.AdmittedMutations)) - } - got := ep.AdmittedMutations[0] - if got.Status != mutationStatusUncertain || got.OpKey != OperationPRPublish { - t.Fatalf("entry = %+v, want uncertain pr.publish", got) - } - if got.Publication == nil || *got.Publication != *pub { - t.Fatalf("persisted descriptor = %+v, want %+v", got.Publication, pub) - } - - // Legacy shape: an entry with no publication field decodes and stays usable. - if cerr := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.AdmittedMutations = append(r.AdmittedMutations, - AdmittedMutation{OpKey: OperationPRPublish, Status: mutationStatusCompleted}) - return nil - }); cerr != nil { - t.Fatalf("epochCAS append legacy: %v", cerr) - } - ep2, _, lerr2 := LoadEpochRecord(fx.repo, fx.key) - if lerr2 != nil { - t.Fatalf("LoadEpochRecord after legacy append: %v", lerr2) - } - if ep2.AdmittedMutations[1].Publication != nil { - t.Fatal("legacy entry must decode with a nil descriptor") - } -} - // TestPublicationRetryMatchMatrix: an uncertain entry is settled ONLY by a later // (higher-index) completed AND verified entry with the same operation and a // field-for-field identical VALID descriptor. Everything else leaves it pending. @@ -284,88 +229,6 @@ func TestMutationJournalOutcomeVerifiesOnlyObservedPostcondition(t *testing.T) { } } -// TestJournaledRetryOutcomeGatesSettlement (change 0444 review blocker): through the -// REAL admission + completion callback, an identical retry settles the uncertain -// original ONLY when its final Result verified the postcondition. A retry resolved -// contended, invalid-state, invalid-input, or internal-error persists completed -// but unverified and leaves the original pending; a verified flag handed to an -// uncertain completion is never persisted; and a legacy completed entry decoded -// without the field is unverified. -func TestJournaledRetryOutcomeGatesSettlement(t *testing.T) { - fx := newCancelFixture(t, false) - cases := []struct { - r Result - settle bool - }{ - {ResultApplied, true}, - {ResultNoOp, true}, - {ResultContended, false}, - {ResultInvalidState, false}, - {ResultInvalidInput, false}, - {ResultInternalError, false}, - } - for n, tc := range cases { - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/outcome", HeadCommit: fmt.Sprintf("%040x", n+1)} - od, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) - if err != nil { - t.Fatalf("%s: admit original: %v", tc.r, err) - } - od(mutationJournalOutcome(ResultExternalFailed)) - rd, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) - if err != nil { - t.Fatalf("%s: admit retry: %v", tc.r, err) - } - rd(mutationJournalOutcome(tc.r)) - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("%s: LoadEpochRecord: %v", tc.r, err) - } - orig, retry := len(ep.AdmittedMutations)-2, len(ep.AdmittedMutations)-1 - if ep.AdmittedMutations[orig].Status != mutationStatusUncertain || ep.AdmittedMutations[orig].Verified { - t.Fatalf("%s: original = %+v, want uncertain and unverified", tc.r, ep.AdmittedMutations[orig]) - } - if ep.AdmittedMutations[retry].Status != mutationStatusCompleted || ep.AdmittedMutations[retry].Verified != tc.settle { - t.Fatalf("%s: retry = %+v, want completed with verified=%v", tc.r, ep.AdmittedMutations[retry], tc.settle) - } - if got := publicationRetryMatch(ep, orig); got != tc.settle { - t.Fatalf("%s: publicationRetryMatch = %v, want %v", tc.r, got, tc.settle) - } - } - - // A verified flag handed alongside an uncertain completion is never persisted. - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/uncertain-verified", HeadCommit: fmt.Sprintf("%040x", 99)} - ud, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &desc) - if err != nil { - t.Fatalf("admit: %v", err) - } - ud(mutationStatusUncertain, true) - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if last := ep.AdmittedMutations[len(ep.AdmittedMutations)-1]; last.Verified { - t.Fatalf("uncertain entry persisted verified: %+v", last) - } - - // Legacy decode: a completed entry written before the field existed is - // unverified and never settles an identical uncertain original. - var legacy AdmittedMutation - if err := json.Unmarshal([]byte(`{"op_key":"workspace.publish","status":"completed",`+ - `"publication":{"repo_dir":"/repo/.git","remote":"origin","head_ref":"refs/heads/fix/legacy",`+ - `"head_commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}`), &legacy); err != nil { - t.Fatalf("decode legacy entry: %v", err) - } - if legacy.Verified || !validPublication(OperationWorkspacePublish, legacy.Publication) { - t.Fatalf("legacy entry = %+v, want a valid descriptor and verified=false", legacy) - } - orig := AdmittedMutation{OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: legacy.Publication} - if publicationRetryMatch(EpochRecord{AdmittedMutations: []AdmittedMutation{orig, legacy}}, 0) { - t.Fatal("a legacy completed entry with no verified flag must never settle") - } -} - // TestSettleablePublicationIndexes: collects every settleable index, ascending, and // nothing else. func TestSettleablePublicationIndexes(t *testing.T) { @@ -381,449 +244,3 @@ func TestSettleablePublicationIndexes(t *testing.T) { t.Fatalf("settleable = %v, want [0]", got) } } - -// TestSettleUncertainPublicationsDurable: settlement re-derives matches under the -// epoch lock, flips ONLY matched originals uncertain→completed, is idempotent, and -// never touches unmatched entries, participants, or epoch state. -func TestSettleUncertainPublicationsDurable(t *testing.T) { - fx := newCancelFixture(t, false) - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - participant := EpochParticipant{Kind: "task", NativeHandle: "handle-1", RegisteredAt: "2026-09-23T00:00:00Z"} - if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.State = EpochCancelling // settlement is observation of fact; it works on a fenced epoch - r.Participants = []EpochParticipant{participant} - r.AdmittedMutations = []AdmittedMutation{ - {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, - {OpKey: OperationPRPublish, Status: mutationStatusUncertain}, // legacy: stays pending - {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, - } - return nil - }); err != nil { - t.Fatalf("seed journal: %v", err) - } - before, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord (before): %v", err) - } - - settled, findings := settleUncertainPublications(fx.repo, fx.key) - if len(findings) != 0 { - t.Fatalf("findings = %v, want none", findings) - } - if len(settled) != 1 || settled[0] != "mutation-settled:"+OperationWorkspacePublish { - t.Fatalf("settled = %v, want [mutation-settled:workspace.publish]", settled) - } - - ep, gen, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if len(ep.AdmittedMutations) != 3 { - t.Fatalf("journal length = %d, want 3 (settlement never appends or drops entries)", len(ep.AdmittedMutations)) - } - if ep.AdmittedMutations[0].Status != mutationStatusCompleted { - t.Fatal("matched original must be durably completed — assert the RECORD changed, not a result string") - } - if ep.AdmittedMutations[0].OpKey != OperationWorkspacePublish || - ep.AdmittedMutations[0].Publication == nil || *ep.AdmittedMutations[0].Publication != desc { - t.Fatal("settlement must preserve the entry's identity") - } - if ep.AdmittedMutations[1].Status != mutationStatusUncertain || ep.AdmittedMutations[1].Publication != nil { - t.Fatal("legacy descriptor-less entry must remain pending and untouched") - } - if ep.AdmittedMutations[2].Status != mutationStatusCompleted || - ep.AdmittedMutations[2].Publication == nil || *ep.AdmittedMutations[2].Publication != desc { - t.Fatal("the settling retry entry must be untouched") - } - if ep.State != EpochCancelling { - t.Fatalf("epoch state = %q; settlement must never transition the epoch", ep.State) - } - if ep.EpochID != before.EpochID || ep.ChangeID != before.ChangeID || ep.Worktree != before.Worktree { - t.Fatal("settlement must never touch epoch identity fields") - } - if len(ep.Participants) != 1 || ep.Participants[0] != participant { - t.Fatalf("participants = %+v; settlement must never touch participants", ep.Participants) - } - - // Idempotent replay: nothing left to settle, no findings, and NO write — the - // physical generation does not rotate. - settled2, findings2 := settleUncertainPublications(fx.repo, fx.key) - if len(settled2) != 0 || len(findings2) != 0 { - t.Fatalf("replay settled=%v findings=%v, want none", settled2, findings2) - } - _, gen2, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord (replay): %v", err) - } - if gen2 != gen { - t.Fatalf("replay rotated generation %q -> %q; a no-match pass must write nothing", gen, gen2) - } -} - -// TestSettleUncertainPublicationsFailureIsBoundedFinding: an unreadable epoch is a -// bounded finding, never a panic and never a fabricated settlement. -func TestSettleUncertainPublicationsFailureIsBoundedFinding(t *testing.T) { - fx := newCancelFixture(t, false) - // Corrupt the record so the CAS read fails closed. - dir := filepath.Join(fx.common, "docket", "rungate", fx.key) - if err := os.WriteFile(filepath.Join(dir, epochRecordFileName), []byte("{not json"), 0o600); err != nil { - t.Fatalf("corrupt record: %v", err) - } - settled, findings := settleUncertainPublications(fx.repo, fx.key) - if len(settled) != 0 { - t.Fatalf("settled = %v, want none on failure", settled) - } - if len(findings) != 1 || findings[0] != "mutation-settle-failed" { - t.Fatalf("findings = %v, want [mutation-settle-failed]", findings) - } -} - -// TestSettleUncertainPublicationsWriteFailureReportsNoSettlement: when matches are -// found under the lock but the atomic write cannot land, the writer reports the -// bounded finding and NO settled tokens (the closure's accumulated tokens are -// discarded), and the durable entry stays uncertain — exclusion is retained. -func TestSettleUncertainPublicationsWriteFailureReportsNoSettlement(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root ignores directory write permission") - } - fx := newCancelFixture(t, false) - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.AdmittedMutations = []AdmittedMutation{ - {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, - {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, - } - return nil - }); err != nil { - t.Fatalf("seed journal: %v", err) - } - // The lock file already exists (the seed CAS created it); a read-only key dir - // still lets the CAS lock and read, but the same-directory temp file cannot be - // created, so the write fails AFTER the match closure ran. - dir := filepath.Join(fx.common, "docket", "rungate", fx.key) - if err := os.Chmod(dir, 0o500); err != nil { - t.Fatalf("chmod key dir: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) - - settled, findings := settleUncertainPublications(fx.repo, fx.key) - if len(settled) != 0 { - t.Fatalf("settled = %v, want none when the write never landed", settled) - } - if len(findings) != 1 || findings[0] != "mutation-settle-failed" { - t.Fatalf("findings = %v, want [mutation-settle-failed]", findings) - } - if err := os.Chmod(dir, 0o700); err != nil { - t.Fatalf("restore key dir: %v", err) - } - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if ep.AdmittedMutations[0].Status != mutationStatusUncertain { - t.Fatal("a failed settlement write must leave the original entry uncertain") - } -} - -// TestSettlementNeverDowngradesUnderRacingCallback (change 0444 acceptance 6): a -// completion callback racing the settlement (both under the epoch CAS) can never -// regress completed→uncertain or lose its own completed write, and an unrelated -// entry appended between match and write is never cleared — the settlement -// re-derives its matches from the fresh record under the lock. -func TestSettlementNeverDowngradesUnderRacingCallback(t *testing.T) { - fx := newCancelFixture(t, false) - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - other := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/other", HeadCommit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} - if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.AdmittedMutations = []AdmittedMutation{ - {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, - {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, - } - return nil - }); err != nil { - t.Fatalf("seed: %v", err) - } - // An unrelated admission lands right before settlement runs. - if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.AdmittedMutations = append(r.AdmittedMutations, - AdmittedMutation{OpKey: OperationWorkspacePublish, Status: mutationStatusAdmitted, Publication: &other}) - return nil - }); err != nil { - t.Fatalf("append racer: %v", err) - } - settled, findings := settleUncertainPublications(fx.repo, fx.key) - if len(findings) != 0 || len(settled) != 1 { - t.Fatalf("settled=%v findings=%v, want one settlement and no finding", settled, findings) - } - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord: %v", err) - } - if ep.AdmittedMutations[2].Status != mutationStatusAdmitted { - t.Fatal("the racing unrelated admission must be untouched") - } - if ep.AdmittedMutations[0].Status != mutationStatusCompleted || ep.AdmittedMutations[1].Status != mutationStatusCompleted { - t.Fatal("the matched original must be settled and a completed entry must never be downgraded") - } - - // Deterministic ordering: settlement BEFORE the retry's completion callback - // lands settles nothing (the retry is still admitted, so it is no evidence); - // the callback then lands completed and a repeat settlement converges. - d2 := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w2", HeadCommit: "cccccccccccccccccccccccccccccccccccccccc"} - origDone, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &d2) - if err != nil { - t.Fatalf("admit original: %v", err) - } - origDone(mutationStatusUncertain, false) - retryDone, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &d2) - if err != nil { - t.Fatalf("admit retry: %v", err) - } - if s, f := settleUncertainPublications(fx.repo, fx.key); len(s) != 0 || len(f) != 0 { - t.Fatalf("settled=%v findings=%v before the retry completed, want none", s, f) - } - retryDone(mutationStatusCompleted, true) - if s, f := settleUncertainPublications(fx.repo, fx.key); len(s) != 1 || len(f) != 0 { - t.Fatalf("settled=%v findings=%v after the retry completed, want one settlement", s, f) - } - ep, _, err = LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord (ordering): %v", err) - } - for i := 3; i <= 4; i++ { - if ep.AdmittedMutations[i].Status != mutationStatusCompleted { - t.Fatalf("entry %d = %q after ordered callback + settlement, want completed", i, ep.AdmittedMutations[i].Status) - } - } - - // Real parallelism: each round journals an uncertain original, an in-flight - // identical retry, and an unrelated in-flight admission through the REAL - // admission gate, then races eight settlements against the retry's completion - // callback and four fresh unrelated admissions. The flock-serialized CAS must - // keep every invariant in every round: the callback's completed write is never - // lost or downgraded, no racing admission is dropped by a settlement write, - // the unrelated admissions and every earlier entry are untouched, and the - // original is only ever uncertain or completed. - for round := range 12 { - rd := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/round", HeadCommit: fmt.Sprintf("%040x", round+1)} - ru := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/unrelated", HeadCommit: fmt.Sprintf("%040x", round+1)} - before, _, lerr := LoadEpochRecord(fx.repo, fx.key) - if lerr != nil { - t.Fatalf("round %d: load: %v", round, lerr) - } - base := len(before.AdmittedMutations) - od, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &rd) - if aerr != nil { - t.Fatalf("round %d: admit original: %v", round, aerr) - } - od(mutationStatusUncertain, false) - rdone, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &rd) - if aerr != nil { - t.Fatalf("round %d: admit retry: %v", round, aerr) - } - if _, aerr := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &ru); aerr != nil { - t.Fatalf("round %d: admit unrelated: %v", round, aerr) - } - - start := make(chan struct{}) - var ( - wg sync.WaitGroup - mu sync.Mutex - raceFind []string - ) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - <-start - _, f := settleUncertainPublications(fx.repo, fx.key) - mu.Lock() - raceFind = append(raceFind, f...) - mu.Unlock() - }() - } - wg.Add(1) - go func() { - defer wg.Done() - <-start - rdone(mutationStatusCompleted, true) - }() - // Unrelated admissions APPENDED during the race: a settlement that wrote a - // record matched outside the lock (a stale snapshot) would silently drop them. - const racers = 4 - var admitErrs []error - for k := range racers { - wg.Add(1) - go func() { - defer wg.Done() - <-start - cp := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: fmt.Sprintf("refs/heads/fix/racer-%d", k), HeadCommit: rd.HeadCommit} - if _, err := admitWorkflowMutation(fx.worktree, OperationWorkspacePublish, &cp); err != nil { - mu.Lock() - admitErrs = append(admitErrs, err) - mu.Unlock() - } - }() - } - close(start) - wg.Wait() - if len(raceFind) != 0 { - t.Fatalf("round %d: concurrent settlement findings = %v, want none (the lock serializes, never fails)", round, raceFind) - } - if len(admitErrs) != 0 { - t.Fatalf("round %d: racing admissions failed: %v", round, admitErrs) - } - - got, _, lerr := LoadEpochRecord(fx.repo, fx.key) - if lerr != nil { - t.Fatalf("round %d: load after race: %v", round, lerr) - } - if len(got.AdmittedMutations) != base+3+racers { - t.Fatalf("round %d: journal length = %d, want %d (a racing admission was lost)", round, len(got.AdmittedMutations), base+3+racers) - } - seen := map[string]bool{} - for _, m := range got.AdmittedMutations[base+3:] { - if m.Status != mutationStatusAdmitted || m.Publication == nil { - t.Fatalf("round %d: racing admission = %+v, want an untouched admitted entry", round, m) - } - seen[m.Publication.HeadRef] = true - } - if len(seen) != racers { - t.Fatalf("round %d: racing admissions present = %v, want %d distinct", round, seen, racers) - } - for i := range base { - if got.AdmittedMutations[i].Status != before.AdmittedMutations[i].Status { - t.Fatalf("round %d: earlier entry %d changed %q -> %q", round, i, - before.AdmittedMutations[i].Status, got.AdmittedMutations[i].Status) - } - } - if s := got.AdmittedMutations[base].Status; s != mutationStatusUncertain && s != mutationStatusCompleted { - t.Fatalf("round %d: original = %q, want uncertain or completed", round, s) - } - if s := got.AdmittedMutations[base+1].Status; s != mutationStatusCompleted { - t.Fatalf("round %d: retry = %q after its completion callback, want completed (a lost or downgraded callback write)", round, s) - } - if s := got.AdmittedMutations[base+2].Status; s != mutationStatusAdmitted { - t.Fatalf("round %d: unrelated admission = %q, want admitted (untouched)", round, s) - } - - // Convergence: whatever the interleaving, one more settlement settles it. - if _, f := settleUncertainPublications(fx.repo, fx.key); len(f) != 0 { - t.Fatalf("round %d: convergence findings = %v", round, f) - } - conv, _, lerr := LoadEpochRecord(fx.repo, fx.key) - if lerr != nil { - t.Fatalf("round %d: load after convergence: %v", round, lerr) - } - if s := conv.AdmittedMutations[base].Status; s != mutationStatusCompleted { - t.Fatalf("round %d: original = %q after convergence, want completed", round, s) - } - } -} - -// TestSettlementInterruptionConverges (change 0444 acceptance 6): a settlement -// whose durable write cannot land never lets cancellation claim `cancelled` — the -// entry stays uncertain, exclusion is retained, and the bounded finding names the -// failure — and once the record is writable again, repeating the SAME cancel -// converges. (An interruption AFTER a successful write is a harmless idempotent -// replay, proven by TestSettleUncertainPublicationsDurable's replay assert.) -func TestSettlementInterruptionConverges(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root ignores directory write permission") - } - fx := newCancelFixture(t, true) - desc := MutationPublication{RepoDir: "/repo/.git", Remote: "origin", - HeadRef: "refs/heads/fix/w", HeadCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - if err := epochCAS(fx.repo, fx.key, func(r *EpochRecord) error { - r.AdmittedMutations = []AdmittedMutation{ - {OpKey: OperationWorkspacePublish, Status: mutationStatusUncertain, Publication: &desc}, - {OpKey: OperationWorkspacePublish, Status: mutationStatusCompleted, Verified: true, Publication: &desc}, - } - return nil - }); err != nil { - t.Fatalf("seed: %v", err) - } - // A read-only key dir still lets the CAS lock and read, but the same-directory - // temp file cannot be created, so every epoch write fails. - dir := filepath.Join(fx.common, "docket", "rungate", fx.key) - t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) - originalStatus := func(when string) string { - t.Helper() - ep, _, err := LoadEpochRecord(fx.repo, fx.key) - if err != nil { - t.Fatalf("LoadEpochRecord (%s): %v", when, err) - } - return ep.AdmittedMutations[0].Status - } - - // (a) Unwritable before the cancel: the fence itself cannot land, so the - // cancel refuses — never cancelled — and the entry and epoch are untouched. - if err := os.Chmod(dir, 0o500); err != nil { - t.Fatalf("chmod: %v", err) - } - stopper := &fakeCancelStopper{proven: map[string]bool{fx.runDir: true}} - seams := cancelSeams{store: fx.store, stopper: stopper, launches: okLaunchReconciler()} - pre := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") - if pre.Disposition == CancelDispositionCancelled { - t.Fatalf("disposition = cancelled with an unwritable epoch; findings=%v", pre.Findings) - } - if s := originalStatus("unwritable fence"); s != mutationStatusUncertain { - t.Fatalf("original = %q after a failed fence, want uncertain", s) - } - if st := loadEpochState(t, fx.repo, fx.key); st != EpochActive { - t.Fatalf("epoch state = %q after a failed fence, want active (nothing landed)", st) - } - if err := os.Chmod(dir, 0o700); err != nil { - t.Fatalf("chmod back: %v", err) - } - - // (b) Interrupted between the fence and the settlement: the fence lands, then - // the store turns unwritable during teardown (the process stop), so ONLY the - // settlement write fails. Cancellation must stay pending with the bounded - // finding, report no settlement, and leave the entry uncertain. - stopper.onStop = func(string) { - if err := os.Chmod(dir, 0o500); err != nil { - t.Errorf("chmod mid-teardown: %v", err) - } - } - res := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") - if res.Disposition != CancelDispositionPending { - t.Fatalf("disposition = %q with an unpersistable settlement (findings %v), want cancellation-pending", res.Disposition, res.Findings) - } - if !hasFinding(res.Findings, "mutation-settle-failed") { - t.Fatalf("findings = %v, want mutation-settle-failed", res.Findings) - } - if !hasFinding(res.Findings, "mutation-pending:"+OperationWorkspacePublish) { - t.Fatalf("findings = %v, want mutation-pending:workspace.publish (exclusion retained)", res.Findings) - } - if hasFinding(res.Findings, "mutation-settled") { - t.Fatalf("findings = %v; a settlement that never landed must not be reported", res.Findings) - } - if err := os.Chmod(dir, 0o700); err != nil { - t.Fatalf("chmod back: %v", err) - } - if s := originalStatus("interrupted settlement"); s != mutationStatusUncertain { - t.Fatalf("original = %q after a failed settlement write, want uncertain", s) - } - if st := loadEpochState(t, fx.repo, fx.key); st != EpochCancelling { - t.Fatalf("epoch state = %q, want cancelling (the fence is durably held)", st) - } - - // (c) Writable again: the SAME repeat cancel converges. - stopper.onStop = nil - res2 := runCancel(seams, fx.repo, fx.key, fx.epochID, "human stop") - if res2.Disposition != CancelDispositionCancelled { - t.Fatalf("repeat disposition = %q (findings %v), want cancelled", res2.Disposition, res2.Findings) - } - if s := originalStatus("repeat cancel"); s != mutationStatusCompleted { - t.Fatalf("original = %q after the converged repeat cancel, want completed", s) - } -} diff --git a/internal/app/rungate_store_helpers_test.go b/internal/app/rungate_store_helpers_test.go new file mode 100644 index 000000000..dc05584a6 --- /dev/null +++ b/internal/app/rungate_store_helpers_test.go @@ -0,0 +1,55 @@ +package app + +import ( + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// Run-gate store test helpers shared with default-build (untagged) test files. +// The gate-record store tests themselves live behind the integration tag in +// rungate_store_integration_test.go (change 0465); these fixtures stay untagged +// because other untagged test files still reference them. + +// newGateRepo initializes a temp git repo with a deterministic identity and one +// seed commit (a commit is required before `git worktree add` can attach a +// linked worktree). It returns the repo's working-tree path. +func newGateRepo(t *testing.T) string { + t.Helper() + requireRealGit(t) + dir := testsupport.TempDir(t) + runGit(t, dir, "init") + gitIdentity(t, dir) + writeRepoFile(t, dir, "seed.txt", "seed\n") + runGit(t, dir, "add", "seed.txt") + runGit(t, dir, "commit", "-m", "seed") + return dir +} + +// sampleGateRecord is a fully-populated non-authoritative record (Schema and +// Repo are stamped by the store, so they are left zero here). AttemptLimit is +// stamped to 2 — the historical single-retry default — so fixtures minted from +// this record preserve their pre-0421 one-retry semantics (change 0421). +func sampleGateRecord() GateRecord { + return GateRecord{ + Target: "docket-implement-next", + CreatedAt: 1700000000, + DispatchEpoch: 1700000005, + BeforeIDs: []int{12, 34, 56}, + AttributedID: 0, + Retry: RetryUnused, + Disposition: "gate-armed", + Terminal: false, + AttemptLimit: 2, + } +} + +// mintGateWithHash mints a record carrying ChildContextHash, optionally terminal. +func mintGateWithHash(t *testing.T, repoDir, hash string, terminal bool) string { + t.Helper() + key, err := MintGateRecord(repoDir, GateRecord{Target: "docket-implement-next", Retry: RetryUnused, ChildContextHash: hash, Terminal: terminal, AttemptLimit: 2}) + if err != nil { + t.Fatalf("MintGateRecord: %v", err) + } + return key +} diff --git a/internal/app/rungate_store_test.go b/internal/app/rungate_store_integration_test.go similarity index 75% rename from internal/app/rungate_store_test.go rename to internal/app/rungate_store_integration_test.go index fa8be83d7..430d61304 100644 --- a/internal/app/rungate_store_test.go +++ b/internal/app/rungate_store_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -5,8 +7,6 @@ import ( "os" "path/filepath" "testing" - - "github.com/danielhanold/docket/internal/testsupport" ) // These are the durable gate-record store tests (change 0334, Task 1). Each @@ -15,39 +15,8 @@ import ( // common-dir rooting, cross-repo refusal, and linked-worktree resolution are // exercised against real git, not a mock. The store is the generalization of // scripts/lib/docket-dispatch-dir.sh's durable-dir conventions. - -// newGateRepo initializes a temp git repo with a deterministic identity and one -// seed commit (a commit is required before `git worktree add` can attach a -// linked worktree). It returns the repo's working-tree path. -func newGateRepo(t *testing.T) string { - t.Helper() - requireRealGit(t) - dir := testsupport.TempDir(t) - runGit(t, dir, "init") - gitIdentity(t, dir) - writeRepoFile(t, dir, "seed.txt", "seed\n") - runGit(t, dir, "add", "seed.txt") - runGit(t, dir, "commit", "-m", "seed") - return dir -} - -// sampleGateRecord is a fully-populated non-authoritative record (Schema and -// Repo are stamped by the store, so they are left zero here). AttemptLimit is -// stamped to 2 — the historical single-retry default — so fixtures minted from -// this record preserve their pre-0421 one-retry semantics (change 0421). -func sampleGateRecord() GateRecord { - return GateRecord{ - Target: "docket-implement-next", - CreatedAt: 1700000000, - DispatchEpoch: 1700000005, - BeforeIDs: []int{12, 34, 56}, - AttributedID: 0, - Retry: RetryUnused, - Disposition: "gate-armed", - Terminal: false, - AttemptLimit: 2, - } -} +// The fixtures untagged test files share (newGateRepo, sampleGateRecord, +// mintGateWithHash) live in rungate_store_helpers_test.go (change 0465). // repeat returns s repeated n times (a tiny local helper so the malformed-key // case can build an over-long key without importing strings just for this). @@ -71,20 +40,10 @@ func mintPlainGate(t *testing.T, repoDir string) string { return key } -// mintGateWithHash mints a record carrying ChildContextHash, optionally terminal. -func mintGateWithHash(t *testing.T, repoDir, hash string, terminal bool) string { - t.Helper() - key, err := MintGateRecord(repoDir, GateRecord{Target: "docket-implement-next", Retry: RetryUnused, ChildContextHash: hash, Terminal: terminal, AttemptLimit: 2}) - if err != nil { - t.Fatalf("MintGateRecord: %v", err) - } - return key -} - -// TestGateSchemaV2RecordFailsClosed: a hand-written schema-2 record (the pre-0407 +// TestIntegrationGateEpochGateSchemaV2RecordFailsClosed: a hand-written schema-2 record (the pre-0407 // shape whose AttributedID may be an inferred guess) must fail closed on load as // corrupt-record — never a silent migration that blesses an old guessed id. -func TestGateSchemaV2RecordFailsClosed(t *testing.T) { +func TestIntegrationGateEpochGateSchemaV2RecordFailsClosed(t *testing.T) { repo := newGateRepo(t) key, err := MintGateRecord(repo, GateRecord{Target: "docket-implement-next", Retry: RetryUnused, AttemptLimit: 2}) if err != nil { @@ -107,10 +66,10 @@ func TestGateSchemaV2RecordFailsClosed(t *testing.T) { } } -// TestReserveGateClaimIsBindOnce: the first reservation wins; a different +// TestIntegrationGateEpochReserveGateClaimIsBindOnce: the first reservation wins; a different // (change, request) under the same key is refused binding-conflict; an // identical replay is a no-op. -func TestReserveGateClaimIsBindOnce(t *testing.T) { +func TestIntegrationGateEpochReserveGateClaimIsBindOnce(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) if err := ReserveGateClaim(repo, key, 3, "claim-3-aaa"); err != nil { @@ -126,10 +85,10 @@ func TestReserveGateClaimIsBindOnce(t *testing.T) { } } -// TestConfirmGateClaimMirrorsRecord: confirm finalizes the binding and mirrors +// TestIntegrationGateEpochConfirmGateClaimMirrorsRecord: confirm finalizes the binding and mirrors // AttributedID/BoundRequestID/BoundRevision onto the record; a mismatched // confirm is binding-conflict; a re-confirm is idempotent. -func TestConfirmGateClaimMirrorsRecord(t *testing.T) { +func TestIntegrationGateEpochConfirmGateClaimMirrorsRecord(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) if err := ReserveGateClaim(repo, key, 3, "claim-3-aaa"); err != nil { @@ -157,10 +116,10 @@ func TestConfirmGateClaimMirrorsRecord(t *testing.T) { } } -// TestConfirmWithoutReservationFails: a failed or absent reservation can never +// TestIntegrationGateEpochConfirmWithoutReservationFails: a failed or absent reservation can never // become a confirmed binding (spec: "Failed claims never become confirmed // bindings"). -func TestConfirmWithoutReservationFails(t *testing.T) { +func TestIntegrationGateEpochConfirmWithoutReservationFails(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) if err := ConfirmGateClaim(repo, key, 3, "claim-3-aaa", "deadbeef", ""); err == nil { @@ -168,9 +127,9 @@ func TestConfirmWithoutReservationFails(t *testing.T) { } } -// TestLoadGateClaimBindingCorruptFailsClosed: unparseable binding bytes are a +// TestIntegrationGateEpochLoadGateClaimBindingCorruptFailsClosed: unparseable binding bytes are a // typed corrupt-record error, never (ok=false, nil). -func TestLoadGateClaimBindingCorruptFailsClosed(t *testing.T) { +func TestIntegrationGateEpochLoadGateClaimBindingCorruptFailsClosed(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) common, _ := gateGitCommonDir(repo) @@ -184,10 +143,10 @@ func TestLoadGateClaimBindingCorruptFailsClosed(t *testing.T) { } } -// TestFindGateRecordByContextHash: exactly-one non-terminal match resolves; +// TestIntegrationGateEpochFindGateRecordByContextHash: exactly-one non-terminal match resolves; // zero is not-found; two armed gates sharing a hash is context-ambiguous; // a terminal record does not match. -func TestFindGateRecordByContextHash(t *testing.T) { +func TestIntegrationGateEpochFindGateRecordByContextHash(t *testing.T) { repo := newGateRepo(t) keyA := mintGateWithHash(t, repo, "ha", false) _ = mintGateWithHash(t, repo, "hb", false) @@ -212,10 +171,10 @@ func TestFindGateRecordByContextHash(t *testing.T) { // --- counted per-attempt retry budget and schema v4 (change 0421) --- -// TestConsumeGateRetryPerAttemptCAS: with limit 3, distinct attempts each grant +// TestIntegrationGateEpochConsumeGateRetryPerAttemptCAS: with limit 3, distinct attempts each grant // their own marker exactly once, a repeat of a spent attempt refuses, and an // attempt at or above the limit refuses WITHOUT creating a marker. -func TestConsumeGateRetryPerAttemptCAS(t *testing.T) { +func TestIntegrationGateEpochConsumeGateRetryPerAttemptCAS(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) @@ -244,9 +203,9 @@ func TestConsumeGateRetryPerAttemptCAS(t *testing.T) { } } -// TestConsumeGateRetryLimitOne: a limit of 1 disables retries — attempt 1 is +// TestIntegrationGateEpochConsumeGateRetryLimitOne: a limit of 1 disables retries — attempt 1 is // already at the limit, so nothing is granted and no marker is created. -func TestConsumeGateRetryLimitOne(t *testing.T) { +func TestIntegrationGateEpochConsumeGateRetryLimitOne(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) if ok, err := ConsumeGateRetry(repo, key, 1, 1); err != nil || ok { @@ -257,11 +216,11 @@ func TestConsumeGateRetryLimitOne(t *testing.T) { } } -// TestGateRetryUsageCountsLegacyMarker: a bare legacy `retry-consumed` marker +// TestIntegrationGateEpochGateRetryUsageCountsLegacyMarker: a bare legacy `retry-consumed` marker // (schema v3's single-permit name) counts as one consumed marker and is read as // the attempt-1 marker, so an already-consumed legacy permit can never be // re-granted — an older consumed marker must never read as unused budget. -func TestGateRetryUsageCountsLegacyMarker(t *testing.T) { +func TestIntegrationGateEpochGateRetryUsageCountsLegacyMarker(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) common, _ := gateGitCommonDir(repo) @@ -277,10 +236,10 @@ func TestGateRetryUsageCountsLegacyMarker(t *testing.T) { } } -// TestLoadGateRecordRefusesV3: a v3-shaped record fails closed on load with the +// TestIntegrationGateEpochLoadGateRecordRefusesV3: a v3-shaped record fails closed on load with the // schema-mismatch diagnostic — the v4 store never silently migrates an older // record whose consumed state could be reinterpreted as unused budget. -func TestLoadGateRecordRefusesV3(t *testing.T) { +func TestIntegrationGateEpochLoadGateRecordRefusesV3(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) rec, err := LoadGateRecord(repo, key) @@ -300,10 +259,10 @@ func TestLoadGateRecordRefusesV3(t *testing.T) { } } -// TestSaveGateRecordRefusesUnstampedLimit: a v4 record whose AttemptLimit is +// TestIntegrationGateEpochSaveGateRecordRefusesUnstampedLimit: a v4 record whose AttemptLimit is // below the floor is a corrupt/unstamped record and must fail closed on the write // boundary, exactly like a partial continuation triple or claim-binding pair. -func TestSaveGateRecordRefusesUnstampedLimit(t *testing.T) { +func TestIntegrationGateEpochSaveGateRecordRefusesUnstampedLimit(t *testing.T) { repo := newGateRepo(t) key := mintPlainGate(t, repo) rec, err := LoadGateRecord(repo, key) diff --git a/internal/app/rungate_verdict.go b/internal/app/rungate_verdict.go index 0930d75b6..43d961fd9 100644 --- a/internal/app/rungate_verdict.go +++ b/internal/app/rungate_verdict.go @@ -333,7 +333,7 @@ func RunGateVerdict(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, // on. Only a genuinely quiescent incomplete — no scope, or zero candidate // drives — falls through to the retry CAS below. [ORDERING MUTATION: moving // the ConsumeGateRetry call above this check spends the retry on a continuable - // run — see TestVerdictIncompleteWithTrackedDriveContinuesWithoutRetry.] + // run — see TestIntegrationGateVerdictVerdictIncompleteWithTrackedDriveContinuesWithoutRetry.] if rec.ScopeID != "" { if res, handled := gateOuterContinuation(ctx, deps, wdeps, gdeps, repoDir, key, rec, id); handled { return res @@ -768,7 +768,7 @@ func gateRecoveredWorktree(ctx context.Context, deps PlanningDeps, repoDir strin // save and the bind are best-effort — the committed claim receipt is authority, and // production is already protected by the resolved change id + context-hash filter // and the verified parent capability. [MUTATION: dropping the BindScopeChange call -// leaves the fresh-run scope unbound — see TestVerdictFreshRunBindsScopeChange.] +// leaves the fresh-run scope unbound — see TestIntegrationGateVerdictVerdictFreshRunBindsScopeChange.] func gateAdoptOwnership(wdeps WorkspaceDeps, repoDir, key string, rec *GateRecord, changeID int, requestID, revision string) { if rec.AttributedID != 0 { return diff --git a/internal/app/rungate_verdict_helpers_test.go b/internal/app/rungate_verdict_helpers_test.go new file mode 100644 index 000000000..a37587c1f --- /dev/null +++ b/internal/app/rungate_verdict_helpers_test.go @@ -0,0 +1,193 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gatedrive" + "github.com/danielhanold/docket/internal/repository" +) + +// Run-gate verdict test helpers shared with default-build (untagged) test files. +// The verdict tests themselves live behind the integration tag in +// rungate_verdict_integration_test.go (change 0465); these fixtures, fakes and +// probes stay untagged because other untagged test files still reference them. + +const gateDefaultClaimedAt = "2026-08-02T00:00:00Z" + +// gateInProgressBlob builds an in-progress change blob whose claimed_at is the +// default stamp ("keep"), removed (""), or replaced with the given raw value. +func gateInProgressBlob(id int, slug, claimedAt string) StatusBlob { + src := lifecycleChange(id, slug, "in-progress") + const def = "claimed_at: " + gateDefaultClaimedAt + switch claimedAt { + case "keep": + // leave the default stamp in place + case "": + src = strings.Replace(src, def+"\n", "", 1) + default: + src = strings.Replace(src, def, "claimed_at: "+claimedAt, 1) + } + return StatusBlob{ + Kind: repository.KindChange, + Location: repository.LocationActive, + Path: groomPath(id, slug), + Version: miVersion, + Data: []byte(src), + } +} + +// gateIncompleteRecord renders an in-progress change 3 carrying a valid pr: and +// linkage, so the ONLY unmet postcondition RunVerify reports is not-implemented +// (the run is claimed but not yet marked implemented). It lets the retry-mapping +// tests assert an exact single-conjunct report line. +func gateIncompleteRecord() []byte { + src := string(rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug)) + src = strings.Replace(src, "blocked_by:\n", "pr: '"+rvRecordedPR()+"'\nblocked_by:\n", 1) + return []byte(src) +} + +// gateMintArmed mints an armed record (Retry unused, no attribution yet) with the +// given before-set, dispatch epoch, and child-context hash, as gate-before would. +// Since change 0407 the before-set and dispatch epoch are diagnostics only (they +// no longer create attribution); hash is the record's ChildContextHash, the seam +// the verdict path's proof filter keys on. +func gateMintArmed(t *testing.T, repoDir string, beforeIDs []int, dispatchEpoch int64, hash string) string { + t.Helper() + key, err := MintGateRecord(repoDir, GateRecord{ + Target: "docket-implement-next", + CreatedAt: 1, + DispatchEpoch: dispatchEpoch, + BeforeIDs: beforeIDs, + ChildContextHash: hash, + Retry: RetryUnused, + Disposition: "gate-armed", + AttemptLimit: 2, + }) + if err != nil { + t.Fatalf("MintGateRecord: %v", err) + } + return key +} + +// fakeProofScanner is the injected ClaimProofScanner for the verdict path's +// ownership resolution (change 0407): it returns canned proofs newest-first, or an +// error. A nil scanner (not this fake) is the fail-closed proof-unavailable case. +type fakeProofScanner struct { + proofs []ClaimProof + err error +} + +func (f *fakeProofScanner) ScanClaimProofs(context.Context, string) ([]ClaimProof, error) { + return f.proofs, f.err +} + +// gateRetryMarkerExists reports whether the O_EXCL retry marker for key exists on +// disk. It reads the FILESYSTEM (never a mock), so a continuation that must never +// reach the retry CAS is a real, provable property. +func gateRetryMarkerExists(t *testing.T, repoDir, key string) bool { + t.Helper() + common, err := gateGitCommonDir(repoDir) + if err != nil { + t.Fatalf("gateGitCommonDir: %v", err) + } + _, serr := os.Stat(filepath.Join(common, "docket", "rungate", key, gateRetryMarkerName)) + if serr != nil && !os.IsNotExist(serr) { + t.Fatalf("stat retry marker: %v", serr) + } + return serr == nil +} + +// verdictCompletionFixture is one prepared run whose keyed verdict verifies +// run-complete AND whose epoch ownership is ready to close out. +type verdictCompletionFixture struct { + repo, key, epochID, worktree string + store *gatedrive.Store + deps PlanningDeps + wdeps WorkspaceDeps + gdeps GitHubDeps + observer *fakeProcessObserver + launchObserver *fakeLaunchObserver +} + +// seams returns the injected completion seam bundle for a direct completeSuccessfulRun +// call (used to pre-drive the closeout before a persistence-fault replay test). +func (fx verdictCompletionFixture) seams() cancelSeams { + return cancelSeams{store: fx.store, observer: fx.observer, launchObserver: fx.launchObserver} +} + +func newVerdictCompletionFixture(t *testing.T) verdictCompletionFixture { + t.Helper() + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), + rvPR(f.head, string(prEvidenceBytes(t, f.head))), + ) + repo := f.repo.invocation + common, err := gateGitCommonDir(repo) + if err != nil { + t.Fatalf("gateGitCommonDir: %v", err) + } + key := gateMintArmed(t, repo, nil, 1, "ha") + if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := ConfirmGateClaim(repo, key, 3, "claim-3-v", "r1", ""); err != nil { + t.Fatalf("confirm: %v", err) + } + wdeps.ClaimProofs = &fakeProofScanner{proofs: []ClaimProof{ + {RequestID: "claim-3-v", ChangeID: 3, GateContextHash: "ha", Revision: "r1"}, + }} + + ep, err := MintEpochRecord(repo, key, "3") + if err != nil { + t.Fatalf("MintEpochRecord: %v", err) + } + worktree := filepath.Join(repo, "feature-wt") + if err := os.MkdirAll(worktree, 0o755); err != nil { + t.Fatalf("mkdir worktree: %v", err) + } + if err := epochCAS(repo, key, func(r *EpochRecord) error { + r.Worktree = worktree + return nil + }); err != nil { + t.Fatalf("epochCAS set worktree: %v", err) + } + store := gatedrive.OpenStore(common) + // A released epoch-owned slot (the run's drives are done) is exactly what the + // closeout retires — reserve+confirm+release, mirroring the cancel/completion + // fixtures. Release retains RunEpochID (between-drive ownership), so the slot is + // slotOwned+released until the closeout detaches it. + runDir := filepath.Join(worktree, "run-1") + token, terr := store.ReserveWorktreeExecutionForEpoch(common, worktree, ep.EpochID, nil) + if terr != nil { + t.Fatalf("ReserveWorktreeExecutionForEpoch: %v", terr) + } + if cerr := store.ConfirmWorktreeExecution(worktree, token, "run-1", runDir); cerr != nil { + t.Fatalf("ConfirmWorktreeExecution: %v", cerr) + } + slot, _, lerr := store.LoadWorktreeExecution(worktree) + if lerr != nil { + t.Fatalf("LoadWorktreeExecution: %v", lerr) + } + if rerr := store.ReleaseWorktreeExecution(worktree, slot.ReservationToken); rerr != nil { + t.Fatalf("ReleaseWorktreeExecution: %v", rerr) + } + must(t, RegisterEpochParticipant(repo, key, ep.EpochID, + EpochParticipant{Kind: "coordinator", NativeHandle: "turn-1"})) + must(t, RecordEpochParticipantTerminal(repo, key, ep.EpochID, + "turn-1", "t1", ParticipantTerminalCompleted)) + + observer := &fakeProcessObserver{defaultProven: true} + launchObserver := &fakeLaunchObserver{report: gatedrive.EpochLaunchReport{Accounted: true}} + wdeps.CancelSeams = func(string) cancelSeams { + return cancelSeams{store: store, observer: observer, launchObserver: launchObserver} + } + return verdictCompletionFixture{ + repo: repo, key: key, epochID: ep.EpochID, worktree: worktree, store: store, + deps: deps, wdeps: wdeps, gdeps: gdeps, observer: observer, launchObserver: launchObserver, + } +} diff --git a/internal/app/rungate_verdict_test.go b/internal/app/rungate_verdict_integration_test.go similarity index 81% rename from internal/app/rungate_verdict_test.go rename to internal/app/rungate_verdict_integration_test.go index 15f430cdc..acd511034 100644 --- a/internal/app/rungate_verdict_test.go +++ b/internal/app/rungate_verdict_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -10,7 +12,6 @@ import ( "testing" "time" - "github.com/danielhanold/docket/internal/gatedrive" "github.com/danielhanold/docket/internal/repository" ) @@ -28,8 +29,6 @@ import ( // RunVerify delegation is driven by the run_verify_test.go fixtures (rvFixture, // rvRecord, rvInProgressRecord, rvPR, rvAgreeingReceipt). -const gateDefaultClaimedAt = "2026-08-02T00:00:00Z" - // gateClaimEpoch is the Unix epoch of gateDefaultClaimedAt — the claim instant // lifecycleChange stamps on an in-progress record. Attribution filter (c) // compares a candidate's claimed_at against the record's DispatchEpoch, so tests @@ -43,38 +42,6 @@ func gateClaimEpoch(t *testing.T) int64 { return tm.Unix() } -// gateInProgressBlob builds an in-progress change blob whose claimed_at is the -// default stamp ("keep"), removed (""), or replaced with the given raw value. -func gateInProgressBlob(id int, slug, claimedAt string) StatusBlob { - src := lifecycleChange(id, slug, "in-progress") - const def = "claimed_at: " + gateDefaultClaimedAt - switch claimedAt { - case "keep": - // leave the default stamp in place - case "": - src = strings.Replace(src, def+"\n", "", 1) - default: - src = strings.Replace(src, def, "claimed_at: "+claimedAt, 1) - } - return StatusBlob{ - Kind: repository.KindChange, - Location: repository.LocationActive, - Path: groomPath(id, slug), - Version: miVersion, - Data: []byte(src), - } -} - -// gateIncompleteRecord renders an in-progress change 3 carrying a valid pr: and -// linkage, so the ONLY unmet postcondition RunVerify reports is not-implemented -// (the run is claimed but not yet marked implemented). It lets the retry-mapping -// tests assert an exact single-conjunct report line. -func gateIncompleteRecord() []byte { - src := string(rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug)) - src = strings.Replace(src, "blocked_by:\n", "pr: '"+rvRecordedPR()+"'\nblocked_by:\n", 1) - return []byte(src) -} - // gateLightDeps wires a planning-only deps set over the given corpus (no git // client, no workspace/github seams) for attribution paths that never reach // RunVerify. @@ -83,29 +50,6 @@ func gateLightDeps(t *testing.T, corpus []StatusBlob) PlanningDeps { return PlanningDeps{Reader: &fakeReader{pin: mainPin(t), corpus: corpus}, Clock: testClock()} } -// gateMintArmed mints an armed record (Retry unused, no attribution yet) with the -// given before-set, dispatch epoch, and child-context hash, as gate-before would. -// Since change 0407 the before-set and dispatch epoch are diagnostics only (they -// no longer create attribution); hash is the record's ChildContextHash, the seam -// the verdict path's proof filter keys on. -func gateMintArmed(t *testing.T, repoDir string, beforeIDs []int, dispatchEpoch int64, hash string) string { - t.Helper() - key, err := MintGateRecord(repoDir, GateRecord{ - Target: "docket-implement-next", - CreatedAt: 1, - DispatchEpoch: dispatchEpoch, - BeforeIDs: beforeIDs, - ChildContextHash: hash, - Retry: RetryUnused, - Disposition: "gate-armed", - AttemptLimit: 2, - }) - if err != nil { - t.Fatalf("MintGateRecord: %v", err) - } - return key -} - // gateMintAttributed mints a record already attributed to id — the state a second // gate-verdict call reads after a first call attributed the claim. func gateMintAttributed(t *testing.T, repoDir string, id int) string { @@ -321,13 +265,13 @@ func gateMintAttributedScopedLimit(t *testing.T, repoDir, scopeID, parentCap, ch return key } -// TestVerdictIncompleteRespectsAttemptLimit is the counted-budget heart (change +// TestIntegrationGateVerdictVerdictIncompleteRespectsAttemptLimit is the counted-budget heart (change // 0421): a quiescent run-incomplete grants at most AttemptLimit-1 gate-retry-once, // each on a distinct attempt transition, then a terminal gate-stop. limit 1 grants // none; limit 2 grants one; limit 4 grants exactly three. The report TOKENS are // unchanged (gate-retry-once / gate-stop … run-incomplete ); the // used/limit surface is the additive AttemptsUsed/AttemptLimit result fields. -func TestVerdictIncompleteRespectsAttemptLimit(t *testing.T) { +func TestIntegrationGateVerdictVerdictIncompleteRespectsAttemptLimit(t *testing.T) { cases := []struct { limit int wantRetries int @@ -377,11 +321,11 @@ func TestVerdictIncompleteRespectsAttemptLimit(t *testing.T) { } } -// TestVerdictIncompleteRepeatObservationDoesNotDoubleGrant: after a gate-retry-once +// TestIntegrationGateVerdictVerdictIncompleteRepeatObservationDoesNotDoubleGrant: after a gate-retry-once // for attempt 1 (default limit 2), a second verdict call WITHOUT a new attempt // completing is the terminal gate-stop — the budget is spent — and GateRetryUsage // stays 1 (no second marker). -func TestVerdictIncompleteRepeatObservationDoesNotDoubleGrant(t *testing.T) { +func TestIntegrationGateVerdictVerdictIncompleteRepeatObservationDoesNotDoubleGrant(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -402,14 +346,14 @@ func TestVerdictIncompleteRepeatObservationDoesNotDoubleGrant(t *testing.T) { } } -// TestVerdictIncompleteNoGrantLeavesRetryMirrorUnused: an immediate-exhaustion +// TestIntegrationGateVerdictVerdictIncompleteNoGrantLeavesRetryMirrorUnused: an immediate-exhaustion // (limit 1) run-incomplete stops terminally with no retry granted and no marker // created, so the persisted GateRecord's readable Retry mirror must stay // RetryUnused — nothing was consumed. LoadGateRecord only upgrades the mirror from // markers and never downgrades, so a mirror set to RetryConsumed on a no-grant stop // would permanently misreport "consumed" though GateRetryUsage == 0. This reddens // if the mirror is set before branching on `granted`. -func TestVerdictIncompleteNoGrantLeavesRetryMirrorUnused(t *testing.T) { +func TestIntegrationGateVerdictVerdictIncompleteNoGrantLeavesRetryMirrorUnused(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -433,11 +377,11 @@ func TestVerdictIncompleteNoGrantLeavesRetryMirrorUnused(t *testing.T) { } } -// TestVerdictHaltPrecedenceOverBudget: a run-halted verdict against a fresh, +// TestIntegrationGateVerdictVerdictHaltPrecedenceOverBudget: a run-halted verdict against a fresh, // unspent limit-4 record stops terminally (gate-stop run-halted) and spends NO // attempt — run-halted keeps absolute precedence ahead of any counting, and the // attempts surface stays absent on the halt path. -func TestVerdictHaltPrecedenceOverBudget(t *testing.T) { +func TestIntegrationGateVerdictVerdictHaltPrecedenceOverBudget(t *testing.T) { repo := newGateRepo(t) deps := gateLightDeps(t, []StatusBlob{gateHaltedInProgressBlob(3, rvSlug)}) key := gateMintAttributedLimit(t, repo, 3, 4) @@ -457,11 +401,11 @@ func TestVerdictHaltPrecedenceOverBudget(t *testing.T) { } } -// TestVerdictContinuationConsumesNoAttempt: a scope-bound run-incomplete taken over +// TestIntegrationGateVerdictVerdictContinuationConsumesNoAttempt: a scope-bound run-incomplete taken over // as a live continuation reaches gate-continue WITHOUT touching the retry CAS — // GateRetryUsage stays 0 even with a fresh limit-4 budget. This reddens if the CAS // is ever moved above the outer-takeover check. -func TestVerdictContinuationConsumesNoAttempt(t *testing.T) { +func TestIntegrationGateVerdictVerdictContinuationConsumesNoAttempt(t *testing.T) { f := newRunVerifyFixture(t, true) tookOver := false reader := gatedWaitingReader{receipt: rvAgreeingReceipt(f.head), ready: &tookOver} @@ -482,38 +426,10 @@ func TestVerdictContinuationConsumesNoAttempt(t *testing.T) { } } -// fakeProofScanner is the injected ClaimProofScanner for the verdict path's -// ownership resolution (change 0407): it returns canned proofs newest-first, or an -// error. A nil scanner (not this fake) is the fail-closed proof-unavailable case. -type fakeProofScanner struct { - proofs []ClaimProof - err error -} - -func (f *fakeProofScanner) ScanClaimProofs(context.Context, string) ([]ClaimProof, error) { - return f.proofs, f.err -} - -// gateRetryMarkerExists reports whether the O_EXCL retry marker for key exists on -// disk. It reads the FILESYSTEM (never a mock), so a continuation that must never -// reach the retry CAS is a real, provable property. -func gateRetryMarkerExists(t *testing.T, repoDir, key string) bool { - t.Helper() - common, err := gateGitCommonDir(repoDir) - if err != nil { - t.Fatalf("gateGitCommonDir: %v", err) - } - _, serr := os.Stat(filepath.Join(common, "docket", "rungate", key, gateRetryMarkerName)) - if serr != nil && !os.IsNotExist(serr) { - t.Fatalf("stat retry marker: %v", serr) - } - return serr == nil -} - -// TestVerdictWaitingIsNonterminalContinue: a RunVerify run-waiting (a worker +// TestIntegrationGateVerdictVerdictWaitingIsNonterminalContinue: a RunVerify run-waiting (a worker // cooperatively handed off) maps to a NONTERMINAL gate-continue that keeps the key // and spends no retry, minting a continuation id and persisting the triple. -func TestVerdictWaitingIsNonterminalContinue(t *testing.T) { +func TestIntegrationGateVerdictVerdictWaitingIsNonterminalContinue(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := rvWaitingDeps(t, f, fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true}) wdeps.Continuation = &fakeContinuationSeam{handoffToken: "h0token"} @@ -551,13 +467,13 @@ func TestVerdictWaitingIsNonterminalContinue(t *testing.T) { } } -// TestVerdictIncompleteWithTrackedDriveContinuesWithoutRetry: a run-incomplete +// TestIntegrationGateVerdictVerdictIncompleteWithTrackedDriveContinuesWithoutRetry: a run-incomplete // whose recovery scope still binds a tracked drive is TAKEN OVER and continued, // and the O_EXCL retry marker is NEVER created — asserted on the filesystem, so // the "cannot reach the retry CAS" ordering property is real. This is the mutation // target for Task 6 Step 3 (moving ConsumeGateRetry above the tracked-drive check // creates the marker and reddens this test). -func TestVerdictIncompleteWithTrackedDriveContinuesWithoutRetry(t *testing.T) { +func TestIntegrationGateVerdictVerdictIncompleteWithTrackedDriveContinuesWithoutRetry(t *testing.T) { f := newRunVerifyFixture(t, true) tookOver := false reader := gatedWaitingReader{receipt: rvAgreeingReceipt(f.head), ready: &tookOver} @@ -592,10 +508,10 @@ func TestVerdictIncompleteWithTrackedDriveContinuesWithoutRetry(t *testing.T) { } } -// TestVerdictIncompleteQuiescentStillRetriesOnce: a run-incomplete with a scope +// TestIntegrationGateVerdictVerdictIncompleteQuiescentStillRetriesOnce: a run-incomplete with a scope // but ZERO tracked-drive candidates is genuinely quiescent — it falls through to // the unchanged retry path (gate-retry-once, then terminal gate-stop). -func TestVerdictIncompleteQuiescentStillRetriesOnce(t *testing.T) { +func TestIntegrationGateVerdictVerdictIncompleteQuiescentStillRetriesOnce(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -621,10 +537,10 @@ func TestVerdictIncompleteQuiescentStillRetriesOnce(t *testing.T) { } } -// TestVerdictAmbiguousDrivesStops: more than one candidate tracked drive is +// TestIntegrationGateVerdictVerdictAmbiguousDrivesStops: more than one candidate tracked drive is // unsafe ownership — it earns neither retry nor continuation, stopping terminally // with gate-unavailable takeover-ambiguous and never touching the retry marker. -func TestVerdictAmbiguousDrivesStops(t *testing.T) { +func TestIntegrationGateVerdictVerdictAmbiguousDrivesStops(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -648,9 +564,9 @@ func TestVerdictAmbiguousDrivesStops(t *testing.T) { } } -// TestVerdictTakeoverHaltStops: a takeover that HALTs (unsafe ownership) stops +// TestIntegrationGateVerdictVerdictTakeoverHaltStops: a takeover that HALTs (unsafe ownership) stops // terminally with the driver's cause and never spends the retry. -func TestVerdictTakeoverHaltStops(t *testing.T) { +func TestIntegrationGateVerdictVerdictTakeoverHaltStops(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -678,9 +594,9 @@ func TestVerdictTakeoverHaltStops(t *testing.T) { } } -// TestVerdictContinueNeverAuthorizesNewClaim: the continuation path leaves +// TestIntegrationGateVerdictVerdictContinueNeverAuthorizesNewClaim: the continuation path leaves // attribution untouched — an already-attributed record's AttributedID is unchanged. -func TestVerdictContinueNeverAuthorizesNewClaim(t *testing.T) { +func TestIntegrationGateVerdictVerdictContinueNeverAuthorizesNewClaim(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := rvWaitingDeps(t, f, fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true}) wdeps.Continuation = &fakeContinuationSeam{handoffToken: "h0token"} @@ -702,13 +618,13 @@ func TestVerdictContinueNeverAuthorizesNewClaim(t *testing.T) { } } -// TestVerdictFreshRunBindsScopeChange: on a FRESH run (no pre-attributed id), when +// TestIntegrationGateVerdictVerdictFreshRunBindsScopeChange: on a FRESH run (no pre-attributed id), when // ownership resolution adopts the sole matching claim proof the verdict path binds // that change id into the outer recovery scope (spec §3 defense-in-depth) so a later // outer takeover's scopeIdentityMatch pins the change rather than skipping it on an // empty scope field. Mutation target: dropping the BindScopeChange call at the // adoption point reddens the bindCalls assertion below. -func TestVerdictFreshRunBindsScopeChange(t *testing.T) { +func TestIntegrationGateVerdictVerdictFreshRunBindsScopeChange(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), @@ -739,11 +655,11 @@ func TestVerdictFreshRunBindsScopeChange(t *testing.T) { } } -// TestVerdictContinuationDoesNotRebindScope: a continuation (an already-attributed +// TestIntegrationGateVerdictVerdictContinuationDoesNotRebindScope: a continuation (an already-attributed // record — the state a second gate-verdict call reads) skips attribution entirely, // so it MUST NOT re-bind the outer scope's change id. This is the bind-once guard's // other half: the fresh run binds, a continuation never touches it. -func TestVerdictContinuationDoesNotRebindScope(t *testing.T) { +func TestIntegrationGateVerdictVerdictContinuationDoesNotRebindScope(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := rvWaitingDeps(t, f, fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true}) seam := &fakeContinuationSeam{handoffToken: "h0token"} @@ -768,10 +684,10 @@ func TestVerdictContinuationDoesNotRebindScope(t *testing.T) { } } -// TestVerdictObservePathStillCannotContinue: the observe (unattributed) render +// TestIntegrationGateVerdictVerdictObservePathStillCannotContinue: the observe (unattributed) render // path is structurally unable to emit a retry OR a continuation — extending the // existing observe-cannot-retry guarantee to gate-continue. -func TestVerdictObservePathStillCannotContinue(t *testing.T) { +func TestIntegrationGateVerdictVerdictObservePathStillCannotContinue(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( gateIncompleteRecord(), @@ -799,11 +715,11 @@ func TestVerdictObservePathStillCannotContinue(t *testing.T) { // conflicting / unprovable case fails CLOSED to a non-authorizing report. The // before-set and dispatch epoch are diagnostics only and can never grant a retry. -// TestVerdictConfirmedBindingResolvesBoundChange: a confirmed binding whose newest +// TestIntegrationGateVerdictVerdictConfirmedBindingResolvesBoundChange: a confirmed binding whose newest // proof for the id matches the bound request id resolves the bound change and // delegates unchanged to RunVerify — a completed run reports run-complete even // though the claim left the change in-progress. -func TestVerdictConfirmedBindingResolvesBoundChange(t *testing.T) { +func TestIntegrationGateVerdictVerdictConfirmedBindingResolvesBoundChange(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -829,11 +745,11 @@ func TestVerdictConfirmedBindingResolvesBoundChange(t *testing.T) { } } -// TestVerdictNoBindingNoProofIsNoAttributableClaim: a dispatch that claims nothing +// TestIntegrationGateVerdictVerdictNoBindingNoProofIsNoAttributableClaim: a dispatch that claims nothing // resolves to no-attributable-claim and never acquires a sibling's claim — a proof // under a DIFFERENT context hash is filtered out, the retry is never spent, and the // sibling id never appears in the report. -func TestVerdictNoBindingNoProofIsNoAttributableClaim(t *testing.T) { +func TestIntegrationGateVerdictVerdictNoBindingNoProofIsNoAttributableClaim(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") wdeps := WorkspaceDeps{ClaimProofs: &fakeProofScanner{proofs: []ClaimProof{ @@ -858,10 +774,10 @@ func TestVerdictNoBindingNoProofIsNoAttributableClaim(t *testing.T) { } } -// TestVerdictUnconfirmedReservationRecoversFromExactReceipt: a reservation whose +// TestIntegrationGateVerdictVerdictUnconfirmedReservationRecoversFromExactReceipt: a reservation whose // confirm was interrupted recovers from the exact committed receipt (same request // id, same context hash), confirms the binding, and delegates to the bound id. -func TestVerdictUnconfirmedReservationRecoversFromExactReceipt(t *testing.T) { +func TestIntegrationGateVerdictVerdictUnconfirmedReservationRecoversFromExactReceipt(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -885,11 +801,11 @@ func TestVerdictUnconfirmedReservationRecoversFromExactReceipt(t *testing.T) { } } -// TestVerdictUnconfirmedReservationWithoutReceiptStops: an unconfirmed reservation +// TestIntegrationGateVerdictVerdictUnconfirmedReservationWithoutReceiptStops: an unconfirmed reservation // with no matching committed receipt never became a real claim — the verdict is // no-attributable-claim and the reservation is left refused (never released so a // different claim can take it, never confirmed). -func TestVerdictUnconfirmedReservationWithoutReceiptStops(t *testing.T) { +func TestIntegrationGateVerdictVerdictUnconfirmedReservationWithoutReceiptStops(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { @@ -907,7 +823,7 @@ func TestVerdictUnconfirmedReservationWithoutReceiptStops(t *testing.T) { } } -// TestVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim: an +// TestIntegrationGateVerdictVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim: an // unconfirmed reservation whose only committed proof shares the request id but // carries a DIFFERENT context hash is a sibling collision, not this dispatch's // receipt. gateProofForClaim's `&& p.GateContextHash == contextHash` clause must @@ -915,7 +831,7 @@ func TestVerdictUnconfirmedReservationWithoutReceiptStops(t *testing.T) { // Dropping that clause reddens this test (mutation-load-bearing) — neither // RecoversFromExactReceipt (matching hash) nor WithoutReceiptStops (no proofs) // exercises the same-request-id sibling. -func TestVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim(t *testing.T) { +func TestIntegrationGateVerdictVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { @@ -934,11 +850,11 @@ func TestVerdictUnconfirmedReservationSiblingContextHashIsNoAttributableClaim(t } } -// TestVerdictAbsentBindingAdoptsSoleProof: with no binding file at all, a single +// TestIntegrationGateVerdictVerdictAbsentBindingAdoptsSoleProof: with no binding file at all, a single // committed proof matching the record's context hash is adopted (reserved, // confirmed, mirrored) and delegation proceeds; two matching proofs are unsafe // ownership and fail closed to binding-conflict. -func TestVerdictAbsentBindingAdoptsSoleProof(t *testing.T) { +func TestIntegrationGateVerdictVerdictAbsentBindingAdoptsSoleProof(t *testing.T) { t.Run("sole proof adopted", func(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( @@ -978,11 +894,11 @@ func TestVerdictAbsentBindingAdoptsSoleProof(t *testing.T) { }) } -// TestVerdictClaimReplacedStops: a confirmed binding whose bound change carries a +// TestIntegrationGateVerdictVerdictClaimReplacedStops: a confirmed binding whose bound change carries a // NEWER proof under a different request id means the change was reclaimed and // re-claimed by another run — the old gate must neither retry nor take over the // replacement. The binding is left unchanged and the retry is never spent. -func TestVerdictClaimReplacedStops(t *testing.T) { +func TestIntegrationGateVerdictVerdictClaimReplacedStops(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") if err := ReserveGateClaim(repo, key, 3, "claim-3-v1"); err != nil { @@ -1012,10 +928,10 @@ func TestVerdictClaimReplacedStops(t *testing.T) { } } -// TestVerdictNilProofScannerFailsClosed: a claim-bound gate with no proof access +// TestIntegrationGateVerdictVerdictNilProofScannerFailsClosed: a claim-bound gate with no proof access // cannot verify continuity — unlike the continuation seam, ownership can never // proceed without proofs, so it fails closed to proof-unavailable. -func TestVerdictNilProofScannerFailsClosed(t *testing.T) { +func TestIntegrationGateVerdictVerdictNilProofScannerFailsClosed(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { @@ -1033,9 +949,9 @@ func TestVerdictNilProofScannerFailsClosed(t *testing.T) { } } -// TestVerdictProofScanErrorFailsClosed: a proof-scan error fails closed to +// TestIntegrationGateVerdictVerdictProofScanErrorFailsClosed: a proof-scan error fails closed to // proof-unavailable and never consumes a retry. -func TestVerdictProofScanErrorFailsClosed(t *testing.T) { +func TestIntegrationGateVerdictVerdictProofScanErrorFailsClosed(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { @@ -1054,9 +970,9 @@ func TestVerdictProofScanErrorFailsClosed(t *testing.T) { } } -// TestVerdictCorruptBindingFailsClosed: an unparseable binding file is a typed +// TestIntegrationGateVerdictVerdictCorruptBindingFailsClosed: an unparseable binding file is a typed // binding-unreadable stop — never a silent (ok=false) fall-through to attribution. -func TestVerdictCorruptBindingFailsClosed(t *testing.T) { +func TestIntegrationGateVerdictVerdictCorruptBindingFailsClosed(t *testing.T) { repo := newGateRepo(t) key := gateMintArmed(t, repo, nil, 1, "ha") common, err := gateGitCommonDir(repo) @@ -1072,11 +988,11 @@ func TestVerdictCorruptBindingFailsClosed(t *testing.T) { } } -// TestVerdictResumeBindingSkipsContinuity: a gate-before --resume record +// TestIntegrationGateVerdictVerdictResumeBindingSkipsContinuity: a gate-before --resume record // (AttributedID set, BoundRequestID empty) is pre-bound by verified identity — the // continuity check never runs, so a scanner that WOULD report a replacement still // delegates to RunVerify (preserved verified-resume behavior). -func TestVerdictResumeBindingSkipsContinuity(t *testing.T) { +func TestIntegrationGateVerdictVerdictResumeBindingSkipsContinuity(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -1096,12 +1012,12 @@ func TestVerdictResumeBindingSkipsContinuity(t *testing.T) { } } -// TestVerdictOwnershipIgnoresBeforeSetAndEpoch pins the 0407 defect: a sibling +// TestIntegrationGateVerdictVerdictOwnershipIgnoresBeforeSetAndEpoch pins the 0407 defect: a sibling // in-progress claim the OLD before-set/epoch filters would have attributed sits in // the corpus, but ownership reads only committed proofs. With no proof carrying the // record's context hash, the verdict is no-attributable-claim — never the sibling. // Mutation partner: re-introducing epoch/before-set inference reddens exactly this. -func TestVerdictOwnershipIgnoresBeforeSetAndEpoch(t *testing.T) { +func TestIntegrationGateVerdictVerdictOwnershipIgnoresBeforeSetAndEpoch(t *testing.T) { repo := newGateRepo(t) deps := gateLightDeps(t, []StatusBlob{gateInProgressBlob(9, "sibling", "keep")}) key := gateMintArmed(t, repo, nil, 1, "ha") @@ -1133,101 +1049,10 @@ func TestVerdictOwnershipIgnoresBeforeSetAndEpoch(t *testing.T) { // keyless/standalone/legacy shape (no epoch beside the record) keeps EXACTLY the prior // behavior, and unattributed observe mode never touches ownership. -// verdictCompletionFixture is one prepared run whose keyed verdict verifies -// run-complete AND whose epoch ownership is ready to close out. -type verdictCompletionFixture struct { - repo, key, epochID, worktree string - store *gatedrive.Store - deps PlanningDeps - wdeps WorkspaceDeps - gdeps GitHubDeps - observer *fakeProcessObserver - launchObserver *fakeLaunchObserver -} - -// seams returns the injected completion seam bundle for a direct completeSuccessfulRun -// call (used to pre-drive the closeout before a persistence-fault replay test). -func (fx verdictCompletionFixture) seams() cancelSeams { - return cancelSeams{store: fx.store, observer: fx.observer, launchObserver: fx.launchObserver} -} - -func newVerdictCompletionFixture(t *testing.T) verdictCompletionFixture { - t.Helper() - f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), - ) - repo := f.repo.invocation - common, err := gateGitCommonDir(repo) - if err != nil { - t.Fatalf("gateGitCommonDir: %v", err) - } - key := gateMintArmed(t, repo, nil, 1, "ha") - if err := ReserveGateClaim(repo, key, 3, "claim-3-v"); err != nil { - t.Fatalf("reserve: %v", err) - } - if err := ConfirmGateClaim(repo, key, 3, "claim-3-v", "r1", ""); err != nil { - t.Fatalf("confirm: %v", err) - } - wdeps.ClaimProofs = &fakeProofScanner{proofs: []ClaimProof{ - {RequestID: "claim-3-v", ChangeID: 3, GateContextHash: "ha", Revision: "r1"}, - }} - - ep, err := MintEpochRecord(repo, key, "3") - if err != nil { - t.Fatalf("MintEpochRecord: %v", err) - } - worktree := filepath.Join(repo, "feature-wt") - if err := os.MkdirAll(worktree, 0o755); err != nil { - t.Fatalf("mkdir worktree: %v", err) - } - if err := epochCAS(repo, key, func(r *EpochRecord) error { - r.Worktree = worktree - return nil - }); err != nil { - t.Fatalf("epochCAS set worktree: %v", err) - } - store := gatedrive.OpenStore(common) - // A released epoch-owned slot (the run's drives are done) is exactly what the - // closeout retires — reserve+confirm+release, mirroring the cancel/completion - // fixtures. Release retains RunEpochID (between-drive ownership), so the slot is - // slotOwned+released until the closeout detaches it. - runDir := filepath.Join(worktree, "run-1") - token, terr := store.ReserveWorktreeExecutionForEpoch(common, worktree, ep.EpochID, nil) - if terr != nil { - t.Fatalf("ReserveWorktreeExecutionForEpoch: %v", terr) - } - if cerr := store.ConfirmWorktreeExecution(worktree, token, "run-1", runDir); cerr != nil { - t.Fatalf("ConfirmWorktreeExecution: %v", cerr) - } - slot, _, lerr := store.LoadWorktreeExecution(worktree) - if lerr != nil { - t.Fatalf("LoadWorktreeExecution: %v", lerr) - } - if rerr := store.ReleaseWorktreeExecution(worktree, slot.ReservationToken); rerr != nil { - t.Fatalf("ReleaseWorktreeExecution: %v", rerr) - } - must(t, RegisterEpochParticipant(repo, key, ep.EpochID, - EpochParticipant{Kind: "coordinator", NativeHandle: "turn-1"})) - must(t, RecordEpochParticipantTerminal(repo, key, ep.EpochID, - "turn-1", "t1", ParticipantTerminalCompleted)) - - observer := &fakeProcessObserver{defaultProven: true} - launchObserver := &fakeLaunchObserver{report: gatedrive.EpochLaunchReport{Accounted: true}} - wdeps.CancelSeams = func(string) cancelSeams { - return cancelSeams{store: store, observer: observer, launchObserver: launchObserver} - } - return verdictCompletionFixture{ - repo: repo, key: key, epochID: ep.EpochID, worktree: worktree, store: store, - deps: deps, wdeps: wdeps, gdeps: gdeps, observer: observer, launchObserver: launchObserver, - } -} - -// TestVerdictRunCompleteClosesOutEpochOwnership: a keyed run-complete drives the +// TestIntegrationGateVerdictVerdictRunCompleteClosesOutEpochOwnership: a keyed run-complete drives the // closeout — gate-done run-complete, the epoch is completed, and the slot's RunEpochID // is cleared so a standalone finalize gate can admit. -func TestVerdictRunCompleteClosesOutEpochOwnership(t *testing.T) { +func TestIntegrationGateVerdictVerdictRunCompleteClosesOutEpochOwnership(t *testing.T) { fx := newVerdictCompletionFixture(t) res := RunGateVerdict(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, fx.key) if got, want := res.HumanText(), "gate-done "+fx.key+" run-complete 3"; got != want { @@ -1245,10 +1070,10 @@ func TestVerdictRunCompleteClosesOutEpochOwnership(t *testing.T) { } } -// TestVerdictRunCompleteWithoutEpochUnchanged: with no epoch beside the record the +// TestIntegrationGateVerdictVerdictRunCompleteWithoutEpochUnchanged: with no epoch beside the record the // verdict keeps EXACTLY the prior behavior — gate-done run-complete, no epoch // fabricated, no completion findings. -func TestVerdictRunCompleteWithoutEpochUnchanged(t *testing.T) { +func TestIntegrationGateVerdictVerdictRunCompleteWithoutEpochUnchanged(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), @@ -1277,14 +1102,14 @@ func TestVerdictRunCompleteWithoutEpochUnchanged(t *testing.T) { } } -// TestVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess: one unproven obligation +// TestIntegrationGateVerdictVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess: one unproven obligation // (a registered execution participant whose run is observed live) blocks the // closeout — gate-stop gate-unavailable completion-unaccounted with diagnostic // findings, the epoch stays completing (the fence holds), and no retry is spent (AC2 // budget preservation). The released owned slot itself is NOT that obligation: its // release is the durable proof of its run (change 0446 spec §5), so it is never // re-observed. -func TestVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess(t *testing.T) { +func TestIntegrationGateVerdictVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess(t *testing.T) { fx := newVerdictCompletionFixture(t) must(t, RegisterEpochParticipant(fx.repo, fx.key, fx.epochID, EpochParticipant{Kind: "raw-run", NativeHandle: "exec-live"})) @@ -1310,10 +1135,10 @@ func TestVerdictRunCompleteBlockedCloseoutStopsWithoutSuccess(t *testing.T) { } } -// TestVerdictRunCompleteCancelledEpochNeverReportsSuccess: an explicit cancellation +// TestIntegrationGateVerdictVerdictRunCompleteCancelledEpochNeverReportsSuccess: an explicit cancellation // that already won is never relabelled successful — gate-stop gate-unavailable // run-cancelled, never gate-done, and the epoch state is untouched. -func TestVerdictRunCompleteCancelledEpochNeverReportsSuccess(t *testing.T) { +func TestIntegrationGateVerdictVerdictRunCompleteCancelledEpochNeverReportsSuccess(t *testing.T) { fx := newVerdictCompletionFixture(t) forceEpochState(t, fx.repo, fx.key, EpochCancelled) res := RunGateVerdict(context.Background(), fx.deps, fx.wdeps, fx.gdeps, fx.repo, fx.key) @@ -1328,12 +1153,12 @@ func TestVerdictRunCompleteCancelledEpochNeverReportsSuccess(t *testing.T) { } } -// TestVerdictRunCompleteReportPersistFailureIsReported: the closeout finishes (epoch +// TestIntegrationGateVerdictVerdictRunCompleteReportPersistFailureIsReported: the closeout finishes (epoch // durably completed) but the terminal gate-report save fails — gate-stop // gate-unavailable report-unpersisted (the failure is reported, not hidden). A SECOND // verdict with the fault cleared replays the completed epoch to gate-done run-complete // (AC6 gate-report write failure + replay). -func TestVerdictRunCompleteReportPersistFailureIsReported(t *testing.T) { +func TestIntegrationGateVerdictVerdictRunCompleteReportPersistFailureIsReported(t *testing.T) { fx := newVerdictCompletionFixture(t) // Pre-drive the closeout so the epoch is durably completed: a replay does NO epoch // writes (the fence observes completed), isolating the checked report SAVE as the @@ -1363,10 +1188,10 @@ func TestVerdictRunCompleteReportPersistFailureIsReported(t *testing.T) { } } -// TestVerdictObserveModeNeverTouchesOwnership: the unattributed observe path over the +// TestIntegrationGateVerdictVerdictObserveModeNeverTouchesOwnership: the unattributed observe path over the // same epoch-backed complete fixture leaves the epoch and slot byte-identical (AC5) — // it holds no key, drives no closeout, and renders the plain observe run-complete line. -func TestVerdictObserveModeNeverTouchesOwnership(t *testing.T) { +func TestIntegrationGateVerdictVerdictObserveModeNeverTouchesOwnership(t *testing.T) { fx := newVerdictCompletionFixture(t) _, genBefore, err := LoadEpochRecord(fx.repo, fx.key) if err != nil { diff --git a/internal/app/sweep_session_test.go b/internal/app/sweep_session_integration_test.go similarity index 95% rename from internal/app/sweep_session_test.go rename to internal/app/sweep_session_integration_test.go index e894735d5..e44c47bb5 100644 --- a/internal/app/sweep_session_test.go +++ b/internal/app/sweep_session_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package app import ( @@ -99,7 +101,7 @@ func sessionUnderTest(t *testing.T, client *gitcli.Client, invocation string) (* return newSweepSession(client, repo, base), reader } -func TestPrepareIsOneMetadataFetchZeroSetupProbes(t *testing.T) { +func TestIntegrationContextProbePrepareIsOneMetadataFetchZeroSetupProbes(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0001-alpha.md": changeRecord(1, "alpha", "Alpha"), @@ -133,7 +135,7 @@ func TestPrepareIsOneMetadataFetchZeroSetupProbes(t *testing.T) { } } -func TestPrepareObservesFreshMetadataTip(t *testing.T) { +func TestIntegrationContextProbePrepareObservesFreshMetadataTip(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0001-alpha.md": changeRecord(1, "alpha", "Alpha"), @@ -171,7 +173,7 @@ func TestPrepareObservesFreshMetadataTip(t *testing.T) { } } -func TestPrepareFailedFetchIsErrorNeverStaleFallback(t *testing.T) { +func TestIntegrationContextProbePrepareFailedFetchIsErrorNeverStaleFallback(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0001-alpha.md": changeRecord(1, "alpha", "Alpha"), @@ -192,7 +194,7 @@ func TestPrepareFailedFetchIsErrorNeverStaleFallback(t *testing.T) { } } -func TestBoundReaderNeverFetches(t *testing.T) { +func TestIntegrationContextProbeBoundReaderNeverFetches(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0001-alpha.md": changeRecord(1, "alpha", "Alpha"), @@ -248,7 +250,7 @@ func TestBoundReaderNeverFetches(t *testing.T) { } } -func TestSessionRefusesDifferentRepository(t *testing.T) { +func TestIntegrationContextProbeSessionRefusesDifferentRepository(t *testing.T) { requireRealGit(t) records := map[string]string{ "docs/changes/active/0001-alpha.md": changeRecord(1, "alpha", "Alpha"), diff --git a/internal/cli/gate_test.go b/internal/cli/gate_test.go index be293c218..6d1daf1e7 100644 --- a/internal/cli/gate_test.go +++ b/internal/cli/gate_test.go @@ -1010,8 +1010,8 @@ func TestCLIDoesNotImportProcess(t *testing.T) { // The first run is genuinely LIVE (a long sleep): since change 0446 the admission // boundary settles a proven-finished raw incumbent, so a first run that had already // completed would rightly be admitted over and could not prove the busy refusal. -// The finished-incumbent side is pinned by the app layer's -// TestGateLaunchSettlesFinishedRawIncumbent. +// The finished-incumbent side is pinned by the app layer's integration-tagged +// TestIntegrationGateLifecycleGateLaunchSettlesFinishedRawIncumbent. func TestGateLaunchInsideWorktreeSecondRefused(t *testing.T) { wt := gateDriveConfiguredRepo(t, "metadata_branch: main\n") diff --git a/internal/repoguard/race_gate_timeout_test.go b/internal/repoguard/race_gate_timeout_test.go new file mode 100644 index 000000000..a9b87ad9e --- /dev/null +++ b/internal/repoguard/race_gate_timeout_test.go @@ -0,0 +1,185 @@ +package repoguard + +// Change 0465: tests/test_go_race.sh passes an explicit -timeout backstop (below Go's +// 10m per-package default) and turns an overrun into a named, readable NOT OK line +// instead of a bare goroutine-dump panic. These are behavioral tests over a COPY of +// the real wrapper (read at test time, so nothing frozen can drift) with a fake `go` +// on PATH that logs its argv. Same pattern and helpers as gofmt_toolchain_test.go +// (writeToolScript, readLog). The asserts pin the mechanism (the argv go test +// actually received) and also prove the gate is not weakened (-race, -count=1, ./...). + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const fakeRaceGoScript = `printf 'argv:[%s]\n' "$*" >>"$GO_FAKE_LOG" +if [ "$1" = test ] && [ -n "${FAKE_GO_TIMEOUT:-}" ]; then + printf 'panic: test timed out after 8m0s\n\ngoroutine 1 [running]:\nFAIL\tfixture/slow\t480.012s\nFAIL\n' + exit 1 +fi +exit 0 +` + +type raceGateFixture struct { + root, wrapper, goLog string + env []string +} + +func newRaceGateFixture(t *testing.T) *raceGateFixture { + t.Helper() + repoRoot, err := Root() + if err != nil { + t.Fatal(err) + } + realWrapper, err := os.ReadFile(filepath.Join(repoRoot, "tests", "test_go_race.sh")) + if err != nil { + t.Fatal(err) + } + base := testsupport.TempDir(t) + f := &raceGateFixture{root: filepath.Join(base, "fixture"), goLog: filepath.Join(base, "go.log")} + f.wrapper = filepath.Join(f.root, "tests", "test_go_race.sh") + if err := os.MkdirAll(filepath.Dir(f.wrapper), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.wrapper, realWrapper, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(f.wrapper, 0o755); err != nil { + t.Fatal(err) + } + fakeBin := filepath.Join(base, "fakebin") + writeToolScript(t, filepath.Join(fakeBin, "go"), fakeRaceGoScript) + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + switch key { + case "PATH", "GOMODCACHE", "GOCACHE", "GOFLAGS", "GOMAXPROCS", + "DOCKET_GO_TEST_CONCURRENCY", "GO_FAKE_LOG", "FAKE_GO_TIMEOUT": + continue + } + f.env = append(f.env, kv) + } + // GOMODCACHE/GOCACHE pre-set so the wrapper's cache block never calls git. + f.env = append(f.env, + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + "GOMODCACHE="+filepath.Join(base, "gomodcache"), + "GOCACHE="+filepath.Join(base, "gocache"), + "GO_FAKE_LOG="+f.goLog, + ) + return f +} + +func (f *raceGateFixture) run(t *testing.T) (string, int) { + t.Helper() + cmd := exec.Command("bash", f.wrapper) + cmd.Dir = f.root + cmd.Env = f.env + b, err := cmd.CombinedOutput() + var ee *exec.ExitError + switch { + case err == nil: + return string(b), 0 + case errors.As(err, &ee): + return string(b), ee.ExitCode() + default: + t.Fatalf("running the wrapper copy: %v\n%s", err, b) + return "", -1 + } +} + +// raceBackstopFloor is the smallest -timeout backstop the race gate may carry: twice +// the worst post-partition package's projected CI time. The inputs are change 0465's +// measurements, the same ones the BACKSTOP TIMEOUT note in tests/test_go_race.sh +// cites: internal/repository/transaction at 48.7s (the measured local worst package, +// idle, -p 2), scaled by the worst observed local-to-CI slowdown (the whole gate took +// 238s locally and up to 908s on the macos-15 runner). 48.7s x 908/238 x 2 is ~372s. +func raceBackstopFloor() time.Duration { + const ( + localWorstPackage = 48700 * time.Millisecond + localGate = 238.0 + worstCIGate = 908.0 + ) + slowdown := worstCIGate / localGate + return time.Duration(2 * float64(localWorstPackage) * slowdown) +} + +// raceTestArgv returns the argv of the fake `go test` invocation. +func raceTestArgv(t *testing.T, log string) []string { + t.Helper() + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, "argv:[test ") { + return strings.Fields(strings.TrimSuffix(strings.TrimPrefix(line, "argv:["), "]")) + } + } + t.Fatalf("the fake go never received a `go test` invocation; log:\n%s", log) + return nil +} + +func TestRaceGatePassesTimeoutBackstopBelowGoDefault(t *testing.T) { + f := newRaceGateFixture(t) + out, code := f.run(t) + if code != 0 { + t.Fatalf("a green fake run must exit 0, got %d:\n%s", code, out) + } + argv := raceTestArgv(t, readLog(t, f.goLog)) + var timeout time.Duration + found := false + for i, a := range argv { + val := "" + switch { + case a == "-timeout" && i+1 < len(argv): + val = argv[i+1] + case strings.HasPrefix(a, "-timeout="): + val = strings.TrimPrefix(a, "-timeout=") + default: + continue + } + d, err := time.ParseDuration(val) + if err != nil { + t.Fatalf("-timeout value %q does not parse as a duration: %v", val, err) + } + timeout, found = d, true + } + if !found { + t.Fatalf("go test -race must carry an explicit -timeout backstop; argv %q", argv) + } + if timeout <= 0 || timeout >= 10*time.Minute { + t.Fatalf("the backstop %s must be positive and below Go's 10m default", timeout) + } + if floor := raceBackstopFloor(); timeout < floor { + t.Fatalf("the backstop %s is below %s, twice the worst package's projected CI time: it would trip on a loaded CI runner, the failure class change 0465 removes (see BACKSTOP TIMEOUT in tests/test_go_race.sh)", timeout, floor.Round(time.Second)) + } + for _, want := range []string{"-race", "-count=1", "./..."} { + if !slices.Contains(argv, want) { + t.Fatalf("the race gate must not be weakened: argv %q lacks %q", argv, want) + } + } +} + +var raceBackstopMarker = regexp.MustCompile(`(?m)^NOT OK - no package ran past the \S+ -timeout backstop$`) + +func TestRaceGateNamesTimeoutBackstopOnOverrun(t *testing.T) { + f := newRaceGateFixture(t) + f.env = append(f.env, "FAKE_GO_TIMEOUT=1") + out, code := f.run(t) + if code == 0 { + t.Fatalf("an overrun must fail the gate:\n%s", out) + } + if !raceBackstopMarker.MatchString(out) { + t.Fatalf("an overrun must print the named backstop marker, got:\n%s", out) + } + for _, want := range []string{"FAIL\tfixture/slow", "PARTITION AND LANE"} { + if !strings.Contains(out, want) { + t.Fatalf("the overrun diagnostic must contain %q, got:\n%s", want, out) + } + } +} diff --git a/internal/suiterunner/budgetstate.go b/internal/suiterunner/budgetstate.go index 3be89b5e0..9ebf5319e 100644 --- a/internal/suiterunner/budgetstate.go +++ b/internal/suiterunner/budgetstate.go @@ -116,6 +116,42 @@ func ContextKey(path string, jobs, cpus int, osName, arch string, ceiling int, m return fmt.Sprintf("%s|j%d|c%d|%s|%s|b%d|m%s|s%d", path, jobs, cpus, osName, arch, ceiling, string(mode), bsSchema) } +// budgetKeyPath renders a target path for the budget-state context key relative to +// the checkout root (change 0465), so every worktree of one repository accumulates +// one record per target and the screen-then-confirm streak can actually reach its +// serial confirmation. An already-relative path, an empty root, or a path outside +// the root (a DOCKET_RUNTESTS_TESTS_DIR override) is returned unchanged; a +// symlink-spelled root is compared after resolving both sides. +func budgetKeyPath(repoRoot, path string) string { + if repoRoot == "" || !filepath.IsAbs(path) { + return path + } + if rel, ok := relUnderRoot(repoRoot, path); ok { + return rel + } + rr, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + return path + } + rp, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + if rel, ok := relUnderRoot(rr, rp); ok { + return rel + } + return path +} + +// relUnderRoot reports path relative to root when it lies strictly inside root. +func relUnderRoot(root, path string) (string, bool) { + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", false + } + return filepath.ToSlash(rel), true +} + // DefaultStatePath resolves the Go runner's OWN advisory budget-state store: // /docket/development-test-budget-state.tsv. It is deliberately // NOT the Bash oracle's /docket/run-tests-budget-state.tsv — see the diff --git a/internal/suiterunner/budgetstate_test.go b/internal/suiterunner/budgetstate_test.go index 775106005..ba2bbe793 100644 --- a/internal/suiterunner/budgetstate_test.go +++ b/internal/suiterunner/budgetstate_test.go @@ -364,3 +364,49 @@ func TestStorePathIsNotTheBashRunners(t *testing.T) { t.Fatalf("default store path %q must never be the Bash runner's store", p) } } + +// Change 0465: the budget-state key leads with the REPO-RELATIVE target path, so every +// worktree of one repository accumulates one record per target. +func TestBudgetKeyPathIsRepoRelative(t *testing.T) { + cases := []struct{ name, root, path, want string }{ + {"under the primary checkout", "/w/docket", "/w/docket/tests/test_x.sh", "tests/test_x.sh"}, + {"under a linked worktree", "/w/docket/.worktrees/fix-y", "/w/docket/.worktrees/fix-y/tests/test_x.sh", "tests/test_x.sh"}, + {"already relative", "/w/docket", "tests/test_x.sh", "tests/test_x.sh"}, + {"no repo root", "", "/w/docket/tests/test_x.sh", "/w/docket/tests/test_x.sh"}, + {"outside the root keeps its absolute key", "/w/docket", "/elsewhere/tests/test_x.sh", "/elsewhere/tests/test_x.sh"}, + {"a sibling sharing a name prefix is not under the root", "/w/a", "/w/ab/tests/test_x.sh", "/w/ab/tests/test_x.sh"}, + } + for _, tc := range cases { + if got := budgetKeyPath(tc.root, tc.path); got != tc.want { + t.Errorf("%s: budgetKeyPath(%q, %q) = %q, want %q", tc.name, tc.root, tc.path, got, tc.want) + } + } +} + +func TestContextKeySameAcrossCheckouts(t *testing.T) { + a := ContextKey(budgetKeyPath("/Users/x/docket", "/Users/x/docket/tests/test_go_race.sh"), 8, 8, "Darwin", "arm64", 60, ModeParallel) + b := ContextKey(budgetKeyPath("/Users/x/docket/.worktrees/fix-y", "/Users/x/docket/.worktrees/fix-y/tests/test_go_race.sh"), 8, 8, "Darwin", "arm64", 60, ModeParallel) + want := "tests/test_go_race.sh|j8|c8|Darwin|arm64|b60|mparallel|s1" + if a != want || b != want { + t.Fatalf("keys must converge on %q, got primary=%q worktree=%q", want, a, b) + } +} + +// A symlink-spelled root (macOS /var vs /private/var) must still converge. +func TestBudgetKeyPathResolvesSymlinkedRoot(t *testing.T) { + real := testsupport.TempDir(t) + if err := os.MkdirAll(filepath.Join(real, "tests"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(real, "tests", "test_x.sh") + if err := os.WriteFile(target, []byte("#!/usr/bin/env bash\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(testsupport.TempDir(t), "checkout-link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if got := budgetKeyPath(link, target); got != "tests/test_x.sh" { + t.Fatalf("budgetKeyPath(%q, %q) = %q, want tests/test_x.sh", link, target, got) + } +} diff --git a/internal/suiterunner/run.go b/internal/suiterunner/run.go index 8769fe464..3abdb7c2a 100644 --- a/internal/suiterunner/run.go +++ b/internal/suiterunner/run.go @@ -184,7 +184,8 @@ func Run(ctx context.Context, cfg Config) int { } else { over := o.Result.RC == 0 && ScreenOver(secs, ceil) o.Screened = over - key := ContextKey(o.Target.Path, cfg.Jobs, cpus, osName, arch, ceil, o.Target.Mode) + // Change 0465: key on the repo-relative path so worktrees share one record. + key := ContextKey(budgetKeyPath(cfg.RepoRoot, o.Target.Path), cfg.Jobs, cpus, osName, arch, ceil, o.Target.Mode) screenObs = append(screenObs, ScreenObs{Key: key, Path: o.Target.Path, Ceiling: ceil, Secs: secs, Over: over}) } } diff --git a/internal/suiterunner/run_test.go b/internal/suiterunner/run_test.go index c939f01e0..16bf15923 100644 --- a/internal/suiterunner/run_test.go +++ b/internal/suiterunner/run_test.go @@ -124,3 +124,37 @@ func TestRunUsageErrors(t *testing.T) { } }) } + +// Change 0465: two checkouts of one repository (a primary and a .worktrees/) +// sharing one budget-state store accumulate ONE streak for the same target. Before +// the fix each absolute path minted its own record and the second run read 1/5. +func TestRunBudgetStateConvergesAcrossCheckouts(t *testing.T) { + state := filepath.Join(testsupport.TempDir(t), "state.tsv") + durations := writeDurations(t, [][3]string{{"test_slow.sh", "1000", "1"}}) + run := func(root string) string { + t.Helper() + tests := filepath.Join(root, "tests") + if err := os.MkdirAll(tests, 0o755); err != nil { + t.Fatal(err) + } + writeScript(t, tests, "slow", "# docket-suite: go\necho 'ok - slow'\n") + var out, errBuf bytes.Buffer + cfg := runCfg(t, tests, &out, &errBuf) + cfg.RepoRoot = root + cfg.StatePath = state + cfg.DurationsPath = durations + if code := Run(context.Background(), cfg); code != 0 { + t.Fatalf("run in %s exit = %d\nstdout:\n%s\nstderr:\n%s", root, code, out.String(), errBuf.String()) + } + return out.String() + } + primary := filepath.Join(testsupport.TempDir(t), "docket") + first := run(primary) + second := run(filepath.Join(primary, ".worktrees", "fix-slow")) + if !strings.Contains(first, "consecutive parallel-overrun streak 1/5") { + t.Fatalf("first checkout must open the streak at 1/5:\n%s", first) + } + if !strings.Contains(second, "consecutive parallel-overrun streak 2/5") { + t.Fatalf("second checkout must CONTINUE the same record (2/5), not start its own:\n%s", second) + } +} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index bcddad505..77f2151b6 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -22,7 +22,7 @@ tests/test_go_integration_app_change.sh 35 parallel tests/test_go_integration_app_changeruntime.sh 40 parallel tests/test_go_integration_app_cleanup.sh 40 parallel tests/test_go_integration_app_closeout.sh 40 parallel -tests/test_go_integration_app_concurrency.sh 25 parallel +tests/test_go_integration_app_concurrency.sh 35 parallel tests/test_go_integration_app_merge.sh 30 parallel tests/test_go_integration_app_rebase.sh 45 parallel tests/test_go_integration_app_rebasepublished.sh 45 parallel @@ -45,6 +45,18 @@ tests/test_go_integration_app_workflowlifecycle.sh 35 parallel tests/test_go_integration_app_named.sh 20 parallel tests/test_go_integration_app_sweep.sh 55 parallel tests/test_go_integration_app_sync.sh 55 parallel +tests/test_go_integration_app_gatecancel.sh 15 parallel +tests/test_go_integration_app_gateverdict.sh 20 parallel +tests/test_go_integration_app_gatefence.sh 20 parallel +tests/test_go_integration_app_gatecompletion.sh 15 parallel +tests/test_go_integration_app_gateepoch.sh 10 parallel +tests/test_go_integration_app_gatearm.sh 15 parallel +tests/test_go_integration_app_gatelifecycle.sh 10 parallel +tests/test_go_integration_app_finalizeops.sh 30 parallel +tests/test_go_integration_app_finalizerebaseops.sh 45 parallel +tests/test_go_integration_app_evidence.sh 35 parallel +tests/test_go_integration_app_recordops.sh 55 parallel +tests/test_go_integration_app_contextprobe.sh 15 parallel tests/test_go_integration_contract.sh 15 parallel tests/test_go_integration_cli_agent.sh 15 parallel tests/test_go_integration_gitcli_concurrency.sh 10 parallel diff --git a/tests/test_go_integration_app_concurrency.sh b/tests/test_go_integration_app_concurrency.sh index 6b5f83849..6b21555bb 100755 --- a/tests/test_go_integration_app_concurrency.sh +++ b/tests/test_go_integration_app_concurrency.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # docket-suite: go # tests/test_go_integration_app_concurrency.sh — Go integration shard (change 0333): -# the concurrency-bearing app tests (concurrent planning mutations and gate-retry CAS), behind the `integration` build tag, prefix +# the concurrency-bearing app tests (concurrent planning mutations and gate-retry CAS, and the +# run-gate verdict/epoch/launch/cancel-resume/settlement and finalize-reserve concurrency tests moved out of the +# default corpus by change 0465), behind the `integration` build tag, prefix # ^TestRaceIntegrationAppConcurrency, run in RACE mode. Declarations only — execution and inspection live in # tests/lib/go-integration-shard.sh; the completeness contract is # tests/test_go_integration_contract.sh. diff --git a/tests/test_go_integration_app_contextprobe.sh b/tests/test_go_integration_app_contextprobe.sh new file mode 100755 index 000000000..2ada538ad --- /dev/null +++ b/tests/test_go_integration_app_contextprobe.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_contextprobe.sh — Go integration shard (change 0465, extending change +# 0333's partition): the repo-phase resolution, operational-context, named-branch-facts, and sweep-session tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationContextProbe. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationContextProbe" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_evidence.sh b/tests/test_go_integration_app_evidence.sh new file mode 100755 index 000000000..75fb2e3a4 --- /dev/null +++ b/tests/test_go_integration_app_evidence.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_evidence.sh — Go integration shard (change 0465, extending change +# 0333's partition): the evidence record/recertify and run-verify tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationEvidence. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationEvidence" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_finalizeops.sh b/tests/test_go_integration_app_finalizeops.sh new file mode 100755 index 000000000..9ee5f8b4a --- /dev/null +++ b/tests/test_go_integration_app_finalizeops.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_finalizeops.sh — Go integration shard (change 0465, extending change +# 0333's partition): the finalize resolver-reserve, block, publish, and PR-publish tests (the rebase-continue +# half lives in tests/test_go_integration_app_finalizerebaseops.sh) — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationFinalizeOps. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationFinalizeOps" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_finalizerebaseops.sh b/tests/test_go_integration_app_finalizerebaseops.sh new file mode 100755 index 000000000..952196d55 --- /dev/null +++ b/tests/test_go_integration_app_finalizerebaseops.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_finalizerebaseops.sh — Go integration shard (change 0465, extending change +# 0333's partition): the finalize rebase-continue, resolver-budget, gate-halt, and receipt-mutation tests (split +# from tests/test_go_integration_app_finalizeops.sh to fit the parallel lane) — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationFinalizeRebaseOps. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationFinalizeRebaseOps" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gatearm.sh b/tests/test_go_integration_app_gatearm.sh new file mode 100755 index 000000000..0688d78f6 --- /dev/null +++ b/tests/test_go_integration_app_gatearm.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatearm.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate arm (gate-before), resume, and gate-claim tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateArm. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateArm" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gatecancel.sh b/tests/test_go_integration_app_gatecancel.sh new file mode 100755 index 000000000..7937c780c --- /dev/null +++ b/tests/test_go_integration_app_gatecancel.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatecancel.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate cancel, retirement and terminal-repair tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateCancel. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateCancel" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gatecompletion.sh b/tests/test_go_integration_app_gatecompletion.sh new file mode 100755 index 000000000..146a06014 --- /dev/null +++ b/tests/test_go_integration_app_gatecompletion.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatecompletion.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate completion, production-census, and publication-settlement tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateCompletion. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateCompletion" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gateepoch.sh b/tests/test_go_integration_app_gateepoch.sh new file mode 100755 index 000000000..4c10775dc --- /dev/null +++ b/tests/test_go_integration_app_gateepoch.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gateepoch.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate epoch record, gate store and launch-gate tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateEpoch. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateEpoch" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gatefence.sh b/tests/test_go_integration_app_gatefence.sh new file mode 100755 index 000000000..d9f1ea208 --- /dev/null +++ b/tests/test_go_integration_app_gatefence.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatefence.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate fencing and ownership tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateFence. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateFence" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gatelifecycle.sh b/tests/test_go_integration_app_gatelifecycle.sh new file mode 100755 index 000000000..43a2d6505 --- /dev/null +++ b/tests/test_go_integration_app_gatelifecycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gatelifecycle.sh — Go integration shard (change 0465, extending change +# 0333's partition): the real-process gate launch/stop lifecycle and death-guardian tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateLifecycle. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateLifecycle" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_gateverdict.sh b/tests/test_go_integration_app_gateverdict.sh new file mode 100755 index 000000000..4cb954e28 --- /dev/null +++ b/tests/test_go_integration_app_gateverdict.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_gateverdict.sh — Go integration shard (change 0465, extending change +# 0333's partition): the run-gate verdict and claim-binding tests — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationGateVerdict. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationGateVerdict" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_recordops.sh b/tests/test_go_integration_app_recordops.sh new file mode 100755 index 000000000..aa2a8be6d --- /dev/null +++ b/tests/test_go_integration_app_recordops.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_recordops.sh — Go integration shard (change 0465, extending change +# 0333's partition): the change/ADR record-operation tests over real git (unrelated-invalid-record, claim gate-context, and real-git replay families) — real-git tests moved out of the +# default internal/app corpus, which must never start real git (the no-real-git guard +# in internal/app/nogit_guard_test.go) — behind the `integration` build tag, prefix +# ^TestIntegrationRecordOps. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationRecordOps" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_race.sh b/tests/test_go_race.sh index 3260b1946..0a01f363a 100755 --- a/tests/test_go_race.sh +++ b/tests/test_go_race.sh @@ -14,12 +14,31 @@ # internal/gitcli behind the `integration` build tag — dedicated shard runners # (tests/test_go_integration_*.sh) own it, and tests/test_go_integration_contract.sh # proves that partition is total. This gate therefore covers the FAST default -# corpus only: the ~190s internal/app real-git tail that dominated it no longer -# runs here. With that tail gone, `go test -race`'s GOMAXPROCS-wide race workers -# no longer oversubscribe the cores the other parallel jobs need (change 0332's -# reason for the serial lane, and change 0329's load-dependent build-gate halt), -# so this gate rides the PARALLEL lane under an ordinary sub-60s row in -# tests/runtime-budgets.tsv like every other file. +# corpus only. Change 0465 made that an enforced invariant for internal/app, the +# package whose default corpus had regrown to ~920 tests (337 of them real-git, +# 225s of its 237s under -race): the default-tag internal/app test corpus never +# starts a real `git` process. installNoGitGuard (internal/app/nogit_guard_test.go) +# shadows git on PATH in the default build and fails the package on any attempt, +# so a new real-git test cannot land here unnoticed. With that tail gone, `go test +# -race`'s GOMAXPROCS-wide race workers do not oversubscribe the cores the other +# parallel jobs need (change 0332's reason for the serial lane, and change 0329's +# load-dependent build-gate halt), so this gate rides the PARALLEL lane under an +# ordinary row in tests/runtime-budgets.tsv like every other file. +# +# BACKSTOP TIMEOUT (change 0465). `go test` is given an explicit -timeout +# (RACE_TIMEOUT below) of 8m, sized from CI-projected data rather than an idle +# local run. The measured post-partition worst package is +# internal/repository/transaction at 48.7s (local, idle, -p 2). The change's own +# CI data puts the macos-15 runner at ~2.4-3.8x slower than local (this gate: +# 238s local vs 581-908s on passing CI runs), projecting that package to ~120-186s +# in CI (up to ~230s with load noise). 8m is at least twice that worst projection, +# so a loaded runner does not trip the backstop, and still below Go's 10m +# per-package default. TestRaceGatePassesTimeoutBackstopBelowGoDefault +# (internal/repoguard) pins both bounds. It is NOT a growth allowance — the +# guard and the budget row are the growth detectors. It exists so an overrun fails with the named +# "no package ran past the … -timeout backstop" assert and the offending FAIL line, +# instead of a 10m goroutine-dump panic. Never raise it to make a slow package fit; +# move the slow tests behind the integration tag instead. # # WHY -count=1. The detector's verdict must never be served from Go's test-result # cache: a cached "ok" certifies a previous tree, not this one. -count=1 forces a @@ -107,8 +126,11 @@ fi # non-zero, so the captured output is replayed on failure rather than # summarized — the WARNING block names the two conflicting stacks and is the # whole diagnostic. -race_out="$(go test -race $go_conc_args -count=1 ./... 2>&1)" +# The backstop — see BACKSTOP TIMEOUT in this header (change 0465). +RACE_TIMEOUT="8m" +race_out="$(go test -race $go_conc_args -timeout "$RACE_TIMEOUT" -count=1 ./... 2>&1)" race_rc=$? assert "go test -race -count=1 ./... (the whole module) passes" '[ "$race_rc" -eq 0 ] || { printf "%s\n" "$race_out" >&2; false; }' +assert "no package ran past the ${RACE_TIMEOUT} -timeout backstop" '! grep -q -E -e "^panic: test timed out after" <<<"$race_out" || { grep -E -e "^(FAIL[[:space:]]|panic: test timed out)" <<<"$race_out" >&2; printf "%s\n" "tests/test_go_race.sh: a package ran past the ${RACE_TIMEOUT} backstop — the fast default corpus has outgrown this gate; move real-git, subprocess, and process-lifecycle tests behind //go:build integration (see PARTITION AND LANE in this file)" >&2; false; }' exit "$fail"