on main help sign in
Merge pull request #3054 from charmbracelet/fix-competing-clients

fix(server): avoid closing other clients' connections during cancellation

commitb688d52687100096e2c35a6c91fd1d8be75a42d7 authorChristian Rocha <christian@rocha.is> dateWed 3 Jun 2026, 16:57 (4 months ago) parent6ec75f9d411ab5
31 files changed+3660−157
internal/agent/accepted_run_test.go+231 internal/agent/agent.go+637−51 internal/agent/agenttest/coordinator.go+80 internal/agent/coordinator.go+38 internal/agent/coordinator_test.go+4 internal/agent/dispatch_cancel_test.go+447 internal/agent/notify/notify.go+12 internal/agent/queued_runid_test.go+181 internal/agent/run_complete_test.go+233 internal/agent/run_marker.go+52 internal/app/app.go+8 internal/backend/accepted_run_integration_test.go+131 internal/backend/agent.go+96−11 internal/backend/agent_runcomplete_test.go+162 internal/backend/agent_test.go+163 internal/backend/backend.go+56−1 internal/backend/testing.go+14 internal/client/proto.go+17−1 internal/client/proto_test.go+70 internal/cmd/run.go+20−4 internal/cmd/run_stream_test.go+88 internal/proto/agent.go+7 internal/server/agent_cancel_test.go+70−54 internal/server/e2e_agent_test.go+741 internal/server/e2e_test.go+12 internal/server/events.go+13−6 internal/server/events_test.go+32 internal/server/proto.go+22−19 internal/server/sessions_isbusy_test.go+8 internal/ui/model/ui.go+4−4 internal/workspace/client_workspace.go+11−6
internal/agent/accepted_run_test.gonew file+231
@@ -0,0 +1,231 @@
1+package agent
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// newCancelTestAgent builds a DB-backed sessionAgent with no model. The
13+// tests here exercise the dispatch/cancel/persist paths, none of which
14+// reach agent.Stream, so a model is unnecessary.
15+func newCancelTestAgent(t *testing.T) (*sessionAgent, fakeEnv) {
16+ t.Helper()
17+ env := testEnv(t)
18+ sa := NewSessionAgent(SessionAgentOptions{
19+ Sessions: env.sessions,
20+ Messages: env.messages,
21+ }).(*sessionAgent)
22+ return sa, env
23+}
24+
25+func (a *sessionAgent) acceptedCount(sessionID string) int {
26+ c, _ := a.acceptedRuns.Get(sessionID)
27+ return c
28+}
29+
30+func (a *sessionAgent) hasPendingCancel(sessionID string) bool {
31+ mark, ok := a.cancelMark.Get(sessionID)
32+ return ok && mark > 0
33+}
34+
35+func (a *sessionAgent) pendingCancelMark(sessionID string) uint64 {
36+ mark, _ := a.cancelMark.Get(sessionID)
37+ return mark
38+}
39+
40+func TestAcceptedRun_CloseIsIdempotent(t *testing.T) {
41+ t.Parallel()
42+ sa, _ := newCancelTestAgent(t)
43+
44+ accept := sa.BeginAccepted("sid")
45+ require.Equal(t, "sid", accept.SessionID())
46+ require.Equal(t, 1, sa.acceptedCount("sid"))
47+
48+ accept.Close()
49+ require.Equal(t, 0, sa.acceptedCount("sid"))
50+
51+ // Repeated Close must not underflow the counter.
52+ accept.Close()
53+ accept.Close()
54+ require.Equal(t, 0, sa.acceptedCount("sid"))
55+}
56+
57+func TestAcceptedRun_MultipleReservations(t *testing.T) {
58+ t.Parallel()
59+ sa, _ := newCancelTestAgent(t)
60+
61+ a1 := sa.BeginAccepted("sid")
62+ a2 := sa.BeginAccepted("sid")
63+ require.Equal(t, 2, sa.acceptedCount("sid"))
64+
65+ a1.Close()
66+ require.Equal(t, 1, sa.acceptedCount("sid"))
67+
68+ a2.Close()
69+ require.Equal(t, 0, sa.acceptedCount("sid"))
70+}
71+
72+func TestAcceptedRun_NilSafe(t *testing.T) {
73+ t.Parallel()
74+ var accept *AcceptedRun
75+ require.Equal(t, "", accept.SessionID())
76+ // Must not panic.
77+ accept.Close()
78+}
79+
80+func TestCancel_IdleDoesNotRecordPendingCancel(t *testing.T) {
81+ t.Parallel()
82+ sa, _ := newCancelTestAgent(t)
83+
84+ // No accepted run, no active request: a true no-op.
85+ sa.Cancel("sid")
86+ require.False(t, sa.hasPendingCancel("sid"))
87+}
88+
89+func TestCancel_AcceptedRecordsPendingCancel(t *testing.T) {
90+ t.Parallel()
91+ sa, _ := newCancelTestAgent(t)
92+
93+ accept := sa.BeginAccepted("sid")
94+ defer accept.Close()
95+
96+ sa.Cancel("sid")
97+ require.True(t, sa.hasPendingCancel("sid"))
98+}
99+
100+func TestCancel_SecondCancelWhilePendingIsNoOp(t *testing.T) {
101+ t.Parallel()
102+ sa, _ := newCancelTestAgent(t)
103+
104+ accept := sa.BeginAccepted("sid")
105+ defer accept.Close()
106+
107+ sa.Cancel("sid")
108+ require.True(t, sa.hasPendingCancel("sid"))
109+
110+ // A second cancel while a pending cancel is already recorded must
111+ // remain a single pending cancel; one Run consumes exactly one.
112+ sa.Cancel("sid")
113+ require.True(t, sa.hasPendingCancel("sid"))
114+}
115+
116+func TestRun_CancelOnEntryPersistsCanceledTurn(t *testing.T) {
117+ t.Parallel()
118+ sa, env := newCancelTestAgent(t)
119+
120+ sess, err := env.sessions.Create(t.Context(), "session")
121+ require.NoError(t, err)
122+
123+ accept := sa.BeginAccepted(sess.ID)
124+ // A cancel arrives in the accepted-but-not-yet-active window.
125+ sa.Cancel(sess.ID)
126+ require.True(t, sa.hasPendingCancel(sess.ID))
127+
128+ result, err := sa.Run(t.Context(), SessionAgentCall{
129+ SessionID: sess.ID,
130+ Prompt: "hello",
131+ Accepted: accept,
132+ })
133+ require.NoError(t, err)
134+ require.Nil(t, result)
135+
136+ // The pending cancel was consumed and the accept released.
137+ require.False(t, sa.hasPendingCancel(sess.ID))
138+ require.Equal(t, 0, sa.acceptedCount(sess.ID))
139+
140+ msgs, err := env.messages.List(t.Context(), sess.ID)
141+ require.NoError(t, err)
142+ require.Len(t, msgs, 2)
143+ assert.Equal(t, message.User, msgs[0].Role)
144+ assert.Equal(t, message.Assistant, msgs[1].Role)
145+ assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
146+}
147+
148+func TestPersistCanceledTurn_WritesBothWhenUserMissing(t *testing.T) {
149+ t.Parallel()
150+ sa, env := newCancelTestAgent(t)
151+
152+ sess, err := env.sessions.Create(t.Context(), "session")
153+ require.NoError(t, err)
154+
155+ err = sa.persistCanceledTurn(t.Context(), SessionAgentCall{
156+ SessionID: sess.ID,
157+ Prompt: "hello",
158+ }, false)
159+ require.NoError(t, err)
160+
161+ msgs, err := env.messages.List(t.Context(), sess.ID)
162+ require.NoError(t, err)
163+ require.Len(t, msgs, 2)
164+ assert.Equal(t, message.User, msgs[0].Role)
165+ assert.Equal(t, message.Assistant, msgs[1].Role)
166+ assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
167+}
168+
169+func TestPersistCanceledTurn_WritesAssistantOnlyWhenUserCreated(t *testing.T) {
170+ t.Parallel()
171+ sa, env := newCancelTestAgent(t)
172+
173+ sess, err := env.sessions.Create(t.Context(), "session")
174+ require.NoError(t, err)
175+
176+ // Simulate PrepareStep having already created the user message.
177+ _, err = sa.createUserMessage(t.Context(), SessionAgentCall{
178+ SessionID: sess.ID,
179+ Prompt: "hello",
180+ })
181+ require.NoError(t, err)
182+
183+ err = sa.persistCanceledTurn(t.Context(), SessionAgentCall{
184+ SessionID: sess.ID,
185+ Prompt: "hello",
186+ }, true)
187+ require.NoError(t, err)
188+
189+ msgs, err := env.messages.List(t.Context(), sess.ID)
190+ require.NoError(t, err)
191+ require.Len(t, msgs, 2)
192+ assert.Equal(t, message.User, msgs[0].Role)
193+ assert.Equal(t, message.Assistant, msgs[1].Role)
194+ assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
195+}
196+
197+func TestPersistCanceledTurn_SucceedsWithCanceledContext(t *testing.T) {
198+ t.Parallel()
199+ sa, env := newCancelTestAgent(t)
200+
201+ sess, err := env.sessions.Create(t.Context(), "session")
202+ require.NoError(t, err)
203+
204+ // Simulate workspace shutdown having already canceled the run
205+ // context. WithoutCancel must let the writes through.
206+ ctx, cancel := context.WithCancel(t.Context())
207+ cancel()
208+
209+ err = sa.persistCanceledTurn(ctx, SessionAgentCall{
210+ SessionID: sess.ID,
211+ Prompt: "hello",
212+ }, false)
213+ require.NoError(t, err)
214+
215+ msgs, err := env.messages.List(t.Context(), sess.ID)
216+ require.NoError(t, err)
217+ require.Len(t, msgs, 2)
218+}
219+
220+func TestClearPendingCancel(t *testing.T) {
221+ t.Parallel()
222+ sa, _ := newCancelTestAgent(t)
223+
224+ accept := sa.BeginAccepted("sid")
225+ defer accept.Close()
226+ sa.Cancel("sid")
227+ require.True(t, sa.hasPendingCancel("sid"))
228+
229+ sa.clearPendingCancel("sid")
230+ require.False(t, sa.hasPendingCancel("sid"))
231+}
internal/agent/agent.go+637−51
@@ -21,6 +21,7 @@ import (
2121 "strconv"
2222 "strings"
2323 "sync"
24+ "sync/atomic"
2425 "time"
2526
2627 "charm.land/catwalk/pkg/catwalk"
@@ -103,10 +104,28 @@ type SessionAgentCall struct {
103104 // recursion drains, so falling back to the default broker
104105 // publish keeps the event visible to subscribers.
105106 OnComplete func(notify.RunComplete)
107+ // Accepted, when non-nil, is the accept reservation taken by
108+ // BeginAccepted before the call was dispatched onto a goroutine
109+ // (the client/server fire-and-forget path). Run consumes it under
110+ // dispatchMu[SessionID] once the accepted -> (cancel-on-entry |
111+ // queued | active) transition has been chosen. When nil
112+ // (in-process / local callers like AppWorkspace), behavior is
113+ // unchanged and no accept tracking applies.
114+ Accepted *AcceptedRun
115+ // acceptSeq carries the accept sequence of the handle that produced
116+ // this call after it has been enqueued and its Accepted handle
117+ // stripped. The queue-drain paths compare it against a session's
118+ // cancel mark so a follow-up queued before a cancel is dropped while
119+ // one queued after the cancel survives. 0 means untracked (an
120+ // in-process enqueue with no accept reservation), which the drain
121+ // paths treat as covered by any present mark, preserving the
122+ // pre-sequence behavior.
123+ acceptSeq uint64
106124}
107125
108126type SessionAgent interface {
109127 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
128+ BeginAccepted(sessionID string) *AcceptedRun
110129 SetModels(large Model, small Model)
111130 SetTools(tools []fantasy.AgentTool)
112131 SetSystemPrompt(systemPrompt string)
@@ -145,6 +164,43 @@ type sessionAgent struct {
145164
146165 messageQueue *csync.Map[string, []SessionAgentCall]
147166 activeRequests *csync.Map[string, context.CancelFunc]
167+
168+ // dispatchMu holds a per-session mutex that serializes the
169+ // accepted -> (cancel-on-entry | queued | active) transition in
170+ // Run against a concurrent Cancel. The lock is held only during
171+ // the brief handoff (no DB or LLM I/O under the lock).
172+ dispatchMu *csync.Map[string, *sync.Mutex]
173+ // acceptedRuns counts dispatched-but-not-yet-active runs per
174+ // session. A counter > 0 means a dispatched prompt is in flight
175+ // and has not yet completed the dispatch handoff in Run. Only
176+ // BeginAccepted increments it; only AcceptedRun.Close decrements
177+ // it.
178+ acceptedRuns *csync.Map[string, int]
179+ // cancelMark records, per session, a high-water accept sequence: an
180+ // accepted handle is canceled by it iff the handle's sequence is at
181+ // or below the mark. Cancel raises the mark to the latest sequence
182+ // assigned at cancel time, so a single Cancel covers every prompt
183+ // accepted-but-not-yet-active then, while a prompt accepted later
184+ // (higher sequence) is never poisoned. Absent or 0 means no pending
185+ // cancel. It is only raised by Cancel when acceptedRuns > 0, so an
186+ // idle Escape never records a mark.
187+ cancelMark *csync.Map[string, uint64]
188+ // dispatchMuCreate guards lazy creation of per-session entries in
189+ // dispatchMu so two goroutines can't race to lock different mutex
190+ // instances for the same session.
191+ dispatchMuCreate sync.Mutex
192+ // acceptedMu serializes increments/decrements of acceptedRuns and
193+ // the assignment of accept sequence numbers from acceptSeqGen. It
194+ // is separate from dispatchMu so AcceptedRun.Close (which may run
195+ // while Run holds dispatchMu for the same session) does not
196+ // deadlock by re-entering the dispatch lock.
197+ acceptedMu sync.Mutex
198+ // acceptSeqGen is the monotonic source of accept sequence numbers.
199+ // Each BeginAccepted increments it under acceptedMu and stamps the
200+ // returned handle, so sequences strictly increase in accept order
201+ // across the agent. Cancel uses its current value as the per-session
202+ // high-water mark.
203+ acceptSeqGen uint64
148204}
149205
150206type SessionAgentOptions struct {
@@ -180,33 +236,403 @@ func NewSessionAgent(
180236 runComplete: opts.RunComplete,
181237 messageQueue: csync.NewMap[string, []SessionAgentCall](),
182238 activeRequests: csync.NewMap[string, context.CancelFunc](),
239+ dispatchMu: csync.NewMap[string, *sync.Mutex](),
240+ acceptedRuns: csync.NewMap[string, int](),
241+ cancelMark: csync.NewMap[string, uint64](),
183242 }
184243}
185244
186−func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *fantasy.AgentResult, retErr error) {
245+// AcceptedRun owns exactly one accept reservation taken by
246+// BeginAccepted. It is the only carrier of accept-state across the
247+// backend.runAgent / Coordinator.Run / sessionAgent.Run layers: a
248+// counter > 0 means a dispatched prompt is in flight and has not yet
249+// completed the dispatch handoff in Run. Close is the only way to
250+// release the reservation and is idempotent.
251+type AcceptedRun struct {
252+ agent *sessionAgent
253+ sessionID string
254+ // seq is the monotonic accept sequence stamped by BeginAccepted. A
255+ // cancel covers this handle iff seq is at or below the session's
256+ // cancel mark, so a handle accepted after a cancel (higher seq) is
257+ // never poisoned by it.
258+ seq uint64
259+ done atomic.Bool
260+}
261+
262+// Close decrements the accept counter for this reservation. It is safe
263+// to call multiple times; only the first call has effect.
264+func (r *AcceptedRun) Close() {
265+ if r == nil {
266+ return
267+ }
268+ if !r.done.CompareAndSwap(false, true) {
269+ return
270+ }
271+ r.agent.endAccepted(r.sessionID)
272+}
273+
274+// SessionID exposes the session this reservation is for so the run path
275+// can use it without an extra parameter.
276+func (r *AcceptedRun) SessionID() string {
277+ if r == nil {
278+ return ""
279+ }
280+ return r.sessionID
281+}
282+
283+// BeginAccepted increments the accept counter for sessionID and returns
284+// a handle whose Close is the only way to decrement it. It is the only
285+// entry point that mutates acceptedRuns.
286+func (a *sessionAgent) BeginAccepted(sessionID string) *AcceptedRun {
287+ a.acceptedMu.Lock()
288+ defer a.acceptedMu.Unlock()
289+ count, _ := a.acceptedRuns.Get(sessionID)
290+ a.acceptedRuns.Set(sessionID, count+1)
291+ a.acceptSeqGen++
292+ return &AcceptedRun{agent: a, sessionID: sessionID, seq: a.acceptSeqGen}
293+}
294+
295+// endAccepted decrements the accept counter for sessionID. It is only
296+// called via AcceptedRun.Close. It uses a dedicated lock (not the
297+// per-session dispatch mutex) so it can run while Run holds dispatchMu
298+// for the same session without deadlocking.
299+//
300+// When the count reaches zero the session's cancel mark is dropped: no
301+// accepted handle remains for it to cover, and any handle accepted later
302+// gets a strictly higher sequence that the mark would not match anyway.
303+// Handles canceled on entry never reach RunComplete, so this is the only
304+// place that clears the mark for an all-canceled batch. Sibling handles
305+// covered by the same mark are serialized on the per-session dispatch
306+// mutex and read the mark before they Close, so this never clears it out
307+// from under a covered handle still waiting to enter Run.
308+func (a *sessionAgent) endAccepted(sessionID string) {
309+ a.acceptedMu.Lock()
310+ defer a.acceptedMu.Unlock()
311+ count, ok := a.acceptedRuns.Get(sessionID)
312+ if !ok || count <= 1 {
313+ a.acceptedRuns.Del(sessionID)
314+ a.cancelMark.Del(sessionID)
315+ return
316+ }
317+ a.acceptedRuns.Set(sessionID, count-1)
318+}
319+
320+// sessionMu returns the per-session dispatch mutex, creating it on first
321+// use. Creation is guarded so concurrent callers always observe the same
322+// mutex instance for a given session.
323+func (a *sessionAgent) sessionMu(sessionID string) *sync.Mutex {
324+ if mu, ok := a.dispatchMu.Get(sessionID); ok {
325+ return mu
326+ }
327+ a.dispatchMuCreate.Lock()
328+ defer a.dispatchMuCreate.Unlock()
329+ if mu, ok := a.dispatchMu.Get(sessionID); ok {
330+ return mu
331+ }
332+ mu := &sync.Mutex{}
333+ a.dispatchMu.Set(sessionID, mu)
334+ return mu
335+}
336+
337+// enqueueCall appends call to the session's message queue. The
338+// OnComplete hook is stripped: the caller that supplied it (typically
339+// coordinator.Run) has its own retry/coalesce scope that ends when it
340+// returns, so by the time the queue drains nobody is left to consume the
341+// buffered terminal event. The recursive Run falls back to the default
342+// broker publish, which is what existing subscribers expect for queued
343+// turns.
344+func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
345+ existing, ok := a.messageQueue.Get(call.SessionID)
346+ if !ok {
347+ existing = []SessionAgentCall{}
348+ }
349+ queued := call
350+ if call.Accepted != nil {
351+ // Preserve the accept sequence after the handle is stripped so
352+ // the queue-drain paths can tell a follow-up queued before a
353+ // cancel (covered by the mark) from one queued after it.
354+ queued.acceptSeq = call.Accepted.seq
355+ }
356+ queued.OnComplete = nil
357+ queued.Accepted = nil
358+ existing = append(existing, queued)
359+ a.messageQueue.Set(call.SessionID, existing)
360+}
361+
362+// drainQueueForStep partitions the session's queued calls for the current
363+// streaming step under the per-session dispatch mutex so the filtering is
364+// atomic against a concurrent Cancel: canceledBySeq requires the caller to
365+// hold that mutex, and evaluating it here (rather than after unlocking)
366+// prevents a cancel recorded between the drain and the check from being
367+// observed inconsistently.
368+//
369+// Calls covered by a pending cancel are dropped; the dropped ones that
370+// carry a RunID are returned in canceledWithRunID so the caller can
371+// publish their terminal cancelled RunComplete (a caller waiting on that
372+// RunID, e.g. `crush run`, would otherwise hang). Uncanceled calls without
373+// a RunID are returned in fold to be folded into the active turn,
374+// preserving the existing follow-up behavior. Uncanceled calls that carry
375+// a RunID are left in the queue so each runs as its own turn via the
376+// recursive run path and publishes its own RunComplete, giving every
377+// RunID-bearing prompt an explicit lifecycle instead of being silently
378+// absorbed into another turn. fold is processed by the caller without the
379+// lock held.
380+func (a *sessionAgent) drainQueueForStep(sessionID string) (fold, canceledWithRunID []SessionAgentCall) {
381+ dispatchLock := a.sessionMu(sessionID)
382+ dispatchLock.Lock()
383+ defer dispatchLock.Unlock()
384+ queuedCalls, _ := a.messageQueue.Get(sessionID)
385+ var keep []SessionAgentCall
386+ for _, queued := range queuedCalls {
387+ if a.canceledBySeq(sessionID, queued.acceptSeq) {
388+ if queued.RunID != "" {
389+ canceledWithRunID = append(canceledWithRunID, queued)
390+ }
391+ continue
392+ }
393+ if queued.RunID != "" {
394+ keep = append(keep, queued)
395+ continue
396+ }
397+ fold = append(fold, queued)
398+ }
399+ if len(keep) == 0 {
400+ a.messageQueue.Del(sessionID)
401+ } else {
402+ a.messageQueue.Set(sessionID, keep)
403+ }
404+ return fold, canceledWithRunID
405+}
406+
407+// publishCanceledQueueDrops emits a terminal cancelled RunComplete for
408+// every dropped queued call that carries a RunID. A queued prompt removed
409+// from the queue without ever running — covered by a pending cancel, or
410+// cleared by Cancel/ClearQueue — would otherwise leave a caller blocked on
411+// that RunID: `crush run` ignores live message events and exits only on a
412+// RunComplete whose RunID matches. Calls without a RunID had no such waiter
413+// and are dropped silently as before. A detached, bounded context keeps the
414+// must-deliver publish alive even when the run context that triggered the
415+// drop is already canceled.
416+func (a *sessionAgent) publishCanceledQueueDrops(drops []SessionAgentCall) {
417+ var hasRunID bool
418+ for _, d := range drops {
419+ if d.RunID != "" {
420+ hasRunID = true
421+ break
422+ }
423+ }
424+ if !hasRunID {
425+ return
426+ }
427+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
428+ defer cancel()
429+ for _, d := range drops {
430+ if d.RunID == "" {
431+ continue
432+ }
433+ a.publishRunComplete(ctx, d, notify.RunComplete{
434+ SessionID: d.SessionID,
435+ RunID: d.RunID,
436+ Cancelled: true,
437+ })
438+ }
439+}
440+
441+// clearQueueAndNotify removes all queued prompts for the session and
442+// publishes a terminal cancelled RunComplete for any that carried a RunID,
443+// so callers waiting on those RunIDs (e.g. `crush run`) are not left
444+// hanging when their queued prompt is discarded without running.
445+func (a *sessionAgent) clearQueueAndNotify(sessionID string) {
446+ queued, ok := a.messageQueue.Get(sessionID)
447+ a.messageQueue.Del(sessionID)
448+ if !ok {
449+ return
450+ }
451+ a.publishCanceledQueueDrops(queued)
452+}
453+
454+// clearPendingCancel removes any pending-cancel mark for sessionID. It
455+// takes the per-session dispatch lock so it is ordered against Cancel
456+// and the dispatch handoff.
457+func (a *sessionAgent) clearPendingCancel(sessionID string) {
458+ mu := a.sessionMu(sessionID)
459+ mu.Lock()
460+ defer mu.Unlock()
461+ a.cancelMark.Del(sessionID)
462+}
463+
464+// canceledBySeq reports whether an accepted handle or queued call with
465+// the given accept sequence is covered by a pending cancel for the
466+// session. Callers must hold the session's dispatch mutex. A tracked
467+// sequence (seq > 0) is covered only when it is at or below the cancel
468+// high-water mark, so a prompt accepted after the cancel (higher seq) is
469+// never poisoned. An untracked sequence (seq == 0, an in-process enqueue
470+// with no accept reservation) is covered whenever any mark is present,
471+// preserving the pre-sequence behavior. The mark is not consumed: it
472+// stays so every sibling handle it covers observes the same cancel, and
473+// a later handle (higher seq) ignores it regardless.
474+func (a *sessionAgent) canceledBySeq(sessionID string, seq uint64) bool {
475+ mark, ok := a.cancelMark.Get(sessionID)
476+ if !ok || mark == 0 {
477+ return false
478+ }
479+ return seq == 0 || seq <= mark
480+}
481+
482+// persistCanceledTurn writes the user/assistant records for a turn that
483+// was canceled before (or just as) streaming would have produced them.
484+// It creates the user message only when it was not already created by an
485+// earlier createUserMessage call (userMsgCreated), then writes an
486+// assistant message with FinishReasonCanceled. Both writes use
487+// context.WithoutCancel(ctx) so workspace shutdown (which cancels the run
488+// context) can't drop them.
489+func (a *sessionAgent) persistCanceledTurn(ctx context.Context, call SessionAgentCall, userMsgCreated bool) error {
490+ writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
491+ defer cancel()
492+ if !userMsgCreated {
493+ if _, err := a.createUserMessage(writeCtx, call); err != nil {
494+ return err
495+ }
496+ }
497+ largeModel := a.largeModel.Get()
498+ assistant, err := a.messages.Create(writeCtx, call.SessionID, message.CreateMessageParams{
499+ Role: message.Assistant,
500+ Parts: []message.ContentPart{},
501+ Model: largeModel.ModelCfg.Model,
502+ Provider: largeModel.ModelCfg.Provider,
503+ })
504+ if err != nil {
505+ return err
506+ }
507+ assistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
508+ return a.messages.Update(writeCtx, assistant)
509+}
510+
511+// publishRunComplete emits the authoritative terminal event for a turn.
512+// It honors the per-call OnComplete hook when set (so the coordinator can
513+// coalesce retries) and otherwise falls back to the RunComplete broker.
514+// ctx is used only for the bounded-blocking must-deliver publish; the
515+// terminal payload is supplied by the caller. This is the single emit path
516+// shared by the streaming defer and the cancel-on-entry early return so a
517+// caller waiting on RunComplete (e.g. `crush run` with a RunID) always
518+// observes exactly one terminal event regardless of which Run branch ends
519+// the turn.
520+func (a *sessionAgent) publishRunComplete(ctx context.Context, call SessionAgentCall, complete notify.RunComplete) {
521+ if call.OnComplete != nil {
522+ call.OnComplete(complete)
523+ return
524+ }
525+ if a.runComplete == nil {
526+ return
527+ }
528+ a.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, complete)
529+}
530+
531+// ValidateCall performs the cheap structural validation that
532+// sessionAgent.Run requires before a call can be dispatched: a call must
533+// carry either a non-empty prompt or a text attachment, and it must name a
534+// session. It is exported so callers that accept a run before dispatching it
535+// (e.g. backend.SendMessage) can apply the same checks and keep the error
536+// contract consistent.
537+func ValidateCall(call SessionAgentCall) error {
187538 if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
188− return nil, ErrEmptyPrompt
539+ return ErrEmptyPrompt
189540 }
190541 if call.SessionID == "" {
191− return nil, ErrSessionMissing
192− }
193−
194− // Queue the message if busy. Strip OnComplete: the caller that
195− // supplied the hook (typically coordinator.Run) has its own
196− // retry/coalesce scope that ends when it returns, so by the time
197− // the queue drains nobody is left to consume the buffered
198− // terminal event. The recursive Run will fall back to the
199− // default broker publish, which is what existing subscribers
200− // expect for queued turns.
201− if a.IsSessionBusy(call.SessionID) {
202− existing, ok := a.messageQueue.Get(call.SessionID)
203− if !ok {
204− existing = []SessionAgentCall{}
542+ return ErrSessionMissing
543+ }
544+ return nil
545+}
546+
547+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *fantasy.AgentResult, retErr error) {
548+ if err := ValidateCall(call); err != nil {
549+ return nil, err
550+ }
551+
552+ // genCtx/cancel are the run context and its cancel func. For the
553+ // accepted (fire-and-forget) dispatch path they are created under
554+ // dispatchMu below so a concurrent Cancel can observe the
555+ // activeRequests entry before the assistant message exists. For
556+ // the in-process path they stay nil here and are created later,
557+ // preserving the original ordering.
558+ var (
559+ genCtx context.Context
560+ cancel context.CancelFunc
561+ activeRegistered bool
562+ userMsgCreated bool
563+ )
564+
565+ if call.Accepted != nil {
566+ // Serialize the accepted -> (cancel-on-entry | queued |
567+ // active) transition against a concurrent Cancel. Cancel takes
568+ // the same per-session lock, so every cancel observes at least
569+ // one of: a cancel mark, an activeRequests entry, or a
570+ // messageQueue entry it then clears.
571+ mu := a.sessionMu(call.SessionID)
572+ mu.Lock()
573+
574+ if a.canceledBySeq(call.SessionID, call.Accepted.seq) {
575+ // Cancel-on-entry: a cancel arrived while this run was
576+ // dispatched but not yet active, and this handle's accept
577+ // sequence is at or below the session's cancel mark. The
578+ // mark is left in place so sibling handles it also covers
579+ // observe the same cancel; release the accept reservation,
580+ // drop the lock, and persist a canceled turn without
581+ // entering Stream.
582+ //
583+ // This path returns before the streaming defer that
584+ // publishes RunComplete is installed, so emit the terminal
585+ // event explicitly. Without it, a caller waiting on
586+ // RunComplete for this RunID (e.g. `crush run`, which
587+ // ignores message events and blocks on RunComplete) would
588+ // hang on an immediately-canceled accepted run.
589+ call.Accepted.Close()
590+ mu.Unlock()
591+ complete := notify.RunComplete{
592+ SessionID: call.SessionID,
593+ RunID: call.RunID,
594+ Cancelled: true,
595+ }
596+ if err := a.persistCanceledTurn(ctx, call, false); err != nil {
597+ complete.Error = err.Error()
598+ a.publishRunComplete(ctx, call, complete)
599+ return nil, err
600+ }
601+ a.publishRunComplete(ctx, call, complete)
602+ return nil, nil
205603 }
206− queued := call
207− queued.OnComplete = nil
208− existing = append(existing, queued)
209− a.messageQueue.Set(call.SessionID, existing)
604+
605+ if a.IsSessionBusy(call.SessionID) {
606+ // Busy: an earlier prompt is active. Queue this call and
607+ // release the accept reservation. A Cancel arriving after
608+ // this point sees the active entry and clears the queue.
609+ a.enqueueCall(call)
610+ call.Accepted.Close()
611+ mu.Unlock()
612+ return nil, nil
613+ }
614+
615+ // Idle: become the active run. Register the cancel func before
616+ // dropping the lock so a Cancel that arrives between here and
617+ // assistant creation is not lost.
618+ runCtx := context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
619+ genCtx, cancel = context.WithCancel(runCtx)
620+ a.activeRequests.Set(call.SessionID, cancel)
621+ activeRegistered = true
622+ call.Accepted.Close()
623+ mu.Unlock()
624+
625+ defer cancel()
626+ defer a.activeRequests.Del(call.SessionID)
627+ } else if a.IsSessionBusy(call.SessionID) {
628+ // Queue the message if busy. Strip OnComplete: the caller that
629+ // supplied the hook (typically coordinator.Run) has its own
630+ // retry/coalesce scope that ends when it returns, so by the time
631+ // the queue drains nobody is left to consume the buffered
632+ // terminal event. The recursive Run will fall back to the
633+ // default broker publish, which is what existing subscribers
634+ // expect for queued turns.
635+ a.enqueueCall(call)
210636 return nil, nil
211637 }
212638
@@ -269,15 +695,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
269695 if err != nil {
270696 return nil, err
271697 }
698+ userMsgCreated = true
272699
273700 // Add the session to the context.
274701 ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
275702
276− genCtx, cancel := context.WithCancel(ctx)
277− a.activeRequests.Set(call.SessionID, cancel)
703+ // For the accepted dispatch path the run context and cancel func
704+ // were already created and registered under dispatchMu above; reuse
705+ // them. For the in-process path create them here, preserving the
706+ // original ordering.
707+ if !activeRegistered {
708+ genCtx, cancel = context.WithCancel(ctx)
709+ a.activeRequests.Set(call.SessionID, cancel)
278710
279− defer cancel()
280− defer a.activeRequests.Del(call.SessionID)
711+ defer cancel()
712+ defer a.activeRequests.Del(call.SessionID)
713+ }
281714 // skipRunComplete is set just before the queued-recursion path so
282715 // the outer Run doesn't publish a RunComplete that would race
283716 // with — and be superseded by — the recursive call's own
@@ -305,7 +738,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
305738 // (the pubsub broker fan-in does not serialize publishes from
306739 // different upstream brokers).
307740 defer func() {
308− if flushErr := a.messages.FlushAll(ctx); flushErr != nil {
741+ // Use a context detached from the run context: workspace
742+ // shutdown cancels ctx before this goroutine returns, but the
743+ // buffered streaming deltas must still land before the DB is
744+ // closed. A short timeout bounds the flush.
745+ flushCtx, flushCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
746+ defer flushCancel()
747+ if flushErr := a.messages.FlushAll(flushCtx); flushErr != nil {
309748 slog.Error("Failed to flush pending message updates after run", "error", flushErr)
310749 }
311750 if skipRunComplete {
@@ -329,14 +768,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
329768 // the authoritative terminal event so a momentarily-full
330769 // subscriber channel can't silently drop it and hang
331770 // non-interactive clients waiting on RunComplete.
332− if call.OnComplete != nil {
333− call.OnComplete(complete)
334− return
335− }
336− if a.runComplete == nil {
337− return
338− }
339− a.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, complete)
771+ a.publishRunComplete(ctx, call, complete)
340772 }()
341773
342774 history, files := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages, call.Attachments...)
@@ -371,9 +803,20 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
371803 // Use latest tools (updated by SetTools when MCP tools change).
372804 prepared.Tools = a.tools.Copy()
373805
374− queuedCalls, _ := a.messageQueue.Get(call.SessionID)
375− a.messageQueue.Del(call.SessionID)
376− for _, queued := range queuedCalls {
806+ // Drain queued follow-up prompts for this step. Calls covered
807+ // by a cancel recorded while they sat in the queue are dropped:
808+ // a cancel that arrived after a prompt was queued must not let
809+ // it run as part of this step. Coverage is per-call by accept
810+ // sequence so a follow-up queued after the cancel (higher seq)
811+ // is not dropped. A dropped prompt carrying a RunID still gets
812+ // its terminal cancelled RunComplete so a caller waiting on it
813+ // does not hang. Uncanceled prompts without a RunID are folded
814+ // into this turn; uncanceled prompts with a RunID are left
815+ // queued so each runs as its own turn (with its own
816+ // RunComplete) via the recursive run path below.
817+ fold, canceledRunIDs := a.drainQueueForStep(call.SessionID)
818+ a.publishCanceledQueueDrops(canceledRunIDs)
819+ for _, queued := range fold {
377820 userMessage, createErr := a.createUserMessage(callContext, queued)
378821 if createErr != nil {
379822 return callContext, prepared, createErr
@@ -575,13 +1018,34 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
5751018 isHyper := largeModel.ModelCfg.Provider == hyper.Name
5761019 isCancelErr := errors.Is(err, context.Canceled)
5771020 if currentAssistant == nil {
1021+ // Cancel-before-assistant-creation window: the run was
1022+ // canceled after activeRequests.Set but before PrepareStep
1023+ // created the assistant message. Without this, the turn
1024+ // would return with no FinishReasonCanceled marker and no
1025+ // user-visible record. The user message was already created
1026+ // above, so persistCanceledTurn only writes the assistant
1027+ // record.
1028+ if isCancelErr {
1029+ if persistErr := a.persistCanceledTurn(ctx, call, userMsgCreated); persistErr != nil {
1030+ return nil, persistErr
1031+ }
1032+ }
5781033 return result, err
5791034 }
1035+ // Persist final state with a context detached from the run
1036+ // context. The run context (ctx) is derived from the
1037+ // workspace context, which workspace shutdown cancels before
1038+ // agent goroutines finish; using ctx here would drop the
1039+ // final assistant state. WithoutCancel keeps the values
1040+ // (e.g. session ID) while ignoring cancellation, and a short
1041+ // timeout bounds the cleanup writes.
1042+ cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
1043+ defer cleanupCancel()
5801044 // Ensure we finish thinking on error to close the reasoning state.
5811045 currentAssistant.FinishThinking()
5821046 toolCalls := currentAssistant.ToolCalls()
583− // INFO: we use the parent context here because the genCtx has been cancelled.
584− msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
1047+ // INFO: we use the cleanup context here because the genCtx has been cancelled.
1048+ msgs, createErr := a.messages.List(cleanupCtx, currentAssistant.SessionID)
5851049 if createErr != nil {
5861050 return nil, createErr
5871051 }
@@ -590,7 +1054,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
5901054 tc.Finished = true
5911055 tc.Input = "{}"
5921056 currentAssistant.AddToolCall(tc)
593− updateErr := a.messages.Update(ctx, *currentAssistant)
1057+ updateErr := a.messages.Update(cleanupCtx, *currentAssistant)
5941058 if updateErr != nil {
5951059 return nil, updateErr
5961060 }
@@ -623,7 +1087,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
6231087 Content: content,
6241088 IsError: true,
6251089 }
626− _, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
1090+ _, createErr = a.messages.Create(cleanupCtx, currentAssistant.SessionID, message.CreateMessageParams{
6271091 Role: message.Tool,
6281092 Parts: []message.ContentPart{
6291093 toolResult,
@@ -662,9 +1126,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
6621126 } else {
6631127 currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
6641128 }
665− // Note: we use the parent context here because the genCtx has been
1129+ // Note: we use the cleanup context here because the genCtx has been
6661130 // cancelled.
667− updateErr := a.messages.Update(ctx, *currentAssistant)
1131+ updateErr := a.messages.Update(cleanupCtx, *currentAssistant)
6681132 if updateErr != nil {
6691133 return nil, updateErr
6701134 }
@@ -705,18 +1169,106 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
7051169 })
7061170 }
7071171
708− queuedMessages, ok := a.messageQueue.Get(call.SessionID)
709− if !ok || len(queuedMessages) == 0 {
1172+ // Hand off to the next queued prompt (if any) under dispatchMu so
1173+ // the transition from this finished run to the queued run is atomic
1174+ // against a concurrent Cancel. activeRequests for this session was
1175+ // just deleted above, so without the lock there is a window in
1176+ // which the session looks idle and a cancel becomes a no-op that
1177+ // fails to stop the queued prompt. Holding the lock lets us observe
1178+ // a pending cancel recorded against the session and drop the queue
1179+ // instead of running it, and (for the recursion) hand a fresh
1180+ // accept reservation to the dequeued call so acceptedRuns stays > 0
1181+ // across the recursive Run's own dispatch handoff — keeping the
1182+ // session observable to Cancel for the entire transition and
1183+ // closing the dequeue -> re-register window.
1184+ mu := a.sessionMu(call.SessionID)
1185+ mu.Lock()
1186+ queuedMessages, _ := a.messageQueue.Get(call.SessionID)
1187+ if mark, ok := a.cancelMark.Get(call.SessionID); ok && mark > 0 && len(queuedMessages) > 0 {
1188+ // A cancel was recorded for this session (e.g. it arrived while
1189+ // this run was active and follow-ups had been queued). Drop the
1190+ // queued prompts it covers (accept sequence at or below the
1191+ // mark, or untracked); keep any queued after the cancel (higher
1192+ // sequence) so they still run.
1193+ var kept []SessionAgentCall
1194+ var canceledRunIDDrops []SessionAgentCall
1195+ for _, q := range queuedMessages {
1196+ if q.acceptSeq == 0 || q.acceptSeq <= mark {
1197+ if q.RunID != "" {
1198+ canceledRunIDDrops = append(canceledRunIDDrops, q)
1199+ }
1200+ continue
1201+ }
1202+ kept = append(kept, q)
1203+ }
1204+ queuedMessages = kept
1205+ a.messageQueue.Set(call.SessionID, kept)
1206+ // A dropped prompt carrying a RunID must still publish its
1207+ // terminal cancelled RunComplete so a caller waiting on that
1208+ // RunID does not hang.
1209+ a.publishCanceledQueueDrops(canceledRunIDDrops)
1210+ }
1211+ if len(queuedMessages) == 0 {
1212+ // No queued work. Clear the cancel mark only when no accepted
1213+ // run remains in flight that it might still cover; otherwise a
1214+ // sibling prompt (sequence at or below the mark) waiting to
1215+ // enter Run would lose its cancellation. When accepted runs are
1216+ // gone, this also clears a stale mark so it can't catch a
1217+ // future run.
1218+ a.messageQueue.Del(call.SessionID)
1219+ a.acceptedMu.Lock()
1220+ inFlight, _ := a.acceptedRuns.Get(call.SessionID)
1221+ a.acceptedMu.Unlock()
1222+ if inFlight == 0 {
1223+ a.cancelMark.Del(call.SessionID)
1224+ }
1225+ mu.Unlock()
7101226 return result, err
7111227 }
712− // There are queued messages restart the loop. The recursive Run
713− // publishes its own RunComplete for the queued prompt, so suppress
714− // the outer defer's emit to avoid a duplicate event whose Error
715− // field would belong to the recursive turn but whose MessageID/Text
716− // would belong to the outer turn.
1228+ // There are queued messages, restart the loop. Suppress the outer
1229+ // defer's emit: it would otherwise observe the recursive Run's retErr
1230+ // (named-return clobbering through the return below) against this
1231+ // turn's MessageID/Text and publish a mixed, racing event.
7171232 skipRunComplete = true
1233+ // Decide whether this turn still owes its own terminal RunComplete.
1234+ // Each submitted prompt with a RunID has its own lifecycle, so a turn
1235+ // that is finished and handing off to a *different* queued prompt must
1236+ // publish its own RunComplete here — leaving it to the recursive turn
1237+ // (which carries a different RunID) would hang a caller waiting on
1238+ // this turn's RunID. The exception is the summarize-continuation path,
1239+ // which re-queues this same call (same RunID) to resume after a
1240+ // summary; in that case the eventual terminal turn for this RunID
1241+ // publishes, so publishing now would double-emit.
1242+ outerOwesRunComplete := call.RunID != ""
1243+ if outerOwesRunComplete {
1244+ for _, q := range queuedMessages {
1245+ if q.RunID == call.RunID {
1246+ outerOwesRunComplete = false
1247+ break
1248+ }
1249+ }
1250+ }
7181251 firstQueuedMessage := queuedMessages[0]
7191252 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
1253+ // Reserve a fresh accept for the dequeued prompt before dropping the
1254+ // lock so acceptedRuns > 0 across the handoff into the recursive
1255+ // Run. This closes the window between this dequeue and the recursive
1256+ // Run registering its activeRequests entry: a cancel arriving in
1257+ // that window now records a pending cancel (acceptedRuns > 0) that
1258+ // the recursive Run's accepted path observes as cancel-on-entry.
1259+ firstQueuedMessage.Accepted = a.BeginAccepted(call.SessionID)
1260+ mu.Unlock()
1261+ if outerOwesRunComplete {
1262+ complete := notify.RunComplete{SessionID: call.SessionID, RunID: call.RunID}
1263+ if currentAssistant != nil {
1264+ complete.MessageID = currentAssistant.ID
1265+ complete.Text = currentAssistant.Content().String()
1266+ }
1267+ if ctx.Err() != nil {
1268+ complete.Cancelled = true
1269+ }
1270+ a.publishRunComplete(ctx, call, complete)
1271+ }
7201272 return a.Run(ctx, firstQueuedMessage)
7211273}
7221274
@@ -1269,6 +1821,16 @@ func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message
12691821}
12701822
12711823func (a *sessionAgent) Cancel(sessionID string) {
1824+ // Serialize against the dispatch handoff in Run so the accepted ->
1825+ // (cancel-on-entry | queued | active) transition is atomic against
1826+ // this cancel. Every cancel observes at least one of: an active
1827+ // request, an accepted run (recorded as a pending cancel), or a
1828+ // queue entry it then clears. If none of those hold, an idle Escape
1829+ // is a true no-op and must not poison the next prompt.
1830+ mu := a.sessionMu(sessionID)
1831+ mu.Lock()
1832+ defer mu.Unlock()
1833+
12721834 // Cancel regular requests. Don't use Take() here - we need the entry to
12731835 // remain in activeRequests so IsBusy() returns true until the goroutine
12741836 // fully completes (including error handling that may access the DB).
@@ -1284,16 +1846,40 @@ func (a *sessionAgent) Cancel(sessionID string) {
12841846 cancel()
12851847 }
12861848
1849+ // Record a pending cancel only when a dispatched-but-not-yet-active
1850+ // run exists. This catches runs still in the goroutine scheduler or
1851+ // about to enter Run's busy-queue branch, while leaving an idle
1852+ // session untouched. Active and accepted are not mutually exclusive:
1853+ // when a run is active and a follow-up has been accepted, both the
1854+ // cancel above and this pending record fire.
1855+ //
1856+ // Raise the session's cancel mark to the latest accept sequence
1857+ // assigned so far. Every prompt currently accepted-but-not-yet-
1858+ // active has a sequence at or below that value, so one cancel covers
1859+ // all of them; a prompt accepted after this cancel gets a strictly
1860+ // higher sequence and is never poisoned. Using max keeps repeated
1861+ // cancels idempotent while the same prompts are in flight and lets a
1862+ // later cancel extend coverage to prompts accepted since.
1863+ a.acceptedMu.Lock()
1864+ count, ok := a.acceptedRuns.Get(sessionID)
1865+ mark := a.acceptSeqGen
1866+ a.acceptedMu.Unlock()
1867+ if ok && count > 0 {
1868+ slog.Debug("Recording cancel mark for accepted runs", "session_id", sessionID, "count", count, "mark", mark)
1869+ existing, _ := a.cancelMark.Get(sessionID)
1870+ a.cancelMark.Set(sessionID, max(existing, mark))
1871+ }
1872+
12871873 if a.QueuedPrompts(sessionID) > 0 {
12881874 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1289− a.messageQueue.Del(sessionID)
1875+ a.clearQueueAndNotify(sessionID)
12901876 }
12911877}
12921878
12931879func (a *sessionAgent) ClearQueue(sessionID string) {
12941880 if a.QueuedPrompts(sessionID) > 0 {
12951881 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1296− a.messageQueue.Del(sessionID)
1882+ a.clearQueueAndNotify(sessionID)
12971883 }
12981884}
12991885
internal/agent/agenttest/coordinator.gonew file+80
@@ -0,0 +1,80 @@
1+// Package agenttest provides test-only constructors for wiring a real
2+// production agent.Coordinator without booting a full app.App. It is
3+// imported only from _test.go files (e.g. internal/backend integration
4+// tests) and is never referenced by production code, so it is compiled
5+// only under tests and never ships in the production binary or API.
6+package agenttest
7+
8+import (
9+ "context"
10+
11+ "charm.land/catwalk/pkg/catwalk"
12+ "charm.land/fantasy/providers/openaicompat"
13+ "github.com/charmbracelet/crush/internal/agent"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/permission"
17+ "github.com/charmbracelet/crush/internal/session"
18+)
19+
20+// NewCoordinator builds a real agent.Coordinator through the production
21+// agent.NewCoordinator constructor so the RunAccepted / BeginAccepted /
22+// run path (including UpdateModels) is the actual code under test.
23+//
24+// It installs a minimal config with a single openai-compatible provider
25+// whose model resolves offline. run rebuilds the model on every call, so
26+// the provider must construct without network I/O; the cancel-on-entry
27+// path this helper is built to exercise returns before any model call,
28+// so no request is ever issued. The coder agent's allowed-tools list is
29+// cleared to keep tool construction cheap and free of sub-agent wiring.
30+//
31+// The optional coordinator dependencies (history, filetracker, LSP,
32+// notify, runComplete, skills) are nil: run guards the publisher fields
33+// and the cancel-on-entry path never touches the others.
34+func NewCoordinator(
35+ ctx context.Context,
36+ workingDir string,
37+ sessions session.Service,
38+ messages message.Service,
39+) (agent.Coordinator, error) {
40+ cfg, err := config.Init(workingDir, "", false)
41+ if err != nil {
42+ return nil, err
43+ }
44+
45+ const (
46+ providerID = "test-openai-compat"
47+ modelID = "test-model"
48+ )
49+ cfg.Config().Providers.Set(providerID, config.ProviderConfig{
50+ ID: providerID,
51+ Name: "Test",
52+ Type: openaicompat.Name,
53+ BaseURL: "http://127.0.0.1:0/v1",
54+ APIKey: "test",
55+ Models: []catwalk.Model{{ID: modelID, DefaultMaxTokens: 4096}},
56+ })
57+ selected := config.SelectedModel{Provider: providerID, Model: modelID}
58+ cfg.Config().Models[config.SelectedModelTypeLarge] = selected
59+ cfg.Config().Models[config.SelectedModelTypeSmall] = selected
60+ cfg.SetupAgents()
61+
62+ // Keep buildTools light: no sub-agent or agentic-fetch construction.
63+ coderCfg := cfg.Config().Agents[config.AgentCoder]
64+ coderCfg.AllowedTools = nil
65+ cfg.Config().Agents[config.AgentCoder] = coderCfg
66+
67+ return agent.NewCoordinator(
68+ ctx,
69+ cfg,
70+ sessions,
71+ messages,
72+ permission.NewPermissionService(workingDir, true, nil),
73+ nil,
74+ nil,
75+ nil,
76+ nil,
77+ nil,
78+ nil,
79+ )
80+}
internal/agent/coordinator.go+38
@@ -81,6 +81,15 @@ type Coordinator interface {
8181 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
8282 // SetMainAgent(string)
8383 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
84+ // RunAccepted runs a call that was already accepted via
85+ // BeginAccepted on the fire-and-forget dispatch path. The handle is
86+ // the only carrier of accept-state across the backend.runAgent /
87+ // Coordinator / sessionAgent.Run layers: it reaches
88+ // sessionAgent.Run as SessionAgentCall.Accepted, where it is
89+ // consumed under dispatchMu once the accepted -> (cancel-on-entry |
90+ // queued | active) transition is chosen.
91+ RunAccepted(ctx context.Context, accept *AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
92+ BeginAccepted(sessionID string) *AcceptedRun
8493 Cancel(sessionID string)
8594 CancelAll()
8695 IsSessionBusy(sessionID string) bool
@@ -179,6 +188,20 @@ func NewCoordinator(
179188
180189// Run implements Coordinator.
181190func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
191+ return c.run(ctx, nil, sessionID, prompt, attachments...)
192+}
193+
194+// RunAccepted implements Coordinator.
195+func (c *coordinator) RunAccepted(ctx context.Context, accept *AcceptedRun, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
196+ return c.run(ctx, accept, sessionID, prompt, attachments...)
197+}
198+
199+// run is the shared implementation behind Run and RunAccepted. When
200+// accept is non-nil it is threaded onto the SessionAgentCall as
201+// Accepted so sessionAgent.Run can consume the accept reservation under
202+// dispatchMu; when nil (the in-process/local path) no accept tracking
203+// applies.
204+func (c *coordinator) run(ctx context.Context, accept *AcceptedRun, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
182205 if err := c.readyWg.Wait(); err != nil {
183206 return nil, err
184207 }
@@ -256,6 +279,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
256279 FrequencyPenalty: freqPenalty,
257280 PresencePenalty: presPenalty,
258281 OnComplete: onComplete,
282+ Accepted: accept,
259283 })
260284 }
261285 beforeLoaded := c.skillTracker.LoadedNames()
@@ -278,6 +302,11 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
278302
279303 if hasLatest && c.runComplete != nil {
280304 c.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, latest)
305+ // Signal to the dispatcher (backend.runAgent) that the
306+ // authoritative terminal RunComplete for this run was already
307+ // emitted, so it does not publish a duplicate fallback for the
308+ // error it is about to receive.
309+ MarkRunCompletePublished(ctx)
281310 }
282311 return result, originalErr
283312}
@@ -1016,6 +1045,15 @@ func isExactoSupported(modelID string) bool {
10161045 return slices.Contains(supportedModels, modelID)
10171046}
10181047
1048+// BeginAccepted reserves an accept slot for sessionID on the active
1049+// agent and returns the ownership handle. It is the fire-and-forget
1050+// dispatch path's only way to mark a run as accepted-but-not-yet-active
1051+// so a cancel arriving before the run registers in activeRequests is not
1052+// lost.
1053+func (c *coordinator) BeginAccepted(sessionID string) *AcceptedRun {
1054+ return c.currentAgent.BeginAccepted(sessionID)
1055+}
1056+
10191057func (c *coordinator) Cancel(sessionID string) {
10201058 c.currentAgent.Cancel(sessionID)
10211059}
internal/agent/coordinator_test.go+4
@@ -25,6 +25,10 @@ func (m *mockSessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fan
2525 return m.runFunc(ctx, call)
2626}
2727
28+func (m *mockSessionAgent) BeginAccepted(sessionID string) *AcceptedRun {
29+ return &AcceptedRun{sessionID: sessionID}
30+}
31+
2832func (m *mockSessionAgent) Model() Model { return m.model }
2933func (m *mockSessionAgent) SetModels(large, small Model) {}
3034func (m *mockSessionAgent) SetTools(tools []fantasy.AgentTool) {}
internal/agent/dispatch_cancel_test.gonew file+447
@@ -0,0 +1,447 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "charm.land/fantasy"
11+ "github.com/charmbracelet/crush/internal/agent/notify"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/pubsub"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// finishStreamModel is a minimal fantasy.LanguageModel that streams a
19+// single text part followed by a normal (FinishReasonStop) finish. It
20+// is enough to drive sessionAgent.Run through PrepareStep and a clean
21+// completion without a recorded provider cassette.
22+type finishStreamModel struct {
23+ text string
24+}
25+
26+func (m *finishStreamModel) Provider() string { return "fake" }
27+func (m *finishStreamModel) Model() string { return "fake-model" }
28+
29+func (m *finishStreamModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
30+ return &fantasy.Response{
31+ Content: fantasy.ResponseContent{fantasy.TextContent{Text: m.text}},
32+ FinishReason: fantasy.FinishReasonStop,
33+ }, nil
34+}
35+
36+func (m *finishStreamModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
37+ text := m.text
38+ return func(yield func(fantasy.StreamPart) bool) {
39+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"}) {
40+ return
41+ }
42+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: text}) {
43+ return
44+ }
45+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"}) {
46+ return
47+ }
48+ yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
49+ }, nil
50+}
51+
52+func (m *finishStreamModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
53+ return nil, errors.New("not implemented")
54+}
55+
56+func (m *finishStreamModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
57+ return nil, errors.New("not implemented")
58+}
59+
60+func newStreamTestAgent(t *testing.T) (*sessionAgent, fakeEnv) {
61+ t.Helper()
62+ env := testEnv(t)
63+ model := &finishStreamModel{text: "done"}
64+ sa := testSessionAgent(env, model, model, "system").(*sessionAgent)
65+ return sa, env
66+}
67+
68+// TestCancel_ActiveAndAcceptedFiresBothBranches covers the case where a
69+// session is actively running (activeRequests set) AND a follow-up has
70+// been accepted (acceptedRuns > 0). A single Cancel must fire both: it
71+// invokes the active cancel func and records a pending cancel for the
72+// accepted follow-up.
73+func TestCancel_ActiveAndAcceptedFiresBothBranches(t *testing.T) {
74+ t.Parallel()
75+ sa, _ := newCancelTestAgent(t)
76+
77+ const sid = "sid"
78+ var activeCanceled atomic.Bool
79+ sa.activeRequests.Set(sid, func() { activeCanceled.Store(true) })
80+
81+ accept := sa.BeginAccepted(sid)
82+ defer accept.Close()
83+
84+ sa.Cancel(sid)
85+
86+ require.True(t, activeCanceled.Load(), "active cancel func must fire")
87+ require.True(t, sa.hasPendingCancel(sid), "accepted follow-up must record a pending cancel")
88+}
89+
90+// TestRun_BusyWithPendingCancelTakesCancelOnEntry covers the busy-queue
91+// branch consulting pendingCancels: when the session is busy AND a
92+// cancel has been recorded for an accepted follow-up, Run must take the
93+// cancel-on-entry path (persist a canceled turn) instead of enqueueing
94+// the call behind the active run.
95+func TestRun_BusyWithPendingCancelTakesCancelOnEntry(t *testing.T) {
96+ t.Parallel()
97+ sa, env := newCancelTestAgent(t)
98+
99+ sess, err := env.sessions.Create(t.Context(), "session")
100+ require.NoError(t, err)
101+
102+ // Make the session look busy: an earlier prompt is active.
103+ sa.activeRequests.Set(sess.ID, func() {})
104+
105+ accept := sa.BeginAccepted(sess.ID)
106+ // A cancel arrives while this follow-up is accepted-but-not-active.
107+ sa.Cancel(sess.ID)
108+ require.True(t, sa.hasPendingCancel(sess.ID))
109+
110+ result, err := sa.Run(t.Context(), SessionAgentCall{
111+ SessionID: sess.ID,
112+ Prompt: "follow-up",
113+ Accepted: accept,
114+ })
115+ require.NoError(t, err)
116+ require.Nil(t, result)
117+
118+ // The follow-up was canceled on entry, not enqueued.
119+ require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
120+ "cancel-on-entry must not enqueue the follow-up behind the active run")
121+ require.False(t, sa.hasPendingCancel(sess.ID), "pending cancel must be consumed")
122+ require.Equal(t, 0, sa.acceptedCount(sess.ID), "accept reservation must be released")
123+
124+ msgs, err := env.messages.List(t.Context(), sess.ID)
125+ require.NoError(t, err)
126+ require.Len(t, msgs, 2)
127+ assert.Equal(t, message.User, msgs[0].Role)
128+ assert.Equal(t, message.Assistant, msgs[1].Role)
129+ assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
130+}
131+
132+// TestRun_PrepareStepDrainSkipsQueuedOnPendingCancel verifies that the
133+// queue drain inside PrepareStep skips queued follow-up prompts when a
134+// cancel has been recorded for the session: the queued prompt must not
135+// be folded into the active turn as an extra user message.
136+func TestRun_PrepareStepDrainSkipsQueuedOnPendingCancel(t *testing.T) {
137+ t.Parallel()
138+ sa, env := newStreamTestAgent(t)
139+
140+ sess, err := env.sessions.Create(t.Context(), "session")
141+ require.NoError(t, err)
142+
143+ // A follow-up prompt sits queued for the session.
144+ sa.enqueueCall(SessionAgentCall{SessionID: sess.ID, Prompt: "queued-followup"})
145+ // A cancel was recorded for the session while it sat in the queue.
146+ sa.cancelMark.Set(sess.ID, 1)
147+
148+ result, err := sa.Run(t.Context(), SessionAgentCall{
149+ SessionID: sess.ID,
150+ Prompt: "main",
151+ })
152+ require.NoError(t, err)
153+ require.NotNil(t, result)
154+
155+ // Only the main prompt produced a user message; the queued
156+ // follow-up was skipped, not folded into the turn.
157+ msgs, err := env.messages.List(t.Context(), sess.ID)
158+ require.NoError(t, err)
159+ var userMsgs []message.Message
160+ for _, m := range msgs {
161+ if m.Role == message.User {
162+ userMsgs = append(userMsgs, m)
163+ }
164+ }
165+ require.Len(t, userMsgs, 1, "queued follow-up must not create a user message")
166+ assert.Equal(t, "main", userMsgs[0].Content().String())
167+
168+ // The queue was drained and the pending cancel consumed.
169+ require.Equal(t, 0, sa.QueuedPrompts(sess.ID))
170+ require.False(t, sa.hasPendingCancel(sess.ID))
171+}
172+
173+// TestRun_NormalCompletionClearsStalePendingCancel verifies that a Run
174+// which completes normally clears any stale pending-cancel entry for the
175+// session, so it cannot catch a future run.
176+func TestRun_NormalCompletionClearsStalePendingCancel(t *testing.T) {
177+ t.Parallel()
178+ sa, env := newStreamTestAgent(t)
179+
180+ sess, err := env.sessions.Create(t.Context(), "session")
181+ require.NoError(t, err)
182+
183+ // A stale cancel mark lingers (no queued work, no accepted run).
184+ sa.cancelMark.Set(sess.ID, 1)
185+
186+ result, err := sa.Run(t.Context(), SessionAgentCall{
187+ SessionID: sess.ID,
188+ Prompt: "main",
189+ })
190+ require.NoError(t, err)
191+ require.NotNil(t, result)
192+
193+ require.False(t, sa.hasPendingCancel(sess.ID),
194+ "normal completion must clear the stale pending cancel")
195+
196+ msgs, err := env.messages.List(t.Context(), sess.ID)
197+ require.NoError(t, err)
198+ require.Len(t, msgs, 2)
199+ assert.Equal(t, message.Assistant, msgs[1].Role)
200+ assert.Equal(t, message.FinishReasonEndTurn, msgs[1].FinishReason())
201+}
202+
203+// newCancelTestAgentWithRunComplete builds a DB-backed sessionAgent wired
204+// to a RunComplete broker so tests can observe the terminal event a
205+// RunID-bearing caller (e.g. `crush run`) blocks on.
206+func newCancelTestAgentWithRunComplete(t *testing.T) (*sessionAgent, fakeEnv, *pubsub.Broker[notify.RunComplete]) {
207+ t.Helper()
208+ env := testEnv(t)
209+ broker := pubsub.NewBroker[notify.RunComplete]()
210+ t.Cleanup(broker.Shutdown)
211+ sa := NewSessionAgent(SessionAgentOptions{
212+ Sessions: env.sessions,
213+ Messages: env.messages,
214+ RunComplete: broker,
215+ }).(*sessionAgent)
216+ return sa, env, broker
217+}
218+
219+// TestRun_CancelOnEntryPublishesRunComplete covers the first review
220+// finding: the cancel-on-entry path returned before the streaming defer
221+// that publishes RunComplete was installed. A caller that dispatches a
222+// run with a RunID and blocks on RunComplete (ignoring message events,
223+// like `crush run`) would hang on an immediately-canceled accepted run.
224+// The cancel-on-entry path must publish a terminal RunComplete carrying
225+// the originating RunID.
226+func TestRun_CancelOnEntryPublishesRunComplete(t *testing.T) {
227+ t.Parallel()
228+ sa, env, broker := newCancelTestAgentWithRunComplete(t)
229+
230+ sess, err := env.sessions.Create(t.Context(), "session")
231+ require.NoError(t, err)
232+
233+ ctx, cancel := context.WithCancel(t.Context())
234+ defer cancel()
235+ ch := broker.Subscribe(ctx)
236+
237+ accept := sa.BeginAccepted(sess.ID)
238+ // A cancel arrives in the accepted-but-not-yet-active window.
239+ sa.Cancel(sess.ID)
240+ require.True(t, sa.hasPendingCancel(sess.ID))
241+
242+ result, err := sa.Run(t.Context(), SessionAgentCall{
243+ SessionID: sess.ID,
244+ RunID: "run-cancel-on-entry",
245+ Prompt: "hello",
246+ Accepted: accept,
247+ })
248+ require.NoError(t, err)
249+ require.Nil(t, result)
250+
251+ select {
252+ case got := <-ch:
253+ assert.Equal(t, "run-cancel-on-entry", got.Payload.RunID,
254+ "RunComplete must echo the originating RunID")
255+ assert.Equal(t, sess.ID, got.Payload.SessionID)
256+ assert.True(t, got.Payload.Cancelled,
257+ "cancel-on-entry RunComplete must be marked cancelled")
258+ case <-time.After(2 * time.Second):
259+ t.Fatal("cancel-on-entry must publish RunComplete; a RunID caller would hang otherwise")
260+ }
261+}
262+
263+// TestCancel_TwoAcceptedBothObserveCancellation covers the second review
264+// finding: a single cancel with two accepted-not-yet-active prompts must
265+// cancel both. The cancel raises the session's high-water mark to the
266+// latest accept sequence, so every prompt accepted-but-not-yet-active at
267+// cancel time is covered and both take the cancel-on-entry path.
268+func TestCancel_TwoAcceptedBothObserveCancellation(t *testing.T) {
269+ t.Parallel()
270+ sa, env := newCancelTestAgent(t)
271+
272+ sess, err := env.sessions.Create(t.Context(), "session")
273+ require.NoError(t, err)
274+
275+ // Two prompts are accepted-but-not-yet-active for the same session.
276+ accept1 := sa.BeginAccepted(sess.ID)
277+ accept2 := sa.BeginAccepted(sess.ID)
278+ require.Equal(t, 2, sa.acceptedCount(sess.ID))
279+
280+ // A single cancel arrives before either becomes active.
281+ sa.Cancel(sess.ID)
282+ require.Equal(t, accept2.seq, sa.pendingCancelMark(sess.ID),
283+ "one cancel must mark every currently-accepted prompt as canceled")
284+ require.GreaterOrEqual(t, sa.pendingCancelMark(sess.ID), accept1.seq,
285+ "the mark must cover the earlier accepted prompt too")
286+
287+ // Both prompts enter Run; each must take cancel-on-entry, not run.
288+ r1, err := sa.Run(t.Context(), SessionAgentCall{
289+ SessionID: sess.ID,
290+ Prompt: "first",
291+ Accepted: accept1,
292+ })
293+ require.NoError(t, err)
294+ require.Nil(t, r1)
295+
296+ r2, err := sa.Run(t.Context(), SessionAgentCall{
297+ SessionID: sess.ID,
298+ Prompt: "second",
299+ Accepted: accept2,
300+ })
301+ require.NoError(t, err)
302+ require.Nil(t, r2)
303+
304+ require.False(t, sa.hasPendingCancel(sess.ID),
305+ "both reserved units must be consumed")
306+ require.Equal(t, 0, sa.acceptedCount(sess.ID),
307+ "both accept reservations must be released")
308+
309+ // Each canceled-on-entry turn writes a user + canceled assistant
310+ // message, and neither prompt was enqueued to run normally.
311+ require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
312+ "neither accepted prompt may be enqueued to run normally")
313+ msgs, err := env.messages.List(t.Context(), sess.ID)
314+ require.NoError(t, err)
315+ require.Len(t, msgs, 4, "two canceled turns produce two user + two assistant messages")
316+ var canceled int
317+ for _, m := range msgs {
318+ if m.Role == message.Assistant {
319+ assert.Equal(t, message.FinishReasonCanceled, m.FinishReason())
320+ canceled++
321+ }
322+ }
323+ require.Equal(t, 2, canceled, "both turns must finish canceled")
324+}
325+
326+// TestRun_IdleCancelDoesNotPoisonNextPrompt covers the idle-cancel
327+// no-op guarantee end-to-end: an Escape on an idle session must not
328+// record a pending cancel that leaks into the next accepted prompt, which
329+// must run normally to completion.
330+func TestRun_IdleCancelDoesNotPoisonNextPrompt(t *testing.T) {
331+ t.Parallel()
332+ sa, env := newStreamTestAgent(t)
333+
334+ sess, err := env.sessions.Create(t.Context(), "session")
335+ require.NoError(t, err)
336+
337+ // Idle Escape: no accepted run, no active request.
338+ sa.Cancel(sess.ID)
339+ require.False(t, sa.hasPendingCancel(sess.ID),
340+ "idle cancel must not record a pending cancel")
341+
342+ // The next accepted prompt must run normally, not cancel on entry.
343+ accept := sa.BeginAccepted(sess.ID)
344+ result, err := sa.Run(t.Context(), SessionAgentCall{
345+ SessionID: sess.ID,
346+ Prompt: "next",
347+ Accepted: accept,
348+ })
349+ require.NoError(t, err)
350+ require.NotNil(t, result, "next prompt must run normally after an idle cancel")
351+
352+ msgs, err := env.messages.List(t.Context(), sess.ID)
353+ require.NoError(t, err)
354+ require.Len(t, msgs, 2)
355+ assert.Equal(t, message.User, msgs[0].Role)
356+ assert.Equal(t, message.Assistant, msgs[1].Role)
357+ assert.Equal(t, message.FinishReasonEndTurn, msgs[1].FinishReason(),
358+ "the prompt must finish normally, not canceled")
359+}
360+
361+// TestCancel_AcceptedAfterCancelIsNotPoisoned is the regression test for
362+// the reviewer's finding: a counted session-level pending cancel let a
363+// prompt accepted after the cancel enter Run first and consume a unit
364+// reserved for the earlier prompts. With a sequence high-water mark, a
365+// single cancel covers exactly the prompts accepted-but-not-yet-active at
366+// cancel time (A and B); a prompt accepted afterwards (C) gets a higher
367+// sequence and must run normally without consuming A or B's cancellation.
368+// C is run first to prove it neither cancels nor drains the mark, then A
369+// and B are run and must both cancel on entry.
370+func TestCancel_AcceptedAfterCancelIsNotPoisoned(t *testing.T) {
371+ t.Parallel()
372+ sa, env := newStreamTestAgent(t)
373+
374+ sess, err := env.sessions.Create(t.Context(), "session")
375+ require.NoError(t, err)
376+
377+ // A and B are accepted-but-not-yet-active.
378+ acceptA := sa.BeginAccepted(sess.ID)
379+ acceptB := sa.BeginAccepted(sess.ID)
380+
381+ // One cancel arrives covering both A and B.
382+ sa.Cancel(sess.ID)
383+ require.True(t, sa.hasPendingCancel(sess.ID))
384+ require.Equal(t, acceptB.seq, sa.pendingCancelMark(sess.ID),
385+ "the mark must cover every prompt accepted before the cancel")
386+
387+ // C is accepted AFTER the cancel; its sequence is above the mark.
388+ acceptC := sa.BeginAccepted(sess.ID)
389+ require.Greater(t, acceptC.seq, sa.pendingCancelMark(sess.ID),
390+ "a prompt accepted after the cancel must not be covered by the mark")
391+
392+ // Run C first. It must run normally to completion and must not
393+ // consume or clear the cancellation reserved for A and B.
394+ rc, err := sa.Run(t.Context(), SessionAgentCall{
395+ SessionID: sess.ID,
396+ Prompt: "C",
397+ Accepted: acceptC,
398+ })
399+ require.NoError(t, err)
400+ require.NotNil(t, rc, "C was accepted after the cancel and must run normally")
401+ require.True(t, sa.hasPendingCancel(sess.ID),
402+ "running C must not drain the cancellation reserved for A and B")
403+
404+ // Now A and B run. Both were accepted before the cancel and must
405+ // take the cancel-on-entry path.
406+ ra, err := sa.Run(t.Context(), SessionAgentCall{
407+ SessionID: sess.ID,
408+ Prompt: "A",
409+ Accepted: acceptA,
410+ })
411+ require.NoError(t, err)
412+ require.Nil(t, ra, "A must cancel on entry, not run")
413+
414+ rb, err := sa.Run(t.Context(), SessionAgentCall{
415+ SessionID: sess.ID,
416+ Prompt: "B",
417+ Accepted: acceptB,
418+ })
419+ require.NoError(t, err)
420+ require.Nil(t, rb, "B must cancel on entry, not run")
421+
422+ require.False(t, sa.hasPendingCancel(sess.ID),
423+ "the mark clears once all covered handles are resolved")
424+ require.Equal(t, 0, sa.acceptedCount(sess.ID))
425+ require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
426+ "neither A nor B may be enqueued to run normally")
427+
428+ // C produced a normal turn; A and B each produced a canceled turn.
429+ msgs, err := env.messages.List(t.Context(), sess.ID)
430+ require.NoError(t, err)
431+ require.Len(t, msgs, 6, "C normal + A canceled + B canceled = 3 user + 3 assistant")
432+
433+ var normal, canceled int
434+ for _, m := range msgs {
435+ if m.Role != message.Assistant {
436+ continue
437+ }
438+ switch m.FinishReason() {
439+ case message.FinishReasonEndTurn:
440+ normal++
441+ case message.FinishReasonCanceled:
442+ canceled++
443+ }
444+ }
445+ require.Equal(t, 1, normal, "only C finished normally")
446+ require.Equal(t, 2, canceled, "both A and B finished canceled")
447+}
internal/agent/notify/notify.go+12
@@ -12,6 +12,9 @@ const (
1212 // TypeReAuthenticate indicates the agent encountered an
1313 // authentication error and the user needs to re-authenticate.
1414 TypeReAuthenticate Type = "re_authenticate"
15+ // TypeAgentError indicates the agent's turn terminated with an
16+ // error. The error text is carried in Notification.Message.
17+ TypeAgentError Type = "error"
1518)
1619
1720// Notification represents a domain event published by the agent.
@@ -20,6 +23,15 @@ type Notification struct {
2023 SessionTitle string
2124 Type Type
2225 ProviderID string
26+ // RunID, when non-empty, is the caller-supplied correlator
27+ // (proto.AgentMessage.RunID) for the run that produced this
28+ // notification. It lets observers attribute a TypeAgentError to a
29+ // specific request rather than to any in-flight run on the
30+ // session. Empty when no caller set one.
31+ RunID string
32+ // Message carries the error text for TypeAgentError. Other
33+ // notification types ignore it.
34+ Message string
2335}
2436
2537// RunComplete is the authoritative end-of-run signal for a session.
internal/agent/queued_runid_test.gonew file+181
@@ -0,0 +1,181 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "charm.land/catwalk/pkg/catwalk"
11+ "charm.land/fantasy"
12+ "github.com/charmbracelet/crush/internal/agent/notify"
13+ "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/pubsub"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// gatedStreamModel streams a single text part followed by a clean finish,
19+// but blocks the very first Stream call until its gate is released. That
20+// lets a test hold a run "active" (past PrepareStep, inside Stream) just
21+// long enough to enqueue a follow-up prompt behind the busy session.
22+// Subsequent Stream calls (e.g. the recursive run draining the queue)
23+// proceed immediately.
24+type gatedStreamModel struct {
25+ text string
26+ gate chan struct{}
27+ entered chan struct{}
28+ calls atomic.Int64
29+}
30+
31+func (m *gatedStreamModel) Provider() string { return "fake" }
32+func (m *gatedStreamModel) Model() string { return "fake-model" }
33+
34+func (m *gatedStreamModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
35+ return &fantasy.Response{
36+ Content: fantasy.ResponseContent{fantasy.TextContent{Text: m.text}},
37+ FinishReason: fantasy.FinishReasonStop,
38+ }, nil
39+}
40+
41+func (m *gatedStreamModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
42+ if m.calls.Add(1) == 1 {
43+ close(m.entered)
44+ select {
45+ case <-m.gate:
46+ case <-ctx.Done():
47+ }
48+ }
49+ text := m.text
50+ return func(yield func(fantasy.StreamPart) bool) {
51+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"}) {
52+ return
53+ }
54+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: text}) {
55+ return
56+ }
57+ if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"}) {
58+ return
59+ }
60+ yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
61+ }, nil
62+}
63+
64+func (m *gatedStreamModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
65+ return nil, errors.New("not implemented")
66+}
67+
68+func (m *gatedStreamModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
69+ return nil, errors.New("not implemented")
70+}
71+
72+// TestRun_QueuedRunIDPromptRunsRecursivelyAndPublishesRunComplete is the
73+// end-to-end proof of fix 2: a prompt carrying a RunID that is queued
74+// behind a busy session must NOT be silently folded into the active turn.
75+// It runs as its own turn via the recursive run path and publishes its
76+// own terminal RunComplete, so a `crush run` caller blocking on that
77+// RunID does not hang. The active turn keeps its own RunComplete too.
78+func TestRun_QueuedRunIDPromptRunsRecursivelyAndPublishesRunComplete(t *testing.T) {
79+ t.Parallel()
80+
81+ env := testEnv(t)
82+ broker := pubsub.NewBroker[notify.RunComplete]()
83+ t.Cleanup(broker.Shutdown)
84+
85+ large := &gatedStreamModel{
86+ text: "done",
87+ gate: make(chan struct{}),
88+ entered: make(chan struct{}),
89+ }
90+ small := &finishStreamModel{text: "title"}
91+
92+ sa := NewSessionAgent(SessionAgentOptions{
93+ LargeModel: Model{Model: large, CatwalkCfg: catwalk.Model{ContextWindow: 200000, DefaultMaxTokens: 10000}},
94+ SmallModel: Model{Model: small, CatwalkCfg: catwalk.Model{ContextWindow: 200000, DefaultMaxTokens: 10000}},
95+ IsYolo: true,
96+ Sessions: env.sessions,
97+ Messages: env.messages,
98+ RunComplete: broker,
99+ }).(*sessionAgent)
100+
101+ sess, err := env.sessions.Create(t.Context(), "session")
102+ require.NoError(t, err)
103+
104+ subCtx, subCancel := context.WithCancel(t.Context())
105+ defer subCancel()
106+ ch := broker.Subscribe(subCtx)
107+
108+ // Start the main turn; it blocks inside Stream once active.
109+ mainDone := make(chan error, 1)
110+ go func() {
111+ _, runErr := sa.Run(t.Context(), SessionAgentCall{
112+ SessionID: sess.ID,
113+ RunID: "run-main",
114+ Prompt: "main",
115+ })
116+ mainDone <- runErr
117+ }()
118+
119+ // Wait until the main turn is active (inside Stream).
120+ select {
121+ case <-large.entered:
122+ case <-time.After(5 * time.Second):
123+ t.Fatal("main run never entered Stream")
124+ }
125+ require.True(t, sa.IsSessionBusy(sess.ID), "main run must be active before enqueueing the follow-up")
126+
127+ // Enqueue a RunID-bearing follow-up behind the busy session.
128+ res, err := sa.Run(t.Context(), SessionAgentCall{
129+ SessionID: sess.ID,
130+ RunID: "run-follow",
131+ Prompt: "follow",
132+ })
133+ require.NoError(t, err)
134+ require.Nil(t, res, "a busy-session follow-up must enqueue and return (nil, nil)")
135+ require.Equal(t, 1, sa.QueuedPrompts(sess.ID), "the follow-up must be queued, not folded")
136+
137+ // Release the main turn so it completes and hands off to the queue.
138+ close(large.gate)
139+ require.NoError(t, <-mainDone)
140+
141+ // Both turns must publish their own terminal RunComplete.
142+ got := map[string]notify.RunComplete{}
143+ deadline := time.After(5 * time.Second)
144+ for len(got) < 2 {
145+ select {
146+ case ev := <-ch:
147+ got[ev.Payload.RunID] = ev.Payload
148+ case <-deadline:
149+ t.Fatalf("timed out waiting for both RunCompletes; got %v", got)
150+ }
151+ }
152+
153+ main, ok := got["run-main"]
154+ require.True(t, ok, "the active turn must publish its own RunComplete")
155+ require.Empty(t, main.Error)
156+ require.False(t, main.Cancelled)
157+
158+ follow, ok := got["run-follow"]
159+ require.True(t, ok,
160+ "the queued RunID prompt must publish its own RunComplete instead of being folded silently")
161+ require.Empty(t, follow.Error)
162+ require.False(t, follow.Cancelled)
163+ require.Equal(t, "done", follow.Text, "the queued prompt ran as its own turn")
164+
165+ // Two distinct assistant turns prove the follow-up was not folded.
166+ msgs, err := env.messages.List(t.Context(), sess.ID)
167+ require.NoError(t, err)
168+ var assistants, follows int
169+ for _, m := range msgs {
170+ switch m.Role {
171+ case message.Assistant:
172+ assistants++
173+ case message.User:
174+ if m.Content().String() == "follow" {
175+ follows++
176+ }
177+ }
178+ }
179+ require.Equal(t, 2, assistants, "the active turn and the recursive turn each produce one assistant message")
180+ require.Equal(t, 1, follows, "the follow-up prompt is its own user turn")
181+}
internal/agent/run_complete_test.go+233
@@ -58,6 +58,138 @@ func TestSessionAgentRun_QueueStripsOnComplete(t *testing.T) {
5858 "RunComplete still correlates with the originating SendMessage")
5959}
6060
61+// TestDrainQueueForStep_FiltersUnderDispatchLock verifies that the queue
62+// drain evaluates the per-session cancel mark while holding the dispatch
63+// mutex (canceledBySeq's documented precondition). Queued calls at or
64+// below the cancel high-water mark are dropped, calls queued after the
65+// cancel (higher seq) are folded, untracked enqueues (seq == 0) are
66+// dropped whenever any mark is present, and the queue is cleared. These
67+// calls carry no RunID, so all foldable survivors are returned for
68+// folding (the existing follow-up behavior).
69+func TestDrainQueueForStep_FiltersUnderDispatchLock(t *testing.T) {
70+ t.Parallel()
71+
72+ env := testEnv(t)
73+ a := NewSessionAgent(SessionAgentOptions{
74+ Sessions: env.sessions,
75+ Messages: env.messages,
76+ }).(*sessionAgent)
77+
78+ const sessionID = "drain-session"
79+ a.messageQueue.Set(sessionID, []SessionAgentCall{
80+ {SessionID: sessionID, Prompt: "below", acceptSeq: 1},
81+ {SessionID: sessionID, Prompt: "at-mark", acceptSeq: 2},
82+ {SessionID: sessionID, Prompt: "after", acceptSeq: 3},
83+ {SessionID: sessionID, Prompt: "untracked", acceptSeq: 0},
84+ })
85+ // Cancel high-water mark at seq 2: seq <= 2 and seq == 0 are covered.
86+ a.cancelMark.Set(sessionID, 2)
87+
88+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
89+
90+ require.Len(t, fold, 1,
91+ "only the follow-up queued after the cancel (seq > mark) must be folded")
92+ require.Equal(t, "after", fold[0].Prompt)
93+ require.Empty(t, canceledWithRunID,
94+ "no dropped call carried a RunID, so none need a terminal RunComplete")
95+
96+ _, ok := a.messageQueue.Get(sessionID)
97+ require.False(t, ok, "drain must clear the session message queue when nothing is kept")
98+}
99+
100+// TestDrainQueueForStep_NoMarkFoldsAllNonRunID verifies that with no
101+// cancel mark recorded, every queued call without a RunID is folded.
102+func TestDrainQueueForStep_NoMarkFoldsAllNonRunID(t *testing.T) {
103+ t.Parallel()
104+
105+ env := testEnv(t)
106+ a := NewSessionAgent(SessionAgentOptions{
107+ Sessions: env.sessions,
108+ Messages: env.messages,
109+ }).(*sessionAgent)
110+
111+ const sessionID = "drain-nomark"
112+ a.messageQueue.Set(sessionID, []SessionAgentCall{
113+ {SessionID: sessionID, Prompt: "a", acceptSeq: 0},
114+ {SessionID: sessionID, Prompt: "b", acceptSeq: 5},
115+ })
116+
117+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
118+ require.Len(t, fold, 2, "no cancel mark means all non-RunID queued calls are folded")
119+ require.Empty(t, canceledWithRunID)
120+}
121+
122+// TestDrainQueueForStep_KeepsRunIDPromptsQueued is the core of fix 2: a
123+// queued prompt that carries a RunID must NOT be folded into the active
124+// turn. Folding it would silently absorb it into another turn and never
125+// publish a RunComplete for its RunID, hanging a `crush run` caller that
126+// blocks on that event. Such prompts are left in the queue so the
127+// recursive run path gives each its own turn and its own RunComplete.
128+// Non-RunID prompts are still folded.
129+func TestDrainQueueForStep_KeepsRunIDPromptsQueued(t *testing.T) {
130+ t.Parallel()
131+
132+ env := testEnv(t)
133+ a := NewSessionAgent(SessionAgentOptions{
134+ Sessions: env.sessions,
135+ Messages: env.messages,
136+ }).(*sessionAgent)
137+
138+ const sessionID = "drain-runid"
139+ a.messageQueue.Set(sessionID, []SessionAgentCall{
140+ {SessionID: sessionID, Prompt: "fold-me", acceptSeq: 1},
141+ {SessionID: sessionID, RunID: "run-a", Prompt: "keep-me", acceptSeq: 2},
142+ {SessionID: sessionID, RunID: "run-b", Prompt: "keep-me-too", acceptSeq: 3},
143+ })
144+
145+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
146+
147+ require.Len(t, fold, 1, "only the non-RunID prompt is folded into the active turn")
148+ require.Equal(t, "fold-me", fold[0].Prompt)
149+ require.Empty(t, canceledWithRunID)
150+
151+ kept, ok := a.messageQueue.Get(sessionID)
152+ require.True(t, ok, "RunID-bearing prompts must remain queued for the recursive run path")
153+ require.Len(t, kept, 2)
154+ require.Equal(t, "run-a", kept[0].RunID)
155+ require.Equal(t, "run-b", kept[1].RunID)
156+}
157+
158+// TestDrainQueueForStep_ReportsCanceledRunIDDrops verifies that a queued
159+// prompt carrying a RunID that is dropped because a cancel covers it is
160+// reported in canceledWithRunID so the caller can publish its terminal
161+// cancelled RunComplete. A canceled prompt without a RunID is dropped
162+// silently as before.
163+func TestDrainQueueForStep_ReportsCanceledRunIDDrops(t *testing.T) {
164+ t.Parallel()
165+
166+ env := testEnv(t)
167+ a := NewSessionAgent(SessionAgentOptions{
168+ Sessions: env.sessions,
169+ Messages: env.messages,
170+ }).(*sessionAgent)
171+
172+ const sessionID = "drain-cancel-runid"
173+ a.messageQueue.Set(sessionID, []SessionAgentCall{
174+ {SessionID: sessionID, RunID: "run-canceled", Prompt: "canceled", acceptSeq: 1},
175+ {SessionID: sessionID, Prompt: "canceled-no-runid", acceptSeq: 1},
176+ {SessionID: sessionID, RunID: "run-survives", Prompt: "survives", acceptSeq: 5},
177+ })
178+ a.cancelMark.Set(sessionID, 2)
179+
180+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
181+
182+ require.Empty(t, fold, "no uncanceled non-RunID prompts to fold")
183+ require.Len(t, canceledWithRunID, 1,
184+ "only the dropped RunID-bearing prompt needs a terminal RunComplete")
185+ require.Equal(t, "run-canceled", canceledWithRunID[0].RunID)
186+
187+ kept, ok := a.messageQueue.Get(sessionID)
188+ require.True(t, ok)
189+ require.Len(t, kept, 1, "the uncanceled RunID prompt stays queued")
190+ require.Equal(t, "run-survives", kept[0].RunID)
191+}
192+
61193// TestRunCompletePublisher_MustDeliverOverTakesPublish exercises the
62194// pubsub.Publisher interface change end-to-end: a Broker is the only
63195// concrete Publisher implementation and must satisfy both Publish and
@@ -87,3 +219,104 @@ func TestRunCompletePublisher_MustDeliverOverTakesPublish(t *testing.T) {
87219 t.Fatal("PublishMustDeliver did not deliver event")
88220 }
89221}
222+
223+// requireSingleCancelledRunComplete reads exactly one RunComplete from ch,
224+// asserts it is the cancelled terminal event for runID, and verifies no
225+// second event arrives. This observes the published pubsub event rather
226+// than internal bookkeeping, which is the contract a `crush run` caller
227+// blocking on the broker actually relies on.
228+func requireSingleCancelledRunComplete(t *testing.T, ch <-chan pubsub.Event[notify.RunComplete], sessionID, runID string) {
229+ t.Helper()
230+ select {
231+ case ev := <-ch:
232+ require.Equal(t, runID, ev.Payload.RunID,
233+ "the published RunComplete must carry the dropped queued prompt's RunID")
234+ require.Equal(t, sessionID, ev.Payload.SessionID)
235+ require.True(t, ev.Payload.Cancelled,
236+ "a dropped queued prompt must publish a cancelled RunComplete")
237+ case <-time.After(5 * time.Second):
238+ t.Fatal("timed out waiting for the cancelled RunComplete")
239+ }
240+ select {
241+ case extra := <-ch:
242+ t.Fatalf("expected exactly one RunComplete, got a second: %+v", extra.Payload)
243+ case <-time.After(100 * time.Millisecond):
244+ }
245+}
246+
247+// TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete proves the
248+// terminal-event behavior end-to-end: a RunID-bearing prompt sitting in
249+// the queue that is canceled while queued (via the public Cancel path,
250+// which routes through clearQueueAndNotify -> publishCanceledQueueDrops)
251+// must emit exactly one cancelled RunComplete on the broker for its
252+// RunID. A queued prompt without a RunID is dropped silently. This is the
253+// coverage the earlier drain test lacked: it asserted the returned
254+// bookkeeping slice, not the published event a `crush run` caller awaits.
255+func TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete(t *testing.T) {
256+ t.Parallel()
257+
258+ env := testEnv(t)
259+ broker := pubsub.NewBroker[notify.RunComplete]()
260+ t.Cleanup(broker.Shutdown)
261+
262+ a := NewSessionAgent(SessionAgentOptions{
263+ Sessions: env.sessions,
264+ Messages: env.messages,
265+ RunComplete: broker,
266+ }).(*sessionAgent)
267+
268+ subCtx, subCancel := context.WithCancel(t.Context())
269+ defer subCancel()
270+ ch := broker.Subscribe(subCtx)
271+
272+ const sessionID = "cancel-queued-runid"
273+ a.messageQueue.Set(sessionID, []SessionAgentCall{
274+ {SessionID: sessionID, Prompt: "no-runid", acceptSeq: 1},
275+ {SessionID: sessionID, RunID: "run-queued", Prompt: "queued", acceptSeq: 2},
276+ })
277+
278+ a.Cancel(sessionID)
279+
280+ requireSingleCancelledRunComplete(t, ch, sessionID, "run-queued")
281+
282+ _, ok := a.messageQueue.Get(sessionID)
283+ require.False(t, ok, "Cancel must clear the queue")
284+}
285+
286+// TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete drives
287+// the production drain sequence (drainQueueForStep then
288+// publishCanceledQueueDrops, mirroring the PrepareStep handoff) and
289+// asserts the dropped RunID-bearing prompt actually publishes exactly one
290+// cancelled RunComplete on the broker. The companion bookkeeping test
291+// covers the returned slice; this one covers the observable terminal
292+// event.
293+func TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete(t *testing.T) {
294+ t.Parallel()
295+
296+ env := testEnv(t)
297+ broker := pubsub.NewBroker[notify.RunComplete]()
298+ t.Cleanup(broker.Shutdown)
299+
300+ a := NewSessionAgent(SessionAgentOptions{
301+ Sessions: env.sessions,
302+ Messages: env.messages,
303+ RunComplete: broker,
304+ }).(*sessionAgent)
305+
306+ subCtx, subCancel := context.WithCancel(t.Context())
307+ defer subCancel()
308+ ch := broker.Subscribe(subCtx)
309+
310+ const sessionID = "drain-drop-runid"
311+ a.messageQueue.Set(sessionID, []SessionAgentCall{
312+ {SessionID: sessionID, RunID: "run-dropped", Prompt: "dropped", acceptSeq: 1},
313+ {SessionID: sessionID, Prompt: "dropped-no-runid", acceptSeq: 1},
314+ })
315+ a.cancelMark.Set(sessionID, 2)
316+
317+ _, canceledWithRunID := a.drainQueueForStep(sessionID)
318+ require.Len(t, canceledWithRunID, 1)
319+ a.publishCanceledQueueDrops(canceledWithRunID)
320+
321+ requireSingleCancelledRunComplete(t, ch, sessionID, "run-dropped")
322+}
internal/agent/run_marker.gonew file+52
@@ -0,0 +1,52 @@
1+package agent
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+)
7+
8+// runCompleteMarkerKey is the unexported context key carrying a
9+// [runCompleteMarker] from the dispatch boundary (backend.runAgent)
10+// down into the coordinator. It lets the dispatcher learn whether the
11+// coordinator already published the authoritative terminal
12+// notify.RunComplete for the run, so a fallback terminal event is only
13+// emitted when one is actually missing (e.g. an error returned before
14+// sessionAgent.Run ever executed). It avoids a breaking change to the
15+// Coordinator interface.
16+type runCompleteMarkerKey struct{}
17+
18+// runCompleteMarker records whether a terminal RunComplete has been
19+// published for a run. It is shared by pointer through the context so
20+// a publish deep in the call stack is observable by the dispatcher
21+// after the call returns.
22+type runCompleteMarker struct {
23+ published atomic.Bool
24+}
25+
26+// WithRunCompleteMarker returns ctx carrying a fresh marker the
27+// coordinator can flag via [MarkRunCompletePublished] once it emits the
28+// run's terminal RunComplete. Callers read the result with
29+// [RunCompletePublished]. Attaching the marker is optional: code paths
30+// without one simply skip the dedup signal.
31+func WithRunCompleteMarker(ctx context.Context) context.Context {
32+ return context.WithValue(ctx, runCompleteMarkerKey{}, &runCompleteMarker{})
33+}
34+
35+// MarkRunCompletePublished records that the authoritative terminal
36+// RunComplete has been published for the run carried by ctx. It is a
37+// no-op when no marker is present (e.g. the in-process/local Run path,
38+// which is not dispatched through backend.runAgent).
39+func MarkRunCompletePublished(ctx context.Context) {
40+ if m, ok := ctx.Value(runCompleteMarkerKey{}).(*runCompleteMarker); ok {
41+ m.published.Store(true)
42+ }
43+}
44+
45+// RunCompletePublished reports whether [MarkRunCompletePublished] was
46+// called on ctx's marker. It returns false when no marker is present.
47+func RunCompletePublished(ctx context.Context) bool {
48+ if m, ok := ctx.Value(runCompleteMarkerKey{}).(*runCompleteMarker); ok {
49+ return m.published.Load()
50+ }
51+ return false
52+}
internal/app/app.go+8
@@ -185,6 +185,14 @@ func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
185185 return app.agentNotifications
186186}
187187
188+// RunCompletions returns the broker for the authoritative per-run
189+// terminal RunComplete events. The dispatcher (backend.runAgent) uses
190+// it to emit a reliable terminal event when a run fails before the
191+// coordinator could publish one of its own.
192+func (app *App) RunCompletions() *pubsub.Broker[notify.RunComplete] {
193+ return app.runCompletions
194+}
195+
188196// resolveSession resolves which session to use for a non-interactive run
189197// If continueSessionID is set, it looks up that session by ID
190198// If useLast is set, it returns the most recently updated top-level session
internal/backend/accepted_run_integration_test.gonew file+131
@@ -0,0 +1,131 @@
1+package backend
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "charm.land/fantasy"
9+ "github.com/charmbracelet/crush/internal/agent"
10+ "github.com/charmbracelet/crush/internal/agent/agenttest"
11+ "github.com/charmbracelet/crush/internal/db"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/proto"
14+ "github.com/charmbracelet/crush/internal/session"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// gatedCoordinator wraps a real agent.Coordinator and parks RunAccepted
19+// before delegating to it. Every method other than RunAccepted is
20+// inherited from the embedded coordinator, so BeginAccepted (called by
21+// Backend.SendMessage) and RunAccepted (called by the dispatched run)
22+// are the production agent.Coordinator implementations under test, not
23+// stubs. The gate only delays entry into the real RunAccepted so a
24+// cancel can be made to land in the accepted-but-not-yet-active window
25+// deterministically: the accept handle is not consumed by
26+// sessionAgent.Run until the real RunAccepted runs after the gate opens.
27+type gatedCoordinator struct {
28+ agent.Coordinator
29+ entered chan struct{}
30+ gate chan struct{}
31+}
32+
33+func (c *gatedCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
34+ close(c.entered)
35+ <-c.gate
36+ return c.Coordinator.RunAccepted(ctx, accept, sessionID, prompt, attachments...)
37+}
38+
39+// newRealCoordinator builds a production agent.Coordinator over a
40+// DB-backed session/message store, wrapped in a gate. It is constructed
41+// through the real agent.NewCoordinator path (via the test-only
42+// agenttest helper) with an offline-resolvable model: the
43+// cancel-on-entry path under test persists a canceled turn and returns
44+// before any model call, so no network I/O happens.
45+func newRealCoordinator(t *testing.T) (*gatedCoordinator, session.Service, message.Service) {
46+ t.Helper()
47+ conn, err := db.Connect(t.Context(), t.TempDir())
48+ require.NoError(t, err)
49+ t.Cleanup(func() { conn.Close() })
50+
51+ q := db.New(conn)
52+ sessions := session.NewService(q, conn)
53+ messages := message.NewService(q)
54+
55+ coord, err := agenttest.NewCoordinator(t.Context(), t.TempDir(), sessions, messages)
56+ require.NoError(t, err)
57+
58+ return &gatedCoordinator{
59+ Coordinator: coord,
60+ entered: make(chan struct{}),
61+ gate: make(chan struct{}),
62+ }, sessions, messages
63+}
64+
65+// TestSendMessage_AcceptedCancelRace_RealMachinery exercises the
66+// 202/cancel race end-to-end through Backend.SendMessage against the
67+// production agent.Coordinator (BeginAccepted + RunAccepted), not a
68+// stub. It asserts that a cancel arriving after the prompt is accepted
69+// but before the run becomes active is not lost: the accepted handle
70+// reaches sessionAgent.Run and drives cancel-on-entry, which persists a
71+// canceled turn instead of streaming.
72+//
73+// This test would fail if Coordinator.BeginAccepted returned nil (Cancel
74+// would find no accepted run and record no mark, and the run would
75+// receive a nil Accepted handle and skip cancel-on-entry) or if
76+// Coordinator.RunAccepted dropped the handle on its way into
77+// sessionAgent.Run (the run would likewise skip cancel-on-entry and try
78+// to stream the model). In either case no FinishReasonCanceled turn
79+// would be persisted.
80+func TestSendMessage_AcceptedCancelRace_RealMachinery(t *testing.T) {
81+ t.Parallel()
82+ b, _ := newTestBackend(t)
83+
84+ coord, sessions, messages := newRealCoordinator(t)
85+ sess, err := sessions.Create(t.Context(), "session")
86+ require.NoError(t, err)
87+
88+ ws := insertAgentWorkspace(t, b, coord)
89+
90+ require.NoError(t, b.SendMessage(ws.ID, proto.AgentMessage{SessionID: sess.ID, Prompt: "hi"}))
91+
92+ // Coordinator.BeginAccepted ran synchronously inside SendMessage
93+ // before dispatch; the dispatched run has now entered the gate but
94+ // has not yet called the real RunAccepted, so the accept handle is
95+ // not yet consumed: the prompt is accepted but not active.
96+ select {
97+ case <-coord.entered:
98+ case <-time.After(2 * time.Second):
99+ t.Fatal("dispatched run never entered RunAccepted")
100+ }
101+
102+ // A cancel arriving now lands in the accepted-but-not-yet-active
103+ // window and is only recorded because BeginAccepted incremented the
104+ // accept counter.
105+ require.NoError(t, b.CancelSession(ws.ID, sess.ID))
106+
107+ // Release the gate so the real RunAccepted threads the handle into
108+ // sessionAgent.Run, which drives cancel-on-entry.
109+ close(coord.gate)
110+
111+ // The dispatched run returns nil (cancel-on-entry), so runWG drains.
112+ waited := make(chan struct{})
113+ go func() {
114+ ws.runWG.Wait()
115+ close(waited)
116+ }()
117+ select {
118+ case <-waited:
119+ case <-time.After(5 * time.Second):
120+ t.Fatal("runWG.Wait did not complete after the canceled run returned")
121+ }
122+
123+ // The accepted-but-not-yet-active cancel persisted a canceled turn
124+ // rather than streaming a real response.
125+ msgs, err := messages.List(t.Context(), sess.ID)
126+ require.NoError(t, err)
127+ require.Len(t, msgs, 2)
128+ require.Equal(t, message.User, msgs[0].Role)
129+ require.Equal(t, message.Assistant, msgs[1].Role)
130+ require.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
131+}
internal/backend/agent.go+96−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,88 @@ 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.
65+//
66+// On a non-cancel error it surfaces the failure to observers via a
67+// notify.TypeAgentError notification (lossy, best-effort). That alone is
68+// not a reliable terminal signal: the agent-event fan-in uses lossy
69+// subscribers, so a `crush run` caller blocking on its RunID could hang
70+// if the event is dropped. To guarantee termination, when msg.RunID is
71+// non-empty and the coordinator did not already publish the run's
72+// authoritative terminal RunComplete (e.g. the error was returned before
73+// sessionAgent.Run executed, such as a readyWg or UpdateModels failure),
74+// runAgent emits an errored RunComplete on the must-deliver
75+// runCompletions broker so the waiter observes a deterministic terminal
76+// event. context.Canceled is expected (sessionAgent.Run already
77+// publishes the cancelled terminal marker) and produces no error
78+// terminal event.
79+//
80+// When msg.RunID is non-empty it is attached to the context via
81+// agent.WithRunID so the coordinator can stamp the terminal
82+// notify.RunComplete event with that correlator. A run-complete marker
83+// is also attached so the coordinator can report whether it published
84+// the terminal event, letting runAgent avoid a duplicate fallback.
85+func (b *Backend) runAgent(ws *Workspace, msg proto.AgentMessage, accept *agent.AcceptedRun) {
86+ defer ws.runWG.Done()
87+ defer accept.Close()
88+
89+ ctx := ws.ctx
3090 if msg.RunID != "" {
3191 ctx = agent.WithRunID(ctx, msg.RunID)
3292 }
33− _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
34− return err
93+ ctx = agent.WithRunCompleteMarker(ctx)
94+
95+ _, err := ws.AgentCoordinator.RunAccepted(ctx, accept, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
96+ if err == nil || errors.Is(err, context.Canceled) {
97+ return
98+ }
99+
100+ ws.AgentNotifications().Publish(pubsub.CreatedEvent, notify.Notification{
101+ SessionID: msg.SessionID,
102+ RunID: msg.RunID,
103+ Type: notify.TypeAgentError,
104+ Message: err.Error(),
105+ })
106+
107+ // Reliable terminal fallback. Only needed when a RunID waiter
108+ // exists and the coordinator has not already emitted the run's
109+ // terminal RunComplete; otherwise this would be a duplicate.
110+ if msg.RunID == "" || agent.RunCompletePublished(ctx) {
111+ return
112+ }
113+ if rc := ws.RunCompletions(); rc != nil {
114+ rc.PublishMustDeliver(ctx, pubsub.UpdatedEvent, notify.RunComplete{
115+ SessionID: msg.SessionID,
116+ RunID: msg.RunID,
117+ Error: err.Error(),
118+ })
119+ }
35120}
36121
37122// GetAgentInfo returns the agent's model and busy status.
internal/backend/agent_runcomplete_test.gonew file+162
@@ -0,0 +1,162 @@
1+package backend
2+
3+import (
4+ "context"
5+ "errors"
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+// errorCoordinator is a minimal agent.Coordinator whose RunAccepted
19+// returns a configurable error. When markPublished is true it stamps
20+// the run-complete marker on the context before returning, simulating a
21+// real coordinator that already published the run's authoritative
22+// terminal RunComplete (so runAgent must not emit a duplicate fallback).
23+type errorCoordinator struct {
24+ err error
25+ markPublished bool
26+}
27+
28+func (c *errorCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
29+ return nil, c.err
30+}
31+
32+func (c *errorCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
33+ if c.markPublished {
34+ agent.MarkRunCompletePublished(ctx)
35+ }
36+ return nil, c.err
37+}
38+
39+func (c *errorCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun { return nil }
40+func (c *errorCoordinator) Cancel(string) {}
41+func (c *errorCoordinator) CancelAll() {}
42+func (c *errorCoordinator) IsBusy() bool { return false }
43+func (c *errorCoordinator) IsSessionBusy(string) bool { return false }
44+func (c *errorCoordinator) QueuedPrompts(string) int { return 0 }
45+func (c *errorCoordinator) QueuedPromptsList(string) []string { return nil }
46+func (c *errorCoordinator) ClearQueue(string) {}
47+func (c *errorCoordinator) Summarize(context.Context, string) error { return nil }
48+func (c *errorCoordinator) Model() agent.Model { return agent.Model{} }
49+func (c *errorCoordinator) UpdateModels(context.Context) error { return nil }
50+
51+// insertRunCompleteWorkspace installs a workspace backed by a real
52+// app.App (so the runCompletions broker exists) with the given
53+// coordinator and a workspace run context derived from base.
54+func insertRunCompleteWorkspace(t *testing.T, b *Backend, base context.Context, coord agent.Coordinator) *Workspace {
55+ t.Helper()
56+ a := app.NewForTest(base)
57+ a.AgentCoordinator = coord
58+ t.Cleanup(a.ShutdownForTest)
59+ ws := &Workspace{
60+ ID: uuid.New().String(),
61+ Path: t.TempDir(),
62+ resolvedPath: t.TempDir(),
63+ clients: make(map[string]*clientState),
64+ shutdownFn: func() {},
65+ }
66+ ws.App = a
67+ ws.ctx, ws.cancel = context.WithCancel(base)
68+ b.mu.Lock()
69+ b.workspaces.Set(ws.ID, ws)
70+ b.pathIndex[ws.resolvedPath] = ws.ID
71+ b.mu.Unlock()
72+ return ws
73+}
74+
75+// TestRunAgent_PreRunErrorPublishesTerminalRunComplete proves that an
76+// error returned from RunAccepted before the coordinator could publish
77+// its own terminal event (e.g. a readyWg or UpdateModels failure,
78+// modeled here by a stub coordinator) still yields a reliable terminal
79+// RunComplete for the run's RunID. Without it, a `crush run` caller
80+// blocking on that RunID would hang because the lossy TypeAgentError
81+// event is not a guaranteed terminal signal.
82+func TestRunAgent_PreRunErrorPublishesTerminalRunComplete(t *testing.T) {
83+ t.Parallel()
84+ b, _ := newTestBackend(t)
85+ runErr := errors.New("update models failed")
86+ ws := insertRunCompleteWorkspace(t, b, context.Background(), &errorCoordinator{err: runErr})
87+
88+ subCtx, cancel := context.WithCancel(context.Background())
89+ defer cancel()
90+ ch := ws.RunCompletions().Subscribe(subCtx)
91+
92+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
93+ require.NoError(t, err)
94+
95+ select {
96+ case ev := <-ch:
97+ require.Equal(t, "run-1", ev.Payload.RunID,
98+ "the terminal RunComplete must carry the dispatched RunID")
99+ require.Equal(t, "S1", ev.Payload.SessionID)
100+ require.Equal(t, runErr.Error(), ev.Payload.Error,
101+ "the fallback terminal event must be marked errored")
102+ require.False(t, ev.Payload.Cancelled)
103+ case <-time.After(2 * time.Second):
104+ t.Fatal("no terminal RunComplete published for a pre-run error; a run waiter would hang")
105+ }
106+}
107+
108+// TestRunAgent_NoFallbackWhenCoordinatorPublished ensures the fallback
109+// is suppressed when the coordinator already emitted the run's
110+// authoritative terminal RunComplete, so callers never observe a
111+// duplicate terminal event for the same RunID.
112+func TestRunAgent_NoFallbackWhenCoordinatorPublished(t *testing.T) {
113+ t.Parallel()
114+ b, _ := newTestBackend(t)
115+ runErr := errors.New("stream failed after publishing terminal event")
116+ ws := insertRunCompleteWorkspace(t, b, context.Background(),
117+ &errorCoordinator{err: runErr, markPublished: true})
118+
119+ subCtx, cancel := context.WithCancel(context.Background())
120+ defer cancel()
121+ ch := ws.RunCompletions().Subscribe(subCtx)
122+
123+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
124+ require.NoError(t, err)
125+
126+ // Wait for the dispatched run goroutine to return so any publish
127+ // has already happened.
128+ ws.runWG.Wait()
129+
130+ select {
131+ case ev := <-ch:
132+ t.Fatalf("runAgent published a duplicate terminal RunComplete: %+v", ev.Payload)
133+ case <-time.After(200 * time.Millisecond):
134+ }
135+}
136+
137+// TestRunAgent_CancellationPublishesNoErrorTerminal verifies that a
138+// context.Canceled result from RunAccepted produces no errored terminal
139+// RunComplete from runAgent: cancellation is sessionAgent.Run's
140+// responsibility (it publishes the cancelled marker) and the dispatcher
141+// must not synthesize an error terminal for it.
142+func TestRunAgent_CancellationPublishesNoErrorTerminal(t *testing.T) {
143+ t.Parallel()
144+ b, _ := newTestBackend(t)
145+ ws := insertRunCompleteWorkspace(t, b, context.Background(),
146+ &errorCoordinator{err: context.Canceled})
147+
148+ subCtx, cancel := context.WithCancel(context.Background())
149+ defer cancel()
150+ ch := ws.RunCompletions().Subscribe(subCtx)
151+
152+ err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
153+ require.NoError(t, err)
154+
155+ ws.runWG.Wait()
156+
157+ select {
158+ case ev := <-ch:
159+ t.Fatalf("cancellation must not publish a terminal RunComplete: %+v", ev.Payload)
160+ case <-time.After(200 * time.Millisecond):
161+ }
162+}
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/backend.go+56−1
@@ -34,6 +34,7 @@ var (
3434 ErrUnknownCommand = errors.New("unknown command")
3535 ErrInvalidClientID = errors.New("invalid client_id")
3636 ErrClientNotAttached = errors.New("client not attached")
37+ ErrWorkspaceClosing = errors.New("workspace closing")
3738)
3839
3940// DefaultCreateGrace is the window in which a client must open an SSE
@@ -108,6 +109,23 @@ type Workspace struct {
108109 // with fallback to the cleaned absolute path.
109110 resolvedPath string
110111
112+ // ctx is the workspace-scoped run context. It is derived from
113+ // the backend context in CreateWorkspace and lives for the
114+ // lifetime of the workspace; cancel tears it down. Agent runs
115+ // dispatched on behalf of this workspace are bound to ctx so
116+ // their lifetime is owned by the workspace, not by any single
117+ // client's HTTP request.
118+ ctx context.Context
119+ cancel context.CancelFunc
120+
121+ // runMu guards closing and gates dispatch of new agent runs.
122+ // closing is set by Shutdown so no new runs are accepted once
123+ // teardown has begun. runWG tracks dispatched agent goroutines
124+ // so Shutdown can wait for them to return before app cleanup.
125+ runMu sync.Mutex
126+ closing bool
127+ runWG sync.WaitGroup
128+
111129 // clientsMu guards clients. It is held only briefly (no IO).
112130 clientsMu sync.Mutex
113131 // clients tracks each client's claim on this workspace. Refcount
@@ -122,7 +140,7 @@ type Workspace struct {
122140}
123141
124142// invokeShutdown calls the workspace shutdown hook if set, falling
125−// back to the embedded [app.App.Shutdown] when not.
143+// back to the workspace [Workspace.Shutdown] wrapper when not.
126144func (w *Workspace) invokeShutdown() {
127145 if w.shutdownFn != nil {
128146 w.shutdownFn()
@@ -133,6 +151,40 @@ func (w *Workspace) invokeShutdown() {
133151 }
134152}
135153
154+// Shutdown tears the workspace down in an order that is safe for
155+// agent runs whose lifetime is bound to the workspace context. It
156+// shadows the promoted [app.App.Shutdown] so callers reaching
157+// ws.Shutdown() always observe this ordering:
158+//
159+// 1. Mark the workspace closing so no new agent runs are accepted.
160+// 2. Cancel the workspace run context so any dispatched goroutine
161+// that has not yet registered its per-session cancel still
162+// observes cancellation.
163+// 3. Cancel active coordinator work for runs that already
164+// registered their per-session cancel function.
165+// 4. Wait for dispatched agent goroutines to return.
166+// 5. Run the embedded [app.App.Shutdown] cleanup (DB, LSP, etc).
167+//
168+// CancelAll is idempotent, so the second call inside app.App.Shutdown
169+// is harmless; the important guarantee is that cancel -> CancelAll ->
170+// runWG.Wait completes before the embedded cleanup touches the DB.
171+func (w *Workspace) Shutdown() {
172+ w.runMu.Lock()
173+ w.closing = true
174+ w.runMu.Unlock()
175+
176+ if w.cancel != nil {
177+ w.cancel()
178+ }
179+ if w.App != nil && w.AgentCoordinator != nil {
180+ w.AgentCoordinator.CancelAll()
181+ }
182+ w.runWG.Wait()
183+ if w.App != nil {
184+ w.App.Shutdown()
185+ }
186+}
187+
136188// New creates a new [Backend].
137189func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
138190 return &Backend{
@@ -247,6 +299,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
247299 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
248300 }
249301
302+ wsCtx, wsCancel := context.WithCancel(b.ctx)
250303 ws := &Workspace{
251304 App: appWorkspace,
252305 ID: id,
@@ -255,6 +308,8 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
255308 Env: args.Env,
256309 Skills: skillsMgr,
257310 resolvedPath: key,
311+ ctx: wsCtx,
312+ cancel: wsCancel,
258313 clients: make(map[string]*clientState),
259314 }
260315
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/client/proto.go+17−1
@@ -423,12 +423,28 @@ func (c *Client) SendMessage(ctx context.Context, id string, sessionID, runID, p
423423 return fmt.Errorf("failed to send message to agent: %w", err)
424424 }
425425 defer rsp.Body.Close()
426− if rsp.StatusCode != http.StatusOK {
426+ if rsp.StatusCode != http.StatusOK && rsp.StatusCode != http.StatusAccepted {
427+ if msg := decodeErrorMessage(rsp.Body); msg != "" {
428+ return fmt.Errorf("failed to send message to agent: status code %d: %s", rsp.StatusCode, msg)
429+ }
427430 return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
428431 }
429432 return nil
430433}
431434
435+// decodeErrorMessage attempts to decode the response body as a
436+// proto.Error and returns its message. It returns an empty string
437+// when the body is empty or cannot be decoded into a proto.Error
438+// with a non-empty message, letting callers fall back to a
439+// status-only error.
440+func decodeErrorMessage(body io.Reader) string {
441+ var e proto.Error
442+ if err := json.NewDecoder(body).Decode(&e); err != nil {
443+ return ""
444+ }
445+ return e.Message
446+}
447+
432448// GetAgentSessionInfo retrieves the agent session info for a workspace.
433449func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
434450 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
internal/client/proto_test.go+70
@@ -88,6 +88,76 @@ func TestSubscribeEventsContextCancelClosesEvents(t *testing.T) {
8888 }
8989}
9090
91+func TestSendMessageAcceptsStatusAccepted(t *testing.T) {
92+ t.Parallel()
93+
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95+ w.WriteHeader(http.StatusAccepted)
96+ }))
97+ defer srv.Close()
98+
99+ c := captureClient(t, srv)
100+ require.NoError(t, c.SendMessage(context.Background(), "ws1", "sess1", "", "hello"))
101+}
102+
103+func TestSendMessageAcceptsStatusOK(t *testing.T) {
104+ t.Parallel()
105+
106+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
107+ w.WriteHeader(http.StatusOK)
108+ }))
109+ defer srv.Close()
110+
111+ c := captureClient(t, srv)
112+ require.NoError(t, c.SendMessage(context.Background(), "ws1", "sess1", "", "hello"))
113+}
114+
115+func TestSendMessageDecodesErrorBody(t *testing.T) {
116+ t.Parallel()
117+
118+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
119+ w.WriteHeader(http.StatusBadRequest)
120+ _ = json.NewEncoder(w).Encode(proto.Error{Message: "session id is required"})
121+ }))
122+ defer srv.Close()
123+
124+ c := captureClient(t, srv)
125+ err := c.SendMessage(context.Background(), "ws1", "", "", "hello")
126+ require.Error(t, err)
127+ require.Contains(t, err.Error(), "status code 400")
128+ require.Contains(t, err.Error(), "session id is required")
129+}
130+
131+func TestSendMessageFallsBackOnMalformedErrorBody(t *testing.T) {
132+ t.Parallel()
133+
134+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
135+ w.WriteHeader(http.StatusInternalServerError)
136+ _, _ = w.Write([]byte("not json"))
137+ }))
138+ defer srv.Close()
139+
140+ c := captureClient(t, srv)
141+ err := c.SendMessage(context.Background(), "ws1", "sess1", "", "hello")
142+ require.Error(t, err)
143+ require.Contains(t, err.Error(), "status code 500")
144+ require.NotContains(t, err.Error(), "not json")
145+}
146+
147+func TestSendMessageFallsBackOnEmptyErrorBody(t *testing.T) {
148+ t.Parallel()
149+
150+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
151+ w.WriteHeader(http.StatusInternalServerError)
152+ }))
153+ defer srv.Close()
154+
155+ c := captureClient(t, srv)
156+ err := c.SendMessage(context.Background(), "ws1", "sess1", "", "hello")
157+ require.Error(t, err)
158+ require.Contains(t, err.Error(), "status code 500")
159+}
160+
91161func marshalSSEPayload(t *testing.T) []byte {
92162 t.Helper()
93163
internal/cmd/run.go+20−4
@@ -409,11 +409,27 @@ func (s *runStream) handle(ev any, stopSpinner func()) (done bool, err error) {
409409 return true, nil
410410
411411 case pubsub.Event[proto.AgentEvent]:
412− if e.Payload.Error != nil {
413− stop()
414− return true, fmt.Errorf("agent error: %w", e.Payload.Error)
412+ if e.Payload.Error == nil {
413+ return false, nil
415414 }
416− return false, nil
415+ // Attribute the error to our run before treating it as
416+ // fatal. Async errors from an unrelated workspace run share
417+ // this channel, so a foreign failure must not abort us:
418+ // - if the event carries a RunID, it is the authoritative
419+ // correlator: it must match our run exactly, otherwise it
420+ // belongs to a different request and we ignore it.
421+ // - if the event carries no RunID (older server), fall back
422+ // to SessionID: it must be present and match our session,
423+ // otherwise we ignore it.
424+ if e.Payload.RunID != "" {
425+ if e.Payload.RunID != s.runID {
426+ return false, nil
427+ }
428+ } else if e.Payload.SessionID == "" || e.Payload.SessionID != s.sessionID {
429+ return false, nil
430+ }
431+ stop()
432+ return true, fmt.Errorf("agent error: %w", e.Payload.Error)
417433 }
418434 return false, nil
419435}
internal/cmd/run_stream_test.go+88
@@ -2,6 +2,7 @@ package cmd
22
33import (
44 "bytes"
5+ "errors"
56 "testing"
67 "time"
78
@@ -307,6 +308,93 @@ func TestRunStream_RunIDSuppressesLiveMessagesAndPrintsRunComplete(t *testing.T)
307308 require.Equal(t, "streamed prefix final", buf.String())
308309}
309310
311+// TestRunStream_AgentErrorRunIDFiltersForeign verifies that an async
312+// agent error carrying a non-empty RunID is fatal only when it matches
313+// our run. A foreign RunID is ignored regardless of the event's
314+// SessionID, because RunID is the authoritative correlator and async
315+// errors share the agent event channel: without strict RunID matching
316+// an unrelated workspace failure would abort our run.
317+func TestRunStream_AgentErrorRunIDFiltersForeign(t *testing.T) {
318+ t.Parallel()
319+
320+ // Foreign RunID with a matching session is still foreign.
321+ s := &runStream{sessionID: "S", runID: "run-mine", out: &bytes.Buffer{}, read: map[string]int{}}
322+ done, err := s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
323+ Type: proto.AgentEventTypeError,
324+ SessionID: "S",
325+ RunID: "run-other",
326+ Error: errors.New("foreign boom"),
327+ }}, nil)
328+ require.NoError(t, err, "foreign RunID error must not abort our run")
329+ require.False(t, done)
330+
331+ // Foreign RunID with a different session is ignored.
332+ done, err = s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
333+ Type: proto.AgentEventTypeError,
334+ SessionID: "other",
335+ RunID: "run-other",
336+ Error: errors.New("foreign boom"),
337+ }}, nil)
338+ require.NoError(t, err, "foreign RunID error must not abort our run")
339+ require.False(t, done)
340+
341+ // Foreign RunID with a missing session is ignored.
342+ done, err = s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
343+ Type: proto.AgentEventTypeError,
344+ RunID: "run-other",
345+ Error: errors.New("foreign boom"),
346+ }}, nil)
347+ require.NoError(t, err, "foreign RunID error must not abort our run")
348+ require.False(t, done)
349+
350+ // Matching RunID is fatal.
351+ done, err = s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
352+ Type: proto.AgentEventTypeError,
353+ SessionID: "S",
354+ RunID: "run-mine",
355+ Error: errors.New("my boom"),
356+ }}, nil)
357+ require.Error(t, err, "matching RunID error must be fatal")
358+ require.True(t, done)
359+}
360+
361+// TestRunStream_AgentErrorNoRunIDFiltersBySession verifies the
362+// compatibility fallback: when the event carries no RunID, attribution
363+// falls back to SessionID. An error for another session or with an
364+// empty session is ignored, while an error for our own session is fatal
365+// so a real failure is never dropped.
366+func TestRunStream_AgentErrorNoRunIDFiltersBySession(t *testing.T) {
367+ t.Parallel()
368+
369+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
370+
371+ // Empty RunID for another session is ignored.
372+ done, err := s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
373+ Type: proto.AgentEventTypeError,
374+ SessionID: "other",
375+ Error: errors.New("foreign boom"),
376+ }}, nil)
377+ require.NoError(t, err, "error for another session must not abort our run")
378+ require.False(t, done)
379+
380+ // Empty RunID with an empty session is ignored.
381+ done, err = s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
382+ Type: proto.AgentEventTypeError,
383+ Error: errors.New("foreign boom"),
384+ }}, nil)
385+ require.NoError(t, err, "error with no session must not abort our run")
386+ require.False(t, done)
387+
388+ // Empty RunID with a matching session is fatal.
389+ done, err = s.handle(pubsub.Event[proto.AgentEvent]{Payload: proto.AgentEvent{
390+ Type: proto.AgentEventTypeError,
391+ SessionID: "S",
392+ Error: errors.New("my boom"),
393+ }}, nil)
394+ require.Error(t, err, "error for our own session must be fatal")
395+ require.True(t, done)
396+}
397+
310398// TestRunStream_NoRunIDFallsBackToSessionID preserves the older
311399// behaviour for callers (and tests) that don't supply a RunID:
312400// SessionID-only matching still terminates the stream on the
internal/proto/agent.go+7
@@ -31,6 +31,13 @@ type AgentEvent struct {
3131 Message Message `json:"message"`
3232 Error error `json:"error,omitempty"`
3333
34+ // RunID echoes the caller-supplied AgentMessage.RunID for the run
35+ // that produced this event. It lets observers (notably
36+ // `crush run`) attribute an error event to a specific request
37+ // instead of to any in-flight run on the session. Empty when no
38+ // caller set one.
39+ RunID string `json:"run_id,omitempty"`
40+
3441 // When summarizing.
3542 SessionID string `json:"session_id,omitempty"`
3643 SessionTitle string `json:"session_title,omitempty"`
internal/server/agent_cancel_test.go+70−54
@@ -60,6 +60,13 @@ func (s *runCoordinator) Run(ctx context.Context, sessionID, prompt string, atta
6060 return nil, s.returnFn(ctx)
6161}
6262
63+func (s *runCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
64+ return s.Run(ctx, sessionID, prompt, attachments...)
65+}
66+
67+func (s *runCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun {
68+ return nil
69+}
6370func (s *runCoordinator) Cancel(string) {}
6471func (s *runCoordinator) CancelAll() {}
6572func (s *runCoordinator) IsBusy() bool { return false }
@@ -115,11 +122,12 @@ func postAgent(t *testing.T, c *controllerV1, ctx context.Context, wsID, session
115122}
116123
117124// TestPostAgent_ReturnsOKOnContextCanceled verifies that when another
118−// client cancels the session mid-turn, the prompting client's still
119−// open POST receives 200 (not 500). The agent surfaces the
120−// FinishReasonCanceled marker to every SSE subscriber via the
121−// assistant message; the HTTP response from the prompter should not
122−// 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.
123131func TestPostAgent_ReturnsOKOnContextCanceled(t *testing.T) {
124132 t.Parallel()
125133
@@ -128,33 +136,56 @@ func TestPostAgent_ReturnsOKOnContextCanceled(t *testing.T) {
128136 })
129137 c, wsID := buildAgentWorkspace(t, coord)
130138
131− done := make(chan *httptest.ResponseRecorder, 1)
132− go func() {
133− done <- postAgent(t, c, t.Context(), wsID, "S1")
134− }()
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.StatusAccepted, rec.Code, "fire-and-forget SendMessage must return 202 without waiting for the run")
135143
136− // Wait until Run is in flight, then release it to return
137− // 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.
138147 select {
139148 case <-coord.entered:
140149 case <-time.After(2 * time.Second):
141− t.Fatal("coordinator Run was never entered")
150+ t.Fatal("dispatched run was never entered")
142151 }
143152 close(coord.release)
144153
145− select {
146− case rec := <-done:
147− require.Equal(t, http.StatusOK, rec.Code, "context.Canceled from another client's cancel must not surface as 500")
148− case <-time.After(2 * time.Second):
149− t.Fatal("handler did not return after coordinator returned context.Canceled")
150− }
154+ // Wait for the dispatched run to fully return. Backend.runAgent
155+ // swallows context.Canceled, so it must not publish a
156+ // notify.TypeAgentError. Publishing would dereference the synthetic
157+ // workspace's nil notification broker and crash this goroutine,
158+ // which is the explicit guard that a cancel produces no top-level
159+ // error event.
160+ require.Eventually(t, func() bool {
161+ return coord.ranCount.Load() == 1
162+ }, 2*time.Second, 10*time.Millisecond)
151163}
152164
153−// TestPostAgent_DetachesRequestContext verifies that canceling the
154−// prompting client's HTTP request context does not cancel the
155−// in-flight agent run. The coordinator must observe a context whose
156−// Done channel never fires from the request side; only the explicit
157−// cancel endpoint may end the run.
165+// TestHandleError_ContextCanceledFallsThroughTo500 documents the step 8
166+// cleanup: the old context.Canceled special case in handleError was
167+// removed because runtime cancellation of an agent run can no longer
168+// reach handleError. The agent-prompt handler returns 202 before the run
169+// starts (fire-and-forget SendMessage) and Backend.runAgent swallows
170+// context.Canceled. Any context.Canceled that still reaches handleError
171+// is therefore an unexpected synchronous error and falls through to the
172+// default 500 like any other.
173+func TestHandleError_ContextCanceledFallsThroughTo500(t *testing.T) {
174+ t.Parallel()
175+
176+ c := &controllerV1{server: &Server{}}
177+ rec := httptest.NewRecorder()
178+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
179+
180+ c.handleError(rec, req, context.Canceled)
181+
182+ require.Equal(t, http.StatusInternalServerError, rec.Code)
183+}
184+
185+// TestPostAgent_DetachesRequestContext verifies that the dispatched run
186+// is bound to the workspace context, not the prompting client's HTTP
187+// request context. Canceling the request context must neither cancel
188+// the run nor be observed by the coordinator.
158189func TestPostAgent_DetachesRequestContext(t *testing.T) {
159190 t.Parallel()
160191
@@ -165,46 +196,31 @@ func TestPostAgent_DetachesRequestContext(t *testing.T) {
165196
166197 reqCtx, cancelReq := context.WithCancel(context.Background())
167198
168− done := make(chan *httptest.ResponseRecorder, 1)
169− go func() {
170− done <- postAgent(t, c, reqCtx, wsID, "S1")
171− }()
199+ // The handler returns immediately; the run keeps executing on its
200+ // own goroutine bound to the workspace context.
201+ rec := postAgent(t, c, reqCtx, wsID, "S1")
202+ require.Equal(t, http.StatusAccepted, rec.Code)
172203
173− // Wait until Run is in flight, then drop the prompting client.
174204 select {
175205 case <-coord.entered:
176206 case <-time.After(2 * time.Second):
177− t.Fatal("coordinator Run was never entered")
207+ t.Fatal("dispatched run was never entered")
178208 }
209+
210+ // Drop the prompting client. This must not reach the run.
179211 cancelReq()
180212
181− // The captured ctx must be detached: context.WithoutCancel
182− // returns a ctx with Done() == nil so request cancellation cannot
183− // propagate.
184213 got := coord.capturedCtx()
185214 require.NotNil(t, got)
186− require.Nil(t, got.Done(), "coordinator ctx must be detached from r.Context() via context.WithoutCancel")
187− require.NoError(t, got.Err(), "coordinator ctx must not inherit cancellation from the dropped request")
188−
189− // Confirm Run is still running: it should not have completed
190− // just because the request ctx was canceled.
191− select {
192− case <-done:
193− t.Fatal("handler returned before run completed; request ctx cancellation leaked into the run")
194− case <-time.After(50 * time.Millisecond):
195− }
215+ // Compare by identity (pointer), not reflect.DeepEqual: deep
216+ // comparison would traverse context internals that the runtime
217+ // mutates concurrently.
218+ require.False(t, got == reqCtx, "run ctx must not be the request ctx")
219+ require.NoError(t, got.Err(), "run ctx must not inherit cancellation from the dropped request")
196220
197− // Release the run; the handler should now complete cleanly.
221+ // Release the run so it returns cleanly.
198222 close(coord.release)
199− select {
200− case rec := <-done:
201− // Writing to a recorder whose request ctx was canceled
202− // still works; in production the TCP write would silently
203− // fail, which is fine because the run already completed and
204− // SSE subscribers have the result.
205− require.Equal(t, http.StatusOK, rec.Code)
206− case <-time.After(2 * time.Second):
207− t.Fatal("handler did not return after release")
208− }
209− require.Equal(t, int32(1), coord.ranCount.Load())
223+ require.Eventually(t, func() bool {
224+ return coord.ranCount.Load() == 1
225+ }, 2*time.Second, 10*time.Millisecond)
210226}
internal/server/e2e_agent_test.gonew file+741
@@ -0,0 +1,741 @@
1+package server
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "charm.land/fantasy"
16+ "github.com/charmbracelet/crush/internal/agent"
17+ "github.com/charmbracelet/crush/internal/app"
18+ "github.com/charmbracelet/crush/internal/backend"
19+ "github.com/charmbracelet/crush/internal/message"
20+ "github.com/charmbracelet/crush/internal/proto"
21+ "github.com/charmbracelet/crush/internal/pubsub"
22+ "github.com/google/uuid"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+// scriptedCoordinator is an agent.Coordinator stub that mimics the
27+// externally-observable contract of a real run over the SSE pipeline
28+// without booting a real model, database, or scheduler. It publishes a
29+// user message when a run begins and an assistant message (with the
30+// appropriate FinishReason) when the run ends, exactly the way the real
31+// sessionAgent.Run surfaces a turn to SSE subscribers.
32+//
33+// A run blocks until either its per-session context is canceled (via
34+// Cancel, mirroring the explicit cancel endpoint) or the test releases
35+// it. On cancel it emits a FinishReasonCanceled assistant message and
36+// returns context.Canceled (which backend.runAgent swallows, so no
37+// AgentEvent error is published). On normal release it emits a
38+// FinishReasonEndTurn assistant message and returns nil.
39+//
40+// The internal scheduler signal points the PLAN's e2e cases reference
41+// (e.g. "before registration in activeRequests", "between
42+// activeRequests.Set and assistant create") are not exposed by the
43+// codebase, so this stub reproduces the documented black-box outcome by
44+// controlling run timing directly through blockEntered / release.
45+type scriptedCoordinator struct {
46+ app *app.App
47+
48+ // blockEntered, when non-nil, is signaled (once) right after a run
49+ // is entered and before the user message is emitted, letting a test
50+ // interleave a cancel with the dispatched goroutine.
51+ blockEntered chan struct{}
52+
53+ mu sync.Mutex
54+ // cancels holds the cancel func for every in-flight run, keyed by a
55+ // monotonic id so concurrent runs for the same session each get their
56+ // own entry (a map keyed only by sessionID would let a second run
57+ // overwrite the first's cancel func and leak it).
58+ cancels map[int64]sessionCancel
59+ // pendingCancels counts cancels that arrived for a session while a run
60+ // was in flight; a run for that session consumes one on entry and
61+ // cancels itself, modeling the cancel-on-entry path a follow-up takes.
62+ pendingCancels map[string]int
63+ nextRunID int64
64+ // entered carries the monotonic run id assigned to each run as it is
65+ // entered, so a test can correlate a later assistant message back to a
66+ // specific run (run 1 vs an accepted follow-up).
67+ entered chan int64
68+ runStarts atomic.Int32
69+
70+ release chan struct{}
71+}
72+
73+type sessionCancel struct {
74+ sessionID string
75+ cancel context.CancelFunc
76+}
77+
78+func newScriptedCoordinator(a *app.App) *scriptedCoordinator {
79+ return &scriptedCoordinator{
80+ app: a,
81+ cancels: make(map[int64]sessionCancel),
82+ pendingCancels: make(map[string]int),
83+ entered: make(chan int64, 8),
84+ release: make(chan struct{}),
85+ }
86+}
87+
88+func (c *scriptedCoordinator) emitUser(sessionID, id string) {
89+ c.app.SendEvent(pubsub.Event[message.Message]{
90+ Type: pubsub.CreatedEvent,
91+ Payload: message.Message{
92+ ID: id,
93+ SessionID: sessionID,
94+ Role: message.User,
95+ Parts: []message.ContentPart{message.TextContent{Text: "hi"}},
96+ },
97+ })
98+}
99+
100+func (c *scriptedCoordinator) emitAssistant(sessionID, id string, reason message.FinishReason) {
101+ c.app.SendEvent(pubsub.Event[message.Message]{
102+ Type: pubsub.CreatedEvent,
103+ Payload: message.Message{
104+ ID: id,
105+ SessionID: sessionID,
106+ Role: message.Assistant,
107+ Parts: []message.ContentPart{message.Finish{Reason: reason}},
108+ },
109+ })
110+}
111+
112+func (c *scriptedCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
113+ c.runStarts.Add(1)
114+ runCtx, cancel := context.WithCancel(ctx)
115+
116+ c.mu.Lock()
117+ id := c.nextRunID
118+ c.nextRunID++
119+ c.cancels[id] = sessionCancel{sessionID: sessionID, cancel: cancel}
120+ // Cancel-on-entry: if a cancel for this session arrived while this
121+ // run was still being dispatched (no run yet in flight to receive
122+ // it), consume the pending cancel now so the run takes the canceled
123+ // path instead of streaming output.
124+ if c.pendingCancels[sessionID] > 0 {
125+ c.pendingCancels[sessionID]--
126+ cancel()
127+ }
128+ c.mu.Unlock()
129+
130+ select {
131+ case c.entered <- id:
132+ default:
133+ }
134+
135+ if c.blockEntered != nil {
136+ select {
137+ case <-c.blockEntered:
138+ case <-runCtx.Done():
139+ }
140+ }
141+
142+ defer func() {
143+ c.mu.Lock()
144+ delete(c.cancels, id)
145+ c.mu.Unlock()
146+ cancel()
147+ }()
148+
149+ // Qualify the emitted message ids with the run id so a test can
150+ // attribute an assistant message to the exact run that produced it
151+ // (run 1 vs an accepted follow-up sharing the same session).
152+ userID := fmt.Sprintf("u-%s-%d", sessionID, id)
153+ asstID := fmt.Sprintf("a-%s-%d", sessionID, id)
154+
155+ c.emitUser(sessionID, userID)
156+
157+ // Cancellation takes priority: if the run was already canceled it
158+ // must take the canceled path even when release is closed, so a
159+ // canceled run never races into a normal FinishReasonEndTurn.
160+ select {
161+ case <-runCtx.Done():
162+ c.emitAssistant(sessionID, asstID, message.FinishReasonCanceled)
163+ return nil, context.Canceled
164+ default:
165+ }
166+
167+ select {
168+ case <-c.release:
169+ c.emitAssistant(sessionID, asstID, message.FinishReasonEndTurn)
170+ return nil, nil
171+ case <-runCtx.Done():
172+ c.emitAssistant(sessionID, asstID, message.FinishReasonCanceled)
173+ return nil, context.Canceled
174+ }
175+}
176+
177+func (c *scriptedCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
178+ return c.Run(ctx, sessionID, prompt, attachments...)
179+}
180+
181+func (c *scriptedCoordinator) BeginAccepted(string) *agent.AcceptedRun { return nil }
182+
183+func (c *scriptedCoordinator) Cancel(sessionID string) {
184+ c.mu.Lock()
185+ defer c.mu.Unlock()
186+ // Cancel every in-flight run for this session. Concurrent runs for
187+ // the same session (an active run plus an accepted follow-up still
188+ // dispatching) each hold their own entry, so all of them are torn
189+ // down by a single per-session cancel.
190+ var canceled int
191+ for _, sc := range c.cancels {
192+ if sc.sessionID == sessionID {
193+ sc.cancel()
194+ canceled++
195+ }
196+ }
197+ // If at least one run was in flight, arm a pending cancel so a
198+ // follow-up that has been accepted but not yet entered Run takes the
199+ // cancel-on-entry path. With no run in flight this is a no-op,
200+ // mirroring the production guarantee that an idle cancel does not arm
201+ // a pending cancel against the next prompt.
202+ if canceled > 0 {
203+ c.pendingCancels[sessionID]++
204+ }
205+}
206+
207+func (c *scriptedCoordinator) CancelAll() {
208+ c.mu.Lock()
209+ defer c.mu.Unlock()
210+ for _, sc := range c.cancels {
211+ sc.cancel()
212+ }
213+}
214+
215+func (c *scriptedCoordinator) IsBusy() bool { return false }
216+func (c *scriptedCoordinator) IsSessionBusy(string) bool { return false }
217+func (c *scriptedCoordinator) QueuedPrompts(string) int { return 0 }
218+func (c *scriptedCoordinator) QueuedPromptsList(string) []string { return nil }
219+func (c *scriptedCoordinator) ClearQueue(string) {}
220+func (c *scriptedCoordinator) Summarize(context.Context, string) error { return nil }
221+func (c *scriptedCoordinator) Model() agent.Model { return agent.Model{} }
222+func (c *scriptedCoordinator) UpdateModels(context.Context) error { return nil }
223+
224+// agentE2EHarness extends the SSE harness with a scripted coordinator
225+// wired into the workspace's embedded app.App, so POST /agent drives a
226+// real backend.SendMessage dispatch whose emitted user/assistant
227+// messages fan out over the same SSE pipeline production uses.
228+type agentE2EHarness struct {
229+ *e2eHarness
230+ coord *scriptedCoordinator
231+}
232+
233+func newAgentE2EHarness(t *testing.T) *agentE2EHarness {
234+ t.Helper()
235+
236+ h := &e2eHarness{}
237+
238+ appCtx, cancel := context.WithCancel(context.Background())
239+ a := app.NewForTest(appCtx)
240+ coord := newScriptedCoordinator(a)
241+ a.AgentCoordinator = coord
242+ t.Cleanup(func() {
243+ cancel()
244+ a.ShutdownForTest()
245+ })
246+
247+ h.installServer(t)
248+
249+ ws := &backend.Workspace{
250+ ID: uuid.New().String(),
251+ Path: t.TempDir(),
252+ App: a,
253+ }
254+ backend.SetWorkspaceShutdownFnForTest(ws, func() {})
255+ backend.InsertWorkspaceForTest(h.backend, ws)
256+
257+ h.workspace = ws
258+ h.app = a
259+ return &agentE2EHarness{e2eHarness: h, coord: coord}
260+}
261+
262+// postAgentHTTP drives POST /v1/workspaces/{id}/agent over the harness's
263+// httptest server and returns the status code.
264+func (h *agentE2EHarness) postAgentHTTP(t *testing.T, ctx context.Context, sessionID string) int {
265+ t.Helper()
266+ body, err := json.Marshal(proto.AgentMessage{SessionID: sessionID, Prompt: "hi"})
267+ require.NoError(t, err)
268+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
269+ h.httpSrv.URL+"/v1/workspaces/"+h.workspace.ID+"/agent", bytes.NewReader(body))
270+ require.NoError(t, err)
271+ req.Header.Set("Content-Type", "application/json")
272+ resp, err := h.httpSrv.Client().Do(req)
273+ require.NoError(t, err)
274+ _, _ = io.Copy(io.Discard, resp.Body)
275+ resp.Body.Close()
276+ return resp.StatusCode
277+}
278+
279+// cancelAgentHTTP drives POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel.
280+func (h *agentE2EHarness) cancelAgentHTTP(t *testing.T, ctx context.Context, sessionID string) int {
281+ t.Helper()
282+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
283+ h.httpSrv.URL+"/v1/workspaces/"+h.workspace.ID+"/agent/sessions/"+sessionID+"/cancel", nil)
284+ require.NoError(t, err)
285+ resp, err := h.httpSrv.Client().Do(req)
286+ require.NoError(t, err)
287+ _, _ = io.Copy(io.Discard, resp.Body)
288+ resp.Body.Close()
289+ return resp.StatusCode
290+}
291+
292+// waitForRunEntered blocks until a dispatched run for any session has
293+// been entered by the scripted coordinator, or fails the test. It
294+// returns the monotonic run id assigned to that run so a caller can
295+// correlate it with a later assistant message; callers that don't need
296+// the id can ignore the return value.
297+func (h *agentE2EHarness) waitForRunEntered(t *testing.T) int64 {
298+ t.Helper()
299+ select {
300+ case id := <-h.coord.entered:
301+ return id
302+ case <-time.After(2 * time.Second):
303+ t.Fatal("dispatched run was never entered")
304+ return 0
305+ }
306+}
307+
308+// finishReason extracts the assistant message's FinishReason, if any.
309+func finishReason(m proto.Message) (proto.FinishReason, bool) {
310+ for _, p := range m.Parts {
311+ if f, ok := p.(proto.Finish); ok {
312+ return f.Reason, true
313+ }
314+ }
315+ return "", false
316+}
317+
318+// TestE2E_CancelByOtherClientDoesNotErrorPrompter covers PLAN Tests ->
319+// New end-to-end coverage item 1: a second client canceling a run does
320+// not surface a server error to the prompter; the run ends with a
321+// FinishReasonCanceled assistant message and no AgentEvent carries a
322+// non-nil Error.
323+func TestE2E_CancelByOtherClientDoesNotErrorPrompter(t *testing.T) {
324+ t.Parallel()
325+ h := newAgentE2EHarness(t)
326+ ctx, cancel := context.WithCancel(t.Context())
327+ t.Cleanup(cancel)
328+
329+ cidA := uuid.New().String()
330+ cidB := uuid.New().String()
331+ evcA, cancelA := h.subscribeSSE(t, ctx, h.workspace.ID, cidA)
332+ t.Cleanup(cancelA)
333+ evcB, cancelB := h.subscribeSSE(t, ctx, h.workspace.ID, cidB)
334+ t.Cleanup(cancelB)
335+ h.waitForAttached(t, 2)
336+
337+ const sid = "s-cancel-other"
338+
339+ // A posts a long-running prompt; the handler must return 202
340+ // immediately (the run blocks in the coordinator).
341+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
342+ h.waitForRunEntered(t)
343+
344+ // B cancels.
345+ require.Equal(t, http.StatusOK, h.cancelAgentHTTP(t, ctx, sid))
346+
347+ // A's SSE stream receives the FinishReasonCanceled assistant
348+ // message.
349+ pickCtx, pickCancel := context.WithTimeout(ctx, 3*time.Second)
350+ defer pickCancel()
351+ got, ok := drainUntil(pickCtx, evcA, func(e pubsub.Event[proto.Message]) bool {
352+ r, has := finishReason(e.Payload)
353+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonCanceled
354+ })
355+ require.True(t, ok, "client A must observe a FinishReasonCanceled assistant message")
356+ require.Equal(t, sid, got.Payload.SessionID)
357+
358+ // No AgentEvent error reaches A (cancel is not a server error).
359+ errCtx, errCancel := context.WithTimeout(ctx, 250*time.Millisecond)
360+ defer errCancel()
361+ _, gotErrA := drainUntil(errCtx, evcA, func(e pubsub.Event[proto.AgentEvent]) bool {
362+ return e.Payload.Type == proto.AgentEventTypeError && e.Payload.Error != nil
363+ })
364+ require.False(t, gotErrA, "cancel must not surface an AgentEvent error to the prompter")
365+
366+ // And no AgentEvent error reaches the canceling client B either; the
367+ // PLAN requires that *no* client observes a non-nil Error.
368+ errCtxB, errCancelB := context.WithTimeout(ctx, 250*time.Millisecond)
369+ defer errCancelB()
370+ _, gotErrB := drainUntil(errCtxB, evcB, func(e pubsub.Event[proto.AgentEvent]) bool {
371+ return e.Payload.Type == proto.AgentEventTypeError && e.Payload.Error != nil
372+ })
373+ require.False(t, gotErrB, "cancel must not surface an AgentEvent error to any client")
374+}
375+
376+// TestE2E_CancelImmediatelyAfter202IsNotLost covers PLAN item 1a: a
377+// cancel that races a freshly-dispatched run (before it would emit any
378+// output) is not lost. The run takes the cancel-on-entry path and emits
379+// a user message followed by a FinishReasonCanceled assistant message
380+// rather than streaming model output.
381+func TestE2E_CancelImmediatelyAfter202IsNotLost(t *testing.T) {
382+ t.Parallel()
383+ h := newAgentE2EHarness(t)
384+ // Gate the run on a signal the test controls so the cancel can be
385+ // observed while the dispatched goroutine is parked at entry.
386+ h.coord.blockEntered = make(chan struct{})
387+
388+ ctx, cancel := context.WithCancel(t.Context())
389+ t.Cleanup(cancel)
390+
391+ cid := uuid.New().String()
392+ evc, cancelSSE := h.subscribeSSE(t, ctx, h.workspace.ID, cid)
393+ t.Cleanup(cancelSSE)
394+ h.waitForAttached(t, 1)
395+
396+ const sid = "s-race-cancel"
397+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
398+ h.waitForRunEntered(t)
399+
400+ // Cancel while the run is still blocked at entry, then release it.
401+ require.Equal(t, http.StatusOK, h.cancelAgentHTTP(t, ctx, sid))
402+ close(h.coord.blockEntered)
403+
404+ pickCtx, pickCancel := context.WithTimeout(ctx, 3*time.Second)
405+ defer pickCancel()
406+
407+ gotUser, okUser := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
408+ return e.Payload.Role == proto.User && e.Payload.SessionID == sid
409+ })
410+ require.True(t, okUser, "the canceled turn must still record a user message")
411+ require.Equal(t, sid, gotUser.Payload.SessionID)
412+
413+ gotAsst, okAsst := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
414+ r, has := finishReason(e.Payload)
415+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonCanceled
416+ })
417+ require.True(t, okAsst, "the canceled turn must end with a FinishReasonCanceled assistant message")
418+ require.Equal(t, sid, gotAsst.Payload.SessionID)
419+}
420+
421+// TestE2E_IdleCancelDoesNotPoisonNextPrompt covers PLAN item 1b: an
422+// idle cancel (no active run) must not poison the next prompt. With the
423+// scripted coordinator the cancel records a pending entry only if a run
424+// is in flight; an idle cancel records one, but the documented
425+// guarantee is that the *next* prompt's outcome is observable. Here we
426+// assert the regression-relevant external behavior: after an idle
427+// cancel, a subsequent normal prompt is able to run and emit output.
428+//
429+// NOTE: This is a simplified version. The real "idle Escape must not
430+// poison" guarantee lives inside sessionAgent.Cancel's acceptedRuns
431+// gating, which is covered by the agent unit tests; the e2e stub cannot
432+// distinguish "truly idle" from "accepted but not yet running" without
433+// the internal acceptedRuns signal. See test summary.
434+func TestE2E_IdleCancelDoesNotPoisonNextPrompt(t *testing.T) {
435+ t.Parallel()
436+ h := newAgentE2EHarness(t)
437+ ctx, cancel := context.WithCancel(t.Context())
438+ t.Cleanup(cancel)
439+
440+ cid := uuid.New().String()
441+ evc, cancelSSE := h.subscribeSSE(t, ctx, h.workspace.ID, cid)
442+ t.Cleanup(cancelSSE)
443+ h.waitForAttached(t, 1)
444+
445+ const sid = "s-idle-cancel"
446+
447+ // Idle cancel: no run in flight. The scripted coordinator drops it
448+ // (no pending cancel recorded for a session that has no run), which
449+ // models the production guarantee that an idle Escape does not arm
450+ // a cancel against the next prompt.
451+ require.Equal(t, http.StatusOK, h.cancelAgentHTTP(t, ctx, sid))
452+
453+ // Now a normal prompt; release it so it finishes successfully.
454+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
455+ h.waitForRunEntered(t)
456+ close(h.coord.release)
457+
458+ pickCtx, pickCancel := context.WithTimeout(ctx, 3*time.Second)
459+ defer pickCancel()
460+ got, ok := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
461+ r, has := finishReason(e.Payload)
462+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonEndTurn
463+ })
464+ require.True(t, ok, "the next prompt after an idle cancel must run to FinishReasonEndTurn")
465+ require.Equal(t, sid, got.Payload.SessionID)
466+
467+ // And it must not be marked canceled.
468+ canCtx, canCancel := context.WithTimeout(ctx, 200*time.Millisecond)
469+ defer canCancel()
470+ _, gotCanceled := drainUntil(canCtx, evc, func(e pubsub.Event[proto.Message]) bool {
471+ r, has := finishReason(e.Payload)
472+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonCanceled
473+ })
474+ require.False(t, gotCanceled, "an idle cancel must not produce a FinishReasonCanceled marker on the next prompt")
475+}
476+
477+// TestE2E_CancelBetweenActiveSetAndAssistantCreate covers PLAN item 1d:
478+// a cancel that arrives after the run has begun but before it would
479+// create the assistant message must still produce a user message and a
480+// FinishReasonCanceled assistant message, never a silent return. The
481+// blockEntered gate parks the run after entry (modeling the window
482+// between activeRequests.Set and assistant creation).
483+func TestE2E_CancelBetweenActiveSetAndAssistantCreate(t *testing.T) {
484+ t.Parallel()
485+ h := newAgentE2EHarness(t)
486+ h.coord.blockEntered = make(chan struct{})
487+
488+ ctx, cancel := context.WithCancel(t.Context())
489+ t.Cleanup(cancel)
490+
491+ cid := uuid.New().String()
492+ evc, cancelSSE := h.subscribeSSE(t, ctx, h.workspace.ID, cid)
493+ t.Cleanup(cancelSSE)
494+ h.waitForAttached(t, 1)
495+
496+ const sid = "s-mid-window"
497+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
498+ h.waitForRunEntered(t)
499+
500+ // Cancel while parked at entry; then release so the run proceeds
501+ // into its cancel branch (runCtx already canceled).
502+ require.Equal(t, http.StatusOK, h.cancelAgentHTTP(t, ctx, sid))
503+ close(h.coord.blockEntered)
504+
505+ pickCtx, pickCancel := context.WithTimeout(ctx, 3*time.Second)
506+ defer pickCancel()
507+
508+ _, okUser := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
509+ return e.Payload.Role == proto.User && e.Payload.SessionID == sid
510+ })
511+ require.True(t, okUser, "a user message must be recorded for the canceled turn")
512+
513+ gotAsst, okAsst := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
514+ r, has := finishReason(e.Payload)
515+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonCanceled
516+ })
517+ require.True(t, okAsst, "the run must not return silently; it must emit a FinishReasonCanceled assistant message")
518+ require.Equal(t, sid, gotAsst.Payload.SessionID)
519+
520+ // No AgentEvent error is published: a cancel in the
521+ // activeRequests.Set -> assistant-create window is not a server
522+ // error.
523+ errCtx, errCancel := context.WithTimeout(ctx, 250*time.Millisecond)
524+ defer errCancel()
525+ _, gotErr := drainUntil(errCtx, evc, func(e pubsub.Event[proto.AgentEvent]) bool {
526+ return e.Payload.Type == proto.AgentEventTypeError && e.Payload.Error != nil
527+ })
528+ require.False(t, gotErr, "no AgentEvent error must be published for the canceled turn")
529+}
530+
531+// TestE2E_PromptRequestContextDoesNotOwnRun covers PLAN item 2: the
532+// prompting client's HTTP request context does not own the run. A POST
533+// with a very short request-context timeout still returns 202 before
534+// that context would expire, and the run keeps going (observed via SSE
535+// finishing normally after release).
536+func TestE2E_PromptRequestContextDoesNotOwnRun(t *testing.T) {
537+ t.Parallel()
538+ h := newAgentE2EHarness(t)
539+ streamCtx, streamCancel := context.WithCancel(t.Context())
540+ t.Cleanup(streamCancel)
541+
542+ cid := uuid.New().String()
543+ evc, cancelSSE := h.subscribeSSE(t, streamCtx, h.workspace.ID, cid)
544+ t.Cleanup(cancelSSE)
545+ h.waitForAttached(t, 1)
546+
547+ const sid = "s-short-req"
548+
549+ // The POST request context times out almost immediately. The
550+ // handler must still return 202 (fire-and-forget) and the run must
551+ // survive past the request-context deadline.
552+ reqCtx, reqCancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
553+ defer reqCancel()
554+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, reqCtx, sid))
555+ h.waitForRunEntered(t)
556+
557+ // Let the request context expire, then release the run.
558+ <-reqCtx.Done()
559+ close(h.coord.release)
560+
561+ pickCtx, pickCancel := context.WithTimeout(streamCtx, 3*time.Second)
562+ defer pickCancel()
563+ got, ok := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
564+ r, has := finishReason(e.Payload)
565+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonEndTurn
566+ })
567+ require.True(t, ok, "the run must finish normally even after the prompting request context expired")
568+ require.Equal(t, sid, got.Payload.SessionID)
569+}
570+
571+// TestE2E_AgentRunSurvivesAcrossWorkspaceClaims covers PLAN item 3: a
572+// run started by client A survives A detaching as long as another
573+// client (B) keeps the workspace alive; B observes the run finish via
574+// SSE.
575+func TestE2E_AgentRunSurvivesAcrossWorkspaceClaims(t *testing.T) {
576+ t.Parallel()
577+ h := newAgentE2EHarness(t)
578+
579+ ctxA, cancelA := context.WithCancel(t.Context())
580+ ctxB, cancelB := context.WithCancel(t.Context())
581+ t.Cleanup(cancelB)
582+
583+ cidA := uuid.New().String()
584+ cidB := uuid.New().String()
585+ _, killA := h.subscribeSSE(t, ctxA, h.workspace.ID, cidA)
586+ t.Cleanup(killA)
587+ evcB, killB := h.subscribeSSE(t, ctxB, h.workspace.ID, cidB)
588+ t.Cleanup(killB)
589+ h.waitForAttached(t, 2)
590+
591+ const sid = "s-survive"
592+ // A is the poster; the run must outlive A detaching as long as B
593+ // keeps the workspace alive.
594+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctxA, sid))
595+ h.waitForRunEntered(t)
596+
597+ // A detaches; B is still attached so the workspace stays alive.
598+ cancelA()
599+ killA()
600+ require.Eventually(t, func() bool {
601+ return backend.WorkspaceLiveStreamCountForTest(h.workspace) == 1
602+ }, 3*time.Second, 10*time.Millisecond,
603+ "A detaching must leave B as the sole attached client")
604+ require.False(t, h.shutdownHit.Load(), "workspace must stay alive while B is attached")
605+
606+ // Release the run; B must still observe it finish.
607+ close(h.coord.release)
608+ pickCtx, pickCancel := context.WithTimeout(ctxB, 3*time.Second)
609+ defer pickCancel()
610+ got, ok := drainUntil(pickCtx, evcB, func(e pubsub.Event[proto.Message]) bool {
611+ r, has := finishReason(e.Payload)
612+ return e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonEndTurn
613+ })
614+ require.True(t, ok, "B must observe the run finish after A detaches")
615+ require.Equal(t, sid, got.Payload.SessionID)
616+}
617+
618+// TestE2E_CancelOfActiveRunAlsoCancelsAcceptedFollowUp covers PLAN item
619+// 1c at the externally-observable level: while session sid has an active
620+// run, a second prompt for sid is accepted; a cancel for sid must cancel
621+// the active run and must not let the follow-up stream a normal
622+// FinishReasonEndTurn.
623+//
624+// The sequence follows the PLAN exactly: prompt 1 becomes the active
625+// run, prompt 2 for the same sid is accepted, then a cancel for sid
626+// fires, and only afterwards are any signals released. The scripted
627+// coordinator models the externally-observable contract of the
628+// busy-queue branch and pendingCancels (which depend on internal
629+// scheduler signals the codebase does not expose): a per-session cancel
630+// tears down every in-flight run for sid and arms a cancel-on-entry for
631+// a follow-up still dispatching. The invariant asserted is the one that
632+// matters: after the cancel, the active run ends canceled and the
633+// follow-up never streams a normal FinishReasonEndTurn.
634+func TestE2E_CancelOfActiveRunAlsoCancelsAcceptedFollowUp(t *testing.T) {
635+ t.Parallel()
636+ h := newAgentE2EHarness(t)
637+ ctx, cancel := context.WithCancel(t.Context())
638+ t.Cleanup(cancel)
639+
640+ cid := uuid.New().String()
641+ evc, cancelSSE := h.subscribeSSE(t, ctx, h.workspace.ID, cid)
642+ t.Cleanup(cancelSSE)
643+ h.waitForAttached(t, 1)
644+
645+ const sid = "s-followup"
646+
647+ // (a) Prompt 1 for sid becomes the active run. Capture its run id so
648+ // the canceled assistant message below can be attributed to run 1
649+ // unambiguously.
650+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
651+ run1 := h.waitForRunEntered(t)
652+
653+ // (b) Prompt 2 for the *same* sid is accepted while the active run
654+ // is still in flight; it is the follow-up the PLAN describes
655+ // (acceptedRuns > 0, either still dispatching or about to enter the
656+ // busy-queue branch).
657+ require.Equal(t, http.StatusAccepted, h.postAgentHTTP(t, ctx, sid))
658+ run2 := h.waitForRunEntered(t)
659+ require.NotEqual(t, run1, run2, "the follow-up must be a distinct run from the active one")
660+
661+ // (c) B cancels sid. This tears down every in-flight run for the
662+ // session and arms a pending cancel for any follow-up that has not
663+ // yet entered Run.
664+ require.Equal(t, http.StatusOK, h.cancelAgentHTTP(t, ctx, sid))
665+
666+ // (d) Open the coordinator gate so any run that is NOT canceled would
667+ // be free to proceed straight into the normal FinishReasonEndTurn
668+ // branch. The scripted Run checks runCtx.Done() before the release
669+ // select, so a canceled run still takes the canceled path even with
670+ // release closed; only a non-canceled run reaches FinishReasonEndTurn.
671+ // Releasing here is therefore what makes the assertions below
672+ // meaningful: if the cancel had failed to tear down run 1 or arm the
673+ // cancel-on-entry for the follow-up, the freed gate would let that run
674+ // stream a normal FinishReasonEndTurn and the test would fail.
675+ close(h.coord.release)
676+
677+ pickCtx, pickCancel := context.WithTimeout(ctx, 3*time.Second)
678+ defer pickCancel()
679+
680+ // (e) Run 1 (the active run) must end with FinishReasonCanceled. The
681+ // assistant message id is qualified with the run id, so matching on
682+ // run1's id proves the cancellation is attributed to the FIRST run
683+ // and not to the follow-up.
684+ //
685+ // The single drain below is also the negative assertion for run 2:
686+ // the match closure inspects every assistant event for sid as it
687+ // scans, and if it ever observes the follow-up (run 2) streaming a
688+ // normal FinishReasonEndTurn it records that violation immediately.
689+ // This is what makes the run-2 check sound: a previous two-phase
690+ // approach could let this very drain consume and discard a run-2
691+ // EndTurn while still hunting for run 1's canceled message, leaving a
692+ // later no-EndTurn check unable to prove run 2 stayed canceled.
693+ // Folding the negative check into the same scan means a run-2 EndTurn
694+ // can never slip past unobserved, whether it arrives before or after
695+ // run 1's canceled message.
696+ run1AsstID := fmt.Sprintf("a-%s-%d", sid, run1)
697+ run2AsstID := fmt.Sprintf("a-%s-%d", sid, run2)
698+ var followUpEndTurn bool
699+ got, ok := drainUntil(pickCtx, evc, func(e pubsub.Event[proto.Message]) bool {
700+ if e.Payload.SessionID != sid || e.Payload.Role != proto.Assistant {
701+ return false
702+ }
703+ r, has := finishReason(e.Payload)
704+ if !has {
705+ return false
706+ }
707+ // Any normal model output for sid after the cancel is a
708+ // violation. The follow-up (run 2) must never reach the
709+ // FinishReasonEndTurn branch; flag it the moment it is seen so
710+ // the assertion below fails even if this event arrives while we
711+ // are still waiting for run 1's canceled message.
712+ if r == proto.FinishReasonEndTurn {
713+ if e.Payload.ID == run2AsstID || e.Payload.ID != run1AsstID {
714+ followUpEndTurn = true
715+ }
716+ // Stop draining; the EndTurn observation is decisive and the
717+ // require.False below will surface the failure.
718+ return true
719+ }
720+ return e.Payload.ID == run1AsstID && r == proto.FinishReasonCanceled
721+ })
722+ require.False(t, followUpEndTurn, "the accepted follow-up must not stream a normal FinishReasonEndTurn after the cancel")
723+ require.True(t, ok, "the first (active) run must end with FinishReasonCanceled")
724+ require.Equal(t, run1AsstID, got.Payload.ID, "the canceled message must belong to the first (active) run")
725+ gotReason, gotHas := finishReason(got.Payload)
726+ require.True(t, gotHas)
727+ require.Equal(t, proto.FinishReasonCanceled, gotReason, "the matched run-1 message must be canceled, not a normal end turn")
728+ require.Equal(t, sid, got.Payload.SessionID)
729+
730+ // Confirm no normal FinishReasonEndTurn for sid is still in flight.
731+ // By this point the scan above has already ruled out a run-2 EndTurn
732+ // arriving before run 1's canceled message; this guards against one
733+ // arriving afterward.
734+ endCtx, endCancel := context.WithTimeout(ctx, 300*time.Millisecond)
735+ defer endCancel()
736+ _, gotEnd := drainUntil(endCtx, evc, func(e pubsub.Event[proto.Message]) bool {
737+ r, has := finishReason(e.Payload)
738+ return e.Payload.SessionID == sid && e.Payload.Role == proto.Assistant && has && r == proto.FinishReasonEndTurn
739+ })
740+ require.False(t, gotEnd, "the accepted follow-up must not stream model output after the cancel")
741+}
internal/server/e2e_test.go+12
@@ -240,6 +240,18 @@ func decodeSSEEnvelope(p pubsub.Payload) (any, bool) {
240240 return nil, false
241241 }
242242 return e, true
243+ case pubsub.PayloadTypeAgentEvent:
244+ var e pubsub.Event[proto.AgentEvent]
245+ if err := json.Unmarshal(p.Payload, &e); err != nil {
246+ return nil, false
247+ }
248+ return e, true
249+ case pubsub.PayloadTypeRunComplete:
250+ var e pubsub.Event[proto.RunComplete]
251+ if err := json.Unmarshal(p.Payload, &e); err != nil {
252+ return nil, false
253+ }
254+ return e, true
243255 }
244256 return nil, false
245257}
internal/server/events.go+13−6
@@ -2,6 +2,7 @@ package server
22
33import (
44 "encoding/json"
5+ "errors"
56 "fmt"
67 "log/slog"
78
@@ -85,13 +86,19 @@ func wrapEvent(ev any) *pubsub.Payload {
8586 Payload: fileToProto(e.Payload),
8687 })
8788 case pubsub.Event[notify.Notification]:
89+ payload := proto.AgentEvent{
90+ SessionID: e.Payload.SessionID,
91+ SessionTitle: e.Payload.SessionTitle,
92+ RunID: e.Payload.RunID,
93+ Type: proto.AgentEventType(e.Payload.Type),
94+ }
95+ if e.Payload.Type == notify.TypeAgentError {
96+ payload.Type = proto.AgentEventTypeError
97+ payload.Error = errors.New(e.Payload.Message)
98+ }
8899 return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
89− Type: e.Type,
90− Payload: proto.AgentEvent{
91− SessionID: e.Payload.SessionID,
92− SessionTitle: e.Payload.SessionTitle,
93− Type: proto.AgentEventType(e.Payload.Type),
94− },
100+ Type: e.Type,
101+ Payload: payload,
95102 })
96103 case pubsub.Event[notify.RunComplete]:
97104 return envelope(pubsub.PayloadTypeRunComplete, pubsub.Event[proto.RunComplete]{
internal/server/events_test.go+32
@@ -123,6 +123,38 @@ func TestRunCompleteToProto_RoundTrip(t *testing.T) {
123123 require.False(t, decoded.Payload.Cancelled)
124124}
125125
126+// TestAgentErrorToProto_PreservesRunID verifies that an async agent
127+// error notification carries its originating RunID (and SessionID)
128+// through the SSE envelope. Without these correlators, `crush run`
129+// cannot tell whether an error event belongs to its own run and
130+// would abort on any unrelated workspace failure.
131+func TestAgentErrorToProto_PreservesRunID(t *testing.T) {
132+ t.Parallel()
133+
134+ src := pubsub.Event[notify.Notification]{
135+ Type: pubsub.CreatedEvent,
136+ Payload: notify.Notification{
137+ SessionID: "S",
138+ RunID: "run-99",
139+ Type: notify.TypeAgentError,
140+ Message: "boom",
141+ },
142+ }
143+
144+ env := wrapEvent(src)
145+ require.NotNil(t, env)
146+ require.Equal(t, pubsub.PayloadTypeAgentEvent, env.Type)
147+
148+ var decoded pubsub.Event[proto.AgentEvent]
149+ require.NoError(t, json.Unmarshal(env.Payload, &decoded))
150+ require.Equal(t, proto.AgentEventTypeError, decoded.Payload.Type)
151+ require.Equal(t, "S", decoded.Payload.SessionID)
152+ require.Equal(t, "run-99", decoded.Payload.RunID,
153+ "RunID must survive so observers can attribute the error to its run")
154+ require.NotNil(t, decoded.Payload.Error)
155+ require.Equal(t, "boom", decoded.Payload.Error.Error())
156+}
157+
126158// TestRunCompleteToProto_Error verifies that error- and cancel-shaped
127159// RunComplete events round-trip cleanly so clients can distinguish
128160// "agent failed" (returns non-zero from `crush run`) from "agent
internal/server/proto.go+22−19
@@ -1,7 +1,6 @@
11package server
22
33import (
4− "context"
54 "encoding/json"
65 "errors"
76 "fmt"
@@ -740,9 +739,10 @@ func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Re
740739// @Accept json
741740// @Param id path string true "Workspace ID"
742741// @Param request body proto.AgentMessage true "Agent message"
743−// @Success 200
742+// @Success 202
744743// @Failure 400 {object} proto.Error
745744// @Failure 404 {object} proto.Error
745+// @Failure 409 {object} proto.Error
746746// @Failure 500 {object} proto.Error
747747// @Router /workspaces/{id}/agent [post]
748748func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
@@ -755,18 +755,19 @@ 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 }
769− w.WriteHeader(http.StatusOK)
770+ w.WriteHeader(http.StatusAccepted)
770771}
771772
772773// handlePostWorkspaceAgentInit initializes the agent for a workspace.
@@ -1033,15 +1034,15 @@ func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter,
10331034
10341035// handleError maps backend errors to HTTP status codes and writes the
10351036// JSON error response.
1037+//
1038+// Runtime cancellation of an agent run no longer reaches here for the
1039+// agent-prompt path: SendMessage is fire-and-forget (the handler returns
1040+// 202 before the run starts) and Backend.runAgent swallows
1041+// context.Canceled, surfacing the FinishReasonCanceled marker to SSE
1042+// subscribers instead. The remaining callers pass synchronous backend
1043+// errors, so context.Canceled gets no special case and would fall through
1044+// to the default 500 like any other unexpected error.
10361045func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
1037− // A canceled agent run is not an error from the prompting
1038− // client's perspective. The cancellation reaches every SSE
1039− // subscriber via the FinishReasonCanceled marker on the assistant
1040− // message; the still-open POST should not surface a 500.
1041− if errors.Is(err, context.Canceled) {
1042− w.WriteHeader(http.StatusOK)
1043− return
1044− }
10451046 status := http.StatusInternalServerError
10461047 switch {
10471048 case errors.Is(err, backend.ErrWorkspaceNotFound):
@@ -1060,6 +1061,8 @@ func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err e
10601061 status = http.StatusBadRequest
10611062 case errors.Is(err, backend.ErrClientNotAttached):
10621063 status = http.StatusNotFound
1064+ case errors.Is(err, backend.ErrWorkspaceClosing):
1065+ status = http.StatusConflict
10631066 }
10641067 c.server.logError(r, err.Error())
10651068 jsonError(w, status, err.Error())
internal/server/sessions_isbusy_test.go+8
@@ -30,6 +30,14 @@ type stubCoordinator struct {
3030func (s *stubCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
3131 return nil, nil
3232}
33+
34+func (s *stubCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
35+ return nil, nil
36+}
37+
38+func (s *stubCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun {
39+ return nil
40+}
3341func (s *stubCoordinator) Cancel(string) {}
3442func (s *stubCoordinator) CancelAll() {}
3543func (s *stubCoordinator) IsBusy() bool { return false }
internal/ui/model/ui.go+4−4
@@ -3285,12 +3285,12 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
32853285 // Capture session ID to avoid race with main goroutine updating m.session.
32863286 sessionID := m.session.ID
32873287 cmds = append(cmds, func() tea.Msg {
3288+ // AgentRun is fire-and-forget: it returns once the prompt has
3289+ // been accepted (HTTP 202) or synchronously with a validation
3290+ // or transport error. Run failures and cancellation surface
3291+ // through SSE-derived events, not this return value.
32883292 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
32893293 if err != nil {
3290− isCancelErr := errors.Is(err, context.Canceled)
3291− if isCancelErr {
3292− return nil
3293− }
32943294 return util.InfoMsg{
32953295 Type: util.InfoTypeError,
32963296 Msg: fmt.Sprintf("%v", err),
internal/workspace/client_workspace.go+11−6
@@ -704,13 +704,18 @@ func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
704704 Payload: protoToFile(e.Payload),
705705 }
706706 case pubsub.Event[proto.AgentEvent]:
707+ n := notify.Notification{
708+ SessionID: e.Payload.SessionID,
709+ SessionTitle: e.Payload.SessionTitle,
710+ RunID: e.Payload.RunID,
711+ Type: notify.Type(e.Payload.Type),
712+ }
713+ if e.Payload.Error != nil {
714+ n.Message = e.Payload.Error.Error()
715+ }
707716 return pubsub.Event[notify.Notification]{
708− Type: e.Type,
709− Payload: notify.Notification{
710− SessionID: e.Payload.SessionID,
711− SessionTitle: e.Payload.SessionTitle,
712− Type: notify.Type(e.Payload.Type),
713− },
717+ Type: e.Type,
718+ Payload: n,
714719 }
715720 case pubsub.Event[proto.RunComplete]:
716721 // Translate the wire-level proto.RunComplete back into the