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
2 changes: 1 addition & 1 deletion .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version = 1
name = "feed"
version = "3.1.2"
version = "3.1.3"
api_version = 3
entrypoint = "plugin.py"

Expand Down
3 changes: 1 addition & 2 deletions content_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion feed_runtime/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import fcntl
import hashlib
import json
import logging
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion mcp/tests/test_runtime_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
35 changes: 21 additions & 14 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)


Expand All @@ -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",
)
10 changes: 5 additions & 5 deletions tests/test_legacy_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
14 changes: 7 additions & 7 deletions tests/test_manager_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
34 changes: 29 additions & 5 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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
Expand Down
Loading