on main help sign in
chore(server): run accepted server prompts in the background

Accept validated prompts quickly, then run them on workspace-owned background work. Failures are published through events while normal cancellation continues to be represented by the canceled assistant turn. Co-Authored-By: Charm Crush <crush@charm.land>

commit8b242c5313a79630d22429c04b699adcb624a89a authorChristian Rocha <christian@rocha.is> dateSun 31 May 2026, 23:06 (5 months ago) parent0a97743
5 files changed+287−74
internal/backend/agent.go+67−11 internal/backend/agent_test.go+163 internal/backend/testing.go+14 internal/server/agent_cancel_test.go+34−55 internal/server/proto.go+9−8
internal/backend/agent.go+67−11
@@ -2,22 +2,30 @@ package backend
22
33import (
44 "context"
5+ "errors"
56
67 "github.com/charmbracelet/crush/internal/agent"
8+ "github.com/charmbracelet/crush/internal/agent/notify"
79 "github.com/charmbracelet/crush/internal/config"
810 "github.com/charmbracelet/crush/internal/proto"
11+ "github.com/charmbracelet/crush/internal/pubsub"
912)
1013
11−// SendMessage sends a prompt to the agent coordinator for the given
12−// workspace and session.
14+// SendMessage validates and accepts a prompt for the workspace's agent,
15+// then dispatches the run on a goroutine bound to the workspace context
16+// and returns immediately. It does not wait for the LLM turn to
17+// complete: the run's lifetime is owned by the workspace, not by the
18+// caller. Errors from the dispatched run reach observers through the
19+// agent event channels (a notify.TypeAgentError notification), not
20+// through this return value.
1321//
14−// When msg.RunID is non-empty it is attached to the context via
15−// agent.WithRunID so the coordinator can stamp the resulting
16−// SessionAgentCall (and therefore the terminal notify.RunComplete
17−// event) with that correlator. This is the only way for the
18−// originating client to distinguish its own turn's RunComplete from
19−// any concurrent turn that finishes on the same session.
20−func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
22+// SendMessage returns synchronously when the request cannot be accepted:
23+// ErrWorkspaceNotFound if the workspace is missing, ErrAgentNotInitialized
24+// if its coordinator is nil, the structural validation errors from
25+// agent.ValidateCall (ErrEmptyPrompt, ErrSessionMissing) when the prompt
26+// or session is missing, and ErrWorkspaceClosing if the workspace is
27+// being torn down.
28+func (b *Backend) SendMessage(workspaceID string, msg proto.AgentMessage) error {
2129 ws, err := b.GetWorkspace(workspaceID)
2230 if err != nil {
2331 return err
@@ -27,11 +35,59 @@ func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto
2735 return ErrAgentNotInitialized
2836 }
2937
38+ if err := agent.ValidateCall(agent.SessionAgentCall{
39+ SessionID: msg.SessionID,
40+ Prompt: msg.Prompt,
41+ Attachments: proto.AttachmentsToMessage(msg.Attachments),
42+ }); err != nil {
43+ return err
44+ }
45+
46+ accept := ws.AgentCoordinator.BeginAccepted(msg.SessionID)
47+
48+ ws.runMu.Lock()
49+ if ws.closing {
50+ ws.runMu.Unlock()
51+ accept.Close()
52+ return ErrWorkspaceClosing
53+ }
54+ ws.runWG.Add(1)
55+ ws.runMu.Unlock()
56+
57+ go b.runAgent(ws, msg, accept)
58+ return nil
59+}
60+
61+// runAgent executes an accepted agent run for the workspace. It owns the
62+// accept reservation (releasing it on return) and the runWG ticket added
63+// by SendMessage. The run is bound to the workspace context so its
64+// lifetime is independent of any client's HTTP request. On a non-cancel
65+// error it surfaces the failure to observers via a notify.TypeAgentError
66+// notification; context.Canceled is expected (the FinishReasonCanceled
67+// marker is already published by sessionAgent.Run) and swallowed.
68+//
69+// When msg.RunID is non-empty it is attached to the context via
70+// agent.WithRunID so the coordinator can stamp the terminal
71+// notify.RunComplete event with that correlator.
72+func (b *Backend) runAgent(ws *Workspace, msg proto.AgentMessage, accept *agent.AcceptedRun) {
73+ defer ws.runWG.Done()
74+ defer accept.Close()
75+
76+ ctx := ws.ctx
3077 if msg.RunID != "" {
3178 ctx = agent.WithRunID(ctx, msg.RunID)
3279 }
33− _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
34− return err
80+
81+ _, err := ws.AgentCoordinator.RunAccepted(ctx, accept, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
82+ if err == nil || errors.Is(err, context.Canceled) {
83+ return
84+ }
85+
86+ ws.AgentNotifications().Publish(pubsub.CreatedEvent, notify.Notification{
87+ SessionID: msg.SessionID,
88+ Type: notify.TypeAgentError,
89+ Message: err.Error(),
90+ })
3591}
3692
3793// GetAgentInfo returns the agent's model and busy status.
internal/backend/agent_test.gonew file+163
@@ -0,0 +1,163 @@
1+package backend
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "charm.land/fantasy"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ "github.com/charmbracelet/crush/internal/app"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/proto"
14+ "github.com/google/uuid"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// blockingCoordinator is a minimal agent.Coordinator whose RunAccepted
19+// blocks until release is closed. It records that RunAccepted was
20+// entered so tests can observe the dispatched goroutine. Every other
21+// method returns a zero value.
22+type blockingCoordinator struct {
23+ entered chan struct{}
24+ release chan struct{}
25+ runCount atomic.Int32
26+}
27+
28+func newBlockingCoordinator() *blockingCoordinator {
29+ return &blockingCoordinator{
30+ entered: make(chan struct{}, 1),
31+ release: make(chan struct{}),
32+ }
33+}
34+
35+func (c *blockingCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
36+ return nil, nil
37+}
38+
39+func (c *blockingCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
40+ c.runCount.Add(1)
41+ select {
42+ case c.entered <- struct{}{}:
43+ default:
44+ }
45+ <-c.release
46+ return nil, nil
47+}
48+
49+func (c *blockingCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun { return nil }
50+func (c *blockingCoordinator) Cancel(string) {}
51+func (c *blockingCoordinator) CancelAll() {}
52+func (c *blockingCoordinator) IsBusy() bool { return false }
53+func (c *blockingCoordinator) IsSessionBusy(string) bool { return false }
54+func (c *blockingCoordinator) QueuedPrompts(string) int { return 0 }
55+func (c *blockingCoordinator) QueuedPromptsList(string) []string { return nil }
56+func (c *blockingCoordinator) ClearQueue(string) {}
57+func (c *blockingCoordinator) Summarize(context.Context, string) error { return nil }
58+func (c *blockingCoordinator) Model() agent.Model { return agent.Model{} }
59+func (c *blockingCoordinator) UpdateModels(context.Context) error { return nil }
60+
61+// insertAgentWorkspace installs a synthetic workspace with the given
62+// coordinator (or none) and a workspace run context, mirroring the
63+// fields CreateWorkspace initializes.
64+func insertAgentWorkspace(t *testing.T, b *Backend, coord agent.Coordinator) *Workspace {
65+ t.Helper()
66+ ws := &Workspace{
67+ ID: uuid.New().String(),
68+ Path: t.TempDir(),
69+ resolvedPath: t.TempDir(),
70+ clients: make(map[string]*clientState),
71+ shutdownFn: func() {},
72+ }
73+ ws.App = &app.App{AgentCoordinator: coord}
74+ ws.ctx, ws.cancel = context.WithCancel(b.ctx)
75+ b.mu.Lock()
76+ b.workspaces.Set(ws.ID, ws)
77+ b.pathIndex[ws.resolvedPath] = ws.ID
78+ b.mu.Unlock()
79+ return ws
80+}
81+
82+func TestSendMessage_WorkspaceNotFound(t *testing.T) {
83+ t.Parallel()
84+ b, _ := newTestBackend(t)
85+ err := b.SendMessage("nope", proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
86+ require.ErrorIs(t, err, ErrWorkspaceNotFound)
87+}
88+
89+func TestSendMessage_AgentNotInitialized(t *testing.T) {
90+ t.Parallel()
91+ b, _ := newTestBackend(t)
92+ ws := insertAgentWorkspace(t, b, nil)
93+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
94+ require.ErrorIs(t, err, ErrAgentNotInitialized)
95+}
96+
97+func TestSendMessage_EmptyPrompt(t *testing.T) {
98+ t.Parallel()
99+ b, _ := newTestBackend(t)
100+ ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
101+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: ""})
102+ require.ErrorIs(t, err, agent.ErrEmptyPrompt)
103+}
104+
105+func TestSendMessage_SessionMissing(t *testing.T) {
106+ t.Parallel()
107+ b, _ := newTestBackend(t)
108+ ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
109+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "", Prompt: "hi"})
110+ require.ErrorIs(t, err, agent.ErrSessionMissing)
111+}
112+
113+func TestSendMessage_WorkspaceClosing(t *testing.T) {
114+ t.Parallel()
115+ b, _ := newTestBackend(t)
116+ ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
117+ ws.runMu.Lock()
118+ ws.closing = true
119+ ws.runMu.Unlock()
120+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
121+ require.ErrorIs(t, err, ErrWorkspaceClosing)
122+}
123+
124+// TestSendMessage_SuccessIncrementsRunWG asserts the happy path returns
125+// nil synchronously and dispatches a tracked goroutine: while
126+// RunAccepted blocks, runWG.Wait must not complete (the ticket is
127+// outstanding); after release it drains.
128+func TestSendMessage_SuccessIncrementsRunWG(t *testing.T) {
129+ t.Parallel()
130+ b, _ := newTestBackend(t)
131+ coord := newBlockingCoordinator()
132+ ws := insertAgentWorkspace(t, b, coord)
133+
134+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
135+ require.NoError(t, err)
136+
137+ select {
138+ case <-coord.entered:
139+ case <-time.After(2 * time.Second):
140+ t.Fatal("dispatched goroutine never entered RunAccepted")
141+ }
142+ require.Equal(t, int32(1), coord.runCount.Load())
143+
144+ waited := make(chan struct{})
145+ go func() {
146+ ws.runWG.Wait()
147+ close(waited)
148+ }()
149+
150+ select {
151+ case <-waited:
152+ t.Fatal("runWG.Wait completed while the run was still in flight; ticket was not added")
153+ case <-time.After(100 * time.Millisecond):
154+ }
155+
156+ close(coord.release)
157+
158+ select {
159+ case <-waited:
160+ case <-time.After(2 * time.Second):
161+ t.Fatal("runWG.Wait did not complete after the run returned")
162+ }
163+}
internal/backend/testing.go+14
@@ -1,9 +1,16 @@
11package backend
22
3+import "context"
4+
35// InsertWorkspaceForTest registers ws with b under its current ID and
46// path. It is intended for tests in other packages that need to drive
57// HTTP handlers against a synthetic workspace without booting a real
68// app.App. Production code should go through CreateWorkspace.
9+//
10+// If the workspace has no run context yet it is derived from the
11+// backend context (falling back to context.Background), mirroring the
12+// initialization CreateWorkspace performs, so dispatched agent runs
13+// have a non-nil ws.ctx.
714func InsertWorkspaceForTest(b *Backend, ws *Workspace) {
815 if ws.resolvedPath == "" {
916 ws.resolvedPath = ws.Path
@@ -11,6 +18,13 @@ func InsertWorkspaceForTest(b *Backend, ws *Workspace) {
1118 if ws.clients == nil {
1219 ws.clients = make(map[string]*clientState)
1320 }
21+ if ws.ctx == nil {
22+ parent := b.ctx
23+ if parent == nil {
24+ parent = context.Background()
25+ }
26+ ws.ctx, ws.cancel = context.WithCancel(parent)
27+ }
1428 b.mu.Lock()
1529 defer b.mu.Unlock()
1630 b.workspaces.Set(ws.ID, ws)
internal/server/agent_cancel_test.go+34−55
@@ -122,11 +122,12 @@ func postAgent(t *testing.T, c *controllerV1, ctx context.Context, wsID, session
122122}
123123
124124// TestPostAgent_ReturnsOKOnContextCanceled verifies that when another
125−// client cancels the session mid-turn, the prompting client's still
126−// open POST receives 200 (not 500). The agent surfaces the
127−// FinishReasonCanceled marker to every SSE subscriber via the
128−// assistant message; the HTTP response from the prompter should not
129−// double as an error signal.
125+// client cancels the session mid-turn, the prompting client's POST is
126+// unaffected: SendMessage is fire-and-forget, so the handler returns
127+// 200 immediately without waiting for the turn. A run that later
128+// returns context.Canceled never surfaces as a 500 to the prompter;
129+// the FinishReasonCanceled marker reaches SSE subscribers via the
130+// assistant message instead.
130131func TestPostAgent_ReturnsOKOnContextCanceled(t *testing.T) {
131132 t.Parallel()
132133
@@ -135,33 +136,26 @@ func TestPostAgent_ReturnsOKOnContextCanceled(t *testing.T) {
135136 })
136137 c, wsID := buildAgentWorkspace(t, coord)
137138
138− done := make(chan *httptest.ResponseRecorder, 1)
139− go func() {
140− done <- postAgent(t, c, t.Context(), wsID, "S1")
141− }()
139+ // The handler returns immediately, before the dispatched run is
140+ // released, because the run no longer owns the HTTP response.
141+ rec := postAgent(t, c, t.Context(), wsID, "S1")
142+ require.Equal(t, http.StatusOK, rec.Code, "fire-and-forget SendMessage must return 200 without waiting for the run")
142143
143− // Wait until Run is in flight, then release it to return
144− // context.Canceled.
144+ // The run is dispatched on a goroutine; let it return
145+ // context.Canceled. Nothing from that path reaches the (already
146+ // returned) handler.
145147 select {
146148 case <-coord.entered:
147149 case <-time.After(2 * time.Second):
148− t.Fatal("coordinator Run was never entered")
150+ t.Fatal("dispatched run was never entered")
149151 }
150152 close(coord.release)
151−
152− select {
153− case rec := <-done:
154− require.Equal(t, http.StatusOK, rec.Code, "context.Canceled from another client's cancel must not surface as 500")
155− case <-time.After(2 * time.Second):
156− t.Fatal("handler did not return after coordinator returned context.Canceled")
157− }
158153}
159154
160−// TestPostAgent_DetachesRequestContext verifies that canceling the
161−// prompting client's HTTP request context does not cancel the
162−// in-flight agent run. The coordinator must observe a context whose
163−// Done channel never fires from the request side; only the explicit
164−// cancel endpoint may end the run.
155+// TestPostAgent_DetachesRequestContext verifies that the dispatched run
156+// is bound to the workspace context, not the prompting client's HTTP
157+// request context. Canceling the request context must neither cancel
158+// the run nor be observed by the coordinator.
165159func TestPostAgent_DetachesRequestContext(t *testing.T) {
166160 t.Parallel()
167161
@@ -172,46 +166,31 @@ func TestPostAgent_DetachesRequestContext(t *testing.T) {
172166
173167 reqCtx, cancelReq := context.WithCancel(context.Background())
174168
175− done := make(chan *httptest.ResponseRecorder, 1)
176− go func() {
177− done <- postAgent(t, c, reqCtx, wsID, "S1")
178− }()
169+ // The handler returns immediately; the run keeps executing on its
170+ // own goroutine bound to the workspace context.
171+ rec := postAgent(t, c, reqCtx, wsID, "S1")
172+ require.Equal(t, http.StatusOK, rec.Code)
179173
180− // Wait until Run is in flight, then drop the prompting client.
181174 select {
182175 case <-coord.entered:
183176 case <-time.After(2 * time.Second):
184− t.Fatal("coordinator Run was never entered")
177+ t.Fatal("dispatched run was never entered")
185178 }
179+
180+ // Drop the prompting client. This must not reach the run.
186181 cancelReq()
187182
188− // The captured ctx must be detached: context.WithoutCancel
189− // returns a ctx with Done() == nil so request cancellation cannot
190− // propagate.
191183 got := coord.capturedCtx()
192184 require.NotNil(t, got)
193− require.Nil(t, got.Done(), "coordinator ctx must be detached from r.Context() via context.WithoutCancel")
194− require.NoError(t, got.Err(), "coordinator ctx must not inherit cancellation from the dropped request")
185+ // Compare by identity (pointer), not reflect.DeepEqual: deep
186+ // comparison would traverse context internals that the runtime
187+ // mutates concurrently.
188+ require.False(t, got == reqCtx, "run ctx must not be the request ctx")
189+ require.NoError(t, got.Err(), "run ctx must not inherit cancellation from the dropped request")
195190
196− // Confirm Run is still running: it should not have completed
197− // just because the request ctx was canceled.
198− select {
199− case <-done:
200− t.Fatal("handler returned before run completed; request ctx cancellation leaked into the run")
201− case <-time.After(50 * time.Millisecond):
202− }
203−
204− // Release the run; the handler should now complete cleanly.
191+ // Release the run so it returns cleanly.
205192 close(coord.release)
206− select {
207− case rec := <-done:
208− // Writing to a recorder whose request ctx was canceled
209− // still works; in production the TCP write would silently
210− // fail, which is fine because the run already completed and
211− // SSE subscribers have the result.
212− require.Equal(t, http.StatusOK, rec.Code)
213− case <-time.After(2 * time.Second):
214− t.Fatal("handler did not return after release")
215− }
216− require.Equal(t, int32(1), coord.ranCount.Load())
193+ require.Eventually(t, func() bool {
194+ return coord.ranCount.Load() == 1
195+ }, 2*time.Second, 10*time.Millisecond)
217196}
internal/server/proto.go+9−8
@@ -755,14 +755,15 @@ func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.R
755755 return
756756 }
757757
758− // Detach the run's lifetime from the prompting client's HTTP
759− // request. Without this, A dropping its TCP connection (network
760− // blip, TUI restart) or B canceling the session via the explicit
761− // cancel endpoint would also cancel A's request context and tear
762− // down a turn that other subscribed clients are still watching.
763− // Only the explicit cancel endpoint should be able to end a run.
764− ctx := context.WithoutCancel(r.Context())
765− if err := c.backend.SendMessage(ctx, id, msg); err != nil {
758+ // The run's lifetime is detached from the prompting client's HTTP
759+ // request: SendMessage validates and accepts the prompt, dispatches
760+ // the run on a goroutine bound to the workspace context, and returns
761+ // immediately. A dropping its TCP connection (network blip, TUI
762+ // restart) or B canceling the session via the explicit cancel
763+ // endpoint can no longer tear down a turn that other subscribed
764+ // clients are still watching. Only the explicit cancel endpoint
765+ // should be able to end a run.
766+ if err := c.backend.SendMessage(id, msg); err != nil {
766767 c.handleError(w, r, err)
767768 return
768769 }