Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/content/docs/guides/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Before any post-pipeline local commit or fresh run, read `branch_sync`.
Only when its structured `next_action.code` is `sync`, run `no-mistakes axi sync` first.
When `next_action.code` is `recover_custody` - a terminal run left unpublished pipeline commits preserved in the local gate - run `no-mistakes axi sync --recover` to return custody, or `no-mistakes rerun` to resume validating the preserved head.
A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: it is immediately usable and needs no sync action.
A terminal `pipeline_owned` state with `safety: blocked_recover_preserved_head_missing` may still require manual reconciliation for sync or recovery; `no-mistakes axi run` rechecks that the recorded head and recovery evidence are truly absent and can start unrelated fresh work only in that case. Any surviving commit or recovery anchor keeps custody blocked.
When `next_action.code` is `continue_active_run`, run the reported command and keep driving the active run.
If synchronization is blocked, process that state instead of improvising reset, stash, merge, rebase, force, or branch replacement.
Then commit follow-up work on top so every pipeline fix commit remains in the branch.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Run `axi sync` only when structured output offers `next_action.code: sync`; proc
A run that goes terminal (cancelled, failed, or completed without a push stage) after moving the pipeline head leaves the branch `pipeline_owned`. Status offers `next_action.code: recover_custody` only when recovery can establish the same eligibility it will enforce: an equal or ahead local head proves the source locally and can create the local anchor when the gate is unavailable, but any existing gate recovery ref must still match the recorded head; importing a missing preserved head requires an exact run-specific gate anchor (or legacy commit evidence that can be anchored), a clean worktree, and either local ancestry or the content-preservation proof described below. The eligible state reports `safety: blocked_pipeline_owned_recoverable`, the run's terminal `pipeline.status`, and the exact `submitted_head`/`current_head`/`relation` ownership facts.
A run whose terminalization verifies that the managed worktree head never changed from the submitted head releases the branch instead: the terminal outcome, including cancellation, ends ownership; status reports `state: user_owned` with the same exact ownership facts and no `next_action`; the branch and head are immediately usable for any separately authorized delivery; and nothing blocks a direct push or PR.
Without positive evidence that the submitted head stayed unchanged, custody is not guessed away. Missing or conflicting evidence, and import cases with a dirty worktree or genuinely divergent history, require manual reconciliation instead of advertising a recovery that will refuse.
If a terminal moved head is genuinely absent from both the invoking worktree and local gate, and no recovery anchor survives, sync and recovery remain manual-only but that unrecoverable historical record does not block `axi run` from attempting unrelated fresh work; the fresh path rechecks the absence and ordinary gate push safety still applies.
While a run is still active, it reports `state: pipeline_owned`, the exact submitted/current heads and their relation, and `next_action.code: continue_active_run` with `no-mistakes axi status`, even when its head has not moved yet.
`--recover` verifies the run is terminal, anchors the preserved head under `refs/no-mistakes/recover/<run>` in the invoking repository, and stamps custody returned so a fresh run can start.
For equal or ahead worktrees where the preserved head is already locally reachable, recovery writes that anchor locally without requiring gate access. If the gate is available, an existing symbolic, non-commit, or mismatched recovery ref is conflicting evidence and recovery refuses without overwriting it.
Expand Down
53 changes: 52 additions & 1 deletion internal/branchsync/recover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ func TestRecoverDivergedRefusesButKeepLocalReturnsCustody(t *testing.T) {
}

kept := f.service.Recover(f.ctx, true)
if !kept.Recovered || kept.Changed {
if !kept.Recovered || kept.Changed || kept.State != StateCustodyReturned || kept.Safety != "custody_returned" {
t.Fatalf("keep-local recover = %#v", kept)
}
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != divergedHead {
Expand All @@ -468,6 +468,57 @@ func TestRecoverDivergedRefusesButKeepLocalReturnsCustody(t *testing.T) {
}
}

// TestRecoverKeepLocalMissingPreservedHeadDoesNotClaimRecovery pins the
// missing-head boundary: --keep-local may return custody only after the
// preserved head is verified. A dead run with no recoverable object must stay
// manual-only instead of reporting recovered=true and stranding ownership.
func TestRecoverKeepLocalMissingPreservedHeadDoesNotClaimRecovery(t *testing.T) {
t.Parallel()

f := newRecoverFixture(t, types.RunFailed)
missing := strings.Repeat("f", 40)
if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, types.RunFailed, missing); err != nil {
t.Fatal(err)
}

state := f.service.Recover(f.ctx, true)
if state.Recovered || state.Changed || state.State != StatePipelineOwned || state.Safety != "blocked_recover_preserved_head_missing" {
t.Fatalf("missing-head keep-local recovery = %#v", state)
}
if f.custodyReturned() {
t.Fatal("missing-head recovery stamped custody")
}
}

