943 lines
33 KiB
Go
943 lines
33 KiB
Go
package clarification
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
// ErrBundleNotFound is ResolveBundle's single "not found" outcome (A3, A5).
|
|
// An unknown pending_id, a denied AuthorizeTaskAccess, a bundle whose
|
|
// task_id cannot be resolved (M5a), and a bundle belonging to the separate
|
|
// autopilot parent-question protocol all collapse to this one sentinel by
|
|
// design, so REST/MCP layers map every one of them to the same 404 without
|
|
// distinguishing the cause.
|
|
var ErrBundleNotFound = errors.New("clarification bundle not found")
|
|
|
|
// errClarificationNotActive is R2's no-winner branch: the claim was lost and
|
|
// the re-read found no answered/rejected message either, so the bundle is
|
|
// simply not active (superseded turn, terminal session, expired, cancelled,
|
|
// or malformed). REST/MCP layers map it to 409 with an unchanged message.
|
|
var errClarificationNotActive = errors.New("clarification request is no longer active")
|
|
|
|
// IsNotActiveError reports whether err is R2's no-winner outcome, for
|
|
// REST/MCP layers deciding a 409 response.
|
|
func IsNotActiveError(err error) bool {
|
|
return errors.Is(err, errClarificationNotActive)
|
|
}
|
|
|
|
// Resolution is ResolveBundle's result: the winning (or, on a lost claim,
|
|
// reconstructed - R2b) terminal status and response for a bundle.
|
|
type Resolution struct {
|
|
PendingID string
|
|
SessionID string
|
|
TaskID string
|
|
Status string
|
|
Response *Response
|
|
}
|
|
|
|
// taskAccessAuthorizer is step 2's authorization check ("The resolution
|
|
// claim", step 2).
|
|
type taskAccessAuthorizer interface {
|
|
AuthorizeTaskAccess(ctx context.Context, taskID string) error
|
|
}
|
|
|
|
// clarificationResponseClaim carries a winning claim's committed rows through
|
|
// delivery. messages is immutable after claim construction: a delivery
|
|
// callback can outlive the responder's bounded wait, so callback results
|
|
// stay local.
|
|
type clarificationResponseClaim struct {
|
|
response *Response
|
|
terminalStatus string
|
|
messages []*taskmodels.Message
|
|
}
|
|
|
|
// Resolver implements ResolveBundle
|
|
// (docs/specs/integrations/requirements/external-question-answering.md, "Resolution semantics"),
|
|
// the single service-layer operation the REST endpoints and the
|
|
// answer_question_kandev MCP tool both call. It resolves a bundle's identity
|
|
// and authorization (M5, A1-A9), validates the outcome (N6-N8b), claims it
|
|
// through upstream's CompleteActiveClarificationBundle (P1, R1, R3, R6), and
|
|
// on a win delivers through upstream's existing live-waiter/detached-resume
|
|
// path (R7) unchanged; on a loss it reconstructs the winner's outcome (R2).
|
|
type Resolver struct {
|
|
store *Store
|
|
repo handlerMessageStore
|
|
messages MessageCreator
|
|
authorizer taskAccessAuthorizer
|
|
detachedResumer DetachedClarificationResumer
|
|
eventBus EventBus
|
|
logger *logger.Logger
|
|
now func() time.Time // seam for deterministic tests
|
|
}
|
|
|
|
// NewResolver creates a Resolver.
|
|
func NewResolver(
|
|
store *Store,
|
|
repo handlerMessageStore,
|
|
messages MessageCreator,
|
|
authorizer taskAccessAuthorizer,
|
|
detachedResumer DetachedClarificationResumer,
|
|
eventBus EventBus,
|
|
log *logger.Logger,
|
|
) *Resolver {
|
|
return &Resolver{
|
|
store: store,
|
|
repo: repo,
|
|
messages: messages,
|
|
authorizer: authorizer,
|
|
detachedResumer: detachedResumer,
|
|
eventBus: eventBus,
|
|
logger: log.WithFields(zap.String("component", "clarification-resolver")),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// AuthorizeBundleAccess resolves a bundle's identity (M5) and authorizes the
|
|
// caller against its owning task (A2), without claiming or applying anything.
|
|
// REST read endpoints (GET /:id, GET /:id/wait) call this ahead of their
|
|
// in-memory read (A7) so an unauthorized caller gets ErrBundleNotFound
|
|
// exactly as A3 requires, and A5a's "no durable messages" case also
|
|
// collapses to the same sentinel. POST /:id/cancel calls this alone (A9):
|
|
// cancel does not go through ResolveBundle.
|
|
func (r *Resolver) AuthorizeBundleAccess(ctx context.Context, pendingID string) (sessionID, taskID string, err error) {
|
|
_, sessionID, taskID, err = r.resolveIdentity(ctx, pendingID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := r.authorizer.AuthorizeTaskAccess(ctx, taskID); err != nil {
|
|
return "", "", ErrBundleNotFound // A3
|
|
}
|
|
return sessionID, taskID, nil
|
|
}
|
|
|
|
// resolveIdentity is step 1 (M5, M5a): read the bundle's durable messages
|
|
// and derive session_id/task_id. A bundle with no messages, one whose
|
|
// task_id cannot be resolved from either source, or one belonging to the
|
|
// separate autopilot parent-question protocol (isParentQuestionBundle) is
|
|
// not-found.
|
|
func (r *Resolver) resolveIdentity(ctx context.Context, pendingID string) ([]*taskmodels.Message, string, string, error) {
|
|
msgs, err := r.repo.FindMessagesByPendingID(ctx, pendingID)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
if len(msgs) == 0 {
|
|
return nil, "", "", ErrBundleNotFound
|
|
}
|
|
if isParentQuestionBundle(msgs) {
|
|
return nil, "", "", ErrBundleNotFound
|
|
}
|
|
sessionID, taskID, ok := r.resolveBundleTaskID(ctx, msgs)
|
|
if !ok {
|
|
return nil, "", "", ErrBundleNotFound // M5a
|
|
}
|
|
return msgs, sessionID, taskID, nil
|
|
}
|
|
|
|
// isParentQuestionBundle reports whether any message in the bundle carries
|
|
// the autopilot parent-question marker (models.MetaKeyParentQuestion,
|
|
// parent_question.go's parentQuestionMetadata). Those records share this
|
|
// bundle's message type and status/pending_id metadata keys, but they
|
|
// belong to a separate, ownership-gated resolution channel
|
|
// (message_task_kandev's reply_to_question_id, validateParentQuestionOwnership)
|
|
// that ResolveBundle and AuthorizeBundleAccess must never claim or read —
|
|
// doing so would let any workspace-authorized caller (REST or the external
|
|
// answer_question_kandev/list_pending_questions_kandev MCP tools) resolve a
|
|
// running autopilot agent's question to its parent, bypassing that
|
|
// ownership check and the parent-only interaction boundary the spec's S2
|
|
// relies on.
|
|
func isParentQuestionBundle(msgs []*taskmodels.Message) bool {
|
|
for _, m := range msgs {
|
|
if marker, _ := m.Metadata[taskmodels.MetaKeyParentQuestion].(bool); marker {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveBundleTaskID implements M5: session_id is always present on the
|
|
// message row. task_id may be empty there; when so, it is resolved from the
|
|
// bundle's session row. When neither source yields one, ok is false (M5a).
|
|
func (r *Resolver) resolveBundleTaskID(ctx context.Context, msgs []*taskmodels.Message) (sessionID, taskID string, ok bool) {
|
|
sessionID = msgs[0].TaskSessionID
|
|
for _, m := range msgs {
|
|
if m.TaskID != "" {
|
|
return sessionID, m.TaskID, true
|
|
}
|
|
}
|
|
session, err := r.repo.GetTaskSession(ctx, sessionID)
|
|
if err != nil || session == nil || session.TaskID == "" {
|
|
return sessionID, "", false
|
|
}
|
|
return sessionID, session.TaskID, true
|
|
}
|
|
|
|
// ResolveBundle runs the ordered steps described in the spec: resolve
|
|
// identity, authorize, validate, claim, and (winner only) deliver. Cancel
|
|
// SHALL NOT be submitted here (A9): cancel is authorized via
|
|
// AuthorizeBundleAccess alone and never claims.
|
|
func (r *Resolver) ResolveBundle(ctx context.Context, pendingID string, outcome Outcome) (*Resolution, bool, error) {
|
|
msgs, sessionID, taskID, err := r.resolveIdentity(ctx, pendingID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if err := r.authorizer.AuthorizeTaskAccess(ctx, taskID); err != nil {
|
|
return nil, false, ErrBundleNotFound // A3
|
|
}
|
|
|
|
questions := bundleQuestions(msgs)
|
|
if err := validateOutcome(questions, outcome); err != nil {
|
|
return nil, false, err // N8c, R2c: validation runs before the claim
|
|
}
|
|
|
|
return r.claimAndDeliver(ctx, pendingID, sessionID, taskID, questions, outcome)
|
|
}
|
|
|
|
// claimAndDeliver is steps 4 and 5. R4: the durable claim commits before any
|
|
// resume event is published, so a loser can never trigger a resume.
|
|
func (r *Resolver) claimAndDeliver(
|
|
ctx context.Context,
|
|
pendingID, sessionID, taskID string,
|
|
questions []Question,
|
|
outcome Outcome,
|
|
) (*Resolution, bool, error) {
|
|
status, response := buildOutcomeResponse(pendingID, questions, outcome, r.now())
|
|
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
completedMessages, claimed, claimErr := r.messages.CompleteActiveClarificationBundle(
|
|
persistenceCtx,
|
|
pendingID,
|
|
status,
|
|
responsesFromAnswers(response.Answers),
|
|
)
|
|
cancel()
|
|
if claimErr != nil {
|
|
r.logger.Error("failed to claim clarification response",
|
|
zap.String("pending_id", pendingID), zap.Error(claimErr))
|
|
return nil, false, fmt.Errorf("failed to update clarification state: %w", claimErr) // R4a
|
|
}
|
|
if !claimed {
|
|
return r.reconstructLoser(ctx, pendingID, sessionID, taskID) // R2
|
|
}
|
|
|
|
if err := r.deliverClaimedResolution(ctx, pendingID, status, response, completedMessages); err != nil {
|
|
return nil, true, err
|
|
}
|
|
|
|
return &Resolution{
|
|
PendingID: pendingID,
|
|
SessionID: sessionID,
|
|
TaskID: taskID,
|
|
Status: status,
|
|
Response: response,
|
|
}, true, nil
|
|
}
|
|
|
|
// responsesFromAnswers builds the map CompleteActiveClarificationBundle
|
|
// requires for an answered outcome, keyed by question_id. Returns nil for a
|
|
// rejection (empty Answers), matching upstream's own claim call.
|
|
func responsesFromAnswers(answers []Answer) map[string]interface{} {
|
|
if len(answers) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]interface{}, len(answers))
|
|
for _, a := range answers {
|
|
out[a.QuestionID] = a
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reconstructLoser is R2/R2a/R2b: a lost claim re-reads the bundle's durable
|
|
// messages exactly once and branches on what it finds, without retrying and
|
|
// without claiming or modifying anything.
|
|
func (r *Resolver) reconstructLoser(ctx context.Context, pendingID, sessionID, taskID string) (*Resolution, bool, error) {
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
msgs, err := r.repo.FindMessagesByPendingID(persistenceCtx, pendingID)
|
|
if err != nil {
|
|
r.logger.Error("failed to re-read clarification bundle after lost claim",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return nil, false, fmt.Errorf("failed to determine clarification outcome: %w", err)
|
|
}
|
|
status, response, hasWinner := reconstructWinnerResolution(pendingID, msgs, r.logger)
|
|
if !hasWinner {
|
|
return nil, false, errClarificationNotActive
|
|
}
|
|
return &Resolution{
|
|
PendingID: pendingID,
|
|
SessionID: sessionID,
|
|
TaskID: taskID,
|
|
Status: status,
|
|
Response: response,
|
|
}, false, nil
|
|
}
|
|
|
|
// reconstructWinnerResolution implements R2b. status is the status of the
|
|
// first message in D2 order whose status is answered or rejected; answers is
|
|
// built by walking the bundle's messages in D2 order and taking each
|
|
// message's metadata.response where present, in that order. reject_reason
|
|
// is always "" (R2b: upstream stores no reject reason on the message rows).
|
|
func reconstructWinnerResolution(pendingID string, msgs []*taskmodels.Message, log *logger.Logger) (string, *Response, bool) {
|
|
ordered := orderBundleMessages(msgs)
|
|
|
|
status := ""
|
|
var respondedAt time.Time
|
|
for _, m := range ordered {
|
|
s := stringFromMetadata(m.Metadata, metaStatusKey)
|
|
if s == string(StatusAnswered) || s == string(StatusRejected) {
|
|
status = s
|
|
respondedAt = m.UpdatedAt
|
|
break
|
|
}
|
|
}
|
|
if status == "" {
|
|
return "", nil, false
|
|
}
|
|
|
|
answers := make([]Answer, 0, len(ordered))
|
|
for _, m := range ordered {
|
|
if a, ok := answerFromMessageMetadata(m.ID, m.Metadata, log); ok {
|
|
answers = append(answers, a)
|
|
}
|
|
}
|
|
|
|
return status, &Response{
|
|
PendingID: pendingID,
|
|
Answers: answers,
|
|
Rejected: status == string(StatusRejected),
|
|
RejectReason: "",
|
|
RespondedAt: respondedAt,
|
|
}, true
|
|
}
|
|
|
|
// answerFromMessageMetadata reads one message's stored metadata.response, if
|
|
// present, as an Answer. A message that carries no response contributes no
|
|
// entry. A malformed stored value is skipped and logged rather than aborting
|
|
// the whole reconstruction.
|
|
func answerFromMessageMetadata(messageID string, meta map[string]any, log *logger.Logger) (Answer, bool) {
|
|
raw, present := meta["response"]
|
|
if !present {
|
|
return Answer{}, false
|
|
}
|
|
rawMap, ok := raw.(map[string]any)
|
|
if !ok {
|
|
if log != nil {
|
|
log.Warn("clarification message has malformed response metadata; skipping in replay",
|
|
zap.String("message_id", messageID))
|
|
}
|
|
return Answer{}, false
|
|
}
|
|
answer := Answer{QuestionID: questionIDFromMetadata(meta)}
|
|
if opts, ok := rawMap["selected_options"].([]interface{}); ok {
|
|
for _, o := range opts {
|
|
if s, ok := o.(string); ok {
|
|
answer.SelectedOptions = append(answer.SelectedOptions, s)
|
|
}
|
|
}
|
|
}
|
|
if s, ok := rawMap["custom_text"].(string); ok {
|
|
answer.CustomText = s
|
|
}
|
|
return answer, true
|
|
}
|
|
|
|
// deliverClaimedResolution is R7: the winner's delivery and resume path is
|
|
// upstream's, reached unchanged. It calls the existing
|
|
// Store.RespondWithDeliveryConfirmation path and SHALL NOT re-implement live
|
|
// delivery, detached resume dispatch, or the RestoreActiveClarificationBundle
|
|
// rollback.
|
|
func (r *Resolver) deliverClaimedResolution(
|
|
ctx context.Context,
|
|
pendingID, status string,
|
|
response *Response,
|
|
completedMessages []*taskmodels.Message,
|
|
) error {
|
|
claim := &clarificationResponseClaim{
|
|
response: response,
|
|
terminalStatus: status,
|
|
messages: completedMessages,
|
|
}
|
|
|
|
// Durable current-turn ownership is claimed before touching the live waiter.
|
|
// This closes the window where a newer turn exists but its canceller has not
|
|
// yet drained the superseded in-memory request.
|
|
deliveryCtx, cancelDelivery := context.WithTimeout(
|
|
context.WithoutCancel(ctx),
|
|
clarificationPersistenceTimeout+5*time.Second,
|
|
)
|
|
defer cancelDelivery()
|
|
// The callback may outlive this handler's bounded wait. Keep its result in
|
|
// callback-owned storage so the immutable claim remains safe for recovery.
|
|
finalizedMessages := make(chan []*taskmodels.Message, 1)
|
|
deliveryErr := r.store.RespondWithDeliveryConfirmation(
|
|
deliveryCtx,
|
|
pendingID,
|
|
claim.response,
|
|
func() error {
|
|
finalized, confirmErr := r.confirmLiveClarificationResponseDelivery(ctx, pendingID, claim)
|
|
if confirmErr == nil {
|
|
finalizedMessages <- finalized
|
|
}
|
|
return confirmErr
|
|
},
|
|
)
|
|
if deliveryErr == nil {
|
|
finalized := <-finalizedMessages
|
|
r.publishClarificationBundleUpdates(ctx, pendingID, finalized)
|
|
r.publishPrimaryAnsweredEvent(
|
|
ctx,
|
|
pendingID,
|
|
response.Answers,
|
|
response.Rejected,
|
|
response.RejectReason,
|
|
r.clarificationClaimTurnID(pendingID, finalized),
|
|
)
|
|
r.logger.Info("clarification answered via primary path (same turn)",
|
|
zap.String("pending_id", pendingID),
|
|
zap.Int("answers", len(response.Answers)),
|
|
zap.Bool("rejected", response.Rejected))
|
|
return nil
|
|
}
|
|
if errors.Is(deliveryErr, ErrAlreadyResponded) {
|
|
// Defensive only: the durable claim above is exclusive. Retain this as a
|
|
// safety net if the in-memory waiter ever diverges from durable state.
|
|
if finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok {
|
|
r.publishClarificationBundleUpdates(ctx, pendingID, finalized)
|
|
}
|
|
r.logger.Warn("duplicate response attempt", zap.String("pending_id", pendingID))
|
|
return errors.New("response already submitted")
|
|
}
|
|
if !errors.Is(deliveryErr, ErrNotFound) {
|
|
restored := r.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages)
|
|
r.logger.Error("failed to deliver clarification response",
|
|
zap.String("pending_id", pendingID),
|
|
zap.Bool("restored", restored),
|
|
zap.Error(deliveryErr))
|
|
if restored {
|
|
return errors.New("failed to deliver clarification response; response can be retried")
|
|
}
|
|
return errors.New("failed to deliver clarification response and recover pending clarification state")
|
|
}
|
|
return r.deliverDetachedClarificationResponse(ctx, pendingID, response, claim, deliveryErr)
|
|
}
|
|
|
|
func (r *Resolver) deliverDetachedClarificationResponse(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
response *Response,
|
|
claim *clarificationResponseClaim,
|
|
deliveryErr error,
|
|
) error {
|
|
// Fallback path: entry not found (agent timed out, entry was cleaned up).
|
|
// If the user rejected (clicked X to dismiss), they're discarding a stale
|
|
// overlay — not continuing the conversation. Treat as a no-op so we don't
|
|
// surprise them by resuming the agent with "User declined to answer".
|
|
if response.Rejected {
|
|
finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim)
|
|
if !ok {
|
|
return errors.New("clarification rejection was recorded, but delivery state could not be finalized")
|
|
}
|
|
r.publishStaleDismissedEvent(ctx, pendingID)
|
|
r.publishClarificationBundleUpdates(ctx, pendingID, finalized)
|
|
r.logger.Info("clarification rejected after agent moved on; no-op",
|
|
zap.String("pending_id", pendingID))
|
|
return nil
|
|
}
|
|
|
|
// User is providing an affirmative answer after the agent moved on. Ask the
|
|
// orchestrator to accept a new turn containing the answer before publishing
|
|
// the terminal clarification update.
|
|
r.logger.Info("clarification entry not found, using acknowledged resume fallback",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("error", deliveryErr.Error()))
|
|
|
|
if err := r.resumeDetachedClarification(
|
|
ctx, pendingID, response.Answers, response.Rejected, response.RejectReason, claim.messages,
|
|
); err != nil {
|
|
if detachedResumeWasAccepted(err) {
|
|
// The prompt reached agentctl. Keep the durable answer terminal so a
|
|
// retry cannot dispatch it again, even though turn publication failed
|
|
// and the caller must receive a server error.
|
|
if finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim); ok {
|
|
r.publishClarificationBundleUpdates(ctx, pendingID, finalized)
|
|
}
|
|
r.logger.Error("accepted clarification resume was not durably published",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return errors.New("clarification response was accepted, but dispatch state could not be finalized")
|
|
}
|
|
restored := r.restoreFailedClarificationClaim(ctx, pendingID, claim.terminalStatus, claim.messages)
|
|
r.logger.Error("failed to resume detached clarification",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
if restored {
|
|
return errors.New("failed to resume clarification; response can be retried")
|
|
}
|
|
return errors.New("failed to resume clarification and recover pending clarification state")
|
|
}
|
|
finalized, ok := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim)
|
|
if !ok {
|
|
return errors.New("clarification response was accepted, but delivery state could not be finalized")
|
|
}
|
|
r.publishClarificationBundleUpdates(ctx, pendingID, finalized)
|
|
return nil
|
|
}
|
|
|
|
func (r *Resolver) confirmLiveClarificationResponseDelivery(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
claim *clarificationResponseClaim,
|
|
) ([]*taskmodels.Message, error) {
|
|
finalizedMessages, finalized := r.finalizeClarificationResponseDelivery(ctx, pendingID, claim)
|
|
if !finalized {
|
|
return nil, errors.New("clarification delivery confirmation failed")
|
|
}
|
|
return finalizedMessages, nil
|
|
}
|
|
|
|
func (r *Resolver) finalizeClarificationResponseDelivery(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
claim *clarificationResponseClaim,
|
|
) ([]*taskmodels.Message, bool) {
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
finalizedMessages, finalized, err := r.messages.FinalizeClarificationResponseDelivery(
|
|
persistenceCtx,
|
|
pendingID,
|
|
claim.terminalStatus,
|
|
claim.messages,
|
|
)
|
|
if err != nil {
|
|
r.logger.Error("failed to finalize clarification response delivery",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return nil, false
|
|
}
|
|
if !finalized {
|
|
r.logger.Warn("clarification response delivery was not finalizable",
|
|
zap.String("pending_id", pendingID))
|
|
return nil, false
|
|
}
|
|
// The initial claim (completeClaimedClarificationMessages) is the only
|
|
// write that touches updated_at; finalization only clears an internal
|
|
// delivery-recovery marker, so the column is stable from the moment the
|
|
// claim commits. Stamp the winner's own response with that same
|
|
// column value (claim.response is the same pointer returned to the
|
|
// caller) so both it and a later caller's R2b reconstruction, which
|
|
// re-reads this column, report the identical instant.
|
|
if claim.response != nil && len(finalizedMessages) > 0 {
|
|
claim.response.RespondedAt = finalizedMessages[0].UpdatedAt
|
|
}
|
|
return finalizedMessages, true
|
|
}
|
|
|
|
func (r *Resolver) clarificationClaimTurnID(pendingID string, messages []*taskmodels.Message) string {
|
|
turnID, err := clarificationClaimTurnIdentity(messages)
|
|
if err != nil {
|
|
r.logger.Warn("clarification bundle has inconsistent turn IDs",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return ""
|
|
}
|
|
return turnID
|
|
}
|
|
|
|
func clarificationClaimTurnIdentity(messages []*taskmodels.Message) (string, error) {
|
|
turnID := ""
|
|
turnMessageID := ""
|
|
for _, message := range messages {
|
|
if message == nil || message.ID == "" || message.TurnID == "" {
|
|
continue
|
|
}
|
|
if turnID == "" {
|
|
turnID = message.TurnID
|
|
turnMessageID = message.ID
|
|
continue
|
|
}
|
|
if message.TurnID != turnID {
|
|
return "", fmt.Errorf(
|
|
"clarification bundle mixes turn %q from message %s with turn %q from message %s",
|
|
turnID, turnMessageID, message.TurnID, message.ID,
|
|
)
|
|
}
|
|
}
|
|
return turnID, nil
|
|
}
|
|
|
|
func (r *Resolver) publishPrimaryAnsweredEvent(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
answers []Answer,
|
|
rejected bool,
|
|
rejectReason, clarificationTurnID string,
|
|
) {
|
|
if r.eventBus == nil {
|
|
return
|
|
}
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID)
|
|
if err != nil {
|
|
r.logger.Warn("failed to resolve context for primary clarification event",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return
|
|
}
|
|
if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" {
|
|
r.logger.Warn("missing session/task for primary clarification event",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("session_id", clarificationCtx.SessionID),
|
|
zap.String("task_id", clarificationCtx.TaskID))
|
|
return
|
|
}
|
|
|
|
answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason)
|
|
eventData := map[string]any{
|
|
metaSessionIDKey: clarificationCtx.SessionID,
|
|
metaTaskIDKey: clarificationCtx.TaskID,
|
|
metaPendingIDKey: pendingID,
|
|
"clarification_turn_id": clarificationTurnID,
|
|
metaQuestionKey: clarificationCtx.QuestionSummary,
|
|
"answer_text": answerText,
|
|
metaRejectedKey: rejected,
|
|
"reject_reason": rejectReason,
|
|
}
|
|
if err := r.eventBus.Publish(persistenceCtx, events.ClarificationPrimaryAnswered, bus.NewEvent(
|
|
events.ClarificationPrimaryAnswered,
|
|
"clarification-resolver",
|
|
eventData,
|
|
)); err != nil {
|
|
r.logger.Warn("failed to publish primary clarification event",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("session_id", clarificationCtx.SessionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (r *Resolver) publishClarificationBundleUpdates(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
messages []*taskmodels.Message,
|
|
) {
|
|
publishCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
if err := r.messages.PublishClarificationBundleUpdates(publishCtx, messages); err != nil {
|
|
r.logger.Error("failed to publish clarification bundle updates",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (r *Resolver) restoreFailedClarificationClaim(
|
|
ctx context.Context,
|
|
pendingID, terminalStatus string,
|
|
claimedMessages []*taskmodels.Message,
|
|
) bool {
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
restoredMessages, restored, err := r.messages.RestoreActiveClarificationBundle(
|
|
persistenceCtx, pendingID, terminalStatus, claimedMessages,
|
|
)
|
|
if err != nil {
|
|
r.logger.Error("failed to restore clarification after delivery failure",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return false
|
|
}
|
|
if !restored {
|
|
r.logger.Warn("clarification claim was not restorable after delivery failure",
|
|
zap.String("pending_id", pendingID))
|
|
return false
|
|
}
|
|
if err := r.messages.PublishClarificationBundleUpdates(persistenceCtx, restoredMessages); err != nil {
|
|
r.logger.Error("failed to converge restored clarification state",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
// The durable bundle is pending again, so retry remains safe even when
|
|
// its best-effort live projection could not be acknowledged.
|
|
return true
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *Resolver) publishStaleDismissedEvent(ctx context.Context, pendingID string) {
|
|
if r.eventBus == nil {
|
|
return
|
|
}
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID)
|
|
if err != nil || clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" {
|
|
r.logger.Warn("failed to resolve context for stale-dismissed clarification event",
|
|
zap.String("pending_id", pendingID), zap.Error(err))
|
|
return
|
|
}
|
|
eventData := map[string]any{
|
|
"session_id": clarificationCtx.SessionID,
|
|
"task_id": clarificationCtx.TaskID,
|
|
"pending_id": pendingID,
|
|
}
|
|
if err := r.eventBus.Publish(persistenceCtx, events.ClarificationStaleDismissed, bus.NewEvent(
|
|
events.ClarificationStaleDismissed,
|
|
"clarification-resolver",
|
|
eventData,
|
|
)); err != nil {
|
|
r.logger.Warn("failed to publish stale-dismissed clarification event",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("session_id", clarificationCtx.SessionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// resumeDetachedClarification synchronously asks the orchestrator to accept a
|
|
// new turn. Used when the original clarification waiter has gone away.
|
|
func (r *Resolver) resumeDetachedClarification(
|
|
ctx context.Context,
|
|
pendingID string,
|
|
answers []Answer,
|
|
rejected bool,
|
|
rejectReason string,
|
|
claimedMessages []*taskmodels.Message,
|
|
) error {
|
|
if r.detachedResumer == nil {
|
|
return errors.New("detached clarification resumer unavailable")
|
|
}
|
|
persistenceCtx, cancel := clarificationPersistenceContext(ctx)
|
|
defer cancel()
|
|
|
|
clarificationCtx, err := r.resolveClarificationEventContext(persistenceCtx, pendingID)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve clarification fallback context: %w", err)
|
|
}
|
|
if clarificationCtx.SessionID == "" || clarificationCtx.TaskID == "" {
|
|
return fmt.Errorf(
|
|
"missing session/task for clarification fallback event: session=%q task=%q",
|
|
clarificationCtx.SessionID, clarificationCtx.TaskID,
|
|
)
|
|
}
|
|
|
|
answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason)
|
|
clarificationTurnID, claimedMessageIDs, err := clarificationClaimRecovery(claimedMessages)
|
|
if err != nil {
|
|
return fmt.Errorf("build clarification claim recovery: %w", err)
|
|
}
|
|
|
|
request := DetachedClarificationResume{
|
|
SessionID: clarificationCtx.SessionID,
|
|
TaskID: clarificationCtx.TaskID,
|
|
PendingID: pendingID,
|
|
ClarificationTurnID: clarificationTurnID,
|
|
ClaimedMessageIDs: claimedMessageIDs,
|
|
Question: clarificationCtx.QuestionSummary,
|
|
AnswerText: answerText,
|
|
Rejected: rejected,
|
|
RejectReason: rejectReason,
|
|
}
|
|
if err := r.detachedResumer.ResumeDetachedClarification(persistenceCtx, request); err != nil {
|
|
return fmt.Errorf("resume detached clarification: %w", err)
|
|
}
|
|
|
|
r.logger.Info("clarification answered via acknowledged fallback (new turn)",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("session_id", clarificationCtx.SessionID),
|
|
zap.String("task_id", clarificationCtx.TaskID))
|
|
return nil
|
|
}
|
|
|
|
func clarificationClaimRecovery(messages []*taskmodels.Message) (string, []string, error) {
|
|
turnID, err := clarificationClaimTurnIdentity(messages)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
messageIDs := make([]string, 0, len(messages))
|
|
for _, message := range messages {
|
|
if message == nil || message.ID == "" {
|
|
continue
|
|
}
|
|
messageIDs = append(messageIDs, message.ID)
|
|
}
|
|
return turnID, messageIDs, nil
|
|
}
|
|
|
|
func detachedResumeWasAccepted(err error) bool {
|
|
var accepted interface{ DetachedResumeAccepted() bool }
|
|
return errors.As(err, &accepted) && accepted.DetachedResumeAccepted()
|
|
}
|
|
|
|
type clarificationEventContext struct {
|
|
SessionID string
|
|
TaskID string
|
|
Questions []Question // Source-of-truth questions used to label answers; falls back to a single synthetic Question when only metadata is available.
|
|
QuestionSummary string // Pre-formatted multi-line "Q1: ...\nQ2: ..." used by the orchestrator resume prompt.
|
|
}
|
|
|
|
func (r *Resolver) resolveClarificationEventContext(ctx context.Context, pendingID string) (clarificationEventContext, error) {
|
|
var out clarificationEventContext
|
|
|
|
if req, ok := r.store.GetRequest(pendingID); ok && req != nil {
|
|
out.SessionID = req.SessionID
|
|
out.TaskID = req.TaskID
|
|
out.Questions = req.Questions
|
|
out.QuestionSummary = formatQuestionSummary(req.Questions)
|
|
if out.SessionID != "" && out.TaskID != "" && len(out.Questions) > 0 {
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
if r.repo == nil {
|
|
return out, fmt.Errorf("message repository unavailable")
|
|
}
|
|
|
|
msgs, err := r.repo.FindMessagesByPendingID(ctx, pendingID)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if len(msgs) == 0 {
|
|
return out, fmt.Errorf("no messages for pending_id %s", pendingID)
|
|
}
|
|
|
|
if out.SessionID == "" {
|
|
out.SessionID = msgs[0].TaskSessionID
|
|
}
|
|
if out.TaskID == "" {
|
|
out.TaskID = msgs[0].TaskID
|
|
}
|
|
if len(out.Questions) == 0 {
|
|
out.Questions = questionsFromMessages(msgs)
|
|
out.QuestionSummary = formatQuestionSummary(out.Questions)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// questionsFromMessages reconstructs a Question slice from persisted
|
|
// clarification messages, ordered by metadata.question_index so the rebuilt
|
|
// summary matches the bundle the agent originally sent. Used as a fallback
|
|
// when the in-store request has been cleaned up but the persisted metadata
|
|
// still carries the original question text. Unlike bundleQuestions
|
|
// (bundle_question.go), used for validation and answer application, this
|
|
// does not parse options: it feeds only the resume-summary text.
|
|
func questionsFromMessages(msgs []*taskmodels.Message) []Question {
|
|
sorted := make([]*taskmodels.Message, len(msgs))
|
|
copy(sorted, msgs)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
return questionIndexFromMetadata(sorted[i].Metadata) < questionIndexFromMetadata(sorted[j].Metadata)
|
|
})
|
|
out := make([]Question, 0, len(sorted))
|
|
for _, m := range sorted {
|
|
out = append(out, questionFromMessageMetadata(m.Metadata))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func questionFromMessageMetadata(meta map[string]any) Question {
|
|
q := Question{ID: stringFromMetadata(meta, metaQuestionIDKey)}
|
|
qData, ok := meta[metaQuestionKey].(map[string]any)
|
|
if !ok {
|
|
return q
|
|
}
|
|
if v, ok := qData["prompt"].(string); ok {
|
|
q.Prompt = v
|
|
}
|
|
if v, ok := qData["title"].(string); ok {
|
|
q.Title = v
|
|
}
|
|
if q.ID == "" {
|
|
if v, ok := qData["id"].(string); ok {
|
|
q.ID = v
|
|
}
|
|
}
|
|
return q
|
|
}
|
|
|
|
func questionIndexFromMetadata(meta map[string]any) int {
|
|
if meta == nil {
|
|
return 0
|
|
}
|
|
switch v := meta["question_index"].(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func formatQuestionSummary(questions []Question) string {
|
|
if len(questions) == 0 {
|
|
return ""
|
|
}
|
|
if len(questions) == 1 {
|
|
return questions[0].Prompt
|
|
}
|
|
parts := make([]string, 0, len(questions))
|
|
for i, q := range questions {
|
|
parts = append(parts, fmt.Sprintf("Q%d: %s", i+1, q.Prompt))
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
// buildAnswerSummary constructs a human-readable summary of the user's response
|
|
// across every question in the bundle. Used in the orchestrator resume prompt
|
|
// and for chat history rendering.
|
|
func buildAnswerSummary(questions []Question, answers []Answer, rejected bool, rejectReason string) string {
|
|
if rejected {
|
|
if rejectReason != "" {
|
|
return fmt.Sprintf("User declined to answer. Reason: %s", rejectReason)
|
|
}
|
|
return "User declined to answer."
|
|
}
|
|
if len(answers) == 0 {
|
|
return "User provided no specific answer."
|
|
}
|
|
if len(questions) <= 1 && len(answers) == 1 {
|
|
return formatSingleAnswer(answers[0])
|
|
}
|
|
|
|
answersByID := make(map[string]Answer, len(answers))
|
|
for _, a := range answers {
|
|
answersByID[a.QuestionID] = a
|
|
}
|
|
|
|
parts := make([]string, 0, len(answers))
|
|
for i, q := range questions {
|
|
ans, ok := answersByID[q.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(ans)))
|
|
}
|
|
if len(parts) == 0 {
|
|
// No matches by id — fall back to positional formatting so we still
|
|
// surface the answers rather than silently dropping them.
|
|
for i, a := range answers {
|
|
parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(a)))
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func formatSingleAnswer(a Answer) string {
|
|
if a.CustomText != "" {
|
|
return fmt.Sprintf("User answered: %s", a.CustomText)
|
|
}
|
|
if len(a.SelectedOptions) > 0 {
|
|
return fmt.Sprintf("User selected: %v", a.SelectedOptions)
|
|
}
|
|
return "User provided no specific answer."
|
|
}
|
|
|
|
func formatAnswerBody(a Answer) string {
|
|
if a.CustomText != "" {
|
|
return a.CustomText
|
|
}
|
|
if len(a.SelectedOptions) > 0 {
|
|
return fmt.Sprintf("%v", a.SelectedOptions)
|
|
}
|
|
return "(no answer)"
|
|
}
|