diff --git a/api/routes.py b/api/routes.py index efae299c..b28bd2f4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6967,7 +6967,7 @@ def _handle_chat_start(handler, body, diag=None): attachments = _normalize_chat_attachments(body.get("attachments") or [])[:20] diag.stage("resolve_workspace") if diag else None try: - workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) + workspace = _resolve_chat_workspace_with_recovery(s, body.get("workspace")) except ValueError as e: return bad(handler, str(e)) requested_model = body.get("model") or s.model @@ -7000,6 +7000,24 @@ def _handle_chat_start(handler, body, diag=None): +def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: + """Recover stale implicit session workspaces without hiding explicit errors.""" + explicit = requested_workspace not in (None, "") + candidate = requested_workspace if explicit else getattr(s, "workspace", None) + try: + return str(resolve_trusted_workspace(candidate)) + except ValueError: + if explicit: + raise + fallback = str(resolve_trusted_workspace(get_last_workspace())) + s.workspace = fallback + try: + s.save() + except Exception: + pass + return fallback + + def _normalize_chat_attachments(raw_attachments): """Normalize attachment payloads from the browser. diff --git a/api/workspace.py b/api/workspace.py index 5ec8ec9e..97c768f6 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -64,7 +64,7 @@ def _profile_default_workspace() -> str: 2. 'default_workspace' — alternate explicit key 3. 'terminal.cwd' — hermes-agent terminal working dir (most common) - Falls back to the boot-time DEFAULT_WORKSPACE constant. + Falls back to the live DEFAULT_WORKSPACE from api.config. """ try: from api.config import get_config @@ -86,7 +86,12 @@ def _profile_default_workspace() -> str: return str(p) except (ImportError, Exception): logger.debug("Failed to load profile default workspace config") - return str(_BOOT_DEFAULT_WORKSPACE) + try: + from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE + + return str(Path(_LIVE_DEFAULT_WORKSPACE).expanduser().resolve()) + except Exception: + return str(Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()) # ── Public API ────────────────────────────────────────────────────────────── diff --git a/tests/test_workspace_stale_recovery.py b/tests/test_workspace_stale_recovery.py new file mode 100644 index 00000000..31141525 --- /dev/null +++ b/tests/test_workspace_stale_recovery.py @@ -0,0 +1,71 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from api import config as api_config +from api import routes, workspace + + +def test_profile_default_workspace_uses_live_config_default(monkeypatch, tmp_path): + live_default = tmp_path / "live-default" + live_default.mkdir() + + monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", live_default) + monkeypatch.setattr(api_config, "get_config", lambda: {}) + + assert workspace._profile_default_workspace() == str(live_default.resolve()) + + +def test_resolve_chat_workspace_with_recovery_repairs_missing_implicit_workspace(monkeypatch, tmp_path): + fallback = tmp_path / "fallback" + fallback.mkdir() + stale = tmp_path / "deleted-workspace" + + def fake_resolve(value): + if value == str(stale): + raise ValueError(f"Path does not exist: {stale}") + return Path(value).resolve() + + saved = {"count": 0} + + def fake_save(): + saved["count"] += 1 + + session = SimpleNamespace(session_id="sess-1", workspace=str(stale), save=fake_save) + + monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) + monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) + + resolved = routes._resolve_chat_workspace_with_recovery(session, None) + + assert resolved == str(fallback.resolve()) + assert session.workspace == str(fallback.resolve()) + assert saved["count"] == 1 + + +def test_resolve_chat_workspace_with_recovery_preserves_explicit_errors(monkeypatch, tmp_path): + fallback = tmp_path / "fallback" + fallback.mkdir() + stale = tmp_path / "deleted-workspace" + + def fake_resolve(value): + if value == str(stale): + raise ValueError(f"Path does not exist: {stale}") + return Path(value).resolve() + + saved = {"count": 0} + + def fake_save(): + saved["count"] += 1 + + session = SimpleNamespace(session_id="sess-2", workspace=str(fallback), save=fake_save) + + monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) + monkeypatch.setattr(routes, "get_last_workspace", lambda: str(fallback)) + + with pytest.raises(ValueError, match="Path does not exist"): + routes._resolve_chat_workspace_with_recovery(session, str(stale)) + + assert session.workspace == str(fallback) + assert saved["count"] == 0