// TestNormalizeRecoveredStateDoesNotLeavePipelineOwnership proves the durable
// custody stamp wins over a stale pre-recovery classification. This is the
// state returned by the live recovery loop: recovered=true must not send the
// caller back to recover_custody forever.
func TestNormalizeRecoveredStateDoesNotLeavePipelineOwnership(t *testing.T) {
t.Parallel()

f := newRecoverFixture(t, types.RunFailed)
if err := f.db.SetRunCustodyReturned(f.run.ID); err != nil {
t.Fatal(err)
}
run, err := f.db.GetRun(f.run.ID)
if err != nil {
t.Fatal(err)
}
state := State{
State: StatePipelineOwned,
Local: LocalState{Branch: "feature/recover", Head: f.submitted, Clean: true},
Pipeline: PipelineState{CurrentHead: f.preserved},
}
f.service.normalizeRecoveredState(f.ctx, &state, run)
if state.State != StateCustodyReturned || state.Safety != "custody_returned" {
t.Fatalf("normalized recovered state = %#v", state)
}
if state.NextAction == nil || state.NextAction.Code != "run_pipeline" {
t.Fatalf("normalized recovered next action = %#v", state.NextAction)
}
}

// TestRecoverKeepLocalDirtyBehindReturnsCustodyWithoutTouchingWorktree covers
// the explicit keep-local choice on a dirty worktree: no worktree mutation is
// needed, so dirtiness must not block it, and the gate follows the kept head.
Expand Down
24 changes: 23 additions & 1 deletion internal/branchsync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State {
}
state, run, _ := s.inspect(ctx)
if run != nil && run.CustodyReturnedAt != nil {
s.normalizeRecoveredState(ctx, &state, run)
state.Recovered = true
state.Changed = false
return state
Expand Down Expand Up @@ -995,12 +996,33 @@ func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed bool)
state.NextAction = nil
return state
}
state, _, _ := s.inspect(ctx)
state, freshRun, ok := s.inspect(ctx)
// The stamp is the custody transition. If inspection raced with the
// terminal run's old classification, never report a successful recovery
// while continuing to advertise pipeline ownership.
if ok {
s.normalizeRecoveredState(ctx, &state, freshRun)
}
state.Recovered = true
state.Changed = changed
return state
}

// normalizeRecoveredState applies the durable custody transition to a state
// snapshot that was classified before or during the recovery stamp. A stale
// pipeline_owned result must never be returned with recovered=true: callers
// use that state to decide whether another recovery is required.
func (s *Service) normalizeRecoveredState(ctx context.Context, state *State, run *db.Run) {
if state == nil || run == nil || run.CustodyReturnedAt == nil || state.State != StatePipelineOwned {
return
}
if run.LastPushedSHA == nil {
s.classifyCustodyReturned(ctx, state)
return
}
s.classifyRelation(ctx, state, ptr(run.LastPushedSHA), run.BaseSHA, false)
}

func recoverAnchorRef(runID string) string {
return custody.RecoveryRef(runID)
}
Expand Down
57 changes: 57 additions & 0 deletions internal/cli/axi_drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"time"

toon "github.com/toon-format/toon-go"

"github.com/kunchenguid/no-mistakes/internal/branchsync"
"github.com/kunchenguid/no-mistakes/internal/cimonitor"
"github.com/kunchenguid/no-mistakes/internal/custody"
"github.com/kunchenguid/no-mistakes/internal/daemon"
"github.com/kunchenguid/no-mistakes/internal/db"
"github.com/kunchenguid/no-mistakes/internal/gate"
Expand Down Expand Up @@ -277,6 +279,14 @@ func freshRunBranchOwnershipState(ctx context.Context, env *axiEnv) *branchsync.
if branchsync.RunHeadUnmoved(state) {
return nil
}
// A terminal run can retain a moved head in its record after that
// commit and all recovery evidence have disappeared. Keep the
// branch-sync state manual-only, but do not let an unrecoverable
// historical record block unrelated fresh work forever. Any surviving
// commit or recovery anchor remains a custody block.
if state.Safety == "blocked_recover_preserved_head_missing" && freshRunHasNoPipelineEvidence(ctx, env, state) {
return nil
}
return &state
case branchsync.StatePushInProgress:
return &state
Expand All @@ -285,6 +295,53 @@ func freshRunBranchOwnershipState(ctx context.Context, env *axiEnv) *branchsync.
}
}

