209 lines
6.8 KiB
Go
209 lines
6.8 KiB
Go
// Package clarification provides types and services for agent clarification requests.
|
|
package clarification
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Canceller wraps Store with message-update side effects.
|
|
// When the agent's turn completes, it cancels pending clarifications
|
|
// and marks the database messages with agent_disconnected metadata.
|
|
type Canceller struct {
|
|
store *Store
|
|
repo messageStore
|
|
eventBus EventBus
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewCanceller creates a Canceller.
|
|
func NewCanceller(store *Store, repo messageStore, eventBus EventBus, log *logger.Logger) *Canceller {
|
|
return &Canceller{
|
|
store: store,
|
|
repo: repo,
|
|
eventBus: eventBus,
|
|
logger: log.WithFields(zap.String("component", "clarification-canceller")),
|
|
}
|
|
}
|
|
|
|
// isTerminalStatus returns true for statuses that should not be overwritten.
|
|
func isTerminalStatus(status string) bool {
|
|
switch status {
|
|
case string(StatusAnswered), string(StatusExpired), string(StatusRejected), string(StatusCancelled):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// markMessagesDetached unblocks WaitForResponse and keeps the clarification
|
|
// interactive: status stays pending and agent_disconnected is set so the UI
|
|
// can route a late answer through the event fallback path.
|
|
func (c *Canceller) markMessagesDetached(ctx context.Context, msgs []*taskmodels.Message, pendingID string) {
|
|
writeCtx := context.WithoutCancel(ctx)
|
|
for _, msg := range msgs {
|
|
if msg.Metadata == nil {
|
|
msg.Metadata = map[string]any{}
|
|
}
|
|
if current, _ := msg.Metadata["status"].(string); isTerminalStatus(current) {
|
|
continue
|
|
}
|
|
msg.Metadata["agent_disconnected"] = true
|
|
if err := c.repo.UpdateMessage(writeCtx, msg); err != nil {
|
|
c.logger.Warn("failed to update message with detached status",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("message_id", msg.ID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
c.publishMessageUpdated(writeCtx, msg)
|
|
}
|
|
}
|
|
|
|
// markMessagesExpired updates the given messages to status=expired and publishes
|
|
// a message.updated event for each one. It is idempotent: already-terminal
|
|
// messages are skipped.
|
|
func (c *Canceller) markMessagesExpired(ctx context.Context, msgs []*taskmodels.Message, pendingID string) {
|
|
writeCtx := context.WithoutCancel(ctx)
|
|
for _, msg := range msgs {
|
|
if msg.Metadata == nil {
|
|
msg.Metadata = map[string]any{}
|
|
}
|
|
if current, _ := msg.Metadata["status"].(string); isTerminalStatus(current) {
|
|
continue
|
|
}
|
|
msg.Metadata["agent_disconnected"] = true
|
|
msg.Metadata["status"] = string(StatusExpired)
|
|
if err := c.repo.UpdateMessage(writeCtx, msg); err != nil {
|
|
c.logger.Warn("failed to update message with expired status",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("message_id", msg.ID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
c.publishMessageUpdated(writeCtx, msg)
|
|
}
|
|
}
|
|
|
|
func (c *Canceller) detachSessionBundles(ctx context.Context, sessionID string) int {
|
|
pendingIDs := c.store.CancelSession(sessionID)
|
|
|
|
handled := make(map[string]bool, len(pendingIDs))
|
|
for _, id := range pendingIDs {
|
|
msgs, err := c.repo.FindMessagesByPendingID(ctx, id)
|
|
if err != nil || len(msgs) == 0 {
|
|
c.logger.Debug("messages not found for detached clarification",
|
|
zap.String("pending_id", id),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
c.markMessagesDetached(ctx, msgs, id)
|
|
handled[id] = true
|
|
}
|
|
|
|
msgs, err := c.repo.FindPendingClarificationMessagesBySessionID(ctx, sessionID)
|
|
if err != nil || len(msgs) == 0 {
|
|
return len(pendingIDs)
|
|
}
|
|
|
|
byPendingID := make(map[string][]*taskmodels.Message)
|
|
for _, msg := range msgs {
|
|
pid := stringFromMetadata(msg.Metadata, "pending_id")
|
|
if pid == "" || handled[pid] {
|
|
continue
|
|
}
|
|
byPendingID[pid] = append(byPendingID[pid], msg)
|
|
}
|
|
for pid, bundle := range byPendingID {
|
|
c.markMessagesDetached(ctx, bundle, pid)
|
|
}
|
|
return len(pendingIDs) + len(byPendingID)
|
|
}
|
|
|
|
// DetachSessionAndNotify cancels in-memory WaitForResponse waiters for a session
|
|
// and marks DB clarification messages as pending with agent_disconnected=true.
|
|
// The overlay stays interactive; a late answer uses the event fallback path.
|
|
func (c *Canceller) DetachSessionAndNotify(ctx context.Context, sessionID string) int {
|
|
return c.detachSessionBundles(ctx, sessionID)
|
|
}
|
|
|
|
// Deprecated: use DetachSessionAndNotify. This alias remains so older tests and
|
|
// integrations keep the pre-rename entrypoint while detach semantics settle.
|
|
func (c *Canceller) CancelSessionAndNotify(ctx context.Context, sessionID string) int {
|
|
return c.DetachSessionAndNotify(ctx, sessionID)
|
|
}
|
|
|
|
// ExpireSessionAndNotify cancels in-memory waiters and marks clarification
|
|
// messages expired so the overlay closes and history shows a timed-out entry.
|
|
// TODO: wire this into terminal teardown paths that should close the overlay
|
|
// instead of preserving the deferred-answer UX.
|
|
func (c *Canceller) ExpireSessionAndNotify(ctx context.Context, sessionID string) int {
|
|
pendingIDs := c.store.CancelSession(sessionID)
|
|
|
|
handled := make(map[string]bool, len(pendingIDs))
|
|
for _, id := range pendingIDs {
|
|
msgs, err := c.repo.FindMessagesByPendingID(ctx, id)
|
|
if err != nil || len(msgs) == 0 {
|
|
continue
|
|
}
|
|
c.markMessagesExpired(ctx, msgs, id)
|
|
handled[id] = true
|
|
}
|
|
|
|
msgs, err := c.repo.FindPendingClarificationMessagesBySessionID(ctx, sessionID)
|
|
if err != nil || len(msgs) == 0 {
|
|
return len(pendingIDs)
|
|
}
|
|
|
|
byPendingID := make(map[string][]*taskmodels.Message)
|
|
for _, msg := range msgs {
|
|
pid := stringFromMetadata(msg.Metadata, "pending_id")
|
|
if pid == "" || handled[pid] {
|
|
continue
|
|
}
|
|
byPendingID[pid] = append(byPendingID[pid], msg)
|
|
}
|
|
for pid, bundle := range byPendingID {
|
|
c.markMessagesExpired(ctx, bundle, pid)
|
|
}
|
|
return len(pendingIDs) + len(byPendingID)
|
|
}
|
|
|
|
// publishMessageUpdated publishes a message.updated event to the event bus.
|
|
func (c *Canceller) publishMessageUpdated(ctx context.Context, msg *taskmodels.Message) {
|
|
if c.eventBus == nil {
|
|
return
|
|
}
|
|
|
|
msgType := string(msg.Type)
|
|
if msgType == "" {
|
|
msgType = "message"
|
|
}
|
|
|
|
data := map[string]any{
|
|
"message_id": msg.ID,
|
|
"session_id": msg.TaskSessionID,
|
|
"task_id": msg.TaskID,
|
|
"turn_id": msg.TurnID,
|
|
"author_type": string(msg.AuthorType),
|
|
"author_id": msg.AuthorID,
|
|
"content": msg.Content,
|
|
"type": msgType,
|
|
"requests_input": msg.RequestsInput,
|
|
"created_at": msg.CreatedAt.Format(time.RFC3339),
|
|
"metadata": msg.Metadata,
|
|
}
|
|
|
|
event := bus.NewEvent(events.MessageUpdated, "clarification-canceller", data)
|
|
if err := c.eventBus.Publish(ctx, events.MessageUpdated, event); err != nil {
|
|
c.logger.Warn("failed to publish message.updated event",
|
|
zap.String("message_id", msg.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|