Skip to content

kai ship --server: recover from a base commit the remote never saw - #104

Merged
jschatz1 merged 1 commit into
mainfrom
fix/ship-base-recovery
Sep 9, 2026
Merged

kai ship --server: recover from a base commit the remote never saw#104
jschatz1 merged 1 commit into
mainfrom
fix/ship-base-recovery

Conversation

@jschatz1

@jschatz1 jschatz1 commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

The base a ship names is the checkout's HEAD at spawn time, and that is often a commit GitHub has never seen (unpushed, or the original of a squash-merged PR). The server failed with "not our ref" and told the person to re-ship from a fresh session. Now:

  • Before the request: if the spawn base is not on any remote-tracking branch (after one fetch), the base moves to the merge-base with the remote's default branch, and each file's delta is re-derived against it, so the PR carries only the agent's change.
  • After the request: if the server still cannot fetch the base, the ship moves the base and retries once.
  • Both say what they did on stderr; kit says it up front too (kaicontext/kai-tui, checkout notice).

Test plan

  • TestShipBaseFor_MovesOffAnUnpushedCommit: a bare remote, a pushed commit, a local-only commit on top, the spawn's agent hunk re-derived against the pushed commit
  • existing TestShipContentFor_StripsCheckoutEdits; go test ./cmd/kai; execlint clean

🤖 Generated with Claude Code

The ship names the commit the session was spawned from and the server
fetches it from GitHub to build the branch on. That commit is the
checkout's HEAD at spawn time, and a checkout's HEAD is often a commit
GitHub has never seen — committed locally and not pushed, or the
original of a squash-merged PR. The server failed with "not our ref"
and the message told the person to re-ship from a fresh session base.
That was the harness handing its own problem to the user.

shipBaseFor resolves it before the request: if the spawn base is on
the remote, it is the base; if not (after one fetch), the base moves
to the merge-base with the remote's default branch, and every file's
delta is re-derived against it — the base's content plus the agent's
hunks — so the PR carries only the agent's change and not the unpushed
commits it was working on top of. A hunk that cannot be separated
ships the whole file and says so, as before. If the server still
refuses the base (a remote the checkout does not track, a force-push
underneath), the ship moves the base and goes again, once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

jschatz1 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a69cc9c9-ab0f-44c6-a4d3-0095cf13ccb8


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jschatz1
jschatz1 merged commit 36a60cb into main Sep 9, 2026
8 checks passed

@kaicontext kaicontext Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Kai review

Kai Summary

Read through this one. 5 things worth your eyes before it merges. 👇

Review: kaicontext/kai-cli @ 9b7c066kai ship --server: recover from a base commit the remote never saw

The rest of the read-through

Scope: I read the full diff (cmd/kai/ship_base.go, cmd/kai/ship_base_test.go, cmd/kai/ship_server.go) and traced shipBaseFor, shipContentAgainst, and the buildFiles retry through runShipServer. The spawnpkg.Entry struct (RemoteName, BaseGitSHA, SourceRepo) lives in kai-engine/spawn, a sibling repo I cannot read here — I'm taking those fields on the word of how this repo's spawn.go already populates and reads them (e.g., spawn.go:279 sets ent.RemoteName, spawn.go:497-505 reads e.RemoteName).

What this change does: before queueing a server-ship, shipBaseFor checks whether the spawn base commit is reachable on a remote-tracking ref (fetching once if stale); if not, it moves the base to the merge-base with the remote's default branch and re-derives each shipped file against that commit so the PR carries the agent's hunks and not the unpushed local commits beneath. If the server later refuses the base anyway, the polling loop retries once with a freshly moved base. Overall this is a sound, well-scoped fix to a real "the harness handed its problem to the user" bug, and the delta re-derivation in shipContentAgainst is correctly factored out of shipContentFor.

