session persistence: save/restore active sessions across restarts

On shutdown (SIGTERM/SIGINT), all active sessions are persisted to
~/.local/share/ollie/active-sessions/ as JSON files containing the
full message context, backend, model, agent, and cwd metadata.

On startup, persisted sessions are automatically restored with their
complete context window, then the persist files are cleaned up.
Copilot sessions are excluded from persistence (ephemeral).
This commit is contained in:
Levi Neely 2026-07-14 20:42:08 +02:00
parent 76c4ddcdd9
commit bb2ced28d8
1 changed files with 224 additions and 0 deletions

224
main.go
View File

@ -7,6 +7,7 @@ import (
"hash/crc32" "hash/crc32"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@ -524,6 +525,226 @@ func (m *SessionManager) sendNotification(title, body string) {
) )
} }
// --- Session Persistence ---
// activeSessionsDir returns the directory for persisting active sessions across restarts.
func activeSessionsDir() string {
return filepath.Join(paths.DataDir(), "active-sessions")
}
// saveAllSessions persists every active session to disk so they can be restored on restart.
func (m *SessionManager) saveAllSessions() {
dir := activeSessionsDir()
os.MkdirAll(dir, 0700)
// Remove stale files from previous run
entries, _ := os.ReadDir(dir)
for _, e := range entries {
os.Remove(filepath.Join(dir, e.Name()))
}
m.mu.RLock()
defer m.mu.RUnlock()
for id, sess := range m.sessions {
// Skip copilot sessions — they are ephemeral
if strings.HasSuffix(id, "-copilot") {
continue
}
path := filepath.Join(dir, id+".json")
if err := sess.core.SaveSession(path); err != nil {
fmt.Fprintf(os.Stderr, "persist session %s: %v\n", id, err)
}
}
}
// restoreAllSessions loads previously-persisted sessions and recreates them.
func (m *SessionManager) restoreAllSessions() {
dir := activeSessionsDir()
entries, err := os.ReadDir(dir)
if err != nil {
return // no saved sessions
}
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 {
fmt.Fprintf(os.Stderr, "restore session %s: %v\n", e.Name(), err)
continue
}
if err := m.restoreSession(ps); err != nil {
fmt.Fprintf(os.Stderr, "restore session %s: %v\n", ps.ID, err)
}
}
// Clean up persisted files after successful restore
for _, e := range entries {
os.Remove(filepath.Join(dir, e.Name()))
}
}
// restoreSession recreates a single session from persisted state.
func (m *SessionManager) restoreSession(ps *agent.PersistedSession) error {
cwd := ps.CWD
if cwd == "" {
cwd, _ = os.Getwd()
}
agentName := ps.Agent
if agentName == "" {
agentName = "default"
}
sessID := ps.ID
// Load agent config
agentsDir := paths.CfgDir() + "/agents"
var cfg *config.Config
cfgPath := agent.AgentConfigPath(agentsDir, agentName)
if f, err := os.Open(cfgPath); err == nil {
cfg, _ = config.Load(f)
f.Close()
}
// Resolve backend
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)
}
// Resolve model
modelName := ps.Model
if modelName == "" && cfg != nil && cfg.Model != "" {
modelName = cfg.Model
}
if modelName == "" {
modelName = os.Getenv("OLLIE_MODEL")
}
if modelName != "" {
be.SetModel(modelName)
}
// Sessions dir
sessionsDir := paths.DataDir() + "/sessions"
os.MkdirAll(sessionsDir, 0700)
// Build dispatcher + runtime
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": execute.Decl(cwd),
})
rt := agent.BuildRuntime(cfg, newDisp(), cwd, []string{"OLLIE_SESSION_ID=" + sessID})
// Restore session from persisted messages
restoredSession := agent.RestoreSession(ps.Messages)
if ps.TaskState != nil {
restoredSession.TaskState = ps.TaskState
}
// Create the agent core with restored session
core := agent.NewAgentCore(agent.AgentCoreConfig{
Backend: be,
AgentName: agentName,
AgentsDir: agentsDir,
SessionsDir: sessionsDir,
SessionID: sessID,
CWD: cwd,
Session: restoredSession,
Runtime: rt,
NewDispatcher: newDisp,
})
ctx, cancel := context.WithCancel(context.Background())
sess := &managedSession{
core: core,
ctx: ctx,
cancel: cancel,
id: sessID,
agent: agentName,
}
// Subscribe to events for the chat log + signal dispatch
streamingRole := ""
core.Bus().Subscribe("event", func(ev agent.Event) {
var text string
switch ev.Role {
case "assistant", "reasoning":
if streamingRole != ev.Role {
if streamingRole != "" {
text += "\n\n"
}
text += "[" + ev.Role + "]\n"
streamingRole = ev.Role
}
text += ev.Content
default:
if ev.Role == "usage" {
return
}
if streamingRole != "" {
text += "\n\n"
streamingRole = ""
}
switch ev.Role {
case "user":
text += "[user]\n" + ev.Content + "\n\n"
case "call":
text += "[call:" + ev.Name + "]\n" + ev.Content + "\n\n"
case "tool":
text += "[tool:" + ev.Name + "]\n" + ev.Content + "\n\n"
case "error":
text += "[error]\n" + ev.Content + "\n\n"
case "info":
text += "[info] " + ev.Content + "\n\n"
default:
text += "[" + ev.Role + "]\n" + ev.Content + "\n\n"
}
}
if text == "" {
return
}
sess.logMu.Lock()
offset := int64(len(sess.log))
sess.log = append(sess.log, []byte(text)...)
sess.logMu.Unlock()
m.conn.Emit(busPath, busIface+".ChatUpdated", sess.id, offset, text)
})
// Watch state changes
go func() {
current := "idle"
for {
next, ok := core.WaitChange(ctx, agent.WatchState, current)
if !ok {
return
}
current = next
m.conn.Emit(busPath, busIface+".StateChanged", sess.id, current)
if current == "idle" {
m.sendNotification("Ollie", "Session "+sess.id+" finished")
}
}
}()
m.mu.Lock()
m.sessions[sessID] = sess
m.mu.Unlock()
m.conn.Emit(busPath, busIface+".SessionCreated", sessID)
fmt.Printf(" restored session: %s [%s/%s]\n", sessID, agentName, modelName)
return nil
}
// --- Shutdown --- // --- Shutdown ---
// --- Completion --- // --- Completion ---
@ -727,6 +948,8 @@ func crc32Str(s string) uint32 {
} }
func (m *SessionManager) Shutdown() { func (m *SessionManager) Shutdown() {
m.saveAllSessions()
m.mu.Lock() m.mu.Lock()
for id, sess := range m.sessions { for id, sess := range m.sessions {
sess.core.Interrupt(agent.ErrInterrupted) sess.core.Interrupt(agent.ErrInterrupted)
@ -877,6 +1100,7 @@ func main() {
} }
mgr := NewSessionManager(conn) mgr := NewSessionManager(conn)
mgr.restoreAllSessions()
conn.Export(mgr, busPath, busIface) conn.Export(mgr, busPath, busIface)
conn.Export(introspect.Introspectable(introspectXML), busPath, conn.Export(introspect.Introspectable(introspectXML), busPath,