From 8de7a54c569b60d8f0184e235c468e39ef7c4639 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 13:16:33 +0800 Subject: [PATCH] feat(eventmail): publish Feed content through plugin services --- .github/workflows/plugin-api-v3.yml | 2 +- akashic.plugin.toml | 2 +- content_source.py | 3 +-- feed_runtime/backend.py | 8 ++++++- mcp/tests/test_runtime_paths.py | 2 +- plugin.py | 35 +++++++++++++++++------------ tests/test_legacy_handoff.py | 10 ++++----- tests/test_manager_integration.py | 14 ++++++------ tests/test_plugin.py | 34 +++++++++++++++++++++++----- 9 files changed, 73 insertions(+), 37 deletions(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index d460cb4..a2cd21e 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 065dfb7fb37534c57ed500b1ed9b5deb7090cdb0 + ref: 39cbdcefc155aaf6c41deafd7754a37e6126c23c path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 4a64931..80e1735 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "feed" -version = "3.1.2" +version = "3.1.3" api_version = 3 entrypoint = "plugin.py" diff --git a/content_source.py b/content_source.py index e829c7e..3ff9e30 100644 --- a/content_source.py +++ b/content_source.py @@ -10,8 +10,7 @@ from pathlib import Path from typing import Protocol, cast -from agent.control.timer import TimerHandle, TimerStatus -from agent.plugin_composition import PluginTimers +from agent.plugin_composition import PluginTimers, TimerHandle, TimerStatus from .feed_runtime import CONTENT_SOURCE_ID, backend diff --git a/feed_runtime/backend.py b/feed_runtime/backend.py index ec63738..c8a7c10 100644 --- a/feed_runtime/backend.py +++ b/feed_runtime/backend.py @@ -9,6 +9,7 @@ from __future__ import annotations +import fcntl import hashlib import json import logging @@ -119,7 +120,12 @@ def _connect(cfg: FeedMcpConfig) -> sqlite3.Connection: conn = sqlite3.connect(cfg.db_path, timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout=30000") - conn.execute("PRAGMA journal_mode=WAL") + # SQLite does not wait reliably when two processes first enable WAL. + # Serialize only that one-time database mode transition. + lock_path = cfg.db_path.with_suffix(cfg.db_path.suffix + ".init.lock") + with lock_path.open("a+b") as init_lock: + fcntl.flock(init_lock, fcntl.LOCK_EX) + conn.execute("PRAGMA journal_mode=WAL") conn.execute( """ CREATE TABLE IF NOT EXISTS sources ( diff --git a/mcp/tests/test_runtime_paths.py b/mcp/tests/test_runtime_paths.py index 5c5613a..3092944 100644 --- a/mcp/tests/test_runtime_paths.py +++ b/mcp/tests/test_runtime_paths.py @@ -27,7 +27,7 @@ def test_runtime_entrypoints_reject_missing_data_dir( def test_v3_module_keeps_skill_root_and_identity_exports() -> None: assert plugin.api_version == 3 assert plugin.name == "feed" - assert plugin.version == "3.1.2" + assert plugin.version == "3.1.3" assert plugin.skill_roots == ("skills",) assert _config_path() == Path(__file__).resolve().parents[1] / "feed_mcp.json" diff --git a/plugin.py b/plugin.py index 30d9a32..fd5eda5 100644 --- a/plugin.py +++ b/plugin.py @@ -19,14 +19,14 @@ class FeedConfig(BaseModel): pass -CONTENT_SOURCE = ServiceKey[ContentSourceServices]("content.source.v1") +CONTENT_SOURCE = ServiceKey[ContentSourceServices]("eventmail.content_source.v1") api_version = 3 name = "feed" -version = "3.1.2" +version = "3.1.3" desc = "由 Timer 驱动的 Feed Content source 与用户 MCP" Config = FeedConfig -inject = (MCP_SERVERS, TIMERS, CONTENT_SOURCE) +inject = (MCP_SERVERS, TIMERS) skill_roots = ("skills",) @@ -48,16 +48,23 @@ async def apply(ctx: Context, config: object) -> None: ), ) - # 2. 正式 Root 独占外部轮询与 Content ACK。 - runtime = FeedContentRuntime( - ctx.data_root, - ctx.require(TIMERS), - ctx.require(CONTENT_SOURCE).bind(CONTENT_SOURCE_ID), - ) + # 2. EventMail 存在时,独立子 Fiber 才启动主动来源。 + async def apply_eventmail(source_ctx: Context) -> None: + runtime = FeedContentRuntime( + source_ctx.data_root, + source_ctx.require(TIMERS), + source_ctx.require(CONTENT_SOURCE).bind(CONTENT_SOURCE_ID), + ) + + def setup() -> object: + return runtime.close - def setup() -> object: - return runtime.close + _ = await source_ctx.effect(setup, label="feed-content-source-runtime") + _ = await source_ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) + _ = await source_ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) - _ = await ctx.effect(setup, label="feed-content-source-runtime") - _ = await ctx.on(RUNTIME_STARTED, lambda _: runtime.start()) - _ = await ctx.on(RUNTIME_STOPPING, lambda _: runtime.close()) + _ = await ctx.inject( + (TIMERS, CONTENT_SOURCE), + apply_eventmail, + name="feed-eventmail-source", + ) diff --git a/tests/test_legacy_handoff.py b/tests/test_legacy_handoff.py index e897475..3253022 100644 --- a/tests/test_legacy_handoff.py +++ b/tests/test_legacy_handoff.py @@ -19,7 +19,7 @@ LegacyFactKind, apply_handoff, ) -from plugins.content.store import ContentIdentityConflict, ContentStore +from plugins.eventmail.store import EventMailIdentityConflict, EventMailStore from feed_runtime import backend from legacy_handoff import FeedLegacyHandoffAdapter, LEGACY_SOURCE_ID @@ -30,7 +30,7 @@ class _BoundContent: - def __init__(self, store: ContentStore, source_id: str = TARGET_SOURCE) -> None: + def __init__(self, store: EventMailStore, source_id: str = TARGET_SOURCE) -> None: self.store = store self.source_id = source_id @@ -136,10 +136,10 @@ def _seed_provider(data_root: Path, rows: Sequence[Mapping[str, object]]) -> Non def _fixture( tmp_path: Path, -) -> tuple[Path, ContentStore, _BoundContent, FeedLegacyHandoffAdapter]: +) -> tuple[Path, EventMailStore, _BoundContent, FeedLegacyHandoffAdapter]: data_root = tmp_path / "feed-data" _seed_provider(data_root, _legacy_rows()) - store = ContentStore(tmp_path / "content.sqlite3") + store = EventMailStore(tmp_path / "content.sqlite3") store.initialize() bound = _BoundContent(store) return data_root, store, bound, FeedLegacyHandoffAdapter(data_root, bound) @@ -373,7 +373,7 @@ def test_revision_change_after_target_is_a_batch_conflict(tmp_path: Path) -> Non connection.close() changed = adapter.plan(fact) - with pytest.raises(ContentIdentityConflict, match="batch identity conflict"): + with pytest.raises(EventMailIdentityConflict, match="batch identity conflict"): adapter.apply(fact, changed) assert store.state_counts() == {"pending": 1} diff --git a/tests/test_manager_integration.py b/tests/test_manager_integration.py index 2d68ecf..71d0b43 100644 --- a/tests/test_manager_integration.py +++ b/tests/test_manager_integration.py @@ -15,7 +15,7 @@ from agent.control.timer import TimerReceipt, TimerStatus from agent.plugins.manager import PluginManager from bus.event_bus import EventBus -from plugins.content.store import ContentStore +from plugins.eventmail.store import EventMailStore from feed_runtime import backend @@ -86,9 +86,9 @@ def _stage_plugins(tmp_path: Path) -> tuple[Path, Path]: runtime = _fixture_runtime() plugins = tmp_path / "plugins" - content = plugins / "content" + content = plugins / "eventmail" feed = plugins / "feed" - shutil.copytree(CORE_ROOT / "plugins" / "content", content) + shutil.copytree(CORE_ROOT / "plugins" / "eventmail", content) shutil.copytree( ROOT, feed, @@ -111,9 +111,9 @@ def _stage_legacy_plugins(tmp_path: Path) -> tuple[Path, Path]: runtime = _fixture_runtime() plugins = tmp_path / "plugins" - content = plugins / "content" + content = plugins / "eventmail" feed = plugins / "feed" - shutil.copytree(CORE_ROOT / "plugins" / "content", content) + shutil.copytree(CORE_ROOT / "plugins" / "eventmail", content) shutil.copytree(ROOT / "tests" / "fixtures" / "legacy_feed_owner", feed) (feed / "mcp" / ".venv").symlink_to(runtime, target_is_directory=True) return content, feed @@ -241,9 +241,9 @@ def timer_factory() -> _Timer: formal_timer = next(timer for timer in timers if timer.handles) formal_timer.handles[0].fire() content_path = ( - workspace / "plugin-data" / "content-builtin" / "content.sqlite3" + workspace / "plugin-data" / "eventmail-builtin" / "eventmail.sqlite3" ) - content_store = ContentStore(content_path) + content_store = EventMailStore(content_path) await _eventually( lambda: content_store.state_counts().get("pending") == 1 ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 41ca83e..fcfff01 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -51,11 +51,11 @@ def bind(self, source_id: str) -> BoundContentSource: def test_pure_v3_exports_and_exact_apply() -> None: assert plugin.api_version == 3 assert plugin.name == "feed" - assert plugin.version == "3.1.2" + assert plugin.version == "3.1.3" assert plugin.skill_roots == ("skills",) assert tuple(inspect.signature(plugin.apply).parameters) == ("ctx", "config") assert ComposablePlugin.from_module(plugin).skill_roots == ("skills",) - assert "content.source.v1" in inspect.getsource(plugin) + assert "eventmail.content_source.v1" in inspect.getsource(plugin) @pytest.mark.asyncio @@ -77,6 +77,7 @@ async def test_apply_registers_user_mcp_and_dormant_content_runtime( name="feed", runtime=PluginRuntime( plugin_id="feed", + generation_id="feed:test", plugin_dir=ROOT, data_dir=data_dir, workspace=tmp_path / "workspace", @@ -92,17 +93,40 @@ async def test_apply_registers_user_mcp_and_dormant_content_runtime( assert not data_dir.exists() topology = root.topology_view() assert topology.listeners == ( - "serial:runtime.started:feed", - "serial:runtime.stopping:feed", + "serial:runtime.started:feed-eventmail-source", + "serial:runtime.stopping:feed-eventmail-source", ) await root.dispose() +@pytest.mark.asyncio +async def test_apply_keeps_user_mcp_without_eventmail(tmp_path: Path) -> None: + root = CompositionRoot("feed:without-eventmail") + servers = PluginMcpServers(root.instance_token) + await root.context.provide(MCP_SERVERS, servers) + await root.context.provide(TIMERS, PluginTimers.candidate_validation()) + await root.mount( + ComposablePlugin.from_module(plugin), + name="feed", + runtime=PluginRuntime( + plugin_id="feed", + generation_id="feed:without-eventmail", + plugin_dir=ROOT, + data_dir=tmp_path / "plugin-data", + workspace=tmp_path / "workspace", + config=plugin.FeedConfig(), + ), + ) + + assert "feed" in _freeze_plugin_mcp_servers(servers, root.instance_token) + await root.dispose() + + def test_static_manifest_freezes_tools_and_data_exclusions() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == "feed" - assert manifest.version == "3.1.2" + assert manifest.version == "3.1.3" assert manifest.api_version == 3 assert manifest.requirements == ("mcp/requirements.txt",) assert "feed_mcp.sqlite3" in manifest.exclude_data_paths