mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 22:59:04 +02:00
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus. Closes #5219
344 lines
14 KiB
Go
344 lines
14 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/multica-ai/multica/server/internal/integrations/channel"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// This file is the SHARED, channel-agnostic chat-session service every IM
|
|
// adapter reuses (MUL-3516). It was lifted out of the Feishu-specific
|
|
// lark.chatSessionService so that adding an IM never re-implements the
|
|
// session/append/`/issue` machinery — the platform adapter contributes only a
|
|
// channel_type, its session titles, and (because enrichment is
|
|
// platform-specific) the command-parse source. The logic — find-or-create
|
|
// session + binding, append message + touch + reply-target + in-tx dedup mark,
|
|
// `/issue` parse — is identical across platforms and carries the channel_type
|
|
// discriminator through the generalized channel_* tables.
|
|
|
|
const pgSQLStateUniqueViolation = "23505"
|
|
|
|
// TxStarter abstracts transaction creation. Satisfied by *pgxpool.Pool. Kept
|
|
// local to the engine so the integration layer never back-references
|
|
// internal/service.
|
|
type TxStarter interface {
|
|
Begin(ctx context.Context) (pgx.Tx, error)
|
|
}
|
|
|
|
// SessionQueries is the narrow slice of the generated queries the ChatSession
|
|
// service needs. *db.Queries satisfies it through the dbSessionQueries adapter
|
|
// (whose WithTx returns the interface type); tests supply an in-memory fake.
|
|
type SessionQueries interface {
|
|
WithTx(tx pgx.Tx) SessionQueries
|
|
GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
|
|
LockWorkspaceForChatSessionCreate(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error)
|
|
CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error)
|
|
CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error)
|
|
CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error)
|
|
TouchChatSession(ctx context.Context, id pgtype.UUID) error
|
|
GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error)
|
|
UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error
|
|
MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error)
|
|
}
|
|
|
|
// dbSessionQueries adapts *db.Queries to SessionQueries — the only purpose is
|
|
// to give WithTx an interface return type so the transactional path stays
|
|
// behind SessionQueries.
|
|
type dbSessionQueries struct{ q *db.Queries }
|
|
|
|
func (a dbSessionQueries) WithTx(tx pgx.Tx) SessionQueries {
|
|
return dbSessionQueries{q: a.q.WithTx(tx)}
|
|
}
|
|
func (a dbSessionQueries) GetChannelChatSessionBinding(ctx context.Context, arg db.GetChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
|
|
return a.q.GetChannelChatSessionBinding(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) LockWorkspaceForChatSessionCreate(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
|
|
return a.q.LockWorkspaceForChatSessionCreate(ctx, id)
|
|
}
|
|
func (a dbSessionQueries) CreateChatSession(ctx context.Context, arg db.CreateChatSessionParams) (db.ChatSession, error) {
|
|
return a.q.CreateChatSession(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) CreateChannelChatSessionBinding(ctx context.Context, arg db.CreateChannelChatSessionBindingParams) (db.ChannelChatSessionBinding, error) {
|
|
return a.q.CreateChannelChatSessionBinding(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) CreateChatMessage(ctx context.Context, arg db.CreateChatMessageParams) (db.ChatMessage, error) {
|
|
return a.q.CreateChatMessage(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) TouchChatSession(ctx context.Context, id pgtype.UUID) error {
|
|
return a.q.TouchChatSession(ctx, id)
|
|
}
|
|
func (a dbSessionQueries) GetMostRecentUserChatMessage(ctx context.Context, chatSessionID pgtype.UUID) (db.ChatMessage, error) {
|
|
return a.q.GetMostRecentUserChatMessage(ctx, chatSessionID)
|
|
}
|
|
func (a dbSessionQueries) UpdateChannelChatSessionBindingReplyTarget(ctx context.Context, arg db.UpdateChannelChatSessionBindingReplyTargetParams) error {
|
|
return a.q.UpdateChannelChatSessionBindingReplyTarget(ctx, arg)
|
|
}
|
|
func (a dbSessionQueries) MarkChannelInboundDedupProcessed(ctx context.Context, arg db.MarkChannelInboundDedupProcessedParams) (int64, error) {
|
|
return a.q.MarkChannelInboundDedupProcessed(ctx, arg)
|
|
}
|
|
|
|
// SessionTitles are the per-platform display titles a freshly created
|
|
// chat_session gets (the first message has not been appended yet, so the title
|
|
// cannot be derived from content). The adapter supplies its own wording.
|
|
type SessionTitles struct {
|
|
Group string
|
|
Direct string
|
|
Fallback string
|
|
}
|
|
|
|
func (t SessionTitles) forType(ct channel.ChatType) string {
|
|
switch ct {
|
|
case channel.ChatTypeGroup:
|
|
return t.Group
|
|
case channel.ChatTypeP2P:
|
|
return t.Direct
|
|
default:
|
|
return t.Fallback
|
|
}
|
|
}
|
|
|
|
// ChatSession is the shared chat-session service. One instance is built per
|
|
// channel_type (so the binding rows carry the right discriminator); the logic
|
|
// is otherwise platform-neutral.
|
|
type ChatSession struct {
|
|
q SessionQueries
|
|
tx TxStarter
|
|
channelType channel.Type
|
|
titles SessionTitles
|
|
}
|
|
|
|
// NewChatSession builds the shared service over the generated queries. tx is
|
|
// required: AppendUserMessage runs the dedup Mark inside the chat_message
|
|
// transaction so the durable write and the Mark commit (or roll back) together.
|
|
func NewChatSession(q *db.Queries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
|
|
return &ChatSession{q: dbSessionQueries{q: q}, tx: tx, channelType: channelType, titles: titles}
|
|
}
|
|
|
|
// newChatSessionWith is the test seam: it accepts a SessionQueries directly so
|
|
// an in-memory fake can stand in for *db.Queries.
|
|
func newChatSessionWith(q SessionQueries, tx TxStarter, channelType channel.Type, titles SessionTitles) *ChatSession {
|
|
return &ChatSession{q: q, tx: tx, channelType: channelType, titles: titles}
|
|
}
|
|
|
|
// EnsureSessionInput is the channel-agnostic input for EnsureSession.
|
|
//
|
|
// BindingKey is the SESSION-ISOLATION key (stored as channel_chat_id; one
|
|
// chat_session per (installation_id, BindingKey)). It is intentionally NOT the
|
|
// same thing as "the chat to reply into": the adapter composes it so that
|
|
// distinct conversations get distinct sessions — Feishu passes the chat id;
|
|
// Slack passes the channel id for a DM, and the channel id PLUS the thread root
|
|
// for a channel/thread, so two @bot threads in one Slack channel do not collapse
|
|
// into one transcript (the Hermes model: IM-independent, Slack groups isolated
|
|
// by thread root). A raw platform chat id must never be passed straight through
|
|
// as the key for a threaded platform.
|
|
//
|
|
// BindingConfig is opaque platform routing the key alone cannot carry — e.g.
|
|
// Slack's real channel_id when BindingKey is a composite — persisted on the
|
|
// binding's config for the outbound path to read back. nil means "{}".
|
|
//
|
|
// Sender is the already-resolved Multica user (the session creator: the sole
|
|
// human for p2p, the installer for group chats — the caller decides which).
|
|
type EnsureSessionInput struct {
|
|
WorkspaceID pgtype.UUID
|
|
AgentID pgtype.UUID
|
|
InstallationID pgtype.UUID
|
|
Sender pgtype.UUID
|
|
BindingKey string
|
|
BindingConfig []byte
|
|
ChatType channel.ChatType
|
|
}
|
|
|
|
// EnsureSession returns the chat_session.id bound to (installation, BindingKey),
|
|
// creating it (with its channel_chat_session_binding) on first contact. The
|
|
// race between two concurrent first messages is resolved by the
|
|
// UNIQUE (installation_id, channel_chat_id) constraint: the loser re-reads the
|
|
// winner's row.
|
|
func (s *ChatSession) EnsureSession(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
|
|
lookup := db.GetChannelChatSessionBindingParams{InstallationID: in.InstallationID, ChannelChatID: in.BindingKey}
|
|
|
|
existing, err := s.q.GetChannelChatSessionBinding(ctx, lookup)
|
|
if err == nil {
|
|
return existing.ChatSessionID, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return pgtype.UUID{}, fmt.Errorf("lookup chat session binding: %w", err)
|
|
}
|
|
|
|
id, err := s.createSessionAndBinding(ctx, in)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
if isUniqueViolation(err) {
|
|
existing, lookupErr := s.q.GetChannelChatSessionBinding(ctx, lookup)
|
|
if lookupErr == nil {
|
|
return existing.ChatSessionID, nil
|
|
}
|
|
return pgtype.UUID{}, fmt.Errorf("race re-read after unique violation: %w", lookupErr)
|
|
}
|
|
return pgtype.UUID{}, err
|
|
}
|
|
|
|
func (s *ChatSession) createSessionAndBinding(ctx context.Context, in EnsureSessionInput) (pgtype.UUID, error) {
|
|
tx, err := s.tx.Begin(ctx)
|
|
if err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.q.WithTx(tx)
|
|
|
|
// FOR KEY SHARE on the workspace row before creating the session — the creator
|
|
// half of the #5219 delete/create protocol, so a channel session cannot be
|
|
// created into a workspace mid-delete (see LockWorkspaceForChatSessionCreate).
|
|
if _, err := qtx.LockWorkspaceForChatSessionCreate(ctx, in.WorkspaceID); err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("lock workspace for chat session create: %w", err)
|
|
}
|
|
|
|
session, err := qtx.CreateChatSession(ctx, db.CreateChatSessionParams{
|
|
WorkspaceID: in.WorkspaceID,
|
|
AgentID: in.AgentID,
|
|
CreatorID: in.Sender,
|
|
Title: s.titles.forType(in.ChatType),
|
|
})
|
|
if err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("create chat session: %w", err)
|
|
}
|
|
bindingConfig := in.BindingConfig
|
|
if len(bindingConfig) == 0 {
|
|
bindingConfig = []byte("{}")
|
|
}
|
|
if _, err := qtx.CreateChannelChatSessionBinding(ctx, db.CreateChannelChatSessionBindingParams{
|
|
ChatSessionID: session.ID,
|
|
InstallationID: in.InstallationID,
|
|
ChannelType: string(s.channelType),
|
|
ChannelChatID: in.BindingKey,
|
|
ChatType: string(in.ChatType),
|
|
Config: bindingConfig,
|
|
}); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return pgtype.UUID{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return session.ID, nil
|
|
}
|
|
|
|
// AppendInput is the channel-agnostic input for AppendUserMessage. Body is the
|
|
// full stored text (including any platform enrichment); CommandText is the
|
|
// user's OWN typed text used for `/issue` parsing (empty falls back to Body) —
|
|
// the adapter supplies it because enrichment is platform-specific. ClaimToken
|
|
// is the dedup owner-fence: when valid, the Mark runs inside this method's tx.
|
|
//
|
|
// MessageID and ThreadID are the REAL platform message id and thread id of this
|
|
// trigger — the outbound reply target recorded on the binding (last_message_id /
|
|
// last_thread_id), NOT the session BindingKey. Because each isolated session has
|
|
// its own binding row, recording the real thread here per session does not clash
|
|
// across sibling threads.
|
|
type AppendInput struct {
|
|
SessionID pgtype.UUID
|
|
Sender pgtype.UUID
|
|
InstallationID pgtype.UUID
|
|
Body string
|
|
CommandText string
|
|
MessageID string
|
|
ThreadID string
|
|
ClaimToken pgtype.UUID
|
|
}
|
|
|
|
// AppendUserMessage writes the user message into the chat_session (touching it
|
|
// and recording the reply target), runs the in-tx dedup Mark when a claim token
|
|
// is supplied, and returns the parsed `/issue` command when present. Returns
|
|
// ErrClaimLost when a concurrent reclaim rotated the dedup token mid-flight, in
|
|
// which case the whole transaction rolls back (no chat_message lands).
|
|
func (s *ChatSession) AppendUserMessage(ctx context.Context, in AppendInput) (AppendResult, error) {
|
|
tx, err := s.tx.Begin(ctx)
|
|
if err != nil {
|
|
return AppendResult{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := s.q.WithTx(tx)
|
|
|
|
// Parse before the insert so the bare-`/issue` previous-message fallback
|
|
// queries the message set that does NOT yet include this message.
|
|
commandSource := in.CommandText
|
|
if commandSource == "" {
|
|
commandSource = in.Body
|
|
}
|
|
cmd, _ := ParseIssueCommand(commandSource)
|
|
if cmd != nil && cmd.Title == "" {
|
|
prev, err := qtx.GetMostRecentUserChatMessage(ctx, in.SessionID)
|
|
if err == nil {
|
|
cmd.Title = titleFromPreviousMessage(prev.Content)
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return AppendResult{}, fmt.Errorf("previous message lookup: %w", err)
|
|
}
|
|
}
|
|
|
|
if _, err := qtx.CreateChatMessage(ctx, db.CreateChatMessageParams{
|
|
ChatSessionID: in.SessionID,
|
|
Role: "user",
|
|
Content: in.Body,
|
|
}); err != nil {
|
|
return AppendResult{}, fmt.Errorf("create chat message: %w", err)
|
|
}
|
|
if err := qtx.TouchChatSession(ctx, in.SessionID); err != nil {
|
|
return AppendResult{}, fmt.Errorf("touch chat session: %w", err)
|
|
}
|
|
|
|
// Record the latest trigger so the decoupled outbound patcher can thread
|
|
// its reply back into the originating topic.
|
|
if in.MessageID != "" {
|
|
if err := qtx.UpdateChannelChatSessionBindingReplyTarget(ctx, db.UpdateChannelChatSessionBindingReplyTargetParams{
|
|
ChatSessionID: in.SessionID,
|
|
LastMessageID: textOrNull(in.MessageID),
|
|
LastThreadID: textOrNull(in.ThreadID),
|
|
}); err != nil {
|
|
return AppendResult{}, fmt.Errorf("update reply target: %w", err)
|
|
}
|
|
}
|
|
|
|
markedInTx := false
|
|
if in.ClaimToken.Valid && in.MessageID != "" {
|
|
rows, err := qtx.MarkChannelInboundDedupProcessed(ctx, db.MarkChannelInboundDedupProcessedParams{
|
|
InstallationID: in.InstallationID,
|
|
MessageID: in.MessageID,
|
|
ClaimToken: in.ClaimToken,
|
|
})
|
|
if err != nil {
|
|
return AppendResult{}, fmt.Errorf("mark dedup processed: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
// Another worker re-claimed the dedup row; roll back via the
|
|
// deferred Rollback so no second chat_message lands.
|
|
return AppendResult{}, ErrClaimLost
|
|
}
|
|
markedInTx = true
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return AppendResult{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return AppendResult{IssueCommand: cmd, DedupMarked: markedInTx}, nil
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pg *pgconn.PgError
|
|
if errors.As(err, &pg) {
|
|
return pg.Code == pgSQLStateUniqueViolation
|
|
}
|
|
return false
|
|
}
|
|
|
|
func textOrNull(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|