Commit history detached from cancellation on every path, not just failure #73

Merged
steve merged 2 commits from fix/commit-survives-disconnect into main 2026-07-21 12:49:18 +00:00
3 changed files with 61 additions and 17 deletions
Showing only changes of commit d59303238f - Show all commits
+14 -4
View File
@@ -26,6 +26,11 @@ import (
// by everything at once, which reads as a hang — and the whole design rests on
// 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.
type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"`
@@ -68,9 +73,11 @@ func (h *handlers) agentChat(c *gin.Context) {
send := stream.send
Review

🟡 Magic number keep-alive interval should be a named constant

maintainability · flagged by 2 models

  • internal/api/agent.go:73 — Magic number heartbeat interval. The keep-alive duration 20 * time.Second is hardcoded as a literal at the call site, while every other timeout/duration in this package is a named constant (oidcDiscoveryTimeout, oidcExchangeTimeout, etc.). This breaks the project's existing pattern, buries the tuning knob, and makes it non-discoverable for anyone adjusting proxy timeouts later. It should be a package-level constant such as `sseKeepAliveInterval = 20 * tim…

🪰 Gadfly · advisory

🟡 **Magic number keep-alive interval should be a named constant** _maintainability · flagged by 2 models_ * **`internal/api/agent.go:73` — Magic number heartbeat interval.** The keep-alive duration `20 * time.Second` is hardcoded as a literal at the call site, while every other timeout/duration in this package is a named constant (`oidcDiscoveryTimeout`, `oidcExchangeTimeout`, etc.). This breaks the project's existing pattern, buries the tuning knob, and makes it non-discoverable for anyone adjusting proxy timeouts later. It should be a package-level constant such as `sseKeepAliveInterval = 20 * tim… <sub>🪰 Gadfly · advisory</sub>
// 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
// open; SSE ignores comments, so this costs the client nothing.
stopBeat := stream.keepAlive(20 * time.Second)
// idle proxy will cut a quiet connection. Deferred so a panic in the run
// can't leak the ticker goroutine; stopping it twice is harmless.
stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat()
Review

🟡 stopBeat is called manually instead of via defer, so a panic in h.agent.Run skips cleanup (mitigated only by request-context cancellation)

error-handling · flagged by 2 models

  • internal/api/agent.go:79stopBeat is called manually instead of via defer. stopBeat := stream.keepAlive(...) is started at line 73, then h.agent.Run(...) runs (lines 74–78), then stopBeat() is called as a plain statement at line 79 (before the error check). If Run (or anything between the two lines) panics, stopBeat() is never reached. The keep-alive goroutine does have a self-cleanup escape hatch — it also selects on s.c.Request.Context().Done() (line 159), and a panic…

🪰 Gadfly · advisory

🟡 **stopBeat is called manually instead of via defer, so a panic in h.agent.Run skips cleanup (mitigated only by request-context cancellation)** _error-handling · flagged by 2 models_ - **`internal/api/agent.go:79` — `stopBeat` is called manually instead of via `defer`.** `stopBeat := stream.keepAlive(...)` is started at line 73, then `h.agent.Run(...)` runs (lines 74–78), then `stopBeat()` is called as a plain statement at line 79 (before the error check). If `Run` (or anything between the two lines) panics, `stopBeat()` is never reached. The keep-alive goroutine does have a self-cleanup escape hatch — it also selects on `s.c.Request.Context().Done()` (line 159), and a panic… <sub>🪰 Gadfly · advisory</sub>
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
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() {
close(done)
once.Do(func() { close(done) })
<-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
// operation that changed nothing doesn't belong in history.
//
// The write is DETACHED FROM CANCELLATION, always. By the time it runs, the data
// changes it describes have already committed — so cancelling it cannot undo
// anything, it can only lose the record of what happened and leave real changes
// with no way to undo them.
//
// This was originally only done on the failure path, on the reasoning that a
// 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.
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
// than at each call site so no caller can be the one that forgets. By the time a
// commit runs, the data changes it describes have already been written — so
// 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
// thing the whole change-set design exists to prevent.
Review

🟡 commitScope doc embeds a production incident post-mortem that belongs in the commit message, not godoc

maintainability · flagged by 1 model

  • internal/service/revisions.go:159 — doc comment carries a production post-mortem that will rot. The commitScope comment now embeds a historical narrative ("Found in production with 18 plantings and no change set behind them") alongside the durable rule. The rule ("the write is detached from cancellation, always") is worth keeping; the incident report belongs in the commit message / PR description, not the godoc, where it reads as rationale that future readers can't act on and will be t…

🪰 Gadfly · advisory

🟡 **commitScope doc embeds a production incident post-mortem that belongs in the commit message, not godoc** _maintainability · flagged by 1 model_ - **`internal/service/revisions.go:159` — doc comment carries a production post-mortem that will rot.** The `commitScope` comment now embeds a historical narrative ("Found in production with 18 plantings and no change set behind them") alongside the durable rule. The rule ("the write is detached from cancellation, always") is worth keeping; the incident report belongs in the commit message / PR description, not the godoc, where it reads as rationale that future readers can't act on and will be t… <sub>🪰 Gadfly · advisory</sub>
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
revs := sc.taken()
if len(revs) == 0 {
@@ -207,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
sc.append(revs)
return
}
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
}, revs); err != nil {
// Auto-scope: one operation, its own change set. Written through the same
// detached path as everything else — a REST client that hangs up right after
// 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}
Review

🟠 Auto-scope record() path still commits history with the caller's (possibly dead) context — same orphan-history bug class the PR claims to close universally, left unfixed for every non-agent mutation

correctness, error-handling · flagged by 1 model

  • internal/service/revisions.go:210 — auto-scope record path still commits history with the caller's (possibly dead) context, the same orphan-history bug class the PR claims to close universally, left unfixed for every non-agent mutation. The PR's thesis (the new commitScope comment, revisions.go:154–157: "The write is DETACHED FROM CANCELLATION, always… it can only lose the record of what happened and leave real changes with no way to undo them") applies equally to the auto-scope bran…

🪰 Gadfly · advisory

🟠 **Auto-scope record() path still commits history with the caller's (possibly dead) context — same orphan-history bug class the PR claims to close universally, left unfixed for every non-agent mutation** _correctness, error-handling · flagged by 1 model_ - **`internal/service/revisions.go:210` — auto-scope `record` path still commits history with the caller's (possibly dead) context, the same orphan-history bug class the PR claims to close universally, left unfixed for every non-agent mutation.** The PR's thesis (the new `commitScope` comment, revisions.go:154–157: "The write is DETACHED FROM CANCELLATION, always… it can only lose the record of what happened and leave real changes with no way to undo them") applies equally to the auto-scope bran… <sub>🪰 Gadfly · advisory</sub>
auto.append(revs)
if _, err := s.commitScope(ctx, auto, nil); err != nil {
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))
}
}
// 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)
}
}