Commit history detached from cancellation on EVERY path, not just failure
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s

Found by using it: the agent planted 18 beans, the client's stream dropped, and
those 18 plantings ended up in the garden with no change set behind them — real
changes with no way to undo them, which is the one guarantee this whole design
rests on.

The earlier fix detached the FAILURE path and reasoned that a cancelled context
is why fn failed. That missed the commoner case. An agent turn whose client
disconnects can still COMPLETE — the model finishes, the tools have already
written — and then the success path committed with a dead context, the write
failed, WithChangeSet returned an error, and the work was orphaned.

commitScope now detaches from cancellation itself, so every caller gets it. That
is the right home for the rule: by the time a commit runs, the data it describes
has already been written, so cancelling it cannot undo anything — it can only
lose the record of what happened. There is no path on which that is the
behaviour anyone wants.

Also added an SSE keep-alive, since the dropped connection is worth not having
in the first place: a model thinking between tool calls sends nothing for a
while, and an idle proxy will cut a quiet stream. A comment frame every 20s
keeps it open, and SSE ignores comment frames so the client is unaffected.

That introduced a second goroutine writing the response, so the event stream now
serializes writes behind a mutex — step events come from the agent's run
goroutine while the ticker writes from its own, and two goroutines writing a
ResponseWriter concurrently corrupts frames long before it crashes anything.
Verified under -race.

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:38:24 -04:00
co-authored by Claude Opus 4.8
parent 4ea0d0b262
commit 9ef4593fa8
3 changed files with 134 additions and 19 deletions
+54
View File
@@ -805,3 +805,57 @@ func countsTotal(counts []domain.ChangeCount) int {
}
return n
}
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
//
// The failure path was detached from cancellation; the SUCCESS path was not. An
// agent turn whose client disconnects can still COMPLETE — and then the success
// path committed with a dead context, the write failed, and real changes were
// left with no change set and no way to undo them. Found live with 18 plantings
// behind no history at all.
//
// Nothing about a commit needs the caller to still be there: by the time it
// runs, the data it describes has already been written.
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(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)
plant := seedOwnPlant(t, s, owner, 15)
ctx, cancel := context.WithCancel(context.Background())
before := len(history(t, s, owner, g.ID))
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
return err
}
cancel() // the client disconnects, mid-turn, after the work landed
return nil
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs == nil {
t.Fatal("the turn changed things but produced no change set")
}
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
}
if after[0].Summary != "plant beans in the second bed" {
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
}
// And it's undoable, which is the entire point.
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
}
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
if len(active) != 0 {
t.Errorf("%d plantings survived the undo", len(active))
}
}