From e90193a04859080808d14a87e458ebaa8104de3c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Oct 2026 00:53:09 -0700 Subject: [PATCH 1/2] fix: keep shallow sync finalization blob-filtered Complete commit ancestry without eagerly fetching historical file blobs. Preserve owner fencing and fail-closed release semantics while distinguishing a witnessed live child from ambiguous transport state. --- CHANGELOG.md | 4 ++ docs/features/sync.md | 14 +++++ internal/cli/ssh.go | 6 +-- internal/cli/sync_finalize_history_test.go | 61 ++++++++++++++++++++++ internal/cli/workspace_owner.go | 4 +- internal/cli/workspace_owner_test.go | 47 +++++++++++++++++ 6 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 internal/cli/sync_finalize_history_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d055199980..deddbe44c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Sync: keep finalization fetches blob-filtered when completing shallow Git history, and explain live-child owner release refusals with safe lease recovery guidance. [PR 2854](https://github.com/openclaw/crabbox/pull/2854). + ## 0.78.1 - 2026-10-15 ### Highlights diff --git a/docs/features/sync.md b/docs/features/sync.md index cb34118556..91fe62a8a1 100644 --- a/docs/features/sync.md +++ b/docs/features/sync.md @@ -318,6 +318,13 @@ the original error. These labels add diagnostic context, not permission to continue or retry; arbitrary protocol output is not added to those transport error messages. An ambiguous inspection still fails closed. +A release refusal with `protocol state CHILD` and exit 75 means the witnessed +remote phase is still active; the owner is retained. Cancelling local SSH does +not prove that its remote Git fetch or other phase stopped. Wait for that phase +to finish before retrying. If it remains stuck, use the run's printed +`stop_command` to release the lease and retry on a fresh lease. `--full-resync` +still needs the same owner and cannot override a live child. + POSIX and WSL2 children register themselves before executing the requested workload. Registration waits at most five seconds for the owner lock; it does not leave a background child waiting indefinitely for a start file. A failed @@ -354,6 +361,13 @@ Once ownership is established, sync runs these steps: 10. Finalize: git-hydrate the worktree against the configured base ref, run the mass-deletion sanity check, and record the new fingerprint. +Finalization requests blob-filtered Git fetches, including when completing a +shallow repository left by workflow setup or a previous command. On origins +that support filtering, commit ancestry becomes available without downloading +every historical version of every file. Git can fetch historical file contents +when a later command explicitly requests them. Origins that ignore filtering +can still transfer full historical contents. + For a new or empty POSIX workspace with Git seeding disabled (`sync.gitSeed: false`) and no requested Git overlay, the manifest-writing command also checks whether the target has GNU tar or bsdtar and gzip. Eligible first transfers use diff --git a/internal/cli/ssh.go b/internal/cli/ssh.go index 26f6759638..841d2092d6 100644 --- a/internal/cli/ssh.go +++ b/internal/cli/ssh.go @@ -2956,9 +2956,9 @@ fi refspec := "+refs/heads/" + opts.BaseRef + ":refs/remotes/origin/" + opts.BaseRef script += `if exact_git_root && git remote get-url origin >/dev/null 2>&1; then if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then - git fetch --quiet --unshallow origin ` + shellQuote(refspec) + ` || git fetch --quiet --depth=1000 origin ` + shellQuote(refspec) + ` || git fetch --quiet origin ` + shellQuote(refspec) + ` || git fetch --quiet origin ` + shellQuote(opts.BaseRef) + ` || true + git fetch --quiet --filter=blob:none --unshallow origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none --depth=1000 origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(opts.BaseRef) + ` || true else - git fetch --quiet origin ` + shellQuote(refspec) + ` || git fetch --quiet origin ` + shellQuote(opts.BaseRef) + ` || true + git fetch --quiet --filter=blob:none origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(opts.BaseRef) + ` || true fi fi ` @@ -3058,7 +3058,7 @@ coherence_committed=; coherence_mutated=; head_changed=; index_changed= tmp_ref="refs/crabbox/sync-$expected_token"; advertised_branch=` + shellQuote(plan.Branch) + `; expected_origin=` + shellQuote(plan.RemoteURL) + ` ` + remoteGitOriginTransportFunctions() + ` coherence_fetch() { - set -- --quiet --no-tags + set -- --quiet --filter=blob:none --no-tags # An existing shallow boundary can hide ancestors of the advertised branch. if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then set -- "$@" --unshallow diff --git a/internal/cli/sync_finalize_history_test.go b/internal/cli/sync_finalize_history_test.go new file mode 100644 index 0000000000..622a9e8c7d --- /dev/null +++ b/internal/cli/sync_finalize_history_test.go @@ -0,0 +1,61 @@ +//go:build !windows + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRemoteFinalizeSyncDoesNotDownloadHistoricalBlobs(t *testing.T) { + f := newGitCoherenceFixture(t) + runGit(t, f.origin, "config", "uploadpack.allowFilter", "true") + oldBlob := gitOutput(f.source, "rev-parse", f.a+":tracked.txt") + for _, scenario := range []struct { + name string + coherence, hydrate bool + }{ + {name: "coherence", coherence: true}, + {name: "hydrate", hydrate: true}, + {name: "coherence-and-hydrate", coherence: true, hydrate: true}, + } { + t.Run(scenario.name, func(t *testing.T) { + workdir := filepath.Join(t.TempDir(), "work") + origin := "file://" + filepath.ToSlash(f.origin) + if out, err := exec.Command("git", "clone", "--quiet", "--depth=1", origin, workdir).CombinedOutput(); err != nil { + t.Fatalf("shallow clone: %v\n%s", err, out) + } + assertOldBlobMissing := func() { + t.Helper() + cmd := exec.Command("git", "-C", workdir, "-c", "protocol.allow=never", "cat-file", "-e", oldBlob) + cmd.Env = append(os.Environ(), "GIT_NO_LAZY_FETCH=1") + if err := cmd.Run(); err == nil { + t.Fatal("downloaded a blob that exists only in history") + } + } + assertOldBlobMissing() + plan := gitCoherencePlan{} + if scenario.coherence { + plan = f.plan(t, f.c) + plan.RemoteURL = origin + } + const token = "1234567890abcdef1234567890abcdef" + stageCoherenceFinalize(t, workdir, token) + command := remoteFinalizeSync(workdir, remoteSyncFinalizeOptions{ + Token: token, Coherence: plan, HydrateGit: scenario.hydrate, BaseRef: "main", + }) + if out, err := exec.CommandContext(t.Context(), "/bin/sh", "-c", command).CombinedOutput(); err != nil { + t.Fatalf("finalize: %v\n%s", err, out) + } + assertOldBlobMissing() + requireGitOutput(t, workdir, "false", "rev-parse", "--is-shallow-repository") + requireGitOutput(t, workdir, f.c, "rev-parse", "HEAD") + // Commit ancestry remains available even though historical file bytes + // are deferred until a caller explicitly asks Git for them. + runGit(t, workdir, "merge-base", "--is-ancestor", f.a, "HEAD") + requireGitOutput(t, workdir, "A", "show", f.a+":tracked.txt") + }) + } +} diff --git a/internal/cli/workspace_owner.go b/internal/cli/workspace_owner.go index 728ef799db..9646de45a3 100644 --- a/internal/cli/workspace_owner.go +++ b/internal/cli/workspace_owner.go @@ -664,7 +664,9 @@ func (o *workspaceOwner) Close(ctx context.Context) (err error) { } renewErr := o.Err() response, releaseErr := callWorkspaceOwnerTransport(ctx, o.callTimeout(), o.transport, workspaceOwnerRemoteRequest{Action: workspaceOwnerRelease, Key: o.key, Token: o.token, TTL: o.ttl}) - if releaseErr != nil { + if response == "CHILD" && ctx.Err() == nil && (releaseErr == nil || exitCode(releaseErr) == 75) { + releaseErr = errors.Join(Exit(7, "release remote workspace owner failed closed: remote phase is still active (protocol state CHILD); owner retained; wait for the phase to finish, or use the printed stop_command and retry on a fresh lease"), releaseErr) + } else if releaseErr != nil { releaseErr = workspaceOwnerProtocolError(response, releaseErr) releaseErr = workspaceOwnerCallError("release remote workspace owner", releaseErr) } else if response != "RELEASED" { diff --git a/internal/cli/workspace_owner_test.go b/internal/cli/workspace_owner_test.go index d25d8627f1..56a88c6f4c 100644 --- a/internal/cli/workspace_owner_test.go +++ b/internal/cli/workspace_owner_test.go @@ -156,6 +156,53 @@ func TestWorkspaceOwnerTransportContextDiagnostics(t *testing.T) { } } +func TestWorkspaceOwnerReleaseChildDiagnostic(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX exit-status fixture") + } + for _, code := range []int{0, 75, 255} { + t.Run(strconv.Itoa(code), func(t *testing.T) { + var cause error + if code != 0 { + cause = exec.Command("/bin/sh", "-c", "exit "+strconv.Itoa(code)).Run() + if cause == nil { + t.Fatal("exit fixture succeeded") + } + } + calls := 0 + owner := &workspaceOwner{ + stop: make(chan struct{}), done: make(chan struct{}), + transport: workspaceOwnerTransportFunc(func(_ context.Context, req workspaceOwnerRemoteRequest) (string, error) { + calls++ + if req.Action != workspaceOwnerRelease { + t.Fatalf("unexpected action %s", req.Action) + } + return "CHILD", cause + }), + } + close(owner.done) + err := owner.Close(t.Context()) + if err == nil || calls != 1 { + t.Fatalf("release did not fail closed once: calls=%d err=%v", calls, err) + } + if code == 255 { + if !strings.Contains(err.Error(), "ambiguous remote state") || strings.Contains(err.Error(), "owner retained") { + t.Fatalf("transport failure became a confirmed refusal: %v", err) + } + return + } + for _, want := range []string{"protocol state CHILD", "remote phase is still active", "owner retained", "stop_command", "fresh lease"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missing %q in %v", want, err) + } + } + if cause != nil && !errors.Is(err, cause) { + t.Fatal("release diagnostic lost the original refusal") + } + }) + } +} + func newFakeWorkspaceOwnerRemote() *fakeWorkspaceOwnerRemote { return &fakeWorkspaceOwnerRemote{changed: make(chan struct{})} } From 226941fee58aa1601ccdb0805ee5ad095708a775 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Oct 2026 01:15:22 -0700 Subject: [PATCH 2/2] fix: keep filtered finalizers within SSH transport bounds Factor the repeated hydration fetch invocation into one shell helper. Preserve every fallback and the existing 30000-byte witnessed-command limit. --- internal/cli/ssh.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cli/ssh.go b/internal/cli/ssh.go index 841d2092d6..d738b2227b 100644 --- a/internal/cli/ssh.go +++ b/internal/cli/ssh.go @@ -2955,10 +2955,11 @@ fi if !opts.GitOverlay && !opts.PlainManifest && opts.HydrateGit && opts.BaseRef != "" { refspec := "+refs/heads/" + opts.BaseRef + ":refs/remotes/origin/" + opts.BaseRef script += `if exact_git_root && git remote get-url origin >/dev/null 2>&1; then + hydrate_fetch() { git fetch --quiet --filter=blob:none origin "$@"; } if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = true ]; then - git fetch --quiet --filter=blob:none --unshallow origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none --depth=1000 origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(opts.BaseRef) + ` || true + hydrate_fetch --unshallow ` + shellQuote(refspec) + ` || hydrate_fetch --depth=1000 ` + shellQuote(refspec) + ` || hydrate_fetch ` + shellQuote(refspec) + ` || hydrate_fetch ` + shellQuote(opts.BaseRef) + ` || true else - git fetch --quiet --filter=blob:none origin ` + shellQuote(refspec) + ` || git fetch --quiet --filter=blob:none origin ` + shellQuote(opts.BaseRef) + ` || true + hydrate_fetch ` + shellQuote(refspec) + ` || hydrate_fetch ` + shellQuote(opts.BaseRef) + ` || true fi fi `