Files
ConverSun bb37af823a fix: persist multi-workspace /dir overrides without cross-channel leakage (#505)
* fix: persist per-workspace dir overrides across agent recreation

In multi-workspace mode, /dir overrides were lost after daemon restart
or workspace pool eviction because getOrCreateWorkspaceAgent always
recreated agents with work_dir=workspace root, ignoring the override
stored in project state.

Changes:
- Add WorkspaceDirOverrides map to ProjectStateStore for per-workspace
  dir override persistence
- Apply persisted override when recreating workspace agents in
  getOrCreateWorkspaceAgent
- Store workspace-specific overrides in dirApply when in multi-workspace
  mode (set and reset)
- Auto-clear invalid overrides (missing/non-directory paths)

Backward compatible: existing work_dir_override field preserved for
single-workspace mode.

* fix: use per-channel dir overrides instead of per-workspace

Two channels bound to the same workspace were sharing /dir overrides.
Change the override key from workspace path to interactiveKey
(workspace:sessionKey) so each channel maintains independent dir state.

- Remove override from getOrCreateWorkspaceAgent (shared agent layer)
- Add applyChannelDirOverride helper, called in handleMessage and
  commandContext (per-channel routing layer)
- dirApply now stores/clears overrides keyed by interactiveKey

* refactor: centralize multi-workspace context resolution

Use a shared workspaceContext helper so commandContext and message
routing build the same workspace agent/session/interactiveKey state in
one place. This keeps the per-channel dir override fix unchanged while
removing duplicate override application logic.

* fix: route multi-workspace dir overrides by effective work dir

Avoid mutating shared workspace agents when applying per-channel /dir
overrides. Resolve the effective work dir from project state and route
each channel to an agent context keyed by that dir instead.

This removes cross-channel work_dir races while preserving existing
/dir persistence and reset behavior.
2026-04-09 18:55:30 +08:00

118 lines
2.8 KiB
Go

package core
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"sync"
)
type projectStateData struct {
WorkDirOverride string `json:"work_dir_override,omitempty"`
WorkspaceDirOverrides map[string]string `json:"workspace_dir_overrides,omitempty"`
}
// ProjectStateStore persists lightweight runtime state for one project.
type ProjectStateStore struct {
mu sync.RWMutex
storePath string
state projectStateData
}
func NewProjectStateStore(path string) *ProjectStateStore {
ps := &ProjectStateStore{storePath: path}
if path != "" {
ps.load()
}
return ps
}
func (ps *ProjectStateStore) WorkDirOverride() string {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.state.WorkDirOverride
}
func (ps *ProjectStateStore) SetWorkDirOverride(dir string) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.state.WorkDirOverride = dir
}
func (ps *ProjectStateStore) WorkspaceDirOverride(workspace string) string {
ps.mu.RLock()
defer ps.mu.RUnlock()
if ps.state.WorkspaceDirOverrides == nil {
return ""
}
return ps.state.WorkspaceDirOverrides[workspace]
}
func (ps *ProjectStateStore) SetWorkspaceDirOverride(workspace, dir string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.state.WorkspaceDirOverrides == nil {
ps.state.WorkspaceDirOverrides = make(map[string]string)
}
ps.state.WorkspaceDirOverrides[workspace] = dir
}
func (ps *ProjectStateStore) ClearWorkspaceDirOverride(workspace string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.state.WorkspaceDirOverrides == nil {
return
}
delete(ps.state.WorkspaceDirOverrides, workspace)
if len(ps.state.WorkspaceDirOverrides) == 0 {
ps.state.WorkspaceDirOverrides = nil
}
}
func (ps *ProjectStateStore) ClearWorkDirOverride() {
ps.SetWorkDirOverride("")
}
func (ps *ProjectStateStore) Save() {
ps.mu.RLock()
defer ps.mu.RUnlock()
ps.saveLocked()
}
func (ps *ProjectStateStore) saveLocked() {
if ps.storePath == "" {
return
}
data, err := json.MarshalIndent(ps.state, "", " ")
if err != nil {
slog.Error("project_state: failed to marshal", "error", err)
return
}
if err := os.MkdirAll(filepath.Dir(ps.storePath), 0o755); err != nil {
slog.Error("project_state: failed to create dir", "path", ps.storePath, "error", err)
return
}
if err := AtomicWriteFile(ps.storePath, data, 0o644); err != nil {
slog.Error("project_state: failed to write", "path", ps.storePath, "error", err)
}
}
func (ps *ProjectStateStore) load() {
data, err := os.ReadFile(ps.storePath)
if err != nil {
if !os.IsNotExist(err) {
slog.Error("project_state: failed to read", "path", ps.storePath, "error", err)
}
return
}
var state projectStateData
if err := json.Unmarshal(data, &state); err != nil {
slog.Error("project_state: failed to unmarshal", "path", ps.storePath, "error", err)
return
}
ps.state = state
}