session persistence: SIGHUP, bus disconnect, checkpointing, chat log restore

- Add SIGHUP to signal handler for non-systemd session teardown
- Detect D-Bus session bus disconnection via conn.Context().Done()
- Checkpoint individual sessions to disk on every idle transition
- Remove checkpoint files when sessions are killed
- Rebuild visible chat log from last 8 messages on restore
This commit is contained in:
Levi Neely 2026-07-14 20:58:06 +02:00
parent bb2ced28d8
commit 99a8dbfe88
1 changed files with 86 additions and 2 deletions

88
main.go
View File

@ -197,6 +197,7 @@ func (m *SessionManager) CreateSession(cwd, backendName, modelName, agentName st
// Send notification when a turn completes // Send notification when a turn completes
if current == "idle" { if current == "idle" {
m.sendNotification("Ollie", "Session "+sess.id+" finished") m.sendNotification("Ollie", "Session "+sess.id+" finished")
m.persistSession(sess.id)
} }
} }
}() }()
@ -234,6 +235,7 @@ func (m *SessionManager) KillSession(sessionID string) (bool, *dbus.Error) {
sess.core.Interrupt(agent.ErrInterrupted) sess.core.Interrupt(agent.ErrInterrupted)
sess.core.Close() sess.core.Close()
sess.cancel() sess.cancel()
m.removePersistedSession(sessionID)
m.conn.Emit(busPath, busIface+".SessionKilled", sessionID) m.conn.Emit(busPath, busIface+".SessionKilled", sessionID)
return true, nil return true, nil
@ -532,6 +534,81 @@ func activeSessionsDir() string {
return filepath.Join(paths.DataDir(), "active-sessions") return filepath.Join(paths.DataDir(), "active-sessions")
} }
// persistSession saves a single session to the active-sessions directory.
// Called on every idle transition for crash resilience.
func (m *SessionManager) persistSession(id string) {
if strings.HasSuffix(id, "-copilot") {
return
}
dir := activeSessionsDir()
os.MkdirAll(dir, 0700)
m.mu.RLock()
sess, ok := m.sessions[id]
m.mu.RUnlock()
if !ok {
return
}
path := filepath.Join(dir, id+".json")
if err := sess.core.SaveSession(path); err != nil {
fmt.Fprintf(os.Stderr, "checkpoint session %s: %v\n", id, err)
}
}
// removePersistedSession deletes the checkpoint file for a killed session.
func (m *SessionManager) removePersistedSession(id string) {
path := filepath.Join(activeSessionsDir(), id+".json")
os.Remove(path)
}
// rebuildChatLog reconstructs a human-readable chat log from the tail of the
// persisted message list. This gives restored sessions visible history.
func rebuildChatLog(messages []backend.Message) []byte {
// Show up to the last hotTailSize messages (matches the verbatim context window).
const tailSize = 8
start := 0
if len(messages) > tailSize {
start = len(messages) - tailSize
}
tail := messages[start:]
var buf strings.Builder
if start > 0 {
buf.WriteString(fmt.Sprintf("[info] session restored (%d earlier messages compacted)\n\n", start))
}
for _, msg := range tail {
switch msg.Role {
case "system":
continue // don't show system messages in chat log
case "user":
buf.WriteString("[user]\n" + msg.Content + "\n\n")
case "assistant":
if msg.Reasoning != "" {
buf.WriteString("[reasoning]\n" + msg.Reasoning + "\n\n")
}
if msg.Content != "" {
buf.WriteString("[assistant]\n" + msg.Content + "\n\n")
}
for _, tc := range msg.ToolCalls {
buf.WriteString("[call:" + tc.Name + "]\n" + string(tc.Arguments) + "\n\n")
}
case "tool":
name := "result"
if msg.ToolCallID != "" {
name = msg.ToolCallID
}
// Truncate long tool results in the log
content := msg.Content
if len(content) > 2000 {
content = content[:2000] + "\n... (truncated)"
}
buf.WriteString("[tool:" + name + "]\n" + content + "\n\n")
}
}
return []byte(buf.String())
}
// saveAllSessions persists every active session to disk so they can be restored on restart. // saveAllSessions persists every active session to disk so they can be restored on restart.
func (m *SessionManager) saveAllSessions() { func (m *SessionManager) saveAllSessions() {
dir := activeSessionsDir() dir := activeSessionsDir()
@ -668,6 +745,7 @@ func (m *SessionManager) restoreSession(ps *agent.PersistedSession) error {
cancel: cancel, cancel: cancel,
id: sessID, id: sessID,
agent: agentName, agent: agentName,
log: rebuildChatLog(ps.Messages),
} }
// Subscribe to events for the chat log + signal dispatch // Subscribe to events for the chat log + signal dispatch
@ -732,6 +810,7 @@ func (m *SessionManager) restoreSession(ps *agent.PersistedSession) error {
if current == "idle" { if current == "idle" {
m.sendNotification("Ollie", "Session "+sess.id+" finished") m.sendNotification("Ollie", "Session "+sess.id+" finished")
m.persistSession(sess.id)
} }
} }
}() }()
@ -1110,8 +1189,13 @@ func main() {
// Wait for signal // Wait for signal
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
<-sigCh
// Shutdown on signal or D-Bus disconnection (session bus gone).
select {
case <-sigCh:
case <-conn.Context().Done():
}
fmt.Println("ollie-dbus daemon shutting down") fmt.Println("ollie-dbus daemon shutting down")
mgr.Shutdown() mgr.Shutdown()