9p: add session persistence across restarts

Sessions are saved to ~/.local/share/ollie/sessions/active-9p/ on
shutdown and restored on startup. Core auto-saves to SessionsDir
during turns. KillSession removes persisted files.

Closes the major feature gap with the D-Bus daemon.
This commit is contained in:
Levi Neely 2026-07-18 15:12:11 +02:00
parent 4ff3961dfd
commit b13f42778a
1 changed files with 159 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@ -216,6 +217,7 @@ func NewManager(cfg ManagerConfig) *Manager {
sessions: make(map[string]*Session),
}
ss.nextUID.Store(9999)
ss.restoreAllSessions()
ss.tree = fs.NewTree(nil, 0,
fs.WithStat(func(_ []string, name string) (os.FileInfo, error) { return ss.stat(name) }),
fs.WithOpener(func(_ []string, name string) (fs.File, error) { return ss.openEntry(name) }),
@ -636,7 +638,163 @@ func (s *Manager) InterruptAll() {
}
// Shutdown kills all active sessions.
// --- Session Persistence ---
func (s *Manager) activeSessionsDir() string {
return filepath.Join(paths.DataDir(), "sessions", "active-9p")
}
func (s *Manager) persistSession(id string) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return
}
dir := s.activeSessionsDir()
os.MkdirAll(dir, 0700)
path := filepath.Join(dir, id+".json")
if err := sess.Core.SaveSession(path); err != nil {
s.cfg.Log.Error("persist session %s: %v", id, err)
}
}
func (s *Manager) removePersistedSession(id string) {
path := filepath.Join(s.activeSessionsDir(), id+".json")
os.Remove(path)
}
func (s *Manager) saveAllSessions() {
s.mu.RLock()
ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}
s.mu.RUnlock()
for _, id := range ids {
s.persistSession(id)
}
}
func (s *Manager) restoreAllSessions() {
dir := s.activeSessionsDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
ps, err := agent.LoadPersistedSession(path)
if err != nil {
s.cfg.Log.Error("restore session %s: %v", e.Name(), err)
continue
}
if err := s.restoreSession(ps); err != nil {
s.cfg.Log.Error("restore session %s: %v", ps.ID, err)
}
}
// Clean up persisted files after successful restore
for _, e := range entries {
os.Remove(filepath.Join(dir, e.Name()))
}
}
func (s *Manager) restoreSession(ps *agent.PersistedSession) error {
cwd := ps.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
agentName := ps.Agent
if agentName == "" {
agentName = "default"
}
sessID := ps.ID
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
backendName := ps.Backend
if backendName == "" && cfg != nil && cfg.Backend != "" {
backendName = cfg.Backend
}
be, err := backend.NewWithName(backendName)
if err != nil {
return fmt.Errorf("backend: %w", err)
}
modelName := ps.Model
if modelName == "" && cfg != nil && cfg.Model != "" {
modelName = cfg.Model
}
if modelName == "" {
modelName = os.Getenv("OLLIE_MODEL")
}
if modelName != "" {
be.SetModel(modelName)
}
uname := s.nextUname()
var execOpts []execute.Option
if !s.cfg.NoMount {
execOpts = append(execOpts, WithMount9P())
}
if s.cfg.Strict {
execOpts = append(execOpts, execute.WithStrict())
}
if s.cfg.Yolo {
execOpts = append(execOpts, execute.WithYolo())
}
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": execute.Decl(cwd, execOpts...),
})
rt := agent.BuildRuntime(cfg, newDisp(), cwd, []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + uname, "OLLIE=" + MountPathFor(uname)})
restoredSession := agent.RestoreSession(ps.Messages)
if ps.TaskState != nil {
restoredSession.TaskState = ps.TaskState
}
var sessPtr *Session
core := agent.NewAgentCore(agent.AgentCoreConfig{
Backend: be,
AgentName: agentName,
AgentsDir: s.cfg.AgentsDir,
SessionsDir: s.cfg.SessionsDir,
SessionID: sessID,
Uname: uname,
CWD: cwd,
Session: restoredSession,
Runtime: rt,
NewDispatcher: newDisp,
Log: s.cfg.Sink.NewLogger("core"),
ReadPlanStep: func() string {
if sessPtr == nil {
return ""
}
sessPtr.mu.RLock()
data := make([]byte, len(sessPtr.plan))
copy(data, sessPtr.plan)
sessPtr.mu.RUnlock()
return agent.NextUncheckedStep(data)
},
})
ctx, cancel := context.WithCancel(context.Background())
sess := NewSession(sessID, core, ctx, cancel)
sessPtr = sess
sess.uname = uname
s.mu.Lock()
s.sessions[sessID] = sess
s.mu.Unlock()
s.cfg.Log.Info("restored session %s (backend=%s model=%s agent=%s)", sessID, backendName, modelName, agentName)
return nil
}
func (s *Manager) Shutdown() {
s.saveAllSessions()
s.mu.Lock()
ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
@ -656,6 +814,7 @@ func (s *Manager) KillSession(id string) {
if sess != nil {
sess.Cancel()
sess.Core.Close()
s.removePersistedSession(id)
s.cfg.Log.Info("killed session %s", id)
}
}