diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 44a3e17..4a64931 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "feed" -version = "3.1.1" +version = "3.1.2" api_version = 3 entrypoint = "plugin.py" diff --git a/content_source.py b/content_source.py index 171796b..e829c7e 100644 --- a/content_source.py +++ b/content_source.py @@ -174,7 +174,7 @@ async def _settle_delivered(self) -> int: row["settlement_ref"], "Feed settlement_ref" ) receipt = self._content.ack(settlement_ref) - if receipt.get("changed") is not True: + if receipt.get("settled") is not True: raise RuntimeError(f"Feed Content ACK 未提交: {receipt!r}") settled += 1 return settled diff --git a/mcp/tests/test_runtime_paths.py b/mcp/tests/test_runtime_paths.py index 7be0ad5..5c5613a 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.1" + assert plugin.version == "3.1.2" 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 18073d9..30d9a32 100644 --- a/plugin.py +++ b/plugin.py @@ -23,7 +23,7 @@ class FeedConfig(BaseModel): api_version = 3 name = "feed" -version = "3.1.1" +version = "3.1.2" desc = "由 Timer 驱动的 Feed Content source 与用户 MCP" Config = FeedConfig inject = (MCP_SERVERS, TIMERS, CONTENT_SOURCE) diff --git a/tests/test_content_source.py b/tests/test_content_source.py index 2d36283..6c0cdfc 100644 --- a/tests/test_content_source.py +++ b/tests/test_content_source.py @@ -58,6 +58,10 @@ def __init__(self) -> None: self.submissions: list[tuple[str, tuple[dict[str, object], ...]]] = [] self.rows: list[dict[str, object]] = [] self.fail_ack_once = False + self.ack_receipt: dict[str, object] = { + "settled": True, + "duplicate": False, + } def submit(self, batch_id, items): frozen = tuple(dict(item) for item in items) @@ -71,10 +75,13 @@ def ack(self, settlement_ref): if self.fail_ack_once: self.fail_ack_once = False raise RuntimeError("crash after provider ACK") - self.rows = [ - row for row in self.rows if row["settlement_ref"] != settlement_ref - ] - return {"changed": True} + if self.ack_receipt.get("settled") is True: + self.rows = [ + row + for row in self.rows + if row["settlement_ref"] != settlement_ref + ] + return dict(self.ack_receipt) def _seed_item(data_root: Path, now: datetime) -> None: @@ -177,3 +184,50 @@ async def test_provider_ack_retries_after_crash_before_content_ack( assert connection.execute( "SELECT event_id FROM acked_items" ).fetchall() == [("event-1",)] + + +@pytest.mark.asyncio +async def test_duplicate_content_ack_is_already_settled(tmp_path: Path) -> None: + now = datetime(2026, 8, 23, 10, tzinfo=UTC) + _seed_item(tmp_path, now) + content = _Content() + content.rows = [ + { + "ref": {"item_id": "event-1", "revision": "revision-1"}, + "settlement_ref": "delivery:1", + } + ] + content.ack_receipt = {"settled": True, "duplicate": True} + runtime = FeedContentRuntime( + tmp_path, + PluginTimers(_Timer(now)), + content, + now=lambda: now, + ) + + assert await runtime._settle_delivered() == 1 # pyright: ignore[reportPrivateUsage] + assert content.rows == [] + + +@pytest.mark.asyncio +async def test_unsettled_content_ack_fails_loud(tmp_path: Path) -> None: + now = datetime(2026, 8, 23, 10, tzinfo=UTC) + _seed_item(tmp_path, now) + content = _Content() + content.rows = [ + { + "ref": {"item_id": "event-1", "revision": "revision-1"}, + "settlement_ref": "delivery:1", + } + ] + content.ack_receipt = {"settled": False, "reason": "state_mismatch"} + runtime = FeedContentRuntime( + tmp_path, + PluginTimers(_Timer(now)), + content, + now=lambda: now, + ) + + with pytest.raises(RuntimeError, match="Feed Content ACK 未提交"): + await runtime._settle_delivered() # pyright: ignore[reportPrivateUsage] + assert content.rows diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 36e7be6..41ca83e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -51,7 +51,7 @@ 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.1" + assert plugin.version == "3.1.2" assert plugin.skill_roots == ("skills",) assert tuple(inspect.signature(plugin.apply).parameters) == ("ctx", "config") assert ComposablePlugin.from_module(plugin).skill_roots == ("skills",) @@ -102,7 +102,7 @@ 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.1" + assert manifest.version == "3.1.2" assert manifest.api_version == 3 assert manifest.requirements == ("mcp/requirements.txt",) assert "feed_mcp.sqlite3" in manifest.exclude_data_paths