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
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ repos:
- pydantic-settings>=2.2
- structlog>=24.1
- types-PyYAML
- fastapi>=0.110
- typer>=0.12
- uvicorn>=0.27
- mcp>=1.0
args: [--config-file=pyproject.toml]
files: ^src/plane_conductor/
40 changes: 27 additions & 13 deletions src/plane_conductor/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
_DEFAULT_LOG_DIR = "/var/log/plane-conductor"
_AGENT_SUMMARY_TAIL_BYTES = 64 * 1024
_LOG_NAME_RE = re.compile(
r"^(?P<timestamp>\d{8}T\d{6}Z)-(?P<rest>.+)-(?P<issue_short>[0-9a-fA-F]{8})\.log$"
r"^(?P<timestamp>\d{8}T\d{6}Z)-(?P<rest>.+)-(?P<issue_short>[0-9a-fA-F]{8})" r"\.log(?:\.\d+)?$"
)

mcp = FastMCP("plane-conductor")
Expand Down Expand Up @@ -179,13 +179,21 @@ def recent_runs(
`issue_prefix` (matches first 8 chars of issue UUID). Sorted by
timestamp descending. Returns metadata only — call `read_log` to fetch
contents or `agent_summary` for the final stdout block.

Logrotate-aware: a single agent run can exist on disk as `*.log` (fresh
empty stub after rotation) plus one or more `*.log.N` siblings holding
the actual content. Each agent run is deduplicated to the variant with
the largest `size_bytes`, so an operator scanning history doesn't see a
wall of zero-byte stubs hiding the real logs.
"""
out: list[dict[str, Any]] = []
if limit <= 0:
return []
ld = _log_dir()
if not ld.exists():
return []
known = _known_workspace_slugs()
for f in sorted(ld.glob("*.log"), reverse=True):
by_base: dict[tuple[str, str, str, str], dict[str, Any]] = {}
for f in ld.glob("*.log*"):
meta = _parse_log_filename(f.name, known_slugs=known)
if not meta:
continue
Expand All @@ -199,17 +207,23 @@ def recent_runs(
stat = f.stat()
except OSError:
continue
out.append(
{
**meta,
"path": str(f),
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
}
key = (
meta["timestamp"],
meta["workspace"],
meta["nickname"],
meta["issue_short"],
)
if len(out) >= limit:
break
return out
candidate = {
**meta,
"path": str(f),
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
}
prev = by_base.get(key)
if prev is None or candidate["size_bytes"] > prev["size_bytes"]:
by_base[key] = candidate
out = sorted(by_base.values(), key=lambda r: r["timestamp"], reverse=True)
return out[:limit]


@mcp.tool()
Expand Down
94 changes: 94 additions & 0 deletions tests/test_mcp_server_recent_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""recent_runs must surface real log content even after logrotate.

A rotated agent log lives on disk as `<ts>-<ws>-<nick>-<short>.log.1`
(or `.log.2`, ...) while logrotate leaves a fresh empty `<ts>-<ws>-<nick>-<short>.log`
stub. The MCP-side recent_runs must:
- match the rotated extensions, not only `.log`;
- dedupe sibling variants of the same agent run to the largest size_bytes;
- still sort by spawn timestamp descending.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from plane_conductor import mcp_server


@pytest.fixture
def log_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
d = tmp_path / "plane-conductor-logs"
d.mkdir()
monkeypatch.setenv("LOG_DIR", str(d))
return d


def _touch(d: Path, name: str, content: str = "") -> Path:
p = d / name
p.write_text(content)
return p


def test_rotated_pair_returns_only_the_non_empty_variant(log_dir: Path) -> None:
base = "20260514T181836Z-coinex-rinzler-9405d99a"
_touch(log_dir, f"{base}.log") # empty stub left by logrotate
_touch(log_dir, f"{base}.log.1", "actual content here")

runs = mcp_server.recent_runs(workspace="coinex")

assert len(runs) == 1
assert runs[0]["path"].endswith(".log.1")
assert runs[0]["size_bytes"] > 0
assert runs[0]["nickname"] == "rinzler"
assert runs[0]["issue_short"] == "9405d99a"


def test_multiple_rotations_pick_the_largest(log_dir: Path) -> None:
base = "20260514T181836Z-coinex-rinzler-9405d99a"
_touch(log_dir, f"{base}.log") # 0 bytes
_touch(log_dir, f"{base}.log.1", "x" * 500)
_touch(log_dir, f"{base}.log.2", "x" * 200)

runs = mcp_server.recent_runs(workspace="coinex")

assert len(runs) == 1
assert runs[0]["path"].endswith(".log.1")
assert runs[0]["size_bytes"] == 500


def test_distinct_runs_are_kept_separate(log_dir: Path) -> None:
_touch(log_dir, "20260514T181836Z-coinex-rinzler-9405d99a.log.1", "first")
_touch(log_dir, "20260514T190000Z-coinex-sark-9405d99a.log", "second")
_touch(log_dir, "20260514T200000Z-coinex-flynn-b02eae4b.log", "third")

runs = mcp_server.recent_runs(workspace="coinex")

assert len(runs) == 3
nicks = [r["nickname"] for r in runs]
assert nicks == ["flynn", "sark", "rinzler"] # newest first


def test_filters_and_limit(log_dir: Path) -> None:
_touch(log_dir, "20260514T181836Z-coinex-rinzler-9405d99a.log.1", "x")
_touch(log_dir, "20260514T190000Z-coinex-sark-9405d99a.log", "x")
_touch(log_dir, "20260514T200000Z-aist-flynn-b02eae4b.log", "x")

assert {r["workspace"] for r in mcp_server.recent_runs(workspace="aist")} == {"aist"}
assert {r["nickname"] for r in mcp_server.recent_runs(nickname="sark")} == {"sark"}
assert len(mcp_server.recent_runs(issue_prefix="9405d99a")) == 2
assert len(mcp_server.recent_runs(limit=1)) == 1
assert mcp_server.recent_runs(limit=0) == []
assert mcp_server.recent_runs(limit=-5) == []


def test_non_log_files_are_ignored(log_dir: Path) -> None:
_touch(log_dir, "README.md", "docs")
_touch(log_dir, "20260514T181836Z-coinex-rinzler-9405d99a.log.bak", "irrelevant suffix")
_touch(log_dir, "20260514T190000Z-coinex-sark-9405d99a.log", "ok")

runs = mcp_server.recent_runs()

assert len(runs) == 1
assert runs[0]["nickname"] == "sark"
Loading