@@ -359,28 +359,96 @@ func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
359 a.messageQueue.Set(call.SessionID, existing)
359 a.messageQueue.Set(call.SessionID, existing)
362−// drainUncanceledQueue copies and clears the session's message queue and
362+// drainQueueForStep partitions the session's queued calls for the current
363−// returns the queued calls not covered by a pending cancel. The queue
364−// copy/delete and the cancel-mark check happen under the per-session
365−// dispatch mutex so the filtering is atomic against a concurrent Cancel:
363+// streaming step under the per-session dispatch mutex so the filtering is
366−// canceledBySeq requires the caller to hold that mutex, and evaluating it
364+// atomic against a concurrent Cancel: canceledBySeq requires the caller to
365+// hold that mutex, and evaluating it here (rather than after unlocking)
367−// here (rather than after unlocking) prevents a cancel recorded between
366+// prevents a cancel recorded between the drain and the check from being
368−// the drain and the check from being observed inconsistently. The
367+// observed inconsistently.
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
369−// returned survivors are processed by the caller without the lock held.
378+// absorbed into another turn. fold is processed by the caller without the
370−func (a *sessionAgent) drainUncanceledQueue(sessionID string) []SessionAgentCall {
380+func (a *sessionAgent) drainQueueForStep(sessionID string) (fold, canceledWithRunID []SessionAgentCall) {
371 dispatchLock := a.sessionMu(sessionID)
381 dispatchLock := a.sessionMu(sessionID)
372 dispatchLock.Lock()
382 dispatchLock.Lock()
373 defer dispatchLock.Unlock()
383 defer dispatchLock.Unlock()
374 queuedCalls, _ := a.messageQueue.Get(sessionID)
384 queuedCalls, _ := a.messageQueue.Get(sessionID)
375− a.messageQueue.Del(sessionID)
385+ var keep []SessionAgentCall
376− survivors := queuedCalls[:0]
377 for _, queued := range queuedCalls {
386 for _, queued := range queuedCalls {
378 if a.canceledBySeq(sessionID, queued.acceptSeq) {
387 if a.canceledBySeq(sessionID, queued.acceptSeq) {
388+ if queued.RunID != "" {
389+ canceledWithRunID = append(canceledWithRunID, queued)
393+ if queued.RunID != "" {
394+ keep = append(keep, queued)
397+ fold = append(fold, queued)
400+ a.messageQueue.Del(sessionID)
402+ a.messageQueue.Set(sessionID, keep)
404+ return fold, canceledWithRunID
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) {
418+ for _, d := range drops {
427+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
429+ for _, d := range drops {
381− survivors = append(survivors, queued)
433+ a.publishRunComplete(ctx, d, notify.RunComplete{
434+ SessionID: d.SessionID,
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)
383− return survivors
449+ return
451+ a.publishCanceledQueueDrops(queued)
386// clearPendingCancel removes any pending-cancel mark for sessionID. It
454// clearPendingCancel removes any pending-cancel mark for sessionID. It
@@ -735,14 +803,20 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
735 // Use latest tools (updated by SetTools when MCP tools change).
803 // Use latest tools (updated by SetTools when MCP tools change).
736 prepared.Tools = a.tools.Copy()
804 prepared.Tools = a.tools.Copy()
738− // Drain queued follow-up prompts, but skip any covered by a
806+ // Drain queued follow-up prompts for this step. Calls covered
739− // cancel recorded while they sat in the queue: a cancel that
807+ // by a cancel recorded while they sat in the queue are dropped:
740− // arrived after a prompt was queued must not let it run as
808+ // a cancel that arrived after a prompt was queued must not let
741− // part of this step. Coverage is per-call by accept sequence
809+ // it run as part of this step. Coverage is per-call by accept
742− // so a follow-up queued after the cancel (higher seq) is
810+ // sequence so a follow-up queued after the cancel (higher seq)
743− // still folded in.
811+ // is not dropped. A dropped prompt carrying a RunID still gets
744− survivors := a.drainUncanceledQueue(call.SessionID)
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)
745− for _, queued := range survivors {
819+ for _, queued := range fold {
746 userMessage, createErr := a.createUserMessage(callContext, queued)
820 userMessage, createErr := a.createUserMessage(callContext, queued)
747 if createErr != nil {
821 if createErr != nil {
748 return callContext, prepared, createErr
822 return callContext, prepared, createErr
@@ -1125,14 +1199,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
1125 // mark, or untracked); keep any queued after the cancel (higher
1199 // mark, or untracked); keep any queued after the cancel (higher
1126 // sequence) so they still run.
1200 // sequence) so they still run.
1127 var kept []SessionAgentCall
1201 var kept []SessionAgentCall
1202+ var canceledRunIDDrops []SessionAgentCall
1128 for _, q := range queuedMessages {
1203 for _, q := range queuedMessages {
1129 if q.acceptSeq == 0 || q.acceptSeq <= mark {
1204 if q.acceptSeq == 0 || q.acceptSeq <= mark {
1206+ canceledRunIDDrops = append(canceledRunIDDrops, q)
1130 continue
1208 continue
1132 kept = append(kept, q)
1210 kept = append(kept, q)
1134 queuedMessages = kept
1212 queuedMessages = kept
1135 a.messageQueue.Set(call.SessionID, kept)
1213 a.messageQueue.Set(call.SessionID, kept)
1214+ // A dropped prompt carrying a RunID must still publish its
1215+ // terminal cancelled RunComplete so a caller waiting on that
1216+ // RunID does not hang.
1217+ a.publishCanceledQueueDrops(canceledRunIDDrops)
1137 if len(queuedMessages) == 0 {
1219 if len(queuedMessages) == 0 {
1138 // No queued work. Clear the cancel mark only when no accepted
1220 // No queued work. Clear the cancel mark only when no accepted
@@ -1151,12 +1233,29 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
1151 mu.Unlock()
1233 mu.Unlock()
1152 return result, err
1234 return result, err
1154− // There are queued messages restart the loop. The recursive Run
1236+ // There are queued messages, restart the loop. Suppress the outer
1155− // publishes its own RunComplete for the queued prompt, so suppress
1237+ // defer's emit: it would otherwise observe the recursive Run's retErr
1156− // the outer defer's emit to avoid a duplicate event whose Error
1238+ // (named-return clobbering through the return below) against this
1157− // field would belong to the recursive turn but whose MessageID/Text
1239+ // turn's MessageID/Text and publish a mixed, racing event.
1158− // would belong to the outer turn.
1159 skipRunComplete = true
1240 skipRunComplete = true
1241+ // Decide whether this turn still owes its own terminal RunComplete.
1242+ // Each submitted prompt with a RunID has its own lifecycle, so a turn
1243+ // that is finished and handing off to a *different* queued prompt must
1244+ // publish its own RunComplete here — leaving it to the recursive turn
1245+ // (which carries a different RunID) would hang a caller waiting on
1246+ // this turn's RunID. The exception is the summarize-continuation path,
1247+ // which re-queues this same call (same RunID) to resume after a
1248+ // summary; in that case the eventual terminal turn for this RunID
1249+ // publishes, so publishing now would double-emit.
1250+ outerOwesRunComplete := call.RunID != ""
1251+ if outerOwesRunComplete {
1252+ for _, q := range queuedMessages {
1253+ if q.RunID == call.RunID {
1254+ outerOwesRunComplete = false
1160 firstQueuedMessage := queuedMessages[0]
1259 firstQueuedMessage := queuedMessages[0]
1161 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
1260 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
1162 // Reserve a fresh accept for the dequeued prompt before dropping the
1261 // Reserve a fresh accept for the dequeued prompt before dropping the
@@ -1167,6 +1266,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
1167 // the recursive Run's accepted path observes as cancel-on-entry.
1266 // the recursive Run's accepted path observes as cancel-on-entry.
1168 firstQueuedMessage.Accepted = a.BeginAccepted(call.SessionID)
1267 firstQueuedMessage.Accepted = a.BeginAccepted(call.SessionID)
1169 mu.Unlock()
1268 mu.Unlock()
1269+ if outerOwesRunComplete {
1270+ complete := notify.RunComplete{SessionID: call.SessionID, RunID: call.RunID}
1271+ if currentAssistant != nil {
1272+ complete.MessageID = currentAssistant.ID
1273+ complete.Text = currentAssistant.Content().String()
1275+ if ctx.Err() != nil {
1276+ complete.Cancelled = true
1278+ a.publishRunComplete(ctx, call, complete)
1170 return a.Run(ctx, firstQueuedMessage)
1280 return a.Run(ctx, firstQueuedMessage)
@@ -1770,14 +1880,14 @@ func (a *sessionAgent) Cancel(sessionID string) {
1771 if a.QueuedPrompts(sessionID) > 0 {
1881 if a.QueuedPrompts(sessionID) > 0 {
1772 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1882 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1773− a.messageQueue.Del(sessionID)
1883+ a.clearQueueAndNotify(sessionID)
1777func (a *sessionAgent) ClearQueue(sessionID string) {
1887func (a *sessionAgent) ClearQueue(sessionID string) {
1778 if a.QueuedPrompts(sessionID) > 0 {
1888 if a.QueuedPrompts(sessionID) > 0 {
1779 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1889 slog.Debug("Clearing queued prompts", "session_id", sessionID)
1780− a.messageQueue.Del(sessionID)
1890+ a.clearQueueAndNotify(sessionID)