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
2 changes: 2 additions & 0 deletions docs/src/content/docs/guides/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -178,6 +179,7 @@ no-mistakes axi abort --run <id>
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.
Expand Down
12 changes: 11 additions & 1 deletion docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,15 @@ 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 |
| -------------- | ------ | ------- | ---------------------------------------------------------------------------- |
| `--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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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.

Expand Down
116 changes: 111 additions & 5 deletions internal/branchsync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines +1088 to +1090

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 When the worktree origin differs from the refreshed upstream registration used by the pipeline, the database-loaded repository has URLsVerified unset, so resolvedPushURL queries origin instead of the pipeline's configured destination. This rejects a legitimately published rebased head as blocked_published_head_mismatch, or accepts adoption based on publication to the wrong remote.

Context Used: If there is a VISION.md file at the root of the re... (source)

return originURL
}
return s.Repo.UpstreamURL
}

func recoverAnchorRef(runID string) string {
return custody.RecoveryRef(runID)
}
Expand Down Expand Up @@ -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 "<what the user set out to accomplish>"`}
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 "<what the user set out to accomplish>"`}
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 "<what the user set out to accomplish>"`}
}

// relationBetween classifies the local head against a target commit using only
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). 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."
3 changes: 3 additions & 0 deletions internal/cli/axi_guidance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading