Files
viettranx 175e05287a fix(cache): heartbeat/cron used UUID in session keys causing stale cache after agent update
Session keys must use agentKey (human-readable, e.g. "default") not UUID.
Cache invalidation via InvalidateAgent(agentKey) only matches agentKey suffix,
so UUID-keyed entries were never cleared — causing stale provider/model after
agent updates from the web UI.

- heartbeat: use agentKey (already resolved) instead of UUID for session key
  and HasActiveSessionsForAgent check
- cron: resolve agentKey from UUID via agentStore before building session key
- agents_update: also invalidate by UUID as safety net for legacy entries
- startup migration: auto-fix existing UUID-based session keys in DB
- tests: 7 new tests covering session key format and cache invalidation
2026-03-28 19:02:53 +07:00

190 lines
6.7 KiB
Go

// Package sessions — session key builder and parser.
//
// Session keys follow the TS OpenClaw canonical format:
//
// agent:{agentKey}:{rest}
//
// IMPORTANT: {agentKey} is the human-readable agent key (e.g. "default", "my-agent"),
// NOT the agent's UUID. All Build*SessionKey functions expect agentKey, not UUID.
// This is critical for cache invalidation — the agent router cache uses agentKey
// as the lookup key, and InvalidateAgent() matches by agentKey suffix.
// Tenant isolation is handled separately via context (tenant_id in DB, prefix in cache).
//
// Where {rest} depends on the session type:
//
// DM: {channel}:direct:{peerId}
// Group: {channel}:group:{groupId}
// Forum topic: {channel}:group:{groupId}:topic:{topicId}
// Subagent: subagent:{label}
// Cron: cron:{jobId}
//
// Examples:
//
// agent:default:telegram:direct:386246614
// agent:default:telegram:group:-100123456
// agent:default:telegram:group:-100123456:topic:99
// agent:default:subagent:my-task
// agent:default:cron:reminder-job-id
package sessions
import (
"fmt"
"strings"
"time"
)
// PeerKind distinguishes DM from group conversations.
type PeerKind string
const (
PeerDirect PeerKind = "direct"
PeerGroup PeerKind = "group"
)
// BuildSessionKey builds the canonical agent session key for a channel conversation.
//
// DM: agent:{agentId}:{channel}:direct:{peerID}
// Group: agent:{agentId}:{channel}:group:{chatID}
func BuildSessionKey(agentID, channel string, kind PeerKind, chatID string) string {
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, kind, chatID)
}
// BuildGroupTopicSessionKey builds the session key for a forum group topic.
// TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts
//
// agent:{agentId}:{channel}:group:{chatID}:topic:{topicID}
func BuildGroupTopicSessionKey(agentID, channel, chatID string, topicID int) string {
return fmt.Sprintf("agent:%s:%s:group:%s:topic:%d", agentID, channel, chatID, topicID)
}
// BuildDMThreadSessionKey builds the session key for a DM thread (topic in private chat).
// Preserves message_thread_id for session isolation within the same DM.
//
// agent:{agentId}:{channel}:direct:{peerID}:thread:{threadID}
func BuildDMThreadSessionKey(agentID, channel, peerID string, threadID int) string {
return fmt.Sprintf("agent:%s:%s:direct:%s:thread:%d", agentID, channel, peerID, threadID)
}
// BuildScopedThreadSessionKey builds a session key that includes a thread/topic ID.
// Supports string-based thread IDs (e.g. Slack timestamps).
//
// agent:{agentId}:{channel}:{kind}:{chatID}:thread:{threadID}
func BuildScopedThreadSessionKey(agentID, channel string, kind PeerKind, chatID, threadID string) string {
return fmt.Sprintf("agent:%s:%s:%s:%s:thread:%s", agentID, channel, kind, chatID, threadID)
}
// BuildSubagentSessionKey builds the session key for a subagent.
//
// agent:{agentId}:subagent:{label}
func BuildSubagentSessionKey(agentID, label string) string {
return fmt.Sprintf("agent:%s:subagent:%s", agentID, label)
}
// BuildTeamSessionKey builds an isolated session key for team task execution.
// Scoped per agent + team + chatID (user), matching workspace isolation.
// All tasks from the same user within the same team share one session per member agent.
//
// agent:{agentId}:team:{teamId}:{chatId}
func BuildTeamSessionKey(agentID, teamID, chatID string) string {
return fmt.Sprintf("agent:%s:team:%s:%s", agentID, teamID, chatID)
}
// IsTeamSession checks if a session key indicates a team session.
func IsTeamSession(key string) bool {
_, rest := ParseSessionKey(key)
return strings.HasPrefix(rest, "team:")
}
// BuildCronSessionKey builds the session key for a cron job.
// Each cron job gets one persistent session (all runs share the same history).
//
// agent:{agentId}:cron:{jobID}
//
// Guards against double-prefixing: if jobID is already a canonical session key
// (e.g. "agent:X:..."), only the rest part is used.
func BuildCronSessionKey(agentID, jobID string) string {
if _, rest := ParseSessionKey(jobID); rest != "" {
jobID = rest
}
return fmt.Sprintf("agent:%s:cron:%s", agentID, jobID)
}
// BuildAgentMainSessionKey builds the shared "main" session key for an agent.
// Used when dm_scope="main" — all DMs share one session per agent.
// Matching TS buildAgentMainSessionKey().
//
// agent:{agentId}:{mainKey}
func BuildAgentMainSessionKey(agentID, mainKey string) string {
if mainKey == "" {
mainKey = "main"
}
return fmt.Sprintf("agent:%s:%s", agentID, mainKey)
}
// BuildScopedSessionKey builds a session key using fixed scoping:
// - Groups: per-sender (full key with channel + group ID)
// - DMs: per-channel-peer (channel + peer user ID)
func BuildScopedSessionKey(agentID, channel string, kind PeerKind, chatID string) string {
return BuildSessionKey(agentID, channel, kind, chatID)
}
// ParseSessionKey extracts the agentID and rest from a canonical session key.
// Returns ("", "") if the key is not in the expected format.
func ParseSessionKey(key string) (agentID, rest string) {
parts := strings.SplitN(key, ":", 3)
if len(parts) < 3 || parts[0] != "agent" {
return "", ""
}
return parts[1], parts[2]
}
// IsSubagentSession checks if a session key indicates a subagent session.
func IsSubagentSession(key string) bool {
_, rest := ParseSessionKey(key)
return strings.HasPrefix(strings.ToLower(rest), "subagent:")
}
// IsCronSession checks if a session key indicates a cron session.
func IsCronSession(key string) bool {
_, rest := ParseSessionKey(key)
return strings.HasPrefix(strings.ToLower(rest), "cron:")
}
// BuildHeartbeatSessionKey builds the session key for a heartbeat run.
//
// isolated=true: agent:{agentId}:heartbeat:{unix_ms}
// isolated=false: agent:{agentId}:heartbeat
func BuildHeartbeatSessionKey(agentID string, isolated bool) string {
if isolated {
return fmt.Sprintf("agent:%s:heartbeat:%d", agentID, time.Now().UnixMilli())
}
return fmt.Sprintf("agent:%s:heartbeat", agentID)
}
// IsHeartbeatSession checks if a session key indicates a heartbeat session.
func IsHeartbeatSession(key string) bool {
_, rest := ParseSessionKey(key)
return strings.HasPrefix(rest, "heartbeat")
}
// BuildWSSessionKey builds the canonical WS session key for a web conversation.
//
// agent:{agentId}:ws:direct:{conversationId}
func BuildWSSessionKey(agentID, conversationID string) string {
return BuildSessionKey(agentID, "ws", PeerDirect, conversationID)
}
// IsWSSession checks if a session key is a WS session (new or legacy format).
func IsWSSession(key string) bool {
_, rest := ParseSessionKey(key)
return strings.HasPrefix(rest, "ws:") || strings.HasPrefix(rest, "ws-")
}
// PeerKindFromGroup returns PeerGroup if isGroup is true, PeerDirect otherwise.
func PeerKindFromGroup(isGroup bool) PeerKind {
if isGroup {
return PeerGroup
}
return PeerDirect
}