// freshRunHasNoPipelineEvidence permits a fresh delivery only after positive
// local evidence shows that a terminal run's recorded moved head and its
// recovery anchors are gone. Unreadable repositories or conflicting anchors
// fail closed so this exception cannot discard recoverable pipeline work.
func freshRunHasNoPipelineEvidence(ctx context.Context, env *axiEnv, state branchsync.State) bool {
if env == nil || env.p == nil || env.repo == nil || strings.TrimSpace(state.Pipeline.RunID) == "" || strings.TrimSpace(state.Pipeline.CurrentHead) == "" {
return false
}
if _, err := git.Run(ctx, ".", "cat-file", "-e", state.Pipeline.CurrentHead+"^{commit}"); err == nil {
return false
}
if recoveryEvidencePresent(ctx, ".", state.Pipeline.RunID) {
return false
}

gateDir := env.p.RepoDir(env.repo.ID)
info, err := os.Stat(gateDir)
if err != nil {
return os.IsNotExist(err)
}
if !info.IsDir() {
return false
}
if recoveryEvidencePresent(ctx, gateDir, state.Pipeline.RunID) {
return false
}
if _, err := git.Run(ctx, gateDir, "cat-file", "-e", state.Pipeline.CurrentHead+"^{commit}"); err == nil {
return false
}
// A present but unreadable gate is not proof that the pipeline head is
// gone. Treat it as evidence we cannot safely inspect.
bare, err := git.Run(ctx, gateDir, "rev-parse", "--is-bare-repository")
if err != nil || strings.TrimSpace(bare) != "true" {
return false
}
return true
}

func recoveryEvidencePresent(ctx context.Context, dir, runID string) bool {
ref := custody.RecoveryRef(runID)
if target, err := git.Run(ctx, dir, "symbolic-ref", "-q", ref); err == nil && strings.TrimSpace(target) != "" {
return true
}
_, exists, err := git.ExactRefTarget(ctx, dir, ref)
return err != nil || exists
}

// triggerRun starts a fresh run for branch: it pushes the current HEAD through
// the gate to trigger a pipeline, and falls back to a rerun when the push was a
// no-op (the gate already had this commit). Callers must check for an existing
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/axi_guidance.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ const preserveGateFixCommitsGuidance = "Commit post-pipeline follow-up work on t
// branchSyncAgentGuidance is emitted only when a relevant branch_sync object
// is present. Keeping it conditional avoids flooding ordinary runs whose local
// and pipeline heads never differed.
const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. Process blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement."
const branchSyncAgentGuidance = "Before a post-pipeline local commit or fresh run, follow the structured `branch_sync.next_action`. Run `no-mistakes axi sync` only when its code is `sync`; that guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics. Run `no-mistakes axi sync --recover` only when its code is `recover_custody` (a terminal run left unpublished pipeline commits preserved in the local gate). A `user_owned` state means cancellation released the branch before changing the submitted head: the exact branch and head are yours, immediately usable, and no sync action is needed. A terminal `pipeline_owned` state with `safety: blocked_recover_preserved_head_missing` may still require manual reconciliation for sync or recovery; `no-mistakes axi run` rechecks that the recorded head and recovery evidence are truly absent and can start unrelated fresh work only in that case. Any surviving commit or recovery anchor keeps custody blocked. Process other blocked or pipeline-owned states instead of improvising reset, stash, merge, rebase, force, or branch replacement."
125 changes: 125 additions & 0 deletions internal/cli/axi_missing_head_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package cli

import (
"context"
"os"
"strings"
"testing"

"github.com/kunchenguid/no-mistakes/internal/config"
"github.com/kunchenguid/no-mistakes/internal/custody"
"github.com/kunchenguid/no-mistakes/internal/types"
)

