Files
kandev/apps/backend/internal/task/service/service_messages.go
T
nova28 c82668c789 feat(mcp): expose agent clarification questions and permission requests externally (#2875)
Co-authored-by: Vojtěch Biberle <vojtech.biberle@gmail.com>
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-22 15:39:40 +00:00

951 lines
32 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
)
const (
messageCreateMaxRetries = 5
messageCreateRetryDelay = 50 * time.Millisecond
clarificationPendingStatus = "pending"
)
// CreateMessage creates a new message on an agent session
func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) {
messageID := uuid.New().String()
session, err := s.getSessionWithRetry(
ctx,
req.TaskSessionID,
messageID,
messageCreateMaxRetries,
messageCreateRetryDelay,
)
if err != nil {
return nil, err
}
authorType := models.MessageAuthorUser
if req.AuthorType == "agent" {
authorType = models.MessageAuthorAgent
}
messageType := models.MessageType(req.Type)
if messageType == "" {
messageType = models.MessageTypeMessage
}
taskID := req.TaskID
if taskID == "" && session != nil {
taskID = session.TaskID
}
// Ensure we have a turn ID - get active turn or start a new one
turnID := req.TurnID
if turnID == "" {
var turn *models.Turn
if req.CompletedTurn {
turn, err = s.createCompletedTurn(ctx, session)
} else {
turn, err = s.getOrStartTurnWithRetry(
ctx,
req.TaskSessionID,
messageID,
messageCreateMaxRetries,
messageCreateRetryDelay,
)
}
if err != nil {
s.logger.Warn("failed to get or start turn for message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
return nil, fmt.Errorf("failed to get or start turn: %w", err)
} else if turn != nil {
turnID = turn.ID
}
}
message := &models.Message{
ID: messageID,
TaskSessionID: req.TaskSessionID,
TaskID: taskID,
TurnID: turnID,
AuthorType: authorType,
AuthorID: req.AuthorID,
Content: req.Content,
Type: messageType,
Metadata: req.Metadata,
RequestsInput: req.RequestsInput,
// CreatedAt deliberately left zero: the repository assigns it inside
// the atomic per-session create boundary, which treats a zero
// timestamp as a LIVE create (advancing a colliding or backward key
// by one tick). Pre-populating it here would misclassify the message
// as an explicit import and reject same-microsecond creates.
}
if err := s.messages.CreateMessage(ctx, message); err != nil {
s.logger.Error("failed to create message", zap.Error(err))
return nil, err
}
// Publish message.added event
s.publishMessageEvent(ctx, events.MessageAdded, message)
s.logger.Info("message created",
zap.String("message_id", message.ID),
zap.String("session_id", message.TaskSessionID),
zap.String("author_type", string(message.AuthorType)))
return message, nil
}
// CreateMessageIdempotent persists a caller-owned message ID and returns the
// existing row when the request is replayed. A client can lose the response
// after the database commit, so retrying must not create a second user turn.
// The handler performs the fast preflight before session-state side effects;
// this method also closes the concurrent two-request race at the repository
// primary-key boundary. Replay reads go through GetMessageWithPromptIndex so
// the returned row carries its stable prompt ordinal.
func (s *Service) CreateMessageIdempotent(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) {
if id == "" {
return nil, errors.New("message id is required for idempotent creation")
}
existing, err := s.messages.GetMessageWithPromptIndex(ctx, id)
if err == nil && existing != nil {
return existing, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("check existing message: %w", err)
}
message, err := s.CreateMessageWithID(ctx, id, req)
if err == nil {
return message, nil
}
// Another request may have won the insert while this request was building
// its turn. Read the committed row and treat that duplicate as success.
existing, lookupErr := s.messages.GetMessageWithPromptIndex(ctx, id)
if lookupErr == nil && existing != nil {
return existing, nil
}
return nil, err
}
// CreateMessageWithID creates a new message with a pre-generated ID.
// This is used for streaming messages where the ID is generated by the caller.
// It includes retry logic to handle transient database errors and ensure
// message chunks are not lost during streaming.
func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) {
session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, messageCreateMaxRetries, messageCreateRetryDelay)
if err != nil {
return nil, err
}
message, err := s.buildMessage(ctx, id, req, session)
if err != nil {
return nil, err
}
if err := s.createMessageWithRetry(ctx, message, messageCreateMaxRetries, messageCreateRetryDelay); err != nil {
return nil, err
}
// Publish message.added event
s.publishMessageEvent(ctx, events.MessageAdded, message)
s.logger.Info("message created with ID",
zap.String("message_id", message.ID),
zap.String("session_id", message.TaskSessionID),
zap.String("author_type", string(message.AuthorType)))
return message, nil
}
// getSessionWithRetry fetches a session, retrying on transient errors caused by out-of-order events.
func (s *Service) getSessionWithRetry(ctx context.Context, sessionID, messageID string, maxRetries int, retryDelay time.Duration) (*models.TaskSession, error) {
var session *models.TaskSession
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
session, err = s.sessions.GetTaskSession(ctx, sessionID)
if err == nil {
return session, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("session not found for message create, retrying",
zap.String("session_id", sessionID),
zap.String("message_id", messageID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
s.logger.Warn("session not found for message create after retries",
zap.String("session_id", sessionID),
zap.String("message_id", messageID),
zap.Int("retries", maxRetries),
zap.Error(err))
return nil, err
}
// getOrStartTurnWithRetry fetches or starts a turn, retrying short-lived FK/session visibility races.
func (s *Service) getOrStartTurnWithRetry(ctx context.Context, sessionID, messageID string, maxRetries int, retryDelay time.Duration) (*models.Turn, error) {
var turn *models.Turn
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
turn, err = s.getOrStartTurn(ctx, sessionID)
if err == nil {
return turn, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("failed to get or start turn for message, retrying",
zap.String("session_id", sessionID),
zap.String("message_id", messageID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
time.Sleep(retryDelay)
}
}
return nil, err
}
// buildMessage constructs a Message model from a CreateMessageRequest and resolved session.
func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) (*models.Message, error) {
authorType := models.MessageAuthorUser
if req.AuthorType == "agent" {
authorType = models.MessageAuthorAgent
}
messageType := models.MessageType(req.Type)
if messageType == "" {
messageType = models.MessageTypeMessage
}
taskID := req.TaskID
if taskID == "" && session != nil {
taskID = session.TaskID
}
turnID := req.TurnID
if turnID == "" {
if turn, err := s.getOrStartTurnWithRetry(
ctx,
req.TaskSessionID,
id,
messageCreateMaxRetries,
messageCreateRetryDelay,
); err != nil {
s.logger.Warn("failed to get or start turn for streaming message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
return nil, fmt.Errorf("failed to get or start turn: %w", err)
} else if turn != nil {
turnID = turn.ID
}
}
return &models.Message{
ID: id,
TaskSessionID: req.TaskSessionID,
TaskID: taskID,
TurnID: turnID,
AuthorType: authorType,
AuthorID: req.AuthorID,
Content: req.Content,
Type: messageType,
Metadata: req.Metadata,
RequestsInput: req.RequestsInput,
// CreatedAt deliberately left zero: the repository's atomic per-session
// create boundary assigns it (live creates advance a colliding key).
}, nil
}
// createMessageWithRetry persists a message with retry logic for transient DB errors.
func (s *Service) createMessageWithRetry(ctx context.Context, message *models.Message, maxRetries int, retryDelay time.Duration) error {
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
err = s.messages.CreateMessage(ctx, message)
if err == nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("failed to create message, retrying",
zap.String("message_id", message.ID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
time.Sleep(retryDelay)
}
}
s.logger.Error("failed to create message with ID after retries",
zap.String("id", message.ID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
// GetMessage retrieves a message by ID
func (s *Service) GetMessage(ctx context.Context, id string) (*models.Message, error) {
message, err := s.messages.GetMessage(ctx, id)
if err != nil {
return nil, err
}
// Scope like ListMessages: the shell-output route reaches a message by ID,
// so without this a caller holding someone else's (session_id, message_id)
// pair could read their command output.
if message.TaskSessionID != "" {
if err := s.AuthorizeSessionAccess(ctx, message.TaskSessionID); err != nil {
return nil, err
}
}
return message, nil
}
// GetMessageWithPromptIndex retrieves a message by ID with its computed
// prompt ordinal, scoped like GetMessage. Used by the idempotent WS
// replay/response path so a retried prompt answers with its stable index.
func (s *Service) GetMessageWithPromptIndex(ctx context.Context, id string) (*models.Message, error) {
message, err := s.messages.GetMessageWithPromptIndex(ctx, id)
if err != nil {
return nil, err
}
if message.TaskSessionID != "" {
if err := s.AuthorizeSessionAccess(ctx, message.TaskSessionID); err != nil {
return nil, err
}
}
return message, nil
}
// ListMessages returns all messages for a session.
func (s *Service) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) {
if err := s.AuthorizeSessionAccess(ctx, sessionID); err != nil {
return nil, err
}
return s.messages.ListMessages(ctx, sessionID)
}
// ListMessagesPaginated returns messages for a session with pagination options.
func (s *Service) ListMessagesPaginated(ctx context.Context, req ListMessagesRequest) ([]*models.Message, bool, error) {
if err := s.AuthorizeSessionAccess(ctx, req.TaskSessionID); err != nil {
return nil, false, err
}
limit := req.Limit
if limit <= 0 && (req.Before != "" || req.After != "") {
limit = DefaultMessagesPageSize
}
if limit > MaxMessagesPageSize {
limit = MaxMessagesPageSize
}
return s.messages.ListMessagesPaginated(ctx, req.TaskSessionID, models.ListMessagesOptions{
Limit: limit,
Before: req.Before,
After: req.After,
Sort: req.Sort,
})
}
// ListMessagesForPlugin returns messages matching the plugin Host data API
// filter (ADR 0047), backing internal/plugins' capability-gated
// Messages().List reader. Reads go through the service layer, never a
// repository directly, per ADR 0043.
func (s *Service) ListMessagesForPlugin(ctx context.Context, filter models.PluginMessageFilter) ([]*models.Message, error) {
return s.messages.ListMessagesForPlugin(ctx, filter)
}
// SearchMessages returns messages whose content matches the query in the given session.
func (s *Service) SearchMessages(ctx context.Context, sessionID, query string, limit int) ([]*models.Message, error) {
if err := s.AuthorizeSessionAccess(ctx, sessionID); err != nil {
return nil, err
}
return s.messages.SearchMessages(ctx, sessionID, models.SearchMessagesOptions{
Query: query,
Limit: limit,
})
}
// DeleteMessage deletes a message
func (s *Service) DeleteMessage(ctx context.Context, id string) error {
message, getErr := s.messages.GetMessage(ctx, id)
if err := s.messages.DeleteMessage(ctx, id); err != nil {
s.logger.Error("failed to delete message", zap.String("message_id", id), zap.Error(err))
return err
}
if getErr == nil && message != nil {
s.publishMessageEvent(ctx, events.MessageDeleted, message)
}
s.logger.Info("message deleted", zap.String("message_id", id))
return nil
}
// UpdateMessage updates an existing message and publishes an event.
func (s *Service) UpdateMessage(ctx context.Context, message *models.Message) error {
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update message",
zap.String("message_id", message.ID),
zap.Error(err))
return err
}
// User rows carry the stable prompt ordinal in message.updated events:
// re-read the affected row through GetMessageWithPromptIndex before
// publishing. Agent messages and streaming agent content/thinking updates
// stay on the hot 12-column loaded model and never carry the field.
published := message
if message.AuthorType == models.MessageAuthorUser {
if indexed, err := s.messages.GetMessageWithPromptIndex(ctx, message.ID); err == nil {
published = indexed
} else {
s.logger.Warn("failed to re-read indexed message for update event",
zap.String("message_id", message.ID),
zap.Error(err))
}
}
// Publish message.updated event for real-time streaming. Delivery is best
// effort after the durable write succeeded (see publishMessageEvent).
_ = s.publishMessageEvent(ctx, events.MessageUpdated, published)
return nil
}
// AppendMessageContent appends additional content to an existing message.
// This is used for streaming agent responses where content arrives incrementally.
func (s *Service) AppendMessageContent(ctx context.Context, messageID, additionalContent string) error {
message, err := s.messages.GetMessage(ctx, messageID)
if err != nil {
s.logger.Warn("message not found for append",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Append the new content
message.Content += additionalContent
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to append message content",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Publish message.updated event for real-time streaming
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Debug("message content appended",
zap.String("message_id", messageID),
zap.Int("appended_length", len(additionalContent)),
zap.Int("total_length", len(message.Content)))
return nil
}
// AppendThinkingContent appends additional thinking content to an existing thinking message.
// This updates the metadata.thinking field for streaming agent reasoning.
func (s *Service) AppendThinkingContent(ctx context.Context, messageID, additionalContent string) error {
message, err := s.messages.GetMessage(ctx, messageID)
if err != nil {
s.logger.Warn("thinking message not found for append",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Initialize metadata if nil
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
// Get existing thinking content and append
existingThinking := ""
if existing, ok := message.Metadata["thinking"].(string); ok {
existingThinking = existing
}
message.Metadata["thinking"] = existingThinking + additionalContent
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to append thinking content",
zap.String("message_id", messageID),
zap.Error(err))
return err
}
// Publish message.updated event for real-time streaming
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Debug("thinking content appended",
zap.String("message_id", messageID),
zap.Int("appended_length", len(additionalContent)))
return nil
}
// UpdateToolCallMessage updates a tool call message's status, optionally title and normalized data.
// It includes retry logic to handle race conditions where the complete event
// may arrive before the message has been created by the start event.
// If the message is not found after retries and taskID/turnID/msgType are provided, it creates the message.
// The normalized parameter contains typed tool payload data that gets added to metadata.
func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCallID, status, result, title string, normalized *streams.NormalizedPayload) error {
return s.UpdateToolCallMessageWithCreate(ctx, sessionID, toolCallID, "", status, result, title, normalized, "", "", "")
}
// UpdateToolCallMessageWithCreate is like UpdateToolCallMessage but can create the message if not found.
// If taskID, turnID, and msgType are provided, the message will be created if it doesn't exist.
// parentToolCallID is used for subagent nesting (empty for top-level).
func (s *Service) UpdateToolCallMessageWithCreate(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, result, title string, normalized *streams.NormalizedPayload, taskID, turnID, msgType string) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
message, err := s.getToolCallMessageWithRetry(ctx, sessionID, toolCallID, maxRetries, retryDelay)
// If message not found and we have enough info to create it, do so
if err != nil && taskID != "" && msgType != "" {
return s.createToolCallMessageFallback(ctx, sessionID, toolCallID, parentToolCallID, status, title, turnID, taskID, msgType, normalized)
}
if err != nil {
s.logger.Warn("tool call message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
s.applyToolCallMessageUpdate(message, status, result, title, normalized)
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update tool call message",
zap.String("message_id", message.ID),
zap.String("tool_call_id", toolCallID),
zap.Error(err))
return err
}
// Publish message.updated event
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Info("tool call message updated",
zap.String("message_id", message.ID),
zap.String("tool_call_id", toolCallID),
zap.String("status", status))
return nil
}
// getToolCallMessageWithRetry fetches a tool call message with retry logic for race conditions.
func (s *Service) getToolCallMessageWithRetry(ctx context.Context, sessionID, toolCallID string, maxRetries int, retryDelay time.Duration) (*models.Message, error) {
var message *models.Message
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.messages.GetMessageByToolCallID(ctx, sessionID, toolCallID)
if err == nil {
return message, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("tool call message not found, retrying",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
return nil, err
}
// createToolCallMessageFallback creates a tool call message when it cannot be found via GetMessageByToolCallID.
func (s *Service) createToolCallMessageFallback(ctx context.Context, sessionID, toolCallID, parentToolCallID, status, title, turnID, taskID, msgType string, normalized *streams.NormalizedPayload) error {
s.logger.Info("tool call message not found, creating it",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.String("task_id", taskID),
zap.String("msg_type", msgType))
metadata := map[string]interface{}{
"tool_call_id": toolCallID,
"title": title,
"status": status,
}
if parentToolCallID != "" {
metadata["parent_tool_call_id"] = parentToolCallID
}
if normalized != nil {
metadata["normalized"] = normalized
}
msg, createErr := s.CreateMessage(ctx, &CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
TurnID: turnID,
Content: title,
AuthorType: "agent",
Type: msgType,
Metadata: metadata,
})
if createErr != nil {
s.logger.Error("failed to create tool call message as fallback",
zap.String("session_id", sessionID),
zap.String("tool_call_id", toolCallID),
zap.Error(createErr))
return createErr
}
s.logger.Info("created tool call message as fallback",
zap.String("message_id", msg.ID),
zap.String("tool_call_id", toolCallID),
zap.String("status", status))
return nil
}
// applyToolCallMessageUpdate applies status, result, normalized data, and title to a tool call message.
//
// Defensive guard: a permission_request row also carries `tool_call_id` in metadata, so
// any future code path that hands such a row in here would otherwise silently overwrite
// the user's approve/reject decision and retype it to tool_execute. The repository's
// GetMessageByToolCallID excludes permission_request, but the guard makes the invariant
// explicit at the layer that does the writing.
func (s *Service) applyToolCallMessageUpdate(message *models.Message, status, result, title string, normalized *streams.NormalizedPayload) {
if message.Type == models.MessageTypePermissionRequest {
// Error severity: the repo-layer GetMessageByToolCallID filter is supposed
// to make this branch unreachable. Reaching it means a caller bug; surface
// it loudly so the invariant violation isn't silently swallowed.
s.logger.Error("applyToolCallMessageUpdate refusing to overwrite permission_request",
zap.String("message_id", message.ID),
zap.String("incoming_status", status))
return
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = status
if result != "" {
message.Metadata["result"] = result
}
if normalized != nil {
message.Metadata["normalized"] = normalized
// Update message type if the normalized kind changed
// This handles cases like Read on a directory converting to code_search
newMsgType := models.MessageType(normalized.Kind().ToMessageType())
if newMsgType != message.Type {
s.logger.Debug("updating message type based on normalized kind",
zap.String("message_id", message.ID),
zap.String("old_type", string(message.Type)),
zap.String("new_type", string(newMsgType)),
zap.String("normalized_kind", string(normalized.Kind())))
message.Type = newMsgType
}
}
// Update title/content if provided and different from current
if title != "" && title != message.Content {
message.Content = title
message.Metadata["title"] = title
}
}
// UpdatePermissionMessage updates a permission request message's status.
// It includes retry logic to handle race conditions.
//
// The lookup is qualified by the full (task, session, request, pending)
// identity rather than pending_id alone: a provider may reuse a pending_id
// for a later, unrelated request once the original is resolved, and a
// delayed event about the old request must not be able to expire the new
// one's message.
func (s *Service) UpdatePermissionMessage(ctx context.Context, taskID, sessionID, requestID, pendingID string, status models.PermissionStatus) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
var message *models.Message
var err error
// Retry loop to handle race condition
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.messages.GetPermissionMessageByIdentity(ctx, taskID, sessionID, requestID, pendingID)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("permission message not found, retrying",
zap.String("session_id", sessionID),
zap.String("request_id", requestID),
zap.String("pending_id", pendingID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
if err != nil {
s.logger.Warn("permission message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = string(status)
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update permission message",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.Error(err))
return err
}
// Publish message.updated event
s.publishMessageEvent(ctx, events.MessageUpdated, message)
// When a permission expires, also mark the related tool call as cancelled
// so the UI no longer shows a loading spinner on the tool call.
if status == models.PermissionStatusExpired {
if toolCallID, ok := message.Metadata["tool_call_id"].(string); ok && toolCallID != "" {
if err := s.UpdateToolCallMessage(ctx, sessionID, toolCallID, "error", "", "", nil); err != nil {
s.logger.Warn("failed to cancel related tool call message",
zap.String("tool_call_id", toolCallID),
zap.String("pending_id", pendingID),
zap.Error(err))
}
}
}
s.logger.Info("permission message updated",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.String("status", string(status)))
return nil
}
// ClaimPermissionResolution durably serializes the first resolver before any
// option is delivered to the live agent process.
func (s *Service) ClaimPermissionResolution(ctx context.Context, request models.PermissionResolutionClaimRequest) (*models.PermissionResolutionClaimResult, error) {
result, err := s.messages.ClaimPermissionResolution(ctx, request)
if err != nil {
s.logger.Error("failed to claim permission resolution",
zap.String("task_id", request.TaskID),
zap.String("session_id", request.SessionID),
zap.String("request_id", request.Audit.RequestID),
zap.String("pending_id", request.Audit.PendingID),
zap.Error(err))
return nil, err
}
if result.Outcome == models.PermissionClaimed && result.Message != nil {
_ = s.publishMessageEvent(ctx, events.MessageUpdated, result.Message)
}
return result, nil
}
// FinalizePermissionResolution records the outcome for the exact durable
// claim. Only successful writes publish the existing message update event.
func (s *Service) FinalizePermissionResolution(ctx context.Context, request models.PermissionResolutionFinalizeRequest) (*models.PermissionResolutionFinalizeResult, error) {
result, err := s.messages.FinalizePermissionResolution(ctx, request)
if err != nil {
s.logger.Error("failed to finalize permission resolution",
zap.String("task_id", request.TaskID),
zap.String("session_id", request.SessionID),
zap.String("request_id", request.RequestID),
zap.String("pending_id", request.PendingID),
zap.String("result", string(request.Result)),
zap.Error(err))
return nil, err
}
if result.Outcome == models.PermissionFinalized && result.Message != nil {
_ = s.publishMessageEvent(ctx, events.MessageUpdated, result.Message)
}
return result, nil
}
func (s *Service) GetPermissionResolutionAudit(ctx context.Context, taskID, sessionID, requestID, pendingID string) (*models.PermissionResolutionAudit, error) {
return s.messages.GetPermissionResolutionAudit(ctx, taskID, sessionID, requestID, pendingID)
}
// UpdateClarificationMessageForQuestion updates a single clarification message
// (identified by pending_id + question_id) with the new status and the answer
// payload. Used by both single- and multi-question clarification bundles since
// each question lives in its own chat message.
func (s *Service) UpdateClarificationMessageForQuestion(ctx context.Context, sessionID, pendingID, questionID, status string, answer interface{}) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
var message *models.Message
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.messages.FindMessageByPendingIDAndQuestion(ctx, sessionID, pendingID, questionID)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("clarification message not found, retrying",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.String("question_id", questionID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
if err != nil {
s.logger.Warn("clarification message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.String("question_id", questionID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = status
if answer != nil {
message.Metadata["response"] = answer
}
if err := s.messages.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update clarification message",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.String("question_id", questionID),
zap.Error(err))
return err
}
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Info("clarification message updated",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.String("question_id", questionID),
zap.String("status", status))
return nil
}
// CompleteActiveClarificationBundle atomically transitions a current-turn
// bundle. The caller publishes the returned messages only after response
// delivery succeeds, so a failed detached resume can be restored for retry.
func (s *Service) CompleteActiveClarificationBundle(
ctx context.Context,
pendingID, status string,
responses map[string]interface{},
) ([]*models.Message, bool, error) {
return s.messages.CompleteActiveClarificationBundle(ctx, pendingID, status, responses)
}
// FinalizeClarificationResponseDelivery retires the durable recovery intent
// after the claimed response reaches its live or detached handoff boundary.
func (s *Service) FinalizeClarificationResponseDelivery(
ctx context.Context,
pendingID, terminalStatus string,
claimedMessages []*models.Message,
) ([]*models.Message, bool, error) {
return s.messages.FinalizeClarificationResponseDelivery(
ctx,
pendingID,
terminalStatus,
claimedMessages,
)
}
// RestoreActiveClarificationBundle reopens a terminal bundle after detached
// resume acceptance fails and returns the committed pending rows for publication.
func (s *Service) RestoreActiveClarificationBundle(
ctx context.Context,
pendingID, terminalStatus string,
claimedMessages []*models.Message,
) ([]*models.Message, bool, error) {
return s.messages.RestoreActiveClarificationBundle(
ctx,
pendingID,
terminalStatus,
claimedMessages,
)
}
// PublishClarificationBundleUpdates exposes committed rows to ordinary bus
// subscribers. A restored pending bundle first drives the live summary
// projector synchronously. Projection failure is returned to the caller, but
// does not make the durably restored bundle unsafe to retry; later events and
// reads repair that cache. Committed rows are still published on failure so
// clients do not retain the terminal snapshot.
func (s *Service) PublishClarificationBundleUpdates(ctx context.Context, messages []*models.Message) error {
var resultErr error
if restored := firstRestoredClarification(messages); restored != nil {
if s.statusSummaryProjector == nil {
resultErr = errors.New("task status summary projector is unavailable")
} else if err := s.statusSummaryProjector.HandleEvent(
ctx,
newMessageEvent(events.MessageUpdated, restored),
); err != nil {
resultErr = fmt.Errorf("converge clarification status summary: %w", err)
}
}
for _, message := range messages {
if message == nil {
continue
}
if err := s.publishMessageEvent(ctx, events.MessageUpdated, message); err != nil && resultErr == nil {
resultErr = fmt.Errorf("publish clarification message update: %w", err)
}
}
return resultErr
}
func firstRestoredClarification(messages []*models.Message) *models.Message {
for _, message := range messages {
if message == nil || message.Type != models.MessageTypeClarificationRequest {
continue
}
if status, _ := message.Metadata["status"].(string); status == clarificationPendingStatus {
return message
}
}
return nil
}