session: pause all sessions on shutdown, fix agent config loading

Shutdown changes:
- Shutdown() now calls PauseAll() before closing sessions
- PauseAll() interrupts agents then pauses each session
- Sessions persist their state, allowing resume on next startup

Agent loading fix:
- LoadConfig returns (*AgentConfig, error) instead of *AgentConfig
- Parse errors are no longer silently discarded
- Callers handle nil config gracefully (no nil pointer dereferences)
This commit is contained in:
Levi Neely 2026-10-01 13:34:33 +02:00
parent c1888d31fe
commit f85612a4da
3 changed files with 49 additions and 12 deletions

View File

@ -17,16 +17,20 @@ func AgentConfigPath(agentsDir, name string) string {
} }
// LoadConfig loads an agent configuration file by profile name. // LoadConfig loads an agent configuration file by profile name.
func LoadConfig(agentsDir, name string, open func(string) (*os.File, error)) *AgentConfig { // Returns (nil, nil) if the file does not exist.
// Returns (nil, err) for parse errors.
func LoadConfig(agentsDir, name string, open func(string) (*os.File, error)) (*AgentConfig, error) {
if open == nil { if open == nil {
open = os.Open open = os.Open
} }
path := AgentConfigPath(agentsDir, name) path := AgentConfigPath(agentsDir, name)
f, err := open(path) f, err := open(path)
if err != nil { if err != nil {
return nil if os.IsNotExist(err) {
return nil, nil
}
return nil, err
} }
defer f.Close() defer f.Close()
cfg, _ := Load(f) return Load(f)
return cfg
} }

View File

@ -170,9 +170,10 @@ func Rename(oldName, newName string) error {
return nil return nil
} }
// Shutdown interrupts all sessions, waits for idle, and closes them. // Shutdown pauses all sessions (to allow later resume), waits for idle, then closes them.
func Shutdown() { func Shutdown() {
InterruptAll() // First, pause all sessions to persist their state.
PauseAll()
waitIdle(100*time.Millisecond, 5*time.Second) waitIdle(100*time.Millisecond, 5*time.Second)
mu.Lock() mu.Lock()
@ -188,6 +189,8 @@ func Shutdown() {
delete(sessions, name) delete(sessions, name)
mu.Unlock() mu.Unlock()
if sess != nil { if sess != nil {
// Session is already paused, so Cancel/Keeper/Proc should be nil.
// But close anything still open defensively.
if sess.Cancel != nil { if sess.Cancel != nil {
sess.Cancel() sess.Cancel()
} }
@ -202,6 +205,25 @@ func Shutdown() {
} }
} }
// PauseAll pauses every active session. Agents are interrupted first.
func PauseAll() {
InterruptAll()
mu.RLock()
var toProcess []*Session
for _, sess := range sessions {
if !sess.IsPaused() {
toProcess = append(toProcess, sess)
}
}
mu.RUnlock()
for _, sess := range toProcess {
if err := sess.Pause(); err != nil {
pkgLog.Warn("pause session %s: %v", sess.ID, err)
}
}
}
// InterruptAll interrupts every active session. // InterruptAll interrupts every active session.
func InterruptAll() { func InterruptAll() {
mu.RLock() mu.RLock()
@ -380,7 +402,10 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
return nil, fmt.Errorf("session cwd is required") return nil, fmt.Errorf("session cwd is required")
} }
cfg := agent.LoadConfig(pkgAgentsDir, p.Profile, nil) cfg, err := agent.LoadConfig(pkgAgentsDir, p.Profile, nil)
if err != nil {
return nil, fmt.Errorf("load agent config %q: %w", p.Profile, err)
}
sessID := sess.ID sessID := sess.ID
// Set up tool server — reuse existing session infrastructure // Set up tool server — reuse existing session infrastructure
@ -431,7 +456,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
// Create backend // Create backend
beName := p.Backend beName := p.Backend
if beName == "" { if beName == "" && cfg != nil {
beName = cfg.Backend beName = cfg.Backend
} }
be, err := backend.NewWithName(beName) be, err := backend.NewWithName(beName)
@ -442,7 +467,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
if p.Model != "" { if p.Model != "" {
rt.Backend.SetModel(p.Model) rt.Backend.SetModel(p.Model)
} else if cfg.Model != "" { } else if cfg != nil && cfg.Model != "" {
rt.Backend.SetModel(cfg.Model) rt.Backend.SetModel(cfg.Model)
} }

View File

@ -496,15 +496,23 @@ func (s *Session) Resume() error {
// rebuildAgentRuntime rebuilds the full runtime for an agent (used on resume). // rebuildAgentRuntime rebuilds the full runtime for an agent (used on resume).
func rebuildAgentRuntime(ag *agent.Agent, sessID string) error { func rebuildAgentRuntime(ag *agent.Agent, sessID string) error {
cfg := agent.LoadConfig(pkgAgentsDir, ag.Profile(), os.Open) cfg, err := agent.LoadConfig(pkgAgentsDir, ag.Profile(), os.Open)
if err != nil {
return fmt.Errorf("load agent config %q: %w", ag.Profile(), err)
}
layers := BuildPromptLayers(cfg, ag.Cwd(), sessID, ag.ID(), "linux", false, "") layers := BuildPromptLayers(cfg, ag.Cwd(), sessID, ag.ID(), "linux", false, "")
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + ag.ID()} env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + ag.ID()}
rt := agent.BuildRuntime(cfg, ag.ToolServer(), ag.Cwd(), env, layers.SystemPrompt, layers.EnvBlock) rt := agent.BuildRuntime(cfg, ag.ToolServer(), ag.Cwd(), env, layers.SystemPrompt, layers.EnvBlock)
be, err := backend.NewWithName(cfg.Backend)
var beName string
if cfg != nil {
beName = cfg.Backend
}
be, err := backend.NewWithName(beName)
if err != nil { if err != nil {
return fmt.Errorf("backend: %w", err) return fmt.Errorf("backend: %w", err)
} }
if cfg.Model != "" { if cfg != nil && cfg.Model != "" {
be.SetModel(cfg.Model) be.SetModel(cfg.Model)
} }
rt.Backend = be rt.Backend = be