From 74d865925abb14b5f3b5c3d2bd57090805ac01a0 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 13 Nov 2026 18:48:06 -0500 Subject: [PATCH 1/2] cellsync: a recovering heartbeat that fails is an owed upload #2381 made a recovering batcher renew its hold before publishing, but returned the heartbeat's error bare. A quit while the relay was down therefore reported a confirmed local save as a lost one (TestL4HostedSurfaceQuitWithRelayDownKeepsTheLocalTurns). The heartbeat is that publish's relay step, so its failure is now wrapped as the same owed upload a failed advance reports. Co-Authored-By: Claude Opus 5.5 --- internal/cellsync/batch.go | 6 +++++- internal/cellsync/batch_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index bb7e42cb46..9bc1ea4268 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -562,7 +562,11 @@ func (b *Batcher) publish(ctx context.Context, head string) error { } return b.resolve(ctx) } - return err + // THE HEARTBEAT IS THIS PUBLISH'S RELAY STEP, so its failure is the + // same owed upload a failed advance reports: the turns are durable + // here and only their upload is pending. Returned bare, a quit with + // the relay down reported a confirmed local save as lost. + return syncwords.UploadFailure(err) } } ex, err := b.Publisher.publish(ctx, &d, head, b.info()) diff --git a/internal/cellsync/batch_test.go b/internal/cellsync/batch_test.go index 6ee8cb1620..5374b5da78 100644 --- a/internal/cellsync/batch_test.go +++ b/internal/cellsync/batch_test.go @@ -14,6 +14,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -248,6 +249,25 @@ func TestBatcherRecoversAfterOutageLongerThanLease(t *testing.T) { } } +// TestBatcherRecoveringHeartbeatFailureIsAnOwedUpload proves that a heartbeat +// failing while the batcher recovers reports the same owed upload a failed +// publish does. The heartbeat is the relay step of that publish: the turns are +// durable on this computer and only their upload is pending, which is how a +// quit with the relay down tells a confirmed local save from a lost one. +func TestBatcherRecoveringHeartbeatFailureIsAnOwedUpload(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "0"}) + note(b, r.seal(map[string]string{"a": "1"})) + + r.dirA.set(directory.ErrUnreachable) + b.flush(context.Background()) + err := b.Close(context.Background()) + if err == nil || !syncwords.UploadPending(err) { + t.Fatalf("a recovering publish with the relay down returned %v, want an owed upload", err) + } +} + func TestSkewIsSurfacedOnce(t *testing.T) { r := newRig(t) b := r.batcher() From 6c0deac542ed9665d04d06604d77b1837dc57bcc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 13 Nov 2026 18:48:44 -0500 Subject: [PATCH 2/2] changes: entry for #2643 Co-Authored-By: Claude Opus 5.5 --- .../2643-recovering-heartbeat-owed-upload.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/changes/unreleased/2643-recovering-heartbeat-owed-upload.md diff --git a/docs/changes/unreleased/2643-recovering-heartbeat-owed-upload.md b/docs/changes/unreleased/2643-recovering-heartbeat-owed-upload.md new file mode 100644 index 0000000000..008095df05 --- /dev/null +++ b/docs/changes/unreleased/2643-recovering-heartbeat-owed-upload.md @@ -0,0 +1,11 @@ +--- +kind: fixed +title: A quit while the sync service is down reports the local save again +pr: 2643 +surface: [chat, engine] +invalidates: + - "After #2381, quitting while the sync service was unreachable could report a chat whose turns were saved on this computer as not saved, because the hold renewal tried before the upload returned its error unclassified. The renewal's failure is now the same owed upload a failed upload reports: saved here, upload pending." +--- + +The renewal is the upload's own first step after an outage, so it reports its +failure the same way.