Concerns

  1. cmd/kai/ship_base_test.go:84 — the "reason names unpushed" assertion gates itself on the very thing it's meant to verify, so it can pass without checking. The line is if !strings.Contains(shipBaseFor(spawn, e).Reason, "unpushed") && b.Moved. It calls shipBaseFor a second time (a fresh result whose Reason it inspects), but then ANDs that with b.Moved from the first call, and on failure reports b.Reason from the first call. If the first call did not move (b.Moved == false), the whole condition is false and the t.Errorf never fires — regardless of what either Reason actually says. The test's own earlier assertion (if !b.Moved && b.SHA == pushed) already treats b.Moved as the uncertain quantity; tying the reason check to it means the reason is only ever checked in the branch where the test already considers the move to have happened. To actually verify the reason, assert directly on bif b.Moved && !strings.Contains(b.Reason, "unpushed") { t.Errorf(...) } — not on a re-invocation.

  2. cmd/kai/ship_base.go:35shipBaseFor runs git fetch against e.SourceRepo (the user's checkout), mutating its remote-tracking state from inside kai ship. remoteHas is a read-only git branch -r --contains, but the fallback gitOut(repo, "fetch", "-q", "--no-tags", remoteName(e)) is a network write to the user's working repository that nobody asked kai ship to perform. repo here is e.SourceRepo, the person's source checkout — not the isolated spawn. A kai ship --server now silently advances that repo's origin/* tracking refs (and pulls ref advertisement over the network) as a side effect of resolving the base. If the checkout is on a slow or restricted network, or the person deliberately keeps stale tracking refs, this is surprising. At minimum the note machinery should say it fetched to check the base; better, the fetch should be limited to the move-actually-needed case. This runs on the path of every server-ship, not just the unfetchable one.

  3. cmd/kai/ship_server.go:253 — the belt path calls shipBaseFor again, which fetches a second time against the same checkout. When the server rejects a base that the proactive check passed (!base.Moved), the retry re-invokes shipBaseFor(cwd, entry), which will run remoteHas and, on a miss, another git fetch. Combined with the proactive call, that's two network round-trips into the user's repo for one ship, and the second fetch is redundant with the first if nothing changed in the interval. The retry would do better to reuse the already-resolved state rather than re-running the whole resolution from scratch.

  4. cmd/kai/ship_server.go:252 — the !base.Moved guard means the retry only covers the narrow case where the proactive check thought the base was fetchable but the server disagreed. That's a real window (local tracking refs can be ahead of GitHub), so the belt is not useless. But the comment's framing ("a remote the checkout does not track, a force-push under the person's feet") overstates it: a force-push on the remote would make the proactive remoteHas return false (the fetch updates tracking refs), so the proactive path would move and set Moved=true, and the !base.Moved guard would then suppress the retry the comment describes. The belt as written does not reach the force-push case it names. Not a defect — the proactive path handles force-push by moving — but the comment should describe what the guard actually lets through (local-tracking-says-yes, GitHub-says-no), not the case it doesn't.

  5. cmd/kai/ship_base.go:65fmt.Sscanf(out, "%d", &n) ignores its error. If rev-list fails, n stays 0 and the user-facing reason reads "0 unpushed local commit(s)" while the base was in fact moved off real unpushed work. The reason is misleading in exactly the case the change exists to explain. Check the error, or drop the count from the reason when it can't be determined.

The refactor of shipContentForshipContentAgainst is clean: the only remaining caller of shipContentFor is the delta test, and buildFiles correctly calls shipContentAgainst with the (possibly moved) baseSHA. The retry correctly re-derives payload.Files against the moved base before re-queueing, and resets the poll deadline. The review_analyze.go baseSHA reference in the dependency data is a help-text example string, not a code dependency. No secret/token comparisons are touched.

Merge readiness: the core logic is correct and the factoring is good; the test's self-defeating assertion and the unannounced git fetch into the user's checkout are the two things I'd want addressed before this merges, but neither is a defect in the shipping path itself. Small fixes first.

Important files changed
File Change
cmd/kai/ship_base.go modified · +113 −0
cmd/kai/ship_base_test.go modified · +90 −0
cmd/kai/ship_server.go modified · +78 −30
What I opened — 5 files, 20 turns, 1m30s

1 of the 3 changed files doesn't appear below: cmd/kai/ship_base.go.

  • cmd/kai/review_analyze.go
  • cmd/kai/ship_base_test.go
  • cmd/kai/ship_delta_test.go
  • cmd/kai/ship_server.go
  • cmd/kai/spawn.go

+281 −30 · 3 files · reaches 4 · the full analysis
💬 Reply to any of my comments and I'll answer, or say @kaicontext anywhere on this PR — a question, or "take another look at the retry logic".

Comment thread cmd/kai/ship_base_test.go
if want := "line1\nline2\nline3\nline4 AGENT\n"; string(got) != want || len(overlaps) != 0 {
t.Fatalf("content against the moved base = %q (overlaps %v)", got, overlaps)
}
if !strings.Contains(shipBaseFor(spawn, e).Reason, "unpushed") && b.Moved {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the "reason names unpushed" assertion ANDs its check on b.Moved from the first call while inspecting a fresh second call's Reason, so it silently skips when b.Moved is false and never verifies the reason in the uncertain branch.

Comment thread cmd/kai/ship_base.go
Spawn string // the spawn base (checkout HEAD at spawn), for the delta
Moved bool // SHA != Spawn: the spawn base was not on the remote
Unpushed int // local commits between the two, when moved
Reason string // one line for the person

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shipBaseFor runs git fetch against e.SourceRepo (the user's working checkout), mutating its remote-tracking refs as an unannounced side effect of kai ship on every server-ship.

Comment thread cmd/kai/ship_server.go
// The server could not fetch the base after all. Move to the
// nearest commit it has and go again, once (ship_base.go).
if shipBaseUnfetchable(st.Error) && !base.Moved {
moved := shipBaseFor(cwd, entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the belt retry re-invokes shipBaseFor, fetching a second time into the same checkout; reuse the already-resolved state instead.

Comment thread cmd/kai/ship_server.go
case "failed":
// The server could not fetch the base after all. Move to the
// nearest commit it has and go again, once (ship_base.go).
if shipBaseUnfetchable(st.Error) && !base.Moved {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the !base.Moved guard means the retry does not cover the force-push case the comment names (the proactive path would already move and set Moved=true); the comment should describe the narrower window it actually lets through.

Comment thread cmd/kai/ship_base.go
}
n := 0
if out, err := gitOut(repo, "rev-list", "--count", mb+".."+sha); err == nil {
fmt.Sscanf(out, "%d", &n)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fmt.Sscanf ignores its error, so a rev-list failure produces a misleading "0 unpushed local commit(s)" reason in exactly the case the change exists to explain.

@jschatz1
jschatz1 deleted the fix/ship-base-recovery branch September 9, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant