Skip to content
Open
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 @@ -224,6 +224,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
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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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)
}
})
}
}
1 change: 1 addition & 0 deletions internal/skill/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ When ` + "`next_action.code`" + ` is ` + "`recover_custody`" + `, a terminal run
Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under ` + "`refs/no-mistakes/recover-local/<run>`" + `.
That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours.
A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op.
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.
A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/<run>`" + `.
If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement.
After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent.
Expand Down
1 change: 1 addition & 0 deletions skills/no-mistakes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ When `next_action.code` is `recover_custody`, a terminal run left unpublished pi
Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under `refs/no-mistakes/recover-local/<run>`.
That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours.
A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op.
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.
A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/<run>`.
If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement.
After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent.
Expand Down
Loading