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 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.1"
version = "3.1.2"
api_version = 3
entrypoint = "plugin.py"

Expand Down
2 changes: 1 addition & 1 deletion content_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.1"
assert plugin.version == "3.1.2"
assert plugin.skill_roots == ("skills",)
assert _config_path() == Path(__file__).resolve().parents[1] / "feed_mcp.json"

Expand Down
2 changes: 1 addition & 1 deletion plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 58 additions & 4 deletions tests/test_content_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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
Expand Down
Loading