@@ -21,6 +21,7 @@ import (
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/catwalk/pkg/catwalk"
@@ -103,10 +104,19 @@ type SessionAgentCall struct {
103 // recursion drains, so falling back to the default broker
104 // recursion drains, so falling back to the default broker
104 // publish keeps the event visible to subscribers.
105 // publish keeps the event visible to subscribers.
105 OnComplete func(notify.RunComplete)
106 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
108type SessionAgent interface {
117type SessionAgent interface {
109 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
118 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
119+ BeginAccepted(sessionID string) *AcceptedRun
110 SetModels(large Model, small Model)
120 SetModels(large Model, small Model)
111 SetTools(tools []fantasy.AgentTool)
121 SetTools(tools []fantasy.AgentTool)
112 SetSystemPrompt(systemPrompt string)
122 SetSystemPrompt(systemPrompt string)
@@ -145,6 +155,32 @@ type sessionAgent struct {
146 messageQueue *csync.Map[string, []SessionAgentCall]
156 messageQueue *csync.Map[string, []SessionAgentCall]
147 activeRequests *csync.Map[string, context.CancelFunc]
157 activeRequests *csync.Map[string, context.CancelFunc]
159+ // dispatchMu holds a per-session mutex that serializes the
160+ // accepted -> (cancel-on-entry | queued | active) transition in
161+ // Run against a concurrent Cancel. The lock is held only during
162+ // the brief handoff (no DB or LLM I/O under the lock).
163+ dispatchMu *csync.Map[string, *sync.Mutex]
164+ // acceptedRuns counts dispatched-but-not-yet-active runs per
165+ // session. A counter > 0 means a dispatched prompt is in flight
166+ // and has not yet completed the dispatch handoff in Run. Only
167+ // BeginAccepted increments it; only AcceptedRun.Close decrements
169+ acceptedRuns *csync.Map[string, int]
170+ // pendingCancels records sessions whose dispatched-but-not-yet-
171+ // running call should observe a cancellation request. It is only
172+ // set by Cancel when acceptedRuns > 0, so an idle Escape never
173+ // poisons the next prompt.
174+ pendingCancels *csync.Map[string, struct{}]
175+ // dispatchMuCreate guards lazy creation of per-session entries in
176+ // dispatchMu so two goroutines can't race to lock different mutex
177+ // instances for the same session.
178+ dispatchMuCreate sync.Mutex
179+ // acceptedMu serializes increments/decrements of acceptedRuns. It
180+ // is separate from dispatchMu so AcceptedRun.Close (which may run
181+ // while Run holds dispatchMu for the same session) does not
182+ // deadlock by re-entering the dispatch lock.
183+ acceptedMu sync.Mutex
150type SessionAgentOptions struct {
186type SessionAgentOptions struct {
@@ -180,7 +216,144 @@ func NewSessionAgent(
180 runComplete: opts.RunComplete,
216 runComplete: opts.RunComplete,
181 messageQueue: csync.NewMap[string, []SessionAgentCall](),
217 messageQueue: csync.NewMap[string, []SessionAgentCall](),
182 activeRequests: csync.NewMap[string, context.CancelFunc](),
218 activeRequests: csync.NewMap[string, context.CancelFunc](),
219+ dispatchMu: csync.NewMap[string, *sync.Mutex](),
220+ acceptedRuns: csync.NewMap[string, int](),
221+ pendingCancels: csync.NewMap[string, struct{}](),
225+// AcceptedRun owns exactly one accept reservation taken by
226+// BeginAccepted. It is the only carrier of accept-state across the
227+// backend.runAgent / Coordinator.Run / sessionAgent.Run layers: a
228+// counter > 0 means a dispatched prompt is in flight and has not yet
229+// completed the dispatch handoff in Run. Close is the only way to
230+// release the reservation and is idempotent.
231+type AcceptedRun struct {
237+// Close decrements the accept counter for this reservation. It is safe
238+// to call multiple times; only the first call has effect.
239+func (r *AcceptedRun) Close() {
243+ if !r.done.CompareAndSwap(false, true) {
246+ r.agent.endAccepted(r.sessionID)
249+// SessionID exposes the session this reservation is for so the run path
250+// can use it without an extra parameter.
251+func (r *AcceptedRun) SessionID() string {
258+// BeginAccepted increments the accept counter for sessionID and returns
259+// a handle whose Close is the only way to decrement it. It is the only
260+// entry point that mutates acceptedRuns.
261+func (a *sessionAgent) BeginAccepted(sessionID string) *AcceptedRun {
263+ defer a.acceptedMu.Unlock()
264+ count, _ := a.acceptedRuns.Get(sessionID)
265+ a.acceptedRuns.Set(sessionID, count+1)
266+ return &AcceptedRun{agent: a, sessionID: sessionID}
269+// endAccepted decrements the accept counter for sessionID. It is only
270+// called via AcceptedRun.Close. It uses a dedicated lock (not the
271+// per-session dispatch mutex) so it can run while Run holds dispatchMu
272+// for the same session without deadlocking.
273+func (a *sessionAgent) endAccepted(sessionID string) {
275+ defer a.acceptedMu.Unlock()
276+ count, ok := a.acceptedRuns.Get(sessionID)
277+ if !ok || count <= 1 {
278+ a.acceptedRuns.Del(sessionID)
281+ a.acceptedRuns.Set(sessionID, count-1)
284+// sessionMu returns the per-session dispatch mutex, creating it on first
285+// use. Creation is guarded so concurrent callers always observe the same
286+// mutex instance for a given session.
287+func (a *sessionAgent) sessionMu(sessionID string) *sync.Mutex {
288+ if mu, ok := a.dispatchMu.Get(sessionID); ok {
291+ a.dispatchMuCreate.Lock()
292+ defer a.dispatchMuCreate.Unlock()
293+ if mu, ok := a.dispatchMu.Get(sessionID); ok {
297+ a.dispatchMu.Set(sessionID, mu)
301+// enqueueCall appends call to the session's message queue. The
302+// OnComplete hook is stripped: the caller that supplied it (typically
303+// coordinator.Run) has its own retry/coalesce scope that ends when it
304+// returns, so by the time the queue drains nobody is left to consume the
305+// buffered terminal event. The recursive Run falls back to the default
306+// broker publish, which is what existing subscribers expect for queued
308+func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
309+ existing, ok := a.messageQueue.Get(call.SessionID)
311+ existing = []SessionAgentCall{}
314+ queued.OnComplete = nil
315+ queued.Accepted = nil
316+ existing = append(existing, queued)
317+ a.messageQueue.Set(call.SessionID, existing)
320+// clearPendingCancel removes any pending-cancel record for sessionID. It
321+// takes the per-session dispatch lock so it is ordered against Cancel and
322+// the dispatch handoff.
323+func (a *sessionAgent) clearPendingCancel(sessionID string) {
324+ mu := a.sessionMu(sessionID)
327+ a.pendingCancels.Del(sessionID)
330+// persistCanceledTurn writes the user/assistant records for a turn that
331+// was canceled before (or just as) streaming would have produced them.
332+// It creates the user message only when it was not already created by an
333+// earlier createUserMessage call (userMsgCreated), then writes an
334+// assistant message with FinishReasonCanceled. Both writes use
335+// context.WithoutCancel(ctx) so workspace shutdown (which cancels the run
336+// context) can't drop them.
337+func (a *sessionAgent) persistCanceledTurn(ctx context.Context, call SessionAgentCall, userMsgCreated bool) error {
338+ writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
340+ if !userMsgCreated {
341+ if _, err := a.createUserMessage(writeCtx, call); err != nil {
345+ largeModel := a.largeModel.Get()
346+ assistant, err := a.messages.Create(writeCtx, call.SessionID, message.CreateMessageParams{
347+ Role: message.Assistant,
348+ Parts: []message.ContentPart{},
349+ Model: largeModel.ModelCfg.Model,
350+ Provider: largeModel.ModelCfg.Provider,
355+ assistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
356+ return a.messages.Update(writeCtx, assistant)
186// ValidateCall performs the cheap structural validation that
359// ValidateCall performs the cheap structural validation that
@@ -204,22 +377,73 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
204 return nil, err
377 return nil, err
207− // Queue the message if busy. Strip OnComplete: the caller that
380+ // genCtx/cancel are the run context and its cancel func. For the
208− // supplied the hook (typically coordinator.Run) has its own
381+ // accepted (fire-and-forget) dispatch path they are created under
209− // retry/coalesce scope that ends when it returns, so by the time
382+ // dispatchMu below so a concurrent Cancel can observe the
210− // the queue drains nobody is left to consume the buffered
383+ // activeRequests entry before the assistant message exists. For
211− // terminal event. The recursive Run will fall back to the
384+ // the in-process path they stay nil here and are created later,
212− // default broker publish, which is what existing subscribers
385+ // preserving the original ordering.
213− // expect for queued turns.
386+ var (
214− if a.IsSessionBusy(call.SessionID) {
387+ genCtx context.Context
215− existing, ok := a.messageQueue.Get(call.SessionID)
388+ cancel context.CancelFunc
216− if !ok {
389+ activeRegistered bool
217− existing = []SessionAgentCall{}
390+ userMsgCreated bool
393+ if call.Accepted != nil {
394+ // Serialize the accepted -> (cancel-on-entry | queued |
395+ // active) transition against a concurrent Cancel. Cancel takes
396+ // the same per-session lock, so every cancel observes at least
397+ // one of: pendingCancels, an activeRequests entry, or a
398+ // messageQueue entry it then clears.
399+ mu := a.sessionMu(call.SessionID)
402+ if _, pending := a.pendingCancels.Get(call.SessionID); pending {
403+ // Cancel-on-entry: a cancel arrived while this run was
404+ // dispatched but not yet active. Consume the pending
405+ // cancel, release the accept reservation, drop the lock,
406+ // and persist a canceled turn without entering Stream.
407+ a.pendingCancels.Del(call.SessionID)
408+ call.Accepted.Close()
410+ if err := a.persistCanceledTurn(ctx, call, false); err != nil {
416+ if a.IsSessionBusy(call.SessionID) {
417+ // Busy: an earlier prompt is active. Queue this call and
418+ // release the accept reservation. A Cancel arriving after
419+ // this point sees the active entry and clears the queue.
421+ call.Accepted.Close()
220− queued.OnComplete = nil
426+ // Idle: become the active run. Register the cancel func before
221− existing = append(existing, queued)
427+ // dropping the lock so a Cancel that arrives between here and
222− a.messageQueue.Set(call.SessionID, existing)
428+ // assistant creation is not lost.
429+ runCtx := context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
430+ genCtx, cancel = context.WithCancel(runCtx)
431+ a.activeRequests.Set(call.SessionID, cancel)
432+ activeRegistered = true
433+ call.Accepted.Close()
437+ defer a.activeRequests.Del(call.SessionID)
438+ } else if a.IsSessionBusy(call.SessionID) {
439+ // Queue the message if busy. Strip OnComplete: the caller that
440+ // supplied the hook (typically coordinator.Run) has its own
441+ // retry/coalesce scope that ends when it returns, so by the time
442+ // the queue drains nobody is left to consume the buffered
443+ // terminal event. The recursive Run will fall back to the
444+ // default broker publish, which is what existing subscribers
445+ // expect for queued turns.
223 return nil, nil
447 return nil, nil
@@ -282,15 +506,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
282 if err != nil {
506 if err != nil {
283 return nil, err
507 return nil, err
509+ userMsgCreated = true
286 // Add the session to the context.
511 // Add the session to the context.
287 ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
512 ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
514+ // For the accepted dispatch path the run context and cancel func
515+ // were already created and registered under dispatchMu above; reuse
516+ // them. For the in-process path create them here, preserving the
517+ // original ordering.
518+ if !activeRegistered {
289− genCtx, cancel := context.WithCancel(ctx)
519+ genCtx, cancel = context.WithCancel(ctx)
290− a.activeRequests.Set(call.SessionID, cancel)
520+ a.activeRequests.Set(call.SessionID, cancel)
292− defer cancel()
522+ defer cancel()
293− defer a.activeRequests.Del(call.SessionID)
523+ defer a.activeRequests.Del(call.SessionID)
294 // skipRunComplete is set just before the queued-recursion path so
525 // skipRunComplete is set just before the queued-recursion path so
295 // the outer Run doesn't publish a RunComplete that would race
526 // the outer Run doesn't publish a RunComplete that would race
296 // with — and be superseded by — the recursive call's own
527 // with — and be superseded by — the recursive call's own
@@ -390,14 +621,24 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
390 // Use latest tools (updated by SetTools when MCP tools change).
621 // Use latest tools (updated by SetTools when MCP tools change).
391 prepared.Tools = a.tools.Copy()
622 prepared.Tools = a.tools.Copy()
624+ // Drain queued follow-up prompts, but skip them if a cancel
625+ // was recorded for the session while they sat in the queue:
626+ // a cancel that arrived after the queue insertion must not
627+ // let the queued prompt run as part of this step.
628+ dispatchLock := a.sessionMu(call.SessionID)
630+ _, canceled := a.pendingCancels.Get(call.SessionID)
393 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
631 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
394 a.messageQueue.Del(call.SessionID)
632 a.messageQueue.Del(call.SessionID)
633+ dispatchLock.Unlock()
395− for _, queued := range queuedCalls {
635+ for _, queued := range queuedCalls {
396− userMessage, createErr := a.createUserMessage(callContext, queued)
636+ userMessage, createErr := a.createUserMessage(callContext, queued)
397− if createErr != nil {
637+ if createErr != nil {
398− return callContext, prepared, createErr
638+ return callContext, prepared, createErr
640+ prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
400− prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
403 prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
644 prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
@@ -594,6 +835,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
594 isHyper := largeModel.ModelCfg.Provider == hyper.Name
835 isHyper := largeModel.ModelCfg.Provider == hyper.Name
595 isCancelErr := errors.Is(err, context.Canceled)
836 isCancelErr := errors.Is(err, context.Canceled)
596 if currentAssistant == nil {
837 if currentAssistant == nil {
838+ // Cancel-before-assistant-creation window: the run was
839+ // canceled after activeRequests.Set but before PrepareStep
840+ // created the assistant message. Without this, the turn
841+ // would return with no FinishReasonCanceled marker and no
842+ // user-visible record. The user message was already created
843+ // above, so persistCanceledTurn only writes the assistant
846+ if persistErr := a.persistCanceledTurn(ctx, call, userMsgCreated); persistErr != nil {
847+ return nil, persistErr
597 return result, err
850 return result, err
599 // Persist final state with a context detached from the run
852 // Persist final state with a context detached from the run
@@ -741,8 +994,35 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
997+ // Hand off to the next queued prompt (if any) under dispatchMu so
998+ // the transition from this finished run to the queued run is atomic
999+ // against a concurrent Cancel. activeRequests for this session was
1000+ // just deleted above, so without the lock there is a window in
1001+ // which the session looks idle and a cancel becomes a no-op that
1002+ // fails to stop the queued prompt. Holding the lock lets us observe
1003+ // a pending cancel recorded against the session and drop the queue
1004+ // instead of running it, and (for the recursion) hand a fresh
1005+ // accept reservation to the dequeued call so acceptedRuns stays > 0
1006+ // across the recursive Run's own dispatch handoff — keeping the
1007+ // session observable to Cancel for the entire transition and
1008+ // closing the dequeue -> re-register window.
1009+ mu := a.sessionMu(call.SessionID)
1011+ if _, pending := a.pendingCancels.Get(call.SessionID); pending {
1012+ // A cancel was recorded for this session (e.g. it arrived while
1013+ // this run was active and a follow-up had been accepted). Drop
1014+ // the queue instead of running it and consume the marker.
1015+ a.pendingCancels.Del(call.SessionID)
1016+ a.messageQueue.Del(call.SessionID)
744 queuedMessages, ok := a.messageQueue.Get(call.SessionID)
1020 queuedMessages, ok := a.messageQueue.Get(call.SessionID)
745 if !ok || len(queuedMessages) == 0 {
1021 if !ok || len(queuedMessages) == 0 {
1022+ // No queued work. Clear any stale pending-cancel entry as a
1023+ // safety net so it can't catch a future run (no-op when unset).
1024+ a.pendingCancels.Del(call.SessionID)
746 return result, err
1026 return result, err
748 // There are queued messages restart the loop. The recursive Run
1028 // There are queued messages restart the loop. The recursive Run
@@ -753,6 +1033,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
753 skipRunComplete = true
1033 skipRunComplete = true
754 firstQueuedMessage := queuedMessages[0]
1034 firstQueuedMessage := queuedMessages[0]
755 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
1035 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
1036+ // Reserve a fresh accept for the dequeued prompt before dropping the
1037+ // lock so acceptedRuns > 0 across the handoff into the recursive
1038+ // Run. This closes the window between this dequeue and the recursive
1039+ // Run registering its activeRequests entry: a cancel arriving in
1040+ // that window now records a pending cancel (acceptedRuns > 0) that
1041+ // the recursive Run's accepted path observes as cancel-on-entry.
1042+ firstQueuedMessage.Accepted = a.BeginAccepted(call.SessionID)
756 return a.Run(ctx, firstQueuedMessage)
1044 return a.Run(ctx, firstQueuedMessage)
@@ -1305,6 +1593,16 @@ func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message
1307func (a *sessionAgent) Cancel(sessionID string) {
1595func (a *sessionAgent) Cancel(sessionID string) {
1596+ // Serialize against the dispatch handoff in Run so the accepted ->
1597+ // (cancel-on-entry | queued | active) transition is atomic against
1598+ // this cancel. Every cancel observes at least one of: an active
1599+ // request, an accepted run (recorded as a pending cancel), or a
1600+ // queue entry it then clears. If none of those hold, an idle Escape
1601+ // is a true no-op and must not poison the next prompt.
1602+ mu := a.sessionMu(sessionID)
1308 // Cancel regular requests. Don't use Take() here - we need the entry to
1606 // Cancel regular requests. Don't use Take() here - we need the entry to
1309 // remain in activeRequests so IsBusy() returns true until the goroutine
1607 // remain in activeRequests so IsBusy() returns true until the goroutine
1310 // fully completes (including error handling that may access the DB).
1608 // fully completes (including error handling that may access the DB).
@@ -1320,6 +1618,20 @@ func (a *sessionAgent) Cancel(sessionID string) {
1320 cancel()
1618 cancel()
1621+ // Record a pending cancel only when a dispatched-but-not-yet-active
1622+ // run exists. This catches a run still in the goroutine scheduler or
1623+ // about to enter Run's busy-queue branch, while leaving an idle
1624+ // session untouched. Active and accepted are not mutually exclusive:
1625+ // when a run is active and a follow-up has been accepted, both the
1626+ // cancel above and this pending record fire.
1627+ a.acceptedMu.Lock()
1628+ count, ok := a.acceptedRuns.Get(sessionID)
1629+ a.acceptedMu.Unlock()
1630+ if ok && count > 0 {
1631+ slog.Debug("Recording pending cancel for accepted run", "session_id", sessionID)
1632+ a.pendingCancels.Set(sessionID, struct{}{})
1323 if a.QueuedPrompts(sessionID) > 0 {
1635 if a.QueuedPrompts(sessionID) > 0 {
1324 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1636 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1325 a.messageQueue.Del(sessionID)
1637 a.messageQueue.Del(sessionID)