diff --git a/docs/src/content/docs/guides/agents.md b/docs/src/content/docs/guides/agents.md index d8d6d98ac..74ccddc95 100644 --- a/docs/src/content/docs/guides/agents.md +++ b/docs/src/content/docs/guides/agents.md @@ -169,6 +169,7 @@ no-mistakes axi status no-mistakes axi sync --check no-mistakes axi sync no-mistakes axi sync --recover +no-mistakes axi sync --adopt-published no-mistakes axi respond --action approve no-mistakes axi logs --step review --full no-mistakes axi abort @@ -178,6 +179,7 @@ no-mistakes axi abort --run 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. +When `next_action.code` is `adopt_published`, run `no-mistakes axi sync --adopt-published`: it verifies the configured push target already has the exact rebased local head, then updates only the stale gate lane. A target mismatch or target change refuses without replacing that lane. 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. 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. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f7621dc17..62e78e3d1 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -199,6 +199,7 @@ no-mistakes axi sync --check no-mistakes axi sync no-mistakes axi sync --recover no-mistakes axi sync --recover --keep-local +no-mistakes axi sync --adopt-published ``` | Flag | Type | Default | Description | @@ -206,6 +207,7 @@ no-mistakes axi sync --recover --keep-local | `--check` | `bool` | `false` | Verify the live target and exact plan without changing `HEAD` | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | | `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree | +| `--adopt-published` | `bool` | `false` | Adopt a clean diverged local head into its stale gate lane only when the configured push target has that exact head | The default command is an explicit non-interactive apply request and never prompts. All modes return the complete `branch_sync` object as TOON. @@ -219,6 +221,12 @@ Fork configurations verify the configured fork URL and exact feature ref rather Dirty, in-progress, ahead, genuinely diverged, detached, wrong-branch, offline, changed-target, rewritten, deleted, legacy, or retired states fail closed without destructive recovery. Run `axi sync` only when structured output offers `next_action.code: sync`; process any blocked state instead of substituting reset, stash, merge, rebase, force, or branch replacement. +### Published-rebase gate recovery + +A custody-returned branch can later be rebased and force-with-lease pushed to its configured target. Its local head then diverges from the preserved gate lane, so an ordinary gate push correctly rejects it as non-fast-forward. Status reports `state: custody_returned`, `relation: diverged`, and `next_action.code: adopt_published` instead of directing another rejected `axi run`. + +`axi sync --adopt-published` is the explicit recovery. It requires a clean exact checked-out branch, the same recovered lane at its recorded preserved head, and a live configured push target whose branch exactly equals local `HEAD`. It fetches that verified object into the local gate, preserves the old gate head under the run's recovery ref, then compare-and-swaps only the current lane. It never pushes to the configured target or changes the worktree. A missing, changed, or different target head, a changed gate lane, or changed local assumptions refuses without replacing the lane. + ### Custody recovery 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. @@ -356,6 +364,7 @@ no-mistakes sync --check no-mistakes sync --yes no-mistakes sync --recover no-mistakes sync --recover --keep-local +no-mistakes sync --adopt-published ``` | Flag | Type | Default | Description | @@ -364,8 +373,9 @@ no-mistakes sync --recover --keep-local | `-y`, `--yes` | `bool` | `false` | Apply an eligible guarded synchronization without an interactive prompt | | `--recover` | `bool` | `false` | Return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch) | | `--keep-local` | `bool` | `false` | With `--recover`: keep the current local head; never touches the worktree | +| `--adopt-published` | `bool` | `false` | Adopt a clean diverged local head into its stale gate lane only when the configured push target has that exact head | -Without `--yes`, apply prints the exact full-SHA plan and requires TTY confirmation; `--recover` prompts the same way before returning custody. +Without `--yes`, apply prints the exact full-SHA plan and requires TTY confirmation; `--recover` and `--adopt-published` prompt the same way before they mutate a gate lane. A non-TTY apply or recovery refuses with a direct `--yes` hint. The command uses the same service and safety contract as `no-mistakes axi sync`, including the guarded equivalent advance and custody recovery documented there; it never stashes, rebases, creates a merge commit, switches branches, deletes a branch, or updates an external remote. diff --git a/internal/branchsync/sync.go b/internal/branchsync/sync.go index 52e040a13..db910e9aa 100644 --- a/internal/branchsync/sync.go +++ b/internal/branchsync/sync.go @@ -1001,6 +1001,98 @@ func (s *Service) finishRecover(ctx context.Context, run *db.Run, changed bool) return state } +// AdoptPublished moves one stale custody-returned gate lane to a rewritten +// branch only after the configured push target proves that the exact local +// head is already published there. It never pushes to that target or changes +// the worktree. The gate update is a compare-and-swap, so a concurrent lane +// update wins rather than being overwritten. +func (s *Service) AdoptPublished(ctx context.Context) State { + if refusal, blocked := s.gateContextRefusal(ctx); blocked { + return refusal + } + state, run, ok := s.inspect(ctx) + if !ok || run == nil || state.State != StateCustodyReturned || run.CustodyReturnedAt == nil || state.Relation != RelationDiverged { + return blockedPlan(state, state.State, "blocked_adopt_published_not_applicable", "adopting a published head requires a custody-returned branch whose local and preserved histories have diverged; no files or gate refs were changed") + } + if !state.Local.Clean { + return blockedPlan(state, StateDirty, "blocked_adopt_published_dirty", "the invoking worktree is not completely clean; no files or gate refs were changed") + } + if strings.TrimSpace(s.GateDir) == "" { + return blockedPlan(state, StateAmbiguousContext, "blocked_adopt_published_gate_unavailable", "the local gate is unavailable, so the lane cannot be adopted; no files or gate refs were changed") + } + + branchRef := "refs/heads/" + state.Local.Branch + gateHead, err := git.Run(ctx, s.GateDir, "rev-parse", branchRef+"^{commit}") + if err != nil || gateHead != state.Pipeline.CurrentHead { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_gate_changed", "the gate lane no longer matches the recovered pipeline head; no files or gate refs were changed") + } + + pushURL := s.resolvedPushURL(ctx) + if strings.TrimSpace(pushURL) == "" { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_target_unavailable", "the configured push target is unavailable; no files or gate refs were changed") + } + liveCtx, cancel := context.WithTimeout(ctx, s.remoteTimeout()) + live, err := s.runLsRemote(liveCtx, s.workDir(), pushURL, branchRef) + cancel() + if err != nil { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_offline", "could not verify the configured push target; no files or gate refs were changed") + } + if live != state.Local.Head { + return blockedPlan(state, StateCustodyReturned, "blocked_published_head_mismatch", "the configured push target does not exactly match the local rebased head; no files or gate refs were changed") + } + + // Import the verified remote object through a private temporary ref before + // changing the lane. FetchRemoteRef rejects a target race and removes its + // temporary ref itself, so it cannot leave a long-lived staging branch. + if err := git.FetchRemoteRef(ctx, s.GateDir, pushURL, branchRef, state.Local.Head); err != nil { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_fetch_failed", "the published head could not be imported into the local gate; no files or gate refs were changed") + } + liveCtx, cancel = context.WithTimeout(ctx, s.remoteTimeout()) + live, err = s.runLsRemote(liveCtx, s.workDir(), pushURL, branchRef) + cancel() + if err != nil || live != state.Local.Head { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_target_changed", "the configured push target changed while the published head was being verified; no files or gate refs were changed") + } + + // Re-read local ownership after the remote operations. The first inspection + // is only a snapshot; no gate mutation may follow a branch, HEAD, or custody + // change made while the target was checked. + recheck, recheckRun, recheckOK := s.inspect(ctx) + if !recheckOK || recheckRun == nil || recheckRun.ID != run.ID || recheckRun.CustodyReturnedAt == nil || + recheck.State != StateCustodyReturned || recheck.Relation != RelationDiverged || !recheck.Local.Clean || + recheck.Local.Branch != state.Local.Branch || recheck.Local.Head != state.Local.Head { + return blockedPlan(recheck, StateCustodyReturned, "blocked_adopt_published_assumptions_changed", "the branch, HEAD, or custody state changed while the published head was being verified; no files or gate refs were changed") + } + + if err := custody.PreserveRecoveryHead(ctx, s.GateDir, run.ID, gateHead); err != nil { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_preserve_failed", "the recovered gate head could not be preserved before lane adoption; no files or gate refs were changed") + } + if _, err := git.Run(ctx, s.GateDir, "update-ref", branchRef, state.Local.Head, gateHead); err != nil { + return blockedPlan(state, StateCustodyReturned, "blocked_adopt_published_gate_race", "the gate lane changed while the published head was being adopted; the lane was not replaced and the recovered head remains preserved") + } + + adopted, _, _ := s.inspect(ctx) + adopted.Changed = true + return adopted +} + +// resolvedPushURL mirrors the pipeline's credential-preserving routing: the +// database deliberately stores a redacted upstream URL, while the invoking +// clone's origin can still carry the credentials needed to query it. +func (s *Service) resolvedPushURL(ctx context.Context) string { + if s.Repo == nil { + return "" + } + if strings.TrimSpace(s.Repo.ForkURL) != "" { + return s.Repo.ForkURL + } + if originURL, err := git.GetRemoteURL(ctx, s.workDir(), "origin"); err == nil && strings.TrimSpace(originURL) != "" && + (!s.Repo.URLsVerified || safeurl.Redact(originURL) == s.Repo.UpstreamURL) { + return originURL + } + return s.Repo.UpstreamURL +} + func recoverAnchorRef(runID string) string { return custody.RecoveryRef(runID) } @@ -1563,15 +1655,29 @@ func RunHeadUnmoved(state State) bool { } // classifyCustodyReturned reports a branch whose stranded terminal run was -// explicitly recovered and never had a push binding: the operator owns the -// branch again and the only remaining step is starting a fresh run. The -// relation against the preserved pipeline head is informative only. +// explicitly recovered and never had a push binding. A diverged local head is +// not ready to start a fresh run until the gate lane has safely adopted the +// already-published rewrite; all other relationships remain informative only. func (s *Service) classifyCustodyReturned(ctx context.Context, state *State) { state.State = StateCustodyReturned - state.Safety = "custody_returned" state.Error = "" - state.NextAction = &NextAction{Code: "run_pipeline", Command: `no-mistakes axi run --intent ""`} state.Relation = relationBetween(ctx, s.workDir(), state.Local.Head, state.Pipeline.CurrentHead) + if state.Relation == RelationDiverged { + branchRef := "refs/heads/" + state.Local.Branch + if strings.TrimSpace(s.GateDir) != "" { + gateHead, err := git.Run(ctx, s.GateDir, "rev-parse", branchRef+"^{commit}") + if err == nil && gateHead == state.Local.Head { + state.Safety = "gate_ready" + state.NextAction = &NextAction{Code: "run_pipeline", Command: `no-mistakes axi run --intent ""`} + return + } + } + state.Safety = "recovery_required" + state.NextAction = &NextAction{Code: "adopt_published", Command: "no-mistakes axi sync --adopt-published"} + return + } + state.Safety = "custody_returned" + state.NextAction = &NextAction{Code: "run_pipeline", Command: `no-mistakes axi run --intent ""`} } // relationBetween classifies the local head against a target commit using only diff --git a/internal/cli/axi_guidance.go b/internal/cli/axi_guidance.go index 178b913ba..3151c5d9c 100644 --- a/internal/cli/axi_guidance.go +++ b/internal/cli/axi_guidance.go @@ -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). Run `no-mistakes axi sync --adopt-published` only when its code is `adopt_published`: it verifies the configured push target already has the exact rebased local head, then updates only the stale gate lane. 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." diff --git a/internal/cli/axi_guidance_test.go b/internal/cli/axi_guidance_test.go index 212e3be58..325b598ca 100644 --- a/internal/cli/axi_guidance_test.go +++ b/internal/cli/axi_guidance_test.go @@ -44,6 +44,9 @@ var canonicalBranchSyncPhrases = []string{ "recover_custody", "no-mistakes axi sync --recover", "preserved in the local gate", + "adopt_published", + "no-mistakes axi sync --adopt-published", + "configured push target", // Cancellation releases a run that never changed the submitted head // (v1.44.2 dogfood catch): every surface must name the released state and // that it needs no recovery. diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 2d4d1bc68..13df83101 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -17,7 +17,7 @@ import ( var syncInteractive = terminalInteractive func newSyncCmd() *cobra.Command { - var check, yes, recover, keepLocal bool + var check, yes, recover, keepLocal, adoptPublished bool cmd := &cobra.Command{ Use: "sync", Short: "Safely move the current branch to an exact pipeline-pushed head", @@ -34,14 +34,16 @@ func newSyncCmd() *cobra.Command { "carry every local change. Unproven divergence refuses. A run cancelled before\n" + "the pipeline changed anything releases the branch by itself (user_owned) and\n" + "makes --recover a no-op. --recover --keep-local keeps the current local head\n" + - "instead and never touches the worktree.", + "instead and never touches the worktree. --adopt-published moves a stale\n" + + "custody-returned gate lane only after the configured push target proves the\n" + + "exact divergent local head is already published there.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if check && yes { return &exitError{code: 2, err: fmt.Errorf("--check and --yes cannot be used together")} } - if check && recover { - return &exitError{code: 2, err: fmt.Errorf("--check and --recover cannot be used together")} + if (check && recover) || (check && adoptPublished) || (recover && adoptPublished) { + return &exitError{code: 2, err: fmt.Errorf("choose only one of --check, --recover, and --adopt-published")} } if keepLocal && !recover { return &exitError{code: 2, err: fmt.Errorf("--keep-local requires --recover")} @@ -49,6 +51,9 @@ func newSyncCmd() *cobra.Command { if recover { return runHumanRecover(cmd, keepLocal, yes) } + if adoptPublished { + return runHumanAdoptPublished(cmd, yes) + } return runHumanSync(cmd, check, yes) }, } @@ -56,11 +61,12 @@ func newSyncCmd() *cobra.Command { cmd.Flags().BoolVarP(&yes, "yes", "y", false, "apply an eligible guarded synchronization without prompting") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the preserved commits stay anchored and the gate follows the kept head") + cmd.Flags().BoolVar(&adoptPublished, "adopt-published", false, "adopt a clean diverged local head into its stale gate lane only when the configured push target already has that exact head") return cmd } func newAxiSyncCmd() *cobra.Command { - var check, recover, keepLocal bool + var check, recover, keepLocal, adoptPublished bool cmd := &cobra.Command{ Use: "sync", Short: "Check or apply guarded current-branch synchronization", @@ -72,23 +78,26 @@ func newAxiSyncCmd() *cobra.Command { "verified pipeline head with reset semantics.\n" + "--check performs the same fresh read-only plan. Blocked states change nothing.\n" + "--recover performs the guarded custody return offered by\n" + - "next_action.code: recover_custody; --keep-local keeps the current local head.", + "next_action.code: recover_custody; --keep-local keeps the current local head.\n" + + "--adopt-published performs the guarded gate-lane recovery offered by\n" + + "next_action.code: adopt_published.", Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if check && recover { - return emitError(cmd, 2, "--check and --recover cannot be used together") + if (check && recover) || (check && adoptPublished) || (recover && adoptPublished) { + return emitError(cmd, 2, "choose only one of --check, --recover, and --adopt-published") } if keepLocal && !recover { return emitError(cmd, 2, "--keep-local requires --recover") } - return runAxiSync(cmd, check, recover, keepLocal) + return runAxiSync(cmd, check, recover, keepLocal, adoptPublished) }, } cmd.Flags().BoolVar(&check, "check", false, "freshly verify and return the plan without changing HEAD") cmd.Flags().BoolVar(&recover, "recover", false, "return custody of a branch stranded by a terminal run with unpublished pipeline commits (a no-op when cancellation already released the branch)") cmd.Flags().BoolVar(&keepLocal, "keep-local", false, "with --recover: keep the current local head; the preserved commits stay anchored and the gate follows the kept head") + cmd.Flags().BoolVar(&adoptPublished, "adopt-published", false, "adopt a clean diverged local head into its stale gate lane only when the configured push target already has that exact head") return cmd } @@ -252,6 +261,52 @@ func runHumanRecover(cmd *cobra.Command, keepLocal, yes bool) error { return &exitError{code: 1} } +func runHumanAdoptPublished(cmd *cobra.Command, yes bool) error { + started := time.Now() + var observed branchsync.State + result := "error" + defer func() { trackSyncAttempt("sync", "human_cli", "adopt_published", observed, result, started) }() + + service, closeFn, err := openSyncService() + if err != nil { + return err + } + defer closeFn() + + observed = service.InspectCached(cmd.Context()) + if !yes { + printHumanSyncState(cmd, observed) + if !syncInteractive() { + fmt.Fprintln(cmd.OutOrStdout(), " Non-interactive input cannot confirm this recovery. Re-run with `no-mistakes sync --adopt-published --yes`.") + result = "refused" + return &exitError{code: 1} + } + fmt.Fprintln(cmd.OutOrStdout(), " This verifies that the configured push target already has your exact rebased head,") + fmt.Fprintln(cmd.OutOrStdout(), " then updates only this stale local gate lane. It never changes the target or worktree.") + fmt.Fprint(cmd.OutOrStdout(), " Adopt the published head into this gate lane? [y/N] ") + line, readErr := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if readErr != nil && strings.TrimSpace(line) == "" { + return readErr + } + answer := strings.ToLower(strings.TrimSpace(line)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(cmd.OutOrStdout(), " Cancelled; no files or refs were changed.") + result = "cancelled" + return nil + } + } + + state := service.AdoptPublished(cmd.Context()) + observed = state + printHumanSyncState(cmd, state) + if state.Changed { + result = "applied" + return nil + } + result = "refused" + return &exitError{code: 1} +} + func printHumanSyncState(cmd *cobra.Command, state branchsync.State) { w := cmd.OutOrStdout() fmt.Fprintf(w, "\n Local branch: %s\n", humanSyncSummary(state)) @@ -279,6 +334,12 @@ func humanSyncSummary(state branchsync.State) string { } return "pipeline fix is not pushed yet; do not make local follow-up commits" case branchsync.StateCustodyReturned: + if state.Safety == "recovery_required" && state.NextAction != nil { + return "a rebased local head needs guarded gate-lane adoption before it can start a fresh run" + } + if state.Safety == "gate_ready" { + return "the published rebased head is present in this gate lane; start a fresh run when ready" + } return "custody returned; the branch is yours - start a fresh run when ready" case branchsync.StateUserOwned: return "run ended before the pipeline changed anything; the branch and head are yours and immediately usable" @@ -310,7 +371,7 @@ func humanSyncSummary(state branchsync.State) string { } } -func runAxiSync(cmd *cobra.Command, check, recover, keepLocal bool) error { +func runAxiSync(cmd *cobra.Command, check, recover, keepLocal, adoptPublished bool) error { started := time.Now() mode := "apply" switch { @@ -320,6 +381,8 @@ func runAxiSync(cmd *cobra.Command, check, recover, keepLocal bool) error { mode = "recover_keep_local" case recover: mode = "recover" + case adoptPublished: + mode = "adopt_published" } var state branchsync.State result := "error" @@ -336,6 +399,8 @@ func runAxiSync(cmd *cobra.Command, check, recover, keepLocal bool) error { state = service.Refresh(cmd.Context()) case recover: state = service.Recover(cmd.Context(), keepLocal) + case adoptPublished: + state = service.AdoptPublished(cmd.Context()) default: state = service.Apply(cmd.Context()) } @@ -358,6 +423,9 @@ func runAxiSync(cmd *cobra.Command, check, recover, keepLocal bool) error { if recover { successful = state.Recovered } + if adoptPublished { + successful = state.Changed + } if successful { if state.Changed { result = "applied" diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index 41b5cbfb0..9e4b05cf9 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -347,7 +347,7 @@ func TestAxiStatusCachedBranchSyncDoesNotFetch(t *testing.T) { } type cliRecoverFixture struct { - local, gate, submitted, preserved, runID string + local, remote, gate, submitted, preserved, runID string } // newCLIRecoverFixture reproduces the stranded custody state end to end for @@ -371,6 +371,8 @@ func newCLIRecoverFixture(t *testing.T) cliRecoverFixture { cliGit(t, local, "add", "file.txt") cliGit(t, local, "commit", "-m", "base") base := cliGit(t, local, "rev-parse", "HEAD") + cliGit(t, local, "remote", "add", "origin", remote) + cliGit(t, local, "push", "-u", "origin", "main") cliGit(t, local, "checkout", "-b", "feature/recover") if err := os.WriteFile(filepath.Join(local, "file.txt"), []byte("feature\n"), 0o644); err != nil { t.Fatal(err) @@ -428,7 +430,7 @@ func newCLIRecoverFixture(t *testing.T) cliRecoverFixture { t.Fatal(err) } chdir(t, local) - return cliRecoverFixture{local: local, gate: gate, submitted: submitted, preserved: preserved} + return cliRecoverFixture{local: local, remote: remote, gate: gate, submitted: submitted, preserved: preserved, runID: run.ID} } // newCLIUnmovedAbortFixture reproduces the pre-push abort taken when delivery @@ -918,6 +920,115 @@ func TestAxiSyncRecoverReturnsCustodyEndToEnd(t *testing.T) { } } +// rebaseReturnedCustodyBranch recreates the reported lane-staleness shape: a +// recovered branch is published, rebased onto a newer main, given a follow-up +// commit, then force-with-lease pushed to the actual target. The gate still +// holds the pre-rebase head because no pipeline run has yet seen the rewrite. +func rebaseReturnedCustodyBranch(t *testing.T, f cliRecoverFixture, publishRebase bool) string { + t.Helper() + if out, err := executeCmd("axi", "sync", "--recover"); err != nil { + t.Fatalf("return custody: %v\n%s", err, out) + } + cliGit(t, f.local, "push", "-u", "origin", "HEAD:refs/heads/feature/recover") + + cliGit(t, f.local, "checkout", "main") + if err := os.WriteFile(filepath.Join(f.local, "upstream.txt"), []byte("newer main\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, f.local, "add", "upstream.txt") + cliGit(t, f.local, "commit", "-m", "advance main") + cliGit(t, f.local, "push", "origin", "main") + + cliGit(t, f.local, "checkout", "feature/recover") + cliGit(t, f.local, "rebase", "main") + if err := os.WriteFile(filepath.Join(f.local, "follow-up.txt"), []byte("post-rebase follow-up\n"), 0o644); err != nil { + t.Fatal(err) + } + cliGit(t, f.local, "add", "follow-up.txt") + cliGit(t, f.local, "commit", "-m", "post-rebase follow-up") + rebased := cliGit(t, f.local, "rev-parse", "HEAD") + if publishRebase { + cliGit(t, f.local, "push", "--force-with-lease=refs/heads/feature/recover:"+f.preserved, "origin", "HEAD:refs/heads/feature/recover") + } + return rebased +} + +// TestAxiSyncAdoptPublishedRebasedLane reproduces the observed ordinary Git +// failure before it tests the supported recovery. `axi run` uses the same +// unforced HEAD-to-gate push, so this non-fast-forward is the failure that +// previously stopped it before a run could be created. +func TestAxiSyncAdoptPublishedRebasedLane(t *testing.T) { + f := newCLIRecoverFixture(t) + rebased := rebaseReturnedCustodyBranch(t, f, true) + cliGit(t, f.local, "push", f.gate, f.preserved+":refs/heads/feature/sibling") + sibling := cliGit(t, f.gate, "rev-parse", "refs/heads/feature/sibling") + + if err := git.Push(context.Background(), f.local, f.gate, "refs/heads/feature/recover", "", false); err == nil { + t.Fatal("unforced push to the stale gate unexpectedly succeeded") + } + if got := cliGit(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.preserved { + t.Fatalf("rejected gate push moved lane to %s, want pre-rebase %s", got, f.preserved) + } + + status, err := executeCmd("axi", "status") + if err != nil { + t.Fatalf("status: %v\n%s", err, status) + } + for _, want := range []string{ + "state: custody_returned", + "relation: diverged", + "code: adopt_published", + "command: no-mistakes axi sync --adopt-published", + } { + if !strings.Contains(status, want) { + t.Errorf("rebased status missing %q:\n%s", want, status) + } + } + + out, err := executeCmd("axi", "sync", "--adopt-published") + if err != nil { + t.Fatalf("adopt published rebased head: %v\n%s", err, out) + } + for _, want := range []string{"state: custody_returned", "safety: gate_ready", "changed: true", "code: run_pipeline"} { + if !strings.Contains(out, want) { + t.Errorf("adoption output missing %q:\n%s", want, out) + } + } + if got := cliGit(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != rebased { + t.Fatalf("gate lane = %s, want published rebased head %s", got, rebased) + } + if got := cliGit(t, f.gate, "rev-parse", "refs/heads/feature/sibling"); got != sibling { + t.Fatalf("adoption changed sibling lane to %s, want %s", got, sibling) + } + // The exact no-op push lets axi run take its existing rerun path instead of + // failing before the daemon observes the lane. + if err := git.Push(context.Background(), f.local, f.gate, "refs/heads/feature/recover", "", false); err != nil { + t.Fatalf("gate push after adoption: %v", err) + } +} + +func TestAxiSyncAdoptPublishedRefusesUnpublishedRebase(t *testing.T) { + f := newCLIRecoverFixture(t) + rebased := rebaseReturnedCustodyBranch(t, f, false) + + out, err := executeCmd("axi", "sync", "--adopt-published") + var ee *exitError + if err == nil || !asExitError(err, &ee) || ee.code != 1 { + t.Fatalf("unpublished rebase recovery should refuse, got %#v\n%s", err, out) + } + for _, want := range []string{"safety: blocked_published_head_mismatch", "no files or gate refs were changed"} { + if !strings.Contains(out, want) { + t.Errorf("unpublished rebase refusal missing %q:\n%s", want, out) + } + } + if got := cliGit(t, f.local, "rev-parse", "HEAD"); got != rebased { + t.Fatalf("refused recovery moved local branch to %s, want %s", got, rebased) + } + if got := cliGit(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.preserved { + t.Fatalf("refused recovery moved gate lane to %s, want %s", got, f.preserved) + } +} + func TestAxiSyncRecoverDivergedRefusesThenKeepLocalSucceeds(t *testing.T) { f := newCLIRecoverFixture(t) if err := os.WriteFile(filepath.Join(f.local, "rescope.txt"), []byte("rescope\n"), 0o644); err != nil { @@ -957,8 +1068,12 @@ func TestSyncRecoverFlagValidation(t *testing.T) { newCLIRecoverFixture(t) for _, args := range [][]string{ {"sync", "--check", "--recover"}, + {"sync", "--check", "--adopt-published"}, + {"sync", "--recover", "--adopt-published"}, {"sync", "--keep-local"}, {"axi", "sync", "--check", "--recover"}, + {"axi", "sync", "--check", "--adopt-published"}, + {"axi", "sync", "--recover", "--adopt-published"}, {"axi", "sync", "--keep-local"}, } { out, err := executeCmd(args...) diff --git a/internal/skill/skill.go b/internal/skill/skill.go index f3fda1497..317eaf8c0 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -240,6 +240,7 @@ If it reports ` + "`next_action.code`" + ` is ` + "`continue_active_run`" + `, t 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 and take the preserved head, or ` + "`no-mistakes rerun`" + ` to resume validating it instead. 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/`" + `. 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. +When ` + "`next_action.code`" + ` is ` + "`adopt_published`" + `, a custody-returned branch was rebased after its gate lane stopped moving: run ` + "`no-mistakes axi sync --adopt-published`" + `. It verifies the configured push target already has the exact rebased local head, preserves the old lane head, and updates only that stale gate lane. If the target differs or changes during verification, it refuses without replacing the lane. 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 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/`" + `. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. @@ -309,6 +310,7 @@ no-mistakes axi status # full detail plus cached branch_sync when relevan no-mistakes axi sync --check # freshly verify an offered synchronization plan no-mistakes axi sync # apply only an offered guarded synchronization no-mistakes axi sync --recover # return custody after a terminal run left unpublished pipeline commits +no-mistakes axi sync --adopt-published # adopt an exactly published rebased head into its stale gate lane no-mistakes axi logs --step --full # full log output of one step no-mistakes axi abort # cancel the current-branch active run no-mistakes axi abort --run # cancel a specific run by id (works outside its worktree) diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 36f3b973f..ee5333386 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -240,6 +240,7 @@ If it reports `next_action.code` is `continue_active_run`, the pipeline still ow 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 and take the preserved head, or `no-mistakes rerun` to resume validating it instead. 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/`. 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. +When `next_action.code` is `adopt_published`, a custody-returned branch was rebased after its gate lane stopped moving: run `no-mistakes axi sync --adopt-published`. It verifies the configured push target already has the exact rebased local head, preserves the old lane head, and updates only that stale gate lane. If the target differs or changes during verification, it refuses without replacing the lane. 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 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/`. If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. @@ -309,6 +310,7 @@ no-mistakes axi status # full detail plus cached branch_sync when relevan no-mistakes axi sync --check # freshly verify an offered synchronization plan no-mistakes axi sync # apply only an offered guarded synchronization no-mistakes axi sync --recover # return custody after a terminal run left unpublished pipeline commits +no-mistakes axi sync --adopt-published # adopt an exactly published rebased head into its stale gate lane no-mistakes axi logs --step --full # full log output of one step no-mistakes axi abort # cancel the current-branch active run no-mistakes axi abort --run # cancel a specific run by id (works outside its worktree)