1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Merge adapter lifecycle safeguards

This commit is contained in:
Andraxion 2026-07-29 02:59:24 -04:00
commit 15a913003c
11 changed files with 541 additions and 11 deletions

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import hashlib
import importlib
import sqlite3
import sys
import tempfile
import unittest
from collections.abc import Mapping
@ -14,6 +16,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from docforge.adapter_contract import (
AdapterAssembly,
AdapterEdge,
AdapterImplementation,
AdapterManifest,
AdapterNode,
AdapterProject,
@ -346,6 +349,146 @@ class AdapterContractTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "confined"):
AdapterProject(Loader(self.projection(root)), cache_root=outside)
def test_adapter_implementation_changes_require_a_process_restart(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
implementation_root = root / "adapter"
implementation_root.mkdir()
implementation = implementation_root / "loader.py"
implementation.write_text("VERSION = 1\n", encoding="utf-8")
ignored = implementation_root / "loader.pyc"
ignored.write_bytes(b"derived")
project = AdapterProject(
Loader(self.projection(root)),
cache_root=root / ".cache" / "shadow",
settings=AdapterProjectSettings(
implementation=AdapterImplementation(
roots=(implementation_root,),
suffixes=(".py",),
)
),
)
ProjectIndex(project).build()
ignored.write_bytes(b"changed derived state")
project.validate_runtime()
implementation.write_text("VERSION = 2\n", encoding="utf-8")
with self.assertRaises(DocForgeError) as captured:
project.validate_runtime()
self.assertEqual("adapter_restart_required", captured.exception.code)
self.assertEqual(["adapter/loader.py"], captured.exception.details["changed"])
self.assertEqual([], captured.exception.details["added"])
self.assertEqual([], captured.exception.details["deleted"])
self.assertEqual(1, captured.exception.details["changed_count"])
self.assertFalse(captured.exception.details["paths_truncated"])
def test_adapter_implementation_additions_and_deletions_require_a_restart(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
implementation_root = root / "adapter"
implementation_root.mkdir()
original = implementation_root / "loader.py"
original.write_text("VERSION = 1\n", encoding="utf-8")
project = AdapterProject(
Loader(self.projection(root)),
cache_root=root / ".cache" / "shadow",
settings=AdapterProjectSettings(
implementation=AdapterImplementation(
roots=(implementation_root,),
suffixes=(".py",),
)
),
)
added = implementation_root / "helpers.py"
added.write_text("VALUE = 1\n", encoding="utf-8")
with self.assertRaises(DocForgeError) as addition:
project.load()
self.assertEqual("adapter_restart_required", addition.exception.code)
self.assertEqual(["adapter/helpers.py"], addition.exception.details["added"])
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
implementation_root = root / "adapter"
implementation_root.mkdir()
original = implementation_root / "loader.py"
original.write_text("VERSION = 1\n", encoding="utf-8")
project = AdapterProject(
Loader(self.projection(root)),
cache_root=root / ".cache" / "shadow",
settings=AdapterProjectSettings(
implementation=AdapterImplementation(
roots=(implementation_root,),
suffixes=(".py",),
)
),
)
original.unlink()
with self.assertRaises(DocForgeError) as deletion:
project.incremental_state()
self.assertEqual("adapter_restart_required", deletion.exception.code)
self.assertEqual(["adapter/loader.py"], deletion.exception.details["deleted"])
def test_adapter_implementation_is_inferred_from_a_project_local_package(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
package = root / "adapter_fixture_dynamic"
package.mkdir()
(package / "__init__.py").write_text("", encoding="utf-8")
(package / "loader.py").write_text(
"class Loader:\n"
" def __init__(self, projection):\n"
" self.projection = projection\n"
" def load_projection(self):\n"
" return self.projection\n",
encoding="utf-8",
)
sys.path.insert(0, str(root))
try:
module = importlib.import_module("adapter_fixture_dynamic.loader")
project = AdapterProject(
module.Loader(self.projection(root)),
cache_root=root / ".cache" / "shadow",
)
(package / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
with self.assertRaises(DocForgeError) as captured:
project.validate_runtime()
finally:
sys.path.remove(str(root))
sys.modules.pop("adapter_fixture_dynamic.loader", None)
sys.modules.pop("adapter_fixture_dynamic", None)
self.assertEqual("adapter_restart_required", captured.exception.code)
self.assertEqual(
["adapter_fixture_dynamic/helper.py"],
captured.exception.details["added"],
)
def test_adapter_descriptor_changes_require_a_restart(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
descriptor_root = root / ".docforge"
descriptor_root.mkdir()
descriptor = descriptor_root / "project.toml"
descriptor.write_text("adapter_version = 1\n", encoding="utf-8")
project = AdapterProject(
Loader(self.projection(root)),
cache_root=root / ".cache" / "shadow",
settings=AdapterProjectSettings(descriptor_path=descriptor),
)
descriptor.write_text("adapter_version = 2\n", encoding="utf-8")
with self.assertRaises(DocForgeError) as captured:
project.validate_runtime()
self.assertEqual("adapter_restart_required", captured.exception.code)
self.assertEqual(
[".docforge/project.toml"],
captured.exception.details["changed"],
)
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
@ -547,6 +690,45 @@ class AdapterContractTests(unittest.TestCase):
class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
async def test_mcp_reports_adapter_restart_remediation_without_synchronizing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
implementation_root = root / "adapter"
implementation_root.mkdir()
implementation = implementation_root / "loader.py"
implementation.write_text("VERSION = 1\n", encoding="utf-8")
fixture = AdapterContractTests()
project = AdapterProject(
Loader(fixture.projection(root)),
cache_root=root / ".cache" / "adapter-read-only",
settings=AdapterProjectSettings(
implementation=AdapterImplementation(
roots=(implementation_root,),
suffixes=(".py",),
)
),
)
ProjectIndex(project).build()
server = create_read_only_server(project)
implementation.write_text("VERSION = 2\n", encoding="utf-8")
async with create_connected_server_and_client_session(
server, raise_exceptions=True
) as session:
result = await session.call_tool("docforge_project_info", {})
self.assertEqual("error", result.structuredContent["status"])
self.assertEqual(
"adapter_restart_required",
result.structuredContent["error"]["code"],
)
self.assertEqual("stale", result.structuredContent["staleness"])
self.assertEqual(
{"retryable": False, "action": "restart_project_server"},
result.structuredContent["error"]["remediation"],
)
self.assertNotIn("synchronization", result.structuredContent)
async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()