755 lines
21 KiB
Go
755 lines
21 KiB
Go
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ContinueSession is a sentinel value for AgentSessionID that tells the agent
|
|
// to use --continue (resume most recent session) instead of a specific session ID.
|
|
const ContinueSession = "__continue__"
|
|
|
|
// Session tracks one conversation between a user and the agent.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AgentSessionID string `json:"agent_session_id"`
|
|
AgentType string `json:"agent_type,omitempty"`
|
|
PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"`
|
|
History []HistoryEntry `json:"history"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
// LastUserActivity records when a real user message was last received.
|
|
// Unlike UpdatedAt (bumped by every session.Unlock including heartbeats and
|
|
// unsolicited agent output), this field is only updated when the engine
|
|
// processes an actual incoming user message. It is used by reset_on_idle_mins
|
|
// so that automated activity cannot prevent idle session rotation.
|
|
LastUserActivity time.Time `json:"last_user_activity,omitempty"`
|
|
|
|
mu sync.Mutex `json:"-"`
|
|
busy bool `json:"-"`
|
|
}
|
|
|
|
func (s *Session) TryLock() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.busy {
|
|
return false
|
|
}
|
|
s.busy = true
|
|
return true
|
|
}
|
|
|
|
// Busy reports whether the session is currently locked for an in-flight turn.
|
|
// Used by commands (e.g. /ps) that only make sense while a task is running.
|
|
func (s *Session) Busy() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.busy
|
|
}
|
|
|
|
func (s *Session) Unlock() {
|
|
s.unlock(true)
|
|
}
|
|
|
|
func (s *Session) UnlockWithoutUpdate() {
|
|
s.unlock(false)
|
|
}
|
|
|
|
func (s *Session) unlock(update bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.busy = false
|
|
if update {
|
|
s.UpdatedAt = time.Now()
|
|
}
|
|
}
|
|
|
|
func (s *Session) AddHistory(role, content string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.History = append(s.History, HistoryEntry{
|
|
Role: role,
|
|
Content: content,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
// recordPastAgentSessionID saves the current AgentSessionID to PastAgentSessionIDs
|
|
// so it remains visible in KnownAgentSessionIDs after the ID is replaced or cleared.
|
|
// Must be called with s.mu held.
|
|
func (s *Session) recordPastAgentSessionID() {
|
|
if s.AgentSessionID == "" || s.AgentSessionID == ContinueSession {
|
|
return
|
|
}
|
|
for _, past := range s.PastAgentSessionIDs {
|
|
if past == s.AgentSessionID {
|
|
return
|
|
}
|
|
}
|
|
s.PastAgentSessionIDs = append(s.PastAgentSessionIDs, s.AgentSessionID)
|
|
}
|
|
|
|
// SetAgentInfo atomically sets the agent session ID, agent type, and name.
|
|
func (s *Session) SetAgentInfo(agentSessionID, agentType, name string) {
|
|
if agentSessionID == ContinueSession {
|
|
agentSessionID = ""
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AgentSessionID != agentSessionID {
|
|
s.recordPastAgentSessionID()
|
|
}
|
|
s.AgentSessionID = agentSessionID
|
|
s.AgentType = agentType
|
|
s.Name = name
|
|
}
|
|
|
|
// GetAgentSessionID atomically reads the agent session ID.
|
|
func (s *Session) GetAgentSessionID() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.AgentSessionID
|
|
}
|
|
|
|
// SetName atomically updates the session's display name. The management
|
|
// API used to write s.Name directly, which raced with GetName / listing
|
|
// handlers that read it under s.mu.
|
|
func (s *Session) SetName(name string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.Name = name
|
|
}
|
|
|
|
// GetName atomically reads the session name.
|
|
func (s *Session) GetName() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.Name
|
|
}
|
|
|
|
// TouchUserActivity records the current time as the last user-driven activity
|
|
// on this session. Call this once per incoming user message, after the idle
|
|
// reset check, so that only real user interactions reset the idle timer.
|
|
func (s *Session) TouchUserActivity() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.LastUserActivity = time.Now()
|
|
}
|
|
|
|
// GetLastUserActivity returns when the last real user message was received.
|
|
// Returns zero time if no user message has been processed yet.
|
|
func (s *Session) GetLastUserActivity() time.Time {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.LastUserActivity
|
|
}
|
|
|
|
func (s *Session) GetUpdatedAt() time.Time {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.UpdatedAt
|
|
}
|
|
|
|
// SetAgentSessionID atomically sets the agent session ID and agent type.
|
|
// The ContinueSession sentinel is never persisted — it is only used transiently
|
|
// when starting an agent (see engine); storing it on disk breaks resume (#255).
|
|
// When the existing ID is replaced or cleared, it is saved to PastAgentSessionIDs
|
|
// so filterOwnedSessions continues to recognise the session.
|
|
func (s *Session) SetAgentSessionID(id, agentType string) {
|
|
if id == ContinueSession {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AgentSessionID != id {
|
|
s.recordPastAgentSessionID()
|
|
}
|
|
s.AgentSessionID = id
|
|
s.AgentType = agentType
|
|
}
|
|
|
|
// CompareAndSetAgentSessionID sets the agent session ID only if it is currently
|
|
// empty or still holds the erroneous persisted ContinueSession sentinel.
|
|
// Returns true if the value was set, false if a real session ID was already stored.
|
|
func (s *Session) CompareAndSetAgentSessionID(id, agentType string) bool {
|
|
if id == "" || id == ContinueSession {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AgentSessionID != "" && s.AgentSessionID != ContinueSession {
|
|
return false
|
|
}
|
|
s.AgentSessionID = id
|
|
s.AgentType = agentType
|
|
return true
|
|
}
|
|
|
|
func (s *Session) stripContinueSessionSentinel() {
|
|
s.mu.Lock()
|
|
if s.AgentSessionID == ContinueSession {
|
|
s.AgentSessionID = ""
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Session) ClearHistory() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.History = nil
|
|
}
|
|
|
|
// HistoryLen returns the current number of history entries.
|
|
func (s *Session) HistoryLen() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.History)
|
|
}
|
|
|
|
// GetHistory returns the last n entries. If n <= 0, returns all.
|
|
func (s *Session) GetHistory(n int) []HistoryEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
total := len(s.History)
|
|
if n <= 0 || n > total {
|
|
n = total
|
|
}
|
|
out := make([]HistoryEntry, n)
|
|
copy(out, s.History[total-n:])
|
|
return out
|
|
}
|
|
|
|
// UserMeta stores human-readable display info for a session key.
|
|
type UserMeta struct {
|
|
UserName string `json:"user_name,omitempty"`
|
|
ChatName string `json:"chat_name,omitempty"`
|
|
}
|
|
|
|
// snapshotVersion tracks the schema version so we can detect data saved by
|
|
// older code that didn't persist all migration flags.
|
|
// - 0 (missing): original format or early PastIDTracking-only format
|
|
// - 1: full LegacyData persistence
|
|
const snapshotVersion = 1
|
|
|
|
// sessionSnapshot is the JSON-serializable state of the SessionManager.
|
|
type sessionSnapshot struct {
|
|
Sessions map[string]*Session `json:"sessions"`
|
|
ActiveSession map[string]string `json:"active_session"`
|
|
UserSessions map[string][]string `json:"user_sessions"`
|
|
Counter int64 `json:"counter"`
|
|
SessionNames map[string]string `json:"session_names,omitempty"` // agent session ID → custom name
|
|
UserMeta map[string]*UserMeta `json:"user_meta,omitempty"` // sessionKey → display info
|
|
PastIDTracking bool `json:"past_id_tracking,omitempty"` // true once PastAgentSessionIDs is supported
|
|
LegacyData bool `json:"legacy_data,omitempty"` // true while pre-fix sessions exist
|
|
Version int `json:"version,omitempty"` // schema version for migration detection
|
|
}
|
|
|
|
// SessionManager supports multiple named sessions per user with active-session tracking.
|
|
// It can persist state to a JSON file and reload on startup.
|
|
type SessionManager struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
activeSession map[string]string
|
|
userSessions map[string][]string
|
|
sessionNames map[string]string // agent session ID → custom name
|
|
userMeta map[string]*UserMeta // sessionKey → display info
|
|
counter int64
|
|
storePath string // empty = no persistence
|
|
|
|
// legacyData is true when sessions were loaded from a snapshot that
|
|
// predates PastAgentSessionIDs tracking. In this state, many sessions
|
|
// may have lost their AgentSessionID through /new or provider switches.
|
|
// KnownAgentSessionIDs returns nil to disable filterOwnedSessions.
|
|
legacyData bool
|
|
}
|
|
|
|
func NewSessionManager(storePath string) *SessionManager {
|
|
sm := &SessionManager{
|
|
sessions: make(map[string]*Session),
|
|
activeSession: make(map[string]string),
|
|
userSessions: make(map[string][]string),
|
|
sessionNames: make(map[string]string),
|
|
userMeta: make(map[string]*UserMeta),
|
|
storePath: storePath,
|
|
}
|
|
if storePath != "" {
|
|
sm.load()
|
|
}
|
|
return sm
|
|
}
|
|
|
|
// StorePath returns the file path used for session persistence.
|
|
func (sm *SessionManager) StorePath() string {
|
|
return sm.storePath
|
|
}
|
|
|
|
func (sm *SessionManager) nextID() string {
|
|
sm.counter++
|
|
return fmt.Sprintf("s%d", sm.counter)
|
|
}
|
|
|
|
func (sm *SessionManager) GetOrCreateActive(userKey string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
if sid, ok := sm.activeSession[userKey]; ok {
|
|
if s, ok := sm.sessions[sid]; ok {
|
|
return s
|
|
}
|
|
}
|
|
s := sm.createLocked(userKey, "default")
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) NewSession(userKey, name string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
s := sm.createLocked(userKey, name)
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
|
|
// NewSideSession registers a new session for userKey without changing the active
|
|
// session. Used for isolated one-off runs (e.g. cron with session_mode=new_per_run)
|
|
// so the user's current chat remains the default target for normal messages.
|
|
func (sm *SessionManager) NewSideSession(userKey, name string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
id := sm.nextID()
|
|
now := time.Now()
|
|
s := &Session{
|
|
ID: id,
|
|
Name: name,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
sm.sessions[id] = s
|
|
sm.userSessions[userKey] = append(sm.userSessions[userKey], id)
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) createLocked(userKey, name string) *Session {
|
|
id := sm.nextID()
|
|
now := time.Now()
|
|
s := &Session{
|
|
ID: id,
|
|
Name: name,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
sm.sessions[id] = s
|
|
sm.activeSession[userKey] = id
|
|
sm.userSessions[userKey] = append(sm.userSessions[userKey], id)
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) SwitchSession(userKey, target string) (*Session, error) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
for _, sid := range sm.userSessions[userKey] {
|
|
s := sm.sessions[sid]
|
|
if s != nil && (s.ID == target || s.Name == target) {
|
|
sm.activeSession[userKey] = s.ID
|
|
sm.saveLocked()
|
|
return s, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("session %q not found", target)
|
|
}
|
|
|
|
// SwitchToAgentSession finds or creates an internal session that maps to the
|
|
// given agent session ID. If an existing session already references agentSID,
|
|
// it becomes the active session. Otherwise a new session is created so the
|
|
// previous session's AgentSessionID is preserved in KnownAgentSessionIDs.
|
|
func (sm *SessionManager) SwitchToAgentSession(userKey, agentSID, agentName, summary string) *Session {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
for _, sid := range sm.userSessions[userKey] {
|
|
s := sm.sessions[sid]
|
|
if s == nil {
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
aid := s.AgentSessionID
|
|
s.mu.Unlock()
|
|
if aid == agentSID {
|
|
sm.activeSession[userKey] = s.ID
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
}
|
|
|
|
s := sm.createLocked(userKey, summary)
|
|
s.SetAgentInfo(agentSID, agentName, summary)
|
|
sm.saveLocked()
|
|
return s
|
|
}
|
|
|
|
func (sm *SessionManager) ListSessions(userKey string) []*Session {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
ids := sm.userSessions[userKey]
|
|
out := make([]*Session, 0, len(ids))
|
|
for _, sid := range ids {
|
|
if s, ok := sm.sessions[sid]; ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (sm *SessionManager) ActiveSessionID(userKey string) string {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.activeSession[userKey]
|
|
}
|
|
|
|
// SetSessionName sets a custom display name for an agent session.
|
|
func (sm *SessionManager) SetSessionName(agentSessionID, name string) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
if name == "" {
|
|
delete(sm.sessionNames, agentSessionID)
|
|
} else {
|
|
sm.sessionNames[agentSessionID] = name
|
|
}
|
|
sm.saveLocked()
|
|
}
|
|
|
|
// GetSessionName returns the custom name for an agent session, or "".
|
|
func (sm *SessionManager) GetSessionName(agentSessionID string) string {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.sessionNames[agentSessionID]
|
|
}
|
|
|
|
// UpdateUserMeta updates the human-readable metadata for a session key.
|
|
// Only non-empty fields are applied (merge behavior).
|
|
func (sm *SessionManager) UpdateUserMeta(sessionKey, userName, chatName string) {
|
|
if userName == "" && chatName == "" {
|
|
return
|
|
}
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
meta, ok := sm.userMeta[sessionKey]
|
|
if !ok {
|
|
meta = &UserMeta{}
|
|
sm.userMeta[sessionKey] = meta
|
|
}
|
|
if userName != "" {
|
|
meta.UserName = userName
|
|
}
|
|
if chatName != "" {
|
|
meta.ChatName = chatName
|
|
}
|
|
}
|
|
|
|
// GetUserMeta returns a copy of the stored metadata for a session key, or nil.
|
|
func (sm *SessionManager) GetUserMeta(sessionKey string) *UserMeta {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
m := sm.userMeta[sessionKey]
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
cp := *m
|
|
return &cp
|
|
}
|
|
|
|
// AllSessions returns all sessions across all user keys.
|
|
func (sm *SessionManager) AllSessions() []*Session {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
out := make([]*Session, 0, len(sm.sessions))
|
|
for _, s := range sm.sessions {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// KnownAgentSessionIDs returns the set of agent session IDs tracked by cc-connect.
|
|
// This is used to filter agent.ListSessions() output to only sessions owned by
|
|
// cc-connect, excluding sessions created by external CLI usage in the same work_dir.
|
|
// It includes both current and historical agent session IDs so that sessions whose
|
|
// IDs were cleared (e.g. after /new or provider switch) remain visible.
|
|
//
|
|
// Legacy data: when the snapshot was written before PastAgentSessionIDs tracking
|
|
// existed, many sessions may have silently lost their IDs through /new or provider
|
|
// switches. Returns nil unconditionally while legacyData is true, disabling
|
|
// filterOwnedSessions. legacyData is only cleared once every session has at least
|
|
// one tracked ID (current or past), meaning the data has been fully migrated.
|
|
func (sm *SessionManager) KnownAgentSessionIDs() map[string]struct{} {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
if sm.legacyData {
|
|
return nil
|
|
}
|
|
ids := make(map[string]struct{})
|
|
for _, s := range sm.sessions {
|
|
s.mu.Lock()
|
|
if s.AgentSessionID != "" {
|
|
ids[s.AgentSessionID] = struct{}{}
|
|
}
|
|
for _, past := range s.PastAgentSessionIDs {
|
|
ids[past] = struct{}{}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// SessionKeyMap returns a mapping from session ID to the user key (session_key) it belongs to,
|
|
// plus active session IDs for each user key.
|
|
func (sm *SessionManager) SessionKeyMap() (idToKey map[string]string, activeIDs map[string]bool) {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
idToKey = make(map[string]string, len(sm.sessions))
|
|
activeIDs = make(map[string]bool)
|
|
for userKey, ids := range sm.userSessions {
|
|
for _, sid := range ids {
|
|
idToKey[sid] = userKey
|
|
}
|
|
if aid, ok := sm.activeSession[userKey]; ok {
|
|
activeIDs[aid] = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// FindByID looks up a session by its internal ID across all users.
|
|
func (sm *SessionManager) FindByID(id string) *Session {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.sessions[id]
|
|
}
|
|
|
|
// DeleteByID removes a session by its internal ID from all tracking structures.
|
|
func (sm *SessionManager) DeleteByID(id string) bool {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
if _, ok := sm.sessions[id]; !ok {
|
|
return false
|
|
}
|
|
sm.deleteByIDLocked(id)
|
|
sm.saveLocked()
|
|
return true
|
|
}
|
|
|
|
// DeleteByAgentSessionID removes all local sessions mapped to the given
|
|
// agent session ID. It returns the number of removed local sessions.
|
|
func (sm *SessionManager) DeleteByAgentSessionID(agentSessionID string) int {
|
|
if agentSessionID == "" {
|
|
return 0
|
|
}
|
|
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
removed := 0
|
|
for id, s := range sm.sessions {
|
|
s.mu.Lock()
|
|
matched := s.AgentSessionID == agentSessionID
|
|
s.mu.Unlock()
|
|
if !matched {
|
|
continue
|
|
}
|
|
sm.deleteByIDLocked(id)
|
|
removed++
|
|
}
|
|
if removed > 0 {
|
|
sm.saveLocked()
|
|
}
|
|
return removed
|
|
}
|
|
|
|
func (sm *SessionManager) deleteByIDLocked(id string) {
|
|
delete(sm.sessions, id)
|
|
for userKey, ids := range sm.userSessions {
|
|
for i, sid := range ids {
|
|
if sid == id {
|
|
sm.userSessions[userKey] = append(ids[:i], ids[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if sm.activeSession[userKey] == id {
|
|
delete(sm.activeSession, userKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save persists current state to disk. Safe to call from outside (e.g. after message processing).
|
|
func (sm *SessionManager) Save() {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
sm.saveLocked()
|
|
}
|
|
|
|
func (sm *SessionManager) saveLocked() {
|
|
if sm.storePath == "" {
|
|
return
|
|
}
|
|
|
|
// Build a deep-copy snapshot to avoid racing with concurrent Session mutations.
|
|
snapSessions := make(map[string]*Session, len(sm.sessions))
|
|
for id, s := range sm.sessions {
|
|
s.mu.Lock()
|
|
agentSID := s.AgentSessionID
|
|
if agentSID == ContinueSession {
|
|
agentSID = ""
|
|
s.AgentSessionID = ""
|
|
}
|
|
snapSessions[id] = &Session{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
AgentSessionID: agentSID,
|
|
AgentType: s.AgentType,
|
|
PastAgentSessionIDs: append([]string(nil), s.PastAgentSessionIDs...),
|
|
History: append([]HistoryEntry(nil), s.History...),
|
|
CreatedAt: s.CreatedAt,
|
|
UpdatedAt: s.UpdatedAt,
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Auto-clear legacyData once every session has at least one tracked ID.
|
|
if sm.legacyData {
|
|
allTracked := true
|
|
for _, s := range snapSessions {
|
|
if s.AgentSessionID == "" && len(s.PastAgentSessionIDs) == 0 {
|
|
allTracked = false
|
|
break
|
|
}
|
|
}
|
|
if allTracked {
|
|
sm.legacyData = false
|
|
slog.Info("session: legacy data migration complete, filtering re-enabled")
|
|
}
|
|
}
|
|
|
|
snap := sessionSnapshot{
|
|
Sessions: snapSessions,
|
|
ActiveSession: sm.activeSession,
|
|
UserSessions: sm.userSessions,
|
|
Counter: sm.counter,
|
|
SessionNames: sm.sessionNames,
|
|
UserMeta: sm.userMeta,
|
|
PastIDTracking: true,
|
|
LegacyData: sm.legacyData,
|
|
Version: snapshotVersion,
|
|
}
|
|
data, err := json.MarshalIndent(snap, "", " ")
|
|
if err != nil {
|
|
slog.Error("session: failed to marshal", "error", err)
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(sm.storePath), 0o755); err != nil {
|
|
slog.Error("session: failed to create dir", "error", err)
|
|
return
|
|
}
|
|
if err := AtomicWriteFile(sm.storePath, data, 0o644); err != nil {
|
|
slog.Error("session: failed to write", "path", sm.storePath, "error", err)
|
|
}
|
|
}
|
|
|
|
func (sm *SessionManager) load() {
|
|
data, err := os.ReadFile(sm.storePath)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
slog.Error("session: failed to read", "path", sm.storePath, "error", err)
|
|
}
|
|
return
|
|
}
|
|
var snap sessionSnapshot
|
|
if err := json.Unmarshal(data, &snap); err != nil {
|
|
slog.Error("session: failed to unmarshal", "path", sm.storePath, "error", err)
|
|
return
|
|
}
|
|
sm.sessions = snap.Sessions
|
|
sm.activeSession = snap.ActiveSession
|
|
sm.userSessions = snap.UserSessions
|
|
sm.sessionNames = snap.SessionNames
|
|
sm.userMeta = snap.UserMeta
|
|
sm.counter = snap.Counter
|
|
if snap.Version >= snapshotVersion {
|
|
sm.legacyData = snap.LegacyData
|
|
} else {
|
|
// Snapshot was written before LegacyData persistence existed.
|
|
sm.legacyData = !snap.PastIDTracking
|
|
if !sm.legacyData {
|
|
// PastIDTracking was set by a prior code version but LegacyData
|
|
// wasn't persisted. Check for sessions that lost their IDs before
|
|
// PastAgentSessionIDs tracking was available.
|
|
for _, s := range sm.sessions {
|
|
if s.AgentSessionID == "" && len(s.PastAgentSessionIDs) == 0 {
|
|
sm.legacyData = true
|
|
slog.Info("session: detected untracked sessions from prior data loss, enabling legacy mode")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if sm.sessions == nil {
|
|
sm.sessions = make(map[string]*Session)
|
|
}
|
|
if sm.activeSession == nil {
|
|
sm.activeSession = make(map[string]string)
|
|
}
|
|
if sm.userSessions == nil {
|
|
sm.userSessions = make(map[string][]string)
|
|
}
|
|
if sm.sessionNames == nil {
|
|
sm.sessionNames = make(map[string]string)
|
|
}
|
|
if sm.userMeta == nil {
|
|
sm.userMeta = make(map[string]*UserMeta)
|
|
}
|
|
|
|
for _, s := range sm.sessions {
|
|
s.stripContinueSessionSentinel()
|
|
}
|
|
|
|
slog.Info("session: loaded from disk", "path", sm.storePath, "sessions", len(sm.sessions))
|
|
}
|
|
|
|
// InvalidateForAgent clears AgentSessionID on all sessions whose AgentType
|
|
// does not match the current agent. This handles the case where the user
|
|
// switches agent types (e.g. opencode → pi) and stale session IDs from the
|
|
// old agent would cause errors.
|
|
func (sm *SessionManager) InvalidateForAgent(agentType string) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
invalidated := 0
|
|
for _, s := range sm.sessions {
|
|
s.mu.Lock()
|
|
if s.AgentSessionID != "" && s.AgentType != "" && s.AgentType != agentType {
|
|
slog.Info("session: invalidating stale agent session",
|
|
"session", s.ID,
|
|
"old_agent", s.AgentType,
|
|
"new_agent", agentType,
|
|
"old_agent_session_id", s.AgentSessionID,
|
|
)
|
|
s.recordPastAgentSessionID()
|
|
s.AgentSessionID = ""
|
|
s.AgentType = agentType
|
|
invalidated++
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
if invalidated > 0 {
|
|
sm.saveLocked()
|
|
}
|
|
}
|