// TestFreshRunBranchOwnershipDistinguishesMissingTerminalHead reproduces the
// custody deadlock caused by a terminal run whose moved head is no longer
// available. That state must remain manual-only for recovery, while it must
// not keep unrelated fresh work behind a custody claim for commits that no
// longer exist.
func TestFreshRunBranchOwnershipDistinguishesMissingTerminalHead(t *testing.T) {
for _, tc := range []struct {
name string
recordedHead func(t *testing.T, submitted string) string
advanceWorktree bool
gatePresent bool
survivingAnchor bool
verifiedHead bool
wantFreshBlocked bool
wantSafety string
}{
{
name: "terminal unmoved releases branch",
recordedHead: func(_ *testing.T, submitted string) string {
return submitted
},
verifiedHead: true,
wantFreshBlocked: false,
wantSafety: "user_owned",
},
{
name: "terminal recoverable moved head keeps custody",
recordedHead: func(_ *testing.T, submitted string) string {
return submitted
},
advanceWorktree: true,
verifiedHead: true,
wantFreshBlocked: true,
wantSafety: "blocked_pipeline_owned_recoverable",
},
{
name: "terminal missing moved head releases fresh path",
recordedHead: func(_ *testing.T, _ string) string {
return strings.Repeat("f", 40)
},
gatePresent: true,
wantFreshBlocked: false,
wantSafety: "blocked_recover_preserved_head_missing",
},
{
name: "missing head with recovery evidence keeps custody",
recordedHead: func(_ *testing.T, _ string) string {
return strings.Repeat("f", 40)
},
gatePresent: true,
survivingAnchor: true,
wantFreshBlocked: true,
wantSafety: "blocked_recover_preserved_head_missing",
},
} {
t.Run(tc.name, func(t *testing.T) {
repoDir, paths, database, repo := setupAxiQueryRepo(t)
cliGit(t, repoDir, "checkout", "-b", "feature/missing-head")
chdir(t, repoDir)

submitted := cliGit(t, repoDir, "rev-parse", "HEAD")
recorded := tc.recordedHead(t, submitted)
if tc.advanceWorktree {
cliGit(t, repoDir, "commit", "--allow-empty", "-m", "pipeline fix")
recorded = cliGit(t, repoDir, "rev-parse", "HEAD")
}
if tc.gatePresent {
gateDir := paths.RepoDir(repo.ID)
if err := os.MkdirAll(gateDir, 0o755); err != nil {
t.Fatalf("create gate directory: %v", err)
}
cliGit(t, gateDir, "init", "--bare")
cliGit(t, repoDir, "push", gateDir, "HEAD:refs/heads/feature/missing-head")
}

pipelineRun, err := database.InsertRun(repo.ID, "feature/missing-head", submitted, submitted)
if err != nil {
t.Fatalf("insert pipeline run: %v", err)
}
if err := database.UpdateRunHeadSHA(pipelineRun.ID, recorded); err != nil {
t.Fatalf("record pipeline head: %v", err)
}
if tc.verifiedHead {
if err := database.UpdateRunStatusWithVerifiedHead(pipelineRun.ID, types.RunCancelled, recorded); err != nil {
t.Fatalf("terminalize pipeline run: %v", err)
}
} else if err := database.UpdateRunStatus(pipelineRun.ID, types.RunCancelled); err != nil {
t.Fatalf("terminalize pipeline run: %v", err)
}
if tc.survivingAnchor {
cliGit(t, paths.RepoDir(repo.ID), "update-ref", custody.RecoveryRef(pipelineRun.ID), submitted)
}

env := &axiEnv{p: paths, d: database, repo: repo, cfg: config.DefaultGlobalConfig()}
state := inspectAxiBranchSync(context.Background(), env)
if state.Safety != tc.wantSafety {
t.Fatalf("branch ownership state = %s, want %s: %#v", state.Safety, tc.wantSafety, state)
}
blocked := freshRunBranchOwnershipState(context.Background(), env)
if (blocked != nil) != tc.wantFreshBlocked {
t.Fatalf("fresh-run ownership = %#v, blocked = %t, want blocked = %t", blocked, blocked != nil, tc.wantFreshBlocked)
}
if tc.wantFreshBlocked && blocked.NextAction == nil {
t.Fatal("recoverable terminal head lost its custody guidance")
}
if tc.wantSafety == "blocked_recover_preserved_head_missing" &&
(state.NextAction == nil || state.NextAction.Code != "inspect_and_reconcile_manually") {
t.Fatalf("missing-head state lost manual reconciliation guidance: %#v", state)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/daemon/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,10 @@ func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushRec
return "", err
}

if !samePath(params.Gate, m.paths.RepoDir(repoID)) {
return "", fmt.Errorf("home/gate mismatch: gate %q does not belong to daemon home %q", params.Gate, m.paths.RepoDir(repoID))
}

repo, err := m.db.GetRepo(repoID)
if err != nil {
return "", fmt.Errorf("get repo: %w", err)
Expand Down
27 changes: 27 additions & 0 deletions internal/daemon/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,33 @@ func TestPushReceivedTracksRunTelemetry(t *testing.T) {
}
}

func TestPushReceivedRejectsGateFromDifferentHome(t *testing.T) {
p, d := startTestDaemonWithSteps(t, func() []pipeline.Step {
return []pipeline.Step{&mockPassStep{name: types.StepReview}}
})

const repoID = "cross-home-repo"
_, headSHA := setupTestGitRepo(t, p, d, repoID)
wrongGate := filepath.Join(t.TempDir(), "repos", repoID+".git")

client, err := ipc.Dial(p.Socket())
if err != nil {
t.Fatal(err)
}
defer client.Close()

var result ipc.PushReceivedResult
err = client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{
Gate: wrongGate,
Ref: "refs/heads/main",
Old: "0000000000000000000000000000000000000000",
New: headSHA,
}, &result)
if err == nil || !strings.Contains(err.Error(), "home/gate mismatch") {
t.Fatalf("push from different home error = %v, want explicit home/gate mismatch", err)
}
}

func TestPushReceivedSkipStepsConfiguresExecutor(t *testing.T) {
review := &mockPassStep{name: types.StepReview}
testStep := &mockPassStep{name: types.StepTest}
Expand Down
Loading
Loading