From 6b42e335db5446865a7b2e54e8a0805edc7ebedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 6 Aug 2026 13:45:55 +0100 Subject: [PATCH] feat(queue): add Send Now dispatch controls (#2292) --- .../messageconstraints/attachments.go | 13 + .../orchestrator/event_handlers_agent.go | 232 +++++--- .../event_handlers_clarification.go | 57 ++ .../orchestrator/event_handlers_workflow.go | 19 +- .../orchestrator/handlers/queue_handlers.go | 101 +++- .../handlers/queue_handlers_send_now_test.go | 118 ++++ .../handlers/queue_handlers_test.go | 3 + .../orchestrator/messagequeue/repository.go | 17 + .../messagequeue/repository_memory.go | 206 +++++++ .../messagequeue/repository_send_now_test.go | 254 +++++++++ .../messagequeue/repository_sqlite.go | 405 ++++++++++++++ .../orchestrator/messagequeue/send_now.go | 207 +++++++ .../messagequeue/send_now_test.go | 144 +++++ .../orchestrator/messagequeue/service.go | 52 ++ .../internal/orchestrator/queue_send_now.go | 517 ++++++++++++++++++ .../orchestrator/queue_send_now_test.go | 307 +++++++++++ .../internal/orchestrator/queued_dispatch.go | 301 ++++++++++ apps/backend/internal/orchestrator/service.go | 124 +---- .../internal/orchestrator/task_operations.go | 113 +++- .../internal/task/models/attachments.go | 10 +- apps/backend/pkg/websocket/actions.go | 1 + .../task/chat/queued-ghost-list.test.tsx | 37 ++ .../task/chat/queued-ghost-list.tsx | 323 ++++++----- .../task/chat/queued-ghost-message.tsx | 218 +++----- .../task/chat/queued-ghost-panel-header.tsx | 93 ++++ .../task/chat/queued-ghost-row-actions.tsx | 118 ++++ apps/web/e2e/tests/chat/message-queue.spec.ts | 111 ++++ .../mobile-message-queue-management.spec.ts | 74 ++- .../hooks/domains/session/use-queue.test.ts | 56 +- apps/web/hooks/domains/session/use-queue.ts | 143 ++++- apps/web/lib/api/domains/queue-api.test.ts | 56 ++ apps/web/lib/api/domains/queue-api.ts | 89 ++- apps/web/src/locales/en/chat.json | 11 + apps/web/src/locales/pseudo/chat.json | 11 + ...2026-08-05-queue-send-now-replaces-turn.md | 64 +++ docs/decisions/INDEX.md | 1 + .../plan.md | 199 +++++++ .../task-01-fifo-send-now-arbitration.md | 110 ++++ .../task-02-aggregate-replacement-e2e.md | 86 +++ docs/plans/message-queue-send-now/plan.md | 299 ++++++++++ .../task-01-queue-send-now-claims.md | 79 +++ .../task-02-replacement-turn-dispatch.md | 86 +++ .../task-03-send-now-queue-controls.md | 89 +++ .../task-04-send-now-e2e.md | 87 +++ .../task-05-document-send-now.md | 70 +++ docs/public/coordination.md | 7 + docs/public/sessions-and-review.md | 2 + docs/specs/INDEX.md | 1 + docs/specs/ui/message-queue-management.md | 8 +- docs/specs/ui/message-queue-send-now.md | 249 +++++++++ 50 files changed, 5456 insertions(+), 522 deletions(-) create mode 100644 apps/backend/internal/messageconstraints/attachments.go create mode 100644 apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go create mode 100644 apps/backend/internal/orchestrator/messagequeue/repository_send_now_test.go create mode 100644 apps/backend/internal/orchestrator/messagequeue/send_now.go create mode 100644 apps/backend/internal/orchestrator/messagequeue/send_now_test.go create mode 100644 apps/backend/internal/orchestrator/queue_send_now.go create mode 100644 apps/backend/internal/orchestrator/queue_send_now_test.go create mode 100644 apps/backend/internal/orchestrator/queued_dispatch.go create mode 100644 apps/web/components/task/chat/queued-ghost-panel-header.tsx create mode 100644 apps/web/components/task/chat/queued-ghost-row-actions.tsx create mode 100644 docs/decisions/2026-08-05-queue-send-now-replaces-turn.md create mode 100644 docs/plans/message-queue-send-now-fifo-arbitration/plan.md create mode 100644 docs/plans/message-queue-send-now-fifo-arbitration/task-01-fifo-send-now-arbitration.md create mode 100644 docs/plans/message-queue-send-now-fifo-arbitration/task-02-aggregate-replacement-e2e.md create mode 100644 docs/plans/message-queue-send-now/plan.md create mode 100644 docs/plans/message-queue-send-now/task-01-queue-send-now-claims.md create mode 100644 docs/plans/message-queue-send-now/task-02-replacement-turn-dispatch.md create mode 100644 docs/plans/message-queue-send-now/task-03-send-now-queue-controls.md create mode 100644 docs/plans/message-queue-send-now/task-04-send-now-e2e.md create mode 100644 docs/plans/message-queue-send-now/task-05-document-send-now.md create mode 100644 docs/specs/ui/message-queue-send-now.md diff --git a/apps/backend/internal/messageconstraints/attachments.go b/apps/backend/internal/messageconstraints/attachments.go new file mode 100644 index 0000000000..e4a70aa2ac --- /dev/null +++ b/apps/backend/internal/messageconstraints/attachments.go @@ -0,0 +1,13 @@ +// Package messageconstraints contains limits shared by message admission +// paths without making either the task model or queue implementation depend +// on the other. +package messageconstraints + +const ( + // MaxAttachmentBytes is the maximum total payload size accepted for one + // message. + MaxAttachmentBytes int64 = 100 * 1024 * 1024 + // MaxAttachmentCount is the maximum number of attachments accepted for one + // message. + MaxAttachmentCount = 10 +) diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 8bc3a1c6c2..d43113307c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -358,6 +358,7 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD // execution in this handler preserves the legacy ready-event completion // boundary while still using the normal lifecycle delivery pipeline. var deferredLifecycleDispatch *messagequeue.QueuedMessage + var deferredLifecycleReservation *queuedDispatchReservation defer func() { if deferredLifecycleDispatch == nil { return @@ -365,7 +366,7 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD if hook := s.afterReadyLifecycleReservation; hook != nil { hook() } - s.executeQueuedMessage(data.SessionID, deferredLifecycleDispatch) + s.executeQueuedMessageWithReservation(data.SessionID, deferredLifecycleDispatch, deferredLifecycleReservation) }() if s.isSessionResetInProgress(data.SessionID) { @@ -556,7 +557,7 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD // still owns the same guard used by all drains. Deferring this mark // until after guard release lets a competing manual drain reserve the // same durable row and begin a duplicate delivery attempt. - s.markQueuedDispatchInFlight(data.SessionID, queuedMsg.ID) + deferredLifecycleReservation = s.markQueuedDispatchInFlightWithSourceLocked(data.SessionID, queuedMsg.ID, queuedMsg) deferredLifecycleDispatch = queuedMsg return } @@ -619,21 +620,29 @@ func (s *Service) recordQueuedUserMessage(ctx context.Context, queuedMsg *messag } func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messagequeue.QueuedMessage) { + s.executeQueuedMessageWithReservation(callerSessionID, queuedMsg, nil) +} + +func (s *Service) executeQueuedMessageWithReservation( + callerSessionID string, + queuedMsg *messagequeue.QueuedMessage, + reservation *queuedDispatchReservation, +) { promptCtx := context.Background() // Use a fresh context for async execution reservedSessionID := queuedMsg.SessionID - defer s.clearQueuedDispatchInFlightIfCurrent(reservedSessionID, queuedMsg.ID) + if reservation == nil { + reservation = s.queuedDispatchReservationForEntry(reservedSessionID, queuedMsg.ID) + } + defer s.clearQueuedDispatchInFlightIfCurrent(reservedSessionID, reservation) lifecyclePrompt := isLifecycleAutomationOrigin(queuedMsg.Metadata["origin"]) - // Safety net: guarantee the in-flight marker (set by - // dispatchTakenQueuedMessage before spawning this goroutine — see the - // Service.dispatchingQueued field doc comment) is cleared on every exit - // path, including the early reset-in-progress return below, PROVIDED - // this goroutine still owns it (compare-and-delete keyed on this - // entry's own ID) — the primary claim-and-clear happens deterministically - // inside promptTask's guarded claim step, right before setSessionRunning - // further down; this defer only catches paths that return before - // reaching it, or a losing claim that must not touch a newer dispatch's - // marker. + claimEntryID, handoffDone := s.claimQueuedMessageHandoff( + promptCtx, callerSessionID, queuedMsg, reservation, + ) + if handoffDone { + return + } + if s.isSessionResetInProgress(queuedMsg.SessionID) { s.logger.Warn("queued message execution deferred due to context reset in progress", zap.String("session_id", callerSessionID), @@ -694,27 +703,43 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag s.processOnTurnStartViaEngine(promptCtx, queuedMsg.TaskID, session) } - // Call the internal promptTask directly (not the public PromptTask - // wrapper), passing this entry's own ID as the claim token — see - // promptTask's doc comment and the Service.dispatchingQueued field doc - // comment for the guarded claim-then-mark-RUNNING step this enables, - // and for why a bare, unguarded check-then-clear would not be enough. - var afterClaim func() error - if lifecyclePrompt { - afterClaim = func() error { - if !s.lifecycleQueuedDispatchIsCurrent(promptCtx, queuedMsg) { - return errLifecyclePromptReservationSuperseded - } - return s.recordQueuedUserMessage(promptCtx, queuedMsg, attachments) - } - } - claimEntryID := "" - if s.isQueuedDispatchInFlight(queuedMsg.SessionID) { - claimEntryID = queuedMsg.ID - } + // Call promptTask with this entry's ID as a second ownership check. The + // worker already claimed the handoff before visible side effects; promptTask + // revalidates that ownership while it marks the session RUNNING. + afterClaim := s.queuedLifecycleAfterClaim(promptCtx, queuedMsg, attachments, lifecyclePrompt) _, err := s.promptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, promptContent, queuedMsg.Model, queuedMsg.PlanMode, attachments, false, claimEntryID, lifecyclePrompt, afterClaim) + s.finishQueuedMessageExecution( + promptCtx, callerSessionID, reservedSessionID, queuedMsg, + lifecyclePrompt, userMessageRecorded, err, + ) +} + +func (s *Service) queuedLifecycleAfterClaim( + ctx context.Context, + queuedMsg *messagequeue.QueuedMessage, + attachments []v1.MessageAttachment, + lifecyclePrompt bool, +) func() error { + if !lifecyclePrompt { + return nil + } + return func() error { + if !s.lifecycleQueuedDispatchIsCurrent(ctx, queuedMsg) { + return errLifecyclePromptReservationSuperseded + } + return s.recordQueuedUserMessage(ctx, queuedMsg, attachments) + } +} + +func (s *Service) finishQueuedMessageExecution( + ctx context.Context, + callerSessionID, reservedSessionID string, + queuedMsg *messagequeue.QueuedMessage, + lifecyclePrompt, userMessageRecorded bool, + err error, +) { if errors.Is(err, errLifecyclePromptReservationSuperseded) { s.logger.Info("discarding stale lifecycle dispatch after final claim", zap.String("session_id", callerSessionID), @@ -723,16 +748,16 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag return } if errors.Is(err, errLifecyclePromptInactive) { - s.acknowledgeLifecycleQueueEntry(promptCtx, reservedSessionID, queuedMsg) + s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg) return } var reselected *lifecyclePromptReselectedError if errors.As(err, &reselected) { queuedMsg.SessionID = reselected.sessionID if s.requeueLifecycleMessage( - promptCtx, queuedMsg, queuedMsg.QueuedBy, messageCoalesceKey(queuedMsg), + ctx, queuedMsg, queuedMsg.QueuedBy, messageCoalesceKey(queuedMsg), ) { - s.acknowledgeLifecycleQueueEntry(promptCtx, reservedSessionID, queuedMsg) + s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg) } return } @@ -747,50 +772,113 @@ func (s *Service) executeQueuedMessage(callerSessionID string, queuedMsg *messag zap.String("session_id", callerSessionID), zap.String("task_id", queuedMsg.TaskID), zap.String("queue_id", queuedMsg.ID)) - s.requeueMessage(promptCtx, queuedMsg, "superseded-by-newer-dispatch") + s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch") return } if err != nil { - s.logger.Error("failed to execute queued message", - zap.String("session_id", callerSessionID), - zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID), - zap.Error(err)) - - manualRecovery := isManualRecoveryPromptError(err) - if lifecyclePrompt || errors.Is(err, errLifecyclePromptClaim) || - errors.Is(err, errLifecyclePromptMessagePersistence) || - isSessionBusyError(err) || isTransientPromptError(err) || manualRecovery || - errors.Is(err, lifecycle.ErrCancelEscalated) || isSessionResetInProgressError(err) { - if userMessageRecorded { - markQueuedUserMessageRecorded(queuedMsg) - } - s.logger.Warn("queued message execution failed; requeueing", - zap.String("session_id", callerSessionID), - zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID), - zap.Bool("manual_recovery", manualRecovery)) - if manualRecovery { - s.restoreQueuedMessage(promptCtx, queuedMsg) - } else { - s.requeueMessage(promptCtx, queuedMsg, "workflow-auto-start-retry") - } - return - } - - // TODO: Implement dead letter queue for failed queued messages - // Currently, failed messages are lost. Consider: - // 1. Retry mechanism with exponential backoff - // 2. Persist failed messages to database for manual intervention - // 3. Notification to user about failed queue execution - s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", - zap.String("session_id", callerSessionID), - zap.String("queue_id", queuedMsg.ID), - zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))])) + s.handleQueuedMessageExecutionError( + ctx, callerSessionID, queuedMsg, lifecyclePrompt, userMessageRecorded, err, + ) return } if lifecyclePrompt { - s.acknowledgeLifecycleQueueEntry(promptCtx, reservedSessionID, queuedMsg) + s.acknowledgeLifecycleQueueEntry(ctx, reservedSessionID, queuedMsg) + } +} + +func (s *Service) handleQueuedMessageExecutionError( + ctx context.Context, + callerSessionID string, + queuedMsg *messagequeue.QueuedMessage, + lifecyclePrompt, userMessageRecorded bool, + err error, +) { + s.logger.Error("failed to execute queued message", + zap.String("session_id", callerSessionID), + zap.String("task_id", queuedMsg.TaskID), + zap.String("queue_id", queuedMsg.ID), + zap.Error(err)) + + manualRecovery := isManualRecoveryPromptError(err) + if lifecyclePrompt || errors.Is(err, errLifecyclePromptClaim) || + errors.Is(err, errLifecyclePromptMessagePersistence) || + isSessionBusyError(err) || isTransientPromptError(err) || manualRecovery || + errors.Is(err, lifecycle.ErrCancelEscalated) || isSessionResetInProgressError(err) { + if userMessageRecorded { + markQueuedUserMessageRecorded(queuedMsg) + } + s.logger.Warn("queued message execution failed; requeueing", + zap.String("session_id", callerSessionID), + zap.String("task_id", queuedMsg.TaskID), + zap.String("queue_id", queuedMsg.ID), + zap.Bool("manual_recovery", manualRecovery)) + if manualRecovery { + s.restoreQueuedMessage(ctx, queuedMsg) + } else { + s.requeueMessage(ctx, queuedMsg, "workflow-auto-start-retry") + } + return + } + + // TODO: Implement dead letter queue for failed queued messages + // Currently, failed messages are lost. Consider: + // 1. Retry mechanism with exponential backoff + // 2. Persist failed messages to database for manual recovery + // 3. Notification to user about failed queue execution + s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", + zap.String("session_id", callerSessionID), + zap.String("queue_id", queuedMsg.ID), + zap.Int("content_length", len(queuedMsg.Content))) +} + +// claimQueuedMessageHandoff resolves direct/test reservations and claims the +// worker's ownership before executeQueuedMessage performs visible side effects. +// The bool return is true when the caller must stop because another dispatch +// already won or the reservation could not be claimed. +func (s *Service) claimQueuedMessageHandoff( + ctx context.Context, + callerSessionID string, + queuedMsg *messagequeue.QueuedMessage, + reservation *queuedDispatchReservation, +) (string, bool) { + reservedSessionID := queuedMsg.SessionID + if reservation == nil { + pending := s.pendingQueuedDispatch(reservedSessionID) + accepted := s.acceptedQueuedDispatchForSession(reservedSessionID) + switch { + case pending != nil && pending.entryID == queuedMsg.ID: + reservation = pending + case accepted != nil && accepted.entryID == queuedMsg.ID: + reservation = accepted + case pending != nil || accepted != nil: + s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch") + return "", true + } + } + if reservation == nil { + return "", false + } + + tracked, claimErr := s.claimQueuedDispatchForExecution(reservedSessionID, queuedMsg.ID, reservation) + switch { + case errors.Is(claimErr, errQueuedDispatchSupersededBySendNow): + // Send Now restored and claimed this exact source; the stale FIFO worker + // must not requeue it or create any visible side effects. + return "", true + case errors.Is(claimErr, errQueuedDispatchSuperseded): + s.requeueMessage(ctx, queuedMsg, "superseded-by-newer-dispatch") + return "", true + case claimErr != nil: + s.logger.Warn("failed to claim queued dispatch ownership", + zap.String("session_id", callerSessionID), + zap.String("queue_id", queuedMsg.ID), + zap.Error(claimErr)) + s.requeueMessage(ctx, queuedMsg, "queued-dispatch-claim-retry") + return "", true + case tracked: + return queuedMsg.ID, false + default: + return "", false } } diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification.go b/apps/backend/internal/orchestrator/event_handlers_clarification.go index 1a69e50cb7..5cff44ac19 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification.go @@ -560,6 +560,45 @@ func (s *Service) cancelAgentSilentActionWithKind( return registered.wait(ctx) } +// cancelAgentSilentActionWithKindExclusive is the non-joining cancellation +// path used by Send Now. A second Send Now click or an explicit cancellation +// that already owns the session is reported as a conflict instead of joining +// and inheriting the first operation's reconciliation semantics. +func (s *Service) cancelAgentSilentActionWithKindExclusive( + ctx context.Context, + taskID, sessionID string, + action func(context.Context) (bool, error), + kind cancellationKind, + expectedTurnID string, +) (bool, error) { + if s.repo == nil { + return false, errors.New("cancel agent silently: repository is not configured") + } + var registeredAction func(context.Context, *cancelOperation) (bool, error) + if action != nil { + registeredAction = func(actionCtx context.Context, _ *cancelOperation) (bool, error) { + return action(actionCtx) + } + } + operation, owner, registered, accepted := s.claimCancellationWithActionExclusive( + sessionID, kind, registeredAction, + ) + if !accepted { + return false, ErrSendNowConflict + } + if owner { + s.setCancellationExpectedTurn(sessionID, operation, expectedTurnID) + go s.runSilentCancellation(ctx, taskID, sessionID, operation) + } + if err := operation.wait(ctx); err != nil { + return false, err + } + if registered == nil { + return false, nil + } + return registered.wait(ctx) +} + func (s *Service) runSilentCancellation(requestCtx context.Context, taskID, sessionID string, operation *cancelOperation) { endProjection := s.beginCancellationProjection(sessionID) operation.projectionRelease = endProjection @@ -600,6 +639,9 @@ func (s *Service) runSilentCancellationOwned(ctx context.Context, taskID, sessio if err != nil { return err } + if expectedTurnID, expectedReady := s.cancellationExpectedTurnSnapshot(operation); expectedReady && identity.turnID != expectedTurnID { + return ErrSendNowTurnChanged + } s.setCancellationIdentity(sessionID, operation, identity) if err := s.cancelAgentWhileUnlocked(ctx, sessionID, unlockGuard, relockGuard); err != nil { return err @@ -686,6 +728,21 @@ func (s *Service) cancelAgentSilentWithGuardActionKind( return s.cancelAgentSilentActionWithKind(ctx, taskID, sessionID, action, kind) } +func (s *Service) cancelAgentSilentWithGuardActionKindExclusive( + ctx context.Context, + taskID, sessionID string, + unlockGuard, relockGuard func(), + action func(context.Context) (bool, error), + kind cancellationKind, + expectedTurnID string, +) (bool, error) { + if unlockGuard != nil { + unlockGuard() + defer relockGuard() + } + return s.cancelAgentSilentActionWithKindExclusive(ctx, taskID, sessionID, action, kind, expectedTurnID) +} + func (s *Service) logSilentCancelReconciled(taskID, sessionID string, err error) { if errors.Is(err, lifecycle.ErrCancelEscalated) { s.logger.Warn("agent did not acknowledge silent clarification cancel; reconciling session state", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index b4823883eb..d2e65f1f72 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -1453,10 +1453,9 @@ func (s *Service) drainQueuedMessageForPromptableSession(ctx context.Context, se // public drainQueuedMessageForPromptableSession instead when the guard is // not already held. // -// Backs off without taking anything when isQueuedDispatchInFlight reports -// a different dispatch already handed off for this session — see the -// Service.dispatchingQueued field doc comment for the double-dispatch -// window this closes. +// Backs off without taking anything when any queued dispatch is still settling +// for this session. Send Now uses the phase-specific helpers to supersede only +// a pending automatic FIFO reservation. func (s *Service) drainQueuedMessageForPromptableSessionLocked(ctx context.Context, sessionID string) bool { if s.messageQueue == nil || s.isCancelInFlight(sessionID) || s.isQueuedDispatchInFlight(sessionID) { return false @@ -1481,13 +1480,11 @@ func (s *Service) dispatchTakenQueuedMessage(ctx context.Context, sessionID stri zap.String("queue_id", queuedMsg.ID)) return false } - // Reserve entryID as sessionID's "dispatch in flight" token *before* - // handing off to the async goroutine — see the Service.dispatchingQueued - // field doc comment for why session.State alone isn't a reliable busy - // signal until executeQueuedMessage's own promptTask call reaches its - // guarded claim step, several DB round-trips later. - s.markQueuedDispatchInFlight(sessionID, queuedMsg.ID) - go s.executeQueuedMessage(sessionID, queuedMsg) + // Reserve entryID before handing off to the async goroutine. The worker + // transitions this reservation to accepted under the same guard used by + // Send Now before it performs any visible prompt side effects. + reservation := s.markQueuedDispatchInFlightWithSourceLocked(sessionID, queuedMsg.ID, queuedMsg) + go s.executeQueuedMessageWithReservation(sessionID, queuedMsg, reservation) return true } diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers.go b/apps/backend/internal/orchestrator/handlers/queue_handlers.go index 54b1898d66..1d0f7e083b 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers.go @@ -20,9 +20,15 @@ import ( const ( // queueErrorCodeEntryNotFound is surfaced when an edit/remove targets an entry // that has already been drained (atomic-take won the race). - queueErrorCodeEntryNotFound = "entry_not_found" - queueErrorCodeSessionBusy = "session_busy" - queueErrorCodeNotPromptable = "session_not_promptable" + queueErrorCodeEntryNotFound = "entry_not_found" + queueErrorCodeSessionBusy = "session_busy" + queueErrorCodeNotPromptable = "session_not_promptable" + queueErrorCodeSendNowQueueEmpty = "queue_empty" + queueErrorCodeSendNowQueueChanged = "queue_changed" + queueErrorCodeSendNowConflict = "send_now_conflict" + queueErrorCodeSendNowTurnChanged = "turn_changed" + queueErrorCodeSendNowAttachmentOverflow = "send_now_attachment_overflow" + queueErrorCodeSendNowReferenceOverflow = "send_now_reference_overflow" // queueErrorCodeMergeReferenceOverflow is surfaced when a merge would push // the combined entity references past the per-message cap; the merge is // rejected atomically instead of dropping persisted references. @@ -55,6 +61,13 @@ type QueueDrainer interface { DrainQueuedMessage(ctx context.Context, sessionID string) (bool, error) } +// QueueSendNowDispatcher is implemented by the orchestrator service. It is +// kept separate from QueueDrainer so queue-focused handlers can retain their +// small test doubles while the new action gets the replacement-turn contract. +type QueueSendNowDispatcher interface { + SendQueuedNow(ctx context.Context, sessionID, scope, entryID string) (int, error) +} + // QueueAccessAuthorizer scopes queue reads and mutations to visible sessions. type QueueAccessAuthorizer interface { AuthorizeSessionAccess(ctx context.Context, sessionID string) error @@ -79,6 +92,7 @@ type queueEntryTaker interface { type QueueHandlers struct { queueService QueueService queueDrainer QueueDrainer + queueDispatcher QueueSendNowDispatcher accessAuthorizer QueueAccessAuthorizer eventBus bus.EventBus logger *logger.Logger @@ -105,7 +119,7 @@ func NewQueueHandlers( if len(validators) > 0 { referenceValidator = validators[0] } - return &QueueHandlers{ + handlers := &QueueHandlers{ queueService: queueService, queueDrainer: queueDrainer, accessAuthorizer: accessAuthorizer, @@ -113,6 +127,10 @@ func NewQueueHandlers( logger: log.WithFields(zap.String("component", "queue-handlers")), referenceValidator: referenceValidator, } + if dispatcher, ok := queueDrainer.(QueueSendNowDispatcher); ok { + handlers.queueDispatcher = dispatcher + } + return handlers } // RegisterHandlers registers queue handlers with the dispatcher. @@ -123,6 +141,7 @@ func (h *QueueHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMessageQueueUpdate, h.wsUpdateMessage) d.RegisterFunc(ws.ActionMessageQueueAppend, h.wsAppendToQueue) d.RegisterFunc(ws.ActionMessageQueueDrain, h.wsDrainQueue) + d.RegisterFunc(ws.ActionMessageQueueSendNow, h.wsSendNow) d.RegisterFunc(ws.ActionMessageQueueRemove, h.wsRemoveEntry) d.RegisterFunc(ws.ActionMessageQueueMerge, h.wsMergeIntoAbove) } @@ -279,6 +298,80 @@ func (h *QueueHandlers) wsDrainQueue(ctx context.Context, msg *ws.Message) (*ws. }) } +type wsSendNowRequest struct { + SessionID string `json:"session_id"` + Scope string `json:"scope"` + EntryID string `json:"entry_id,omitempty"` +} + +func (h *QueueHandlers) wsSendNow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsSendNowRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil { + return denied, nil + } + if validation := validateSendNowRequest(req); validation != "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, validation, nil) + } + if h.queueDispatcher == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Queue send-now is unavailable", nil) + } + + sentCount, err := h.queueDispatcher.SendQueuedNow(ctx, req.SessionID, req.Scope, req.EntryID) + if err != nil { + return h.sendNowErrorResponse(msg, req.SessionID, err) + } + + h.publishStatus(ctx, req.SessionID) + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + fieldSessionID: req.SessionID, + "dispatched": true, + "sent_count": sentCount, + }) +} + +func validateSendNowRequest(req wsSendNowRequest) string { + switch { + case req.Scope != orchestrator.QueueSendNowScopeEntry && req.Scope != orchestrator.QueueSendNowScopeAll: + return "scope must be entry or all" + case req.Scope == orchestrator.QueueSendNowScopeEntry && req.EntryID == "": + return "entry_id is required for entry scope" + case req.Scope == orchestrator.QueueSendNowScopeAll && req.EntryID != "": + return "entry_id is not allowed for all scope" + default: + return "" + } +} + +func (h *QueueHandlers) sendNowErrorResponse(msg *ws.Message, sessionID string, err error) (*ws.Message, error) { + switch { + case errors.Is(err, orchestrator.ErrSendNowEntryNotFound): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry is no longer pending", nil) + case errors.Is(err, orchestrator.ErrSendNowQueueEmpty): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowQueueEmpty, "Queue is empty", nil) + case errors.Is(err, orchestrator.ErrSendNowQueueChanged): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowQueueChanged, "Queue changed before Send Now could start", nil) + case errors.Is(err, orchestrator.ErrSendNowConflict): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowConflict, "Another cancellation or Send Now operation is in progress", nil) + case errors.Is(err, orchestrator.ErrSendNowTurnChanged): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowTurnChanged, "The active turn changed before Send Now could start", nil) + case errors.Is(err, messagequeue.ErrSendNowAttachmentOverflow): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowAttachmentOverflow, "Combined attachments exceed the message limits", nil) + case errors.Is(err, messagequeue.ErrSendNowReferenceOverflow): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowReferenceOverflow, "Combined entity references exceed the message limit", nil) + case errors.Is(err, orchestrator.ErrSessionNotPromptable): + return ws.NewError(msg.ID, msg.Action, queueErrorCodeNotPromptable, "Session is not ready for input", nil) + default: + h.logger.Error("failed to send queued message now", zap.String(fieldSessionID, sessionID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to send queued message now", nil) + } +} + type wsGetQueueStatusRequest struct { SessionID string `json:"session_id"` } diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go new file mode 100644 index 0000000000..b284fbf911 --- /dev/null +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +type mockQueueSendNow struct { + sentCount int + err error + calls int + sessionID string + scope string + entryID string +} + +func (m *mockQueueSendNow) DrainQueuedMessage(context.Context, string) (bool, error) { + return false, nil +} + +func (m *mockQueueSendNow) SendQueuedNow(_ context.Context, sessionID, scope, entryID string) (int, error) { + m.calls++ + m.sessionID = sessionID + m.scope = scope + m.entryID = entryID + return m.sentCount, m.err +} + +func TestWsSendNowValidatesScopeAndReturnsSentCount(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console", OutputPath: "stderr"}) + require.NoError(t, err) + dispatcher := &mockQueueSendNow{sentCount: 3} + events := &mockEventBus{} + handlers := NewQueueHandlers(messagequeue.NewServiceMemory(log), events, log, dispatcher, allowQueueAccess{}) + + response, err := handlers.wsSendNow(context.Background(), createTestMessage(t, ws.ActionMessageQueueSendNow, map[string]interface{}{ + "session_id": "session-1", "scope": orchestrator.QueueSendNowScopeAll, + })) + require.NoError(t, err) + if response.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %q, want response", response.Type) + } + if dispatcher.calls != 1 || dispatcher.sessionID != "session-1" || dispatcher.scope != orchestrator.QueueSendNowScopeAll || dispatcher.entryID != "" { + t.Fatalf("dispatcher call = %#v", dispatcher) + } + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(response.Payload, &payload)) + if payload["session_id"] != "session-1" || payload["dispatched"] != true || payload["sent_count"] != float64(3) { + t.Fatalf("response payload = %#v", payload) + } + if events.published != 1 { + t.Fatalf("published events = %d, want 1", events.published) + } + + for _, tc := range []struct { + name string + payload map[string]interface{} + }{ + {name: "missing entry", payload: map[string]interface{}{"session_id": "s", "scope": orchestrator.QueueSendNowScopeEntry}}, + {name: "entry on all", payload: map[string]interface{}{"session_id": "s", "scope": orchestrator.QueueSendNowScopeAll, "entry_id": "q"}}, + {name: "unknown scope", payload: map[string]interface{}{"session_id": "s", "scope": "selection"}}, + } { + t.Run(tc.name, func(t *testing.T) { + before := dispatcher.calls + response, err := handlers.wsSendNow(context.Background(), createTestMessage(t, ws.ActionMessageQueueSendNow, tc.payload)) + require.NoError(t, err) + if response.Type != ws.MessageTypeError { + t.Fatalf("response type = %q, want error", response.Type) + } + if dispatcher.calls != before { + t.Fatalf("dispatcher called for invalid payload") + } + }) + } +} + +func TestWsSendNowMapsStableErrors(t *testing.T) { + for _, tc := range []struct { + name string + err error + code string + }{ + {name: "entry", err: orchestrator.ErrSendNowEntryNotFound, code: queueErrorCodeEntryNotFound}, + {name: "empty", err: orchestrator.ErrSendNowQueueEmpty, code: queueErrorCodeSendNowQueueEmpty}, + {name: "changed", err: orchestrator.ErrSendNowQueueChanged, code: queueErrorCodeSendNowQueueChanged}, + {name: "conflict", err: orchestrator.ErrSendNowConflict, code: queueErrorCodeSendNowConflict}, + {name: "turn", err: orchestrator.ErrSendNowTurnChanged, code: queueErrorCodeSendNowTurnChanged}, + {name: "attachments", err: messagequeue.ErrSendNowAttachmentOverflow, code: queueErrorCodeSendNowAttachmentOverflow}, + {name: "references", err: messagequeue.ErrSendNowReferenceOverflow, code: queueErrorCodeSendNowReferenceOverflow}, + {name: "not promptable", err: orchestrator.ErrSessionNotPromptable, code: queueErrorCodeNotPromptable}, + } { + t.Run(tc.name, func(t *testing.T) { + log, logErr := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console", OutputPath: "stderr"}) + require.NoError(t, logErr) + dispatcher := &mockQueueSendNow{err: tc.err} + handlers := NewQueueHandlers(messagequeue.NewServiceMemory(log), &mockEventBus{}, log, dispatcher, allowQueueAccess{}) + response, callErr := handlers.wsSendNow(context.Background(), createTestMessage(t, ws.ActionMessageQueueSendNow, map[string]interface{}{ + "session_id": "session-1", "scope": orchestrator.QueueSendNowScopeAll, + })) + require.NoError(t, callErr) + if response.Type != ws.MessageTypeError { + t.Fatalf("response type = %q", response.Type) + } + if got := parseError(t, response).Code; got != tc.code { + t.Fatalf("error code = %q, want %q", got, tc.code) + } + }) + } + + var _ QueueSendNowDispatcher = (*mockQueueSendNow)(nil) +} diff --git a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go index e695db70cb..257ce94730 100644 --- a/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go +++ b/apps/backend/internal/orchestrator/handlers/queue_handlers_test.go @@ -181,6 +181,9 @@ func TestQueueHandlersDenyUnauthorizedSessionActions(t *testing.T) { return map[string]interface{}{"session_id": "s", "entry_id": id, "content": "changed"} }}, {name: "drain", action: ws.ActionMessageQueueDrain, call: (*QueueHandlers).wsDrainQueue, body: func(string) map[string]interface{} { return map[string]interface{}{"session_id": "s"} }}, + {name: "send now", action: ws.ActionMessageQueueSendNow, call: (*QueueHandlers).wsSendNow, body: func(string) map[string]interface{} { + return map[string]interface{}{"session_id": "s", "scope": orchestrator.QueueSendNowScopeEntry, "entry_id": "q"} + }}, {name: "remove", action: ws.ActionMessageQueueRemove, call: (*QueueHandlers).wsRemoveEntry, body: func(id string) map[string]interface{} { return map[string]interface{}{"session_id": "s", "entry_id": id} }}, diff --git a/apps/backend/internal/orchestrator/messagequeue/repository.go b/apps/backend/internal/orchestrator/messagequeue/repository.go index 2dc53e8c30..663c7b1bb4 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository.go @@ -66,6 +66,23 @@ type Repository interface { // Returns nil, nil if no entry matches (already taken or never existed). TakeByID(ctx context.Context, sessionID, entryID string) (*QueuedMessage, error) + // ClaimSendNow atomically claims the exact ordered source snapshot for an + // interrupt-and-replace dispatch. The repository compares every requested + // row with the snapshot before mutation, then orders the returned sources by + // their persisted FIFO positions. Ordinary rows are removed, and durable + // lifecycle rows are reserved until AcknowledgeSendNowClaim or + // RestoreSendNowClaim. + ClaimSendNow(ctx context.Context, sessionID string, expected []QueuedMessage) (*SendNowClaim, error) + + // RestoreSendNowClaim puts every source back at its original position and + // clears durable lifecycle reservations. It must restore the complete claim + // or leave the queue unchanged. + RestoreSendNowClaim(ctx context.Context, claim *SendNowClaim) error + + // AcknowledgeSendNowClaim removes every durable source after the replacement + // prompt has been accepted. Ordinary sources were already removed by claim. + AcknowledgeSendNowClaim(ctx context.Context, claim *SendNowClaim) error + // UpdateContent replaces the content/attachments of an entry. The session // scope (`AND session_id = ?`) is mandatory so a caller can't update an // entry by guessing its UUID across sessions. If queuedBy is non-empty the diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go index 40c71183e3..6d52fd4140 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_memory.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_memory.go @@ -2,6 +2,7 @@ package messagequeue import ( "context" + "reflect" "sort" "sync" "time" @@ -315,6 +316,211 @@ func (r *memoryRepository) TakeByID(_ context.Context, sessionID, entryID string return nil, nil } +func (r *memoryRepository) ClaimSendNow(_ context.Context, sessionID string, expected []QueuedMessage) (*SendNowClaim, error) { + r.mu.Lock() + defer r.mu.Unlock() + if len(expected) == 0 { + return nil, ErrSendNowEmpty + } + + requested, err := requestedSendNowIDs(expected) + if err != nil { + return nil, err + } + + list := r.entries[sessionID] + selected := make([]*QueuedMessage, 0, len(expected)) + for _, entry := range list { + if _, ok := requested[entry.ID]; !ok { + continue + } + if entry.IsReservedInFlight() { + return nil, ErrSendNowReservationConflict + } + selected = append(selected, entry) + } + if len(selected) != len(expected) { + return nil, ErrSendNowClaimChanged + } + if err := validateSendNowSnapshot(selected, expected); err != nil { + return nil, ErrSendNowClaimChanged + } + + sources := cloneSendNowSources(selected) + envelope, err := BuildSendNowEnvelope(sources) + if err != nil { + return nil, err + } + generations := make(map[string]int64) + for _, source := range sources { + if source.TaskID != "" { + generations[source.TaskID] = r.generation[source.TaskID] + } + } + + remaining := make([]*QueuedMessage, 0, len(list)) + for _, entry := range list { + if _, ok := requested[entry.ID]; !ok { + remaining = append(remaining, entry) + continue + } + if entry.IsDurableLifecycle() { + entry.Metadata = markReservedMetadata(entry.Metadata) + remaining = append(remaining, entry) + } + } + if len(remaining) == 0 { + delete(r.entries, sessionID) + delete(r.nextPosition, sessionID) + } else { + r.entries[sessionID] = remaining + } + return &SendNowClaim{Sources: sources, Dispatch: *envelope, SourceGenerations: generations}, nil +} + +func (r *memoryRepository) RestoreSendNowClaim(_ context.Context, claim *SendNowClaim) error { + if claim == nil || len(claim.Sources) == 0 { + return ErrSendNowEmpty + } + r.mu.Lock() + defer r.mu.Unlock() + + sessionID := claim.Sources[0].SessionID + list := r.entries[sessionID] + existing := make(map[string]*QueuedMessage, len(list)) + for _, entry := range list { + existing[entry.ID] = entry + } + if err := validateMemorySendNowRestore(claim, sessionID, existing, r.generation); err != nil { + return err + } + + for _, source := range claim.Sources { + if sendNowSourceGenerationChanged(claim, source, r.generation[source.TaskID]) { + continue + } + if existing[source.ID] != nil { + if source.IsDurableLifecycle() { + existing[source.ID].Metadata = restoreSendNowMetadata(existing[source.ID].Metadata, source.Metadata) + } + continue + } + clone := cloneQueuedMessage(&source) + clone.Metadata = clearReservedMetadata(clone.Metadata) + list = append(list, clone) + if clone.Position > r.nextPosition[sessionID] { + r.nextPosition[sessionID] = clone.Position + } + } + sort.Slice(list, func(i, j int) bool { return list[i].Position < list[j].Position }) + r.entries[sessionID] = list + return nil +} + +func validateMemorySendNowRestore( + claim *SendNowClaim, + sessionID string, + existing map[string]*QueuedMessage, + generations map[string]int64, +) error { + for _, source := range claim.Sources { + if source.SessionID != sessionID { + return ErrSendNowClaimChanged + } + if sendNowSourceGenerationChanged(claim, source, generations[source.TaskID]) { + continue + } + entry := existing[source.ID] + if source.IsDurableLifecycle() { + if entry == nil || (!entry.IsReservedInFlight() && !sameQueuedMessageContent(entry, &source)) { + return ErrSendNowClaimChanged + } + continue + } + if entry != nil && entry.IsReservedInFlight() { + return ErrSendNowClaimChanged + } + } + return nil +} + +func (r *memoryRepository) AcknowledgeSendNowClaim(_ context.Context, claim *SendNowClaim) error { + if claim == nil || len(claim.Sources) == 0 { + return ErrSendNowEmpty + } + r.mu.Lock() + defer r.mu.Unlock() + sessionID := claim.Sources[0].SessionID + requested := make(map[string]struct{}) + for _, source := range claim.Sources { + if source.SessionID != sessionID { + return ErrSendNowClaimChanged + } + if !source.IsDurableLifecycle() { + continue + } + requested[source.ID] = struct{}{} + } + for _, entry := range r.entries[sessionID] { + if _, ok := requested[entry.ID]; ok && !entry.IsReservedInFlight() { + return ErrSendNowClaimChanged + } + } + if len(requested) == 0 { + return nil + } + list := r.entries[sessionID] + remaining := list[:0] + for _, entry := range list { + if _, ok := requested[entry.ID]; !ok { + remaining = append(remaining, entry) + } + } + if len(remaining) == 0 { + delete(r.entries, sessionID) + delete(r.nextPosition, sessionID) + } else { + r.entries[sessionID] = remaining + } + return nil +} + +func cloneSendNowSources(entries []*QueuedMessage) []QueuedMessage { + sources := make([]QueuedMessage, 0, len(entries)) + for _, entry := range entries { + clone := cloneQueuedMessage(entry) + clone.Metadata = clearReservedMetadata(clone.Metadata) + if entry.IsDurableLifecycle() { + clone.reservedLifecycleDelivery = true + } + sources = append(sources, *clone) + } + return sources +} + +func cloneQueuedMessage(entry *QueuedMessage) *QueuedMessage { + if entry == nil { + return nil + } + clone := *entry + clone.Attachments = append([]MessageAttachment(nil), entry.Attachments...) + clone.Metadata = copyMessageMetadata(entry.Metadata, 0) + return &clone +} + +func sameQueuedMessageContent(left, right *QueuedMessage) bool { + if left == nil || right == nil { + return false + } + leftCopy := cloneQueuedMessage(left) + rightCopy := cloneQueuedMessage(right) + leftCopy.Metadata = clearReservedMetadata(leftCopy.Metadata) + rightCopy.Metadata = clearReservedMetadata(rightCopy.Metadata) + leftCopy.reservedLifecycleDelivery = false + rightCopy.reservedLifecycleDelivery = false + return reflect.DeepEqual(leftCopy, rightCopy) +} + func (r *memoryRepository) UpdateContent(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { return r.UpdateContentAndMetadata(ctx, sessionID, entryID, content, attachments, nil, queuedBy) } diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_send_now_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_send_now_test.go new file mode 100644 index 0000000000..87a5ac3c6d --- /dev/null +++ b/apps/backend/internal/orchestrator/messagequeue/repository_send_now_test.go @@ -0,0 +1,254 @@ +package messagequeue + +import ( + "context" + "errors" + "testing" +) + +func TestSendNowClaimIsExactAtomicAndRestorable(t *testing.T) { + tests := []struct { + name string + new func(*testing.T) Repository + }{ + {name: "memory", new: func(*testing.T) Repository { return NewMemoryRepository() }}, + {name: "sqlite", new: newTestSQLiteRepo}, + {name: "postgres", new: newTestPostgresRepo}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := tt.new(t) + ctx := context.Background() + first := insertTestEntry(t, repo, "session-1", "task-1", "first", QueuedByUser, nil, nil) + durable := insertTestEntry(t, repo, "session-1", "task-1", "durable", QueuedByWorkflow, nil, + map[string]interface{}{MetadataLifecycleDurable: true}) + third := insertTestEntry(t, repo, "session-1", "task-1", "third", QueuedByUser, nil, nil) + clickSnapshot, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list click-time snapshot: %v", err) + } + if len(clickSnapshot) != 3 { + t.Fatalf("click-time snapshot = %#v, want three entries", clickSnapshot) + } + + claim, err := repo.ClaimSendNow(ctx, "session-1", clickSnapshot) + if err != nil { + t.Fatalf("claim: %v", err) + } + if got := []string{claim.Sources[0].ID, claim.Sources[1].ID, claim.Sources[2].ID}; got[0] != first.ID || got[1] != durable.ID || got[2] != third.ID { + t.Fatalf("claim source order = %v", got) + } + if claim.Dispatch.Content != "first\n\ndurable\n\nthird" { + t.Fatalf("dispatch content = %q", claim.Dispatch.Content) + } + + pending, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after claim: %v", err) + } + if len(pending) != 1 || pending[0].ID != durable.ID || !pending[0].IsReservedInFlight() { + t.Fatalf("pending after claim = %#v, want only reserved durable source", pending) + } + + if err := repo.RestoreSendNowClaim(ctx, claim); err != nil { + t.Fatalf("restore: %v", err) + } + restored, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after restore: %v", err) + } + if len(restored) != 3 || restored[0].ID != first.ID || restored[1].ID != durable.ID || restored[2].ID != third.ID { + t.Fatalf("restored entries = %#v", restored) + } + if restored[1].IsReservedInFlight() { + t.Fatal("restore left durable source reserved") + } + + claim, err = repo.ClaimSendNow(ctx, "session-1", clickSnapshot) + if err != nil { + t.Fatalf("claim before acknowledge: %v", err) + } + if err := repo.AcknowledgeSendNowClaim(ctx, claim); err != nil { + t.Fatalf("acknowledge: %v", err) + } + remaining, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after acknowledge: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("remaining after acknowledge = %#v", remaining) + } + }) + } +} + +func TestSendNowClaimRejectsMissingOrReservedSelectionWithoutMutation(t *testing.T) { + tests := []struct { + name string + new func(*testing.T) Repository + }{ + {name: "memory", new: func(*testing.T) Repository { return NewMemoryRepository() }}, + {name: "sqlite", new: newTestSQLiteRepo}, + {name: "postgres", new: newTestPostgresRepo}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := tt.new(t) + ctx := context.Background() + first := insertTestEntry(t, repo, "session-1", "task-1", "first", QueuedByUser, nil, nil) + second := insertTestEntry(t, repo, "session-1", "task-1", "second", QueuedByUser, nil, nil) + + if _, err := repo.ClaimSendNow(ctx, "session-1", []QueuedMessage{*first, {ID: "missing"}}); !errors.Is(err, ErrSendNowClaimChanged) { + t.Fatalf("missing claim error = %v, want ErrSendNowClaimChanged", err) + } + entries, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after missing claim: %v", err) + } + if len(entries) != 2 || entries[0].ID != first.ID || entries[1].ID != second.ID { + t.Fatalf("entries changed after missing claim = %#v", entries) + } + + snapshot := *first + if err := repo.UpdateContentAndMetadata(ctx, "session-1", first.ID, "edited", nil, + map[string]interface{}{"snapshot": "edited"}, QueuedByUser); err != nil { + t.Fatalf("edit queued snapshot: %v", err) + } + if _, err := repo.ClaimSendNow(ctx, "session-1", []QueuedMessage{snapshot}); !errors.Is(err, ErrSendNowClaimChanged) { + t.Fatalf("changed snapshot error = %v, want ErrSendNowClaimChanged", err) + } + entries, err = repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after changed snapshot: %v", err) + } + if len(entries) != 2 || entries[0].ID != first.ID || entries[0].Content != "edited" || entries[1].ID != second.ID { + t.Fatalf("entries changed after changed snapshot = %#v", entries) + } + + reserved := insertTestEntry(t, repo, "session-1", "task-1", "reserved", QueuedByWorkflow, nil, + map[string]interface{}{MetadataLifecycleDurable: true}) + if _, err := repo.TakeHead(ctx, "session-1"); err != nil { + t.Fatalf("take ordinary head: %v", err) + } + if _, err := repo.TakeHead(ctx, "session-1"); err != nil { + t.Fatalf("take second ordinary head: %v", err) + } + after := insertTestEntry(t, repo, "session-1", "task-1", "after", QueuedByUser, nil, nil) + if _, err := repo.ReserveHead(ctx, "session-1"); err != nil { + t.Fatalf("reserve head: %v", err) + } + if _, err := repo.ClaimSendNow(ctx, "session-1", []QueuedMessage{*reserved, *after}); !errors.Is(err, ErrSendNowReservationConflict) { + t.Fatalf("reserved claim error = %v, want ErrSendNowReservationConflict", err) + } + entries, err = repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after reserved claim: %v", err) + } + if len(entries) != 2 || entries[0].ID != reserved.ID || entries[1].ID != after.ID || !entries[0].IsReservedInFlight() { + t.Fatalf("entries changed after reserved claim = %#v", entries) + } + }) + } +} + +func TestSameQueuedMessageContentIncludesStoredSnapshot(t *testing.T) { + base := &QueuedMessage{ + ID: "entry-1", + SessionID: "session-1", + TaskID: "task-1", + Position: 1, + Content: "original", + Model: "model-a", + PlanMode: true, + Metadata: map[string]interface{}{"source": "original"}, + QueuedBy: QueuedByUser, + } + + changedMetadata := *base + changedMetadata.Metadata = map[string]interface{}{"source": "edited"} + if sameQueuedMessageContent(base, &changedMetadata) { + t.Fatal("metadata edit matched the click-time queue snapshot") + } + + changedModel := *base + changedModel.Model = "model-b" + if sameQueuedMessageContent(base, &changedModel) { + t.Fatal("model edit matched the click-time queue snapshot") + } +} + +func TestSendNowRestoreDiscardsSourcesFromPurgedTaskGeneration(t *testing.T) { + tests := []struct { + name string + new func(*testing.T) Repository + }{ + {name: "memory", new: func(*testing.T) Repository { return NewMemoryRepository() }}, + {name: "sqlite", new: newTestSQLiteRepo}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := tt.new(t) + ctx := context.Background() + entry := insertTestEntry(t, repo, "session-1", "task-1", "discard after purge", QueuedByUser, nil, nil) + claim, err := repo.ClaimSendNow(ctx, "session-1", []QueuedMessage{*entry}) + if err != nil { + t.Fatalf("claim: %v", err) + } + if got := claim.SourceGenerations["task-1"]; got != 0 { + t.Fatalf("claim generation = %d, want initial generation 0", got) + } + + if _, err := repo.PurgeTask(ctx, "task-1"); err != nil { + t.Fatalf("purge task: %v", err) + } + if err := repo.RestoreSendNowClaim(ctx, claim); err != nil { + t.Fatalf("restore after purge: %v", err) + } + entries, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after restore: %v", err) + } + if len(entries) != 0 { + t.Fatalf("restored entries after task purge = %#v, want none", entries) + } + }) + } +} + +func TestSendNowRestoreKeepsCurrentDurableMetadataAndRecordedMarker(t *testing.T) { + repo := NewMemoryRepository().(*memoryRepository) + ctx := context.Background() + entry := insertTestEntry(t, repo, "session-1", "task-1", "durable", QueuedByWorkflow, nil, + map[string]interface{}{ + MetadataLifecycleDurable: true, + "origin": "github_pr_automation", + "custom": "before-claim", + }) + claim, err := repo.ClaimSendNow(ctx, "session-1", []QueuedMessage{*entry}) + if err != nil { + t.Fatalf("claim: %v", err) + } + + // Simulate a metadata update made while the durable row was reserved. The + // restore must not overwrite it with the click-time snapshot. + stored := repo.entries["session-1"][0] + stored.Metadata["custom"] = "edited-while-reserved" + claim.Sources[0].Metadata[metadataUserMessageRecorded] = true + + if err := repo.RestoreSendNowClaim(ctx, claim); err != nil { + t.Fatalf("restore: %v", err) + } + entries, err := repo.ListBySession(ctx, "session-1") + if err != nil { + t.Fatalf("list after restore: %v", err) + } + if got := entries[0].Metadata["custom"]; got != "edited-while-reserved" { + t.Fatalf("restored current metadata = %#v, want edited value", got) + } + if recorded, _ := entries[0].Metadata[metadataUserMessageRecorded].(bool); !recorded { + t.Fatalf("restored metadata lost recorded marker: %#v", entries[0].Metadata) + } +} diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index ce5b639a0a..444f921adf 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -729,6 +729,411 @@ func (r *sqliteRepository) TakeByID(ctx context.Context, sessionID, entryID stri return msg, nil } +func (r *sqliteRepository) ClaimSendNow(ctx context.Context, sessionID string, expected []QueuedMessage) (*SendNowClaim, error) { + if len(expected) == 0 { + return nil, ErrSendNowEmpty + } + unlock := r.withSessionLock(sessionID) + defer unlock() + + requested, err := requestedSendNowIDs(expected) + if err != nil { + return nil, err + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin send-now claim tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + ordered, storedByID, err := r.listOrderedStoredSessionEntries(ctx, tx, sessionID) + if err != nil { + return nil, err + } + sources, err := selectSQLiteSendNowSources(ordered, storedByID, requested, expected) + if err != nil { + return nil, err + } + envelope, err := BuildSendNowEnvelope(sources) + if err != nil { + return nil, err + } + generations := make(map[string]int64) + for _, source := range sources { + if source.TaskID == "" { + continue + } + generation, generationErr := lifecycleGenerationInTx(ctx, tx, r.db, source.TaskID) + if generationErr != nil { + return nil, generationErr + } + generations[source.TaskID] = generation + } + + if err := r.applySQLiteSendNowClaim(ctx, tx, sessionID, sources, storedByID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &SendNowClaim{Sources: sources, Dispatch: *envelope, SourceGenerations: generations}, nil +} + +func (r *sqliteRepository) RestoreSendNowClaim(ctx context.Context, claim *SendNowClaim) error { + tx, sessionID, unlock, err := r.beginSendNowClaimTx(ctx, claim, "restore") + if err != nil { + return err + } + defer unlock() + defer func() { _ = tx.Rollback() }() + stored, err := r.listStoredSessionEntries(ctx, tx, sessionID) + if err != nil { + return err + } + generations := make(map[string]int64) + for _, source := range claim.Sources { + if source.TaskID == "" { + continue + } + generation, generationErr := lifecycleGenerationInTx(ctx, tx, r.db, source.TaskID) + if generationErr != nil { + return generationErr + } + generations[source.TaskID] = generation + } + if err := validateSQLiteSendNowRestore(claim, sessionID, stored, generations); err != nil { + return err + } + + for _, source := range claim.Sources { + if sendNowSourceGenerationChanged(claim, source, generations[source.TaskID]) { + continue + } + if err := r.restoreSQLiteSendNowSource(ctx, tx, sessionID, source, stored); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *sqliteRepository) AcknowledgeSendNowClaim(ctx context.Context, claim *SendNowClaim) error { + tx, sessionID, unlock, err := r.beginSendNowClaimTx(ctx, claim, "acknowledge") + if err != nil { + return err + } + defer unlock() + defer func() { _ = tx.Rollback() }() + stored, err := r.listStoredSessionEntries(ctx, tx, sessionID) + if err != nil { + return err + } + if err := validateSQLiteSendNowAcknowledge(claim, sessionID, stored); err != nil { + return err + } + for _, source := range claim.Sources { + if err := r.acknowledgeSQLiteSendNowSource(ctx, tx, sessionID, source, stored); err != nil { + return err + } + } + return tx.Commit() +} + +func (r *sqliteRepository) beginSendNowClaimTx( + ctx context.Context, + claim *SendNowClaim, + action string, +) (*sqlx.Tx, string, func(), error) { + if claim == nil || len(claim.Sources) == 0 { + return nil, "", nil, ErrSendNowEmpty + } + sessionID := claim.Sources[0].SessionID + unlock := r.withSessionLock(sessionID) + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + unlock() + return nil, "", nil, fmt.Errorf("begin send-now %s tx: %w", action, err) + } + return tx, sessionID, unlock, nil +} + +type storedQueueEntry struct { + message *QueuedMessage + raw string + attachmentsJSON string +} + +func (r *sqliteRepository) listStoredSessionEntries(ctx context.Context, tx *sqlx.Tx, sessionID string) (map[string]storedQueueEntry, error) { + _, entries, err := r.listOrderedStoredSessionEntries(ctx, tx, sessionID) + return entries, err +} + +func (r *sqliteRepository) listOrderedStoredSessionEntries(ctx context.Context, tx *sqlx.Tx, sessionID string) ([]storedQueueEntry, map[string]storedQueueEntry, error) { + rows, err := tx.QueryxContext(ctx, r.db.Rebind(` + SELECT id, session_id, task_id, position, content, model, plan_mode, + attachments_json, metadata_json, queued_at, queued_by + FROM queued_messages + WHERE session_id = ? + ORDER BY position ASC + `), sessionID) + if err != nil { + return nil, nil, fmt.Errorf("list stored send-now entries: %w", err) + } + defer func() { _ = rows.Close() }() + ordered := make([]storedQueueEntry, 0) + entries := make(map[string]storedQueueEntry) + for rows.Next() { + message, raw, scanErr := scanQueuedRowWithMetadataJSON(rows) + if scanErr != nil { + return nil, nil, scanErr + } + attachmentsJSON, marshalErr := marshalAttachments(message.Attachments) + if marshalErr != nil { + return nil, nil, marshalErr + } + entry := storedQueueEntry{message: message, raw: raw, attachmentsJSON: attachmentsJSON} + ordered = append(ordered, entry) + entries[message.ID] = entry + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + return ordered, entries, nil +} + +func selectSQLiteSendNowSources( + ordered []storedQueueEntry, + byID map[string]storedQueueEntry, + requested map[string]struct{}, + expected []QueuedMessage, +) ([]QueuedMessage, error) { + for _, expectedEntry := range expected { + entryID := expectedEntry.ID + entry, ok := byID[entryID] + if !ok { + return nil, ErrSendNowClaimChanged + } + if entry.message.IsReservedInFlight() { + return nil, ErrSendNowReservationConflict + } + } + + selected := make([]*QueuedMessage, 0, len(expected)) + for _, entry := range ordered { + if _, ok := requested[entry.message.ID]; ok { + selected = append(selected, entry.message) + } + } + if len(selected) != len(expected) { + return nil, ErrSendNowClaimChanged + } + if err := validateSendNowSnapshot(selected, expected); err != nil { + return nil, err + } + return cloneSendNowSources(selected), nil +} + +func (r *sqliteRepository) applySQLiteSendNowClaim( + ctx context.Context, + tx *sqlx.Tx, + sessionID string, + sources []QueuedMessage, + storedByID map[string]storedQueueEntry, +) error { + for _, source := range sources { + storedEntry := storedByID[source.ID] + if source.IsDurableLifecycle() { + if err := r.reserveSQLiteSendNowSource(ctx, tx, sessionID, source, storedEntry); err != nil { + return err + } + continue + } + if err := r.removeSQLiteSendNowSource(ctx, tx, sessionID, source, storedEntry); err != nil { + return err + } + } + return nil +} + +func (r *sqliteRepository) reserveSQLiteSendNowSource( + ctx context.Context, + tx *sqlx.Tx, + sessionID string, + source QueuedMessage, + stored storedQueueEntry, +) error { + metadataJSON, err := marshalMetadata(markReservedMetadata(source.Metadata)) + if err != nil { + return err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE queued_messages SET metadata_json = ? + WHERE id = ? AND session_id = ? AND metadata_json = ? + `), metadataJSON, source.ID, sessionID, stored.raw) + if err != nil { + return fmt.Errorf("reserve send-now lifecycle entry: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return ErrSendNowClaimChanged + } + return nil +} + +func (r *sqliteRepository) removeSQLiteSendNowSource( + ctx context.Context, + tx *sqlx.Tx, + sessionID string, + source QueuedMessage, + stored storedQueueEntry, +) error { + result, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM queued_messages + WHERE id = ? AND session_id = ? AND content = ? AND attachments_json = ? AND metadata_json = ? + `), source.ID, sessionID, source.Content, stored.attachmentsJSON, stored.raw) + if err != nil { + return fmt.Errorf("remove send-now entry: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return ErrSendNowClaimChanged + } + return nil +} + +func validateSQLiteSendNowRestore( + claim *SendNowClaim, + sessionID string, + stored map[string]storedQueueEntry, + generations map[string]int64, +) error { + for _, source := range claim.Sources { + if source.SessionID != sessionID { + return ErrSendNowClaimChanged + } + if sendNowSourceGenerationChanged(claim, source, generations[source.TaskID]) { + continue + } + entry, ok := stored[source.ID] + if !ok { + if source.IsDurableLifecycle() { + return ErrSendNowClaimChanged + } + continue + } + if entry.message.IsReservedInFlight() && !source.IsDurableLifecycle() { + return ErrSendNowClaimChanged + } + } + return nil +} + +func (r *sqliteRepository) restoreSQLiteSendNowSource( + ctx context.Context, + tx *sqlx.Tx, + sessionID string, + source QueuedMessage, + stored map[string]storedQueueEntry, +) error { + entry, ok := stored[source.ID] + if !ok { + return r.insertSQLiteSendNowSource(ctx, tx, source) + } + if !source.IsDurableLifecycle() || !entry.message.IsReservedInFlight() { + return nil + } + metadataJSON, err := marshalMetadata(restoreSendNowMetadata(entry.message.Metadata, source.Metadata)) + if err != nil { + return err + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE queued_messages SET metadata_json = ? + WHERE id = ? AND session_id = ? AND metadata_json = ? + `), metadataJSON, source.ID, sessionID, entry.raw) + if err != nil { + return fmt.Errorf("clear send-now lifecycle reservation: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return ErrSendNowClaimChanged + } + return nil +} + +func (r *sqliteRepository) insertSQLiteSendNowSource(ctx context.Context, tx *sqlx.Tx, source QueuedMessage) error { + attachmentsJSON, err := marshalAttachments(source.Attachments) + if err != nil { + return err + } + metadataJSON, err := marshalMetadata(clearReservedMetadata(source.Metadata)) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO queued_messages + (id, session_id, task_id, position, content, model, plan_mode, attachments_json, metadata_json, queued_at, queued_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), source.ID, source.SessionID, source.TaskID, source.Position, source.Content, source.Model, + boolToInt(source.PlanMode), attachmentsJSON, metadataJSON, source.QueuedAt, source.QueuedBy); err != nil { + return fmt.Errorf("restore send-now entry: %w", err) + } + return nil +} + +func validateSQLiteSendNowAcknowledge(claim *SendNowClaim, sessionID string, stored map[string]storedQueueEntry) error { + for _, source := range claim.Sources { + if source.SessionID != sessionID { + return ErrSendNowClaimChanged + } + if !source.IsDurableLifecycle() { + continue + } + if entry, ok := stored[source.ID]; ok && !entry.message.IsReservedInFlight() { + return ErrSendNowClaimChanged + } + } + return nil +} + +func (r *sqliteRepository) acknowledgeSQLiteSendNowSource( + ctx context.Context, + tx *sqlx.Tx, + sessionID string, + source QueuedMessage, + stored map[string]storedQueueEntry, +) error { + if !source.IsDurableLifecycle() { + return nil + } + entry, ok := stored[source.ID] + if !ok { + return nil + } + result, err := tx.ExecContext(ctx, r.db.Rebind(` + DELETE FROM queued_messages WHERE id = ? AND session_id = ? AND metadata_json = ? + `), source.ID, sessionID, entry.raw) + if err != nil { + return fmt.Errorf("acknowledge send-now lifecycle entry: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return ErrSendNowClaimChanged + } + return nil +} + func (r *sqliteRepository) UpdateContent(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, queuedBy string) error { return r.UpdateContentAndMetadata(ctx, sessionID, entryID, content, attachments, nil, queuedBy) } diff --git a/apps/backend/internal/orchestrator/messagequeue/send_now.go b/apps/backend/internal/orchestrator/messagequeue/send_now.go new file mode 100644 index 0000000000..e36aa0762a --- /dev/null +++ b/apps/backend/internal/orchestrator/messagequeue/send_now.go @@ -0,0 +1,207 @@ +package messagequeue + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/messageconstraints" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + metadataUserMessageRecorded = "user_message_recorded" + + // MetadataSendNowSources records the source identity and provenance that + // was folded into a replacement prompt. The original rows remain in the + // SendNowClaim so retry and acknowledgement can address them exactly. + MetadataSendNowSources = "send_now_sources" +) + +// restoreSendNowMetadata keeps the latest persisted metadata while carrying +// forward the monotonic transcript marker set after a replacement prompt was +// recorded. Other claim-time metadata must never overwrite edits made while +// the source was reserved. +func restoreSendNowMetadata(current, claim map[string]interface{}) map[string]interface{} { + metadata := clearReservedMetadata(current) + if recorded, _ := claim[metadataUserMessageRecorded].(bool); recorded { + metadata[metadataUserMessageRecorded] = true + } + return metadata +} + +var ( + ErrSendNowEmpty = errors.New("send-now queue selection is empty") + ErrSendNowClaimChanged = errors.New("send-now queue selection changed") + ErrSendNowReservationConflict = errors.New("send-now queue selection is reserved") + ErrSendNowAttachmentOverflow = errors.New("send-now attachments exceed the message limits") + ErrSendNowReferenceOverflow = errors.New("send-now references exceed the message limit") +) + +// SendNowClaim is the durable handoff for an interrupt-and-replace dispatch. +// Sources are retained in FIFO order so every ordinary entry can be restored +// at its original position and every durable lifecycle row can be acknowledged +// only after the replacement prompt is accepted. +type SendNowClaim struct { + Sources []QueuedMessage `json:"sources"` + Dispatch QueuedMessage `json:"dispatch"` + SourceGenerations map[string]int64 `json:"source_generations,omitempty"` +} + +func sendNowSourceGenerationChanged(claim *SendNowClaim, source QueuedMessage, current int64) bool { + if claim == nil || source.TaskID == "" || claim.SourceGenerations == nil { + return false + } + expected, ok := claim.SourceGenerations[source.TaskID] + return ok && expected != current +} + +// ValidateSendNowEntries checks aggregate admission limits without building a +// dispatch envelope. Callers use it before interrupting an active turn; the +// repository still validates the exact snapshot again inside its claim +// transaction. +func ValidateSendNowEntries(entries []QueuedMessage) error { + if len(entries) == 0 { + return ErrSendNowEmpty + } + + attachmentCount := 0 + attachmentBytes := int64(0) + seenReferences := make(map[string]struct{}) + referenceCount := 0 + for _, entry := range entries { + attachmentCount += len(entry.Attachments) + if attachmentCount > messageconstraints.MaxAttachmentCount { + return fmt.Errorf("%w: at most %d attachments are allowed", ErrSendNowAttachmentOverflow, messageconstraints.MaxAttachmentCount) + } + for _, attachment := range entry.Attachments { + bytes, err := attachmentPayloadBytes(attachment) + if err != nil { + return fmt.Errorf("%w: %v", ErrSendNowAttachmentOverflow, err) + } + attachmentBytes += bytes + if attachmentBytes > messageconstraints.MaxAttachmentBytes { + return fmt.Errorf("%w: total attachment size exceeds %d bytes", ErrSendNowAttachmentOverflow, messageconstraints.MaxAttachmentBytes) + } + } + for _, reference := range entityrefs.NormalizePersisted(entry.Metadata[MetadataEntityReferences]) { + if _, duplicate := seenReferences[reference.Ref]; duplicate { + continue + } + seenReferences[reference.Ref] = struct{}{} + referenceCount++ + if referenceCount > entityrefs.MaxReferencesPerMessage { + return fmt.Errorf("%w: at most %d references are allowed", ErrSendNowReferenceOverflow, entityrefs.MaxReferencesPerMessage) + } + } + } + return nil +} + +func requestedSendNowIDs(expected []QueuedMessage) (map[string]struct{}, error) { + requested := make(map[string]struct{}, len(expected)) + for _, entry := range expected { + if entry.ID == "" { + return nil, ErrSendNowClaimChanged + } + if _, duplicate := requested[entry.ID]; duplicate { + return nil, ErrSendNowClaimChanged + } + requested[entry.ID] = struct{}{} + } + return requested, nil +} + +func validateSendNowSnapshot(selected []*QueuedMessage, expected []QueuedMessage) error { + if len(selected) != len(expected) { + return ErrSendNowClaimChanged + } + for index, entry := range selected { + if !sameQueuedMessageContent(entry, &expected[index]) { + return ErrSendNowClaimChanged + } + } + return nil +} + +// BuildSendNowEnvelope validates and combines an exact, FIFO-ordered source +// snapshot. It performs no repository mutation, which lets callers validate a +// bulk selection before interrupting an active turn. +func BuildSendNowEnvelope(entries []QueuedMessage) (*QueuedMessage, error) { + if err := ValidateSendNowEntries(entries); err != nil { + return nil, err + } + + contentParts := make([]string, 0, len(entries)) + attachments := make([]MessageAttachment, 0) + seenReferences := make(map[string]struct{}) + references := make([]apiv1.EntityReference, 0) + sources := make([]map[string]interface{}, 0, len(entries)) + + for _, entry := range entries { + if entry.Content != "" { + contentParts = append(contentParts, entry.Content) + } + attachments = append(attachments, entry.Attachments...) + + for _, reference := range entityrefs.NormalizePersisted(entry.Metadata[MetadataEntityReferences]) { + if _, duplicate := seenReferences[reference.Ref]; duplicate { + continue + } + seenReferences[reference.Ref] = struct{}{} + references = append(references, reference) + } + + sources = append(sources, map[string]interface{}{ + "id": entry.ID, + "task_id": entry.TaskID, + "position": entry.Position, + "queued_by": entry.QueuedBy, + "queued_at": entry.QueuedAt, + "metadata": copyMessageMetadata(entry.Metadata, 0), + }) + } + + oldest := entries[0] + metadata := copyMessageMetadata(oldest.Metadata, 2) + if len(references) == 0 { + delete(metadata, MetadataEntityReferences) + } else { + metadata[MetadataEntityReferences] = references + } + metadata[MetadataSendNowSources] = sources + + return &QueuedMessage{ + ID: "send-now-" + uuid.NewString(), + SessionID: oldest.SessionID, + TaskID: oldest.TaskID, + Position: oldest.Position, + Content: strings.Join(contentParts, "\n\n"), + Model: oldest.Model, + PlanMode: oldest.PlanMode, + Attachments: attachments, + Metadata: metadata, + QueuedAt: oldest.QueuedAt, + QueuedBy: oldest.QueuedBy, + }, nil +} + +func attachmentPayloadBytes(attachment MessageAttachment) (int64, error) { + if attachment.AttachmentID != "" { + if attachment.SizeBytes < 0 { + return 0, errors.New("attachment size is negative") + } + return attachment.SizeBytes, nil + } + if attachment.Data == "" { + return 0, errors.New("attachment data is empty") + } + decoded, err := base64.StdEncoding.DecodeString(attachment.Data) + if err != nil { + return 0, fmt.Errorf("attachment data is not valid base64: %w", err) + } + return int64(len(decoded)), nil +} diff --git a/apps/backend/internal/orchestrator/messagequeue/send_now_test.go b/apps/backend/internal/orchestrator/messagequeue/send_now_test.go new file mode 100644 index 0000000000..1cf8bca525 --- /dev/null +++ b/apps/backend/internal/orchestrator/messagequeue/send_now_test.go @@ -0,0 +1,144 @@ +package messagequeue + +import ( + "errors" + "strconv" + "testing" + "time" + + "github.com/kandev/kandev/internal/entityrefs" + apiv1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestBuildSendNowEnvelopeAggregatesInFIFOOrder(t *testing.T) { + queuedAt := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC) + entries := []QueuedMessage{ + { + ID: "first", SessionID: "session-1", TaskID: "task-1", Position: 10, + Content: "first body", Model: "model-a", PlanMode: true, + QueuedAt: queuedAt, QueuedBy: QueuedByUser, + Attachments: []MessageAttachment{{Type: "image", Data: "aGVsbG8=", MimeType: "image/png", SizeBytes: 5}}, + Metadata: map[string]interface{}{ + MetadataEntityReferences: []apiv1.EntityReference{testEntityReference("1", "first")}, + "origin": "user-input", + }, + }, + { + ID: "second", SessionID: "session-1", TaskID: "task-2", Position: 20, + Content: "second body", Model: "model-b", PlanMode: false, + QueuedAt: queuedAt.Add(time.Minute), QueuedBy: QueuedByAgent, + Attachments: []MessageAttachment{{Type: "image", Data: "d29ybGQ=", MimeType: "image/jpeg", SizeBytes: 5}}, + Metadata: map[string]interface{}{ + MetadataEntityReferences: []apiv1.EntityReference{ + testEntityReference("1", "duplicate"), + testEntityReference("2", "second"), + }, + MetadataSenderTaskID: "task-2", + }, + }, + } + + envelope, err := BuildSendNowEnvelope(entries) + if err != nil { + t.Fatalf("BuildSendNowEnvelope() error = %v", err) + } + if envelope.SessionID != "session-1" || envelope.TaskID != "task-1" { + t.Fatalf("envelope identity = (%q, %q), want oldest entry identity", envelope.SessionID, envelope.TaskID) + } + if envelope.Content != "first body\n\nsecond body" { + t.Fatalf("envelope content = %q", envelope.Content) + } + if envelope.Model != "model-a" || !envelope.PlanMode || envelope.QueuedBy != QueuedByUser { + t.Fatalf("envelope oldest fields = model %q, plan %t, queued_by %q", envelope.Model, envelope.PlanMode, envelope.QueuedBy) + } + if got := len(envelope.Attachments); got != 2 || envelope.Attachments[0].Data != "aGVsbG8=" || envelope.Attachments[1].Data != "d29ybGQ=" { + t.Fatalf("envelope attachments = %#v", envelope.Attachments) + } + references := entityrefs.NormalizePersisted(envelope.Metadata[MetadataEntityReferences]) + if got := len(references); got != 2 || references[0].Ref != testEntityReference("1", "").Ref || references[1].Ref != testEntityReference("2", "").Ref { + t.Fatalf("envelope references = %#v", references) + } + sources, ok := envelope.Metadata[MetadataSendNowSources].([]map[string]interface{}) + if !ok || len(sources) != 2 || sources[0]["id"] != "first" || sources[1]["queued_by"] != QueuedByAgent { + t.Fatalf("envelope source provenance = %#v", envelope.Metadata[MetadataSendNowSources]) + } +} + +func TestBuildSendNowEnvelopeAllowsAttachmentOnlyEntries(t *testing.T) { + entries := []QueuedMessage{ + {ID: "first", SessionID: "session-1", Position: 1, Attachments: []MessageAttachment{{Data: "aA==", SizeBytes: 1}}}, + {ID: "second", SessionID: "session-1", Position: 2, Content: "body"}, + } + + envelope, err := BuildSendNowEnvelope(entries) + if err != nil { + t.Fatalf("BuildSendNowEnvelope() error = %v", err) + } + if envelope.Content != "body" { + t.Fatalf("envelope content = %q, want body without a leading separator", envelope.Content) + } +} + +func TestBuildSendNowEnvelopeRejectsAggregateLimits(t *testing.T) { + tests := []struct { + name string + entries []QueuedMessage + wantErr error + }{ + { + name: "attachments", + entries: []QueuedMessage{{ + SessionID: "session-1", + Attachments: make([]MessageAttachment, 11), + }}, + wantErr: ErrSendNowAttachmentOverflow, + }, + { + name: "references", + entries: []QueuedMessage{ + {SessionID: "session-1", Metadata: map[string]interface{}{ + MetadataEntityReferences: makeEntityReferences(entityrefs.MaxReferencesPerMessage), + }}, + {SessionID: "session-1", Metadata: map[string]interface{}{ + MetadataEntityReferences: makeEntityReferences(101)[100:], + }}, + }, + wantErr: ErrSendNowReferenceOverflow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BuildSendNowEnvelope(tt.entries) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func testEntityReference(id, title string) apiv1.EntityReference { + if title == "" { + title = "Issue " + id + } + return apiv1.EntityReference{ + Version: apiv1.EntityReferenceVersion, + Ref: "mention:v1:github:issue:acme%2Frepo:" + id, + Provider: "github", + Kind: "issue", + ID: id, + Key: "acme/repo#" + id, + Title: title, + URL: "https://github.com/acme/repo/issues/" + id, + Scope: "acme/repo", + } +} + +func makeEntityReferences(count int) []apiv1.EntityReference { + references := make([]apiv1.EntityReference, count) + for i := range references { + id := strconv.Itoa(i) + references[i] = testEntityReference(id, "Issue "+id) + } + return references +} diff --git a/apps/backend/internal/orchestrator/messagequeue/service.go b/apps/backend/internal/orchestrator/messagequeue/service.go index 567754c3af..95e393faca 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service.go +++ b/apps/backend/internal/orchestrator/messagequeue/service.go @@ -63,6 +63,12 @@ func NewServiceMemory(log *logger.Logger) *Service { // MaxPerSession returns the configured per-session cap. func (s *Service) MaxPerSession() int { return int(s.maxPerSession.Load()) } +// LifecycleGeneration returns the task archive/delete generation captured by +// send-now restoration guards. +func (s *Service) LifecycleGeneration(ctx context.Context, taskID string) (int64, error) { + return s.repo.LifecycleGeneration(ctx, taskID) +} + // SetMaxPerSession applies a new admission cap without pruning existing rows. // Non-positive values disable the cap. func (s *Service) SetMaxPerSession(maxPerSession int) { @@ -519,6 +525,52 @@ func (s *Service) GetEntry(ctx context.Context, sessionID, entryID string) (*Que return nil, ErrEntryNotFound } +// ClaimSendNow atomically claims the exact pending source snapshot for an +// interrupt-and-replace dispatch. The repository orders the retained sources +// by FIFO position and constructs the synthetic dispatch envelope before +// mutating any row, so aggregate validation failures or click-time edits leave +// the queue untouched. +func (s *Service) ClaimSendNow(ctx context.Context, sessionID string, expected []QueuedMessage) (*SendNowClaim, error) { + claim, err := s.repo.ClaimSendNow(ctx, sessionID, expected) + if err != nil { + return nil, err + } + s.logger.Info("claimed queued messages for send now", + zap.String("session_id", sessionID), + zap.Int("source_count", len(claim.Sources))) + return claim, nil +} + +// RestoreSendNowClaim restores every source from an interrupted replacement +// dispatch. Durable lifecycle reservations are cleared as part of the same +// repository operation. +func (s *Service) RestoreSendNowClaim(ctx context.Context, claim *SendNowClaim) error { + if err := s.repo.RestoreSendNowClaim(ctx, claim); err != nil { + return err + } + if claim != nil { + s.logger.Info("restored send-now queue claim", + zap.String("session_id", claim.Dispatch.SessionID), + zap.Int("source_count", len(claim.Sources))) + } + return nil +} + +// AcknowledgeSendNowClaim removes durable lifecycle sources after the +// replacement prompt has been accepted. Ordinary sources were deleted at +// claim time and therefore need no second acknowledgement. +func (s *Service) AcknowledgeSendNowClaim(ctx context.Context, claim *SendNowClaim) error { + if err := s.repo.AcknowledgeSendNowClaim(ctx, claim); err != nil { + return err + } + if claim != nil { + s.logger.Info("acknowledged send-now queue claim", + zap.String("session_id", claim.Dispatch.SessionID), + zap.Int("source_count", len(claim.Sources))) + } + return nil +} + // UpdateMessageWithMetadata atomically edits queue content and applies // metadata replacements while retaining unrelated metadata keys. func (s *Service) UpdateMessageWithMetadata(ctx context.Context, sessionID, entryID, content string, attachments []MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error { diff --git a/apps/backend/internal/orchestrator/queue_send_now.go b/apps/backend/internal/orchestrator/queue_send_now.go new file mode 100644 index 0000000000..8cd399b7b0 --- /dev/null +++ b/apps/backend/internal/orchestrator/queue_send_now.go @@ -0,0 +1,517 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +const ( + QueueSendNowScopeEntry = "entry" + QueueSendNowScopeAll = "all" + + sendNowClaimRecoveryTimeout = 10 * time.Second +) + +var ( + ErrSendNowConflict = errors.New("send-now operation is already in progress") + ErrSendNowQueueEmpty = errors.New("send-now queue is empty") + ErrSendNowEntryNotFound = errors.New("send-now entry is no longer pending") + ErrSendNowQueueChanged = errors.New("send-now queue selection changed") + ErrSendNowTurnChanged = errors.New("send-now active turn changed") + ErrSendNowAttachmentOverflow = messagequeue.ErrSendNowAttachmentOverflow + ErrSendNowReferenceOverflow = messagequeue.ErrSendNowReferenceOverflow + errSendNowDispatchNotTracked = errors.New("send-now dispatch is no longer tracked") +) + +// SendQueuedNow claims either one exact entry or the click-time FIFO snapshot +// of all visible entries. A busy session is silently cancelled through the +// shared cancellation coordinator, then the exact claim is handed to one +// replacement prompt. The explicit Cancel path is deliberately not involved. +func (s *Service) SendQueuedNow(ctx context.Context, sessionID, scope, entryID string) (int, error) { + if err := s.authorizeSession(ctx, sessionID); err != nil { + return 0, err + } + if err := validateSendNowInput(sessionID, scope, entryID); err != nil { + return 0, err + } + if s.messageQueue == nil { + return 0, errors.New("message queue is not configured") + } + + turnBefore, err := s.captureSendNowTurn(ctx, sessionID) + if err != nil { + return 0, err + } + return s.sendQueuedNowAfterCapture(ctx, sessionID, scope, entryID, turnBefore) +} + +type sendNowGuard struct { + lock *sync.Mutex + release func() + locked bool +} + +func newSendNowGuard(s *Service, sessionID string) *sendNowGuard { + lock, release := s.acquireCancelInFlightGuard(sessionID) + lock.Lock() + return &sendNowGuard{lock: lock, release: release, locked: true} +} + +func (guard *sendNowGuard) unlock() { + if !guard.locked { + return + } + guard.lock.Unlock() + guard.locked = false +} + +func (guard *sendNowGuard) relock() { + if guard.locked { + return + } + guard.lock.Lock() + guard.locked = true +} + +func (guard *sendNowGuard) close() { + guard.unlock() + guard.release() +} + +func (s *Service) sendQueuedNowAfterCapture( + ctx context.Context, + sessionID, scope, entryID, turnBefore string, +) (int, error) { + guard := newSendNowGuard(s, sessionID) + defer guard.close() + + if s.currentCancellation(sessionID) != nil || s.isQueuedDispatchAccepted(sessionID) { + return 0, ErrSendNowConflict + } + if err := s.restorePendingQueuedDispatchForSendNow(ctx, sessionID); err != nil { + return 0, err + } + if err := s.verifySendNowTurn(ctx, sessionID, turnBefore); err != nil { + return 0, err + } + + taskID, sessionState, entries, err := s.loadSendNowSelection(ctx, sessionID, scope, entryID) + if err != nil { + return 0, err + } + return s.dispatchSendNowSelection( + ctx, + sessionID, + taskID, + sessionState, + scope, + entries, + turnBefore, + guard.unlock, + guard.relock, + ) +} + +func (s *Service) restorePendingQueuedDispatchForSendNow(ctx context.Context, sessionID string) error { + if !s.isQueuedDispatchInFlight(sessionID) { + return nil + } + reservation, err := s.pendingQueuedDispatchForSendNow(sessionID) + if err != nil { + return err + } + if reservation == nil || reservation.source == nil { + return ErrSendNowConflict + } + restore, err := s.sendNowRestoreClaimForReservation(ctx, reservation) + if err != nil { + return err + } + if err := s.messageQueue.RestoreSendNowClaim(ctx, restore); err != nil { + s.logger.Warn("failed to restore FIFO reservation for send now", + zap.String("session_id", sessionID), + zap.String("queue_id", reservation.source.ID), + zap.Error(err), + ) + s.publishQueueStatusEvent(ctx, sessionID) + return ErrSendNowQueueChanged + } + return s.supersedeQueuedDispatchForSendNow(sessionID, reservation) +} + +func (s *Service) sendNowRestoreClaimForReservation( + ctx context.Context, + reservation *queuedDispatchReservation, +) (*messagequeue.SendNowClaim, error) { + restore := &messagequeue.SendNowClaim{ + Sources: []messagequeue.QueuedMessage{*reservation.source}, + SourceGenerations: make(map[string]int64), + } + if reservation.source.TaskID == "" { + return restore, nil + } + generation, err := s.messageQueue.LifecycleGeneration(ctx, reservation.source.TaskID) + if err != nil { + return nil, ErrSendNowQueueChanged + } + restore.SourceGenerations[reservation.source.TaskID] = generation + return restore, nil +} + +func validateSendNowInput(sessionID, scope, entryID string) error { + switch { + case sessionID == "": + return errors.New("session_id is required") + case scope != QueueSendNowScopeEntry && scope != QueueSendNowScopeAll: + return fmt.Errorf("invalid send-now scope %q", scope) + case scope == QueueSendNowScopeEntry && entryID == "": + return errors.New("entry_id is required for entry scope") + case scope == QueueSendNowScopeAll && entryID != "": + return errors.New("entry_id is not allowed for all scope") + default: + return nil + } +} + +func (s *Service) captureSendNowTurn(ctx context.Context, sessionID string) (string, error) { + turnID, err := s.peekActiveTurnID(ctx, sessionID) + if err != nil { + return "", fmt.Errorf("%w: inspect active turn: %v", ErrSendNowTurnChanged, err) + } + return turnID, nil +} + +func (s *Service) verifySendNowTurn(ctx context.Context, sessionID, expectedTurnID string) error { + if s.turnService == nil { + return nil + } + turnID, err := s.peekActiveTurnID(ctx, sessionID) + if err != nil || turnID != expectedTurnID { + return ErrSendNowTurnChanged + } + return nil +} + +func (s *Service) loadSendNowSelection( + ctx context.Context, + sessionID, scope, entryID string, +) (string, models.TaskSessionState, []messagequeue.QueuedMessage, error) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return "", "", nil, fmt.Errorf("load session for send now: %w", err) + } + if session == nil { + return "", "", nil, ErrSessionNotPromptable + } + entries, _, err := selectSendNowEntries(s.messageQueue.GetStatus(ctx, sessionID), scope, entryID) + if err != nil { + return "", "", nil, err + } + if err := messagequeue.ValidateSendNowEntries(entries); err != nil { + return "", "", nil, err + } + return session.TaskID, session.State, entries, nil +} + +func (s *Service) dispatchSendNowSelection( + ctx context.Context, + sessionID, taskID string, + sessionState models.TaskSessionState, + scope string, + entries []messagequeue.QueuedMessage, + turnBefore string, + unlockGuard, relockGuard func(), +) (int, error) { + promptabilityErr := s.checkSessionPromptable(taskID, sessionID, sessionState) + if promptabilityErr == nil { + dispatched, err := s.claimAndDispatchSendNow(ctx, sessionID, scope, entries) + if err != nil { + return 0, err + } + if !dispatched { + return 0, ErrSendNowQueueChanged + } + return len(entries), nil + } + if !errors.Is(promptabilityErr, ErrAgentPromptInProgress) { + return 0, promptabilityErr + } + + dispatched, err := s.cancelAgentSilentWithGuardActionKindExclusive( + ctx, + taskID, + sessionID, + unlockGuard, + relockGuard, + func(actionCtx context.Context) (bool, error) { + return s.claimAndDispatchSendNow(actionCtx, sessionID, scope, entries) + }, + cancellationKindQueueSendNow, + turnBefore, + ) + if err != nil { + return 0, err + } + if !dispatched { + return 0, ErrSendNowQueueChanged + } + return len(entries), nil +} + +func selectSendNowEntries(status *messagequeue.QueueStatus, scope, entryID string) ([]messagequeue.QueuedMessage, []string, error) { + if status == nil || len(status.Entries) == 0 { + return nil, nil, ErrSendNowQueueEmpty + } + if scope == QueueSendNowScopeEntry { + for _, entry := range status.Entries { + if entry.ID == entryID { + return []messagequeue.QueuedMessage{entry}, []string{entry.ID}, nil + } + } + return nil, nil, ErrSendNowEntryNotFound + } + entries := append([]messagequeue.QueuedMessage(nil), status.Entries...) + entryIDs := make([]string, 0, len(entries)) + for _, entry := range entries { + entryIDs = append(entryIDs, entry.ID) + } + return entries, entryIDs, nil +} + +func (s *Service) claimAndDispatchSendNow(ctx context.Context, sessionID, scope string, entries []messagequeue.QueuedMessage) (bool, error) { + claim, err := s.messageQueue.ClaimSendNow(ctx, sessionID, entries) + if err != nil { + return false, mapSendNowClaimError(scope, err) + } + s.publishQueueStatusEvent(ctx, sessionID) + reservation := s.markQueuedDispatchInFlightWithSourceLocked(sessionID, claim.Dispatch.ID, nil) + if !s.launchSendNowClaim(claim, reservation) { + if restoreErr := s.restoreSendNowClaimWithRetry(context.Background(), claim); restoreErr != nil { + s.logger.Error("failed to restore send-now claim after service shutdown", + zap.String("session_id", sessionID), zap.Error(restoreErr)) + } + s.clearQueuedDispatchInFlightIfCurrent(sessionID, reservation) + s.publishQueueStatusEvent(context.Background(), sessionID) + return false, nil + } + return true, nil +} + +func mapSendNowClaimError(scope string, err error) error { + switch { + case errors.Is(err, messagequeue.ErrSendNowEmpty): + return ErrSendNowQueueChanged + case errors.Is(err, messagequeue.ErrSendNowReservationConflict): + return ErrSendNowConflict + case errors.Is(err, messagequeue.ErrSendNowClaimChanged): + if scope == QueueSendNowScopeEntry { + return ErrSendNowEntryNotFound + } + return ErrSendNowQueueChanged + default: + return err + } +} + +func (s *Service) launchSendNowClaim( + claim *messagequeue.SendNowClaim, + reservation *queuedDispatchReservation, +) bool { + if claim == nil { + return false + } + s.sendNowMu.Lock() + if s.sendNowStopped { + s.sendNowMu.Unlock() + return false + } + if s.sendNowCtx == nil { + s.sendNowCtx, s.sendNowCancel = context.WithCancel(context.Background()) + } + workerCtx := s.sendNowCtx + s.sendNowWorkers.Add(1) + s.sendNowMu.Unlock() + go func() { + defer s.sendNowWorkers.Done() + s.executeSendNowClaimWithContext(workerCtx, claim, reservation) + }() + return true +} + +func (s *Service) resetSendNowWorkers() { + s.sendNowMu.Lock() + defer s.sendNowMu.Unlock() + s.sendNowStopped = false + s.sendNowCtx, s.sendNowCancel = context.WithCancel(context.Background()) +} + +func (s *Service) stopSendNowWorkers() { + s.sendNowMu.Lock() + s.sendNowStopped = true + cancel := s.sendNowCancel + s.sendNowMu.Unlock() + if cancel != nil { + cancel() + } + done := make(chan struct{}) + go func() { + s.sendNowWorkers.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(sendNowClaimRecoveryTimeout): + s.logger.Warn("timed out waiting for send-now workers during shutdown") + } +} + +func (s *Service) executeSendNowClaim(claim *messagequeue.SendNowClaim) { + s.executeSendNowClaimWithContext(context.Background(), claim, nil) +} + +func (s *Service) executeSendNowClaimWithContext( + ctx context.Context, + claim *messagequeue.SendNowClaim, + reservation *queuedDispatchReservation, +) { + if claim == nil { + return + } + sessionID := claim.Dispatch.SessionID + if reservation == nil { + reservation = s.queuedDispatchReservationForEntry(sessionID, claim.Dispatch.ID) + } + defer s.clearQueuedDispatchInFlightIfCurrent(sessionID, reservation) + + restore := func() { + if err := s.restoreSendNowClaimWithRetry(ctx, claim); err != nil { + s.logger.Error("failed to restore send-now queue claim", + zap.String("session_id", sessionID), zap.Error(err)) + } + s.publishQueueStatusEvent(ctx, sessionID) + } + if s.isSessionResetInProgress(sessionID) { + restore() + return + } + if claimErr := s.claimSendNowExecution(sessionID, claim.Dispatch.ID); claimErr != nil { + s.logger.Warn("send-now dispatch lost prompt ownership; restoring queue claim", + zap.String("session_id", sessionID), zap.Error(claimErr)) + restore() + return + } + + if err := s.promptSendNowClaim(ctx, claim); err != nil { + s.logger.Warn("send-now replacement prompt failed; restoring queue claim", + zap.String("session_id", sessionID), zap.Error(err)) + restore() + return + } + if err := s.acknowledgeSendNowClaimWithRetry(ctx, claim); err != nil { + s.logger.Error("failed to acknowledge accepted send-now queue claim", + zap.String("session_id", sessionID), zap.Error(err)) + s.publishQueueStatusEvent(context.Background(), sessionID) + return + } + s.publishQueueStatusEvent(ctx, sessionID) +} + +func (s *Service) claimSendNowExecution(sessionID, dispatchID string) error { + tracked, err := s.claimQueuedDispatchForExecution(sessionID, dispatchID, nil) + if err != nil { + return err + } + if !tracked { + return errSendNowDispatchNotTracked + } + return nil +} + +func (s *Service) promptSendNowClaim(ctx context.Context, claim *messagequeue.SendNowClaim) error { + sessionID := claim.Dispatch.SessionID + attachments := make([]v1.MessageAttachment, len(claim.Dispatch.Attachments)) + for i, attachment := range claim.Dispatch.Attachments { + attachments[i] = v1.MessageAttachment{ + Type: attachment.Type, + AttachmentID: attachment.AttachmentID, + Data: attachment.Data, + MimeType: attachment.MimeType, + Name: attachment.Name, + SizeBytes: attachment.SizeBytes, + DeliveryMode: attachment.DeliveryMode, + } + } + references := entityrefs.NormalizePersisted(claim.Dispatch.Metadata[messagequeue.MetadataEntityReferences]) + promptContent := AppendEntityReferenceContext(claim.Dispatch.Content, references) + if err := s.recordQueuedUserMessage(ctx, &claim.Dispatch, attachments); err != nil { + s.logger.Warn("failed to record send-now user message before prompt", + zap.String("session_id", sessionID), zap.Error(err)) + } else if s.messageCreator != nil { + for i := range claim.Sources { + markQueuedUserMessageRecorded(&claim.Sources[i]) + } + } + if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil && session != nil { + // This transition is intentionally not rolled back if promptTask later + // rejects the replacement. The ordinary FIFO handoff uses the same + // ordering: workflow admission precedes executor prompt acceptance, and + // the restored claim is retried through the normal ready path. + s.processOnTurnStartViaEngine(ctx, claim.Dispatch.TaskID, session) + } + + _, err := s.promptTask(ctx, claim.Dispatch.TaskID, sessionID, promptContent, claim.Dispatch.Model, + claim.Dispatch.PlanMode, attachments, false, claim.Dispatch.ID, false, nil) + return err +} + +func (s *Service) restoreSendNowClaimWithRetry( + ctx context.Context, + claim *messagequeue.SendNowClaim, +) error { + return s.retrySendNowClaimMutation(ctx, func(recoveryCtx context.Context) error { + return s.messageQueue.RestoreSendNowClaim(recoveryCtx, claim) + }) +} + +func (s *Service) acknowledgeSendNowClaimWithRetry( + ctx context.Context, + claim *messagequeue.SendNowClaim, +) error { + return s.retrySendNowClaimMutation(ctx, func(recoveryCtx context.Context) error { + return s.messageQueue.AcknowledgeSendNowClaim(recoveryCtx, claim) + }) +} + +func (s *Service) retrySendNowClaimMutation( + ctx context.Context, + mutate func(context.Context) error, +) error { + recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sendNowClaimRecoveryTimeout) + defer cancel() + var err error + for attempt := 0; attempt < 3; attempt++ { + if err = mutate(recoveryCtx); err == nil { + return nil + } + if attempt == 2 { + break + } + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-recoveryCtx.Done(): + timer.Stop() + return recoveryCtx.Err() + case <-timer.C: + } + } + return err +} diff --git a/apps/backend/internal/orchestrator/queue_send_now_test.go b/apps/backend/internal/orchestrator/queue_send_now_test.go new file mode 100644 index 0000000000..46d6855995 --- /dev/null +++ b/apps/backend/internal/orchestrator/queue_send_now_test.go @@ -0,0 +1,307 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestSelectSendNowEntriesUsesExactEntryOrSnapshot(t *testing.T) { + status := &messagequeue.QueueStatus{Entries: []messagequeue.QueuedMessage{ + {ID: "first", Content: "one"}, + {ID: "second", Content: "two"}, + }} + + entry, ids, err := selectSendNowEntries(status, QueueSendNowScopeEntry, "second") + if err != nil { + t.Fatalf("entry selection error = %v", err) + } + if len(entry) != 1 || entry[0].ID != "second" || len(ids) != 1 || ids[0] != "second" { + t.Fatalf("entry selection = %#v, %#v", entry, ids) + } + + all, ids, err := selectSendNowEntries(status, QueueSendNowScopeAll, "") + if err != nil { + t.Fatalf("all selection error = %v", err) + } + if len(all) != 2 || ids[0] != "first" || ids[1] != "second" { + t.Fatalf("all selection = %#v, %#v", all, ids) + } + all[0].Content = "mutated copy" + if status.Entries[0].Content != "one" { + t.Fatal("all selection mutated the authoritative status snapshot") + } +} + +func TestSelectSendNowEntriesRejectsEmptyAndRacedEntry(t *testing.T) { + for _, tc := range []struct { + name string + status *messagequeue.QueueStatus + scope string + entryID string + want error + }{ + {name: "empty queue", status: &messagequeue.QueueStatus{}, scope: QueueSendNowScopeAll, want: ErrSendNowQueueEmpty}, + {name: "missing entry", status: &messagequeue.QueueStatus{Entries: []messagequeue.QueuedMessage{{ID: "other"}}}, scope: QueueSendNowScopeEntry, entryID: "gone", want: ErrSendNowEntryNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := selectSendNowEntries(tc.status, tc.scope, tc.entryID) + if !errors.Is(err, tc.want) { + t.Fatalf("error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestSendNowWorkersCanRestartAfterStop(t *testing.T) { + svc := &Service{logger: testLogger()} + + svc.stopSendNowWorkers() + if !svc.sendNowStopped { + t.Fatal("stopping Send Now workers did not mark the worker owner stopped") + } + + svc.resetSendNowWorkers() + if svc.sendNowStopped { + t.Fatal("resetting Send Now workers left the worker owner stopped") + } + if svc.sendNowCtx == nil || svc.sendNowCancel == nil { + t.Fatal("resetting Send Now workers did not create a fresh cancellable context") + } + select { + case <-svc.sendNowCtx.Done(): + t.Fatal("fresh Send Now worker context is already cancelled") + default: + } + + svc.stopSendNowWorkers() +} + +func TestExplicitCancellationDoesNotJoinSendNowOperation(t *testing.T) { + operation := &cancelOperation{ + done: make(chan struct{}), + joined: make(chan struct{}), + kind: cancellationKindQueueSendNow, + } + svc := &Service{ + cancellationOperations: map[string]*cancelOperation{"session": operation}, + } + + _, owner, action := svc.claimExplicitCancellation("session", func(context.Context, *cancelOperation) (bool, error) { + t.Fatal("explicit cancellation action must not be registered for Send Now") + return false, nil + }) + if owner { + t.Fatal("explicit cancellation unexpectedly claimed the Send Now operation") + } + if action != nil { + t.Fatal("explicit cancellation unexpectedly joined the Send Now operation") + } + if len(operation.actions) != 0 { + t.Fatalf("Send Now operation gained %d explicit actions", len(operation.actions)) + } + select { + case <-operation.joined: + t.Fatal("explicit cancellation should not mark Send Now as joined") + default: + } +} + +func TestExecuteSendNowClaimRestorePreservesRecordedSources(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-1", "session-1", "step-1") + seedExecutorRunning(t, repo, "session-1", "task-1", "exec-1") + session, err := repo.GetTaskSession(ctx, "session-1") + if err != nil { + t.Fatalf("get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + agentMgr := &mockAgentManager{ + isAgentRunning: true, + promptErr: errors.New("replacement prompt rejected"), + repoForExecutionLookup: repo, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + messageCreator := &mockMessageCreator{} + svc.messageCreator = messageCreator + svc.activeTurns.Store("session-1", "turn-1") + + if _, err := svc.messageQueue.QueueMessageWithMetadata(ctx, "session-1", "task-1", "first", "", messagequeue.QueuedByUser, false, nil, nil); err != nil { + t.Fatalf("seed ordinary replacement source: %v", err) + } + if _, err := svc.messageQueue.QueueMessageWithMetadata(ctx, "session-1", "task-1", "second", "", messagequeue.QueuedByWorkflow, false, nil, + map[string]interface{}{messagequeue.MetadataLifecycleDurable: true}); err != nil { + t.Fatalf("seed durable replacement source: %v", err) + } + sources := svc.messageQueue.GetStatus(ctx, "session-1").Entries + if len(sources) != 2 { + t.Fatalf("seeded replacement source count = %d, want 2", len(sources)) + } + claimed, err := svc.messageQueue.ClaimSendNow(ctx, "session-1", []messagequeue.QueuedMessage{sources[0], sources[1]}) + if err != nil { + t.Fatalf("claim replacement sources: %v", err) + } + + // Production dispatch registers the handoff before starting the worker; + // preserve that ownership precondition for this direct worker test. + svc.markQueuedDispatchInFlight("session-1", claimed.Dispatch.ID) + svc.executeSendNowClaim(claimed) + if len(messageCreator.userMessages) != 1 { + t.Fatalf("replacement retry created %d user messages, want 1", len(messageCreator.userMessages)) + } + entries := svc.messageQueue.GetStatus(ctx, "session-1").Entries + if len(entries) != 2 { + t.Fatalf("restored source count = %d, want 2", len(entries)) + } + for _, entry := range entries { + if recorded, _ := entry.Metadata[metaKeyUserMessageRecorded].(bool); !recorded { + t.Fatalf("restored source %q lost user_message_recorded marker: %#v", entry.ID, entry.Metadata) + } + } +} + +func TestSendQueuedNowSupersedesPendingFIFOHandoff(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-1", "session-1", "step-1") + seedExecutorRunning(t, repo, "session-1", "task-1", "exec-1") + session, err := repo.GetTaskSession(ctx, "session-1") + if err != nil { + t.Fatalf("get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + agentMgr := &mockAgentManager{ + isAgentRunning: true, + promptDone: make(chan struct{}), + repoForExecutionLookup: repo, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.messageCreator = &mockMessageCreator{} + + reservationMetadata := map[string]interface{}{ + messagequeue.MetadataLifecycleDurable: true, + } + if _, err := svc.messageQueue.QueueMessageWithMetadata( + ctx, "session-1", "task-1", "first queued", "", messagequeue.QueuedByWorkflow, false, nil, reservationMetadata, + ); err != nil { + t.Fatalf("queue first message: %v", err) + } + if _, err := svc.messageQueue.QueueMessageWithMetadata( + ctx, "session-1", "task-1", "second queued", "", messagequeue.QueuedByUser, false, nil, nil, + ); err != nil { + t.Fatalf("queue second message: %v", err) + } + + reserved, ok := svc.messageQueue.ReserveQueued(ctx, "session-1") + if !ok || reserved == nil || reserved.Content != "first queued" { + t.Fatalf("reserve FIFO head: message=%#v ok=%v", reserved, ok) + } + reservation := svc.markQueuedDispatchInFlightWithSource("session-1", reserved.ID, reserved) + + sent, err := svc.SendQueuedNow(ctx, "session-1", QueueSendNowScopeAll, "") + if err != nil { + t.Fatalf("send now: %v", err) + } + if sent != 2 { + t.Fatalf("send now sent_count = %d, want 2", sent) + } + // The real FIFO worker may already be runnable when Send Now wins. Run its + // stale handoff synchronously here as well: it must observe the superseded + // phase and neither requeue the durable source nor create side effects. + svc.executeQueuedMessageWithReservation("session-1", reserved, reservation) + + select { + case <-agentMgr.promptDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for aggregate replacement prompt") + } + if len(agentMgr.capturedPrompts) != 1 { + t.Fatalf("replacement prompt count = %d, want 1", len(agentMgr.capturedPrompts)) + } + if got := agentMgr.capturedPrompts[0]; got != "first queued\n\nsecond queued" { + t.Fatalf("replacement prompt = %q, want FIFO aggregate", got) + } + if !strings.Contains(agentMgr.capturedPrompts[0], "first queued") || + !strings.Contains(agentMgr.capturedPrompts[0], "second queued") { + t.Fatalf("replacement prompt lost a queued body: %q", agentMgr.capturedPrompts[0]) + } + if got := svc.messageQueue.GetStatus(ctx, "session-1").Count; got != 0 { + t.Fatalf("queue count after aggregate dispatch = %d, want 0", got) + } + if got := len(svc.messageCreator.(*mockMessageCreator).userMessages); got != 1 { + t.Fatalf("visible replacement message count = %d, want 1", got) + } +} + +func TestSendQueuedNowConflictsAfterFIFOHandoffAccepted(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-1", "session-1", "step-1") + seedExecutorRunning(t, repo, "session-1", "task-1", "exec-1") + session, err := repo.GetTaskSession(ctx, "session-1") + if err != nil { + t.Fatalf("get session: %v", err) + } + session.State = models.TaskSessionStateWaitingForInput + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("set session waiting: %v", err) + } + + promptEntered := make(chan struct{}) + allowPrompt := make(chan struct{}) + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + promptAgentFunc: func(context.Context, string, string, []v1.MessageAttachment, bool) (*executor.PromptResult, error) { + close(promptEntered) + <-allowPrompt + return &executor.PromptResult{}, nil + }, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.messageCreator = &mockMessageCreator{} + + for _, content := range []string{"first queued", "second queued"} { + if _, err := svc.messageQueue.QueueMessageWithMetadata( + ctx, "session-1", "task-1", content, "", messagequeue.QueuedByUser, false, nil, nil, + ); err != nil { + t.Fatalf("queue %q: %v", content, err) + } + } + if !svc.drainQueuedMessageForPromptableSession(ctx, "session-1") { + t.Fatal("normal FIFO drain did not start") + } + <-promptEntered + + if _, err := svc.SendQueuedNow(ctx, "session-1", QueueSendNowScopeAll, ""); !errors.Is(err, ErrSendNowConflict) { + t.Fatalf("send now error = %v, want %v", err, ErrSendNowConflict) + } + if got := agentMgr.cancelAgentCalls.Load(); got != 0 { + t.Fatalf("send now cancelled accepted FIFO turn %d times, want 0", got) + } + status := svc.messageQueue.GetStatus(ctx, "session-1") + if status.Count != 1 || status.Entries[0].Content != "second queued" { + t.Fatalf("remaining queue = %#v, want second queued only", status.Entries) + } + + close(allowPrompt) +} diff --git a/apps/backend/internal/orchestrator/queued_dispatch.go b/apps/backend/internal/orchestrator/queued_dispatch.go new file mode 100644 index 0000000000..a867f78990 --- /dev/null +++ b/apps/backend/internal/orchestrator/queued_dispatch.go @@ -0,0 +1,301 @@ +package orchestrator + +import ( + "errors" + "sync/atomic" + + "github.com/kandev/kandev/internal/orchestrator/messagequeue" +) + +var errQueuedDispatchSupersededBySendNow = errors.New("queued dispatch superseded by send now") + +type queuedDispatchPhase uint32 + +const ( + queuedDispatchPending queuedDispatchPhase = iota + 1 + queuedDispatchAccepted + queuedDispatchSupersededByNewDispatch + queuedDispatchSupersededBySendNow +) + +type queuedDispatchReservation struct { + sessionID string + entryID string + source *messagequeue.QueuedMessage + phase atomic.Uint32 +} + +func newQueuedDispatchReservation(sessionID, entryID string, source *messagequeue.QueuedMessage) *queuedDispatchReservation { + reservation := &queuedDispatchReservation{ + sessionID: sessionID, + entryID: entryID, + source: source, + } + reservation.phase.Store(uint32(queuedDispatchPending)) + return reservation +} + +func (reservation *queuedDispatchReservation) currentPhase() queuedDispatchPhase { + if reservation == nil { + return 0 + } + return queuedDispatchPhase(reservation.phase.Load()) +} + +// markQueuedDispatchInFlight records a dispatch without retaining a source. +// This unguarded helper is used by tests and setup code; production queue-take +// paths use the Locked variant below while holding the session guard. +func (s *Service) markQueuedDispatchInFlight(sessionID, entryID string) *queuedDispatchReservation { + return s.markQueuedDispatchInFlightWithSource(sessionID, entryID, nil) +} + +// markQueuedDispatchInFlightWithSource is the unguarded setup/test helper. +// Production queue-take paths must call markQueuedDispatchInFlightWithSourceLocked +// while holding the session's cancelInFlight guard. +func (s *Service) markQueuedDispatchInFlightWithSource( + sessionID, entryID string, + source *messagequeue.QueuedMessage, +) *queuedDispatchReservation { + if sessionID == "" || entryID == "" { + return nil + } + return s.markQueuedDispatchInFlightWithSourceLocked(sessionID, entryID, source) +} + +// markQueuedDispatchInFlightWithSourceLocked records a dispatch while the +// caller owns sessionID's cancelInFlight guard. All production queue-take +// paths use this variant so a Send Now claim cannot interleave between the +// reservation replacement and the worker launch. +func (s *Service) markQueuedDispatchInFlightWithSourceLocked( + sessionID, entryID string, + source *messagequeue.QueuedMessage, +) *queuedDispatchReservation { + if sessionID == "" || entryID == "" { + return nil + } + reservation := newQueuedDispatchReservation(sessionID, entryID, source) + if previous, ok := s.dispatchingQueued.Load(sessionID); ok { + if previousReservation, ok := previous.(*queuedDispatchReservation); ok { + previousReservation.phase.Store(uint32(queuedDispatchSupersededByNewDispatch)) + } + } + // A genuine cancel-and-take path is allowed to replace an accepted queued + // turn after cancellation has settled. The old accepted marker must not + // block the new dispatch, while the old worker's compare-and-delete cleanup + // still cannot remove this reservation. + s.acceptedQueuedDispatch.Delete(sessionID) + s.dispatchingQueued.Store(sessionID, reservation) + return reservation +} + +func (s *Service) pendingQueuedDispatch(sessionID string) *queuedDispatchReservation { + value, ok := s.dispatchingQueued.Load(sessionID) + if !ok { + return nil + } + reservation, _ := value.(*queuedDispatchReservation) + return reservation +} + +func (s *Service) acceptedQueuedDispatchForSession(sessionID string) *queuedDispatchReservation { + value, ok := s.acceptedQueuedDispatch.Load(sessionID) + if !ok { + return nil + } + reservation, _ := value.(*queuedDispatchReservation) + return reservation +} + +// claimQueuedDispatchForExecution moves a pending reservation to the accepted +// phase before the worker performs any visible message or workflow side effect. +// The same per-session guard arbitrates this transition against Send Now. +func (s *Service) claimQueuedDispatchForExecution( + sessionID, entryID string, + expected *queuedDispatchReservation, +) (bool, error) { + if sessionID == "" || entryID == "" { + return false, nil + } + lock, release := s.acquireCancelInFlightGuard(sessionID) + defer release() + lock.Lock() + defer lock.Unlock() + + var alreadyTracked bool + var err error + expected, alreadyTracked, err = s.resolveQueuedDispatchForClaim(sessionID, entryID, expected) + if alreadyTracked || err != nil { + return alreadyTracked, err + } + if expected == nil { + return false, nil + } + + switch expected.currentPhase() { + case queuedDispatchSupersededBySendNow: + return true, errQueuedDispatchSupersededBySendNow + case queuedDispatchSupersededByNewDispatch: + return true, errQueuedDispatchSuperseded + case queuedDispatchAccepted: + return true, nil + } + + if accepted := s.acceptedQueuedDispatchForSession(sessionID); accepted == expected { + return true, nil + } + if pending := s.pendingQueuedDispatch(sessionID); pending != expected || pending.entryID != entryID { + expected.phase.Store(uint32(queuedDispatchSupersededByNewDispatch)) + return true, errQueuedDispatchSuperseded + } + + expected.phase.Store(uint32(queuedDispatchAccepted)) + s.dispatchingQueued.CompareAndDelete(sessionID, expected) + s.acceptedQueuedDispatch.Store(sessionID, expected) + return true, nil +} + +func (s *Service) resolveQueuedDispatchForClaim( + sessionID, entryID string, + expected *queuedDispatchReservation, +) (*queuedDispatchReservation, bool, error) { + if expected != nil { + if expected.currentPhase() == queuedDispatchSupersededBySendNow { + return expected, false, nil + } + if pending := s.pendingQueuedDispatch(sessionID); pending == expected { + return expected, false, nil + } + if accepted := s.acceptedQueuedDispatchForSession(sessionID); accepted == expected { + return expected, false, nil + } + expected.phase.Store(uint32(queuedDispatchSupersededByNewDispatch)) + return expected, true, errQueuedDispatchSuperseded + } + pending := s.pendingQueuedDispatch(sessionID) + accepted := s.acceptedQueuedDispatchForSession(sessionID) + if pending != nil && pending.entryID == entryID { + return pending, false, nil + } + if accepted != nil && accepted.entryID == entryID { + return nil, true, nil + } + if pending != nil || accepted != nil { + return nil, true, errQueuedDispatchSuperseded + } + return nil, false, nil +} + +// pendingQueuedDispatchForSendNow is called while sessionID's cancellation +// guard is held. It returns the exact pending reservation without changing +// ownership. The source is restored first; only a successful restore may +// supersede the FIFO worker. +func (s *Service) pendingQueuedDispatchForSendNow(sessionID string) (*queuedDispatchReservation, error) { + reservation := s.pendingQueuedDispatch(sessionID) + if reservation == nil { + if s.acceptedQueuedDispatchForSession(sessionID) != nil { + return nil, ErrSendNowConflict + } + return nil, nil + } + if reservation.currentPhase() != queuedDispatchPending || reservation.source == nil { + return nil, ErrSendNowConflict + } + return reservation, nil +} + +// supersedeQueuedDispatchForSendNow is called while sessionID's cancellation +// guard is held, after the exact FIFO source has been restored successfully. +func (s *Service) supersedeQueuedDispatchForSendNow( + sessionID string, + reservation *queuedDispatchReservation, +) error { + if reservation == nil || s.pendingQueuedDispatch(sessionID) != reservation || + reservation.currentPhase() != queuedDispatchPending || reservation.source == nil { + return ErrSendNowConflict + } + reservation.phase.Store(uint32(queuedDispatchSupersededBySendNow)) + if !s.dispatchingQueued.CompareAndDelete(sessionID, reservation) { + return ErrSendNowConflict + } + return nil +} + +func (s *Service) isQueuedDispatchAccepted(sessionID string) bool { + return s.acceptedQueuedDispatchForSession(sessionID) != nil +} + +// clearQueuedDispatchInFlightIfCurrent clears either phase only for the exact +// reservation that owns it. Workers that lost to a newer dispatch cannot +// remove the replacement marker even when the queue entry ID was restored and +// reused. +func (s *Service) clearQueuedDispatchInFlightIfCurrent( + sessionID string, + reservation *queuedDispatchReservation, +) { + if sessionID == "" || reservation == nil { + return + } + if pending := s.pendingQueuedDispatch(sessionID); pending == reservation { + pending.phase.Store(uint32(queuedDispatchSupersededByNewDispatch)) + s.dispatchingQueued.CompareAndDelete(sessionID, reservation) + } + if accepted := s.acceptedQueuedDispatchForSession(sessionID); accepted == reservation { + accepted.phase.Store(uint32(queuedDispatchSupersededByNewDispatch)) + s.acceptedQueuedDispatch.CompareAndDelete(sessionID, reservation) + } +} + +// releaseQueuedDispatchPendingIfCurrent is used by the fast prompt-claim +// helpers. Ownership has already moved to the accepted map, so they must not +// clear the accepted marker while the agent turn is still running. +func (s *Service) releaseQueuedDispatchPendingIfCurrent( + sessionID string, + reservation *queuedDispatchReservation, +) { + if sessionID == "" || reservation == nil { + return + } + if pending := s.pendingQueuedDispatch(sessionID); pending == reservation { + pending.phase.Store(uint32(queuedDispatchAccepted)) + s.dispatchingQueued.CompareAndDelete(sessionID, pending) + } +} + +func (s *Service) queuedDispatchReservationForEntry(sessionID, entryID string) *queuedDispatchReservation { + if sessionID == "" || entryID == "" { + return nil + } + if pending := s.pendingQueuedDispatch(sessionID); pending != nil && pending.entryID == entryID { + return pending + } + if accepted := s.acceptedQueuedDispatchForSession(sessionID); accepted != nil && accepted.entryID == entryID { + return accepted + } + return nil +} + +func (s *Service) isCurrentQueuedDispatch(sessionID, entryID string) bool { + if sessionID == "" || entryID == "" { + return false + } + if pending := s.pendingQueuedDispatch(sessionID); pending != nil && pending.entryID == entryID { + return true + } + accepted := s.acceptedQueuedDispatchForSession(sessionID) + return accepted != nil && accepted.entryID == entryID +} + +// isQueuedDispatchInFlight reports any unsettled queued dispatch. Ordinary +// drains must defer for both phases because the accepted marker exists during +// the short window before session.State becomes RUNNING and remains until the +// successor turn settles. Send Now checks the accepted phase separately so it +// can distinguish a supersedable pending reservation from a terminal conflict. +func (s *Service) isQueuedDispatchInFlight(sessionID string) bool { + return s.pendingQueuedDispatch(sessionID) != nil || s.isQueuedDispatchAccepted(sessionID) +} + +func (s *Service) clearAcceptedQueuedDispatch(sessionID string) { + if sessionID != "" { + s.acceptedQueuedDispatch.Delete(sessionID) + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 2657f0f821..ab76060921 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -516,34 +516,14 @@ type Service struct { // Active turns map: sessionID -> turnID activeTurns sync.Map - // dispatchingQueued tracks, per session, the entry ID of whichever - // queued message was most recently taken and handed off to the async - // executeQueuedMessage goroutine, but hasn't yet reached promptTask's - // own setSessionRunning call — the only point at which session.State - // itself starts correctly reporting "busy" for that dispatch. Without - // this, a second take-and-dispatch decision for the same session (a - // workflow drain, a manual drain, or another parent interrupt) could - // acquire the cancelInFlight guard in that gap, see the still-idle - // session.State, and dispatch a second, unrelated queued entry before - // the first dispatch's turn has even started. - // - // Stores the entry ID (string), not a bool: a newer dispatch for the - // same session (e.g. a second parent interrupt cancelling and - // re-taking) always supersedes an older one that's still settling — - // markQueuedDispatchInFlight unconditionally overwrites. Each - // goroutine must reconfirm it still owns *its own* entry ID - // (isCurrentQueuedDispatch) immediately before calling - // setSessionRunning, and may only clear the marker via a - // compare-and-delete keyed on that same entry ID - // (clearQueuedDispatchInFlightIfCurrent) — otherwise a superseded - // goroutine finishing late could delete a newer dispatch's marker out - // from under it, or proceed to prompt after it no longer owns the - // session. Non-cancelling take-and-dispatch paths - // (drainQueuedMessageForPromptableSessionLocked, takeIfPromptableLocked) - // instead use isQueuedDispatchInFlight, which only asks "is *anything* - // still settling" — they have no cancel to supersede it with, so any - // in-flight dispatch at all is reason enough to defer. - dispatchingQueued sync.Map + // dispatchingQueued tracks the pre-acceptance reservation for the exact + // queued message handed to an async worker. acceptedQueuedDispatch keeps + // the same ownership visible after the worker claims RUNNING until its turn + // settles, so Send Now cannot cancel or duplicate a successor that FIFO has + // already accepted. The two maps are managed by queued_dispatch.go and are + // arbitrated through cancelInFlight. + dispatchingQueued sync.Map + acceptedQueuedDispatch sync.Map // afterReadyLifecycleReservation is a deterministic test seam for the // narrow interval after handleAgentReady releases its per-session guard @@ -676,6 +656,15 @@ type Service struct { mu sync.RWMutex running bool startedAt time.Time + + // sendNowWorkers owns the asynchronous replacement handoffs. The context + // is cancelled during Stop so a claimed durable source gets a bounded + // recovery attempt before shutdown returns. + sendNowMu sync.Mutex + sendNowCtx context.Context + sendNowCancel context.CancelFunc + sendNowStopped bool + sendNowWorkers sync.WaitGroup } // Status contains orchestrator status information @@ -742,6 +731,7 @@ func NewService( } // Create the service (watcher will be created after we have handlers) + sendNowCtx, sendNowCancel := context.WithCancel(context.Background()) s := &Service{ config: cfg, logger: svcLogger, @@ -755,6 +745,8 @@ func NewService( messageQueue: msgQueue, clarificationWatchdogTimeout: 15 * time.Second, gitSnapshotCache: newGitSnapshotCache(), + sendNowCtx: sendNowCtx, + sendNowCancel: sendNowCancel, } exec.SetOnContextWindowReset(s.clearContextWindowForReset) @@ -1291,10 +1283,16 @@ func (s *Service) startTurnForSessionWithOwnership(ctx context.Context, sessionI // query the DB for any open turn and close it. Loops to mop up multiple // zombies (e.g. left over from before this fix) with a small sanity bound. func (s *Service) completeTurnForSession(ctx context.Context, sessionID string) { + s.clearAcceptedQueuedDispatch(sessionID) s.completeTurnForTaskSession(ctx, "", sessionID) } func (s *Service) completeTurnForTaskSession(ctx context.Context, taskID, sessionID string) { + // Stream-only completion paths call this helper directly rather than the + // session wrapper. Clear the accepted queued-dispatch ownership here too, + // otherwise a completed Send Now/FIFO successor can permanently block the + // next queue action. + s.clearAcceptedQueuedDispatch(sessionID) if err := s.completeTurnForTaskSessionChecked(ctx, taskID, sessionID); err != nil { s.logger.Warn("failed to reconcile active turn", zap.String("session_id", sessionID), @@ -1507,74 +1505,6 @@ func (s *Service) peekActiveTurnID(ctx context.Context, sessionID string) (strin return turn.ID, nil } -// markQueuedDispatchInFlight records that entryID — a specific queued -// message — has been taken and handed off to the async -// executeQueuedMessage goroutine for sessionID. Unconditionally -// overwrites any previous entry for the session: a newer dispatch always -// supersedes an older one that's still settling. See the -// Service.dispatchingQueued field doc comment for why this exists: -// session.State alone doesn't reflect "busy" until that goroutine's own -// promptTask call reaches setSessionRunning, several DB round-trips -// later — and for why the token must be the specific entry ID, not a bare -// bool. -func (s *Service) markQueuedDispatchInFlight(sessionID, entryID string) { - if sessionID == "" { - return - } - s.dispatchingQueued.Store(sessionID, entryID) -} - -// clearQueuedDispatchInFlightIfCurrent removes sessionID's in-flight -// marker only if it still names entryID — a compare-and-delete so a -// goroutine whose own dispatch has since been superseded by a newer one -// (a different entryID overwrote the marker) can never clear the -// *newer* dispatch's marker out from under it. Called by -// executeQueuedMessage via defer so the marker is cleared on every exit -// path (success, transient requeue, superseded, or lost/dropped message) -// — but only when this goroutine is still the current owner. -func (s *Service) clearQueuedDispatchInFlightIfCurrent(sessionID, entryID string) { - if sessionID == "" { - return - } - s.dispatchingQueued.CompareAndDelete(sessionID, entryID) -} - -// isCurrentQueuedDispatch reports whether entryID is still the most -// recently handed-off in-flight dispatch for sessionID — i.e. no *other* -// dispatch has superseded it since markQueuedDispatchInFlight was called -// for it. A goroutine that owns entryID must confirm this immediately -// before calling setSessionRunning (see promptTask's claimDispatch -// parameter); if it no longer owns the token, a different dispatch has -// already taken over the session and this one must not proceed. -func (s *Service) isCurrentQueuedDispatch(sessionID, entryID string) bool { - if sessionID == "" { - return false - } - v, ok := s.dispatchingQueued.Load(sessionID) - if !ok { - return false - } - current, _ := v.(string) - return current == entryID -} - -// isQueuedDispatchInFlight reports whether *any* queued message is -// currently in the handoff window for sessionID, regardless of which one -// — see markQueuedDispatchInFlight. Checked by -// drainQueuedMessageForPromptableSessionLocked and takeIfPromptableLocked -// before either takes and directly dispatches another entry on the same -// session without a cancel to supersede whatever is already settling; a -// genuine cancel (cancelAndTakeForPeerMessage) is exempt — see its own -// doc comment for why it may proceed regardless and simply overwrite the -// token via markQueuedDispatchInFlight. -func (s *Service) isQueuedDispatchInFlight(sessionID string) bool { - if sessionID == "" { - return false - } - _, ok := s.dispatchingQueued.Load(sessionID) - return ok -} - func (s *Service) setSessionResetInProgress(sessionID string, inProgress bool) { if sessionID == "" { return @@ -1610,6 +1540,7 @@ func (s *Service) Start(ctx context.Context) error { s.mu.Unlock() s.logger.Info("starting orchestrator service") + s.resetSendNowWorkers() // Reconcile session state from persisted runtime state on startup. // This does NOT launch any agent processes — sessions are recovered lazily @@ -1703,6 +1634,7 @@ func (s *Service) Stop() error { s.cancelAllClarificationWatchdogs() s.cancelAllTransientRetries() + s.stopSendNowWorkers() if len(errs) > 0 { return errs[0] diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index d522d21b41..c3d8d676a2 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -3096,13 +3096,12 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom } // promptTask is PromptTask's implementation, taking an additional -// claimEntryID parameter used only by executeQueuedMessage's internal -// call. When non-empty, this acquires sessionID's cancelInFlight guard -// around a "confirm I still own this dispatch, then mark the session -// RUNNING" step, immediately before startTurnForSession and the -// (potentially long-blocking) executor.Prompt call below it — see -// isCurrentQueuedDispatch and the Service.dispatchingQueued field doc -// comment for the race this closes. +// claimEntryID parameter used only by queued dispatches. When non-empty, this +// acquires sessionID's cancelInFlight guard around a second ownership check +// and the "mark the session RUNNING" step immediately before startTurnForSession +// and the (potentially long-blocking) executor.Prompt call below it. The worker +// claims the handoff before visible side effects; this check keeps the final +// session transition tied to that same ownership record. // // A bare (unguarded) "check the token, then separately call // setSessionRunning" would still let two settling dispatches for the same @@ -3118,10 +3117,10 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom // bounded-hold-time contract in this file. // // Every return path *before* this step (invalid session, not promptable, -// ensureSessionRunning failure, a model switch) never claims anything — -// those are covered by executeQueuedMessage's own deferred cleanup -// instead (clearQueuedDispatchInFlightIfCurrent), which is safe since -// none of them block on an agent turn. +// ensureSessionRunning failure, a model switch) is covered by +// executeQueuedMessage's own deferred cleanup instead +// (clearQueuedDispatchInFlightIfCurrent), which is safe since none of them +// block on an agent turn. func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool, claimEntryID string, lifecyclePrompt bool, afterClaim func() error) (*PromptResult, error) { s.logPromptTaskCall(taskID, sessionID, prompt, model, planMode, attachments, dispatchOnly) if err := s.validatePromptTaskStart(sessionID); err != nil { @@ -3573,10 +3572,9 @@ func (e *lifecyclePromptReselectedError) Error() string { // this turn" decision atomic with coordinator stop; the potentially blocking // executor.Prompt call remains outside the guard. // -// When claimEntryID is set (a queued dispatch claiming its reserved token — -// see the Service.dispatchingQueued field doc comment), the claim happens -// under the same per-session cancelInFlightGuard every other cancel/take- -// and-dispatch decision in this file serializes through. Re-verifying +// When claimEntryID is set, the final claim happens under the same per-session +// cancelInFlightGuard every other cancel/take-and-dispatch decision in this +// file serializes through. Re-verifying // ownership *and* promptability inside this same critical section, // immediately before writing RUNNING, closes a gap that checking the token // alone would leave open: nothing would otherwise stop this call from @@ -3625,15 +3623,14 @@ func (s *Service) claimSessionRunningForPrompt( State: freshSession.State, } } - // Clear the token now, inside the same lock, rather than deferring to - // executeQueuedMessage's cleanup after this entire (potentially - // long-blocking) call returns: from this point session.State is the - // authoritative "busy" signal for this dispatch, so holding the marker - // any longer would only risk the natural agent.ready firing when *this* - // turn completes seeing it still set and skipping the *next* queued - // entry — see the Service.dispatchingQueued field doc comment. + // Remove only the pre-acceptance marker here. The accepted ownership record + // remains until the turn settles, so Send Now cannot cancel or duplicate + // this successor while the executor call is still in progress. if claimEntryID != "" { - s.clearQueuedDispatchInFlightIfCurrent(sessionID, claimEntryID) + s.releaseQueuedDispatchPendingIfCurrent( + sessionID, + s.queuedDispatchReservationForEntry(sessionID, claimEntryID), + ) } return freshSession, nil } @@ -3679,7 +3676,10 @@ func (s *Service) claimLifecycleSessionRunning( s.restoreLifecycleClaim(ctx, taskID, sessionID, claim.PreviousState) return nil, "", errLifecyclePromptInactive } - s.clearQueuedDispatchInFlightIfCurrent(sessionID, claimEntryID) + s.releaseQueuedDispatchPendingIfCurrent( + sessionID, + s.queuedDispatchReservationForEntry(sessionID, claimEntryID), + ) return freshSession, claim.PreviousState, nil } @@ -3981,11 +3981,12 @@ type cancelInFlightGuard struct { type cancellationKind string const ( - cancellationKindExplicit cancellationKind = "explicit" - cancellationKindSilent cancellationKind = "silent" - cancellationKindPeer cancellationKind = "peer" - cancellationKindInternal cancellationKind = "internal" - cancellationOperationTTL = 30 * time.Second + cancellationKindExplicit cancellationKind = "explicit" + cancellationKindSilent cancellationKind = "silent" + cancellationKindPeer cancellationKind = "peer" + cancellationKindInternal cancellationKind = "internal" + cancellationKindQueueSendNow cancellationKind = "queue_send_now" + cancellationOperationTTL = 30 * time.Second ) type cancellationIdentity struct { @@ -4003,6 +4004,8 @@ type cancelOperation struct { kind cancellationKind identity cancellationIdentity identityReady bool + expectedTurnID string + expectedTurnReady bool completionEligible bool completionEligibilityReady bool actions []*cancellationAction @@ -4099,6 +4102,34 @@ func (s *Service) claimCancellationWithAction( return operation, true, registered } +// claimCancellationWithActionExclusive establishes a cancellation owner only +// when no source is already active for the session. Send Now uses this stricter +// variant because joining an explicit cancel (or another Send Now click) would +// otherwise let the shared cancellation finish with the wrong source +// semantics. The accepted result is false when a caller must fail closed. +func (s *Service) claimCancellationWithActionExclusive( + sessionID string, + kind cancellationKind, + action func(context.Context, *cancelOperation) (bool, error), +) (*cancelOperation, bool, *cancellationAction, bool) { + s.cancellationOperationsMu.Lock() + defer s.cancellationOperationsMu.Unlock() + if s.cancellationOperations == nil { + s.cancellationOperations = make(map[string]*cancelOperation) + } + if operation, ok := s.cancellationOperations[sessionID]; ok { + return operation, false, nil, false + } + operation := &cancelOperation{done: make(chan struct{}), joined: make(chan struct{}), kind: kind} + var registered *cancellationAction + if action != nil { + registered = &cancellationAction{done: make(chan struct{}), run: action} + operation.actions = append(operation.actions, registered) + } + s.cancellationOperations[sessionID] = operation + return operation, true, registered, true +} + func (s *Service) claimExplicitCancellation( sessionID string, action func(context.Context, *cancelOperation) (bool, error), @@ -4109,6 +4140,9 @@ func (s *Service) claimExplicitCancellation( s.cancellationOperations = make(map[string]*cancelOperation) } if operation, ok := s.cancellationOperations[sessionID]; ok { + if operation.kind == cancellationKindQueueSendNow { + return operation, false, nil + } operation.markJoinedLocked() if operation.kind == cancellationKindExplicit || action == nil { return operation, false, nil @@ -4151,6 +4185,24 @@ func (s *Service) setCancellationIdentity(sessionID string, operation *cancelOpe } } +func (s *Service) setCancellationExpectedTurn(sessionID string, operation *cancelOperation, turnID string) { + s.cancellationOperationsMu.Lock() + defer s.cancellationOperationsMu.Unlock() + if current := s.cancellationOperations[sessionID]; current == operation { + operation.expectedTurnID = turnID + operation.expectedTurnReady = true + } +} + +func (s *Service) cancellationExpectedTurnSnapshot(operation *cancelOperation) (string, bool) { + s.cancellationOperationsMu.Lock() + defer s.cancellationOperationsMu.Unlock() + if operation == nil { + return "", false + } + return operation.expectedTurnID, operation.expectedTurnReady +} + func (s *Service) setCancellationCompletionEligible(sessionID string, operation *cancelOperation, eligible bool) { s.cancellationOperationsMu.Lock() defer s.cancellationOperationsMu.Unlock() @@ -4695,6 +4747,9 @@ func (s *Service) CancelAgent(ctx context.Context, sessionID string) (err error) if operation.kind == cancellationKindExplicit { return nil } + if operation.kind == cancellationKindQueueSendNow { + return ErrSendNowConflict + } if action == nil { return s.reconcileJoinedExplicitCancellation(ctx, sessionID, operation) } diff --git a/apps/backend/internal/task/models/attachments.go b/apps/backend/internal/task/models/attachments.go index 4f310383c4..604af61851 100644 --- a/apps/backend/internal/task/models/attachments.go +++ b/apps/backend/internal/task/models/attachments.go @@ -1,10 +1,14 @@ package models -import "errors" +import ( + "errors" + + "github.com/kandev/kandev/internal/messageconstraints" +) const ( - MaxMessageAttachmentBytes int64 = 100 * 1024 * 1024 - MaxMessageAttachmentCount = 10 + MaxMessageAttachmentBytes = messageconstraints.MaxAttachmentBytes + MaxMessageAttachmentCount = messageconstraints.MaxAttachmentCount ) var ( diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index bda29c3b94..dda9ae2323 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -117,6 +117,7 @@ const ( ActionMessageQueueUpdate = "message.queue.update" ActionMessageQueueAppend = "message.queue.append" ActionMessageQueueDrain = "message.queue.drain" // Dispatch one queued entry now when the session is promptable + ActionMessageQueueSendNow = "message.queue.send_now" // Interrupt and replace the active turn with an exact queue selection ActionMessageQueueRemove = "message.queue.remove" // Delete a single entry by id ActionMessageQueueMerge = "message.queue.merge" // Fold an entry into the entry above it ActionMessageQueueStatusChanged = "message.queue.status_changed" // Notification: queue status changed diff --git a/apps/web/components/task/chat/queued-ghost-list.test.tsx b/apps/web/components/task/chat/queued-ghost-list.test.tsx index b4119d6a96..31fc85c982 100644 --- a/apps/web/components/task/chat/queued-ghost-list.test.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.test.tsx @@ -44,6 +44,7 @@ const QUEUED_BY_USER = "user"; const MERGE_BUTTON_ID = "queue-entry-merge"; const EDIT_BUTTON_ID = "queue-entry-edit"; const REMOVE_BUTTON_ID = "queue-entry-remove"; +const SEND_NOW_BUTTON_ID = "queue-entry-send-now"; function entry(overrides: Partial = {}): QueuedMessage { return { @@ -89,6 +90,9 @@ function baseState(entries: QueuedMessage[]) { editEntry: vi.fn(async () => {}), removeEntry: vi.fn(async () => {}), mergeEntry: vi.fn(async () => {}), + sendEntryNow: vi.fn(async () => {}), + sendAllNow: vi.fn(async () => {}), + cancellationPending: false, refetch: vi.fn(async () => {}), }; } @@ -212,7 +216,40 @@ describe("QueueAffordance", () => { fireEvent.click(screen.getByTestId("queue-drain-next")); expect(state.drainNext).toHaveBeenCalledTimes(1); }); +}); +describe("QueueAffordance Send Now", () => { + it("sends the whole visible queue from the header", () => { + const state = queueState([entry(), entry({ id: "q-2", content: "second" })]); + useQueueMock.mockReturnValue(state); + render({CHILD}); + fireEvent.click(screen.getByTestId(CHIP_ID)); + fireEvent.click(screen.getByTestId("queue-send-now")); + expect(state.sendAllNow).toHaveBeenCalledTimes(1); + }); + + it("sends the selected row now", () => { + const state = queueState([ + entry({ id: "q-1", content: "first" }), + entry({ id: "q-2", content: "second" }), + ]); + useQueueMock.mockReturnValue(state); + render({CHILD}); + fireEvent.click(screen.getByTestId(CHIP_ID)); + fireEvent.click(screen.getAllByTestId(SEND_NOW_BUTTON_ID)[1]); + expect(state.sendEntryNow).toHaveBeenCalledWith("q-2"); + }); + + it("disables row and header Send Now while cancellation is pending", () => { + useQueueMock.mockReturnValue(queueState([entry()], { cancellationPending: true })); + render({CHILD}); + fireEvent.click(screen.getByTestId(CHIP_ID)); + expect((screen.getByTestId("queue-send-now") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTestId(SEND_NOW_BUTTON_ID) as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe("QueueAffordance busy controls", () => { it("hides the run-next action while the agent is busy", () => { useQueueMock.mockReturnValue(queueState([entry()])); render( diff --git a/apps/web/components/task/chat/queued-ghost-list.tsx b/apps/web/components/task/chat/queued-ghost-list.tsx index 399421b09d..7779f82f37 100644 --- a/apps/web/components/task/chat/queued-ghost-list.tsx +++ b/apps/web/components/task/chat/queued-ghost-list.tsx @@ -2,17 +2,21 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { IconLayoutList, IconPlayerPlay, IconTrash, IconX } from "@tabler/icons-react"; +import { IconLayoutList } from "@tabler/icons-react"; import { toast } from "@/lib/toast/sonner"; import { useTranslation } from "react-i18next"; -import { Button } from "@kandev/ui"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { stripSystemTags } from "@/lib/utils/system-tags"; -import { MergeReferenceOverflowError, QueueEntryNotFoundError } from "@/lib/api/domains/queue-api"; +import { + MergeReferenceOverflowError, + QueueEntryNotFoundError, + QueueSendNowError, +} from "@/lib/api/domains/queue-api"; import { useQueue } from "@/hooks/domains/session/use-queue"; import { canMergeWithAbove, QueuedGhostMessage } from "./queued-ghost-message"; +import { QueuePanelHeader } from "./queued-ghost-panel-header"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; import type { EntityReference } from "@/lib/types/entity-reference"; @@ -83,14 +87,60 @@ type QueuePanelHandlerArgs = { ) => Promise; removeEntry: (entryId: string) => Promise; mergeEntry: (entryId: string) => Promise; + sendEntryNow: (entryId: string) => Promise; + sendAllNow: () => Promise; }; +function useSendNowPanelHandlers( + sendEntryNow: (entryId: string) => Promise, + sendAllNow: () => Promise, +) { + const { t } = useTranslation(); + const sendNowErrorMessage = useCallback( + (err: unknown) => { + if (err instanceof QueueEntryNotFoundError) return t("chat:sendNowEntryAlreadySent"); + if (err instanceof QueueSendNowError) { + const messages: Record = { + queue_empty: "chat:sendNowQueueEmpty", + queue_changed: "chat:sendNowQueueChanged", + send_now_conflict: "chat:sendNowConflict", + turn_changed: "chat:sendNowTurnChanged", + send_now_attachment_overflow: "chat:sendNowAttachmentOverflow", + send_now_reference_overflow: "chat:sendNowReferenceOverflow", + }; + const key = messages[err.code]; + if (key) return t(key); + } + return t("chat:failedToSendQueuedMessageNow"); + }, + [t], + ); + const handleSendEntryNow = useCallback( + (entryId: string) => { + sendEntryNow(entryId).catch((err) => { + console.error("Failed to send queued message now:", err); + toast.error(sendNowErrorMessage(err)); + }); + }, + [sendEntryNow, sendNowErrorMessage], + ); + const handleSendAllNow = useCallback(() => { + sendAllNow().catch((err) => { + console.error("Failed to send queued messages now:", err); + toast.error(sendNowErrorMessage(err)); + }); + }, [sendAllNow, sendNowErrorMessage]); + return { handleSendEntryNow, handleSendAllNow }; +} + function useQueuePanelHandlers({ clearAll, drainNext, editEntry, removeEntry, mergeEntry, + sendEntryNow, + sendAllNow, }: QueuePanelHandlerArgs) { // Tracks merge requests still in flight so a rapid second click on the same // row cannot fire a second request for an entry that is already gone — the @@ -98,6 +148,10 @@ function useQueuePanelHandlers({ // a spurious "not found" error. const pendingMerges = useRef(new Set()); const { t } = useTranslation(); + const { handleSendEntryNow, handleSendAllNow } = useSendNowPanelHandlers( + sendEntryNow, + sendAllNow, + ); const handleSave = useCallback( async (entryId: string, content: string, entityReferences: EntityReference[]) => { await editEntry(entryId, content, undefined, entityReferences); @@ -151,7 +205,99 @@ function useQueuePanelHandlers({ }); }, [drainNext, t]); - return { handleSave, handleRemove, handleMerge, handleClear, handleDrain }; + return { + handleSave, + handleRemove, + handleMerge, + handleClear, + handleDrain, + handleSendEntryNow, + handleSendAllNow, + }; +} + +type QueuePanelDisclosureProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + entries: QueuedMessage[]; + count: number; + max: number; + isFull: boolean; + canDrain: boolean; + isLoading: boolean; + cancellationPending: boolean; + onClose: () => void; + onClear: () => void; + onDrain: () => void; + onSendNow: () => void; + onSave: (entryId: string, content: string, refs: EntityReference[]) => Promise; + onRemove: (entryId: string) => Promise; + onMerge: (entryId: string) => Promise; + onSendEntryNow: (entryId: string) => void; +}; + +function QueuePanelDisclosure({ + isOpen, + onOpenChange, + entries, + count, + max, + isFull, + canDrain, + isLoading, + cancellationPending, + onClose, + onClear, + onDrain, + onSendNow, + onSave, + onRemove, + onMerge, + onSendEntryNow, +}: QueuePanelDisclosureProps) { + return ( + + + + + + ); +} + +function useQueuePanelOpenState(sessionId: string | null, entryCount: number) { + const [isOpen, setIsOpen] = useState(false); + const [lastSession, setLastSession] = useState(sessionId); + const [lastEntryCount, setLastEntryCount] = useState(entryCount); + if (sessionId !== lastSession) { + setLastSession(sessionId); + setIsOpen(false); + } + if (entryCount !== lastEntryCount) { + setLastEntryCount(entryCount); + if (entryCount === 0) setIsOpen(false); + } + return [isOpen, setIsOpen] as const; } /** @@ -179,33 +325,33 @@ export function QueueAffordance({ editEntry, removeEntry, mergeEntry, + sendEntryNow, + sendAllNow, + cancellationPending, } = useQueue(sessionId); - const [isOpen, setIsOpen] = useState(false); - const { handleSave, handleRemove, handleMerge, handleClear, handleDrain } = useQueuePanelHandlers( - { - clearAll, - drainNext, - editEntry, - removeEntry, - mergeEntry, - }, - ); + const entryCount = entries.length; + const [isOpen, setIsOpen] = useQueuePanelOpenState(sessionId, entryCount); + const { + handleSave, + handleRemove, + handleMerge, + handleClear, + handleDrain, + handleSendEntryNow, + handleSendAllNow, + } = useQueuePanelHandlers({ + clearAll, + drainNext, + editEntry, + removeEntry, + mergeEntry, + sendEntryNow, + sendAllNow, + }); // Reset disclosure on session switch or full drain using render-phase state // adjustment (React docs: "Adjusting some state when a prop changes"). This // avoids the cascading-render anti-pattern of doing it inside useEffect. - const entryCount = entries.length; - const [lastSession, setLastSession] = useState(sessionId); - const [lastEntryCount, setLastEntryCount] = useState(entryCount); - if (sessionId !== lastSession) { - setLastSession(sessionId); - setIsOpen(false); - } - if (entryCount !== lastEntryCount) { - setLastEntryCount(entryCount); - if (entryCount === 0) setIsOpen(false); - } - const close = useCallback(() => setIsOpen(false), []); useEscToClose(isOpen, close); @@ -233,29 +379,25 @@ export function QueueAffordance({ return ( <> {renderStatusBar?.(chipNode)} - - - - - + {!renderStatusBar && chipNode && (
{chipNode} @@ -324,12 +466,15 @@ type QueuePanelProps = { isFull: boolean; canDrain: boolean; isLoading: boolean; + cancellationPending: boolean; onClose: () => void; onClear: () => void; onDrain: () => void; + onSendNow: () => void; onSave: (entryId: string, content: string, entityReferences: EntityReference[]) => Promise; onRemove: (entryId: string) => Promise; onMerge: (entryId: string) => Promise; + onSendEntryNow: (entryId: string) => void; }; function QueuePanel({ @@ -339,12 +484,15 @@ function QueuePanel({ isFull, canDrain, isLoading, + cancellationPending, onClose, onClear, onDrain, + onSendNow, onSave, onRemove, onMerge, + onSendEntryNow, }: QueuePanelProps) { const { t } = useTranslation(); return ( @@ -365,8 +513,10 @@ function QueuePanel({ isFull={isFull} canDrain={canDrain} isLoading={isLoading} + cancellationPending={cancellationPending} onClear={onClear} onDrain={onDrain} + onSendNow={onSendNow} onClose={onClose} />
onSave(entry.id, content, entityReferences)} onRemove={() => onRemove(entry.id)} onMerge={() => onMerge(entry.id)} + onSendNow={() => onSendEntryNow(entry.id)} + sendNowDisabled={isLoading || cancellationPending} /> ))}
); } - -type QueuePanelHeaderProps = { - count: number; - max: number; - isFull: boolean; - canDrain: boolean; - isLoading: boolean; - onClear: () => void; - onDrain: () => void; - onClose: () => void; -}; - -function QueuePanelHeader({ - count, - max, - isFull, - canDrain, - isLoading, - onClear, - onDrain, - onClose, -}: QueuePanelHeaderProps) { - const { t } = useTranslation(); - const capacityText = - max > 0 - ? t(isFull ? "chat:queueCapacityFull" : "chat:queueCapacity", { count, max }) - : t("chat:queueCount", { count }); - return ( -
-
- - {t("chat:queued")} - {capacityText} -
-
- {canDrain && ( - - )} - - -
-
- ); -} diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index 7a2ebe7ece..5d57ebf650 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -9,18 +9,7 @@ import { useRef, useState, } from "react"; -import { - IconCheck, - IconChevronDown, - IconChevronUp, - IconEdit, - IconFile, - IconInfoCircle, - IconRobot, - IconUser, - IconX, - IconArrowMerge, -} from "@tabler/icons-react"; +import { IconCheck, IconFile, IconInfoCircle, IconRobot, IconUser } from "@tabler/icons-react"; import ReactMarkdown from "react-markdown"; import { toast } from "@/lib/toast/sonner"; import { useTranslation } from "react-i18next"; @@ -47,6 +36,7 @@ import { survivingEntityReferences, } from "@/lib/entity-references/message-references"; import { buildEntityReferenceMarkdownComponents } from "@/components/task/chat/messages/entity-reference-chip"; +import { QueuedGhostRowActions } from "@/components/task/chat/queued-ghost-row-actions"; import { t } from "@/lib/i18n"; type QueuedAttachment = NonNullable[number]; @@ -302,6 +292,8 @@ type DisplayViewProps = { onStartEdit: () => void; onRemove: () => void; onMerge?: () => void | Promise; + onSendNow: () => void; + sendNowDisabled: boolean; }; /** Rough threshold above which we offer a per-row expand toggle. Two lines of @@ -310,100 +302,12 @@ type DisplayViewProps = { * (lists, code blocks) would otherwise be silently truncated. */ const EXPAND_THRESHOLD = 80; -function shouldOfferExpand(text: string): boolean { - return text.length > EXPAND_THRESHOLD || text.includes("\n"); +function queuePositionLabel(index: number | undefined, position: number | undefined): string { + return `#${index === undefined ? (position ?? 1) : index + 1}`; } -type RowActionsProps = { - canExpand: boolean; - expanded: boolean; - canMerge: boolean; - canEdit: boolean; - canRemove: boolean; - onToggleExpand: () => void; - onMerge?: () => void | Promise; - onStartEdit: () => void; - onRemove: () => void; -}; - -function RowActions({ - canExpand, - expanded, - canMerge, - canEdit, - canRemove, - onToggleExpand, - onMerge, - onStartEdit, - onRemove, -}: RowActionsProps) { - const { t } = useTranslation(); - return ( -
- {canExpand && ( - - )} - {canMerge && onMerge && ( - - )} - {canEdit && ( - - )} - {canRemove && ( - - )} -
- ); +function shouldOfferExpand(text: string): boolean { + return text.length > EXPAND_THRESHOLD || text.includes("\n"); } function DisplayView({ @@ -416,6 +320,8 @@ function DisplayView({ onStartEdit, onRemove, onMerge, + onSendNow, + sendNowDisabled, }: DisplayViewProps) { const { t } = useTranslation(); const visible = stripSystemTags(entry.content); @@ -460,7 +366,7 @@ function DisplayView({ )} - ); @@ -495,6 +403,10 @@ type QueuedGhostMessageProps = { onRemove: () => void | Promise; /** Fold this entry into the one above it. */ onMerge?: () => void | Promise; + /** Interrupt the current turn and dispatch this exact queued entry. */ + onSendNow?: () => void; + /** Disable while the queue mutation or backend cancellation is in flight. */ + sendNowDisabled?: boolean; /** Called after edit save/cancel so the parent can refocus the chat input. */ onEditComplete?: () => void; }; @@ -511,6 +423,54 @@ function useFocusQueuedEdit( }, [editing, textareaRef]); } +type QueuedGhostSaveArgs = { + value: string; + entryContent: string; + entityReferences: readonly EntityReference[]; + onSave: QueuedGhostMessageProps["onSave"]; + onEditComplete?: () => void; + setEditing: (editing: boolean) => void; + setSaving: (saving: boolean) => void; + t: (key: string) => string; +}; + +function useQueuedGhostSave({ + value, + entryContent, + entityReferences, + onSave, + onEditComplete, + setEditing, + setSaving, + t, +}: QueuedGhostSaveArgs) { + return useCallback(async () => { + const trimmed = value.trim(); + if (!trimmed || trimmed === entryContent) { + setEditing(false); + onEditComplete?.(); + return; + } + setSaving(true); + try { + await onSave(trimmed, survivingEntityReferences(trimmed, entityReferences)); + setEditing(false); + onEditComplete?.(); + } catch (err) { + console.error("Failed to update queued entry:", err); + if (err instanceof QueueEntryNotFoundError) { + toast.error(t("chat:queueEditAlreadySent")); + } else { + toast.error(t("chat:queueEditSaveFailed")); + } + setEditing(false); + onEditComplete?.(); + } finally { + setSaving(false); + } + }, [value, entryContent, onSave, onEditComplete, entityReferences, setEditing, setSaving, t]); +} + export const QueuedGhostMessage = forwardRef( function QueuedGhostMessage( { @@ -522,6 +482,8 @@ export const QueuedGhostMessage = forwardRef undefined, + sendNowDisabled = false, onEditComplete, }, ref, @@ -537,7 +499,6 @@ export const QueuedGhostMessage = forwardRef { if (!editing) setValue(entry.content); }, [entry.content, editing]); @@ -549,45 +510,24 @@ export const QueuedGhostMessage = forwardRef ({ startEdit }), [startEdit]); - const handleCancel = useCallback(() => { setValue(entry.content); setEditing(false); onEditComplete?.(); }, [entry.content, onEditComplete]); - const handleSave = useCallback(async () => { - const trimmed = value.trim(); - if (!trimmed || trimmed === entry.content) { - setEditing(false); - onEditComplete?.(); - return; - } - setSaving(true); - try { - await onSave(trimmed, survivingEntityReferences(trimmed, entityReferences)); - setEditing(false); - onEditComplete?.(); - } catch (err) { - console.error("Failed to update queued entry:", err); - // Exit edit mode after both drain races and transient failures. - if (err instanceof QueueEntryNotFoundError) { - toast.error(t("chat:queueEditAlreadySent")); - } else { - toast.error(t("chat:queueEditSaveFailed")); - } - setEditing(false); - onEditComplete?.(); - } finally { - setSaving(false); - } - }, [value, entry.content, onSave, onEditComplete, entityReferences, t]); + const handleSave = useQueuedGhostSave({ + value, + entryContent: entry.content, + entityReferences, + onSave, + onEditComplete, + setEditing, + setSaving, + t, + }); - // Persisted positions are durable FIFO keys and intentionally retain gaps - // after a remove or merge. The panel index is the compact user-facing - // ordinal, so prefer it whenever this row belongs to a rendered list. - const positionNumber = index === undefined ? (entry.position ?? 1) : index + 1; - const positionLabel = `#${positionNumber}`; + const positionLabel = queuePositionLabel(index, entry.position); return (
)}
diff --git a/apps/web/components/task/chat/queued-ghost-panel-header.tsx b/apps/web/components/task/chat/queued-ghost-panel-header.tsx new file mode 100644 index 0000000000..c35abf8f04 --- /dev/null +++ b/apps/web/components/task/chat/queued-ghost-panel-header.tsx @@ -0,0 +1,93 @@ +import { IconLayoutList, IconPlayerPlay, IconSend, IconTrash, IconX } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui"; +import { cn } from "@/lib/utils"; + +export type QueuePanelHeaderProps = { + count: number; + max: number; + isFull: boolean; + canDrain: boolean; + isLoading: boolean; + cancellationPending: boolean; + onClear: () => void; + onDrain: () => void; + onSendNow: () => void; + onClose: () => void; +}; + +export function QueuePanelHeader({ + count, + max, + isFull, + canDrain, + isLoading, + cancellationPending, + onClear, + onDrain, + onSendNow, + onClose, +}: QueuePanelHeaderProps) { + const { t } = useTranslation(); + const capacityText = + max > 0 + ? t(isFull ? "chat:queueCapacityFull" : "chat:queueCapacity", { count, max }) + : t("chat:queueCount", { count }); + return ( +
+
+ + {t("chat:queued")} + {capacityText} +
+
+ {canDrain && ( + + )} + + + +
+
+ ); +} diff --git a/apps/web/components/task/chat/queued-ghost-row-actions.tsx b/apps/web/components/task/chat/queued-ghost-row-actions.tsx new file mode 100644 index 0000000000..6aa8fc8a55 --- /dev/null +++ b/apps/web/components/task/chat/queued-ghost-row-actions.tsx @@ -0,0 +1,118 @@ +import { + IconArrowMerge, + IconChevronDown, + IconChevronUp, + IconEdit, + IconSend, + IconX, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui"; +import { cn } from "@/lib/utils"; + +export type QueuedGhostRowActionsProps = { + canExpand: boolean; + expanded: boolean; + canMerge: boolean; + canEdit: boolean; + canRemove: boolean; + onToggleExpand: () => void; + onMerge?: () => void | Promise; + onStartEdit: () => void; + onRemove: () => void; + onSendNow?: () => void; + sendNowDisabled: boolean; +}; + +export function QueuedGhostRowActions({ + canExpand, + expanded, + canMerge, + canEdit, + canRemove, + onToggleExpand, + onMerge, + onStartEdit, + onRemove, + onSendNow, + sendNowDisabled, +}: QueuedGhostRowActionsProps) { + const { t } = useTranslation(); + return ( +
+ + {canExpand && ( + + )} + {canMerge && onMerge && ( + + )} + {canEdit && ( + + )} + {canRemove && ( + + )} +
+ ); +} diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 16a1a44c2f..e7fd14ce3a 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -198,6 +198,19 @@ async function seedTaskAndWaitForIdle( return session; } +async function queueMessagesWhileBusy( + page: Page, + editor: Locator, + messages: string[], +): Promise { + const submit = page.getByTestId("submit-message-button"); + for (const message of messages) { + await typeWhileBusy(page, editor, message); + await expect(submit).toBeVisible({ timeout: 5_000 }); + await submit.click(); + } +} + test.describe("Task session queue", () => { test.describe.configure({ retries: 1 }); @@ -259,6 +272,104 @@ test.describe("Task session queue", () => { await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); }); + test("row Send Now replaces the active turn and preserves neighboring entries", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const session = await seedTaskAndWaitForIdle( + testPage, + apiClient, + seedData, + "Queue Send Now row test", + ); + await session.sendMessage("/slow 30s"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + const taskID = new URL(testPage.url()).pathname.split("/").pop(); + if (!taskID) throw new Error("task URL did not contain a task ID"); + const workflowStepBefore = (await apiClient.getTask(taskID)).workflow_step_id; + await testPage.waitForTimeout(500); + + const editor = testPage.locator(".tiptap.ProseMirror").first(); + await queueMessagesWhileBusy(testPage, editor, [ + "send first now", + "/slow 10s second now", + "send third now", + ]); + + await openQueuePanel(testPage); + const panel = testPage.getByTestId("queued-ghost-list"); + await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); + const target = panel.getByTestId("queue-entry").filter({ hasText: "second now" }); + await expect(target).toBeVisible(); + await target.hover(); + const sendNow = target.getByTestId("queue-entry-send-now"); + await expect(sendNow).toBeVisible({ timeout: 10_000 }); + await expect(sendNow).toBeEnabled({ timeout: 10_000 }); + await sendNow.click(); + + const transcript = session.chat.locator(".chat-message-list:visible"); + await expect(transcript.getByText("/slow 10s second now", { exact: true })).toBeVisible({ + timeout: 20_000, + }); + await expect(panel.getByTestId("queue-entry-text")).toHaveCount(2, { timeout: 10_000 }); + await expect(panel.getByTestId("queue-entry-text").nth(0)).toContainText("send first now"); + await expect(panel.getByTestId("queue-entry-text").nth(1)).toContainText("send third now"); + await expect(session.chat).not.toContainText("Turn cancelled by user"); + await expect + .poll(async () => (await apiClient.getTask(taskID)).workflow_step_id, { timeout: 10_000 }) + .toBe(workflowStepBefore); + }); + + test("header Send Now replaces the turn with the visible queue in FIFO order", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const session = await seedTaskAndWaitForIdle( + testPage, + apiClient, + seedData, + "Queue Send Now bulk test", + ); + await session.sendMessage("/slow 30s"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await testPage.waitForTimeout(500); + + const editor = testPage.locator(".tiptap.ProseMirror").first(); + await queueMessagesWhileBusy(testPage, editor, ["bulk first", "bulk second", "bulk third"]); + await openQueuePanel(testPage); + const panel = testPage.getByTestId("queued-ghost-list"); + await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); + + await panel.getByTestId("queue-send-now").click(); + await expect(panel).not.toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible({ timeout: 15_000 }); + + const transcript = session.chat.locator(".chat-message-list:visible"); + await expect(transcript).toContainText("bulk first", { timeout: 20_000 }); + await expect(transcript).toContainText("bulk second"); + await expect(transcript).toContainText("bulk third"); + const aggregateUserBubble = transcript + .getByTestId("user-message-bubble") + .filter({ hasText: "bulk first" }); + await expect(aggregateUserBubble).toHaveCount(1); + await expect(aggregateUserBubble).toContainText("bulk second"); + await expect(aggregateUserBubble).toContainText("bulk third"); + const transcriptText = await transcript.innerText(); + expect(transcriptText.indexOf("bulk first")).toBeLessThan( + transcriptText.indexOf("bulk second"), + ); + expect(transcriptText.indexOf("bulk second")).toBeLessThan( + transcriptText.indexOf("bulk third"), + ); + await session.waitForChatIdle({ timeout: 45_000 }); + }); + test("queue editor textarea scrolls when content is long", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts index 6dbd17d115..4918b0ce5e 100644 --- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts @@ -1,5 +1,9 @@ -import { expect, type Locator } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { typeWhileBusy } from "../../helpers/type-while-busy"; +import { SessionPage } from "../../pages/session-page"; import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers"; async function expectTouchTarget(locator: Locator): Promise { @@ -10,6 +14,32 @@ async function expectTouchTarget(locator: Locator): Promise { expect(box!.height).toBeGreaterThanOrEqual(44); } +async function seedBusyQueueTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile queue Send Now", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.sendMessageViaButton("/slow 30s"); + await session.agentStatus().waitFor({ state: "visible", timeout: 15_000 }); + await testPage.waitForTimeout(500); + return session; +} + test("mobile full queue stays usable while removing and clearing messages", async ({ testPage, apiClient, @@ -49,3 +79,45 @@ test("mobile full queue stays usable while removing and clearing messages", asyn await expect(chat.getByTestId("queue-chip")).not.toBeVisible(); await expect(chat.getByTestId("chat-input-editor-shell")).toBeVisible(); }); + +test("mobile Send Now replaces a busy turn without hover or horizontal overflow", async ({ + testPage, + apiClient, + seedData, +}) => { + test.setTimeout(120_000); + + const session = await seedBusyQueueTask(testPage, apiClient, seedData); + const chat = session.activeChat(); + const editor = chat.locator(".tiptap.ProseMirror:visible").first(); + const submit = testPage.getByTestId("submit-message-button"); + for (const message of ["mobile first", "/slow 10s mobile second", "mobile third"]) { + await typeWhileBusy(testPage, editor, message); + await expect(submit).toBeEnabled(); + await submit.tap(); + } + + await chat.getByTestId("queue-chip").tap(); + const panel = chat.getByTestId("queued-ghost-list"); + await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); + const rowSendNow = panel.getByTestId("queue-entry-send-now").nth(1); + const headerSendNow = panel.getByTestId("queue-send-now"); + await expectTouchTarget(rowSendNow); + await expectTouchTarget(headerSendNow); + + const viewport = await testPage.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + + await rowSendNow.tap(); + const transcript = chat.locator(".chat-message-list:visible"); + await expect(transcript.getByText("/slow 10s mobile second", { exact: true })).toBeVisible({ + timeout: 20_000, + }); + await expect(panel.getByTestId("queue-entry-text")).toHaveCount(2, { timeout: 10_000 }); + await expect(panel.getByTestId("queue-entry-text").nth(0)).toContainText("mobile first"); + await expect(panel.getByTestId("queue-entry-text").nth(1)).toContainText("mobile third"); + await expect(session.chat).not.toContainText("Turn cancelled by user"); +}); diff --git a/apps/web/hooks/domains/session/use-queue.test.ts b/apps/web/hooks/domains/session/use-queue.test.ts index c66a55f624..241f378cfd 100644 --- a/apps/web/hooks/domains/session/use-queue.test.ts +++ b/apps/web/hooks/domains/session/use-queue.test.ts @@ -5,8 +5,10 @@ import type { EntityReference } from "@/lib/types/entity-reference"; const queueApiMock = vi.hoisted(() => { class QueueEntryNotFoundError extends Error {} + class QueueSendNowError extends Error {} return { QueueEntryNotFoundError, + QueueSendNowError, queueMessage: vi.fn(), clearQueue: vi.fn(), drainQueuedMessage: vi.fn(), @@ -14,6 +16,7 @@ const queueApiMock = vi.hoisted(() => { updateQueuedMessage: vi.fn(), removeQueuedEntry: vi.fn(), mergeQueuedEntry: vi.fn(), + sendQueuedNow: vi.fn(), }; }); @@ -24,6 +27,7 @@ type MockQueueState = { isLoading: Record; }; connection: { status: string }; + taskSessions: { items: Record }; setQueueEntries: ReturnType; removeQueueEntry: ReturnType; setQueueLoading: ReturnType; @@ -81,6 +85,7 @@ function resetMockState() { isLoading: {}, }, connection: { status: "connected" }, + taskSessions: { items: {} }, setQueueEntries: vi.fn(), removeQueueEntry: vi.fn(), setQueueLoading: vi.fn(), @@ -205,7 +210,7 @@ describe("useQueue", () => { }); }); -describe("useQueue context file metadata", () => { +describe("useQueue context file metadata and Send Now", () => { beforeEach(() => { resetMockState(); setDocumentVisibility("visible"); @@ -242,6 +247,55 @@ describe("useQueue context file metadata", () => { context_files: [{ path: "src/components", name: "components", is_directory: true }], }); }); + + it("sends one exact entry now and refetches authoritative status", async () => { + queueApiMock.sendQueuedNow.mockResolvedValue({ + session_id: SESSION_ID, + dispatched: true, + sent_count: 1, + }); + const { result } = renderHook(() => useQueue(SESSION_ID)); + await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled()); + queueApiMock.getQueueStatus.mockClear(); + + await act(async () => { + await result.current.sendEntryNow("q-2"); + }); + + expect(queueApiMock.sendQueuedNow).toHaveBeenCalledWith({ + session_id: SESSION_ID, + scope: "entry", + entry_id: "q-2", + }); + expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID); + }); + + it("refetches after a raced Send Now failure and preserves the typed error", async () => { + queueApiMock.sendQueuedNow.mockRejectedValueOnce(new queueApiMock.QueueSendNowError()); + const { result } = renderHook(() => useQueue(SESSION_ID)); + await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled()); + queueApiMock.getQueueStatus.mockClear(); + + await act(async () => { + await expect(result.current.sendAllNow()).rejects.toBeInstanceOf( + queueApiMock.QueueSendNowError, + ); + }); + + expect(queueApiMock.sendQueuedNow).toHaveBeenCalledWith({ + session_id: SESSION_ID, + scope: "all", + }); + expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID); + }); + + it("exposes authoritative cancellation progress for disabling controls", async () => { + mockState.taskSessions.items[SESSION_ID] = { cancellation_pending: true }; + const { result } = renderHook(() => useQueue(SESSION_ID)); + await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled()); + + expect(result.current.cancellationPending).toBe(true); + }); }); describe("useQueue mergeEntry", () => { diff --git a/apps/web/hooks/domains/session/use-queue.ts b/apps/web/hooks/domains/session/use-queue.ts index 2b85b55c47..a6fd84cfa7 100644 --- a/apps/web/hooks/domains/session/use-queue.ts +++ b/apps/web/hooks/domains/session/use-queue.ts @@ -10,6 +10,7 @@ import { removeQueuedEntry, mergeQueuedEntry, QueueEntryNotFoundError, + sendQueuedNow, } from "@/lib/api/domains/queue-api"; import type { QueueMessageParams } from "@/lib/api/domains/queue-api"; import type { QueuedMessage } from "@/lib/state/slices/session/types"; @@ -51,7 +52,19 @@ function useQueueState(sessionId: string | null) { const setQueueEntries = useAppStore((state) => state.setQueueEntries); const removeQueueEntry = useAppStore((state) => state.removeQueueEntry); const setQueueLoading = useAppStore((state) => state.setQueueLoading); - return { entries, meta, isLoading, setQueueEntries, removeQueueEntry, setQueueLoading }; + const cancellationPending = useAppStore( + (state) => + (sessionId ? state.taskSessions.items[sessionId]?.cancellation_pending : false) === true, + ); + return { + entries, + meta, + isLoading, + cancellationPending, + setQueueEntries, + removeQueueEntry, + setQueueLoading, + }; } type QueueActionsArgs = { @@ -79,20 +92,52 @@ function useDrainNextAction( }, [sessionId, refetch, setQueueLoading]); } -/** Build an action set bound to the supplied session + slice setters. */ -function useQueueActions({ - sessionId, - setQueueEntries, - removeQueueEntry, - setQueueLoading, - metaMax, -}: QueueActionsArgs) { - // Monotonic per-session refetch version. Concurrent mutations (merge, edit, - // remove, drain) all refetch; without a guard an older in-flight - // getQueueStatus response can land after a newer one and restore a stale - // snapshot (e.g. the pre-merge row). Discard any response that is no longer - // the latest issued for its session. +function useSendNowAction( + sessionId: string | null, + setQueueLoading: ReturnType["setQueueLoading"], + refetch: (sid: string) => Promise, +) { + return useCallback( + async (scope: "entry" | "all", entryId?: string) => { + if (!sessionId) return; + setQueueLoading(sessionId, true); + let mutationFailed = false; + let mutationError: unknown; + try { + try { + await sendQueuedNow({ + session_id: sessionId, + scope, + ...(scope === "entry" ? { entry_id: entryId } : {}), + }); + } catch (err) { + mutationFailed = true; + mutationError = err; + } + + try { + await refetch(sessionId); + } catch (reconcileError) { + if (mutationFailed) throw mutationError; + throw reconcileError; + } + if (mutationFailed) throw mutationError; + } finally { + setQueueLoading(sessionId, false); + } + }, + [sessionId, refetch, setQueueLoading], + ); +} + +function useQueueRefetch( + setQueueEntries: ReturnType["setQueueEntries"], + setQueueLoading: ReturnType["setQueueLoading"], +) { const refetchVersion = useRef>({}); + const invalidate = useCallback((sid: string) => { + refetchVersion.current[sid] = (refetchVersion.current[sid] ?? 0) + 1; + }, []); const refetch = useCallback( async (sid: string) => { const version = (refetchVersion.current[sid] ?? 0) + 1; @@ -103,13 +148,26 @@ function useQueueActions({ if (refetchVersion.current[sid] !== version) return; setQueueEntries(sid, status.entries ?? [], { count: status.count, max: status.max }); } finally { - if (refetchVersion.current[sid] === version) { - setQueueLoading(sid, false); - } + if (refetchVersion.current[sid] === version) setQueueLoading(sid, false); } }, [setQueueEntries, setQueueLoading], ); + return { refetch, invalidate }; +} + +/** Build an action set bound to the supplied session + slice setters. */ +function useQueueActions({ + sessionId, + setQueueEntries, + removeQueueEntry, + setQueueLoading, + metaMax, +}: QueueActionsArgs) { + const { refetch, invalidate: invalidateRefetch } = useQueueRefetch( + setQueueEntries, + setQueueLoading, + ); const queue = useCallback( async ({ @@ -153,7 +211,7 @@ function useQueueActions({ // Invalidate any pre-clear status request before publishing the // optimistic empty state. The explicit refetch below then owns the // newest version and replaces it with the backend's exact result. - refetchVersion.current[sessionId] = (refetchVersion.current[sessionId] ?? 0) + 1; + invalidateRefetch(sessionId); setQueueEntries(sessionId, [], { count: 0, max: metaMax ?? 0 }); } catch (err) { mutationFailed = true; @@ -172,17 +230,32 @@ function useQueueActions({ } finally { setQueueLoading(sessionId, false); } - }, [sessionId, setQueueEntries, setQueueLoading, metaMax, refetch]); + }, [sessionId, setQueueEntries, setQueueLoading, metaMax, refetch, invalidateRefetch]); const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch); + const sendNow = useSendNowAction(sessionId, setQueueLoading, refetch); + + const sendEntryNow = useCallback((entryId: string) => sendNow("entry", entryId), [sendNow]); + const sendAllNow = useCallback(() => sendNow("all"), [sendNow]); + const { editEntry, removeEntry, mergeEntry } = useEntryMutations({ sessionId, removeQueueEntry, refetch, }); - return { refetch, queue, clearAll, drainNext, editEntry, removeEntry, mergeEntry }; + return { + refetch, + queue, + clearAll, + drainNext, + sendEntryNow, + sendAllNow, + editEntry, + removeEntry, + mergeEntry, + }; } type EntryMutationsArgs = { @@ -282,16 +355,25 @@ function useEntryMutations({ sessionId, removeQueueEntry, refetch }: EntryMutati */ export function useQueue(sessionId: string | null) { const state = useQueueState(sessionId); - const { entries, meta, isLoading } = state; + const { entries, meta, isLoading, cancellationPending } = state; const connectionStatus = useAppStore((appState) => appState.connection.status); - const { refetch, queue, clearAll, drainNext, editEntry, removeEntry, mergeEntry } = - useQueueActions({ - sessionId, - setQueueEntries: state.setQueueEntries, - removeQueueEntry: state.removeQueueEntry, - setQueueLoading: state.setQueueLoading, - metaMax: meta?.max, - }); + const { + refetch, + queue, + clearAll, + drainNext, + sendEntryNow, + sendAllNow, + editEntry, + removeEntry, + mergeEntry, + } = useQueueActions({ + sessionId, + setQueueEntries: state.setQueueEntries, + removeQueueEntry: state.removeQueueEntry, + setQueueLoading: state.setQueueLoading, + metaMax: meta?.max, + }); useEffect(() => { if (!sessionId) return; @@ -324,9 +406,12 @@ export function useQueue(sessionId: string | null) { max: meta?.max ?? 0, isFull: meta ? meta.count >= meta.max && meta.max > 0 : false, isLoading, + cancellationPending, queue, clearAll, drainNext, + sendEntryNow, + sendAllNow, editEntry, removeEntry, mergeEntry, diff --git a/apps/web/lib/api/domains/queue-api.test.ts b/apps/web/lib/api/domains/queue-api.test.ts index a975af6569..2a24a453e2 100644 --- a/apps/web/lib/api/domains/queue-api.test.ts +++ b/apps/web/lib/api/domains/queue-api.test.ts @@ -9,11 +9,13 @@ vi.mock("@/lib/ws/connection", () => ({ import { MergeReferenceOverflowError, + QueueSendNowError, QueueFullError, QueueEntryNotFoundError, mergeQueuedEntry, queueMessage, rethrowQueueError, + sendQueuedNow, updateQueuedMessage, } from "./queue-api"; @@ -252,3 +254,57 @@ describe("mergeQueuedEntry", () => { ).rejects.toBeInstanceOf(MergeReferenceOverflowError); }); }); + +describe("sendQueuedNow", () => { + it("sends an exact entry scope", async () => { + const request = vi.fn().mockResolvedValue({ + session_id: "session-1", + dispatched: true, + sent_count: 1, + }); + getWebSocketClientMock.mockReturnValue({ request }); + + await sendQueuedNow({ + session_id: "session-1", + scope: "entry", + entry_id: "q-2", + }); + + expect(request).toHaveBeenCalledWith("message.queue.send_now", { + session_id: "session-1", + scope: "entry", + entry_id: "q-2", + }); + }); + + it("omits entry_id for an all scope snapshot", async () => { + const request = vi.fn().mockResolvedValue({ + session_id: "session-1", + dispatched: true, + sent_count: 3, + }); + getWebSocketClientMock.mockReturnValue({ request }); + + await sendQueuedNow({ session_id: "session-1", scope: "all" }); + + expect(request).toHaveBeenCalledWith("message.queue.send_now", { + session_id: "session-1", + scope: "all", + }); + }); + + it("maps send-now conflict codes to a typed error", async () => { + const request = vi.fn().mockRejectedValue({ + code: "send_now_conflict", + message: "Another cancellation is in progress", + }); + getWebSocketClientMock.mockReturnValue({ request }); + + await expect(sendQueuedNow({ session_id: "session-1", scope: "all" })).rejects.toMatchObject({ + code: "send_now_conflict", + }); + await expect(sendQueuedNow({ session_id: "session-1", scope: "all" })).rejects.toBeInstanceOf( + QueueSendNowError, + ); + }); +}); diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts index 862f907cee..06c2036a80 100644 --- a/apps/web/lib/api/domains/queue-api.ts +++ b/apps/web/lib/api/domains/queue-api.ts @@ -38,6 +38,34 @@ export class MergeReferenceOverflowError extends Error { } } +export type QueueSendNowErrorCode = + | "queue_empty" + | "queue_changed" + | "send_now_conflict" + | "turn_changed" + | "send_now_attachment_overflow" + | "send_now_reference_overflow"; + +const QUEUE_SEND_NOW_ERROR_CODES: ReadonlySet = new Set([ + "queue_empty", + "queue_changed", + "send_now_conflict", + "turn_changed", + "send_now_attachment_overflow", + "send_now_reference_overflow", +]); + +/** Error returned when Send Now cannot safely claim its click-time selection. */ +export class QueueSendNowError extends Error { + readonly code: QueueSendNowErrorCode; + + constructor(code: QueueSendNowErrorCode, message?: string) { + super(message ?? "Queued messages could not be sent now."); + this.name = "QueueSendNowError"; + this.code = code; + } +} + type WSError = { code?: string; message?: string; @@ -58,21 +86,31 @@ function asWSError(err: unknown): WSError | undefined { return undefined; } +function knownQueueError(wsErr: WSError): Error | undefined { + switch (wsErr.code) { + case "queue_full": { + const size = typeof wsErr.details?.queue_size === "number" ? wsErr.details.queue_size : 0; + const max = typeof wsErr.details?.max === "number" ? wsErr.details.max : 0; + return new QueueFullError(size, max); + } + case "entry_not_found": + return new QueueEntryNotFoundError(); + case "merge_reference_overflow": + return new MergeReferenceOverflowError(); + default: + if (wsErr.code && QUEUE_SEND_NOW_ERROR_CODES.has(wsErr.code as QueueSendNowErrorCode)) { + return new QueueSendNowError(wsErr.code as QueueSendNowErrorCode, wsErr.message); + } + return undefined; + } +} + export function rethrowQueueError(err: unknown): never { const wsErr = asWSError(err); - if (wsErr?.code === "queue_full") { - const size = typeof wsErr.details?.queue_size === "number" ? wsErr.details.queue_size : 0; - const max = typeof wsErr.details?.max === "number" ? wsErr.details.max : 0; - throw new QueueFullError(size, max); - } - if (wsErr?.code === "entry_not_found") { - throw new QueueEntryNotFoundError(); - } - if (wsErr?.code === "merge_reference_overflow") { - throw new MergeReferenceOverflowError(); - } - if (wsErr?.message) { - throw new Error(wsErr.message); + if (wsErr) { + const known = knownQueueError(wsErr); + if (known) throw known; + if (wsErr.message) throw new Error(wsErr.message); } throw err instanceof Error ? err : new Error(String(err)); } @@ -128,6 +166,31 @@ export async function drainQueuedMessage(sessionId: string): Promise<{ drained: return client.request<{ drained: boolean }>("message.queue.drain", { session_id: sessionId }); } +export type SendQueuedNowParams = { + session_id: string; + scope: "entry" | "all"; + entry_id?: string; +}; + +/** Interrupt the active turn and replace it with an exact queue selection. */ +export async function sendQueuedNow( + params: SendQueuedNowParams, +): Promise<{ session_id: string; dispatched: boolean; sent_count: number }> { + const client = getWebSocketClient(); + if (!client) { + throw new Error(WS_CLIENT_UNAVAILABLE); + } + try { + return await client.request<{ + session_id: string; + dispatched: boolean; + sent_count: number; + }>("message.queue.send_now", params); + } catch (err) { + rethrowQueueError(err); + } +} + /** Fetch the full queue snapshot (entries + capacity). */ export async function getQueueStatus(sessionId: string): Promise { const client = getWebSocketClient(); diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index ee358e0dc4..11edeef19f 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -48,6 +48,7 @@ "failedToStartQuickChat": "Failed to start quick chat", "fileTreeActions": "File actions", "loadingRepositories": "Loading repositories", + "failedToSendQueuedMessageNow": "Failed to send the queued message now.", "mergeReferenceOverflow": "Too many entity references to merge these queued messages.", "mergeWithAbove": "Merge with above", "newChatTab": "New Chat", @@ -99,6 +100,16 @@ "starting": "Starting...", "startingChat": "Starting chat...", "startNewChat": "Start new chat", + "sendNow": "Send Now", + "sendNowQueuedMessage": "Send queued message now", + "sendNowQueuedMessages": "Send all queued messages now", + "sendNowEntryAlreadySent": "Message already sent — agent picked it up before Send Now could start.", + "sendNowQueueEmpty": "The queue is empty.", + "sendNowQueueChanged": "The queue changed before Send Now could start.", + "sendNowConflict": "Another cancellation or Send Now operation is in progress.", + "sendNowTurnChanged": "The active turn changed before Send Now could start.", + "sendNowAttachmentOverflow": "Combined attachments exceed the message limits.", + "sendNowReferenceOverflow": "Combined entity references exceed the message limit.", "technicalDetails": "Technical details", "theAssociatedWorktree": "The associated worktree", "theTaskAndItsData": "The task and its data", diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 87cebb26df..23f0d39625 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -48,6 +48,7 @@ "failedToStartQuickChat": "Ƒàĩĺēď ţō śţàŕţ qũĩćķ ćĥàţ", "fileTreeActions": "Ƒĩĺē àćţĩōńś", "loadingRepositories": "Ĺōàďĩńĝ ŕēƥōśĩţōŕĩēś", + "failedToSendQueuedMessageNow": "Ƒàĩĺēď ţō śēńď ţĥē qũēũēď ḿēśśàĝē ńōŵ.", "mergeReferenceOverflow": "Ţōō ḿàńŷ ēńţĩţŷ ŕēƒēŕēńćēś ţō ḿēŕĝē ţĥēśē qũēũēď ḿēśśàĝēś.", "mergeWithAbove": "Ḿēŕĝē ŵĩţĥ àƀōvē", "newChatTab": "Ńēŵ Ćĥàţ", @@ -99,6 +100,16 @@ "starting": "Śţàŕţĩńĝ...", "startingChat": "Śţàŕţĩńĝ ćĥàţ...", "startNewChat": "Śţàŕţ ńēŵ ćĥàţ", + "sendNow": "Śēńď Ńōŵ", + "sendNowQueuedMessage": "Śēńď qũēũēď ḿēśśàĝē ńōŵ", + "sendNowQueuedMessages": "Śēńď àĺĺ qũēũēď ḿēśśàĝēś ńōŵ", + "sendNowEntryAlreadySent": "Ḿēśśàĝē àĺŕēàďŷ śēńţ — àĝēńţ ƥĩćķēď ĩţ ũƥ ƀēƒōŕē Śēńď Ńōŵ ćōũĺď śţàŕţ.", + "sendNowQueueEmpty": "Ţĥē qũēũē ĩś ēḿƥţŷ.", + "sendNowQueueChanged": "Ţĥē qũēũē ćĥàńĝēď ƀēƒōŕē Śēńď Ńōŵ ćōũĺď śţàŕţ.", + "sendNowConflict": "Àńōţĥēŕ ćàńćēĺĺàţĩōń ōŕ Śēńď Ńōŵ ōƥēŕàţĩōń ĩś ĩń ƥŕōĝŕēśś.", + "sendNowTurnChanged": "Ţĥē àćţĩvē ţũŕń ćĥàńĝēď ƀēƒōŕē Śēńď Ńōŵ ćōũĺď śţàŕţ.", + "sendNowAttachmentOverflow": "Ćōḿƀĩńēď àţţàćĥḿēńţś ēxćēēď ţĥē ḿēśśàĝē ĺĩḿĩţś.", + "sendNowReferenceOverflow": "Ćōḿƀĩńēď ēńţĩţŷ ŕēƒēŕēńćēś ēxćēēď ţĥē ḿēśśàĝē ĺĩḿĩţ.", "technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś", "theAssociatedWorktree": "Ţĥē àśśōćĩàţēď ŵōŕķţŕēē", "theTaskAndItsData": "Ţĥē ţàśķ àńď ĩţś ďàţà", diff --git a/docs/decisions/2026-08-05-queue-send-now-replaces-turn.md b/docs/decisions/2026-08-05-queue-send-now-replaces-turn.md new file mode 100644 index 0000000000..abe8ce5d1c --- /dev/null +++ b/docs/decisions/2026-08-05-queue-send-now-replaces-turn.md @@ -0,0 +1,64 @@ +# ADR-2026-08-05-queue-send-now-replaces-turn: Queue Send Now Replaces the Active Turn + +**Status:** accepted +**Date:** 2026-08-05 +**Area:** backend, frontend, protocol, workflow + +## Context + +The queue panel needs a **Send Now** action that interrupts a busy agent and +starts a replacement turn from one selected queued message or an aggregate of +the current queue. Kandev's ordinary user Cancel action may intentionally run a +workflow step's `on_turn_complete` policy, move the task to review, or auto-start +the destination step. Reusing that path would let a request to continue working +change workflow position before its replacement prompt is dispatched. + +## Decision + +`message.queue.send_now` is a turn-replacement operation, not an explicit +workflow-completing cancellation. + +- The orchestrator owns the whole cancel-and-dispatch operation under the + existing per-session cancellation coordinator and turn-identity guard. +- The operation gets a distinct cancellation kind. It publishes the existing + backend-owned cancellation progress, but it does not create the ordinary + **Turn cancelled** message, move the task to review, or evaluate + `cancel_triggers_turn_complete` / `on_turn_complete`. +- A promptable session dispatches the claimed queue selection without issuing a + cancel. A busy session cancels only when the active turn is confirmed to be + the same turn observed when the operation began; a successor turn is never + cancelled as a stale continuation of the request. +- Queue selection and dispatch remain one orchestrator operation. A selected + entry or click-time bulk snapshot is claimed only for this replacement turn, + and failure handling restores the original pending entries rather than + silently substituting another queue entry. +- An already accepted explicit user cancellation or another send-now operation + wins the conflict. Send Now fails closed instead of joining a + workflow-completing cancellation and dispatching after it. + +## Consequences + +Users can redirect a long-running turn without moving the task out of its +current workflow step. The action shares cancellation progress and race safety +with the existing coordinator while preserving a separate product meaning from +the Cancel button. + +The backend needs a new WebSocket action, queue claim/aggregation behavior, and +tests for cancellation-source races, successor-turn protection, restoration, +and mixed-provenance bulk delivery. The frontend must treat Send Now as an +asynchronous cancellation operation and disable duplicate actions while the +backend projection is pending. + +## Alternatives Considered + +- **Call the existing explicit `CancelAgent` action, then drain the queue.** + Rejected because configured user cancellation can complete a workflow step, + move the task, and auto-start unrelated successor work before the queued + prompt is sent. +- **Queue a copy of the selected content and use the parent-message interrupt + endpoint.** Rejected because it duplicates a durable row, loses the selected + entry's identity and attachments, and exposes the wrong authorization model. +- **Cancel first and let the ordinary FIFO drain choose the next message.** + Rejected because per-row Send Now must dispatch the selected entry, while + bulk Send Now must use the click-time queue snapshot rather than whichever + row happens to be at the head after a race. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 9d40f915d6..f31b797a8b 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -117,6 +117,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-03-backend-owned-cancellation-progress | [Keep Cancellation Progress Backend Owned](2026-08-03-backend-owned-cancellation-progress.md) | accepted | backend, frontend, protocol | 2026-08-03 | | 2026-08-03-separate-message-queue-provenance-cancellation-and-capacity | [Separate Message Queue Provenance, Cancellation, and Capacity](2026-08-03-separate-message-queue-provenance-cancellation-and-capacity.md) | accepted | backend, frontend, protocol, security | 2026-08-03 | | 2026-08-03-provider-scoped-task-mcp-tools | [Derive Provider-Scoped Task MCP Tools in the Backend](2026-08-03-provider-scoped-task-mcp-tools.md) | accepted | backend, agentctl, protocol, integrations | 2026-08-03 | +| 2026-08-05-queue-send-now-replaces-turn | [Queue Send Now Replaces the Active Turn](2026-08-05-queue-send-now-replaces-turn.md) | accepted | backend, frontend, protocol, workflow | 2026-08-05 | | 2026-08-04-file-backed-prompt-attachments | [File-backed prompt attachments](2026-08-04-file-backed-prompt-attachments.md) | accepted | backend, frontend, protocol, infra | 2026-08-04 | | 2026-08-04-plugin-contribution-lifecycle-authority | [Make Plugin Contribution Lifecycle Authoritative](2026-08-04-plugin-contribution-lifecycle-authority.md) | accepted | frontend, security | 2026-08-04 | | 2026-08-04-navigation-manifest-boundaries | [Centralize Navigation and Namespace Plugin Destinations](2026-08-04-navigation-manifest-boundaries.md) | accepted | frontend | 2026-08-04 | diff --git a/docs/plans/message-queue-send-now-fifo-arbitration/plan.md b/docs/plans/message-queue-send-now-fifo-arbitration/plan.md new file mode 100644 index 0000000000..ebac0c3641 --- /dev/null +++ b/docs/plans/message-queue-send-now-fifo-arbitration/plan.md @@ -0,0 +1,199 @@ +--- +spec: docs/specs/ui/message-queue-send-now.md +created: 2026-08-05 +status: implemented +--- + +# Implementation Plan: Arbitrate Send Now Against FIFO Handoff + +## Overview + +Repair the narrow race between ordinary FIFO queue draining and the explicit +Send Now operation. The diagnostic bundle shows that FIFO removes/reserves the +head and starts an asynchronous worker after the active turn becomes ready; +Send Now then sees only the in-flight marker and returns `send_now_conflict`, +so two queued messages run as separate turns instead of one replacement turn. + +Add an explicit per-session handoff state with a pre-acceptance ownership +boundary. Send Now may supersede and restore an unaccepted FIFO reservation, +then claim its exact row or all-scope snapshot. Once FIFO has accepted prompt +ownership, the existing fail-closed conflict remains the safe behavior. The +fix is backend-only apart from a browser regression assertion; no queue schema, +WebSocket shape, or frontend control change is planned. + +## Confirmed Root Cause + +- The bundle records two queue entries, then the active turn becomes ready. +- The normal drain reserves the FIFO head and launches + `executeQueuedMessage` asynchronously. +- `SendQueuedNow` checks `isQueuedDispatchInFlight` in + `queue_send_now.go` and returns `ErrSendNowConflict` before it can aggregate + the queue. +- The worker can also record the ordinary user message and run turn-start + effects before its final prompt claim, so allowing an uncoordinated + supersession would risk duplicate transcript/workflow side effects. +- The result in the bundle is two independent queued prompts followed by the + Send Now error, matching this race rather than a cancellation-acknowledgement + failure. + +## Backend + +### Per-session queued-dispatch handoff + +Files: + +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/queue_send_now.go` + +Replace the bare session-to-entry in-flight marker with (or wrap it in) a +per-session reservation that records the exact queued source and whether the +automatic worker is still pre-acceptance or has claimed prompt ownership. +Keep the existing per-session cancellation guard as the single arbitration +point. + +- Register the FIFO reservation before spawning the worker, as today. +- Make the worker claim ownership under the guard before recording an ordinary + user message, running `on_turn_start` effects, or accepting the agent prompt. +- Let Send Now, while holding the same guard, supersede only a pre-acceptance + FIFO reservation. Restore that exact ordinary or durable source, publish the + authoritative queue state at the normal transition point, and continue + through the existing exact-entry/bulk claim and replacement dispatch path. +- Preserve the existing `send_now_conflict` behavior for an accepted FIFO + handoff, explicit cancellation, or another Send Now operation. Do not + cancel or duplicate a successor that has already claimed the turn. +- Update all marker helpers and callers (`drainQueuedMessageForPromptableSession`, + targeted queue takes, lifecycle dispatch checks, and `promptTask`) so a stale + worker cannot clear a newer owner or perform side effects after losing. + +### Restoration and lifecycle safety + +Use the existing message-queue restoration and durable acknowledgement contract; +do not add a database migration. A superseded ordinary row must return to its +original FIFO position, and a durable lifecycle row must have its transient +reservation cleared. The winning Send Now claim remains the only source that +can create the replacement prompt and acknowledge durable sources. + +Do not change explicit Cancel completion semantics, captured-turn checks, +backend-owned cancellation progress, normal FIFO ordering, or `Run next` +behavior outside this pre-acceptance arbitration window. + +## Frontend and Protocol + +No production frontend or WebSocket contract change is required. The existing +error mapping/refetch remains the correct response if FIFO has already accepted +its successor. Extend the desktop queue E2E assertion so the aggregate appears +as one user prompt, not merely as three strings that could have arrived in +separate turns. Re-run the existing mobile Send Now scenario because the +control and shared API path are unchanged. + +## Tests + +- **What:** a normal FIFO reservation can be superseded before prompt + acceptance, restored, and included in one exact bulk Send Now claim. + **File:** `apps/backend/internal/orchestrator/queue_send_now_test.go` or a + focused `queued_dispatch_arbitration_test.go` beside it. + **How:** channel-synchronized handoff barriers, no sleeps; assert one + aggregate prompt, both source IDs restored/claimed correctly, FIFO order, + and no stale worker transcript or workflow side effects. +- **What:** an accepted FIFO handoff remains terminal for Send Now. + **File:** the same focused orchestrator test file. + **How:** pause after ownership acceptance, issue Send Now, assert + `ErrSendNowConflict`, no duplicate prompt/message, no cancellation of the + successor, and untouched remaining queue. +- **What:** ordinary FIFO delivery and existing explicit-cancel/peer-interrupt + paths retain their current semantics after the handoff state change. + **Files:** focused existing queue/cancellation tests under + `apps/backend/internal/orchestrator/`. + **How:** run the targeted regression groups with `-race`; include durable + lifecycle and retry paths because they share the reservation marker. +- **What:** the browser-visible bulk action produces one replacement user + prompt containing both queued bodies in order. + **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts`. + **How:** extend the existing header Send Now scenario to assert one + `user-message-bubble` contains the aggregate, then verify the queue is empty + and the turn completes. Re-run the existing mobile Send Now scenario for + shared-path parity. + +## E2E Tests + +- **Scenario:** while a desktop agent is busy, queue multiple messages and + activate header Send Now. Verify the replacement transcript contains one + user prompt with the concatenated bodies in FIFO order, rather than one + visible prompt per source entry, and that the queue disappears. + **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts`. +- **Scenario:** on Pixel 5, run the existing row Send Now flow and verify the + shared arbitration result still leaves neighboring queue entries ordered, + preserves the 44px controls, and keeps document horizontal overflow at zero. + **File:** `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`. + +## Verification Results + +- RED before the arbitration implementation: `cd apps/backend && go test + -race ./internal/orchestrator -run + '^TestSendQueuedNowSupersedesPendingFIFOHandoff$' -count=1` failed with + `send-now operation is already in progress`, reproducing the diagnostic-bundle + race. +- Focused backend: `cd apps/backend && go test -race ./internal/orchestrator + -run 'Test(SendNow|QueuedDispatch|ExecuteQueuedMessage)' -count=1` — 25 + passed. +- Queue repository: `cd apps/backend && go test -race + ./internal/orchestrator/messagequeue -run 'Test.*SendNow' -count=1` — 11 + passed. +- Broader queue/cancellation regression: `cd apps/backend && go test -race + ./internal/orchestrator -run + 'Test(.*Queued.*|.*Queue.*|.*Cancel.*|.*Interrupt.*|.*Peer.*)' -count=1` — + 179 passed. +- Full orchestrator package: `cd apps/backend && go test -race + ./internal/orchestrator -count=1` — 1546 passed. +- Changed-file Go lint: `cd apps/backend && golangci-lint run ./... --new-from-rev + 5c51dde562e2264ae63544993be88fd7f383222d --timeout=5m` — 0 issues. +- Web lint: `cd apps && pnpm --filter @kandev/web lint` — passed. +- Web typecheck: `cd apps/web && pnpm run typecheck` — no errors. +- E2E formatting: `cd apps && pnpm exec prettier --check + web/e2e/tests/chat/message-queue.spec.ts` — passed. +- Desktop aggregate E2E: `cd apps/web && pnpm e2e:run + tests/chat/message-queue.spec.ts -- --grep 'header Send Now' --retries=0` — 1 + passed. +- Mobile parity E2E: `cd apps/web && pnpm e2e:run --project mobile-chrome + tests/chat/mobile-message-queue-management.spec.ts -- --grep 'Send Now' + --retries=0` — 1 passed. +- Orchestrator compile check: `cd apps/backend && go test + ./internal/orchestrator/... -run '^$' -count=1` — no tests found; compile + completed successfully. +- `git diff --check` is run before commit. + +## Implementation Waves and Parallel Candidates + +The default is sequential execution in the primary conversation. These waves do +not authorize subagents. + +Wave 1: + +- [x] [Task 01: Arbitrate FIFO and Send Now handoff](task-01-fifo-send-now-arbitration.md) + +Wave 2: + +- [x] [Task 02: Prove one aggregate replacement prompt](task-02-aggregate-replacement-e2e.md) + +Task 02 depends on Task 01 and is not parallel-safe with it because it verifies +the changed backend timing and prompt ownership behavior. + +## Risks and Boundaries + +- The ownership boundary must precede visible message creation and workflow + turn-start effects; a late check can still create a duplicate transcript row. +- Restoring a reserved lifecycle entry must clear only the transient delivery + reservation and must not acknowledge or discard the durable source. +- Once an automatic prompt is accepted, combining it with a later Send Now + request is intentionally out of scope because it would require retracting or + rewriting an already visible user message. The operation fails closed there. +- No schema migration, new error code, queue reordering, arbitrary subset + selection, or change to normal FIFO/Run next semantics is planned. + +## Open Questions + +None. The diagnostic evidence and existing `message-queue-send-now` contract +define the repair boundary. diff --git a/docs/plans/message-queue-send-now-fifo-arbitration/task-01-fifo-send-now-arbitration.md b/docs/plans/message-queue-send-now-fifo-arbitration/task-01-fifo-send-now-arbitration.md new file mode 100644 index 0000000000..ff2e30dbfe --- /dev/null +++ b/docs/plans/message-queue-send-now-fifo-arbitration/task-01-fifo-send-now-arbitration.md @@ -0,0 +1,110 @@ +--- +id: "01-fifo-send-now-arbitration" +title: "Arbitrate FIFO and Send Now handoff" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 01: Arbitrate FIFO and Send Now handoff + +## Acceptance + +- A normal FIFO drain registers a per-session handoff with an explicit + pre-acceptance phase and an accepted/terminal phase. +- Send Now, under the existing cancellation guard, can supersede only the + pre-acceptance FIFO handoff, restore its exact ordinary or durable source, + and claim the requested exact row or all-scope snapshot without partial + mutation. +- With two queued ordinary messages at the ready boundary, header Send Now + produces one aggregate replacement prompt containing both bodies in FIFO + order; the losing FIFO worker produces no duplicate visible message, + workflow turn-start effect, or prompt. +- An accepted FIFO handoff returns the existing `send_now_conflict` behavior; + it does not cancel or duplicate the successor, and remaining queue entries + remain authoritative. +- Existing normal FIFO, explicit Cancel, peer-interrupt, lifecycle reservation, + retry, captured-turn, and cancellation-progress behavior remains unchanged + outside the new pre-acceptance arbitration window. + +## Verification + +```bash +cd apps/backend +go test -race ./internal/orchestrator -run 'Test(SendNow|QueuedDispatch|ExecuteQueuedMessage)' -count=1 +go test -race ./internal/orchestrator/messagequeue -run 'Test.*SendNow' -count=1 +``` + +## Files Likely Touched + +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/queue_send_now.go` +- `apps/backend/internal/orchestrator/queue_send_now_test.go` +- `apps/backend/internal/orchestrator/event_handlers_queue_test.go` or a new + focused `queued_dispatch_arbitration_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. The task changes the shared per-session dispatch ownership +mechanism and must be implemented and verified as one concurrency surface. + +## Inputs + +- Amended spec scenarios: FIFO reservation superseded before prompt + acceptance, and accepted FIFO handoff failing closed. +- `apps/backend/internal/orchestrator/queue_send_now.go` current conflict gate. +- `dispatchTakenQueuedMessage` and `executeQueuedMessage` in + `event_handlers_workflow.go`/`event_handlers_agent.go`. +- `claimSessionRunningForPrompt` and the existing dispatch token checks in + `task_operations.go`. +- Existing queue lifecycle and cancellation tests that use + `markQueuedDispatchInFlight`. + +## TDD Sequence + +1. Add a deterministic test barrier after the FIFO source is reserved but + before the worker claims prompt ownership. Trigger header Send Now with two + queued sources and assert the current implementation returns the conflict + or otherwise fails the new expected one-prompt invariant (RED). +2. Add the accepted-handoff race case and assert the existing fail-closed + contract, plus a baseline normal FIFO case so the arbitration does not turn + every automatic drain into a Send Now claim. +3. Introduce the reservation state/ownership transition and move ordinary + message recording and turn-start effects behind the worker's guarded + ownership claim. +4. Implement Send Now supersession/restoration for only the pre-acceptance + state, then route the winner through the existing exact claim and aggregate + dispatch path. +5. Add durable lifecycle and stale-worker assertions, run the focused `-race` + commands, and confirm no marker cleanup path can remove a newer owner. + +## Output Contract + +Report the deterministic RED failure, the reservation states and guarded +ownership boundary, restoration behavior for ordinary/durable rows, exact +focused command results, files changed, and any remaining race or blocker. +Mark this task `done` and update its plan checkbox only after the tests pass. + +## Results + +- RED reproduced the reported conflict before the fix: the pending FIFO + handoff test returned `send-now operation is already in progress`. +- Added a typed per-session reservation with pending, accepted, and superseded + phases. The FIFO worker claims the reservation under `cancelInFlight` before + transcript or workflow side effects; Send Now can supersede only pending + FIFO work and restores its exact source first. +- Durable lifecycle restoration clears only the transient reservation; the + aggregate Send Now claim remains responsible for final acknowledgement. + The stale FIFO worker is covered by a synchronous no-requeue assertion. +- Focused command results: 25 orchestrator tests passed with `-race`, and 11 + messagequeue Send Now tests passed with `-race`. +- Broader queue/cancellation command result: 179 tests passed with `-race`. diff --git a/docs/plans/message-queue-send-now-fifo-arbitration/task-02-aggregate-replacement-e2e.md b/docs/plans/message-queue-send-now-fifo-arbitration/task-02-aggregate-replacement-e2e.md new file mode 100644 index 0000000000..03a0e551cb --- /dev/null +++ b/docs/plans/message-queue-send-now-fifo-arbitration/task-02-aggregate-replacement-e2e.md @@ -0,0 +1,86 @@ +--- +id: "02-aggregate-replacement-e2e" +title: "Prove one aggregate replacement prompt" +status: done +wave: 2 +depends_on: ["01-fifo-send-now-arbitration"] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 02: Prove one aggregate replacement prompt + +## Acceptance + +- The desktop header Send Now scenario proves the two or more queued bodies + are rendered in one user-message bubble in FIFO order, not merely that all + strings eventually appear somewhere in the transcript. +- The desktop scenario still proves the queue is empty after a successful bulk + claim and the replacement turn completes. +- The existing Pixel 5 Send Now scenario passes against the repaired backend, + preserving touch geometry, neighboring-row order, and zero document + horizontal overflow. +- The test does not rely on arbitrary sleeps to establish correctness; it uses + visible queue/prompt/idle signals and the existing mock-agent fixtures. + +## Verification + +```bash +cd apps/web +pnpm e2e --project=chromium e2e/tests/chat/message-queue.spec.ts --grep 'header Send Now' --retries=0 +pnpm e2e --project=mobile-chrome e2e/tests/chat/mobile-message-queue-management.spec.ts --grep 'Send Now' --retries=0 +``` + +## Files Likely Touched + +- `apps/web/e2e/tests/chat/message-queue.spec.ts` +- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` only if + the shared-path assertion needs a focused strengthening + +## Dependencies + +- Task 01 must pass its focused backend race tests and leave the backend + handoff state stable. + +## Parallelism + +Sequential. The browser assertions depend on the backend arbitration contract +and share the queue fixtures with the existing mobile scenario. + +## Inputs + +- The amended spec's one-prompt FIFO handoff scenario. +- Existing desktop header and row Send Now coverage in + `message-queue.spec.ts`. +- Existing Pixel 5 coverage in + `mobile-message-queue-management.spec.ts`. +- `data-testid="user-message-bubble"` as the transcript-level assertion for a + single aggregate user prompt. + +## TDD Sequence + +1. Strengthen the desktop bulk scenario to locate the replacement user bubble + containing the first body and require the second body in that same bubble; + run it against the pre-fix implementation to record the expected RED + behavior when the FIFO handoff wins. +2. Run the scenario after Task 01 and assert the queue disappearance and idle + transition only after the aggregate bubble is visible. +3. Re-run the existing mobile Send Now scenario, recording touch-target and + overflow evidence; do not alter production UI for this backend repair. + +## Output Contract + +Report the RED/GREEN browser results, the exact single-bubble assertion, the +mobile parity and overflow checks, files changed, generated traces/screenshots +if any, and cleanup evidence. Mark this task `done` and update its plan +checkbox only after both focused commands pass. + +## Results + +- Strengthened the desktop bulk scenario with a single + `user-message-bubble` assertion containing all queued bodies in FIFO order. +- Desktop managed E2E passed: 1 test for the header Send Now scenario. +- Pixel 5/mobile-chrome managed E2E passed: 1 test covering Send Now without + hover, 44px touch geometry, neighboring queue order, and zero horizontal + overflow. +- No production frontend change or generated E2E artifact was required. diff --git a/docs/plans/message-queue-send-now/plan.md b/docs/plans/message-queue-send-now/plan.md new file mode 100644 index 0000000000..4b20af4be1 --- /dev/null +++ b/docs/plans/message-queue-send-now/plan.md @@ -0,0 +1,299 @@ +--- +spec: docs/specs/ui/message-queue-send-now.md +created: 2026-08-05 +status: implemented +--- + +# Implementation Plan: Send Queued Messages Now + +## Overview + +Add one explicit WebSocket operation that asks the orchestrator to replace the +active turn with either one queued entry or the click-time snapshot of the +whole visible queue. Build the exact-entry/batch claim and restoration contract +first, then compose it with the existing cancellation coordinator and expose it +through the frontend queue hook and inline panel. Finish with desktop/mobile +Playwright coverage and the public queue-control guides. + +Decision inputs: + +- [Queue Send Now Replaces the Active Turn](../../decisions/2026-08-05-queue-send-now-replaces-turn.md) +- [Keep Cancellation Progress Backend Owned](../../decisions/2026-08-03-backend-owned-cancellation-progress.md) +- [Explicit User Cancellation May Complete a Workflow Step](../../decisions/2026-08-02-explicit-user-cancel-completion.md) +- [Version Agent-Ready Events by Prompt Generation](../../decisions/0035-version-agent-ready-events-by-prompt-generation.md) + +--- + +## Backend + +### Exact queue dispatch claims + +Files: + +- `apps/backend/internal/orchestrator/messagequeue/types.go` +- `apps/backend/internal/orchestrator/messagequeue/repository.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_memory.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go` +- `apps/backend/internal/orchestrator/messagequeue/service.go` +- new focused helpers/tests under `apps/backend/internal/orchestrator/messagequeue/` + +Add a `SendNowClaim` value that retains the ordered original entries plus the +synthetic dispatch envelope. Add repository/service operations to atomically +claim an exact ordered list of entry IDs, restore the claim, and acknowledge +its durable lifecycle rows. + +The claim transaction must fail without partial mutation when any requested row +is missing, belongs to another session, or is already reserved. Ordinary rows +are removed; durable lifecycle rows receive the existing in-flight reservation +and remain until acknowledgement. Restoration reinserts ordinary rows at their +original positions and clears durable reservations. + +Build the bulk envelope from the claimed rows: FIFO content joined by `\n\n` +between non-empty bodies, FIFO attachments, canonical reference union, oldest +entry model/plan mode/attribution, and source identity/provenance metadata. +Validate attachment count/bytes and reference count before cancellation using +the same constants as ordinary message admission. + +### Non-completing cancel-and-dispatch + +Files: + +- new `apps/backend/internal/orchestrator/queue_send_now.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/service.go` + +Add `Service.SendQueuedNow(ctx, sessionID, scope, entryID)` and a distinct +`cancellationKindQueueSendNow`. Authorize the session first, snapshot the active +turn and exact queue selection, and validate the aggregate before cancellation. + +Reuse `cancelInFlightGuard`, the backend cancellation projection, captured turn +identity, and silent cancellation reconciliation. Do not call the explicit +`CancelAgent` path: Send Now must not write the ordinary cancellation message, +move the task to review, or evaluate cancelled-turn completion. Reject an +existing explicit cancellation or Send Now owner. If the observed turn changes, +leave the successor and queue untouched; if the session is already promptable, +claim and dispatch without cancellation. + +Refactor the queued execution handoff only as far as necessary to accept a +single- or multi-entry `SendNowClaim`. A successful prompt acknowledges every +durable source; retryable admission/execution failures restore every original +source and publish authoritative queue status. Never fall back to the FIFO head +when the selected entry or bulk snapshot cannot be claimed. + +### WebSocket action + +Files: + +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers.go` +- `apps/backend/internal/backendapp/gateway.go` only if constructor wiring must + name a broader dispatcher interface + +Register `message.queue.send_now` with request fields `session_id`, `scope`, and +conditional `entry_id`. Extend the existing drainer dependency into a queue +dispatcher interface implemented by `orchestrator.Service`. + +Authorize before all queue reads/cancellation, validate the scope combination, +call `SendQueuedNow`, map the spec's stable error codes, publish status, and +return `{session_id, dispatched: true, sent_count}`. Add the action to the +gateway authorization pinning tests so `session_id` receives the existing +backstop. + +--- + +## Frontend + +### Queue API and hook + +Files: + +- `apps/web/lib/api/domains/queue-api.ts` +- `apps/web/lib/api/domains/queue-api.test.ts` +- `apps/web/hooks/domains/session/use-queue.ts` +- `apps/web/hooks/domains/session/use-queue.test.ts` + +Add `sendQueuedNow` plus typed error mapping for the stable send-now codes. Add +`sendEntryNow(entryId)` and `sendAllNow()` to `useQueue`; both set queue loading, +invoke the action, and refetch authoritative status on success or failure. A +missing/raced entry is a reconciled result plus focused feedback, not a stale +optimistic removal. + +### Queue controls + +Files: + +- `apps/web/components/task/chat/queued-ghost-list.tsx` +- `apps/web/components/task/chat/queued-ghost-list.test.tsx` +- `apps/web/components/task/chat/queued-ghost-message.tsx` +- `apps/web/components/task/chat/queued-ghost-message.test.tsx` +- `apps/web/src/locales/en/chat.json` +- generated pseudo-locale output if the catalog workflow changes it + +Add a per-row **Send Now** button to the existing `RowActions` hover/focus +group and thread the selected entry ID through the queue hook. Add header +**Send Now** immediately before **Clear all** and bind it to `sendAllNow`. +Disable both controls while the queue request or authoritative session +`cancellation_pending` state is true, use the existing spinner/toast patterns, +and localize all labels and failures. + +Keep **Run next** unchanged for promptable FIFO-head dispatch. When present, the +header order is **Run next**, **Send Now**, **Clear all**, collapse. Let the +action group wrap on narrow viewports rather than shrink or overflow. + +--- + +## Responsive and Mobile Contract + +- **Desktop outcome and mobile entry point:** desktop opens the existing queue + chip and uses hover/focus row actions or the header; mobile opens the same + chip and sees both Send Now controls without hover. +- **Nearest shipped exemplar:** + `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` supplies the + inline panel, single scroll owner, visible touch controls, and 44px geometry. +- **Hierarchy and primary action:** queue content remains primary; per-row Send + Now sits with row actions, while bulk Send Now is the first bulk mutation + immediately before Clear all. +- **Presentation choice:** retain the inline queue panel because the action is a + frequent, short mutation of already visible content; no drawer or navigation + layer is introduced. +- **Geometry and scrolling:** `queue-scroll-region` remains the only internal + scroll owner. The header action group may wrap, controls are at least 44px on + coarse pointers, the composer remains visible, and document horizontal + overflow stays zero. +- **Shared logic:** API calls, loading/cancellation state, selection, aggregation, + and errors are shared. Only the existing pointer-responsive action visibility + differs. +- **Mobile proof:** a `mobile-chrome` test taps row/header Send Now, asserts the + replacement prompt outcome, verifies touch geometry, and checks horizontal + containment. + +--- + +## Tests + +- **What:** exact-entry and exact-snapshot batch claims are atomic, preserve FIFO + source order, reserve durable rows, restore originals, and acknowledge every + durable source. + **Files:** new `repository_*_send_now_test.go` coverage for memory, SQLite, + and env-gated PostgreSQL plus `service_test.go`. + **How:** table-driven repository tests for selected/all claims, missing or + changed snapshots, mixed ordinary/durable rows, restoration, and no + cross-session mutation. +- **What:** bulk aggregation joins content correctly, preserves FIFO + attachments, deduplicates references, uses the oldest envelope, and rejects + attachment/reference overflow before mutation. + **Files:** new messagequeue send-now helper tests and orchestrator tests where + task-model attachment limits are applied. + **How:** deterministic unit tests with attachment-only messages, repeated + references, mixed provenance, and over-limit inputs. +- **What:** busy Send Now cancels the captured turn and dispatches the exact + selection without explicit-cancel workflow side effects; promptable Send Now + skips cancellation. + **File:** `apps/backend/internal/orchestrator/queue_send_now_test.go`. + **How:** channel-synchronized lifecycle mocks assert one cancel, no review or + cancelled-turn completion, exact prompt content, and one successor turn. +- **What:** successor-turn, explicit-cancel, duplicate-click, queue-change, and + prompt-failure races fail closed or restore the original queue. + **Files:** `queue_send_now_test.go` and focused existing cancellation tests if + shared coordinator behavior changes. + **How:** captured-turn replacement and cancellation-owner race tests without + sleeps; assert no substitute FIFO dispatch. +- **What:** the WS action validates scope, authorizes before dependencies, maps + stable errors, and publishes the post-action status. + **File:** `apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go`. +- **What:** frontend API/hook calls and both controls use the correct scope, + refetch after every outcome, show localized failures, and disable during + loading/cancellation. + **Files:** existing queue API, hook, list, and row component test files. + +--- + +## E2E Tests + +- **Scenario:** while a desktop task agent runs a cancellable slow turn, queue + three distinct messages and activate row Send Now on the second. Verify the + replacement transcript contains the second prompt, the cancelled turn does + not advance the workflow, and the first/third rows remain ordered. + **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts`. +- **Scenario:** while a desktop task agent is busy, queue several messages and + activate header Send Now. Verify one replacement prompt contains all bodies + in FIFO order and the queue disappears. + **File:** `apps/web/e2e/tests/chat/message-queue.spec.ts`. +- **Scenario:** on Pixel 5, open the busy queue panel and tap Send Now. Verify + the row and header controls are 44px, require no hover, remain inside the + viewport with no document horizontal overflow, and complete a replacement + turn. + **File:** `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`. + +--- + +## Public Documentation + +Update the how-to/explanation sections in: + +- `docs/public/coordination.md` +- `docs/public/sessions-and-review.md` + +Explain the difference between **Run next** (promptable FIFO head), **Send Now** +(interrupt and replace without workflow completion), **Clear all**, and the +normal Cancel control. No new page or navigation entry is required. + +--- + +## Verification Results + +- Backend queue claims and envelope aggregation: `cd apps/backend && go test -race ./internal/orchestrator/messagequeue -count=1 -v` — 116 tests passed. The memory and SQLite claim suites cover exact atomic selection, click-time snapshot changes, FIFO restoration, durable reservation, acknowledgement, aggregate attachments, and reference limits. The env-gated PostgreSQL cases were included but skipped because `KANDEV_TEST_POSTGRES_DSN` is unset. +- Backend replacement orchestration: `cd apps/backend && go test -race ./internal/orchestrator -run 'SendNow|ExplicitCancellationDoesNotJoin' -count=1 -v` — 6 tests passed; `cd apps/backend && go test -race ./internal/orchestrator/handlers -run 'SendNow|QueueHandler' -count=1 -v` — 24 tests passed; existing cancellation/peer-interrupt regressions — 56 tests passed. The retry regression also proves restored ordinary and durable sources retain the recorded-user-message marker. +- Backend compile sweep: `cd apps/backend && go test ./... -run '^$'` — completed with no test bodies selected, compiling all packages successfully. +- Backend lint: `make -C apps/backend lint` — completed with 0 issues after extracting the repository, handler, and orchestration validation helpers. +- Frontend queue API, hook, and component suite: 4 files, 97 tests passed. `cd apps/web && pnpm run typecheck` passed; full web lint passed with `pnpm --filter @kandev/web run lint`; `i18n:pseudo`, `i18n:check`, and `i18n:ratchet` all passed. +- Public documentation: `node --test scripts/validate-public-docs.test.mjs` — 58 tests passed; `node scripts/validate-public-docs.mjs` — 41 published docs pages validated; `git diff --check` passed. +- E2E discovery found 12 desktop queue tests and 2 mobile queue-management tests. After rebuilding and syncing the embedded web assets with `make build-web` and `make sync-embedded-web && make build-backend`, `cd apps/web && pnpm e2e --project=chromium e2e/tests/chat/message-queue.spec.ts --grep 'Send Now' --retries=0` — 2 passed in 33.4s; the matching `mobile-chrome` command for `mobile-message-queue-management.spec.ts` — 1 passed in 13.1s. The desktop row scenario verifies workflow-step preservation and neighboring FIFO entries; the mobile scenario verifies touch geometry and zero horizontal overflow. + +--- + +## Implementation Waves and Parallel Candidates + +The default is sequential execution in the primary conversation. The files are +ordered by dependency; these waves do not authorize subagents. + +Wave 1: + +- [x] [Task 01: Add exact send-now queue claims](task-01-queue-send-now-claims.md) + +Wave 2: + +- [x] [Task 02: Orchestrate replacement-turn dispatch](task-02-replacement-turn-dispatch.md) + +Wave 3: + +- [x] [Task 03: Add Send Now queue controls](task-03-send-now-queue-controls.md) + +Wave 4: + +- [x] [Task 04: Prove Send Now end to end](task-04-send-now-e2e.md) +- [x] [Task 05: Document Send Now behavior](task-05-document-send-now.md) + +Tasks 04 and 05 own disjoint E2E and public-doc files, but the final plan/status +and verification-results update is serialized after both tasks complete so the +durable implementation record cannot be overwritten by parallel edits. + +--- + +## Risks and Boundaries + +- `task_operations.go` and cancellation coordination are high-contention + concurrency surfaces. Keep new orchestration in a focused file and preserve + captured-turn and prompt-generation checks. +- Explicit Cancel can complete/advance a workflow step; Send Now must never + reuse or join that semantic path. +- Bulk delivery can mix provenance and durable lifecycle rows. The batch claim + must preserve originals for retry and acknowledge every durable source only + after prompt acceptance. +- Aggregate attachment/reference limits must be checked before cancellation so + a validation error cannot stop useful active work. +- New queue entries accepted after the click-time snapshot remain pending and + must not leak into the replacement prompt. +- No schema migration, feature flag, queue reordering, arbitrary subset + selection, model picker, or change to ordinary FIFO drain is planned. diff --git a/docs/plans/message-queue-send-now/task-01-queue-send-now-claims.md b/docs/plans/message-queue-send-now/task-01-queue-send-now-claims.md new file mode 100644 index 0000000000..900f8c474a --- /dev/null +++ b/docs/plans/message-queue-send-now/task-01-queue-send-now-claims.md @@ -0,0 +1,79 @@ +--- +id: "01-queue-send-now-claims" +title: "Add exact send-now queue claims" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 01: Add exact send-now queue claims + +## Intent + +Give the orchestrator an atomic, loss-aware way to claim one persisted queue +entry or an exact ordered snapshot, restore the original entries on failure, +and acknowledge every durable source after acceptance. + +## Acceptance + +1. An exact-ID claim is all-or-none, session-scoped, FIFO-ordered, and treats + ordinary versus durable lifecycle rows according to the existing delivery + guarantees. +2. Bulk aggregation preserves content, attachments, references, execution + envelope, and source provenance exactly as specified, with pre-mutation + overflow errors. +3. Restore and acknowledge operations handle mixed ordinary/durable claims + without changing unrelated or newly queued rows. + +## Files likely touched + +- `apps/backend/internal/orchestrator/messagequeue/types.go` +- `apps/backend/internal/orchestrator/messagequeue/repository.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_memory.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go` +- `apps/backend/internal/orchestrator/messagequeue/service.go` +- `apps/backend/internal/orchestrator/messagequeue/send_now.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_memory_send_now_test.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite_send_now_test.go` +- `apps/backend/internal/orchestrator/messagequeue/repository_postgres_send_now_test.go` +- `apps/backend/internal/orchestrator/messagequeue/send_now_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task freezes the claim and restoration contract consumed by +Task 02. + +## Inputs + +- Spec: API-adjacent aggregation rules, State and Concurrency, Failure Modes. +- Plan: Backend / Exact queue dispatch claims. +- Existing patterns: `ReserveHead`, `TakeByID`, `Restore`, + `AcknowledgeByID`, and `MergeIntoAbove`. + +## Verification + +```bash +cd apps/backend && go test -race ./internal/orchestrator/messagequeue -run 'SendNow' -count=1 +``` + +If the PostgreSQL DSN is available, the same command must discover and pass the +env-gated PostgreSQL send-now cases; otherwise record the explicit skip. + +## Output contract + +Report files changed, the exact claim/restore/ack semantics, RED and GREEN test +commands/results, PostgreSQL execution or skip, blockers/risks, and synchronize +this task plus `plan.md` status/results. + +## Results + +- Added `SendNowClaim`, FIFO envelope aggregation, exact session-scoped claims, restoration, and durable-source acknowledgement to the memory and SQLite/PostgreSQL-compatible repositories. +- Claims are all-or-none, reject missing/duplicate/reserved or changed click-time snapshots without mutation, retain durable rows with an in-flight reservation, restore ordinary rows at their persisted positions, and acknowledge every durable source only after prompt acceptance. +- GREEN: `cd apps/backend && go test -race ./internal/orchestrator/messagequeue -count=1 -v` — 116 tests passed across memory and SQLite; env-gated PostgreSQL cases are included and skipped because `KANDEV_TEST_POSTGRES_DSN` is unset. +- Aggregate envelope coverage proves FIFO content/attachments, oldest execution metadata, canonical reference de-duplication, provenance, attachment-only entries, and pre-mutation attachment/reference overflow errors. diff --git a/docs/plans/message-queue-send-now/task-02-replacement-turn-dispatch.md b/docs/plans/message-queue-send-now/task-02-replacement-turn-dispatch.md new file mode 100644 index 0000000000..14c8d3660a --- /dev/null +++ b/docs/plans/message-queue-send-now/task-02-replacement-turn-dispatch.md @@ -0,0 +1,86 @@ +--- +id: "02-replacement-turn-dispatch" +title: "Orchestrate replacement-turn dispatch" +status: done +wave: 2 +depends_on: ["01-queue-send-now-claims"] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 02: Orchestrate replacement-turn dispatch + +## Intent + +Compose the exact queue claim with Kandev's unified cancellation coordinator so +Send Now safely replaces a captured busy turn or directly prompts an idle +session without inheriting explicit Cancel workflow semantics. + +## Acceptance + +1. `message.queue.send_now` authorizes and dispatches exactly one selected entry + or the exact bulk snapshot, returning the specified response/error contract. +2. Busy dispatch uses backend cancellation progress but never writes the normal + cancel message, moves to review, or evaluates cancelled-turn completion; + promptable dispatch does not issue a cancel. +3. Successor-turn, explicit-cancel, duplicate Send Now, queue-change, and prompt + failure races fail closed or restore the original claim without a substitute + FIFO dispatch. + +## Files likely touched + +- `apps/backend/internal/orchestrator/queue_send_now.go` +- `apps/backend/internal/orchestrator/queue_send_now_test.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/event_handlers_agent.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers_send_now_test.go` +- `apps/backend/internal/orchestrator/handlers/queue_handlers_test.go` +- `apps/backend/internal/backendapp/gateway.go` + +## Dependencies + +- Task 01 exact queue claim, restoration, and acknowledgement contract. + +## Parallelism + +Sequential. It changes shared cancellation ownership and the public WebSocket +contract used by Task 03. + +## Inputs + +- Spec: API Surface, State and Concurrency, Permissions, Failure Modes. +- Plan: Backend / Non-completing cancel-and-dispatch and WebSocket action. +- ADR: `2026-08-05-queue-send-now-replaces-turn`. +- Existing patterns: `QueueAndInterruptForPeerMessage`, + `cancelAgentSilentWithGuardActionKind`, `dispatchTakenQueuedMessage`, and + `CancellationPendingSnapshot`. + +## Verification + +```bash +cd apps/backend && go test -race ./internal/orchestrator ./internal/orchestrator/handlers -run 'SendQueuedNow|SendNow' -count=1 +``` + +Also run the focused existing cancellation/peer-interrupt regressions affected +by any shared coordinator edit: + +```bash +cd apps/backend && go test -race ./internal/orchestrator -run 'Test(CancelAgent|CancellationSources|QueueAndInterruptForPeerMessage)' -count=1 +``` + +## Output contract + +Report files changed, cancellation-source behavior, wire contract/error mapping, +RED and GREEN test commands/results, race cases covered, blockers/risks, and +synchronize this task plus `plan.md` status/results. + +## Results + +- Added `Service.SendQueuedNow`, the `message.queue.send_now` action, stable validation/error mapping, queue status publication, exact selected/bulk dispatch, and the dedicated non-completing cancellation kind. +- Busy Send Now uses the backend-owned cancellation projection and captured-turn guard without joining explicit Cancel; promptable Send Now claims and prompts directly. Duplicate/cross-owner operations fail closed, and prompt/reset failures restore the complete claim. +- GREEN: `cd apps/backend && go test -race ./internal/orchestrator -run 'SendNow|ExplicitCancellationDoesNotJoin' -count=1 -v` — 6 tests passed; handler coverage with `-run 'SendNow|QueueHandler'` — 24 tests passed; affected existing cancellation/peer-interrupt regressions — 56 tests passed. +- PR fixup: carry the validated click-time source snapshot through cancellation and compare persisted queue fields before claiming; mark every source as `user_message_recorded` before prompt admission so ordinary and durable restoration cannot create duplicate transcript rows. Regression coverage includes changed-snapshot atomicity and a retry with a durable source. +- The WS contract validates scope before dispatch and returns `{session_id, dispatched, sent_count}` on success, with stable queue-empty, queue-changed, conflict, turn-changed, attachment, and reference errors. diff --git a/docs/plans/message-queue-send-now/task-03-send-now-queue-controls.md b/docs/plans/message-queue-send-now/task-03-send-now-queue-controls.md new file mode 100644 index 0000000000..a5c931180c --- /dev/null +++ b/docs/plans/message-queue-send-now/task-03-send-now-queue-controls.md @@ -0,0 +1,89 @@ +--- +id: "03-send-now-queue-controls" +title: "Add Send Now queue controls" +status: done +wave: 3 +depends_on: ["02-replacement-turn-dispatch"] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 03: Add Send Now queue controls + +## Intent + +Expose the new action through the shared queue API/hook and add accessible row +and header controls that work through hover/focus on desktop and direct touch on +coarse-pointer layouts. + +## Acceptance + +1. The API and hook send the exact `entry`/`all` payloads, map stable errors, + and refetch authoritative queue state after every result. +2. Every row exposes hover/focus **Send Now** on fine pointers and an always- + visible 44px touch control on coarse pointers; header **Send Now** appears + immediately before **Clear all** and sends the whole snapshot. +3. Duplicate actions are disabled during local loading or authoritative + cancellation, all new copy is localized, and the narrow header wraps without + adding another scroll owner or horizontal overflow. + +## Files likely touched + +- `apps/web/lib/api/domains/queue-api.ts` +- `apps/web/lib/api/domains/queue-api.test.ts` +- `apps/web/hooks/domains/session/use-queue.ts` +- `apps/web/hooks/domains/session/use-queue.test.ts` +- `apps/web/components/task/chat/queued-ghost-list.tsx` +- `apps/web/components/task/chat/queued-ghost-list.test.tsx` +- `apps/web/components/task/chat/queued-ghost-message.tsx` +- `apps/web/components/task/chat/queued-ghost-message.test.tsx` +- `apps/web/src/locales/en/chat.json` +- pseudo-locale catalog output if regenerated by the repository script + +## Dependencies + +- Task 02 WebSocket request, response, and error contract. + +## Parallelism + +Sequential. It owns the shared frontend surface required by both E2E scenarios. + +## Inputs + +- Spec: What, API Surface, Responsive and Mobile Behavior. +- Plan: Frontend and Responsive and Mobile Contract. +- Nearest mobile exemplar: + `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts`. +- Existing patterns: `drainQueuedMessage`, `useQueuePanelHandlers`, + `RowActions`, and session `cancellation_pending` selectors. + +## Verification + +Bootstrap once if this worktree has no dependencies: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +Then run: + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/api/domains/queue-api.test.ts hooks/domains/session/use-queue.test.ts components/task/chat/queued-ghost-message.test.tsx components/task/chat/queued-ghost-list.test.tsx +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web run lint +cd apps && pnpm --filter @kandev/web run i18n:ratchet && pnpm --filter @kandev/web run i18n:check +``` + +## Output contract + +Report files changed, desktop/mobile interaction details, RED and GREEN test +commands/results, typecheck/lint/i18n outcomes, rendered verification evidence +or its exact blocker, risks, and synchronize this task plus `plan.md` +status/results. + +## Results + +- Added typed `sendQueuedNow` API errors, exact entry/all payloads, authoritative refetches on every result, and `sendEntryNow`/`sendAllNow` queue-hook actions with loading and cancellation guards. +- Added localized row and header Send Now controls. Desktop row actions use hover/focus reveal, coarse-pointer layouts keep 44px controls visible, the header order is Run next / Send Now / Clear all / collapse, and the action group wraps without adding a scroll owner. +- GREEN: focused queue API/hook/list/message suite — 4 files, 97 tests passed; `cd apps/web && pnpm run typecheck` passed; full web lint passed; `i18n:pseudo`, `i18n:check`, and `i18n:ratchet` passed. +- Missing/raced entries reconcile through the authoritative queue state and show focused localized feedback; no optimistic removal is retained. diff --git a/docs/plans/message-queue-send-now/task-04-send-now-e2e.md b/docs/plans/message-queue-send-now/task-04-send-now-e2e.md new file mode 100644 index 0000000000..74d4a8b4ef --- /dev/null +++ b/docs/plans/message-queue-send-now/task-04-send-now-e2e.md @@ -0,0 +1,87 @@ +--- +id: "04-send-now-e2e" +title: "Prove Send Now end to end" +status: done +wave: 4 +depends_on: ["02-replacement-turn-dispatch", "03-send-now-queue-controls"] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 04: Prove Send Now end to end + +## Intent + +Exercise the real cancel-and-replacement flow through the production frontend +and backend on desktop and mobile, including exact row selection, bulk ordering, +workflow-step preservation, and touch geometry. + +## Acceptance + +1. Desktop Playwright proves selected Send Now dispatches the chosen row while + preserving remaining order and proves header Send Now creates one FIFO- + concatenated replacement turn. +2. The workflow step does not advance from cancellation semantics, and the + authoritative queue/transcript settle without duplicate turns. +3. Mobile Playwright uses `.tap()`, proves both controls are at least 44px and + reachable without hover, and asserts no document horizontal overflow. + +## Files likely touched + +- `apps/web/e2e/tests/chat/message-queue.spec.ts` +- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` +- a focused sibling helper only if needed to keep either spec below repository + size/complexity limits + +## Dependencies + +- Task 02 backend action and cancellation behavior. +- Task 03 frontend controls and stable test IDs. + +## Parallelism + +The E2E and public-doc files are disjoint, so implementation work can overlap +after dependencies complete. Final status/results edits to the shared `plan.md` +record are serialized after both tasks finish. User authorization is still +required before using subagents. + +## Inputs + +- Spec scenarios for selected, all, workflow preservation, and phone parity. +- Plan: E2E Tests and Responsive and Mobile Contract. +- Existing helpers: `openQueuePanel`, `typeWhileBusy`, `SessionPage`, and + `mobile-message-queue-management.spec.ts` touch geometry helper. + +## Verification + +Bootstrap once if needed: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +Run the focused scenarios through the managed runner: + +```bash +cd apps/web && pnpm e2e --project=chromium e2e/tests/chat/message-queue.spec.ts --grep 'Send Now' --retries=0 +cd apps/web && pnpm e2e --project=mobile-chrome e2e/tests/chat/mobile-message-queue-management.spec.ts --grep 'Send Now' --retries=0 +``` + +If the managed runner is required for a fresh embedded build, run the +repository build/sync wrapper first, then use the same focused Playwright +commands. Confirm each command discovers the intended test count before +recording it as evidence. + +## Output contract + +Report files changed, RED and GREEN runs, discovered/passed test counts, +desktop/mobile rendered evidence or artifact paths, teardown evidence, blockers +or flake analysis, and synchronize this task plus `plan.md` status/results. + +## Results + +- Added desktop row and bulk replacement scenarios plus the Pixel 5 row scenario. Desktop proves exact second-row selection, remaining FIFO order, one bulk FIFO-concatenated prompt, no ordinary cancellation message, and workflow-step preservation. Mobile uses `.tap()`, checks both Send Now controls, 44px geometry, no hover dependency, replacement content, and document horizontal containment. +- Discovery: desktop queue spec listed 12 tests and mobile queue-management listed 2 tests. +- GREEN desktop: `cd apps/web && pnpm e2e --project=chromium e2e/tests/chat/message-queue.spec.ts --grep 'Send Now' --retries=0` — 2 passed in 33.4s. +- GREEN mobile: `cd apps/web && pnpm e2e --project=mobile-chrome e2e/tests/chat/mobile-message-queue-management.spec.ts --grep 'Send Now' --retries=0` — 1 passed in 13.1s. +- The managed runner initially used stale embedded frontend assets; rebuilding with `make build-web`, `make sync-embedded-web && make build-backend` produced the final no-retry pass. No E2E artifacts are checked in. diff --git a/docs/plans/message-queue-send-now/task-05-document-send-now.md b/docs/plans/message-queue-send-now/task-05-document-send-now.md new file mode 100644 index 0000000000..a44b7f8061 --- /dev/null +++ b/docs/plans/message-queue-send-now/task-05-document-send-now.md @@ -0,0 +1,70 @@ +--- +id: "05-document-send-now" +title: "Document Send Now behavior" +status: done +wave: 4 +depends_on: ["02-replacement-turn-dispatch", "03-send-now-queue-controls"] +plan: "plan.md" +spec: "../../specs/ui/message-queue-send-now.md" +--- + +# Task 05: Document Send Now behavior + +## Intent + +Update the existing public queue and session guidance so users can distinguish +Send Now from Run next, Clear all, and the workflow-aware Cancel control. + +## Acceptance + +1. `coordination.md` documents selected/all Send Now behavior, FIFO bulk + concatenation, and its non-workflow-completing replacement semantics. +2. `sessions-and-review.md` explains when to use Run next, Send Now, Clear all, + or Cancel without duplicating the full queue contract. +3. Public-doc validation and internal links pass; no new page or navigation + entry is introduced. + +## Files likely touched + +- `docs/public/coordination.md` +- `docs/public/sessions-and-review.md` + +## Dependencies + +- Task 02 final backend behavior and Task 03 final labels. + +## Parallelism + +Parallel-safe with Task 04 after dependencies complete. Files are disjoint and +neither task changes a shared contract or package configuration. User +authorization is still required before using subagents. + +## Inputs + +- Spec: What, Failure Modes, Out of Scope. +- Plan: Public Documentation. +- Public docs type: `coordination.md` is primarily explanation/reference; + `sessions-and-review.md` is primarily a how-to guide. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +The public-doc files are disjoint from Task 04, but final status/results edits +to the shared `plan.md` record are serialized after both tasks finish. + +## Output contract + +Report public files changed, the terminology/behavior clarified, exact validator +results, link issues or risks, and synchronize this task plus `plan.md` +status/results. + +## Results + +- Updated `docs/public/coordination.md` and `docs/public/sessions-and-review.md` to distinguish Run next, selected/all Send Now, Clear all, and normal Cancel, including FIFO bulk behavior and non-workflow-completing replacement semantics. +- GREEN: `node --test scripts/validate-public-docs.test.mjs` — 58 tests passed; `node scripts/validate-public-docs.mjs` — 41 published docs pages validated; `git diff --check` passed. +- No new public page or navigation entry was introduced. diff --git a/docs/public/coordination.md b/docs/public/coordination.md index 2a1ad830da..0f60fa2138 100644 --- a/docs/public/coordination.md +++ b/docs/public/coordination.md @@ -124,6 +124,13 @@ The default delivery mode is queued. Each session accepts 10 queued messages by In the task workbench, expand the queue chip to manage pending messages. Every visible pending row has **Remove**, whether it came from a user, another agent, workflow automation, or a server action. **Clear all** removes all visible pending rows in that session and releases their capacity. After removal, merge, or drain, displayed positions compact to `#1` through `#N` while FIFO order stays unchanged. Provenance still matters for editing and merging: only user-origin content can be edited. A row already reserved for delivery is hidden from the panel and cannot be cancelled there. +Use the queue controls according to the outcome you want: + +- **Run next** dispatches the promptable FIFO head without interrupting an active turn. It is available when the session can accept a prompt. +- **Send Now** sends directly when the session is promptable; when an agent turn is active, it waits for the backend to acknowledge cancellation and then replaces that captured turn with either the selected row or the click-time snapshot of every visible row. Bulk Send Now joins non-empty bodies with a blank line, keeps attachments in FIFO order, and deduplicates references. It creates a replacement turn but does not apply normal Cancel side effects: it does not record a cancellation message, complete the cancelled workflow step, or move the task to review. New rows added after the click remain queued. +- **Clear all** removes every visible pending row without sending a prompt. +- **Cancel** in the chat toolbar stops the active turn as a user cancellation. It may record the cancellation, complete an eligible workflow step, and move the task to review; it does not send queued content. + Choose the control by intent: | Intent | Operation | Result | diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index c7a27c97f4..c36b233dc6 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -65,6 +65,8 @@ Stopping a turn does not itself run the next queued message. Expand the queue an The expanded queue also lets you discard stale work. **Remove** is available for every visible pending row, including messages from users, peer agents, workflows, and server actions; **Clear all** removes all visible pending rows in that session. Only user-origin rows remain editable. A message already reserved for delivery is hidden from the queue and cannot be cancelled with these controls. +The queue panel separates four actions. **Run next** sends the promptable FIFO head and leaves a running turn alone. **Send Now** sends directly when the session is promptable; otherwise it waits for backend cancellation acknowledgement, then replaces the active turn with one selected row or the click-time snapshot of all visible rows as one FIFO-ordered prompt. It does not record ordinary Cancel side effects or complete the cancelled workflow step. **Clear all** discards the visible queue. The chat toolbar's **Cancel** is the normal user cancellation for the active turn; it sends no queued prompt and may complete an eligible workflow step or move the task to review. + A CLI-passthrough profile displays the agent's native terminal interface in a PTY. It still belongs to the task, but it does not provide Kandev's structured chat messages and tool-call presentation.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 458ff174c2..e185a3a28a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -159,6 +159,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [mermaid-rendering](ui/mermaid-rendering.md) | shipped | | [message-queue-management](ui/message-queue-management.md) | shipped | | [message-queue-merge](ui/message-queue-merge.md) | shipped | +| [message-queue-send-now](ui/message-queue-send-now.md) | shipped | | [settings-manual-save](ui/settings-manual-save.md) | shipped | | [settings-discovery](ui/settings-discovery.md) | shipped | | [executor-settings-card-spacing](ui/executor-settings-card-spacing.md) | shipped | diff --git a/docs/specs/ui/message-queue-management.md b/docs/specs/ui/message-queue-management.md index adbb1ab489..5404d97f33 100644 --- a/docs/specs/ui/message-queue-management.md +++ b/docs/specs/ui/message-queue-management.md @@ -25,7 +25,9 @@ remains full. Those same rows have no individual remove action. and returns the exact number removed. - Removal does not change provenance rules for other operations. Only user-owned messages may be edited. Merge behavior remains defined by - [Merge Enqueued Messages Individually](message-queue-merge.md). + [Merge Enqueued Messages Individually](message-queue-merge.md), and + interrupt-and-dispatch behavior is defined by + [Send Queued Messages Now](message-queue-send-now.md). - Successful removal publishes the existing `message.queue.status_changed` event. All connected views reconcile to the backend result; the initiating view may update optimistically but must @@ -215,8 +217,8 @@ starts accepting work. - Editing agent-, workflow-, or server-origin message content. - Changing the shipped merge compatibility rules. -- Cancelling a prompt after it has entered in-flight delivery or reached the - executor. +- Interrupting an active prompt to dispatch pending work; that separate action + is governed by [Send Queued Messages Now](message-queue-send-now.md). - Per-workspace, per-task, or per-session capacity overrides. - Automatically pruning old messages when a lower limit is saved. - Queue reordering or bulk selection beyond **Clear all**. diff --git a/docs/specs/ui/message-queue-send-now.md b/docs/specs/ui/message-queue-send-now.md new file mode 100644 index 0000000000..a4c1938d6e --- /dev/null +++ b/docs/specs/ui/message-queue-send-now.md @@ -0,0 +1,249 @@ +--- +status: shipped +created: 2026-08-05 +owner: kandev +--- + +# Send Queued Messages Now + +Decision: +[ADR-2026-08-05-queue-send-now-replaces-turn](../../decisions/2026-08-05-queue-send-now-replaces-turn.md) + +## Why + +When an agent is in a long-running turn, an urgent correction can sit in the +queue until that turn finishes. Users need to replace the current turn with one +specific queued instruction, or with the whole pending queue as one coherent +follow-up, without completing or advancing the task's workflow step. + +## What + +- Every visible pending queue row offers **Send Now**. On a fine-pointer + desktop it appears with the row actions on hover or keyboard focus; on a + coarse-pointer surface it is always visible and touch-sized. +- Per-row **Send Now** interrupts the active agent turn and dispatches that + exact entry as the first prompt of a replacement turn. Other queued entries + remain pending in their existing FIFO order. +- The queue header offers **Send Now** immediately to the left of **Clear all**. + It dispatches the click-time snapshot of all visible pending entries as one + replacement turn. +- Bulk content is concatenated in FIFO order with one blank line between + non-empty message bodies. Attachment-only entries add no empty separators. + Attachments retain FIFO order, and entity references are combined in first- + occurrence order with canonical duplicates removed. +- The bulk turn uses the oldest selected entry's model and plan-mode snapshot. + Its transcript envelope uses that entry's sender attribution while retaining + source entry identities and provenance in metadata for restoration and + diagnostics. +- If the aggregate would exceed the existing per-message attachment count, + attachment byte, or entity-reference limits, the request is rejected before + cancellation. No content is truncated and the queue is unchanged. +- A promptable session dispatches immediately without cancellation. A busy + session uses the existing backend-owned cancellation progress, cancels only + the turn observed when the action began, and starts the replacement turn + after cancellation settles. +- If ordinary FIFO delivery has reserved a queued entry but has not yet + accepted its prompt, Send Now wins that same-session handoff. The reserved + source is restored before the requested selection is claimed, so an + all-scope replacement can include it in one aggregate prompt. Once FIFO has + accepted its prompt, Send Now fails closed with `send_now_conflict`; it does + not duplicate or cancel that successor turn. +- Send Now is a replacement/steering cancellation. It does not create the + ordinary **Turn cancelled** message, move the task to review, evaluate + `cancel_triggers_turn_complete`, or run the cancelled turn's + `on_turn_complete` actions. +- The action is disabled while its request or any backend cancellation for the + session is pending. The backend also rejects overlapping Send Now or explicit + cancellation operations so rapid clicks and multiple clients cannot create + successor turns. +- Successful dispatch publishes the existing queue-status and session + cancellation updates. The initiating client refetches the authoritative queue + after success or failure. + +## API Surface + +New WebSocket action: + +```text +message.queue.send_now +``` + +Request: + +```json +{ + "session_id": "session-id", + "scope": "entry", + "entry_id": "queue-entry-id" +} +``` + +or: + +```json +{ + "session_id": "session-id", + "scope": "all" +} +``` + +`scope` is required and is exactly `entry` or `all`. `entry_id` is required +only for `entry`; malformed combinations are rejected with `validation`. + +Successful response: + +```json +{ + "session_id": "session-id", + "dispatched": true, + "sent_count": 3 +} +``` + +`sent_count` is the number of source queue entries represented by the new +turn. The action returns success only after the selection is claimed and handed +to the replacement-turn dispatch path. + +Errors: + +- `entry_not_found`: the selected entry is no longer visible and pending. +- `queue_empty`: the `all` snapshot contains no visible pending entries. +- `queue_changed`: one or more bulk-snapshot entries changed eligibility before + the atomic claim; no partial selection is dispatched. +- `send_now_conflict`: another cancellation or Send Now operation already owns + the session. +- `turn_changed`: the active turn changed while the operation waited; the + successor is left untouched. +- `send_now_attachment_overflow`: the aggregate exceeds an attachment limit. +- `send_now_reference_overflow`: the aggregate exceeds the entity-reference + limit. +- `session_not_promptable`: the session has neither an interruptible active + turn nor a promptable state. + +All requests require access to `session_id`. Authorization happens before any +queue read, cancellation, or mutation and uses the existing non-enumerating +session-not-found response. + +## State and Concurrency + +The orchestrator snapshots the active turn and, for `scope=all`, the ordered +visible entry IDs before beginning cancellation. All validation and aggregation +limits are checked before the cancellation signal is sent. + +The cancel-and-dispatch decision uses the shared per-session cancellation and +queue-take serialization point. An ordinary FIFO handoff has an explicit +pre-acceptance phase: Send Now may supersede that reservation while the worker +has not claimed prompt ownership, but the worker must claim ownership before +creating a visible user message, running turn-start workflow effects, or +accepting the agent prompt. After that claim, the handoff is terminal for Send +Now and the existing conflict response applies. + +When Send Now supersedes a pre-acceptance FIFO reservation, the backend +restores that exact source (including clearing a durable lifecycle +reservation), then atomically claims exactly the selected ID or the complete +bulk snapshot. New messages accepted after the snapshot remain queued. A +missing or newly ineligible bulk member fails the whole claim; the backend +never sends a partial bulk result or substitutes the FIFO head for a missing +selected entry. + +Ordinary entries are removed when claimed, matching normal queue delivery. +Durable lifecycle entries remain reserved until the combined prompt is +accepted, then all durable source rows are acknowledged. A retryable dispatch +failure restores every ordinary source at its original FIFO position and +releases every durable reservation before publishing queue status. + +## Permissions + +- Any user who can access the session may invoke either Send Now action on its + visible pending entries, regardless of entry provenance. +- The action never accepts caller-supplied `task_id`, `user_id`, sender identity, + content, attachments, or metadata. Those values come from the authorized + session and persisted queue rows. +- Hidden durable entries already reserved for another delivery are not eligible. + +## Failure Modes + +- **Cancellation fails:** no queue selection is claimed; the entries remain + pending and the UI refetches and shows a localized error. +- **Selection changes during cancellation:** the active turn may already be + cancelled, but no replacement is dispatched from a partial or different + selection. The UI refetches and asks the user to retry. +- **FIFO handoff race:** if normal FIFO delivery reserved the head but has not + accepted its prompt, Send Now restores/reclaims that reservation and + dispatches the requested exact selection. If FIFO already accepted the + prompt, Send Now returns `send_now_conflict`; it leaves that successor and + the remaining queue authoritative rather than creating a duplicate turn. +- **Prompt admission fails after claim:** the backend restores the original + entries and their FIFO positions before publishing status. Existing ordinary + queue delivery retains its current process-crash window after a destructive + claim; durable lifecycle rows retain their accepted-until-acknowledged + guarantee. +- **Successor turn appears:** the operation fails with `turn_changed`, leaves + the successor running, and does not claim queue entries. +- **Conflicting explicit Cancel:** the already accepted operation wins. Send Now + never dispatches after a workflow-completing cancellation. + +## Responsive and Mobile Behavior + +- The existing inline queue panel remains the desktop and phone composition; + its queue list remains the only internal scroll owner and the composer stays + visible. +- Desktop row actions keep their current hover/focus disclosure. Phone and + coarse-pointer layouts expose per-row **Send Now** without hover and give it + at least a 44 by 44 CSS-pixel hit target. +- The header **Send Now**, **Clear all**, and collapse controls remain reachable + without horizontal page overflow. The header may wrap its action group on a + narrow phone rather than shrinking labels below usable touch targets. +- Desktop and mobile share the same queue hook, cancellation state, and backend + action. Mobile Playwright coverage uses touch input and proves the same + replacement-turn result. + +## Scenarios + +- **GIVEN** an agent is busy and three messages are queued, **WHEN** the user + clicks row **Send Now** on the second message, **THEN** the active turn is + interrupted, the second message starts the replacement turn, and the first + and third messages remain queued in FIFO order. +- **GIVEN** an agent is busy and three messages are queued, **WHEN** the user + clicks header **Send Now**, **THEN** one replacement turn receives the three + message bodies concatenated in FIFO order and the queue becomes empty. +- **GIVEN** queued messages contain attachments and repeated entity references, + **WHEN** the user sends all now, **THEN** the replacement prompt contains all + attachments in FIFO order and one canonical copy of each reference. +- **GIVEN** a bulk aggregate exceeds an existing message limit, **WHEN** the + user clicks header **Send Now**, **THEN** the active turn continues, the queue + is unchanged, and a localized limit error is shown. +- **GIVEN** the session is already promptable with queued work, **WHEN** the user + clicks either Send Now action, **THEN** the requested selection starts without + issuing a cancellation. +- **GIVEN** a promptable session has two queued messages and normal FIFO + delivery has reserved the first one but has not accepted its prompt, **WHEN** + the user clicks header Send Now, **THEN** the FIFO reservation is restored, + both messages are claimed in FIFO order, and exactly one replacement prompt + contains both bodies. +- **GIVEN** normal FIFO delivery has already accepted its successor prompt, + **WHEN** the user clicks Send Now, **THEN** the action fails closed without a + duplicate user message, duplicate prompt, or cancellation of that successor, + and the authoritative remaining queue is preserved. +- **GIVEN** the workflow step enables `cancel_triggers_turn_complete`, **WHEN** + the user sends a queued message now, **THEN** the task stays on the same step + and only the replacement turn's ordinary lifecycle hooks may change it. +- **GIVEN** a successor turn replaces the observed active turn while Send Now + waits, **WHEN** the operation revalidates, **THEN** it leaves the successor + running and the requested queue entries pending. +- **GIVEN** a phone viewport with a busy agent and queued messages, **WHEN** the + user opens the queue panel, **THEN** both per-row and header Send Now controls + are visible, touch-sized, and can start the same replacement-turn flow without + horizontal overflow. + +## Out of Scope + +- Reordering queued messages before sending. +- Choosing a different model or plan mode for the bulk turn. +- Sending an arbitrary user-selected subset other than one entry or all visible + pending entries. +- Changing normal FIFO auto-drain selection or **Run next**, **Remove**, **Clear + all**, edit, or merge semantics. FIFO participates in Send Now handoff + arbitration only until its prompt is accepted. +- Making ordinary queued-message dispatch crash-durable after its existing + destructive claim boundary.