package session import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" "ollie/agent" olog "ollie/log" "ollie/paths" "ollie/toolsrv" ) // Session owns all session-level state: identity, agents, tool server lifecycle. type Session struct { // Identity (immutable after creation) ID string // UUID Uname string // user principal (numeric UID) // Context lifecycle Ctx context.Context Cancel context.CancelFunc // Tool server lifecycle (set during setup/teardown, not raced) Proc *toolsrv.Process Keeper *toolsrv.ProcessKeeper Remote string // Agent state agents []*agent.Agent log *olog.Logger // Mutable state protected by mu mu sync.RWMutex name string // friendly name toolsConn *toolsrv.Conn // multiplexed RPC connection paused bool // Autosave saveMu sync.Mutex saveDirty bool saveTimer *time.Timer } var sweepTmpOnce sync.Once func ollieTmpDir() string { return filepath.Join(paths.DataDir(), "tmp") } func sweepStaleTmpDirs() { sweepTmpOnce.Do(func() { base := ollieTmpDir() os.RemoveAll(base) //nolint:errcheck os.MkdirAll(base, 0700) //nolint:errcheck }) } // --- Mutable field accessors (only for fields that race) --- // Name returns the mutable friendly name. func (s *Session) Name() string { s.mu.RLock() defer s.mu.RUnlock() return s.name } // SetName sets the friendly name. func (s *Session) SetName(name string) { s.mu.Lock() defer s.mu.Unlock() s.name = name } // ToolsConn returns the tool server connection. func (s *Session) ToolsConn() *toolsrv.Conn { s.mu.RLock() defer s.mu.RUnlock() return s.toolsConn } // SetToolsConn sets the tool server connection. func (s *Session) SetToolsConn(conn *toolsrv.Conn) { s.mu.Lock() defer s.mu.Unlock() s.toolsConn = conn } // IsPaused returns true if the session is paused. func (s *Session) IsPaused() bool { s.mu.RLock() defer s.mu.RUnlock() return s.paused } // FindAgent returns the agent matching the given name or ID, or nil. func (s *Session) FindAgent(nameOrID string) *agent.Agent { for _, ag := range s.agents { if ag.Name() == nameOrID || ag.ID() == nameOrID { return ag } } return nil } // Agents returns the full agent slice. func (s *Session) Agents() []*agent.Agent { return s.agents } // AddAgent appends an agent to the session. func (s *Session) AddAgent(ag *agent.Agent) { s.agents = append(s.agents, ag) ag.SetSessionEnv(s.ID) } // RemoveAgent removes an agent by ID and closes it. func (s *Session) RemoveAgent(id string) bool { idx := -1 for i, ag := range s.agents { if ag.ID() == id { idx = i break } } if idx < 0 { return false } s.agents[idx].Close() s.agents = append(s.agents[:idx], s.agents[idx+1:]...) return true } // Close releases resources for this session. func (s *Session) Close() { s.log.Debug("Close() session=%q", s.ID) s.flushSave() for _, ag := range s.agents { ag.Close() } if s.ID != "" { os.RemoveAll(filepath.Join(ollieTmpDir(), s.ID)) //nolint:errcheck } } // SetEnv stores a session-scoped variable and propagates to all agents. func (s *Session) SetEnv(key, value string) { for _, ag := range s.agents { ag.SetEnv(key, value) } } // SetSessionID renames the session. func (s *Session) SetSessionID(newID string) error { oldID := s.ID if oldID == newID { return nil } if pkgSessionsDir != "" && oldID != "" { for _, suffix := range []string{".json", ".compaction.jsonl"} { oldPath := s.activeSessionPath(oldID, suffix) if _, err := os.Stat(oldPath); err == nil { if err := os.Rename(oldPath, s.activeSessionPath(newID, suffix)); err != nil { return fmt.Errorf("rename %s: %w", suffix, err) } } } } s.ID = newID oldTemp := filepath.Join(ollieTmpDir(), oldID) newTemp := filepath.Join(ollieTmpDir(), newID) if _, err := os.Stat(oldTemp); err == nil { os.Rename(oldTemp, newTemp) //nolint:errcheck } for _, ag := range s.agents { ag.SetSessionEnv(newID) } return nil } // IsConnected returns true if the tool server connection is alive. func (s *Session) IsConnected() bool { s.mu.RLock() paused := s.paused conn := s.toolsConn s.mu.RUnlock() if paused || conn == nil { return false } return conn.Ping() == nil } // Pause stops the tool server to save resources. func (s *Session) Pause() error { s.mu.Lock() defer s.mu.Unlock() if s.paused { return fmt.Errorf("session already paused") } if s.Cancel != nil { s.Cancel() } if s.Keeper != nil { s.Keeper.Close() } else if s.Proc != nil { s.Proc.Close() } if s.toolsConn != nil { s.toolsConn.Close() s.toolsConn = nil } s.paused = true go s.saveSession() return nil } // Resume restarts the tool server after a Pause. func (s *Session) Resume() error { s.mu.Lock() defer s.mu.Unlock() if !s.paused { return fmt.Errorf("session not paused") } ctx, cancel := context.WithCancel(serverCtx) s.Ctx = ctx s.Cancel = cancel if s.Keeper != nil { s.Keeper.SetContext(ctx) conn, err := s.Keeper.Dial() if err != nil { return fmt.Errorf("resume failed: %w", err) } s.toolsConn = conn } else { // Session was restored paused without infra — set up per-agent. for i, ag := range s.agents { cwd := ag.Cwd() if cwd == "" { cwd, _ = os.Getwd() } var reuseFrom *InfraConfig if s.Proc != nil { reuseFrom = &InfraConfig{ Proc: s.Proc, Keeper: s.Keeper, ToolsConn: s.toolsConn, } } infra, err := SetupToolServer(ToolServerConfig{ Ctx: ctx, CWD: cwd, RemoteTarget: s.Remote, Yolo: pkgYolo, ReuseFrom: reuseFrom, }) if err != nil { return fmt.Errorf("resume setup for agent %s failed: %w", ag.ID(), err) } if i == 0 { s.Proc = infra.Proc s.Keeper = infra.Keeper s.toolsConn = infra.ToolsConn } ag.SetToolServer(infra.NewToolServer, infra.ToolsConn) } } s.paused = false go s.saveSession() return nil } // --- Tool loading --- // LoadTool loads a tool into the tool server. func (s *Session) LoadTool(name string, ag *agent.Agent) error { name = strings.TrimSpace(name) if name == "" { return nil } return LoadToolOnConn(s.ToolsConn(), name) } // LoadToolOnConn loads a tool on the given connection. func LoadToolOnConn(conn *toolsrv.Conn, name string) error { if conn == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() args, _ := json.Marshal(map[string]string{"name": name}) if _, err := conn.CallTool(ctx, "tool_load", json.RawMessage(args)); err != nil { return fmt.Errorf("remote load: %w", err) } return nil } // --- Autosave --- func (s *Session) activeSessionPath(id, suffix string) string { return filepath.Join(pkgSessionsDir, "active", id+suffix) } func (s *Session) saveSession() { s.saveMu.Lock() s.saveDirty = true if s.saveTimer == nil { s.saveTimer = time.AfterFunc(2*time.Second, s.flushSave) } s.saveMu.Unlock() } func (s *Session) flushSave() { s.saveMu.Lock() dirty := s.saveDirty s.saveDirty = false if s.saveTimer != nil { s.saveTimer.Stop() s.saveTimer = nil } s.saveMu.Unlock() if !dirty || s.ID == "" || pkgSessionsDir == "" { return } if err := PersistSession(s.Name()); err != nil { s.log.Error("session save: %v", err) } } // Save writes the current session state via PersistSession. func (s *Session) Save() error { return PersistSession(s.Name()) } // --- Constructor for empty session --- // NewEmpty creates an empty session without an agent. func NewEmpty(id string, ctx context.Context, cancel context.CancelFunc) *Session { name := id if idx := strings.IndexByte(id, '-'); idx > 0 { name = id[:idx] } log := pkgSink.NewLogger("session") return &Session{ ID: id, name: name, Ctx: ctx, Cancel: cancel, agents: make([]*agent.Agent, 0), log: log, } }