Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions packages/tapps-mcp/src/tapps_mcp/server_linear_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

import structlog

from tapps_core.cache import AtomicJsonCache, register_cache_stats
from tapps_core.cache import AtomicJsonCache, TTLStaleness, register_cache_stats
from tapps_core.config.settings import load_settings
from tapps_mcp.mcp_register import register_tool
from tapps_mcp.server_helpers import error_response, success_response
Expand Down Expand Up @@ -146,7 +146,33 @@ def _ttl_for_state(state: str | None, ttl_open: int, ttl_closed: int) -> int:

# ADR-0029 / TAP-4561: unified cache-stats counters (snapshot reads/writes).
_snapshot_stats: dict[str, int] = {"hits": 0, "misses": 0, "writes": 0}
register_cache_stats("linear_snapshot", lambda: dict(_snapshot_stats))
# TAP-4558: wall-clock timestamp of the most recent snapshot write (0 == never).
_snapshot_last_write_ts: float = 0.0


def _linear_snapshot_stats() -> dict[str, Any]:
"""Stats provider: counters + staleness/age of the freshest write (TAP-4558).

``age_seconds`` is the age of the most-recently written snapshot (``None``
until the first write); ``stale`` reports whether that freshest write has
already aged past the open-bucket TTL — the conservative (shorter) bound, so
a freshest snapshot older than it is definitively stale. This gives the
unified ``tapps_stats.caches`` surface the same age/staleness signal the
code-graph cache already exposes.
"""
out: dict[str, Any] = dict(_snapshot_stats)
if _snapshot_last_write_ts <= 0:
out["age_seconds"] = None
out["stale"] = None
return out
ttl_open = load_settings().linear_cache_ttl_open_seconds
age = time.time() - _snapshot_last_write_ts
out["age_seconds"] = round(age, 1)
out["stale"] = TTLStaleness(float(ttl_open)).is_stale(_snapshot_last_write_ts)
Comment on lines +168 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use each snapshot's actual expiry for Linear staleness

When the freshest Linear cache write is for a completed or canceled slice, tapps_linear_snapshot_put stores it with linear_cache_ttl_closed_seconds (default 3600s), but this stats provider always evaluates staleness against linear_cache_ttl_open_seconds (default 300s). After five minutes tapps_stats.caches.linear_snapshot.stale reports true even though reads for that snapshot remain valid for another 55 minutes, so cache health telemetry is falsely stale for closed-issue workflows; base this on the written payload's expires_at/TTL or the matching state bucket instead.

Useful? React with 👍 / 👎.

return out


register_cache_stats("linear_snapshot", _linear_snapshot_stats)


