package core import ( "log/slog" "path/filepath" "sync" "time" ) // normalizeWorkspacePath cleans and resolves a workspace path to prevent // mismatches caused by trailing slashes, symlinks, or relative segments. // If the path cannot be resolved (e.g. doesn't exist yet), falls back to // filepath.Clean only. func normalizeWorkspacePath(path string) string { cleaned := filepath.Clean(path) resolved, err := filepath.EvalSymlinks(cleaned) if err != nil { return cleaned } if resolved != path { slog.Debug("workspace path normalized", "original", path, "normalized", resolved) } return resolved } // workspaceState holds the runtime state for a single workspace. type workspaceState struct { mu sync.Mutex workspace string sessions *SessionManager agent Agent lastActivity time.Time activeTurns int } func newWorkspaceState(workspace string) *workspaceState { return &workspaceState{ workspace: workspace, lastActivity: time.Now(), } } func (ws *workspaceState) Touch() { ws.mu.Lock() ws.lastActivity = time.Now() ws.mu.Unlock() } func (ws *workspaceState) BeginTurn() { ws.mu.Lock() ws.activeTurns++ ws.lastActivity = time.Now() ws.mu.Unlock() } func (ws *workspaceState) EndTurn() { ws.mu.Lock() if ws.activeTurns > 0 { ws.activeTurns-- } ws.lastActivity = time.Now() ws.mu.Unlock() } func (ws *workspaceState) HasActiveTurn() bool { ws.mu.Lock() defer ws.mu.Unlock() return ws.activeTurns > 0 } func (ws *workspaceState) LastActivity() time.Time { ws.mu.Lock() defer ws.mu.Unlock() return ws.lastActivity } // workspacePool manages a set of workspace states with idle reaping. type workspacePool struct { mu sync.RWMutex states map[string]*workspaceState // workspace path -> state idleTimeout time.Duration } func newWorkspacePool(idleTimeout time.Duration) *workspacePool { return &workspacePool{ states: make(map[string]*workspaceState), idleTimeout: idleTimeout, } } // Get returns the state for a workspace. func (p *workspacePool) Get(workspace string) *workspaceState { p.mu.RLock() state := p.states[workspace] p.mu.RUnlock() if state != nil { return state } normalized := normalizeWorkspacePath(workspace) if normalized == workspace { return nil } p.mu.RLock() defer p.mu.RUnlock() return p.states[normalized] } // GetOrCreate returns or creates state for a workspace. func (p *workspacePool) GetOrCreate(workspace string) *workspaceState { p.mu.Lock() if s, ok := p.states[workspace]; ok { p.mu.Unlock() return s } p.mu.Unlock() normalized := normalizeWorkspacePath(workspace) p.mu.Lock() defer p.mu.Unlock() if s, ok := p.states[workspace]; ok { return s } if normalized != workspace { if s, ok := p.states[normalized]; ok { return s } workspace = normalized } s := newWorkspaceState(workspace) p.states[workspace] = s return s } // ReapIdle removes and returns workspace paths that have been idle longer than idleTimeout. // A zero idleTimeout disables reaping entirely. func (p *workspacePool) ReapIdle() []string { p.mu.Lock() defer p.mu.Unlock() if p.idleTimeout <= 0 { return nil } cutoff := time.Now().Add(-p.idleTimeout) var reaped []string for path, state := range p.states { if state.HasActiveTurn() { continue } if state.LastActivity().Before(cutoff) { reaped = append(reaped, path) delete(p.states, path) } } return reaped } func (p *workspacePool) All() map[string]*workspaceState { p.mu.RLock() defer p.mu.RUnlock() result := make(map[string]*workspaceState, len(p.states)) for k, v := range p.states { result[k] = v } return result }