Route auto-scoped mutations through the detached commit too
Build image / build-and-push (push) Successful in 5s
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:
+14
-4
@@ -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 30–60s 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
|
||||
|
||||
// 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()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user