mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(workspace): C2 cache refresh fetch for 30s per workspace path
Smoke run 3 fired 'ensure_workspace: refresh fetch returned non-zero' 9 times per run because each evidence(task_id) call triggered ensure_workspace -> fetch. The workspace doesn't change in subseconds. Added a 30s TTL cache keyed by workspace path. ensure_workspace(force=True) bypasses the cache for callers that genuinely need a fresh fetch. Net effect: log noise drops from 9 entries to 1-2 per run; orchestrator spends less time waiting on redundant git fetches. Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md section C2.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Wave C2 (2026-05-12): 30s TTL cache on ensure_workspace refresh fetch.
|
||||
|
||||
Smoke run 3 fired 'ensure_workspace: refresh fetch returned non-zero'
|
||||
9 times per run because each evidence(task_id) call triggered
|
||||
ensure_workspace, which fetches even when the workspace was just
|
||||
fetched seconds ago. The TTL prevents redundant work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.workspace import WorkspaceService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _service() -> WorkspaceService:
|
||||
"""Build a WorkspaceService over a MagicMock session."""
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock()
|
||||
return WorkspaceService(session)
|
||||
|
||||
|
||||
def _bind(svc: WorkspaceService, name: str, value: object) -> None:
|
||||
"""Stub `name` on `svc` without tripping mypy's method-assign check."""
|
||||
object.__setattr__(svc, name, value)
|
||||
|
||||
|
||||
def _fake_agent(slug: str = "be-pm") -> MagicMock:
|
||||
"""Build a MagicMock that satisfies the AgentTable surface used here."""
|
||||
agent = MagicMock()
|
||||
agent.id = uuid4()
|
||||
agent.slug = slug
|
||||
agent.team = None
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def healthy_workspace(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Materialize a directory that passes `_is_workspace_healthy`."""
|
||||
workspace = tmp_path / "roboco" / "backend" / "be-pm"
|
||||
git_dir = workspace / ".git"
|
||||
(git_dir / "objects").mkdir(parents=True)
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
yield workspace
|
||||
|
||||
|
||||
# Named constants to satisfy ruff PLR2004 (magic value in comparison).
|
||||
_FETCH_TTL_SECONDS = 30.0
|
||||
_EXPECTED_ONE_FETCH = 1
|
||||
_EXPECTED_TWO_FETCHES = 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_fetch_within_ttl_is_skipped(
|
||||
healthy_workspace: Path,
|
||||
) -> None:
|
||||
"""ensure_workspace within 30s of a successful fetch skips the second fetch."""
|
||||
svc = _service()
|
||||
agent = _fake_agent()
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))
|
||||
|
||||
fetch_call_count = 0
|
||||
|
||||
async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
|
||||
nonlocal fetch_call_count
|
||||
fetch_call_count += 1
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
WorkspaceService,
|
||||
"_fetch_origin_best_effort",
|
||||
side_effect=fake_fetch,
|
||||
),
|
||||
patch("roboco.services.workspace._ensure_agent_owned"),
|
||||
):
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
|
||||
assert fetch_call_count == _EXPECTED_ONE_FETCH, (
|
||||
f"second ensure_workspace within TTL should skip fetch; "
|
||||
f"got {fetch_call_count} fetches"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_after_ttl_runs_again(
|
||||
healthy_workspace: Path,
|
||||
) -> None:
|
||||
"""After 30s, the cache expires and the next ensure_workspace fetches again."""
|
||||
svc = _service()
|
||||
agent = _fake_agent()
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))
|
||||
|
||||
fetch_call_count = 0
|
||||
|
||||
async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
|
||||
nonlocal fetch_call_count
|
||||
fetch_call_count += 1
|
||||
|
||||
# Simulate time advancing past the TTL between the two calls.
|
||||
# _monotonic() is called twice per ensure_workspace invocation when a
|
||||
# fetch runs: once to read "now" and once to stamp the cache after the
|
||||
# fetch. Sequence:
|
||||
# call 1 (now, 1st ensure_workspace): 0.0 -> fetch runs
|
||||
# call 2 (stamp, 1st ensure_workspace): 0.0 -> cache set to 0.0
|
||||
# call 3 (now, 2nd ensure_workspace): 31.0 -> TTL expired -> fetch runs
|
||||
# call 4 (stamp, 2nd ensure_workspace): 31.0 -> cache set to 31.0
|
||||
monotonic_calls = [0.0, 0.0, _FETCH_TTL_SECONDS + 1, _FETCH_TTL_SECONDS + 1]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
WorkspaceService,
|
||||
"_fetch_origin_best_effort",
|
||||
side_effect=fake_fetch,
|
||||
),
|
||||
patch("roboco.services.workspace._ensure_agent_owned"),
|
||||
patch(
|
||||
"roboco.services.workspace._monotonic",
|
||||
side_effect=monotonic_calls,
|
||||
),
|
||||
):
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
|
||||
assert fetch_call_count == _EXPECTED_TWO_FETCHES, (
|
||||
f"after TTL expiry the second ensure_workspace should fetch again; "
|
||||
f"got {fetch_call_count} fetches"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_true_bypasses_cache(
|
||||
healthy_workspace: Path,
|
||||
) -> None:
|
||||
"""ensure_workspace(force=True) fetches even if the cache is fresh."""
|
||||
svc = _service()
|
||||
agent = _fake_agent()
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))
|
||||
|
||||
fetch_call_count = 0
|
||||
|
||||
async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
|
||||
nonlocal fetch_call_count
|
||||
fetch_call_count += 1
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
WorkspaceService,
|
||||
"_fetch_origin_best_effort",
|
||||
side_effect=fake_fetch,
|
||||
),
|
||||
patch("roboco.services.workspace._ensure_agent_owned"),
|
||||
):
|
||||
# First call populates the cache.
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
|
||||
# Second call with force=True must bypass the cache and fetch again.
|
||||
await svc.ensure_workspace(
|
||||
project_slug="roboco", agent_id=agent.id, force=True
|
||||
)
|
||||
|
||||
assert fetch_call_count == _EXPECTED_TWO_FETCHES, (
|
||||
f"force=True should bypass cache and fetch again; "
|
||||
f"got {fetch_call_count} fetches"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_workspaces_have_independent_caches(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The TTL cache is per workspace path — fetching workspace A does not
|
||||
suppress the fetch for workspace B even within 30s."""
|
||||
|
||||
def _make_healthy(slug: str) -> Path:
|
||||
workspace = tmp_path / "roboco" / "backend" / slug
|
||||
git_dir = workspace / ".git"
|
||||
(git_dir / "objects").mkdir(parents=True)
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
return workspace
|
||||
|
||||
workspace_a = _make_healthy("agent-a")
|
||||
workspace_b = _make_healthy("agent-b")
|
||||
|
||||
svc = _service()
|
||||
|
||||
fetched_paths: list[str] = []
|
||||
|
||||
async def fake_fetch(workspace: Path, _project_slug: str) -> None:
|
||||
fetched_paths.append(str(workspace))
|
||||
|
||||
agent_a = _fake_agent("agent-a")
|
||||
agent_b = _fake_agent("agent-b")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
WorkspaceService,
|
||||
"_fetch_origin_best_effort",
|
||||
side_effect=fake_fetch,
|
||||
),
|
||||
patch("roboco.services.workspace._ensure_agent_owned"),
|
||||
):
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent_a))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=workspace_a))
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent_a.id)
|
||||
|
||||
_bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent_b))
|
||||
_bind(svc, "get_workspace_path", MagicMock(return_value=workspace_b))
|
||||
await svc.ensure_workspace(project_slug="roboco", agent_id=agent_b.id)
|
||||
|
||||
assert len(fetched_paths) == _EXPECTED_TWO_FETCHES, (
|
||||
f"both workspaces should be fetched independently; "
|
||||
f"got {len(fetched_paths)} fetches: {fetched_paths}"
|
||||
)
|
||||
assert str(workspace_a) in fetched_paths
|
||||
assert str(workspace_b) in fetched_paths
|
||||
Reference in New Issue
Block a user