diff --git a/README.md b/README.md index b428d0f1..23d3055f 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,12 @@ 4. [Live Demo](#live-demo) 5. [Requirements](#requirements) 6. [Quickstart](#quickstart) -7. [Documentation](#documentation) -8. [Repository Layout](#repository-layout) -9. [Status](#status) -10. [Stability](#stability) -11. [Contributing](#contributing) +7. [Run The Dashboard](#run-the-dashboard) +8. [Documentation](#documentation) +9. [Repository Layout](#repository-layout) +10. [Status](#status) +11. [Stability](#stability) +12. [Contributing](#contributing) ## Why It Exists @@ -130,6 +131,46 @@ initial skills and harness files. After that, normal work runs through the `l-01-session-job-lifecycle` skill. The agent resolves the active context with `c-08-ar-coordination-context-resolver`, checks memory quality with `c-02-memory-quality-control`, reads relevant onboarding beside code, and updates onboarding after approved changes. +## Run The Dashboard + +The mission-control dashboard ships inside the MCP package. Install the CLI +once with uv — latest stable, no version pin — then start the cockpit from +anywhere in your workspace: + +```text +uv tool install agents-remember-mcp +agents-remember dashboard +``` + +`--config` is optional: the CLI walks up from the current directory and uses +the nearest `.claude/mcp/agents-remember-settings.json`, or the `--config` +recorded in an `.mcp.json` `agents-remember` entry — the same settings file +the MCP server boots from. + +For a dashboard that survives closing the terminal, use daemon mode: + +```text +agents-remember dashboard --daemon # detach; state + log under /logs/dashboard/ +agents-remember dashboard --status # exit 0 when running, 1 when not +agents-remember dashboard --stop +``` + +Or let the MCP server supervise it: set `"dashboard": {"autoStart": true}` in +the MCP settings JSON and every server boot ensures the daemon — adopting a +healthy one, starting a missing one, and restarting on version mismatch so an +upgrade is picked up by the next session +([Settings Reference](docs/reference/settings-json.md)). + +Pinning a version is the debugging/repro path, not the default: `uv tool +install 'agents-remember-mcp==3.0.0rc2'`, or one-shot without installing, +`uvx --from 'agents-remember-mcp==3.0.0rc2' agents-remember dashboard`. + +> **Pre-release note (until 3.0.0 final):** the dashboard currently ships in +> `3.0.0rcN` pre-releases, which default version resolution skips. Install with +> `uv tool install --prerelease allow agents-remember-mcp`, and register the +> MCP server with an explicit `agents-remember-mcp==3.0.0rcN` pin instead of +> `@latest`. + ## Documentation - [Features](docs/features.md) - the concentrated tour of what Agents Remember gives users. @@ -198,7 +239,7 @@ ar-coordination/ ## Status -Agents Remember is at `3.0.0rc1` and actively developed. The core path — by-path onboarding, drift checks, and approval-gated updates — is in real use and stable enough to rely on. The public contracts listed under [Stability](#stability) are held stable across minor releases and change only on a major bump; the internals beneath them and the optional semantic/relationship providers may still evolve, so pin a version and read the notes for your target version in [GitHub Releases](https://github.com/Foxfire1st/agents-remember/releases) — the repository's canonical changelog — before upgrading. The Claude Code path is the most exercised; other harnesses are supported but less battle-tested. +Agents Remember is at `3.0.0rc2` and actively developed. The core path — by-path onboarding, drift checks, and approval-gated updates — is in real use and stable enough to rely on. The public contracts listed under [Stability](#stability) are held stable across minor releases and change only on a major bump; the internals beneath them and the optional semantic/relationship providers may still evolve, so pin a version and read the notes for your target version in [GitHub Releases](https://github.com/Foxfire1st/agents-remember/releases) — the repository's canonical changelog — before upgrading. The Claude Code path is the most exercised; other harnesses are supported but less battle-tested. The 3.0 arc: the working session itself is now observable and steerable — a system-managed session job lifecycle with durable approval gates and an event/projection layer, served as the mission-control browser cockpit directly from the MCP package (`agents-remember dashboard`; [#2](https://github.com/Foxfire1st/agents-remember/issues/2), [#43](https://github.com/Foxfire1st/agents-remember/issues/43)). The `rc` tag means the cockpit surface is still settling toward the final 3.0.0 contract; the architecture beneath it is the one described above. diff --git a/docs/reference/settings-json.md b/docs/reference/settings-json.md index c5c1184a..a0af64d2 100644 --- a/docs/reference/settings-json.md +++ b/docs/reference/settings-json.md @@ -119,7 +119,11 @@ watch settings internally. "toolSeconds": 30, "providerSetupSeconds": 1800 }, - "benchmarksEnabled": false + "benchmarksEnabled": false, + "dashboard": { + "autoStart": false, + "port": 8765 + } } ``` @@ -188,3 +192,13 @@ dependency install (default 1800). Docker control operations such as start, stop, and status use a fixed internal cap and are not configurable. Indexing and database seed or clone are never capped because they scale with repository size. A value of `0` means unlimited for any cap. + +`dashboard` (optional) supervises the mission-control dashboard from the MCP +server. With `dashboard.autoStart` set to `true` (default `false`), every +server boot ensures a detached dashboard daemon on `dashboard.port` (default +`8765`): a healthy same-version daemon is adopted, a missing one is spawned, +and a version or port mismatch restarts it, so an upgrade is picked up by the +next session's boot. Daemon state and logs live under +`/logs/dashboard/`; `agents-remember dashboard --status` / +`--stop` manage the same daemon from the CLI. Unknown `dashboard` keys are +rejected. diff --git a/examples/mcp/settings.example.json b/examples/mcp/settings.example.json index 115b4a78..3624865e 100644 --- a/examples/mcp/settings.example.json +++ b/examples/mcp/settings.example.json @@ -17,5 +17,9 @@ "toolSeconds": 30, "providerSetupSeconds": 1800 }, - "benchmarksEnabled": false + "benchmarksEnabled": false, + "dashboard": { + "autoStart": false, + "port": 8765 + } } diff --git a/mcp/README.md b/mcp/README.md index 48d2d4fe..89f7c4c5 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -79,6 +79,31 @@ python -m pip install agents-remember-mcp agents-remember-mcp --config /absolute/path/to/agents-remember-settings.json ``` +The package also ships the umbrella CLI `agents-remember`, which carries the +mission-control `dashboard` subcommand. Install it as a uv tool — latest +stable, no version pin; pinning (`agents-remember-mcp==X.Y.Z`, also usable +with `uvx --from`) is the debugging/repro path: + +```text +uv tool install agents-remember-mcp +agents-remember dashboard +``` + +`dashboard` finds its `--config` on its own: it walks up from the current +directory to the nearest `.claude/mcp/agents-remember-settings.json`, or the +`--config` recorded in an `.mcp.json` `agents-remember` entry. `--daemon` +detaches it so it survives the terminal that started it (`--status` / +`--stop` manage it; state and log live under +`/logs/dashboard/`), and the +`"dashboard": {"autoStart": true}` settings key makes every MCP server boot +ensure the daemon — adopting a healthy one, starting a missing one, and +restarting on version mismatch. + +> **Pre-release note (until 3.0.0 final):** `3.0.0rcN` pre-releases are +> skipped by default version resolution — install the dashboard-capable CLI +> with `uv tool install --prerelease allow agents-remember-mcp`, and pin the +> server registration (`agents-remember-mcp==3.0.0rcN`) instead of `@latest`. + The config path must be **absolute**, the settings file must live **outside the `ar-coordination/` runtime folder**, and it should live **under your harness's registration folder in an `mcp/` subdirectory** (see diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 0f341932..e4c667e0 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agents-remember-mcp" -version = "3.0.0rc1" +version = "3.0.0rc2" description = "Model Context Protocol server for Agents Remember." readme = "README.md" requires-python = ">=3.11" diff --git a/mcp/src/agents_remember/cli/dashboard.py b/mcp/src/agents_remember/cli/dashboard.py index 3ab107f9..4034f35d 100644 --- a/mcp/src/agents_remember/cli/dashboard.py +++ b/mcp/src/agents_remember/cli/dashboard.py @@ -15,7 +15,9 @@ import uvicorn import agents_remember +from agents_remember.cli.discovery import ConfigDiscoveryError, discover_config from agents_remember.mcp.config import ConfigError, load_config +from agents_remember.serving import daemon as serving_daemon from agents_remember.serving.app import create_app from agents_remember.serving.sim import SimError, build_sim, parse_sim_speed @@ -34,12 +36,21 @@ def _dev_app(): def add_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( - "--config", required=True, help="Absolute path to trusted MCP settings JSON." + "--config", + default=None, + help="Path to trusted MCP settings JSON. Omit to discover it from the working " + "directory: the nearest .claude/mcp/agents-remember-settings.json, or the " + "--config recorded in an .mcp.json agents-remember entry.", ) parser.add_argument( "--host", default="127.0.0.1", help="Bind host (localhost-only by default; do not expose)." ) - parser.add_argument("--port", type=int, default=8765, help="Bind port (default 8765).") + parser.add_argument( + "--port", + type=int, + default=None, + help="Bind port (default: the dashboard.port settings key, else 8765).", + ) parser.add_argument( "--interval", type=float, default=1.0, help="Projection refresh interval, seconds." ) @@ -59,21 +70,53 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: default="1", help="Sim replay speed multiplier (e.g. 1, 10) or 'paused' (default 1).", ) + control = parser.add_mutually_exclusive_group() + control.add_argument( + "--daemon", + action="store_true", + help="Detach: ensure a background dashboard daemon (state and log under " + "/logs/dashboard/) and return. Adopts a healthy matching daemon; " + "restarts one whose version, host, or port differs.", + ) + control.add_argument( + "--status", + action="store_true", + help="Report the dashboard daemon's state and exit (exit 0 running, 1 not).", + ) + control.add_argument( + "--stop", + action="store_true", + help="Stop the dashboard daemon (TERM, bounded wait, KILL fallback) and exit.", + ) + parser.add_argument( + "--no-access-log", + action="store_true", + help="Serve without per-request access logs (the daemon child uses this to keep " + "its log file bounded).", + ) def run(args: argparse.Namespace) -> int: try: - config = load_config(args.config) + config_path = args.config or discover_config() + except ConfigDiscoveryError as error: + print(f"error: {error}") + return 1 + try: + config = load_config(config_path) except ConfigError as error: print(f"error: {error}") return 1 + port = args.port if args.port is not None else config.dashboard.port + if args.daemon or args.status or args.stop: + return _run_daemon_command(args, config, port) if args.reload: if args.sim: print("error: --reload is not supported with --sim") return 1 # Pass an import-string factory (not the built app object) so uvicorn's reloader can # re-import on change; watch only the package source so node_modules/.git don't churn it. - os.environ[_DEV_CONFIG_ENV] = str(Path(args.config).resolve()) + os.environ[_DEV_CONFIG_ENV] = str(Path(config_path).resolve()) os.environ[_DEV_INTERVAL_ENV] = str(args.interval) uvicorn.run( "agents_remember.cli.dashboard:_dev_app", @@ -81,7 +124,8 @@ def run(args: argparse.Namespace) -> int: reload=True, reload_dirs=[str(Path(agents_remember.__file__).parent)], host=args.host, - port=args.port, + port=port, + access_log=not args.no_access_log, ) return 0 if args.sim: @@ -97,5 +141,31 @@ def run(args: argparse.Namespace) -> int: ) else: app = create_app(config, interval=args.interval) - uvicorn.run(app, host=args.host, port=args.port) + uvicorn.run(app, host=args.host, port=port, access_log=not args.no_access_log) return 0 + + +def _run_daemon_command( + args: argparse.Namespace, config: serving_daemon.McpRuntimeConfig, port: int +) -> int: + """Dispatch --status/--stop/--daemon against the recorded daemon state.""" + if args.sim or args.reload: + print("error: --daemon/--status/--stop are not supported with --sim or --reload") + return 1 + directory = serving_daemon.daemon_dir(config) + if args.status: + state, alive = serving_daemon.probe(directory) + if alive and state is not None: + print( + f"dashboard daemon running: pid {state.pid}, http://{state.host}:{state.port}/ " + f"(v{state.version}); log: {state.log_path}" + ) + return 0 + print("dashboard daemon not running") + return 1 + if args.stop: + print(f"dashboard daemon: {serving_daemon.stop(directory)}") + return 0 + result = serving_daemon.ensure(config, host=args.host, port=port, interval=args.interval) + print(f"dashboard daemon {result.action}: {result.detail}") + return 0 if result.action in ("adopted", "started", "restarted") else 1 diff --git a/mcp/src/agents_remember/cli/discovery.py b/mcp/src/agents_remember/cli/discovery.py new file mode 100644 index 00000000..a2d83f05 --- /dev/null +++ b/mcp/src/agents_remember/cli/discovery.py @@ -0,0 +1,99 @@ +"""Trusted-settings discovery for the umbrella CLI (L1 of 260703). + +``agents-remember dashboard`` (and future subcommands) should run without +``--config`` from anywhere under the workspace. Discovery walks the current +directory upward; at each level it probes, in order: + +1. the settings convention ``.claude/mcp/agents-remember-settings.json``, and +2. an ``.mcp.json`` whose ``mcpServers``/``agents-remember`` entry records a + ``--config`` argument — the harness's own registration, reused verbatim so + the CLI and the harness always boot from the same settings file. + +The nearest directory wins and the first USABLE file ends the walk — usable +meaning it parses as JSON and its ``coordinationRoot`` is an existing absolute +directory. That semantic probe matters because the repository itself ships a +placeholder template at the convention path (````), which +must never shadow the real settings above it. A malformed or foreign +``.mcp.json`` is skipped silently: discovery must never crash on someone +else's file. An explicit ``--config`` always bypasses this. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +SETTINGS_CONVENTION = Path(".claude") / "mcp" / "agents-remember-settings.json" +MCP_REGISTRATION = ".mcp.json" +_SERVER_NAME = "agents-remember" + + +class ConfigDiscoveryError(Exception): + """No trusted settings file was discoverable from the starting directory.""" + + +def discover_config(start: Path | None = None) -> Path: + """The nearest trusted settings JSON at or above ``start`` (default: cwd).""" + origin = (start or Path.cwd()).resolve() + for directory in (origin, *origin.parents): + convention = directory / SETTINGS_CONVENTION + if _is_usable_settings(convention): + return convention + registered = _config_from_mcp_registration(directory / MCP_REGISTRATION) + if registered is not None and _is_usable_settings(registered): + return registered + raise ConfigDiscoveryError( + "no trusted settings found: pass --config, or run from a directory at or below " + f"one containing {SETTINGS_CONVENTION.as_posix()} or an {MCP_REGISTRATION} whose " + f"{_SERVER_NAME!r} entry records a --config path (walked up from {origin})" + ) + + +def _config_from_mcp_registration(mcp_json: Path) -> Path | None: + """The ``--config`` path recorded for the agents-remember server, if any.""" + if not mcp_json.is_file(): + return None + try: + data = json.loads(mcp_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + servers = data.get("mcpServers") + if not isinstance(servers, dict): + return None + entry = servers.get(_SERVER_NAME) + if not isinstance(entry, dict): + return None + arguments = entry.get("args") + if not isinstance(arguments, list): + return None + for index, argument in enumerate(arguments): + if argument == "--config" and index + 1 < len(arguments): + candidate = str(arguments[index + 1]) + if candidate: + return Path(candidate) + return None + + +def _is_usable_settings(path: Path) -> bool: + """True for a real trusted settings file — not the shipped placeholder template. + + The probe is semantic, not syntactic: the file must parse as a JSON object whose + ``coordinationRoot`` is an existing absolute directory. The repository's tracked + template carries ```` placeholders and fails this, so a source + checkout never shadows the workspace's real settings. + """ + if not path.is_file(): + return False + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + root = data.get("coordinationRoot") + if not isinstance(root, str) or not root: + return False + candidate = Path(root) + return candidate.is_absolute() and candidate.is_dir() diff --git a/mcp/src/agents_remember/mcp/__init__.py b/mcp/src/agents_remember/mcp/__init__.py index bf4ca724..6c62b431 100644 --- a/mcp/src/agents_remember/mcp/__init__.py +++ b/mcp/src/agents_remember/mcp/__init__.py @@ -8,4 +8,4 @@ # Single source of truth: the installed package metadata (mcp/pyproject.toml). SERVER_VERSION = version("agents-remember-mcp") except PackageNotFoundError: # running from a source checkout without an install - SERVER_VERSION = "3.0.0rc1" + SERVER_VERSION = "3.0.0rc2" diff --git a/mcp/src/agents_remember/mcp/config.py b/mcp/src/agents_remember/mcp/config.py index c8c1aeff..be29794c 100644 --- a/mcp/src/agents_remember/mcp/config.py +++ b/mcp/src/agents_remember/mcp/config.py @@ -24,6 +24,12 @@ # documented, reserved cap. KNOWN_TIMEOUT_CAPS = frozenset({"providerSetupSeconds", "toolSeconds"}) +DEFAULT_DASHBOARD_PORT = 8765 + +# Same fail-loud discipline as timeoutCaps: a typo ("autostart") must surface at +# boot, not silently leave the daemon unsupervised. +KNOWN_DASHBOARD_FIELDS = frozenset({"autoStart", "port"}) + class ConfigError(AgentsRememberError): """Raised when MCP authority settings are missing or unsafe.""" @@ -47,6 +53,14 @@ class ProviderScope: scope: str = "workspace" +@dataclass(frozen=True) +class DashboardSettings: + """The optional ``dashboard`` settings object (defaults keep it fully off).""" + + auto_start: bool = False + port: int = DEFAULT_DASHBOARD_PORT + + @dataclass(frozen=True) class McpRuntimeConfig: config_path: Path @@ -58,6 +72,7 @@ class McpRuntimeConfig: providers: dict[str, ProviderScope] = field(default_factory=dict) timeout_caps: dict[str, int] = field(default_factory=dict) benchmarks_enabled: bool = False + dashboard: DashboardSettings = field(default_factory=DashboardSettings) @property def allowed_repo_ids(self) -> tuple[str, ...]: @@ -119,6 +134,7 @@ def config_from_mapping(data: dict[str, Any], config_path: Path) -> McpRuntimeCo providers = parse_providers(data.get("providers", {}), coordination_root, workspace_root) timeout_caps = parse_timeout_caps(data.get("timeoutCaps", {})) benchmarks_enabled = parse_benchmarks_enabled(data.get("benchmarksEnabled", False)) + dashboard = parse_dashboard_settings(data.get("dashboard")) return McpRuntimeConfig( config_path=config_path, @@ -130,6 +146,7 @@ def config_from_mapping(data: dict[str, Any], config_path: Path) -> McpRuntimeCo providers=providers, timeout_caps=timeout_caps, benchmarks_enabled=benchmarks_enabled, + dashboard=dashboard, ) @@ -286,6 +303,25 @@ def parse_benchmarks_enabled(raw: object) -> bool: return raw +def parse_dashboard_settings(raw: object) -> DashboardSettings: + if raw is None: + return DashboardSettings() + if not isinstance(raw, dict): + raise ConfigError("dashboard settings must be an object") + unknown = sorted(set(raw) - KNOWN_DASHBOARD_FIELDS) + if unknown: + allowed = ", ".join(sorted(KNOWN_DASHBOARD_FIELDS)) + unknown_text = ", ".join(unknown) + raise ConfigError(f"unsupported dashboard setting(s): {unknown_text}; allowed: {allowed}") + auto_start = raw.get("autoStart", False) + if not isinstance(auto_start, bool): + raise ConfigError("dashboard.autoStart must be a boolean") + port = raw.get("port", DEFAULT_DASHBOARD_PORT) + if isinstance(port, bool) or not isinstance(port, int) or not 0 < port < 65536: + raise ConfigError("dashboard.port must be an integer in 1..65535") + return DashboardSettings(auto_start=auto_start, port=port) + + def parse_timeout_caps(raw: object) -> dict[str, int]: if not isinstance(raw, dict): raise ConfigError("timeoutCaps must be an object") diff --git a/mcp/src/agents_remember/mcp/server.py b/mcp/src/agents_remember/mcp/server.py index 1c8d878b..a4660f2a 100644 --- a/mcp/src/agents_remember/mcp/server.py +++ b/mcp/src/agents_remember/mcp/server.py @@ -7,6 +7,7 @@ from agents_remember.benchmarks.runner import CODEX_BENCHMARK_SANDBOX from agents_remember.observer import AmbientLifecycle, EventStore, install_ambient, observer_root +from agents_remember.serving.daemon import maybe_autostart_dashboard from .compact_content import install_compact_content from .config import ConfigError, McpRuntimeConfig, load_config @@ -1052,5 +1053,8 @@ def main(argv: list[str] | None = None) -> int: except ConfigError as error: parser.error(str(error)) + # Boot-time dashboard supervision (dashboard.autoStart): total and threaded — + # it must never delay or break the stdio handshake this process exists for. + maybe_autostart_dashboard(config) run_server(config) return 0 diff --git a/mcp/src/agents_remember/serving/daemon.py b/mcp/src/agents_remember/serving/daemon.py new file mode 100644 index 00000000..2cdafa63 --- /dev/null +++ b/mcp/src/agents_remember/serving/daemon.py @@ -0,0 +1,421 @@ +"""Dashboard daemon supervision: detached serve, status/stop, and boot-time ensure. + +``agents-remember dashboard --daemon`` and the MCP server's ``dashboard.autoStart`` +settings key funnel into one decision function, ``ensure()``: adopt a healthy +daemon, spawn a missing one, restart on version/host/port mismatch (developer +decision: a stale dashboard is worse than a restart blip). State lives under +``/logs/dashboard/``: + +- ``daemon.json`` — pid, host, port, version, configPath, logPath, startedAt — + written atomically and immediately after spawn, so a dying supervisor never + strands an unrecorded child; +- ``dashboard.log`` — the child's stdio, rotated to ``.1`` per spawn (the child + serves with ``--no-access-log`` so per-request noise never grows it); +- ``ensure.lock`` — one non-blocking flock making concurrent MCP boots race-safe: + losers report ``lock-held`` and skip, they never double-spawn. + +The child is the plain foreground ``dashboard`` CLI addressed by module string +(``sys.executable -m agents_remember.cli``), never imported: this module stays +import-light (stdlib + settings types only) so the MCP server can call +``maybe_autostart_dashboard`` without pulling uvicorn/FastAPI into its boot, and +the autostart worker runs in a daemon thread whose only output goes to stderr — +over stdio transport, stdout IS the MCP protocol. + +Liveness is ``kill(pid, 0)`` plus a ``/proc//cmdline`` marker probe: the +recorded pid must still be an agents-remember dashboard, or reboots and pid +reuse would resurrect foreign processes (a zombie's empty cmdline fails the +probe too). On platforms without procfs the kill-probe alone decides. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import json +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from agents_remember.mcp import SERVER_VERSION +from agents_remember.mcp.config import McpRuntimeConfig + +STATE_FILE_NAME = "daemon.json" +LOG_FILE_NAME = "dashboard.log" +LOCK_FILE_NAME = "ensure.lock" + +_READY_TIMEOUT_SECONDS = 20.0 +_STOP_TIMEOUT_SECONDS = 10.0 +_CMDLINE_MARKERS = (b"agents_remember", b"agents-remember") + +# Spawned Popen handles, kept so a long-lived supervisor (the MCP server) reaps +# a daemon child that exits instead of leaving a zombie holding its pid. +_spawned: list[subprocess.Popen[bytes]] = [] + + +@dataclass(frozen=True) +class DaemonState: + """The recorded identity of the running (or last-spawned) dashboard daemon.""" + + pid: int + host: str + port: int + version: str + config_path: str + log_path: str + started_at: str + + +@dataclass(frozen=True) +class EnsureResult: + action: str # adopted | started | restarted | failed | lock-held + state: DaemonState | None + detail: str + + +def daemon_dir(config: McpRuntimeConfig) -> Path: + return config.coordination_root / "logs" / "dashboard" + + +def read_state(directory: Path) -> DaemonState | None: + """The recorded daemon state, or None when absent or unreadable.""" + path = directory / STATE_FILE_NAME + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + try: + return DaemonState( + pid=int(data["pid"]), + host=str(data["host"]), + port=int(data["port"]), + version=str(data["version"]), + config_path=str(data["configPath"]), + log_path=str(data["logPath"]), + started_at=str(data["startedAt"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def write_state(directory: Path, state: DaemonState) -> None: + """Atomic write (tmp + rename): readers never observe a torn state file.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / STATE_FILE_NAME + tmp = path.with_suffix(".json.tmp") + payload = { + "pid": state.pid, + "host": state.host, + "port": state.port, + "version": state.version, + "configPath": state.config_path, + "logPath": state.log_path, + "startedAt": state.started_at, + } + tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +def clear_state(directory: Path) -> None: + (directory / STATE_FILE_NAME).unlink(missing_ok=True) + + +def probe(directory: Path) -> tuple[DaemonState | None, bool]: + """The recorded state plus whether that pid is still OUR dashboard.""" + state = read_state(directory) + if state is None: + return None, False + return state, _state_alive(state) + + +def _state_alive(state: DaemonState) -> bool: + if not _pid_alive(state.pid): + return False + if not Path("/proc").exists(): # no procfs (e.g. macOS): kill-probe decides + return True + return _pid_is_dashboard(state.pid) + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _pid_is_dashboard(pid: int) -> bool: + try: + cmdline = Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return False + if b"dashboard" not in cmdline: + return False + return any(marker in cmdline for marker in _CMDLINE_MARKERS) + + +def spawn( + config: McpRuntimeConfig, + *, + host: str, + port: int, + version: str, + interval: float = 1.0, +) -> DaemonState: + """Launch the detached foreground CLI and record it immediately. + + ``start_new_session`` detaches the child from the caller's terminal and + process group, so it survives the tab that started it. The state file is + written before any readiness check: a supervisor dying mid-ensure must not + strand a running child that nothing remembers. + """ + directory = daemon_dir(config) + directory.mkdir(parents=True, exist_ok=True) + log_path = directory / LOG_FILE_NAME + _rotate_log(log_path) + command = [ + sys.executable, + "-m", + "agents_remember.cli", + "dashboard", + "--config", + str(config.config_path), + "--host", + host, + "--port", + str(port), + "--interval", + str(interval), + "--no-access-log", + ] + with log_path.open("ab") as log: + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=log, + start_new_session=True, + cwd=str(directory), + ) + _spawned.append(process) + state = DaemonState( + pid=process.pid, + host=host, + port=port, + version=version, + config_path=str(config.config_path), + log_path=str(log_path), + started_at=datetime.now(UTC).isoformat(timespec="seconds"), + ) + write_state(directory, state) + return state + + +def stop(directory: Path, *, timeout: float = _STOP_TIMEOUT_SECONDS) -> str: + """TERM, bounded wait, KILL fallback; idempotent when nothing runs. + + Returns ``not-running`` | ``stopped`` | ``killed``. The state file is + cleared in every branch — after stop, the record must not resurrect. + """ + state, alive = probe(directory) + if state is None or not alive: + clear_state(directory) + return "not-running" + try: + os.kill(state.pid, signal.SIGTERM) + except ProcessLookupError: + clear_state(directory) + return "not-running" + if _wait_gone(state.pid, timeout=timeout): + clear_state(directory) + return "stopped" + with contextlib.suppress(ProcessLookupError): + os.kill(state.pid, signal.SIGKILL) + _wait_gone(state.pid, timeout=2.0) + clear_state(directory) + return "killed" + + +def ensure( + config: McpRuntimeConfig, + *, + host: str, + port: int, + version: str = SERVER_VERSION, + interval: float = 1.0, +) -> EnsureResult: + """Adopt a healthy daemon, spawn a missing one, restart a mismatched one. + + ``interval`` reaches the child only when this call spawns (or restarts) it; + an adopted daemon keeps the cadence it was started with. + """ + directory = daemon_dir(config) + directory.mkdir(parents=True, exist_ok=True) + _reap_spawned() + with (directory / LOCK_FILE_NAME).open("a") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return EnsureResult( + action="lock-held", + state=read_state(directory), + detail="another supervisor is ensuring the dashboard daemon; skipped", + ) + try: + return _ensure_locked( + config, directory, host=host, port=port, version=version, interval=interval + ) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +def _ensure_locked( + config: McpRuntimeConfig, + directory: Path, + *, + host: str, + port: int, + version: str, + interval: float, +) -> EnsureResult: + current, alive = probe(directory) + mismatch = "" + if alive and current is not None: + if current.version == version and current.port == port and current.host == host: + return EnsureResult( + action="adopted", + state=current, + detail=f"healthy daemon pid {current.pid} on {_url(current)} (v{current.version})", + ) + mismatch = _describe_mismatch(current, host=host, port=port, version=version) + stop(directory) + state = spawn(config, host=host, port=port, version=version, interval=interval) + if not _wait_ready(state): + if _pid_alive(state.pid): + detail = ( + f"spawned pid {state.pid} but {_url(state)} did not accept within " + f"{_READY_TIMEOUT_SECONDS:.0f}s; it may still be starting — see {state.log_path}" + ) + else: + clear_state(directory) + detail = f"daemon exited during startup; log tail:\n{_log_tail(Path(state.log_path))}" + return EnsureResult(action="failed", state=state, detail=detail) + if mismatch: + return EnsureResult( + action="restarted", + state=state, + detail=f"{mismatch}; now pid {state.pid} on {_url(state)} (v{version})", + ) + return EnsureResult( + action="started", + state=state, + detail=f"pid {state.pid} serving {_url(state)} (v{version}); log: {state.log_path}", + ) + + +def maybe_autostart_dashboard(config: McpRuntimeConfig) -> threading.Thread | None: + """The MCP boot hook: total (never raises) and non-blocking (daemon thread). + + A no-op unless the trusted settings set ``dashboard.autoStart``. All + reporting goes to stderr — the caller is a stdio MCP server whose stdout + carries the protocol. + """ + try: + if not config.dashboard.auto_start: + return None + worker = threading.Thread( + target=_autostart, + args=(config,), + name="ar-dashboard-autostart", + daemon=True, + ) + worker.start() + return worker + except Exception as error: # boot must survive any autostart failure + print(f"dashboard autostart: failed to launch: {error}", file=sys.stderr) + return None + + +def _autostart(config: McpRuntimeConfig) -> None: + try: + result = ensure(config, host="127.0.0.1", port=config.dashboard.port) + print(f"dashboard autostart: {result.action}: {result.detail}", file=sys.stderr) + except Exception as error: # boot must survive any autostart failure + print(f"dashboard autostart: failed: {error}", file=sys.stderr) + + +def _describe_mismatch(current: DaemonState, *, host: str, port: int, version: str) -> str: + reasons: list[str] = [] + if current.version != version: + reasons.append(f"version {current.version} -> {version}") + if current.port != port: + reasons.append(f"port {current.port} -> {port}") + if current.host != host: + reasons.append(f"host {current.host} -> {host}") + return f"restarting pid {current.pid} ({', '.join(reasons)})" + + +def _wait_ready(state: DaemonState, *, timeout: float = _READY_TIMEOUT_SECONDS) -> bool: + """True once the child both stays alive and accepts TCP on its bind.""" + connect_host = state.host if state.host not in ("0.0.0.0", "::") else "127.0.0.1" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _pid_alive(state.pid): + return False + try: + with socket.create_connection((connect_host, state.port), timeout=0.5): + return True + except OSError: + time.sleep(0.25) + return False + + +def _wait_gone(pid: int, *, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + _reap_spawned() + if not _pid_alive(pid): + return True + time.sleep(0.1) + return not _pid_alive(pid) + + +def _reap_spawned() -> None: + """Collect exited children spawned by this process so their pids free up.""" + for process in list(_spawned): + if process.poll() is not None: + _spawned.remove(process) + + +def _rotate_log(log_path: Path) -> None: + try: + if log_path.exists() and log_path.stat().st_size > 0: + os.replace(log_path, log_path.with_suffix(".log.1")) + except OSError: + pass # rotation is best-effort; a spawn must not fail over log shuffling + + +def _log_tail(log_path: Path, *, lines: int = 15) -> str: + try: + content = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "(log unreadable)" + tail = content.splitlines()[-lines:] + return "\n".join(tail) if tail else "(log empty)" + + +def _url(state: DaemonState) -> str: + display_host = state.host if state.host not in ("0.0.0.0", "::") else "127.0.0.1" + return f"http://{display_host}:{state.port}/" diff --git a/mcp/tests/test_cli_discovery.py b/mcp/tests/test_cli_discovery.py new file mode 100644 index 00000000..d693c8e3 --- /dev/null +++ b/mcp/tests/test_cli_discovery.py @@ -0,0 +1,146 @@ +"""Tests for the CLI trusted-settings discovery (260703 L1).""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +MCP_SRC = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(MCP_SRC)) + +from agents_remember.cli.discovery import ( + ConfigDiscoveryError, + discover_config, +) + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _settings(path: Path, coordination_root: Path) -> Path: + """A minimal USABLE settings file: coordinationRoot must exist and be absolute.""" + coordination_root.mkdir(parents=True, exist_ok=True) + return _write( + path, json.dumps({"version": 1, "coordinationRoot": coordination_root.as_posix()}) + ) + + +def _mcp_json(directory: Path, config_path: Path | None, server: str = "agents-remember") -> Path: + args = ["--config", config_path.as_posix()] if config_path else [] + return _write( + directory / ".mcp.json", + json.dumps({"mcpServers": {server: {"command": "uvx", "args": args}}}), + ) + + +class DiscoverConfigTests(unittest.TestCase): + def test_finds_the_settings_convention(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = _settings( + root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination" + ) + deep = root / "a" / "b" + deep.mkdir(parents=True) + self.assertEqual(discover_config(deep), settings) + + def test_finds_the_mcp_registration_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = _settings(root / "elsewhere" / "settings.json", root / "ar-coordination") + _mcp_json(root, settings) + deep = root / "repo" / "src" + deep.mkdir(parents=True) + self.assertEqual(discover_config(deep), settings) + + def test_convention_wins_over_registration_in_the_same_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + convention = _settings( + root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination" + ) + other = _settings(root / "other-settings.json", root / "ar-coordination") + _mcp_json(root, other) + self.assertEqual(discover_config(root), convention) + + def test_nearest_directory_wins_across_levels(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _settings(root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination") + near_settings = _settings(root / "ws" / "elsewhere.json", root / "ar-coordination") + _mcp_json(root / "ws", near_settings) + start = root / "ws" / "repo" + start.mkdir(parents=True) + self.assertEqual(discover_config(start), near_settings) + + def test_malformed_and_foreign_registrations_are_skipped(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = _settings( + root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination" + ) + mid = root / "mid" + _write(mid / ".mcp.json", "{not json") + low = mid / "low" + _mcp_json(low, None) # agents-remember entry without --config + _write( + low / "other" / ".mcp.json", + json.dumps({"mcpServers": {"someone-else": {"args": ["--config", "/x.json"]}}}), + ) + start = low / "other" + self.assertEqual(discover_config(start), settings) + + def test_registration_pointing_at_a_missing_file_is_skipped(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = _settings( + root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination" + ) + _mcp_json(root / "ws", root / "ws" / "gone.json") + start = root / "ws" + start.mkdir(parents=True, exist_ok=True) + self.assertEqual(discover_config(start), settings) + + def test_placeholder_template_is_skipped(self) -> None: + # The repository ships a tracked template at the convention path whose + # coordinationRoot is a "" placeholder; running from inside a + # source checkout must walk past it to the workspace's real settings. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + real = _settings( + root / ".claude/mcp/agents-remember-settings.json", root / "ar-coordination" + ) + repo = root / "agents-remember" + _write( + repo / ".claude/mcp/agents-remember-settings.json", + json.dumps( + { + "version": 1, + "coordinationRoot": "/ar-coordination", + } + ), + ) + start = repo / "mcp" / "src" + start.mkdir(parents=True) + self.assertEqual(discover_config(start), real) + + def test_miss_raises_with_both_patterns_and_the_origin(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + start = Path(tmp) / "nowhere" + start.mkdir() + with self.assertRaises(ConfigDiscoveryError) as ctx: + discover_config(start) + message = str(ctx.exception) + self.assertIn(".claude/mcp/agents-remember-settings.json", message) + self.assertIn(".mcp.json", message) + self.assertIn(start.resolve().as_posix(), message) + + +if __name__ == "__main__": + unittest.main() diff --git a/mcp/tests/test_config.py b/mcp/tests/test_config.py index 229d5ad3..a9789ed4 100644 --- a/mcp/tests/test_config.py +++ b/mcp/tests/test_config.py @@ -9,7 +9,12 @@ MCP_SRC = Path(__file__).resolve().parents[1] / "src" sys.path.insert(0, str(MCP_SRC)) -from agents_remember.mcp.config import ConfigError, load_config, require_config_path +from agents_remember.mcp.config import ( + ConfigError, + McpRuntimeConfig, + load_config, + require_config_path, +) from agents_remember.providers.cgc.context.core import cgc_runner_image from agents_remember.providers.identity import provider_instance_id from agents_remember.providers.settings import lifecycle_settings_from_config @@ -434,5 +439,44 @@ def test_legacy_provider_seconds_is_rejected_with_rename_message(self) -> None: load_config(path) +class DashboardSettingsTests(unittest.TestCase): + def _load(self, dashboard: object | None) -> McpRuntimeConfig: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + payload = settings_payload(root) + if dashboard is not None: + payload["dashboard"] = dashboard + path = root / "mcp-settings.json" + write_json(path, payload) + return load_config(path) + + def test_defaults_stay_off_when_the_key_is_absent(self) -> None: + config = self._load(None) + self.assertFalse(config.dashboard.auto_start) + self.assertEqual(config.dashboard.port, 8765) + + def test_parses_auto_start_and_port(self) -> None: + config = self._load({"autoStart": True, "port": 9321}) + self.assertTrue(config.dashboard.auto_start) + self.assertEqual(config.dashboard.port, 9321) + + def test_unknown_dashboard_key_is_rejected(self) -> None: + with self.assertRaisesRegex(ConfigError, "unsupported dashboard setting"): + self._load({"autostart": True}) + + def test_auto_start_must_be_a_boolean(self) -> None: + with self.assertRaisesRegex(ConfigError, "autoStart must be a boolean"): + self._load({"autoStart": 1}) + + def test_port_must_be_a_valid_port_number(self) -> None: + for bad in (True, 0, 65536, "8080"): + with self.assertRaisesRegex(ConfigError, "dashboard.port"): + self._load({"port": bad}) + + def test_non_object_dashboard_settings_are_rejected(self) -> None: + with self.assertRaisesRegex(ConfigError, "dashboard settings must be an object"): + self._load(["autoStart"]) + + if __name__ == "__main__": unittest.main() diff --git a/mcp/tests/test_dashboard_daemon.py b/mcp/tests/test_dashboard_daemon.py new file mode 100644 index 00000000..4dcf7bfc --- /dev/null +++ b/mcp/tests/test_dashboard_daemon.py @@ -0,0 +1,463 @@ +"""Tests for dashboard daemon supervision (260703 L2).""" + +from __future__ import annotations + +import contextlib +import fcntl +import io +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +MCP_SRC = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(MCP_SRC)) + +from agents_remember.cli import __main__ as cli_main +from agents_remember.cli import dashboard as cli_dashboard +from agents_remember.mcp.config import DashboardSettings, McpRuntimeConfig +from agents_remember.serving import daemon + + +def make_config(root: Path, *, auto_start: bool = False, port: int = 8765) -> McpRuntimeConfig: + return McpRuntimeConfig( + config_path=root / "settings.json", + coordination_root=root / "ar-coordination", + workspace_root=root / "workspace", + transcript_root=root / "ar-coordination" / "logs" / "mcp", + dashboard=DashboardSettings(auto_start=auto_start, port=port), + ) + + +def make_state(**overrides: object) -> daemon.DaemonState: + base: dict = { + "pid": 4242, + "host": "127.0.0.1", + "port": 8765, + "version": "3.0.0rc1", + "config_path": "/abs/settings.json", + "log_path": "/abs/dashboard.log", + "started_at": "2026-07-03T00:00:00+00:00", + } + base.update(overrides) + return daemon.DaemonState(**base) + + +class StateFileTests(unittest.TestCase): + def test_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + state = make_state() + daemon.write_state(directory, state) + self.assertEqual(daemon.read_state(directory), state) + self.assertEqual([], list(directory.glob("*.tmp"))) + + def test_state_file_uses_camel_case_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + daemon.write_state(directory, make_state()) + data = json.loads((directory / daemon.STATE_FILE_NAME).read_text(encoding="utf-8")) + self.assertEqual( + sorted(data), + ["configPath", "host", "logPath", "pid", "port", "startedAt", "version"], + ) + + def test_missing_and_malformed_states_read_as_none(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + self.assertIsNone(daemon.read_state(directory)) + path = directory / daemon.STATE_FILE_NAME + path.write_text("{not json", encoding="utf-8") + self.assertIsNone(daemon.read_state(directory)) + path.write_text(json.dumps([1, 2]), encoding="utf-8") + self.assertIsNone(daemon.read_state(directory)) + path.write_text(json.dumps({"pid": 1}), encoding="utf-8") + self.assertIsNone(daemon.read_state(directory)) + + def test_clear_state_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + daemon.write_state(directory, make_state()) + daemon.clear_state(directory) + daemon.clear_state(directory) + self.assertIsNone(daemon.read_state(directory)) + + +class ProbeTests(unittest.TestCase): + def test_probe_without_state_is_not_alive(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(daemon.probe(Path(tmp)), (None, False)) + + def test_probe_dead_pid_is_not_alive(self) -> None: + child = subprocess.Popen([sys.executable, "-c", "pass"]) + child.wait() + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + state = make_state(pid=child.pid) + daemon.write_state(directory, state) + self.assertEqual(daemon.probe(directory), (state, False)) + + def test_probe_live_pid_with_foreign_cmdline_is_stale(self) -> None: + # pid reuse across reboots: liveness alone must not resurrect a foreign process. + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + state = make_state(pid=1) # init is alive but is no dashboard + daemon.write_state(directory, state) + with mock.patch.object(daemon, "_pid_is_dashboard", return_value=False): + self.assertEqual(daemon.probe(directory), (state, False)) + + def test_probe_live_dashboard_pid_is_alive(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + state = make_state(pid=1) + daemon.write_state(directory, state) + with ( + mock.patch.object(daemon, "_pid_alive", return_value=True), + mock.patch.object(daemon, "_pid_is_dashboard", return_value=True), + ): + self.assertEqual(daemon.probe(directory), (state, True)) + + +def _spawn_sleeper(*, ignore_term: bool) -> subprocess.Popen[bytes]: + handler = "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" if ignore_term else "" + code = ( + "import signal, sys, time\n" + f"{handler}" + "print('ready', flush=True)\n" + "time.sleep(60)\n" + ) + process = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE) + assert process.stdout is not None + process.stdout.readline() # handler installed before anyone signals + return process + + +class StopTests(unittest.TestCase): + def test_stop_without_state_is_not_running(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(daemon.stop(Path(tmp)), "not-running") + + def test_stop_stale_state_clears_it(self) -> None: + child = subprocess.Popen([sys.executable, "-c", "pass"]) + child.wait() + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + daemon.write_state(directory, make_state(pid=child.pid)) + self.assertEqual(daemon.stop(directory), "not-running") + self.assertIsNone(daemon.read_state(directory)) + + def test_stop_terminates_a_cooperative_process(self) -> None: + process = _spawn_sleeper(ignore_term=False) + self.addCleanup(process.wait) + self.addCleanup(lambda: process.poll() is None and process.kill()) + # Registered so _wait_gone's reaping collects OUR child (a real daemon is + # reparented to init; a test child would otherwise linger as a zombie). + daemon._spawned.append(process) + self.addCleanup(lambda: process in daemon._spawned and daemon._spawned.remove(process)) + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + daemon.write_state(directory, make_state(pid=process.pid)) + with mock.patch.object(daemon, "_pid_is_dashboard", return_value=True): + self.assertEqual(daemon.stop(directory), "stopped") + self.assertIsNone(daemon.read_state(directory)) + + def test_stop_escalates_to_kill_when_term_is_ignored(self) -> None: + process = _spawn_sleeper(ignore_term=True) + self.addCleanup(process.wait) + self.addCleanup(lambda: process.poll() is None and process.kill()) + daemon._spawned.append(process) + self.addCleanup(lambda: process in daemon._spawned and daemon._spawned.remove(process)) + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + daemon.write_state(directory, make_state(pid=process.pid)) + with mock.patch.object(daemon, "_pid_is_dashboard", return_value=True): + self.assertEqual(daemon.stop(directory, timeout=0.5), "killed") + self.assertIsNone(daemon.read_state(directory)) + + +class SpawnTests(unittest.TestCase): + def test_spawn_launches_the_detached_cli_and_records_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config = make_config(Path(tmp)) + fake = mock.Mock() + fake.pid = 777 + with mock.patch.object(daemon.subprocess, "Popen", return_value=fake) as popen: + state = daemon.spawn(config, host="127.0.0.1", port=9100, version="9.9.9") + command = popen.call_args.args[0] + self.assertEqual(command[:4], [sys.executable, "-m", "agents_remember.cli", "dashboard"]) + self.assertIn("--config", command) + self.assertEqual(command[command.index("--config") + 1], str(config.config_path)) + self.assertEqual(command[command.index("--port") + 1], "9100") + self.assertEqual(command[command.index("--interval") + 1], "1.0") + self.assertIn("--no-access-log", command) + self.assertTrue(popen.call_args.kwargs["start_new_session"]) + self.assertEqual(state.pid, 777) + self.assertEqual(daemon.read_state(daemon.daemon_dir(config)), state) + daemon._spawned.remove(fake) + + def test_spawn_rotates_the_previous_log(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config = make_config(Path(tmp)) + directory = daemon.daemon_dir(config) + directory.mkdir(parents=True) + log = directory / daemon.LOG_FILE_NAME + log.write_text("old run\n", encoding="utf-8") + fake = mock.Mock() + fake.pid = 778 + with mock.patch.object(daemon.subprocess, "Popen", return_value=fake): + daemon.spawn(config, host="127.0.0.1", port=9100, version="9.9.9") + rotated = directory / "dashboard.log.1" + self.assertEqual(rotated.read_text(encoding="utf-8"), "old run\n") + daemon._spawned.remove(fake) + + +class EnsureTests(unittest.TestCase): + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.config = make_config(Path(tmp.name)) + self.directory = daemon.daemon_dir(self.config) + + def test_absent_daemon_is_started(self) -> None: + spawned = make_state(pid=101, port=9000, version="1.0") + with ( + mock.patch.object(daemon, "spawn", return_value=spawned) as spawn, + mock.patch.object(daemon, "_wait_ready", return_value=True), + mock.patch.object(daemon, "stop") as stop, + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=9000, version="1.0") + self.assertEqual(result.action, "started") + self.assertEqual(result.state, spawned) + spawn.assert_called_once_with( + self.config, host="127.0.0.1", port=9000, version="1.0", interval=1.0 + ) + stop.assert_not_called() + + def test_healthy_matching_daemon_is_adopted(self) -> None: + current = make_state(pid=202, port=9000, version="1.0") + with ( + mock.patch.object(daemon, "probe", return_value=(current, True)), + mock.patch.object(daemon, "spawn") as spawn, + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=9000, version="1.0") + self.assertEqual(result.action, "adopted") + self.assertEqual(result.state, current) + spawn.assert_not_called() + + def test_version_mismatch_restarts(self) -> None: + current = make_state(pid=303, port=9000, version="1.0") + fresh = make_state(pid=304, port=9000, version="2.0") + with ( + mock.patch.object(daemon, "probe", return_value=(current, True)), + mock.patch.object(daemon, "stop") as stop, + mock.patch.object(daemon, "spawn", return_value=fresh) as spawn, + mock.patch.object(daemon, "_wait_ready", return_value=True), + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=9000, version="2.0") + self.assertEqual(result.action, "restarted") + self.assertIn("version 1.0 -> 2.0", result.detail) + stop.assert_called_once() + spawn.assert_called_once() + + def test_port_mismatch_restarts(self) -> None: + current = make_state(pid=305, port=9000, version="1.0") + fresh = make_state(pid=306, port=9001, version="1.0") + with ( + mock.patch.object(daemon, "probe", return_value=(current, True)), + mock.patch.object(daemon, "stop") as stop, + mock.patch.object(daemon, "spawn", return_value=fresh), + mock.patch.object(daemon, "_wait_ready", return_value=True), + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=9001, version="1.0") + self.assertEqual(result.action, "restarted") + self.assertIn("port 9000 -> 9001", result.detail) + stop.assert_called_once() + + def test_child_dying_during_startup_fails_and_clears_state(self) -> None: + def fake_spawn(config: McpRuntimeConfig, **kwargs: object) -> daemon.DaemonState: + state = make_state(pid=407) + daemon.write_state(daemon.daemon_dir(config), state) + return state + + with ( + mock.patch.object(daemon, "spawn", side_effect=fake_spawn), + mock.patch.object(daemon, "_wait_ready", return_value=False), + mock.patch.object(daemon, "_pid_alive", return_value=False), + mock.patch.object(daemon, "_log_tail", return_value="boom"), + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=8765, version="1.0") + self.assertEqual(result.action, "failed") + self.assertIn("boom", result.detail) + self.assertIsNone(daemon.read_state(self.directory)) + + def test_slow_start_fails_but_keeps_state(self) -> None: + def fake_spawn(config: McpRuntimeConfig, **kwargs: object) -> daemon.DaemonState: + state = make_state(pid=408) + daemon.write_state(daemon.daemon_dir(config), state) + return state + + with ( + mock.patch.object(daemon, "spawn", side_effect=fake_spawn), + mock.patch.object(daemon, "_wait_ready", return_value=False), + mock.patch.object(daemon, "_pid_alive", return_value=True), + ): + result = daemon.ensure(self.config, host="127.0.0.1", port=8765, version="1.0") + self.assertEqual(result.action, "failed") + self.assertIn("may still be starting", result.detail) + self.assertIsNotNone(daemon.read_state(self.directory)) + + def test_held_lock_skips_without_spawning(self) -> None: + self.directory.mkdir(parents=True, exist_ok=True) + with (self.directory / daemon.LOCK_FILE_NAME).open("a") as held: + fcntl.flock(held, fcntl.LOCK_EX) + try: + with mock.patch.object(daemon, "spawn") as spawn: + result = daemon.ensure(self.config, host="127.0.0.1", port=8765) + finally: + fcntl.flock(held, fcntl.LOCK_UN) + self.assertEqual(result.action, "lock-held") + spawn.assert_not_called() + + +class AutostartTests(unittest.TestCase): + def test_disabled_autostart_is_a_no_op(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config = make_config(Path(tmp), auto_start=False) + with mock.patch.object(daemon, "ensure") as ensure: + self.assertIsNone(daemon.maybe_autostart_dashboard(config)) + ensure.assert_not_called() + + def test_enabled_autostart_ensures_on_the_settings_port(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config = make_config(Path(tmp), auto_start=True, port=9321) + outcome = daemon.EnsureResult(action="adopted", state=make_state(), detail="fine") + stderr = io.StringIO() + with ( + mock.patch.object(daemon, "ensure", return_value=outcome) as ensure, + contextlib.redirect_stderr(stderr), + ): + worker = daemon.maybe_autostart_dashboard(config) + self.assertIsNotNone(worker) + assert worker is not None + worker.join(timeout=10) + ensure.assert_called_once_with(config, host="127.0.0.1", port=9321) + self.assertIn("dashboard autostart: adopted", stderr.getvalue()) + + def test_autostart_swallows_ensure_failures(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config = make_config(Path(tmp), auto_start=True) + stderr = io.StringIO() + with ( + mock.patch.object(daemon, "ensure", side_effect=RuntimeError("nope")), + contextlib.redirect_stderr(stderr), + ): + worker = daemon.maybe_autostart_dashboard(config) + assert worker is not None + worker.join(timeout=10) + self.assertIn("dashboard autostart: failed: nope", stderr.getvalue()) + + +def _write_settings(root: Path, *, dashboard: dict | None = None) -> Path: + (root / "ar-coordination").mkdir(parents=True, exist_ok=True) + payload: dict = { + "version": 1, + "coordinationRoot": str(root / "ar-coordination"), + "workspaceRoot": str(root / "workspace"), + } + if dashboard is not None: + payload["dashboard"] = dashboard + path = root / "settings.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +class CliDaemonDispatchTests(unittest.TestCase): + def _run(self, root: Path, *flags: str) -> tuple[int, str]: + settings = _write_settings(root) if not (root / "settings.json").exists() else ( + root / "settings.json" + ) + args = cli_main.build_parser().parse_args( + ["dashboard", "--config", str(settings), *flags] + ) + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = cli_dashboard.run(args) + return code, stdout.getvalue() + + def test_status_and_stop_are_mutually_exclusive(self) -> None: + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stderr(io.StringIO()): + settings = _write_settings(Path(tmp)) + with self.assertRaises(SystemExit): + cli_main.build_parser().parse_args( + ["dashboard", "--config", str(settings), "--status", "--stop"] + ) + + def test_status_reports_not_running(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + code, out = self._run(Path(tmp), "--status") + self.assertEqual(code, 1) + self.assertIn("not running", out) + + def test_status_reports_a_running_daemon(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + state = make_state(pid=515) + with mock.patch.object(daemon, "probe", return_value=(state, True)): + code, out = self._run(Path(tmp), "--status") + self.assertEqual(code, 0) + self.assertIn("pid 515", out) + + def test_stop_reports_the_outcome(self) -> None: + with ( + tempfile.TemporaryDirectory() as tmp, + mock.patch.object(daemon, "stop", return_value="stopped") as stop, + ): + code, out = self._run(Path(tmp), "--stop") + self.assertEqual(code, 0) + self.assertIn("stopped", out) + stop.assert_called_once() + + def test_daemon_uses_the_settings_port_by_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_settings(root, dashboard={"port": 9555}) + outcome = daemon.EnsureResult(action="started", state=make_state(), detail="up") + with mock.patch.object(daemon, "ensure", return_value=outcome) as ensure: + code, out = self._run(root, "--daemon") + self.assertEqual(code, 0) + self.assertIn("started", out) + self.assertEqual(ensure.call_args.kwargs["port"], 9555) + + def test_daemon_explicit_port_wins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_settings(root, dashboard={"port": 9555}) + outcome = daemon.EnsureResult(action="started", state=make_state(), detail="up") + with mock.patch.object(daemon, "ensure", return_value=outcome) as ensure: + code, _ = self._run(root, "--daemon", "--port", "9666") + self.assertEqual(code, 0) + self.assertEqual(ensure.call_args.kwargs["port"], 9666) + + def test_daemon_failure_exits_nonzero(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + outcome = daemon.EnsureResult(action="failed", state=None, detail="no boot") + with mock.patch.object(daemon, "ensure", return_value=outcome): + code, out = self._run(Path(tmp), "--daemon") + self.assertEqual(code, 1) + self.assertIn("no boot", out) + + def test_daemon_with_sim_is_rejected(self) -> None: + with ( + tempfile.TemporaryDirectory() as tmp, + mock.patch.object(daemon, "ensure") as ensure, + ): + code, out = self._run(Path(tmp), "--daemon", "--sim", "/fixture") + self.assertEqual(code, 1) + self.assertIn("not supported", out) + ensure.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/mcp/tests/test_serving.py b/mcp/tests/test_serving.py index 3df46e7d..c40e9a80 100644 --- a/mcp/tests/test_serving.py +++ b/mcp/tests/test_serving.py @@ -754,6 +754,10 @@ def _args(self, **overrides: object) -> argparse.Namespace: "reload": False, "sim": None, "sim_speed": "1", + "daemon": False, + "status": False, + "stop": False, + "no_access_log": False, } base.update(overrides) return argparse.Namespace(**base) @@ -1358,6 +1362,10 @@ def _args(self, **overrides: object) -> argparse.Namespace: "reload": False, "sim": None, "sim_speed": "1", + "daemon": False, + "status": False, + "stop": False, + "no_access_log": False, } base.update(overrides) return argparse.Namespace(**base)