def _cache_read(cache_dir: Path, cache_key: str) -> dict[str, Any] | None:
Expand Down Expand Up @@ -179,6 +205,8 @@ def _cache_write(
# indent=None keeps the compact json.dumps byte layout from before.
AtomicJsonCache.write_json(cache_file, payload, indent=None)
_snapshot_stats["writes"] += 1
global _snapshot_last_write_ts
_snapshot_last_write_ts = time.time()
except OSError as exc:
logger.debug("linear_cache_write_failed", key=cache_key, exc=str(exc))

Expand Down
24 changes: 23 additions & 1 deletion packages/tapps-mcp/src/tapps_mcp/tools/dependency_scan_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,29 @@

# ADR-0029 / TAP-4561: unified cache-stats counters.
_stats: dict[str, int] = {"hits": 0, "misses": 0}
register_cache_stats("dependency_scan", lambda: {**_stats, "size": len(_cache)})


def _dependency_scan_stats() -> dict[str, object]:
"""Stats provider: counters + staleness/age of the freshest entry (TAP-4558).

``age_seconds`` is the age of the most-recently populated entry (or ``None``
when the cache is empty); ``stale`` reports whether that freshest entry has
already aged past the TTL. This gives the unified ``tapps_stats.caches``
surface the same age/staleness signal the code-graph cache already exposes.
"""
out: dict[str, object] = {**_stats, "size": len(_cache)}
if _cache:
newest_ts = max(ts for _, ts in _cache.values())
age = time.monotonic() - newest_ts
out["age_seconds"] = round(age, 1)
out["stale"] = TTLStaleness(float(_CACHE_TTL), now_fn=time.monotonic).is_stale(newest_ts)
else:
out["age_seconds"] = None
out["stale"] = None
return out


register_cache_stats("dependency_scan", _dependency_scan_stats)


def set_dependency_findings(project_root: str, findings: list[VulnerabilityFinding]) -> None:
Expand Down
104 changes: 104 additions & 0 deletions packages/tapps-mcp/tests/unit/test_cache_staleness_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Real-cache staleness telemetry in the unified stats surface (TAP-4558).

The unified ``tapps_stats.caches`` surface (TAP-4561) reported raw hit/miss
counters only. TAP-4558 adds ``age_seconds`` + ``stale`` for the dependency-scan
and Linear-snapshot caches so hit-rate *and* staleness health are visible.

These tests drive the **real** cache modules (not synthetic registry providers)
and assert the staleness fields surface through ``collect_cache_stats`` for at
least two distinct caches — the AC4 requirement the prior synthetic tests missed.
"""

from __future__ import annotations

from pathlib import Path

from tapps_core.cache import collect_cache_stats
from tapps_mcp import server_linear_tools
from tapps_mcp.tools import dependency_scan_cache
from tapps_mcp.tools.dependency_scan_cache import (
clear_dependency_cache,
set_dependency_findings,
)


class TestDependencyScanStaleness:
def test_empty_cache_reports_null_age_and_staleness(self) -> None:
clear_dependency_cache()
stats = dependency_scan_cache._dependency_scan_stats()
assert stats["size"] == 0
assert stats["age_seconds"] is None
assert stats["stale"] is None

def test_fresh_entry_reports_age_and_not_stale(self) -> None:
clear_dependency_cache()
set_dependency_findings("/proj-fresh", [])
stats = dependency_scan_cache._dependency_scan_stats()
assert stats["size"] == 1
assert isinstance(stats["age_seconds"], float)
assert stats["age_seconds"] >= 0
# Just-written entry is well within the 300 s TTL.
assert stats["stale"] is False
clear_dependency_cache()

def test_old_entry_reports_stale(self) -> None:
clear_dependency_cache()
# Plant an entry timestamped far in the past (ts=0.0 is past any TTL).
dependency_scan_cache._cache["/proj-old"] = ([], 0.0)
stats = dependency_scan_cache._dependency_scan_stats()
assert stats["stale"] is True
assert isinstance(stats["age_seconds"], float)
clear_dependency_cache()


class TestLinearSnapshotStaleness:
def _reset(self) -> None:
server_linear_tools._snapshot_last_write_ts = 0.0

def test_no_write_reports_null_age_and_staleness(self) -> None:
self._reset()
stats = server_linear_tools._linear_snapshot_stats()
assert stats["age_seconds"] is None
assert stats["stale"] is None

def test_write_records_age_and_freshness(self, tmp_path: Path) -> None:
self._reset()
cache_dir = tmp_path / "linear-snapshots"
cache_dir.mkdir(parents=True, exist_ok=True)
server_linear_tools._cache_write(
cache_dir, "team__proj__any__abc123", {"issues": [], "expires_at": 0}
)
stats = server_linear_tools._linear_snapshot_stats()
assert stats["writes"] >= 1
assert isinstance(stats["age_seconds"], float)
assert stats["age_seconds"] >= 0
# Freshest write is within the default 300 s open-bucket TTL.
assert stats["stale"] is False
self._reset()


def test_staleness_fields_surface_for_at_least_two_real_caches(tmp_path: Path) -> None:
"""AC4: unified surface reports age/staleness for >=2 distinct real caches."""
clear_dependency_cache()
server_linear_tools._snapshot_last_write_ts = 0.0

# Drive both real caches so each has a populated entry / recorded write.
set_dependency_findings("/proj-ac4", [])
cache_dir = tmp_path / "linear-snapshots"
cache_dir.mkdir(parents=True, exist_ok=True)
server_linear_tools._cache_write(
cache_dir, "team__proj__any__ac4", {"issues": [], "expires_at": 0}
)

caches = collect_cache_stats()

for name in ("dependency_scan", "linear_snapshot"):
assert name in caches, f"{name} missing from unified surface"
entry = caches[name]
assert "age_seconds" in entry, f"{name} lacks age_seconds"
assert "stale" in entry, f"{name} lacks stale"
assert entry["age_seconds"] is not None
assert entry["stale"] is False

clear_dependency_cache()
server_linear_tools._snapshot_last_write_ts = 0.0
Loading