Route auto-scoped mutations through the detached commit too
Build image / build-and-push (push) Successful in 5s

Gadfly caught me making the same mistake one level up. This PR claimed the
history write is "detached from cancellation, always" — and record()'s
auto-scope path still called store.WriteChangeSet directly with the caller's
context, so every plain REST mutation kept the orphan-history window the PR was
written to close. That is the path virtually every change takes; the agent is
the exception.

It goes through commitScope now, which is where the rule lives, so no caller can
be the one that forgets. Tested the same way as the agent path: cancel right
after the mutation returns, assert the change set exists and reverts.

Also: stopBeat is deferred so a panic in the run can't leak the ticker goroutine
(and is idempotent, since the handler stops it explicitly on the normal path);
the keep-alive interval is a named constant rather than a literal duplicated
between comment and call site; and commitScope's doc states the rule and why it
lives there, instead of narrating the incident that produced it — that belongs
in a commit message, which is where it now is.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 08:48:43 -04:00
co-authored by Claude Opus 4.8
parent 9ef4593fa8
commit d59303238f
3 changed files with 61 additions and 17 deletions
+14 -4
View File
@@ -26,6 +26,11 @@ import (
// by everything at once, which reads as a hang — and the whole design rests on // by everything at once, which reads as a hang — and the whole design rests on
// watching the canvas change as it happens. // watching the canvas change as it happens.
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
// under the 3060s idle timeout typical of reverse proxies, which is the thing
// it exists to stay ahead of.
const keepAliveInterval = 20 * time.Second
// chatRequest is the body of POST /agent/chat. // chatRequest is the body of POST /agent/chat.
type chatRequest struct { type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"` GardenID int64 `json:"gardenId" binding:"required"`
@@ -68,9 +73,11 @@ func (h *handlers) agentChat(c *gin.Context) {
send := stream.send send := stream.send
// A model thinking hard between tool calls sends nothing for a while, and an // A model thinking hard between tool calls sends nothing for a while, and an
// idle proxy will cut a quiet connection. A comment frame every 20s keeps it // idle proxy will cut a quiet connection. Deferred so a panic in the run
// open; SSE ignores comments, so this costs the client nothing. // can't leak the ticker goroutine; stopping it twice is harmless.
stopBeat := stream.keepAlive(20 * time.Second) stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat()
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history), replayHistory(history),
func(s mdagent.Step) { func(s mdagent.Step) {
@@ -163,8 +170,11 @@ func (s *eventStream) keepAlive(every time.Duration) func() {
} }
} }
}() }()
// Idempotent: the handler stops it explicitly when the run returns and again
// via defer, so a panic can't leak the goroutine.
var once sync.Once
return func() { return func() {
close(done) once.Do(func() { close(done) })
<-stopped <-stopped
} }
} }
+13 -13
View File
@@ -151,16 +151,12 @@ func partialSummary(summary string) string {
// set only by RevertChangeSet. A scope with no revisions writes nothing — an // set only by RevertChangeSet. A scope with no revisions writes nothing — an
// operation that changed nothing doesn't belong in history. // operation that changed nothing doesn't belong in history.
// //
// The write is DETACHED FROM CANCELLATION, always. By the time it runs, the data // The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
// changes it describes have already committed — so cancelling it cannot undo // than at each call site so no caller can be the one that forgets. By the time a
// anything, it can only lose the record of what happened and leave real changes // commit runs, the data changes it describes have already been written — so
// with no way to undo them. // cancelling it cannot undo anything. It can only lose the record of what
// // happened and leave real changes with no way to undo them, which is the one
// This was originally only done on the failure path, on the reasoning that a // thing the whole change-set design exists to prevent.
// cancelled context is why fn failed. That missed the commoner case: an agent
// turn whose client disconnects can still COMPLETE, and then the success path
// commits with a dead context and loses the history anyway. Found in production
// with 18 plantings and no change set behind them.
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) { func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
revs := sc.taken() revs := sc.taken()
if len(revs) == 0 { if len(revs) == 0 {
@@ -207,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
sc.append(revs) sc.append(revs)
return return
} }
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{ // Auto-scope: one operation, its own change set. Written through the same
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary, // detached path as everything else — a REST client that hangs up right after
}, revs); err != nil { // its PATCH landed must not leave that change without history, and this is
// the path virtually every mutation takes.
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
auto.append(revs)
if _, err := s.commitScope(ctx, auto, nil); err != nil {
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary) slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
} }
} }
+34
View File
@@ -859,3 +859,37 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
t.Errorf("%d plantings survived the undo", len(active)) t.Errorf("%d plantings survived the undo", len(active))
} }
} }
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
// path virtually every mutation takes.
//
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
// between the row landing and the change set being written, that change is
// orphaned exactly as an agent turn's was — and this path is used far more.
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
before := len(history(t, s, owner, g.ID))
// The row write and the history write share this context; cancelling after
// the mutation returns is the client hanging up mid-request.
ctx, cancel := context.WithCancel(context.Background())
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
cancel()
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
}
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
}
back, _ := s.store.GetObject(context.Background(), bed.ID)
if back.XCM != bed.XCM {
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
}
}