Files
Agent-System/internal/ctxmgr/chat_turns.go
T
weichen 3e01f18756 merge: integrate final-artifact-vault into local_dev (authorized exception)
User authorized lifting CURRENT FAV whole-branch merge protection on
2026-09-23. Keep FAV_DEGRADED_READ/FAV_PUBLISHER_ENABLED default false.
Drop duplicate goose 00034 (local_dev already has 00036). Preserve
agentx workspace admin routes and pin-export wiring.
2026-09-23 13:57:08 +00:00

400 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ctxmgr
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
)
var (
ErrIdempotencyKeyReused = errors.New("idempotency key reused with different body")
ErrChatTurnNotFound = errors.New("chat turn not found")
)
// ChatTurnStatus 回合状态
type ChatTurnStatus string
const (
TurnStatusAccepted ChatTurnStatus = "accepted"
TurnStatusProcessing ChatTurnStatus = "processing"
TurnStatusCompleted ChatTurnStatus = "completed"
TurnStatusFailed ChatTurnStatus = "failed"
TurnStatusCancelled ChatTurnStatus = "cancelled"
)
// ChatTurnRecord 回合记录
type ChatTurnRecord struct {
ID int64
SessionID string
ClientTurnID string
UserID int64
ProjectID int64
UserMessageID int64
AssistantMessageID int64
RequestBodySHA256 string
Status ChatTurnStatus
OwnerToken string
FirstRequestID string
LastRequestID string
AttemptCount int
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
LeaseExpiresAt *time.Time
}
// AcquireTurnResult 取得执行权结果
type AcquireTurnResult struct {
Record *ChatTurnRecord
WonLease bool // true = 本请求赢得执行权,应跑 Agent
}
func HashTurnBody(sessionID, message string) string {
h := sha256.Sum256([]byte(sessionID + "\n" + message))
return hex.EncodeToString(h[:])
}
// AcquireChatTurn 尝试取得回合执行权(INSERT ON CONFLICT)
func (m *ContextManager) AcquireChatTurn(
ctx context.Context,
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
if sessionID == "" || clientTurnID == "" {
return nil, errors.New("session_id and client_turn_id required")
}
if bodyHash == "" {
bodyHash = HashTurnBody(sessionID, clientTurnID)
}
if m.pool != nil {
return m.acquireChatTurnDB(ctx, sessionID, clientTurnID, bodyHash, requestID, userID, projectID)
}
return m.acquireChatTurnMemory(sessionID, clientTurnID, bodyHash, requestID, userID, projectID)
}
func (m *ContextManager) acquireChatTurnDB(
ctx context.Context,
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
ownerToken := fmt.Sprintf("own_%d", time.Now().UnixNano())
leaseUntil := time.Now().Add(5 * time.Minute)
var existingID int64
var existingHash string
var existingStatus string
var leaseExpiresAt *time.Time
err := m.pool.QueryRow(ctx, `
SELECT id, request_body_sha256, status, lease_expires_at
FROM chat_turns
WHERE session_id = $1 AND client_turn_id = $2
`, sessionID, clientTurnID).Scan(&existingID, &existingHash, &existingStatus, &leaseExpiresAt)
if err == nil {
if existingHash != "" && existingHash != bodyHash {
return nil, ErrIdempotencyKeyReused
}
if existingStatus == string(TurnStatusProcessing) && leaseExpiresAt != nil && time.Now().After(*leaseExpiresAt) {
_, _ = m.pool.Exec(ctx, `
UPDATE chat_turns SET status = $1, updated_at = now()
WHERE id = $2
`, TurnStatusFailed, existingID)
} else {
rec, recErr := m.GetChatTurn(ctx, sessionID, clientTurnID)
if recErr != nil {
return nil, recErr
}
if rec.Status != TurnStatusProcessing || (rec.LeaseExpiresAt != nil && time.Now().Before(*rec.LeaseExpiresAt)) {
_, _ = m.pool.Exec(ctx, `
UPDATE chat_turns
SET attempt_count = attempt_count + 1,
last_request_id = $1,
updated_at = now()
WHERE id = $2
`, requestID, existingID)
rec.AttemptCount++
rec.LastRequestID = requestID
return &AcquireTurnResult{Record: rec, WonLease: false}, nil
}
}
}
var turnID int64
err = m.pool.QueryRow(ctx, `
INSERT INTO chat_turns (
session_id, client_turn_id, user_id, project_id,
request_body_sha256, status, owner_token, lease_expires_at,
first_request_id, last_request_id, attempt_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9, 1)
ON CONFLICT (session_id, client_turn_id) DO NOTHING
RETURNING id
`, sessionID, clientTurnID, userID, projectID, bodyHash, TurnStatusProcessing, ownerToken, leaseUntil, requestID).Scan(&turnID)
if err != nil {
// conflict race: re-read
rec, recErr := m.GetChatTurn(ctx, sessionID, clientTurnID)
if recErr != nil {
return nil, recErr
}
if rec.RequestBodySHA256 != bodyHash {
return nil, ErrIdempotencyKeyReused
}
return &AcquireTurnResult{Record: rec, WonLease: false}, nil
}
leaseCopy := leaseUntil
rec := &ChatTurnRecord{
ID: turnID,
SessionID: sessionID,
ClientTurnID: clientTurnID,
UserID: userID,
ProjectID: projectID,
RequestBodySHA256: bodyHash,
Status: TurnStatusProcessing,
OwnerToken: ownerToken,
FirstRequestID: requestID,
LastRequestID: requestID,
AttemptCount: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LeaseExpiresAt: &leaseCopy,
}
return &AcquireTurnResult{Record: rec, WonLease: true}, nil
}
const maxMemTurns = 2000
const memTurnTTL = 2 * time.Hour
// pruneStaleTurnsLocked 调用方需持有 m.mu 写锁
func (m *ContextManager) pruneStaleTurnsLocked(ttl time.Duration) int {
if ttl <= 0 {
ttl = memTurnTTL
}
now := time.Now()
cleaned := 0
for k, rec := range m.turns {
if rec == nil || now.Sub(rec.UpdatedAt) > ttl {
delete(m.turns, k)
cleaned++
}
}
return cleaned
}
func turnMemKey(sessionID, clientTurnID string) string {
return sessionID + "|" + clientTurnID
}
func (m *ContextManager) acquireChatTurnMemory(
sessionID, clientTurnID, bodyHash, requestID string,
userID, projectID int64,
) (*AcquireTurnResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := turnMemKey(sessionID, clientTurnID)
if existing, ok := m.turns[key]; ok {
if existing.RequestBodySHA256 != bodyHash {
return nil, ErrIdempotencyKeyReused
}
leaseExpired := existing.Status == TurnStatusProcessing &&
existing.LeaseExpiresAt != nil &&
time.Now().After(*existing.LeaseExpiresAt)
if leaseExpired {
existing.Status = TurnStatusFailed
existing.UpdatedAt = time.Now()
} else if existing.Status == TurnStatusProcessing {
existing.AttemptCount++
existing.LastRequestID = requestID
existing.UpdatedAt = time.Now()
return &AcquireTurnResult{Record: existing, WonLease: false}, nil
} else {
existing.AttemptCount++
existing.LastRequestID = requestID
existing.UpdatedAt = time.Now()
return &AcquireTurnResult{Record: existing, WonLease: false}, nil
}
}
now := time.Now()
leaseUntil := now.Add(5 * time.Minute)
if existing, ok := m.turns[key]; ok {
existing.Status = TurnStatusProcessing
existing.OwnerToken = fmt.Sprintf("mem_%d", now.UnixNano())
existing.LastRequestID = requestID
existing.AttemptCount++
existing.UpdatedAt = now
existing.LeaseExpiresAt = &leaseUntil
return &AcquireTurnResult{Record: existing, WonLease: true}, nil
}
if len(m.turns) > maxMemTurns {
m.pruneStaleTurnsLocked(memTurnTTL)
}
rec := &ChatTurnRecord{
ID: int64(len(m.turns) + 1),
SessionID: sessionID,
ClientTurnID: clientTurnID,
UserID: userID,
ProjectID: projectID,
RequestBodySHA256: bodyHash,
Status: TurnStatusProcessing,
OwnerToken: fmt.Sprintf("mem_%d", now.UnixNano()),
FirstRequestID: requestID,
LastRequestID: requestID,
AttemptCount: 1,
CreatedAt: now,
UpdatedAt: now,
LeaseExpiresAt: &leaseUntil,
}
m.turns[key] = rec
return &AcquireTurnResult{Record: rec, WonLease: true}, nil
}
// UpdateChatTurnUserMessage 绑定用户消息 ID
func (m *ContextManager) UpdateChatTurnUserMessage(ctx context.Context, sessionID, clientTurnID string, userMessageID int64) error {
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns SET user_message_id = $1, updated_at = now()
WHERE session_id = $2 AND client_turn_id = $3
`, userMessageID, sessionID, clientTurnID)
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.UserMessageID = userMessageID
rec.UpdatedAt = time.Now()
}
return nil
}
// CompleteChatTurn 标记回合完成
func (m *ContextManager) CompleteChatTurn(ctx context.Context, sessionID, clientTurnID string, assistantMessageID int64) error {
now := time.Now()
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns
SET status = $1, assistant_message_id = $2, completed_at = $3, updated_at = $3
WHERE session_id = $4 AND client_turn_id = $5
`, TurnStatusCompleted, assistantMessageID, now, sessionID, clientTurnID)
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.Status = TurnStatusCompleted
rec.AssistantMessageID = assistantMessageID
rec.CompletedAt = &now
rec.UpdatedAt = now
}
return nil
}
// FailChatTurn 标记回合失败
func (m *ContextManager) FailChatTurn(ctx context.Context, sessionID, clientTurnID string) error {
if m.pool != nil {
_, err := m.pool.Exec(ctx, `
UPDATE chat_turns SET status = $1, updated_at = now()
WHERE session_id = $2 AND client_turn_id = $3
`, TurnStatusFailed, sessionID, clientTurnID)
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok {
rec.Status = TurnStatusFailed
rec.UpdatedAt = time.Now()
}
return nil
}
// GetChatTurn 查询回合
func (m *ContextManager) GetChatTurn(ctx context.Context, sessionID, clientTurnID string) (*ChatTurnRecord, error) {
if m.pool != nil {
var rec ChatTurnRecord
var completedAt *time.Time
var leaseExpiresAt *time.Time
err := m.pool.QueryRow(ctx, `
SELECT id, session_id, client_turn_id,
COALESCE(user_id, 0), COALESCE(project_id, 0),
COALESCE(user_message_id, 0), COALESCE(assistant_message_id, 0),
request_body_sha256, status,
COALESCE(first_request_id, ''), COALESCE(last_request_id, ''),
attempt_count, created_at, updated_at, completed_at, lease_expires_at
FROM chat_turns
WHERE session_id = $1 AND client_turn_id = $2
`, sessionID, clientTurnID).Scan(
&rec.ID, &rec.SessionID, &rec.ClientTurnID,
&rec.UserID, &rec.ProjectID,
&rec.UserMessageID, &rec.AssistantMessageID,
&rec.RequestBodySHA256, &rec.Status,
&rec.FirstRequestID, &rec.LastRequestID,
&rec.AttemptCount, &rec.CreatedAt, &rec.UpdatedAt, &completedAt, &leaseExpiresAt,
)
if err != nil {
return nil, ErrChatTurnNotFound
}
rec.CompletedAt = completedAt
rec.LeaseExpiresAt = leaseExpiresAt
return &rec, nil
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]
if !ok {
return nil, ErrChatTurnNotFound
}
cp := *rec
return &cp, nil
}
// GetChatTurnByAssistantMessageID 按会话 + 助手消息定位回合;用于 copy_only 显式 pin。
func (m *ContextManager) GetChatTurnByAssistantMessageID(ctx context.Context, sessionID string, assistantMessageID int64) (*ChatTurnRecord, error) {
if sessionID == "" || assistantMessageID <= 0 {
return nil, ErrChatTurnNotFound
}
if m.pool != nil {
var rec ChatTurnRecord
var completedAt *time.Time
var leaseExpiresAt *time.Time
err := m.pool.QueryRow(ctx, `
SELECT id, session_id, client_turn_id,
COALESCE(user_id, 0), COALESCE(project_id, 0),
COALESCE(user_message_id, 0), COALESCE(assistant_message_id, 0),
request_body_sha256, status,
COALESCE(first_request_id, ''), COALESCE(last_request_id, ''),
attempt_count, created_at, updated_at, completed_at, lease_expires_at
FROM chat_turns
WHERE session_id = $1 AND assistant_message_id = $2
ORDER BY id DESC
LIMIT 1
`, sessionID, assistantMessageID).Scan(
&rec.ID, &rec.SessionID, &rec.ClientTurnID,
&rec.UserID, &rec.ProjectID,
&rec.UserMessageID, &rec.AssistantMessageID,
&rec.RequestBodySHA256, &rec.Status,
&rec.FirstRequestID, &rec.LastRequestID,
&rec.AttemptCount, &rec.CreatedAt, &rec.UpdatedAt, &completedAt, &leaseExpiresAt,
)
if err != nil {
return nil, ErrChatTurnNotFound
}
rec.CompletedAt = completedAt
rec.LeaseExpiresAt = leaseExpiresAt
return &rec, nil
}
m.mu.Lock()
defer m.mu.Unlock()
for _, rec := range m.turns {
if rec != nil && rec.SessionID == sessionID && rec.AssistantMessageID == assistantMessageID {
cp := *rec
return &cp, nil
}
}
return nil, ErrChatTurnNotFound
}