diff --git a/agent/lifecycle/composition.py b/agent/lifecycle/composition.py index a6060b5b4..5ce7fb659 100644 --- a/agent/lifecycle/composition.py +++ b/agent/lifecycle/composition.py @@ -72,20 +72,3 @@ async def observe_composition_event( # 2. Observe owns failure isolation for ordinary plugin listeners; binding # and caller cancellation failures remain fail-loud at this boundary. await snapshot.composition_root.context.observe(key, payload) - - -async def observe_composition_domain_event(event: object) -> None: - """Bridge one domain event to its request-bound ObserveEventKey.""" - - # 1. Resolve only the three domain facts that have a stable v3 Observe seam. - from agent.turn_events.observe import ( - MEMORY_WRITTEN_EVENT, - RETRIEVAL_COMPLETED_EVENT, - ) - from core.memory.events import MemoryWritten, RetrievalCompleted - - if isinstance(event, RetrievalCompleted): - await observe_composition_event(RETRIEVAL_COMPLETED_EVENT, event) - return - if isinstance(event, MemoryWritten): - await observe_composition_event(MEMORY_WRITTEN_EVENT, event) diff --git a/agent/lifecycle/phases/after_reasoning.py b/agent/lifecycle/phases/after_reasoning.py index e050d1f53..d7db5751c 100644 --- a/agent/lifecycle/phases/after_reasoning.py +++ b/agent/lifecycle/phases/after_reasoning.py @@ -593,9 +593,7 @@ async def run(self, frame: AfterReasoningFrame) -> AfterReasoningFrame: def default_after_reasoning_modules( bus: EventBus, session_services: SessionServices, - plugin_modules: AfterReasoningModules | None = None, ) -> AfterReasoningModules: - legacy_modules = list(plugin_modules or []) builtins: AfterReasoningModules = [ _BuildAfterReasoningCtxModule(), _EmitAfterReasoningCtxModule(bus), @@ -611,7 +609,7 @@ def default_after_reasoning_modules( ] return cast( AfterReasoningModules, - topo_sort_modules(builtins + legacy_modules), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/after_step.py b/agent/lifecycle/phases/after_step.py index efe3db924..94f4ef67a 100644 --- a/agent/lifecycle/phases/after_step.py +++ b/agent/lifecycle/phases/after_step.py @@ -99,7 +99,6 @@ async def run(self, frame: AfterStepFrame) -> AfterStepFrame: def default_after_step_modules( bus: EventBus, - plugin_modules: AfterStepModules | None = None, ) -> AfterStepModules: builtins: AfterStepModules = [ _CopyInputToCtxModule(), @@ -117,5 +116,5 @@ def default_after_step_modules( ] return cast( AfterStepModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/after_turn.py b/agent/lifecycle/phases/after_turn.py index f22404aed..372b22fb1 100644 --- a/agent/lifecycle/phases/after_turn.py +++ b/agent/lifecycle/phases/after_turn.py @@ -386,7 +386,6 @@ def default_after_turn_modules( bus: EventBus, outbound: OutboundPort, context: ContextBuilder, - plugin_modules: AfterTurnModules | None = None, ) -> AfterTurnModules: builtins: AfterTurnModules = [ _BuildTurnWorkModule(context), @@ -402,5 +401,5 @@ def default_after_turn_modules( ] return cast( AfterTurnModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/before_reasoning.py b/agent/lifecycle/phases/before_reasoning.py index 892d1213e..45c106c51 100644 --- a/agent/lifecycle/phases/before_reasoning.py +++ b/agent/lifecycle/phases/before_reasoning.py @@ -158,7 +158,6 @@ def default_before_reasoning_modules( tools: ToolRegistry, session_manager: SessionManager, context: ContextBuilder, - plugin_modules: BeforeReasoningModules | None = None, ) -> BeforeReasoningModules: builtins: BeforeReasoningModules = [ _SyncToolContextModule(tools, session_manager), @@ -170,5 +169,5 @@ def default_before_reasoning_modules( ] return cast( BeforeReasoningModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/before_step.py b/agent/lifecycle/phases/before_step.py index 2117c5adb..04ffbbda7 100644 --- a/agent/lifecycle/phases/before_step.py +++ b/agent/lifecycle/phases/before_step.py @@ -114,7 +114,6 @@ async def run(self, frame: BeforeStepFrame) -> BeforeStepFrame: def default_before_step_modules( bus: EventBus, - plugin_modules: BeforeStepModules | None = None, ) -> BeforeStepModules: builtins: BeforeStepModules = [ _BuildBeforeStepCtxModule(), @@ -125,5 +124,5 @@ def default_before_step_modules( ] return cast( BeforeStepModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/before_turn.py b/agent/lifecycle/phases/before_turn.py index e9d9298f7..c6ef4ae9b 100644 --- a/agent/lifecycle/phases/before_turn.py +++ b/agent/lifecycle/phases/before_turn.py @@ -169,8 +169,6 @@ def default_before_turn_modules( bus: EventBus, session_manager: SessionManager, context_store: ContextStore, - *, - plugin_modules: BeforeTurnModules | None = None, ) -> BeforeTurnModules: builtins: BeforeTurnModules = [ _AcquireSessionModule(session_manager), @@ -183,5 +181,5 @@ def default_before_turn_modules( ] return cast( BeforeTurnModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/lifecycle/phases/prompt_render.py b/agent/lifecycle/phases/prompt_render.py index 719406ad8..0abb75636 100644 --- a/agent/lifecycle/phases/prompt_render.py +++ b/agent/lifecycle/phases/prompt_render.py @@ -148,7 +148,6 @@ async def run(self, frame: PromptRenderFrame) -> PromptRenderFrame: def default_prompt_render_modules( bus: EventBus, context: ContextBuilder, - plugin_modules: PromptRenderModules | None = None, ) -> PromptRenderModules: builtins: PromptRenderModules = [ _BuildPromptRenderCtxModule(), @@ -159,7 +158,7 @@ def default_prompt_render_modules( ] return cast( PromptRenderModules, - topo_sort_modules(builtins + list(plugin_modules or [])), + topo_sort_modules(builtins), ) diff --git a/agent/migrations/payloads/eventmail_v3.py b/agent/migrations/payloads/eventmail_v3.py index 0f5c16c39..cfa717950 100644 --- a/agent/migrations/payloads/eventmail_v3.py +++ b/agent/migrations/payloads/eventmail_v3.py @@ -492,19 +492,13 @@ class ContentTransitionResult(TypedDict): class EventMailV3MigrationStore: """Persist Content revisions and expose source- and Wake-scoped transitions.""" - def __init__( - self, - path: Path, - *, - data_access: Literal["read_write", "read_only"] = "read_write", - ) -> None: + def __init__(self, path: Path) -> None: self.path = path - self.data_access = data_access def initialize(self) -> None: """Create or validate the exact schema and SQLite file integrity.""" - with self._transaction(write=self.data_access == "read_write") as connection: + with self._transaction(write=True) as connection: self._validate_schema(connection) result = connection.execute("PRAGMA integrity_check").fetchone() if result is None or result[0] != "ok": @@ -2340,36 +2334,19 @@ def state_counts(self) -> dict[str, int]: @contextmanager def _transaction(self, *, write: bool) -> Generator[sqlite3.Connection]: - """Open one mode-aware SQLite transaction and close it at the boundary.""" + """Open one SQLite transaction and close it at the boundary.""" - # 1. Reject every candidate write at the store's single transaction boundary. - if write and self.data_access == "read_only": - raise PermissionError( - "Content read-only candidate cannot write shared data" - ) - - # 2. Preserve the formal store's serialized transaction and lazy schema setup. - if self.data_access == "read_write": - self.path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(self.path) - else: - database_uri = self.path.resolve(strict=False).as_uri() + "?mode=ro" - connection = sqlite3.connect(database_uri, uri=True) + _ = write + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path) connection.row_factory = sqlite3.Row try: - if self.data_access == "read_write": - _ = connection.execute("PRAGMA journal_mode = WAL") - _ = connection.execute("PRAGMA foreign_keys = ON") - _ = connection.execute("BEGIN IMMEDIATE") - self._ensure_schema(connection) - else: - _ = connection.execute("PRAGMA query_only = ON") - _ = connection.execute("BEGIN") + _ = connection.execute("PRAGMA journal_mode = WAL") + _ = connection.execute("PRAGMA foreign_keys = ON") + _ = connection.execute("BEGIN IMMEDIATE") + self._ensure_schema(connection) yield connection - if self.data_access == "read_write": - connection.commit() - else: - connection.rollback() + connection.commit() except BaseException: connection.rollback() raise diff --git a/agent/plugin_composition/__init__.py b/agent/plugin_composition/__init__.py index 1f6f73bc6..7101b4e20 100644 --- a/agent/plugin_composition/__init__.py +++ b/agent/plugin_composition/__init__.py @@ -4,7 +4,6 @@ Fiber, FiberHandle, HealthHandle, - Plugin, RuntimeScope, ) from agent.plugin_composition.overlay import ( @@ -443,7 +442,6 @@ "MobileUiRegistry", "MobileUiRpcInvalidRequest", "ObserveEventKey", - "Plugin", "PluginChannels", "PluginCommands", "PluginBackgroundJobs", diff --git a/agent/plugin_composition/context.py b/agent/plugin_composition/context.py index ce943e7d9..3e36b28bb 100644 --- a/agent/plugin_composition/context.py +++ b/agent/plugin_composition/context.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from pathlib import Path from types import ModuleType -from typing import Any, AsyncGenerator, Literal, Protocol, TypeVar, cast +from typing import Any, AsyncGenerator, TypeVar, cast from agent.plugin_composition.effect import Effect, EffectSetup from agent.plugin_composition.diagnostics import ( @@ -53,10 +53,6 @@ FiberObserver = Callable[["Fiber"], object] -class Plugin(Protocol): - def apply(self, ctx: Context) -> object: ... - - class RuntimeScope: """Carry one exact snapshot from a source callback into one async operation.""" @@ -209,12 +205,6 @@ def data_root(self) -> Path: return self.runtime.data_dir - @property - def data_access(self) -> Literal["read_write", "read_only"]: - """Return the access mode assigned to this exact plugin Root.""" - - return self.runtime.data_access - def workspace_root(self, name: str) -> Path: """返回 Core 为当前 generation 投影的声明式 workspace root。""" @@ -235,13 +225,15 @@ def _set_static_active(self, active: bool) -> None: async def mount( self, - plugin: Plugin | PluginApply, + plugin: PluginApply, *, name: str | None = None, inject: Iterable[ServiceKey[object]] | None = None, required_for_readiness: bool = True, ) -> FiberHandle: reject_executor_context_access() + if not callable(plugin) or hasattr(plugin, "apply"): + raise TypeError("Context.mount 只接受 child callable") fiber = await self._root._mount( parent=self._fiber, plugin=plugin, @@ -249,6 +241,8 @@ async def mount( inject=inject, required_for_readiness=required_for_readiness, runtime=self._fiber.runtime, + plugin_module=self._fiber.plugin_module, + static_active=True, ) return FiberHandle(fiber) @@ -873,7 +867,7 @@ def on_dispose(self, observer: FiberObserver) -> Callable[[], None]: async def mount( self, - plugin: Plugin | PluginApply, + plugin: PluginApply, *, name: str | None = None, inject: Iterable[ServiceKey[object]] | None = None, @@ -886,6 +880,31 @@ async def mount( inject=inject, required_for_readiness=True, runtime=runtime, + plugin_module=None, + static_active=True, + ) + + async def _mount_module( + self, + plugin: PluginApply, + *, + name: str, + inject: Iterable[ServiceKey[object]], + runtime: PluginRuntime, + plugin_module: ModuleType, + static_active: bool, + ) -> Fiber: + """Mount one Manager-validated V3 module adapter.""" + + return await self._mount( + parent=self.root_fiber, + plugin=plugin, + name=name, + inject=inject, + required_for_readiness=True, + runtime=runtime, + plugin_module=plugin_module, + static_active=static_active, ) async def dispose(self) -> None: @@ -1174,11 +1193,13 @@ async def _mount( self, *, parent: Fiber, - plugin: Plugin | PluginApply, + plugin: PluginApply, name: str | None, inject: Iterable[ServiceKey[object]] | None, required_for_readiness: bool, runtime: PluginRuntime | None, + plugin_module: ModuleType | None, + static_active: bool, ) -> Fiber: """Publish only after parent ownership exists, then reconcile.""" @@ -1200,7 +1221,6 @@ async def _mount( ) # 2. Parent ownership is visible before publication observers run. - static_active = getattr(plugin, "static_active", True) if not isinstance(static_active, bool): raise TypeError("插件 static_active 必须是 bool") fiber = Fiber( @@ -1212,11 +1232,7 @@ async def _mount( parent=parent, required_for_readiness=required_for_readiness, runtime=runtime, - plugin_module=( - module - if isinstance((module := getattr(plugin, "module", None)), ModuleType) - else parent.plugin_module - ), + plugin_module=plugin_module, static_active=static_active, ) self._next_fiber_id += 1 @@ -1240,23 +1256,19 @@ async def _mount( def _resolve_plugin( self, - plugin: Plugin | PluginApply, + plugin: PluginApply, *, name: str | None, inject: Iterable[ServiceKey[object]] | None, ) -> tuple[PluginApply, str, tuple[ServiceKey[object], ...]]: - if callable(plugin) and not hasattr(plugin, "apply"): - apply = cast(PluginApply, plugin) - else: - candidate = getattr(plugin, "apply", None) - if not callable(candidate): - raise TypeError("插件必须是 callable 或提供 apply(ctx)") - apply = cast(PluginApply, candidate) + if not callable(plugin) or hasattr(plugin, "apply"): + raise TypeError("插件必须是 callable") + apply = plugin resolved_name = name or str(getattr(plugin, "name", "")).strip() resolved_name = resolved_name or getattr(apply, "__name__", "plugin") raw_dependencies = inject if raw_dependencies is None: - raw_dependencies = getattr(plugin, "inject", ()) + raw_dependencies = () dependencies = tuple(cast(Iterable[ServiceKey[object]], raw_dependencies)) if len(set(dependencies)) != len(dependencies): raise ValueError(f"插件依赖重复: {resolved_name}") diff --git a/agent/plugin_composition/model.py b/agent/plugin_composition/model.py index b41befbb0..870276d25 100644 --- a/agent/plugin_composition/model.py +++ b/agent/plugin_composition/model.py @@ -5,7 +5,7 @@ from pathlib import Path from collections.abc import Mapping from types import MappingProxyType -from typing import Generic, Literal, TypeVar, cast +from typing import Generic, TypeVar, cast T = TypeVar("T", covariant=True) @@ -114,7 +114,6 @@ class PluginRuntime: config: object workspace_roots: tuple[str, ...] = () workspace_files: tuple[str, ...] = () - data_access: Literal["read_write", "read_only"] = "read_write" def workspace_root(self, name: str) -> Path: """解析插件声明过的产品级 workspace 顶层目录。""" diff --git a/agent/plugin_composition/tool_catalog.py b/agent/plugin_composition/tool_catalog.py index 2f9ccb86d..795a23331 100644 --- a/agent/plugin_composition/tool_catalog.py +++ b/agent/plugin_composition/tool_catalog.py @@ -335,7 +335,7 @@ async def register( *, provided_for: ServiceKey[object] | None = None, ) -> None: - """Bind Root-local handlers; omission preserves stateless legacy exports.""" + """Bind a Root-local handler or a named module export.""" if ( ctx._root_instance_token() is not self._root_instance_token diff --git a/agent/plugins/install.py b/agent/plugins/install.py index 190776c8a..9aee2fe17 100644 --- a/agent/plugins/install.py +++ b/agent/plugins/install.py @@ -21,7 +21,6 @@ ) from agent.plugins.manifest import ( ensure_workspace_plugin_data_dir, - load_package_manifest, load_plugin_manifest, remove_plugin_manifest_entry, set_plugin_enabled, @@ -156,8 +155,6 @@ def install_git_plugin( # 1. 在任何 cache 改动前校验 manifest,避免坏配置把安装事务推到半路 _ = load_plugin_manifest(home) - _ = load_package_manifest(home) - with tempfile.TemporaryDirectory( dir=marketplace_root, prefix="clone-" ) as clone_dir: diff --git a/agent/plugins/manager.py b/agent/plugins/manager.py index e744ddac2..4f4eeeee3 100644 --- a/agent/plugins/manager.py +++ b/agent/plugins/manager.py @@ -10,6 +10,7 @@ import os import secrets import shutil +import sqlite3 import sys import tomllib from dataclasses import dataclass, replace @@ -108,18 +109,12 @@ from agent.plugins.manifest import ( ensure_workspace_plugin_data_dir, - load_package_manifest, load_plugin_manifest, plugins_root, validate_workspace_plugin_data_path, workspace_plugin_data_dir, - write_package_manifest, write_plugin_manifest, ) -from agent.plugins.packages import ( - _select_enabled_plugin_packages, # pyright: ignore[reportPrivateUsage] - discover_plugin_packages, -) from infra.channels.base import SessionIdentityIndex from infra.channels.artifacts import ChannelAttachmentArtifactStore from session.store import ChannelIdentityWriteReceipt @@ -210,14 +205,6 @@ async def aclose(self) -> None: return None -def _package_project_root(plugin_dirs: list[Path]) -> Path | None: - for plugin_dir in plugin_dirs: - root = plugin_dir.parent if plugin_dir.name == "plugins" else None - if root is not None and (root / "plugin_packages").is_dir(): - return root - return None - - async def _complete_critical(awaitable: Awaitable[U]) -> tuple[U, bool]: """在外部取消后完成关键异步操作,并返回是否收到取消。""" @@ -1147,21 +1134,7 @@ def reload_journal(self) -> ReloadJournal: def sync_manifest(self, *, plugins_home: Path | None = None) -> Path: entries = load_plugin_manifest(plugins_home) - project_root = _package_project_root(self._dirs) - if project_root is not None: - packages = discover_plugin_packages(project_root) - package_entries = load_package_manifest(plugins_home) - for package_id, package in packages.items(): - if package_id not in package_entries: - package_entries[package_id] = any( - entries.get(member, False) for member in package.members - ) - for member in package.members: - entries.pop(member, None) - _ = write_package_manifest(package_entries, plugins_home=plugins_home) for mod in self.discover(installed_selector="latest"): - if mod.get("package_id"): - continue _ = entries.setdefault(_resolve_plugin_id(mod), True) return write_plugin_manifest(entries, plugins_home=plugins_home) @@ -1234,26 +1207,6 @@ def discover( ) -> list[dict[str, str]]: mods: list[dict[str, str]] = [] seen_names: set[str] = set() - project_root = _package_project_root(self._dirs) - packages = discover_plugin_packages(project_root) if project_root else {} - enabled_packages = ( - _select_enabled_plugin_packages( - packages, - load_package_manifest(_plugins_home(self._installed_cache_root)), - ) - if project_root - else {} - ) - member_packages = { - member: package.id - for package in packages.values() - for member in package.members - } - enabled_members = { - member - for package in enabled_packages.values() - for member in package.members - } for source in resolve_plugin_sources( self._dirs, installed_cache_root=self._installed_cache_root, @@ -1265,9 +1218,6 @@ def discover( and name in self._disabled_builtin_plugins ): continue - package_id = member_packages.get(name, "") - if package_id and name not in enabled_members: - continue if name in seen_names and source.source_type == "builtin": logger.warning("插件名重复,跳过: %s (%s)", name, source.plugin_root) continue @@ -1289,7 +1239,6 @@ def discover( "import_path": f"akasic_plugin_{import_source}_{import_suffix}", "marketplace": source.marketplace, "source_type": source.source_type, - "package_id": package_id, } ) return mods @@ -2286,7 +2235,7 @@ async def _reconcile_changed_locked(self) -> list[dict[str, object]]: desired = { plugin_id for plugin_id, mod in discovered.items() - if mod.get("package_id") or manifest.get(plugin_id, True) + if manifest.get(plugin_id, True) } for plugin_id in sorted(set(self._active_generations) - desired): results.append(await self._deactivate_plugin(plugin_id)) @@ -2757,10 +2706,6 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: old_commands = _snapshot_command_catalog(self.current_snapshot) new_commands = _snapshot_command_catalog(ready.snapshot) stable_snapshot = self.current_snapshot - shared_handoff = _requires_shared_candidate_handoff( - ready.previous, - generation, - ) v3_runtime_handoff = self._composition_runtime_declared( ready.snapshot, plugin_id, @@ -2785,7 +2730,6 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: exclusive_endpoint_changed or command_catalog_changed or v3_channel_catalog_changed - or shared_handoff or formal_root_handoff ) from agent.plugins.snapshot import get_current_runtime_lease @@ -2793,7 +2737,6 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: if ( exclusive_endpoint_changed or v3_channel_catalog_changed - or shared_handoff ) and get_current_runtime_lease() is not None: raise RuntimeError( "持有 RuntimeSnapshot lease 时不能切换 Channel runtime" @@ -2809,7 +2752,7 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: ) quiesced_snapshot = ( self._snapshot_store.pause_admission() - if publication_gated and not shared_handoff + if publication_gated else None ) runtime_restore_started = False @@ -2818,10 +2761,6 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: provisional_cancelled = False if publication_gated: try: - if shared_handoff: - await self._snapshot_store.wait_for_no_leases(ready.snapshot) - self._snapshot_store.seal_candidate_validation(ready.snapshot) - quiesced_snapshot = self._snapshot_store.pause_admission() if ( exclusive_endpoint_changed and self._endpoint_quiescer is not None @@ -2830,13 +2769,11 @@ async def switch_ready(self, plugin_id: str) -> dict[str, object]: if quiesced_snapshot is not None and ( exclusive_endpoint_changed or v3_channel_catalog_changed - or shared_handoff or formal_root_handoff ): await self._snapshot_store.wait_for_no_leases(quiesced_snapshot) - if not shared_handoff: - await self._snapshot_store.wait_for_no_leases(ready.snapshot) - self._snapshot_store.seal_candidate_validation(ready.snapshot) + await self._snapshot_store.wait_for_no_leases(ready.snapshot) + self._snapshot_store.seal_candidate_validation(ready.snapshot) ( provisional_transaction, provisional_cancelled, @@ -3410,10 +3347,6 @@ async def _restore_ready_runtime( expected_mcp_catalog_digests = ( None if candidate_runtime is None else candidate_runtime.mcp_catalog_digests ) - candidate_data_access = _snapshot_candidate_data_access( - generation, - ready.snapshot, - ) # 1. 隔离 Root 已封存,先停止其任务,再进入任何 formal await。 if self._dashboard_validation_releaser is not None: @@ -3436,7 +3369,6 @@ async def _restore_ready_runtime( generation, candidate=ready.snapshot, formal=replacement, - candidate_data_access=candidate_data_access, ) except RuntimeError: await self._dispose_unreferenced_composition_root(replacement) @@ -3595,7 +3527,6 @@ async def _publish_prepared(self, plugin_id: str) -> dict[str, object]: raise RuntimeError("插件候选已被 runtime recovery 撤销准入") active = self._active_generations.get(plugin_id) stage_latest = _installed_generation_is_candidate(generation) - shared_handoff = _requires_shared_candidate_handoff(active, generation) try: if stage_latest: if ( @@ -3685,7 +3616,6 @@ async def _publish_prepared(self, plugin_id: str) -> dict[str, object]: exclusive_endpoint_changed or command_catalog_changed or v3_channel_catalog_changed - or shared_handoff or formal_root_handoff ) if self._dashboard_preparer is not None: @@ -3711,17 +3641,7 @@ async def _publish_prepared(self, plugin_id: str) -> dict[str, object]: ) quiesced_snapshot: RuntimeSnapshot | None = None - if shared_handoff: - from agent.plugins.snapshot import get_current_runtime_lease - - if get_current_runtime_lease() is not None: - error_text = "持有 RuntimeSnapshot lease 时不能交接 shared-read writer" - await self.discard_prepared( - plugin_id, - error=f"shared_read_lease: {error_text}", - ) - raise RuntimeError(error_text) - if publication_gated and not shared_handoff: + if publication_gated: from agent.plugins.snapshot import get_current_runtime_lease if ( @@ -3782,15 +3702,6 @@ async def _publish_prepared(self, plugin_id: str) -> dict[str, object]: if not stage_latest: try: self._snapshot_store.seal_pending_validation(snapshot) - if shared_handoff: - quiesced_snapshot = self._snapshot_store.pause_admission() - if ( - exclusive_endpoint_changed - and self._endpoint_quiescer is not None - ): - await self._endpoint_quiescer() - assert quiesced_snapshot is not None - await self._snapshot_store.wait_for_no_leases(quiesced_snapshot) if publication_gated: _, provisional_cancelled = await _complete_critical( self._snapshot_store.commit_provisional(transaction) @@ -4101,10 +4012,6 @@ async def _restore_direct_candidate_runtime( expected_mcp_catalog_digests = ( None if candidate_runtime is None else candidate_runtime.mcp_catalog_digests ) - candidate_data_access = _snapshot_candidate_data_access( - generation, - validation_snapshot, - ) if self._dashboard_validation_releaser is not None: await self._dashboard_validation_releaser(validation_snapshot) await self._stop_runtime_snapshot(validation_snapshot) @@ -4125,7 +4032,6 @@ async def _restore_direct_candidate_runtime( generation, candidate=validation_snapshot, formal=production_snapshot, - candidate_data_access=candidate_data_access, ) except RuntimeError: await self._dispose_unreferenced_composition_root(production_snapshot) @@ -4563,10 +4469,7 @@ async def _load_one( plugin_manifest = load_plugin_manifest( _plugins_home(self._installed_cache_root) ) - if ( - not mod.get("package_id") - and plugin_manifest.get(initial_plugin_id, True) is False - ): + if plugin_manifest.get(initial_plugin_id, True) is False: logger.info("插件已禁用(manifest.toml): %s", initial_plugin_id) return None created_activation_data_dir = False @@ -4929,20 +4832,19 @@ async def rollback_load(error: str) -> None: if not activate and _installed_generation_is_candidate(generation): generation.production_contributions = contributions generation.production_data_dir = generation.data_dir - if not _generation_uses_shared_candidate_data(generation): - assert generation.validation_workspace is not None - validation_data_dir = ( - generation.validation_workspace - / "plugin-data" - / generation.data_dir.name - ) - validation_data_dir.parent.mkdir(parents=True, exist_ok=True) - generation.validation_data_inventory = _copy_validation_data( - generation.data_dir, - validation_data_dir, - _candidate_data_exclude_paths(generation), - ) - generation.data_dir = validation_data_dir + assert generation.validation_workspace is not None + validation_data_dir = ( + generation.validation_workspace + / "plugin-data" + / generation.data_dir.name + ) + validation_data_dir.parent.mkdir(parents=True, exist_ok=True) + generation.validation_data_inventory = _copy_validation_data( + generation.data_dir, + validation_data_dir, + _candidate_data_exclude_paths(generation), + ) + generation.data_dir = validation_data_dir if not activate: generation.runtime_snapshot = await self._compile_generation_snapshot( generation, @@ -5528,9 +5430,12 @@ async def _mount_generation_composition( _ = resolve_declared_workspace_root(self._workspace, name) for name in plugin.workspace_files: _ = resolve_declared_workspace_file(self._workspace, name) - _ = await root.mount( - plugin, + _ = await root._mount_module( # pyright: ignore[reportPrivateUsage] + plugin.apply, name=generation.plugin_id, + inject=plugin.inject, + plugin_module=plugin.module, + static_active=plugin.static_active, runtime=PluginRuntime( plugin_id=generation.plugin_id, generation_id=generation.generation_id, @@ -5540,7 +5445,6 @@ async def _mount_generation_composition( config=generation.config, workspace_roots=plugin.workspace_roots, workspace_files=plugin.workspace_files, - data_access="read_write", ), ) @@ -5596,14 +5500,12 @@ async def _mount_candidate_composition( attempt_workspace, ) for generation, clone, data_dir, config in clones: - data_access: Literal["read_write", "read_only"] = ( - "read_only" - if _generation_uses_shared_candidate_data(generation) - else "read_write" - ) - _ = await root.mount( - clone, + _ = await root._mount_module( # pyright: ignore[reportPrivateUsage] + clone.apply, name=generation.plugin_id, + inject=clone.inject, + plugin_module=clone.module, + static_active=clone.static_active, runtime=PluginRuntime( plugin_id=generation.plugin_id, generation_id=generation.generation_id, @@ -5613,7 +5515,6 @@ async def _mount_candidate_composition( config=config, workspace_roots=clone.workspace_roots, workspace_files=clone.workspace_files, - data_access=data_access, ), ) @@ -5662,17 +5563,13 @@ def _clone_candidate_composable( """重新导入一个 stable v3 插件并绑定 candidate 临时数据。""" plugin_dir = generation.plugin_dir - if _generation_uses_shared_candidate_data(generation): - data_dir = generation.production_data_dir or generation.data_dir - inventory: tuple[str, ...] = () - else: - data_dir = attempt_workspace / "plugin-data" / generation.data_dir.name - _ = data_dir.parent.mkdir(parents=True, exist_ok=True) - inventory = _copy_validation_data( - generation.data_dir, - data_dir, - _candidate_data_exclude_paths(generation), - ) + data_dir = attempt_workspace / "plugin-data" / generation.data_dir.name + _ = data_dir.parent.mkdir(parents=True, exist_ok=True) + inventory = _copy_validation_data( + generation.data_dir, + data_dir, + _candidate_data_exclude_paths(generation), + ) if generation is candidate_owner: generation.validation_data_inventory = inventory module_path = ( @@ -7049,43 +6946,14 @@ def _candidate_data_exclude_paths( return tuple(sorted(excluded)) -def _generation_uses_shared_candidate_data(generation: PluginGeneration) -> bool: - manifest = generation.static_manifest - return manifest is not None and manifest.candidate_data_mode == "shared_read" - - -def _requires_shared_candidate_handoff( - previous: PluginGeneration | None, - candidate: PluginGeneration, -) -> bool: - return previous is not None and ( - _generation_uses_shared_candidate_data(previous) - or _generation_uses_shared_candidate_data(candidate) - ) - - def _validate_candidate_formal_snapshot_identity( generation: PluginGeneration, *, candidate: RuntimeSnapshot, formal: RuntimeSnapshot, - candidate_data_access: Literal["read_write", "read_only"] | None, ) -> None: - """Allow only the declared shared-read access change during formal rebuild.""" - - # 1. A shared candidate changes exactly one Core-owned access assignment. - if _generation_uses_shared_candidate_data(generation): - formal_root = formal.composition_root - if formal_root is None: - raise RuntimeError("shared-read candidate 缺少 composition Root") - formal_access = formal_root.plugin_runtime(generation.plugin_id).data_access - if candidate_data_access != "read_only" or formal_access != "read_write": - raise RuntimeError( - "shared-read candidate data_access 变化无效: " - f"{candidate_data_access} -> {formal_access}" - ) + """Require the formal Root to preserve the validated candidate identity.""" - # 2. Topology and every frozen catalog remain content-identical. if candidate.snapshot_id != formal.snapshot_id: raise RuntimeError( "候选隔离资源恢复后 snapshot identity 发生变化: " @@ -7093,18 +6961,6 @@ def _validate_candidate_formal_snapshot_identity( ) -def _snapshot_candidate_data_access( - generation: PluginGeneration, - snapshot: RuntimeSnapshot, -) -> Literal["read_write", "read_only"] | None: - if not _generation_uses_shared_candidate_data(generation): - return None - root = snapshot.composition_root - if root is None: - raise RuntimeError("shared-read candidate 缺少 composition Root") - return root.plugin_runtime(generation.plugin_id).data_access - - def _copy_validation_data( source: Path, target: Path, @@ -7140,17 +6996,31 @@ def _copy_validation_data( if path.is_symlink(): raise RuntimeError(f"candidate plugin-data 不允许复制符号链接: {path}") - # 3. Excluded paths are omitted before copytree opens their contents. - def ignore(directory: str, names: list[str]) -> list[str]: - current = Path(directory).resolve(strict=True) - relative_dir = current.relative_to(source_root) - ignored: list[str] = [] - for name in names: - if _candidate_data_path_is_excluded(relative_dir / name, excluded): - ignored.append(name) - return ignored - - _ = shutil.copytree(source_root, target, ignore=ignore) + # 3. Copy SQLite through its snapshot API; never race WAL/SHM companion files. + target.mkdir(parents=True) + for directory, dirnames, filenames in os.walk(source_root, followlinks=False): + root = Path(directory) + relative_dir = root.relative_to(source_root) + dirnames[:] = [ + name + for name in dirnames + if not _candidate_data_path_is_excluded(relative_dir / name, excluded) + ] + for name in dirnames: + relative = relative_dir / name + (target / relative).mkdir() + for name in filenames: + relative = relative_dir / name + if _candidate_data_path_is_excluded(relative, excluded): + continue + if name.endswith(("-wal", "-shm")): + continue + source_file = root / name + target_file = target / relative + if _is_sqlite_database(source_file): + _copy_sqlite_snapshot(source_file, target_file) + else: + _ = shutil.copy2(source_file, target_file) # 4. Freeze a relative file inventory for review and Gate evidence. inventory: list[str] = [] @@ -7161,6 +7031,24 @@ def ignore(directory: str, names: list[str]) -> list[str]: return tuple(sorted(inventory)) +def _is_sqlite_database(path: Path) -> bool: + with path.open("rb") as stream: + return stream.read(16) == b"SQLite format 3\x00" + + +def _copy_sqlite_snapshot(source: Path, target: Path) -> None: + """Copy one transactionally consistent SQLite snapshot.""" + + reader = sqlite3.connect(f"file:{source}?mode=ro", uri=True) + writer = sqlite3.connect(target) + try: + reader.backup(writer) + finally: + writer.close() + reader.close() + _ = shutil.copymode(source, target) + + def _candidate_data_path_is_excluded( relative_path: Path, excluded: tuple[str, ...], diff --git a/agent/plugins/manifest.py b/agent/plugins/manifest.py index 3c0b30755..c27143854 100644 --- a/agent/plugins/manifest.py +++ b/agent/plugins/manifest.py @@ -83,6 +83,8 @@ def load_plugin_manifest( if not path.exists(): return {} loaded = tomllib.loads(path.read_text(encoding="utf-8")) + if "packages" in loaded: + raise ValueError("manifest.toml 不再支持 [packages];请改为独立 V3 插件条目") raw_plugins = loaded.get("plugins") if not isinstance(raw_plugins, dict): raise ValueError("manifest.toml 缺少 [plugins] 配置") @@ -97,27 +99,6 @@ def load_plugin_manifest( return result -def load_package_manifest( - plugins_home: Path | None = None, -) -> dict[str, bool]: - path = manifest_path(plugins_home) - if not path.exists(): - return {} - loaded = tomllib.loads(path.read_text(encoding="utf-8")) - raw_packages = loaded.get("packages", {}) - if not isinstance(raw_packages, dict): - raise ValueError("manifest.toml [packages] 配置格式错误") - result: dict[str, bool] = {} - for package_id, raw_entry in cast(dict[object, object], raw_packages).items(): - if not isinstance(package_id, str) or not isinstance(raw_entry, dict): - raise ValueError("manifest.toml 插件包条目格式错误") - enabled = cast(dict[object, object], raw_entry).get("enabled") - if not isinstance(enabled, bool): - raise ValueError(f"manifest.toml 插件包缺少 enabled: {package_id}") - result[package_id] = enabled - return result - - def upsert_plugin_manifest( plugin_id: str, *, @@ -154,33 +135,6 @@ def remove_plugin_manifest_entry( return write_plugin_manifest(entries, plugins_home=plugins_home) -def write_package_manifest( - packages: Mapping[str, bool], - *, - plugins_home: Path | None = None, -) -> Path: - plugins = load_plugin_manifest(plugins_home) - path = manifest_path(plugins_home) - path.parent.mkdir(parents=True, exist_ok=True) - lines = ["[plugins]", ""] - for plugin_id, enabled in sorted(plugins.items()): - escaped = plugin_id.replace("\\", "\\\\").replace('"', '\\"') - lines.extend([ - f'[plugins."{escaped}"]', - f"enabled = {'true' if enabled else 'false'}", - "", - ]) - lines.extend(["[packages]", ""]) - for package_id, enabled in sorted(packages.items()): - escaped = package_id.replace("\\", "\\\\").replace('"', '\\"') - lines.extend([ - f'[packages."{escaped}"]', - f"enabled = {'true' if enabled else 'false'}", - "", - ]) - return _atomic_write(path, "\n".join(lines)) - - def write_plugin_manifest( entries: Mapping[str, bool], *, @@ -188,7 +142,6 @@ def write_plugin_manifest( ) -> Path: path = manifest_path(plugins_home) path.parent.mkdir(parents=True, exist_ok=True) - packages = load_package_manifest(plugins_home) lines = ["[plugins]", ""] for plugin_id, enabled in sorted(entries.items()): escaped = plugin_id.replace("\\", "\\\\").replace('"', '\\"') @@ -199,17 +152,6 @@ def write_plugin_manifest( "", ] ) - if packages: - lines.extend(["[packages]", ""]) - for package_id, enabled in sorted(packages.items()): - escaped = package_id.replace("\\", "\\\\").replace('"', '\\"') - lines.extend( - [ - f'[packages."{escaped}"]', - f"enabled = {'true' if enabled else 'false'}", - "", - ] - ) content = "\n".join(lines) return _atomic_write(path, content) diff --git a/agent/plugins/packages.py b/agent/plugins/packages.py deleted file mode 100644 index cd3702994..000000000 --- a/agent/plugins/packages.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import tomllib -from collections.abc import Mapping -from dataclasses import dataclass -from pathlib import Path -from typing import Any, cast - - -@dataclass(frozen=True) -class PluginPackage: - id: str - root: Path - members: tuple[str, ...] - dashboard: bool - provides: tuple[str, ...] - - -def discover_plugin_packages(project_root: Path) -> dict[str, PluginPackage]: - packages_root = project_root / "plugin_packages" - if not packages_root.is_dir(): - return {} - result: dict[str, PluginPackage] = {} - for path in sorted(packages_root.glob("*/package.toml")): - raw = tomllib.loads(path.read_text(encoding="utf-8")) - package = raw.get("package") - if not isinstance(package, dict): - raise ValueError(f"插件包缺少 [package]: {path}") - package = cast(dict[str, Any], package) - package_id = package.get("id") - members = package.get("members") - if not isinstance(package_id, str) or not package_id: - raise ValueError(f"插件包 id 无效: {path}") - if not isinstance(members, list): - raise ValueError(f"插件包 members 无效: {path}") - member_values = cast(list[object], members) - if not all(isinstance(item, str) and item for item in member_values): - raise ValueError(f"插件包 members 无效: {path}") - members = cast(list[str], member_values) - dashboard = package.get("dashboard", False) - if not isinstance(dashboard, bool): - raise ValueError(f"插件包 dashboard 无效: {path}") - provides = package.get("provides", []) - if not isinstance(provides, list): - raise ValueError(f"插件包 provides 无效: {path}") - provide_values = cast(list[object], provides) - if not all(isinstance(item, str) and item for item in provide_values): - raise ValueError(f"插件包 provides 无效: {path}") - provides = cast(list[str], provide_values) - if package_id in result: - raise ValueError(f"插件包 id 重复: {package_id}") - result[package_id] = PluginPackage( - id=package_id, - root=path.parent, - members=tuple(members), - dashboard=dashboard, - provides=tuple(provides), - ) - _validate_packages(result) - return result - - -def enabled_plugin_packages( - project_root: Path, - entries: dict[str, bool], -) -> dict[str, PluginPackage]: - return _select_enabled_plugin_packages( - discover_plugin_packages(project_root), - entries, - ) - - -def _select_enabled_plugin_packages( - packages: Mapping[str, PluginPackage], - entries: Mapping[str, bool], -) -> dict[str, PluginPackage]: - enabled = { - package_id: package - for package_id, package in packages.items() - if entries.get(package_id, False) - } - claimed: dict[str, str] = {} - for package in enabled.values(): - for capability in package.provides: - owner = claimed.get(capability) - if owner is not None: - raise ValueError( - f"插件包 capability 冲突: {capability}={owner},{package.id}" - ) - claimed[capability] = package.id - return enabled - - -def _validate_packages(packages: dict[str, PluginPackage]) -> None: - owners: dict[str, str] = {} - for package in packages.values(): - for member in package.members: - owner = owners.get(member) - if owner is not None: - raise ValueError(f"插件模块属于多个包: {member}={owner},{package.id}") - owners[member] = package.id diff --git a/agent/plugins/scope.py b/agent/plugins/scope.py index 4c2b6b923..ae644f61a 100644 --- a/agent/plugins/scope.py +++ b/agent/plugins/scope.py @@ -3,19 +3,14 @@ import asyncio import inspect import logging -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable from contextlib import nullcontext from dataclasses import dataclass -from typing import Any, TypeVar - from agent.plugin_composition.diagnostics import plugin_entrypoint -from bus.event_bus import EventBus, EventSubscription, Handler +Cleanup = Callable[[], Awaitable[None] | None] logger = logging.getLogger(__name__) -T = TypeVar("T") -Cleanup = Callable[[], Awaitable[None] | None] - @dataclass(frozen=True) class CleanupFailure: @@ -23,8 +18,8 @@ class CleanupFailure: error: str -# 插件资源接口:现有插件通过 context 或直接使用 scope 登记订阅、任务和 cleanup。 -# 迁移插件前不得绕过逆序清理、聚合失败和清理完成后再恢复取消的语义。 +# Core generation host 的资源作用域。V3 插件只使用 Context/Fiber/Effect;这个对象不属于 +# 公开插件 API。Host cleanup 保持逆序、聚合失败,并在清理完成后恢复调用方取消。 class PluginScope: def __init__( self, @@ -53,57 +48,6 @@ def defer(self, resource: str, cleanup: Cleanup) -> None: raise TypeError(f"插件清理动作不可调用: {self.plugin_id}:{resource}") self._cleanups.append((resource, cleanup)) - def subscribe( - self, - event_bus: EventBus, - event_type: type[T], - handler: Handler[T], - ) -> EventSubscription: - self._ensure_open() - subscription = event_bus.on(event_type, handler) - self.defer( - f"event:{event_type.__name__}", - subscription.close, - ) - return subscription - - def create_task( - self, - coroutine: Coroutine[Any, Any, T], - *, - name: str | None = None, - ) -> asyncio.Task[T]: - if self._closed: - coroutine.close() - self._ensure_open() - task = asyncio.create_task(coroutine, name=name) - - def report_failure(completed: asyncio.Task[T]) -> None: - if completed.cancelled(): - return - error = completed.exception() - if error is None: - return - logger.error( - "插件作用域任务异常: plugin=%s task=%s", - self.plugin_id, - completed.get_name(), - exc_info=(type(error), error, error.__traceback__), - ) - - task.add_done_callback(report_failure) - - async def cancel() -> None: - if not task.done(): - _ = task.cancel() - try: - await task - except asyncio.CancelledError: - return - - self.defer(f"task:{name or task.get_name()}", cancel) - return task - async def aclose(self) -> list[CleanupFailure]: """按逆序完成全部资源清理,并在末尾恢复外部取消。""" diff --git a/agent/plugins/static_manifest.py b/agent/plugins/static_manifest.py index 35350f072..a17ff405f 100644 --- a/agent/plugins/static_manifest.py +++ b/agent/plugins/static_manifest.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Literal, cast +from typing import cast from urllib.parse import urlsplit STATIC_MANIFEST_FILENAME = "akashic.plugin.toml" @@ -34,7 +34,6 @@ "version", "api_version", "entrypoint", - "candidate_data_mode", "python", "validation", "mcp", @@ -109,7 +108,6 @@ class StaticPluginManifest: version: str api_version: int entrypoint: str - candidate_data_mode: Literal["isolated_copy", "shared_read"] python: tuple[StaticPythonRuntime, ...] exclude_data_paths: tuple[str, ...] mcp_servers: tuple[StaticMcpDeclaration, ...] @@ -249,8 +247,6 @@ def _validate_manifest(root: Path, raw: Mapping[str, object]) -> StaticPluginMan ) if not entrypoint.endswith(".py"): raise ValueError("插件静态 manifest entrypoint 必须指向 Python 文件") - candidate_data_mode = _candidate_data_mode(raw.get("candidate_data_mode")) - # 2. Requirements are complete before the artifact is published. python = _python_runtimes(root, raw.get("python", [])) exclude_data_paths = _validation_paths(root, raw.get("validation", {})) @@ -284,8 +280,6 @@ def _validate_manifest(root: Path, raw: Mapping[str, object]) -> StaticPluginMan for channel, paths in channel_credentials ], } - if "candidate_data_mode" in raw: - identity["candidate_data_mode"] = candidate_data_mode identity_digest = hashlib.sha256( json.dumps( identity, @@ -300,7 +294,6 @@ def _validate_manifest(root: Path, raw: Mapping[str, object]) -> StaticPluginMan version=version, api_version=api_version, entrypoint=entrypoint, - candidate_data_mode=candidate_data_mode, python=python, exclude_data_paths=exclude_data_paths, mcp_servers=mcp_servers, @@ -311,19 +304,6 @@ def _validate_manifest(root: Path, raw: Mapping[str, object]) -> StaticPluginMan ) -def _candidate_data_mode( - raw: object, -) -> Literal["isolated_copy", "shared_read"]: - if raw is None: - return "isolated_copy" - if not isinstance(raw, str) or raw not in {"isolated_copy", "shared_read"}: - raise ValueError( - "插件静态 manifest candidate_data_mode 必须为 " - "isolated_copy 或 shared_read" - ) - return cast(Literal["isolated_copy", "shared_read"], raw) - - def _channel_credentials( raw: object, ) -> tuple[tuple[str, tuple[str, ...]], ...]: diff --git a/bootstrap/init_workspace.py b/bootstrap/init_workspace.py index 61839d7e8..baf44e1e9 100644 --- a/bootstrap/init_workspace.py +++ b/bootstrap/init_workspace.py @@ -21,8 +21,6 @@ "observe", "skills", "drift/skills", - "mcp", - "mcp/servers", ) diff --git a/bus/event_bus.py b/bus/event_bus.py index 3124e862d..83d4630b9 100644 --- a/bus/event_bus.py +++ b/bus/event_bus.py @@ -148,12 +148,6 @@ async def fanout( len(handlers), ) - # 2. Legacy EventBus handlers settle first; composition observers then - # consume the same object under the request's exact RuntimeSnapshot. - from agent.lifecycle.composition import observe_composition_domain_event - - await observe_composition_domain_event(event) - def enqueue( self, event: object, diff --git a/docs/INDEX.md b/docs/INDEX.md index f456245e9..e5385a901 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -73,6 +73,8 @@ | 文件或目录 | 回答的问题 | 读取策略 | |---|---|---| +| [插件 V3 能力手册](design/plugin-v3-capabilities.md) | V3 当前有哪些原子能力、插件怎样使用 | 开发或审查 V3 插件时读取 | +| [hua-home 插件运行事实](design/hua-home-plugin-runtime-source-of-truth.md) | 线上权威路径、固定查找方法、本地镜像边界和 exact fleet snapshot | 审计、同步或部署插件时先读取 | | [`WORKFLOW.md`](WORKFLOW.md) | 修改仓库文件时怎样从接手任务走到提交评审 | 每个修改任务读取 | | [`projectneed.md`](projectneed.md) | 系统必须保持什么 | 公共章节先读,再按领域展开 | | [`NOW.md`](NOW.md) | 当前还有什么没做 | 每个非简单任务读取;完成项不应存在 | diff --git a/docs/decisions/0037-plugin-runtime-is-pure-v3.md b/docs/decisions/0037-plugin-runtime-is-pure-v3.md index fc73aa6cb..1b539aa65 100644 --- a/docs/decisions/0037-plugin-runtime-is-pure-v3.md +++ b/docs/decisions/0037-plugin-runtime-is-pure-v3.md @@ -1,6 +1,6 @@ # 0037 · 插件运行时收敛为 pure v3 -- 状态:accepted / implementing +- 状态:accepted / implemented - 日期:2026-08-18 - 关联条款:PLG-001~PLG-014、WSP-001~WSP-005、ERR-001、TST-001~TST-008 - supersedes:[0008](0008-plugin-runtime-publishes-only-committed-snapshots.md) 的 API v2 与 legacy host 选择 @@ -24,12 +24,9 @@ Core 拥有 artifact、candidate、stable/latest、lease、journal、晋升与 2. 每个领域只保留一个 Core owner。Tool、Channel、Command、MCP、managed process、 Job、UI、Skill、Dashboard 和被动链路均从 committed Root snapshot 读取。最后一个 v2 consumer 迁走后立即删除对应 legacy owner,不保留 deprecated alias 或空壳。 -3. `default_proactive` 与 `wake_proactive` 可以继续使用 Core-private proactive bridge 及其只读 - Dashboard reader,直到维护者另行批准迁移。这是一个指定的内建岛,不是外部插件可依赖的 - 兼容 API;外部同名插件不能获得该 bridge 或 reader。 -4. Computer Use Linux 与 Context Pressure 退出已跟踪 fleet。卸载只移除安装清单与 +3. Computer Use Linux 与 Context Pressure 退出已跟踪 fleet。卸载只移除安装清单与 能力 cache;既有 `plugin-data` 默认保留,不因代码收敛而物理删除。 -5. 代码合并与 hua-home 正式替换分开。只有同一 clean head 上的 static fleet、Mobile、 +4. 代码合并与 hua-home 正式替换分开。只有同一 clean head 上的 static fleet、Mobile、 WebUI、Tool/Passive composition 以及分组 E1~E4 报告全部通过,才能声明为线上替换 candidate。正式 workspace 的备份、切换和回滚仍需单独授权。 @@ -45,15 +42,14 @@ Core 拥有 artifact、candidate、stable/latest、lease、journal、晋升与 │ typed capability host │ Tool / Channel / Command / MCP / Job / UI / Skill └──────────────────────┘ -Default/Wake private proactive bridge + Dashboard reader ──── Core-only、非公开 ABI ``` ## 理由 - breaking change 在可控的 fleet 迁移中比永久双轨更容易审计:一份声明、一张 Root、 一个 publication owner、一套 cleanup 证据。 -- Default/Wake 的主动语义尚未被新 Service 完整承接;将特例限定在 Core-private admission - 比对全部外部插件暴露 legacy host 更小。 +- 主动、调度和 Dashboard 已使用普通 V3 Service、event 和 generation host;不再保留专用 + V2 admission 或 lifecycle 岛。 - 数据安全不由 ABI 兼容保证。安全来自 candidate workspace 隔离、权威数据只追加/ 明确更新协议、外部效果三态回执、generation lease、journal 和可恢复备份。 @@ -61,6 +57,8 @@ Default/Wake private proactive bridge + Dashboard reader ──── Core-only - 无 static manifest、`api_version != 3` 或还调用 v2 固定方法的外部插件将在 admission 时 fail-loud,不再被自动包装或跳过。 +- `manifest.toml` 只声明独立插件;旧 `[packages]` 组合与 member 展开已删除,并在边界 + fail-loud。 - 历史 v2 测试、lock、Gate、文档与 CI 入口在零 production consumer 后删除。历史决策 保留并标记 superseded。 - 插件卸载不得级联删除 plugin-data、Session、memory、附件或外部 canonical source。 @@ -70,8 +68,8 @@ Default/Wake private proactive bridge + Dashboard reader ──── Core-only - 静态 fleet 清单中每个启用插件都有 exact source commit、manifest 与 v3 module namespace; 清单与正式安装清单的差异必须显式。 -- 扫描 production source、bootstrap、RuntimeSnapshot 和 Manager 不再存在可达 v2 Plugin lifecycle/ - 固定贡献 consumer;仅 Default/Wake 私有 proactive bridge 可由 exact builtin admission 达到。 +- 扫描 production source、bootstrap、RuntimeSnapshot 和 Manager 不再存在可达 V2 Plugin + lifecycle、固定贡献 consumer、phase module 注入口或 EventBus-to-V3 类型桥。 - 每个领域完成 candidate discard/promote、old lease drain、Effect/resource cleanup、进程内失败与 子进程崩溃恢复;不为断电或物理停机扩张本轮范围。 - 同一 clean Core head 运行 static fleet、Mobile、Tool/Passive composition、WebUI 与 E1~E4; diff --git a/docs/decisions/0046-plugin-candidate-validation-is-incremental.md b/docs/decisions/0046-plugin-candidate-validation-is-incremental.md index 6b6ef5070..d1a6221ff 100644 --- a/docs/decisions/0046-plugin-candidate-validation-is-incremental.md +++ b/docs/decisions/0046-plugin-candidate-validation-is-incremental.md @@ -43,7 +43,7 @@ RuntimeSnapshot 把 stable 中未替换 owner 的不可变 catalog contribution - 未知 required Service:candidate Fiber 保持 pending,latest 不发布。 - candidate 删除仍被 stable consumer 要求的 Service:完整选择图缺依赖,latest 不发布。 - candidate 与未替换 owner 重复提供 Service 或 catalog key:拒绝候选。 -- candidate closure 的 workspace/data 仍按声明采用 isolated copy 或 shared read;未进入 closure 的正式数据零读取、零复制、零清理。 +- candidate closure 的 workspace/data 一律复制到 attempt;未进入 closure 的正式数据零读取、零复制、零清理。`shared_read` 已删除,candidate 不取得正式 plugin-data 路径。 - Python 插件仍是受信代码;绕过 Context 直接访问任意绝对路径不由该机制伪装成安全沙箱。 ## 验收 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5efb0dbfe..689e1aa77 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -42,7 +42,7 @@ | [0034](0034-turn-is-the-logical-work-unit.md) | accepted | Turn 是逻辑工作单元 | CTX-003、SES-007、SES-008、MEM-011、OUT-001、OUT-004、SCH-003 | | [0035](0035-mobile-protocol-delivery-is-phased.md) | accepted | 移动协议交付按变更性质分阶段 | MOB-008、MOB-006、TST-007、GOV-002 | | [0036](0036-plugin-composition-keeps-promotion-owner.md) | accepted | 插件组合内核保留现有晋升 owner | PLG-001~PLG-013、WSP-001~WSP-005、ERR-001、TST-001~TST-007 | -| [0037](0037-plugin-runtime-is-pure-v3.md) | accepted / implementing | 插件运行时收敛为 pure v3 | PLG-001~PLG-014、WSP-001~WSP-005、ERR-001、TST-001~TST-008 | +| [0037](0037-plugin-runtime-is-pure-v3.md) | accepted / implemented | 插件运行时收敛为 pure v3 | PLG-001~PLG-014、WSP-001~WSP-005、ERR-001、TST-001~TST-008 | | [0038](0038-operator-trust-can-publish-offline-plugin-batches.md) | accepted | Operator 信任可以离线发布 exact 插件批次 | PLG-013、RUN-015、ERR-001 | | [0039](0039-react-core-atoms-keep-sources-unprivileged.md) | accepted | React 原子能力留在 Core,来源保持非特权 | RUN-001~RUN-003、RUN-007~RUN-009、OUT-001~OUT-004、PLG-014、SCH-001~SCH-003、PRO-001、SEC-005、SEC-007 | | [0040](0040-wake-duty-gate-lives-in-scoped-react.md) | accepted | Wake duty gate 属于 Wake scoped react | RUN-003、RUN-007~RUN-009、OUT-001~OUT-003、PLG-014、PRO-001~PRO-002 | diff --git a/docs/design/computer-plugin-workload-task-contract.md b/docs/design/computer-plugin-workload-task-contract.md index b318152ee..79e2543d0 100644 --- a/docs/design/computer-plugin-workload-task-contract.md +++ b/docs/design/computer-plugin-workload-task-contract.md @@ -402,7 +402,7 @@ rollout 保持 degraded,不把 pointer 恢复冒充服务已恢复。 在回滚中,新 formal 也必须先返回上节的强 stop 回执;未证明 container/mount 全部 释放时,禁止重启旧 formal,并保留唯一可重试 failure owner。 -候选永远使用 candidate data root。即使插件声明 `candidate_data_mode = "shared_read"`,包含可写 profile 的 +候选永远使用 candidate data root。旧 `candidate_data_mode = "shared_read"` 已删除;包含可写 profile 的 WorkloadData 也不能挂正式目录;Computer candidate 只使用隔离复制或空目录完成协议验证。 ## 8. Computer 插件责任 diff --git a/docs/design/hua-home-plugin-runtime-source-of-truth.md b/docs/design/hua-home-plugin-runtime-source-of-truth.md new file mode 100644 index 000000000..d2410643d --- /dev/null +++ b/docs/design/hua-home-plugin-runtime-source-of-truth.md @@ -0,0 +1,135 @@ +# hua-home 插件运行事实 + +状态:2026-09-02 已在 `hua-home` 实机核验。 + +## 1. 只认 hua-home + +插件线上事实按下面顺序取证: + +1. `/srv/data/services/akashic/activation/active.json`:当前 Core exact release commit。 +2. `/srv/data/services/akashic/state/plugin-home/manifest.toml`:安装身份和 enabled 状态。 +3. `plugin-home/cache///.pointers.json`:外部插件的 exact stable/latest artifact。 +4. `plugin-home/cache/.../.artifacts//akashic.plugin.toml` 与 entrypoint:V3 admission + identity 和真实模块。 +5. `/srv/data/services/akashic/runtime-sources//plugins/`:该 release 实际携带的 + builtin 插件源码。 +6. `akashic-core` 容器进程、日志、snapshot/generation 证据:证明声明已启动,不用安装事实代替 + live 行为。 + +开发机路径都不是运行事实: + +- `/home/huashen/.akashic-plugin` 只是从 hua-home 拉取的可删镜像;不能反向同步到服务端, + 不能用其 mtime、lock 或进程状态证明线上状态。 +- `/home/huashen/.akashic` 是历史开发 workspace;不得用于 fleet、manifest、plugin-data、MCP + 或 generation 审计。废弃的本地 `workspace/mcp` 已删除。 +- 当前 Git worktree 的 `plugins/` 是待发布源码。只有 exact commit 激活到 hua-home 后,才成为 + builtin 运行事实。 +- `/mnt/data/coding/akashic-plugin/` 是外部插件 canonical 开发仓库;它拥有修改,但不代表 + stable artifact 已安装或已启用。 + +## 2. 固定查找方法 + +先查 release 和 manifest: + +```bash +ssh hua-home 'cat /srv/data/services/akashic/activation/active.json' +ssh hua-home 'sed -n "1,240p" /srv/data/services/akashic/state/plugin-home/manifest.toml' +``` + +再查 exact pointer,不扫描开发机旧 cache: + +```bash +ssh hua-home 'find /srv/data/services/akashic/state/plugin-home/cache \ + -name .pointers.json -type f -print | sort' +ssh hua-home 'sed -n "1,80p" \ + /srv/data/services/akashic/state/plugin-home/cache/github/feed/.pointers.json' +``` + +最后核对真实 runtime: + +```bash +ssh hua-home '~/.local/bin/akashic-release doctor' +ssh hua-home 'docker ps --filter name=akashic-core --format "{{.Names}} {{.Status}}"' +ssh hua-home 'docker exec akashic-core ps -eo pid,args' +ssh hua-home 'docker logs --since 30m akashic-core 2>&1 | tail -200' +``` + +需要在开发机只读分析 artifact 时,先重建镜像: + +```bash +rsync -a --delete --exclude=.publication.lock \ + hua-home:/srv/data/services/akashic/state/plugin-home/ \ + /home/huashen/.akashic-plugin/ +``` + +该命令的方向只能是 `hua-home → 开发机`。镜像同步后至少核对两端 `manifest.toml` SHA-256, +再读取 pointer;不要把 rsync 成功当作 runtime ready。 + +## 3. 2026-09-02 exact snapshot + +- Core release:`8304a02420a98ba3cd4600d983f552422186e5b3` +- manifest SHA-256:`7c9f8f274a0ea4b274d1a1227c6d978d53801259e8d7e37095f1ea0934d612bf` +- enabled manifest entries:33(17 builtin + 16 external) +- external artifact directories:40;逐个 static manifest + entrypoint 扫描后 non-V3 为 0 +- 所有 24 个外部 plugin identity 的 stable 与 latest 当前相同 + +### 3.1 Enabled builtins + +| Plugin | Release source | +|---|---| +| akasha | `plugins/akasha` | +| codex | `plugins/codex` | +| compaction | `plugins/compaction` | +| computer | `plugins/computer` | +| conversation-ui | `plugins/conversation_ui` | +| drift | `plugins/drift` | +| eventmail | `plugins/eventmail` | +| markdown_memory | `plugins/markdown_memory` | +| models | `plugins/models` | +| openai-compatible | `plugins/openai_compatible` | +| opencode-go | `plugins/opencode_go` | +| runtime-ui | `plugins/runtime_ui` | +| scheduler | `plugins/scheduler` | +| shell-ui | `plugins/shell_ui` | +| subagent | `plugins/subagent` | +| wake | `plugins/wake` | +| workbench-ui | `plugins/workbench_ui` | + +表中的相对路径必须接在 active release source 后面,不能接当前开发 worktree。 + +### 3.2 Enabled external plugins + +| Plugin | Version | Stable artifact | Runtime declarations | +|---|---:|---|---| +| calendar@github | 3.2.1 | `3.2.1-9997353cdebc1885-restaged-c38b4ef82a0d4980` | MCP 1, process 1 | +| citation@github | 1.0.0 | `1.0.0-a886c74c55c4ef40-restaged-7a161afaf9af462d` | - | +| emotion@github | 3.0.4 | `3.0.4-d828fd7ec97e027b` | - | +| feed@github | 3.1.4 | `3.1.4-fd74018c2a397fcc` | MCP 1 | +| fitbit@github | 3.2.2 | `3.2.2-e0eda11d822e2ca0` | MCP 1, process 1 | +| github-watch@github | 3.0.0 | `3.0.0-b9266ab3ca9932c0` | - | +| huayue-skills@github | 1.1.0 | `1.1.0-65273781113a2305` | Skill | +| meme@github | 1.0.1 | `1.0.1-c185ea7a3847d67a` | - | +| observe@github | 1.4.1 | `1.4.1-09214c23f287f659` | - | +| plugin_undo@github | 2.0.0 | `2.0.0-86941208ea931308-restaged-d023109d29594540` | - | +| proactive_feedback@github | 3.0.1 | `3.0.1-d9d90fd4d3027d44` | - | +| setup_helper@github | 2.0.0 | `2.0.0-3d9671bfee523e78-restaged-64ee4474b9094010` | - | +| shell_restore@github | 2.0.0 | `2.0.0-d9b9e17c7e783463-restaged-f9d6e86f61d74035` | - | +| shell_safety@github | 2.0.0 | `2.0.0-5230f8ac8aec5216-restaged-821e8a5103414ea0` | - | +| status_commands@github | 2.0.0 | `2.0.0-8d119e8cfa53bd91-restaged-300683768df04d9a` | - | +| steam@github | 3.2.1 | `3.2.1-a0fda0602185a0a4-restaged-959c3b8e1d654b84` | MCP 1 | + +### 3.3 Installed but disabled external identities + +`content-wake-formal` marketplace 中以下 8 个 identity 已禁用,但 artifact 仍是 V3: + +- calendar 3.1.0 +- emotion 3.0.0 +- feed 3.1.1 +- fitbit 3.1.0 +- github-watch 3.0.0 +- observe 1.4.0 +- proactive_feedback 3.0.0 +- steam 3.1.0 + +禁用不等于 V2,也不授权删除。只有 pointer/reference、回滚集合和恢复需求都核清后,才能把它们 +作为独立 GC 任务处理。 diff --git a/docs/design/persistence-state-map.md b/docs/design/persistence-state-map.md index aa91fe1a2..5cbaca59b 100644 --- a/docs/design/persistence-state-map.md +++ b/docs/design/persistence-state-map.md @@ -117,9 +117,9 @@ H4 后 Core 配置、Setup、Prompt、Dashboard 与 Mobile Runtime Inspection | `model-registry.sqlite3` | onboarding 或设置事务增加含 credential payload 的 connection、model 和 role binding,并增加单调 revision;`model_definitions.context_window`/`max_output_tokens` 与各自 source 保存模型 capability snapshot | connection 的 key/token、Base URL、模型字段和角色绑定可原位更新;Codex token refresh 不增加模型 revision,其余成功模型事务增加 revision,旧 execution generation 只在 lease 归零后失效。预算 owner 只读取当前 generation 的 `context_window`、`max_output_tokens` 及字段来源;遗留 `effective_context_percent`/`compaction_trigger_percent` 列仅为 v1 schema identity 保留,完全惰性,不是配置或 capability source | 只有独立模型/来源删除操作可以减少;被 role 或 session 引用时必须拒绝,普通模型切换不得 cascade;数据库、WAL/SHM 与备份均按 secret 使用 `0600` | | `data/mobile/master-keys.json` | 文件型密钥 provider 初始化或轮换时追加随机 master key;离线迁移可按既有 ID 导入同一密钥 | 完整集合以 `0600` 原子替换发布;同 ID 同内容导入幂等,不同内容 fail-loud;旧 key 继续支持历史 keyset 回滚 | 当前没有自动删除协议;只能由名称明确的移动身份重置或密钥退役操作在备份、引用扫描和恢复验证后减少;Mobile key store owner 与 keyset manifest 提供恢复证据 | | `sessions.metadata.model_selection` | 会话首次固定 model ref/effort 时增加版本化对象 | 用户切换 model/effort 时仅更新该对象;旧字符串 override 在下一次显式选择时升级 | 用户选择“跟随默认”时只移除该 metadata 键;不得改写或减少 messages | -| 插件贡献的 Skill/Drift skill | 插件 source 持有 skill 正文;安装把版本化副本发布到 cache,generation 从 `skill_roots` 建 catalog | workspace `skills/` 和 `drift/skills/` 软链接随 active generation 重建 | 禁用/卸载插件可以移除已安装副本、catalog 和软链接;外部 canonical source 不归 workspace 或卸载流程所有 | -| 插件贡献的 MCP | 插件安装读取 `mcp_servers()` 并准备 runtime,generation readiness 通过后发布 MCP catalog | 插件升级或热重载按 generation 原子替换,旧代随 lease 排空 | 禁用/卸载插件移除 MCP catalog 和 runtime;plugin-data 不级联删除 | -| `mcp/servers/*.toml` 与手工 skill 目录 | 当前代码仍允许绕过插件直接声明或放置能力 | watcher/loader 可以热加载这些兼容内容 | 目标架构不再扩展这条路径;应迁移成插件并删除第二套 owner,迁移完成前不得把兼容目录写成 canonical 产品资产 | +| 插件贡献的 Skill/Drift skill | 插件 source 持有 skill 正文;安装把版本化副本发布到 cache,generation 从模块 `skill_roots` / `drift_skill_roots` 属性建 catalog | workspace `skills/` 和 `drift/skills/` 软链接随 active generation 重建 | 禁用/卸载插件可以移除已安装副本、catalog 和软链接;外部 canonical source 不归 workspace 或卸载流程所有 | +| 插件贡献的 MCP | static manifest 提供 import-free admission identity;V3 `apply` 用 `MCP_SERVERS.register(...)` 建立 Fiber-owned runtime,generation readiness 核对两者完全一致后发布 catalog | 插件升级或热重载按 generation 原子替换,旧代随 lease 排空 | 禁用/卸载插件移除 MCP catalog 和 runtime;plugin-data 不级联删除 | +| 旧 `mcp/servers/*.toml` | runtime loader、watcher、admin 与 workspace 初始化入口均已删除;既有目录不再被读取 | 无当前 writer 或热加载路径 | 用户确认且备份后可删除既有惰性目录;MCP 只通过 V3 插件 artifact 与 generation catalog 发布 | | `memes/manifest.json` | workspace 初始化时创建空 manifest;Meme 插件和管理 Skill 按显式用户操作增加类别与素材 | Meme 插件按 manifest mtime 重载;Dashboard/Skill 可原位更新 manifest 和类别目录 | 仅明确的 Meme 管理动作可在备份后减少;插件卸载、candidate discard 和 Core 清理不得删除正式素材根 | | 诊断 JSONL、`subagent-runs/` | 运行和调查持续追加产物 | 通常不原位改写 | 当前缺少统一 retention;没有策略前不得假装它们会永久存在,也不得擅自 prune 事故证据 | | lock、PID、readiness、socket | 进程启动时创建 | 随当前 boot 更新 | 由进程生命周期 owner 在停止或重启时移除;它们不是业务事实;`.app-server-token` 作为持久 secret 单独处理 | @@ -450,15 +450,16 @@ H3 前的 `WakeStateStore` 保存以下表;H3 后旧 writer 已删除,H2 只 | 路径 | owner | 性质 | |---|---|---| -| `~/.akashic-plugin/manifest.toml` | `agent.plugins.manifest` | 已安装/启用插件和 package 的全局目录 | +| `~/.akashic-plugin/manifest.toml` | `agent.plugins.manifest` | 已安装/启用 V3 插件的全局目录;只接受独立 plugin 条目 | | `~/.akashic-plugin/cache/` | install/source resolver | 可通过安装源重新获取的插件代码缓存 | | 外部插件 canonical source | 用户选择的源码仓库 | 开发资产,不等同于 cache,也不由 workspace 备份拥有 | **F-013:** 修改外部插件必须定位 canonical source;直接备份或编辑 cache 不能替代源码仓库和安装清单。 -插件包同时是 Skill 和 MCP 的能力交付单元: +V3 插件 artifact 同时可以交付 Skill 和 MCP: -1. 插件类通过 `skill_roots()`、`drift_skill_roots()` 和 `mcp_servers()` 声明能力。 +1. 插件模块通过 `skill_roots`、`drift_skill_roots` 属性声明 Skill;MCP 同时写入 static + manifest,并在 `apply` 中通过 `MCP_SERVERS.register(...)` 注册。 2. `plugin-install` 在 staging 中复制插件代码并准备 MCP runtime,完成后原子发布到全局 cache,再更新 manifest。 3. `PluginManager` 为候选 generation 准备 Skill/MCP catalog;readiness 失败时拒绝候选,旧 generation 继续服务。 4. generation 发布后,`PluginSkillLinker` 才把 active plugin 的 skill 同步成 workspace 软链接。 @@ -479,13 +480,12 @@ H3 前的 `WakeStateStore` 保存以下表;H3 后旧 writer 已删除,H2 只 `plugin-install` 由当前 Gateway 的 runtime owner staged publish,并等待 `latest_ready`;`RuntimeSnapshotStore` 只允许显式 selector 租用 latest,普通 turn 默认 stable。promote/discard 通过 pointer、journal 和 snapshot lease 收敛。安装成功只证明候选 ready,仍必须用 programmatic child 的 snapshot identity、SessionDB/tool trace 和领域 oracle 证明行为有效。 -### 10.3 MCP 的插件路径与现有直装路径 +### 10.3 MCP 的唯一插件路径 -- 目标路径:插件类的 `mcp_servers()` 声明 MCP;安装器准备 runtime;插件 generation 发布 MCP catalog。 +- static manifest 声明 import-free admission identity;V3 `apply` 通过 `MCP_SERVERS.register(...)` + 注册 Fiber-owned runtime;generation readiness 要求两份声明逐字段一致。 - 旧 `mcp/servers/*.toml`、`WorkspaceMcpAdmin` 和 `WorkspaceMcpWatcher` 已删除;MCP 声明只由插件静态 manifest 进入 Root registry 与 generation host。 -- 两条路径发生同名冲突时,当前启动流程 fail-loud。这证明它们确实是两套并列 owner,而不是同一安装流程的不同界面。 - -产品意图已经确认:新增和保留的 MCP 应通过插件安装。直装声明需要迁移成插件贡献;完成迁移前保留兼容读取和恢复能力,但不再把它定义为长期 canonical 资产,也不新增依赖这条路径的功能。 +- 不再存在 workspace 直装读取或兼容 owner;既有惰性目录只按显式备份清理协议处理。 ### 10.4 `skills/` 与 `drift/skills/` @@ -569,7 +569,7 @@ listener 与 Dashboard 读写同一副本,discard 不改正式素材,promoti 4. 没有一条仓库内工作流证明同一份快照能在隔离 workspace 恢复并通过应用级只读 smoke。 5. SQLite 分别 backup 时,每个文件内部一致,但多个数据库与普通文件之间没有全局事务时点。 6. 备份范围没有明确包含或排除 `.app-server-token`、diagnostic traces、旧或非模型全局凭据和全局插件 manifest。 -7. `mcp/servers/*.toml` 与 workspace 手工 skill 目录仍绕过插件安装系统,形成第二套能力 owner;现存内容尚未迁移。 +7. 旧 `mcp/servers/*.toml` runtime 已物理删除;正式 workspace 若仍有惰性历史目录,需要在备份和内容盘点后单独清理。 8. 通用 rolling backup 尚不能把 WebUI publication DB 与它引用的 blobs 作为同一一致性 source;首版 publisher 必须至少导出可审阅 reachable manifest,并在发布正式资源前完成隔离恢复 smoke。 这些缺口正是 `BAK-001` 和 `NOW.md` 中恢复演练事项尚未完成的部分。 @@ -642,9 +642,9 @@ INT-001~INT-008 和 INT-011 已由花月哥哥确认,其中长期语义已 ### INT-011 Skill 和 MCP 通过插件安装 — 已确认 -确认内容:Skill、Drift skill 和 MCP 都由插件包声明和安装。插件 source 持有能力正文,cache 保存已安装版本与 MCP runtime,manifest 记录安装身份;workspace skill 软链接只是 active generation 的投影,plugin-data 继续留在主要 workspace。 +确认内容:Skill、Drift skill 和 MCP 都由 V3 插件 artifact 声明和安装。插件 source 持有能力正文,cache 保存已安装版本与 MCP runtime,manifest 记录安装身份;workspace skill 软链接只是 active generation 的投影,plugin-data 继续留在主要 workspace。 -已提升条款:PLG-009。当前 `mcp/servers/*.toml` 直装通道和 workspace 手工 skill 目录是待迁移兼容路径;恢复应根据插件 manifest/source 重装能力并重建投影,不复制链接目标。 +已提升条款:PLG-009。`mcp/servers/*.toml` 直装通道已删除;恢复根据插件 manifest/source 重装能力并重建投影,不复制历史目录或链接目标。 ### INT-012 诊断数据需要有界保留,不自动进入长期记忆 diff --git a/docs/design/plugin-domain-observe-events-task-contract.md b/docs/design/plugin-domain-observe-events-task-contract.md index 2677b5516..240222cf4 100644 --- a/docs/design/plugin-domain-observe-events-task-contract.md +++ b/docs/design/plugin-domain-observe-events-task-contract.md @@ -1,6 +1,6 @@ # Core 领域 Observe 事件任务合同 -- 状态:implemented / focused-tested +- 状态:superseded by pure-V3 direct publication - 日期:2026-08-17 - 目标分支:`codex/plugin-v3-mobile-ui-query` - 恢复点:`backup/observe-events-pre-20260817` @@ -9,16 +9,16 @@ ## 1. 目标 -为已经存在的三个领域事实提供 Core-owned、request/generation-bound 的 -`ObserveEventKey`,让 v3 插件可以监听同一对象,同时保持 v2 EventBus 的既有 -消费顺序和 payload。Core 不复制领域 DTO,也不替插件拥有数据状态。 +本文记录最初的过渡设计。当前合同见[插件 V3 能力手册](plugin-v3-capabilities.md):领域 +owner 直接发布 request/generation-bound `ObserveEventKey`,`EventBus.fanout()` 不再按 payload +类型猜测并桥接到插件组合树。Core 不复制领域 DTO,也不替插件拥有数据状态。 ```text settled domain fact │ ▼ ┌────────────────────────┐ -│ legacy EventBus fanout │ 先完成既有 handler +│ domain owner settles fact │ └────────────┬───────────┘ ▼ ┌───────────────────────────────┐ @@ -30,60 +30,51 @@ settled domain fact | 事实 | Key | Core owner | 当前生产入口 | |---|---|---|---| -| `ProactiveFinished` | `proactive.finished` | `agent/turn_events/observe.py` | `EventBus.enqueue` → `fanout` | -| `RetrievalCompleted` | `memory.retrieval.completed` | `agent/turn_events/observe.py` | `DefaultMemoryRetrievalPipeline.retrieve` | -| `MemoryWritten` | `memory.written` | `agent/turn_events/observe.py` | `EventBus.fanout`(Memory2 supersede) | +| `RetrievalCompleted` | `memory.retrieval.completed` | `agent/turn_events/observe.py` | Akasha 检索 owner 直接 observe | +| `MemoryWritten` | `memory.written` | `agent/turn_events/observe.py` | 当前无生产者;只保留结构合同 | -payload 继续使用 `bus.events_lifecycle.ProactiveFinished`、 -`core.memory.events.RetrievalCompleted` 和 `core.memory.events.MemoryWritten`; -桥接时传递原对象,不重新拼装事件。 +payload 使用 `core.memory.events.RetrievalCompleted` 和 `core.memory.events.MemoryWritten`; +发布时传递原对象,不重新拼装事件。 ## 2. 调度与 generation 边界 -- `EventBus.fanout` 先等待已有 v2 handlers,再调用 - `observe_composition_domain_event`。无 v2 handler 时不能提前返回,仍要运行 - composition observer。 -- EventBus 已绑定 request lease 时,桥接读取同一个 - `get_lifecycle_runtime_snapshot()`;candidate、stable 和旧 generation 不从 - 全局 latest 重新选择。 -- Retrieval pipeline 接收可选 EventBus。正式 wiring 传入主 EventBus,从而保留 - legacy fanout;没有 EventBus 的单元调用直接使用当前绑定的 composition Root。 -- `TurnCommitted` 继续由 after-turn phase 自己在 legacy fanout 后发出,不能在 - 这个通用 bridge 中重复发送。 +- 领域 owner 调用 `observe_composition_event(KEY, payload)`,从当前 request lease 读取 exact + composition Root;candidate、stable 和旧 generation 不从全局 latest 重新选择。 +- `TurnCommitted` 由 after-turn owner 在 Core EventBus fanout 后显式发布 + `AFTER_TURN_COMMITTED`,不是通用类型桥的一部分。 +- `EventBus` 只服务仍由 Core 拥有的内部事件消费者,不是插件 API。 ## 3. Retrieval 事实 -`DefaultMemoryRetrievalPipeline` 在 MemoryEngine 成功返回后从同一 +Akasha 插件在 MemoryEngine 成功返回后从同一 `MemoryQueryResult` 形成 `RetrievalCompleted`: - `rewritten_query`、`aux_queries`、`hyde_hypotheses` 和 `route_decision` 只从 engine result 的 trace/raw 读取;缺失时使用原始请求或空集合; - 每个 `MemoryRecord` 转成现有 `RetrievalHitSummary`,保留 id、kind、score、 summary、injected、confidence/forced signals 和 metadata; -- engine 普通异常先发布带 `error` 的 settled event,再重新抛出原异常;取消 - 不伪造完成事件,继续传播 cancellation; -- 没有 MemoryEngine 的旧无记忆路径保持空结果和 no-op,不写事件或持久状态。 +- engine 失败或取消时不伪造 completed event,原异常或 cancellation 继续传播。 ## 4. 失败、清理与持久化边界 -- 没有 composition Root 时保持旧路径 no-op;错误 task、释放 lease 或错误 +- 没有 composition Root 时 direct publication no-op;错误 task、释放 lease 或错误 binding 由 lifecycle snapshot owner fail-loud。 - 普通 observer failure 仍由 `EventRegistry.observe` 记录所属 Fiber Incident - 并隔离;调用方取消、进程级异常和 bridge/lease 错误不能被吞掉。 -- 三个 bridge 只在内存中 dispatch。candidate 不写正式 workspace、plugin-data、 + 并隔离;调用方取消、进程级异常和 lease 错误不能被吞掉。 +- direct publication 只在内存中 dispatch。candidate 不写正式 workspace、plugin-data、 SessionDB、memory DB 或外部渠道;插件自身的派生写入仍必须使用 Core 分配的 candidate data root,并由插件合同验证。 -- 本变更没有删除、更新或迁移权威持久记录。旧 EventBus handler、队列 lease、 - Root Effect/listener 的清理语义保持不变。 +- 本变更没有删除、更新或迁移权威持久记录。Core EventBus handler、队列 lease、Root + Effect/listener 的清理语义保持不变。 ## 5. 验收与 mutant 定向测试位于 `tests/test_plugin_composition_lifecycle.py`,覆盖: -- 三类事件的 legacy-before-composition 顺序和原对象 identity; -- 没有 legacy handler 时 composition observer 不被 early-return mutant 跳过; +- direct V3 publication 保留原对象 identity; +- EventBus fanout 不会隐式进入插件 composition; - 两个绑定 Root 间的 candidate/generation 选择与 wrong-task fail-loud; -- Retrieval payload 字段、engine failure event 和原异常传播; +- Retrieval payload 字段和原异常传播; - leaf contract 在 fresh interpreter 中不加载 phase runtime。 验证命令: @@ -92,7 +83,7 @@ payload 继续使用 `bus.events_lifecycle.ProactiveFinished`、 ./.venv/bin/python -m pytest -q tests/test_plugin_composition_lifecycle.py ./.venv/bin/python -m compileall -q agent/turn_events/observe.py \ agent/lifecycle/composition.py bus/event_bus.py \ - agent/retrieval/default_pipeline.py agent/looping/core.py bootstrap/tools.py + plugins/akasha/plugin.py agent/looping/core.py bootstrap/tools.py ``` -回滚:恢复到 `backup/observe-events-pre-20260817` 或回退本次 Core 事件桥接变更。 +历史过渡实现可从 `backup/observe-events-pre-20260817` 查阅,但不得恢复通用桥。 diff --git a/docs/design/plugin-mcp-hot-reload-cache-drain.md b/docs/design/plugin-mcp-hot-reload-cache-drain.md index 07147a23c..a01450232 100644 --- a/docs/design/plugin-mcp-hot-reload-cache-drain.md +++ b/docs/design/plugin-mcp-hot-reload-cache-drain.md @@ -175,7 +175,7 @@ runtime 不变量无法建立时才结束 Core;本轮不定义 critical MCP 旧 cache 可能仍是 `cache////` 单目录布局。source resolver 可以把一个旧可见版本作为初始 stable,但首次 staged 更新必须把新 revision 写入 `.artifacts/` 并建立 pointer pair;不得为了统一布局先删除或搬空当前 stable。 -本设计只覆盖插件静态 manifest 与 `mcp_servers()` Root 声明链。`mcp/servers/*.toml` 与 `WorkspaceMcpWatcher` 已删除,不能作为新的安装、恢复或 cache 删除 owner。 +本设计只覆盖插件 static manifest 与 `MCP_SERVERS.register(...)` Root 注册链。`mcp/servers/*.toml` 与 `WorkspaceMcpWatcher` 已删除,不能作为新的安装、恢复或 cache 删除 owner。 ## 9. 已有证据 diff --git a/docs/design/plugin-v3-capabilities.md b/docs/design/plugin-v3-capabilities.md new file mode 100644 index 000000000..fadb907f5 --- /dev/null +++ b/docs/design/plugin-v3-capabilities.md @@ -0,0 +1,215 @@ +# 插件 V3 能力手册 + +本文记录当前插件 V3 的公开能力和最短用法。代码真源是 +`agent/plugin_composition/__init__.py`、`agent/plugins/composable.py` 以及各能力模块;未从公开包 +导出的 Core 对象不属于插件 API。 + +## 1. 最小插件 + +```python +from agent.plugin_composition import Context + +api_version = 3 +name = "example" +version = "1.0.0" +inject = () + + +async def apply(ctx: Context, config: object) -> None: + pass +``` + +Core 只接受精确的 `apply(ctx, config)`。`api_version != 3`、V2 `Plugin` 子类、固定 lifecycle +方法和 phase module 注入都不会被加载,也没有自动包装或兼容 fallback。插件不能直接接入 +`EventBus`;V3 事件由明确 owner 通过 typed key 发布。 + +| 模块声明 | 用途 | +|---|---| +| `api_version`、`name`、`version`、`apply` | 必需的身份和唯一入口 | +| `Config` | 可选配置模型;Core 校验后传给 `apply` | +| `inject` | 根 Fiber 激活所需的 `ServiceKey` | +| `is_active(services)` | 根据冻结的静态 Service view 决定是否发布静态贡献 | +| `static_semantic_checks()` | 返回安装或 generation 的静态语义检查 | +| `skill_roots`、`drift_skill_roots` | 发布普通 Skill 和 Drift Skill | +| `workspace_roots`、`workspace_files` | 声明被授权的 workspace 路径;只授予真正的数据 owner | +| `dashboard_module` | 发布 Dashboard HTTP/面板模块 | +| `web_module`、`web_requires`、`web_provides`、`web_contract_digests` | 发布 Web 模块及版本化组合合同 | + +## 2. 组合原子能力 + +每次 `apply` 都属于一个 generation-bound Fiber。下列注册和任务归该 Fiber 所有,并在失活、 +重启或卸载时逆序清理。 + +| 原子能力 | 最短用法 | 语义 | +|---|---|---| +| 硬依赖 | 模块级 `inject = (KEY,)` | 全部 Service 可用时根 Fiber 才激活 | +| 可选依赖 | `await ctx.inject((KEY,), child)` | 子 Fiber 随依赖出现和消失,不阻塞 Root readiness | +| 子 Fiber | `await ctx.mount(child, name="worker")` | 分开生命周期、Health、Effect 和依赖 | +| 提供 Service | `await ctx.provide(KEY, value)` | 当前 Fiber 成为该 key 的活动 provider | +| 读取 Service | `ctx.require(KEY)` / `ctx.get(KEY)` | 必需读取 fail-loud;可选读取返回 `None` | +| Effect | `await ctx.effect(setup, label="client")` | `setup` 返回 cleanup;Fiber 逆序调用 | +| 后台任务 | `await ctx.spawn(run(), name="poll")` | 失败进入 Fiber 状态,卸载时取消并等待 | +| Health | `health = await ctx.health("upstream")` | `degrade(reason)` / `recover()`;required 项参与 readiness | +| Incident | `ctx.report_incident("fetch", "timeout")` | 记录历史失败,不隐式改变 Health | +| 数据根 | `ctx.data_root` | Core 为 formal 或 candidate 分配的独立数据根;插件可正常读写 | +| Workspace 路径 | `ctx.workspace_root("memory")` | 返回模块预先声明的原生 `Path`;Core 校验路径归属,但不拦截写入 | +| 运行身份 | `ctx.runtime`、`ctx.generation_id` | plugin、artifact、generation 和目录身份 | +| 短运行作用域 | `async with ctx.runtime_scope(): ...` | 后台操作绑定 exact Root lease | +| 跨 task 作用域 | `scope = ctx.capture_runtime_scope()` | 显式 fork 当前 lease;调用者负责关闭 | +| 诊断 | `ctx.diagnostics.operation(...)` | 记录 generation-bound 边界和有限指标 | + +跨插件 Service 使用本地、版本化结构合同: + +```python +from typing import Protocol +from agent.plugin_composition import Context, ServiceKey + +class Greeter(Protocol): + def greet(self, name: str) -> str: ... + +GREETER = ServiceKey[Greeter]("example.greeter.v1") + +async def apply(ctx: Context, config: object) -> None: + await ctx.provide(GREETER, MyGreeter()) +``` + +双方各自声明同名、同结构的 key,通过 `inject` 和 `ctx.require()` 连接,不能 import 对方源码。 + +## 3. Typed event + +注册统一使用 `await ctx.on(KEY, listener)`。 + +| Key | 发布 | 失败与顺序 | +|---|---|---| +| `EmitEventKey[T]` | `ctx.emit(KEY, payload)` | 同步、按注册顺序、首个失败立即传播 | +| `SerialEventKey[T, R]` | `await ctx.serial(KEY, payload)` | 逐个等待;只有显式 `Bail(value)` 短路 | +| `ParallelEventKey[T]` | `await ctx.parallel(KEY, payload)` | 仅 async listener;全部 settle 后聚合失败 | +| `TransformEventKey[T]` | `await ctx.transform(KEY, payload)` | 按顺序把同类型不可变值传给下一 listener | +| `ObserveEventKey[T]` | `await ctx.observe(KEY, payload)` | 全部 settle;普通失败隔离为 Incident | + +Key 的结构与调度实现归 Core;事实只能由下列领域 owner 发布: + +| Key | 时机 | +|---|---| +| `CONTEXT_PREPARED_EVENT` | Session 与上下文准备后 | +| `PROMPT_RENDER_EVENT` | Prompt 渲染前 | +| `AFTER_REASONING_PREPROCESS_EVENT` | 推理结果形成后、持久化前 | +| `AFTER_REASONING_CLEANUP_EVENT` | Core 清理阶段 | +| `AFTER_TURN_COMMITTED` | user/assistant 已原子提交 | +| `RUNTIME_STARTED`、`RUNTIME_STOPPING` | committed snapshot 启停 | +| `SNAPSHOT_SEALING` | candidate catalog 冻结前 | +| `RETRIEVAL_COMPLETED` | Akasha 插件检索完成;仅 Akasha 活动时发布 | +| `CONTEXT_PROJECTION_COMMITTED` | Compaction 插件完成上下文投影;仅该插件活动时发布 | + +`MEMORY_WRITTEN` key 仍是公开结构合同,但当前 pure-V3 Core 没有生产者;Memory2 退役后不得把 +“能注册 listener”误报成“线上会发布事件”。新增生产者必须由领域 owner 明确发布,不能恢复 +EventBus 类型猜测桥。 + +## 4. Core Service 原子能力 + +所有 Service 先写入 `inject`,再用 `service = ctx.require(KEY)`。声明型注册本身是 Effect。 + +### 4.1 人与 Agent 的入口 + +| Key | 主要方法 | 用途 | +|---|---|---| +| `COMMANDS` | `register(ctx, CommandDefinition(...))` | 人类命令、alias 和 handler | +| `TOOL_CATALOG` | `register(ctx, PluginToolDefinition(...), handler)` | 模型 Tool | +| `UI_SLOTS` | `register_mobile(ctx, definition, query=...)` | Mobile 页面、查询和导航 | +| `CHANNELS` | `register(ctx, ChannelDefinition(...))` | inbound/outbound Channel blueprint | +| `DELIVERIES` | `send(...)` | 当前 Turn 内的一次投递 | +| `DURABLE_DELIVERIES` | `submit()`、`lookup()`、`resume()` | 三态、可恢复的外部投递 | + +Tool 还支持纯 V3 的命名 handler:省略直接 handler,使用 `handler_export` 指向模块内 +`async (context, arguments)`。Core 在 snapshot 编译时解析并校验 exact generation;它用于需要稳定 +导出身份的已安装插件,不是 V2 fallback。 + +### 4.2 Turn、Session 与上下文 + +| Key | 主要方法 | 用途 | +|---|---|---| +| `SCOPED_TURNS` | `create_session()`、`ensure_session()`、`start()`、`read()` | generation-bound programmatic Turn | +| `CONTINUATIONS` | `submit(...)` | 向已有 Turn 提交继续输入 | +| `SESSION_READ` | `read(session_key)` | 只读既有 Session 投影 | +| `SESSION_COMPACTION_STORAGE` | `history_units()`、`prepare()`、`persist()` | Compaction 专用窄持久化边界 | +| `PROVIDER_REQUEST_PROJECTION` | `open_turn(...)` | provider request 的冻结投影和 retry gate | +| `CONTEXT_PROJECTION_FACTS` | `list_committed()`、`get_committed()` | 读取已提交上下文投影事实 | +| `INTERACTION_UNDO` | `bind_source_fence()`、`undo_latest()` | 显式撤销最近 interaction | +| `CONVERSATION_SEMANTIC_INTEREST` | `score(texts, cutoff=...)` | 统一语义兴趣评分 | + +### 4.3 调度与外部运行 + +| Key | 主要方法 | 用途 | +|---|---|---| +| `TIMERS` | `schedule(deadline)` | Core-owned timer | +| `BACKGROUND_JOBS` | `register(ctx, BackgroundJobDefinition(...))` | trigger job 与命名 handler export | +| `MCP_SERVERS` | `register(ctx, McpServerDefinition(...))` | generation-bound MCP server | +| `MANAGED_PROCESSES` | `register(ctx, ManagedProcessDefinition(...))` | Core 监督的进程 | +| `WORKLOADS` | `register(ctx, Workload(...))` | 窄 Controller 管理的容器 workload | +| `EXECUTOR_SERVICE` | `parallel_sync(jobs)` | 有界纯同步工作;worker 不取得 Context/Fiber | + +Skill 和 Drift Skill 使用模块级 `skill_roots` / `drift_skill_roots`,由安装、candidate readiness +和 generation catalog 原子发布。MCP、process 和 workload 有两份职责不同但必须一致的声明: +`akashic.plugin.toml` 提供 import-free admission identity,`apply` 再通过上表 Service 建立 +Fiber-owned registration;candidate readiness 会逐字段核对,不一致时 fail-loud。 + +### 4.4 模型 + +| Key | 主要方法 | 用途 | +|---|---|---| +| `CHAT_MODELS` | `execution()`、`independent_execution()`、`describe()`、`bind()` | 按冻结 revision 取得 chat model | +| `EMBEDDINGS` | `embed(texts)` | 统一 embedding space | +| `MODEL_CATALOG` | `snapshot()`、`validate_chat_selection()` | 模型和 connection 目录 | +| `MODEL_SETTINGS` | `discover()`、`apply(ModelChange)` | 原子修改模型设置 | +| `MODEL_DRIVERS` | `register(ctx, ModelDriverDefinition(...))` | Provider 注册模型 driver | + +Provider 返回结构化 `ModelUsage` 和公开错误类型;未知能力保持 unknown,不用默认值伪装。 + +### 4.5 插件间声明 + +插件可以提供自己的 `ServiceKey`,例如 `memory.recall.v1`、`eventmail.wake.v1` 或 +`drift.proposals.v1`。这些不是 Core 能力总表:owner 定义结构合同,consumer 只通过 key 连接。 +`EMBEDDING_MEMORY_PLUGIN` 是当前 embedding-memory owner claim,同一 Root 只允许一个 owner。 + +## 5. Dashboard 与 Web + +`dashboard_module = "dashboard.py"` 让 Core 用 `DashboardContext` 加载模块。Dashboard 只能通过 +`workspace_root()`、`workspace_file()` 和 `workload_url()` 取得已声明资源。 + +`web_module` 指向随 artifact 发布的浏览器模块。`web_requires` / `web_provides` 声明组合合同, +`web_contract_digests` 固定合同内容。缺少 provider、digest 不一致或越界资源在 publication Gate +fail-loud。 + +## 6. Generation 与 candidate + +```text +source + config + │ + ▼ +isolated candidate Root ── settle / Health / Incident / semantic checks + │ pass + ▼ +committed snapshot ── stable/latest pointer ── request lease + │ + └─ old request keeps old Root until lease drain +``` + +- Candidate 使用隔离 Root、plugin-data 副本、workspace 投影、端口和外部效果策略,不能 + 复用 stable Root 或正式 plugin-data 宣称通过。 +- Root 只生成能力,不能自行晋升。artifact、journal、stable/latest、parent Turn 授权和恢复由 Core + publication plane 拥有。 +- Workspace path 是显式授予正式数据 owner 的高权限能力,不应替代窄 Service;candidate + 只得到声明路径在 attempt workspace 内的副本。 +- 普通卸载删除代码、manifest 和派生投影,默认保留 plugin-data。`manifest.toml` 只接受 + 独立 `[plugins.""]` 条目;旧 `[packages]` 分组不会展开、保留或静默忽略。 + +## 7. 选择能力 + +1. 同一插件内部拆生命周期:`ctx.mount()`。 +2. 插件之间共享行为:版本化 `ServiceKey` + `provide/require`。 +3. 已结算事实的一对多通知:`ObserveEventKey`。 +4. 顺序策略:`SerialEventKey`;同类型改写链:`TransformEventKey`。 +5. 对人暴露动作:`COMMANDS`;对模型暴露动作:`TOOL_CATALOG`。 +6. 长时或可恢复工作:`BACKGROUND_JOBS`、`SCOPED_TURNS`、`DURABLE_DELIVERIES`。 +7. 外部进程、MCP、容器:`MANAGED_PROCESSES`、`MCP_SERVERS`、`WORKLOADS`。 +8. 找不到匹配能力时先定义窄 Service,不给 Manager 增加新的固定插件方法。 diff --git a/docs/design/pr-518-entropy-review.md b/docs/design/pr-518-entropy-review.md index 35d381de9..66c904d07 100644 --- a/docs/design/pr-518-entropy-review.md +++ b/docs/design/pr-518-entropy-review.md @@ -55,7 +55,7 @@ v2/legacy Channel ──► agent.looping.InterruptController ──► Core 私 - 证据:内建 scheduler/subagent 的 `is_active(ServiceView)` 仍读取 timers、scoped turns、deliveries、continuations 的 `.formal`;外部 proactive-feedback v3 源码仍读取 `SessionReadService.formal`,用来阻止 candidate Root 启动 worker 或写插件数据库。 - 失败:scheduler/subagent 在静态准入阶段抛 `AttributeError`;proactive-feedback candidate/formal 行为失去判别入口。 - 处理:`19a51040` 已恢复五个可观察属性;SessionRead、subagent、scheduler 非 soft 路径 17 项通过。 -- 上位替代:长期应由一个 runtime mode owner 表达 candidate/formal;`Context.data_access` 已覆盖 apply 阶段,`ServiceView` 应只表达能力是否可用。但外部插件和静态准入必须先同步迁移。 +- 后续收口:candidate 现在始终使用独立可写副本;`Context.data_access` 与无生产者的 read-only candidate 分支已经删除。 - 结论:本 PR 不能静默删除。跨仓库迁移完成前,保留派生只读属性比伪装删除更安全。 ### 4. `ToolGrant.except_names()` 被删时 scheduler 仍调用 diff --git a/docs/projectneed.md b/docs/projectneed.md index edae5728a..b7596954a 100644 --- a/docs/projectneed.md +++ b/docs/projectneed.md @@ -695,7 +695,7 @@ active 检查错误、generation key 错配、名称冲突、依赖缺失和拓 ### PLG-009 Skill 和 MCP 通过插件安装发布 -Skill、Drift skill 和 MCP server 都由插件包声明并通过插件安装系统进入 Akashic。插件的 `skill_roots`、`drift_skill_roots` 和 `mcp_servers` 是能力来源;安装阶段准备代码与 MCP runtime,generation readiness 全部通过后再原子发布 catalog。workspace 中的 skill 软链接只是当前插件 generation 的可重建投影,不是 canonical source。独立 `mcp/servers/*.toml` 和 workspace 内手工 skill 目录不属于目标安装模型;现有兼容路径必须迁移到插件,不能继续扩展成第二套能力所有权。 +Skill、Drift skill 和 MCP server 都由 V3 插件 artifact 声明并通过插件安装系统进入 Akashic。模块的 `skill_roots`、`drift_skill_roots` 属性是 Skill 来源;MCP 的 static manifest admission identity 必须与 `apply` 中 `MCP_SERVERS.register(...)` 的 Fiber-owned registration 完全一致。安装阶段准备代码与 MCP runtime,generation readiness 全部通过后再原子发布 catalog。workspace 中的 skill 软链接只是当前插件 generation 的可重建投影,不是 canonical source。独立 `mcp/servers/*.toml`、手工 skill 目录和 `[packages]` 均不属于当前安装模型,也没有兼容读取入口。 ### PLG-010 卸载插件默认保留 plugin-data diff --git a/plugins/akasha/akashic.plugin.toml b/plugins/akasha/akashic.plugin.toml index 42decda19..9a8408056 100644 --- a/plugins/akasha/akashic.plugin.toml +++ b/plugins/akasha/akashic.plugin.toml @@ -3,4 +3,3 @@ name = "akasha" version = "3.0.0" api_version = 3 entrypoint = "plugin.py" -candidate_data_mode = "shared_read" diff --git a/plugins/akasha/plugin.py b/plugins/akasha/plugin.py index 6c7a579b2..b703c9498 100644 --- a/plugins/akasha/plugin.py +++ b/plugins/akasha/plugin.py @@ -13,7 +13,7 @@ from agent.lifecycle.composition import ( AFTER_REASONING_PREPROCESS_EVENT, PROMPT_RENDER_EVENT, - observe_composition_domain_event, + observe_composition_event, ) from agent.lifecycle.types import AfterReasoningCtx, PromptRenderCtx from agent.plugin_composition import ( @@ -61,6 +61,7 @@ MemoryToolSpec, ) from agent.turn_events.after_turn import AFTER_TURN_COMMITTED +from agent.turn_events.observe import RETRIEVAL_COMPLETED_EVENT from bus.events_lifecycle import TurnCommitted from session.store import InteractionDeletion from .config import AkashaConfig, load_akasha_config @@ -634,7 +635,10 @@ async def _inject_memory( value = result.trace.get(name) if isinstance(value, (int, float)) and not isinstance(value, bool): diagnostics.measure(f"memory.{name}", value) - await observe_composition_domain_event(build_retrieval_completed(request, result)) + await observe_composition_event( + RETRIEVAL_COMPLETED_EVENT, + build_retrieval_completed(request, result), + ) block = result.text_block.strip() if block: event.system_sections_bottom.append( diff --git a/plugins/drift/akashic.plugin.toml b/plugins/drift/akashic.plugin.toml index 53ff60ac6..7c987fa7b 100644 --- a/plugins/drift/akashic.plugin.toml +++ b/plugins/drift/akashic.plugin.toml @@ -3,4 +3,3 @@ name = "drift" version = "3.0.0" api_version = 3 entrypoint = "plugin.py" -candidate_data_mode = "shared_read" diff --git a/plugins/drift/plugin.py b/plugins/drift/plugin.py index 2a4ebe379..ff9de2ef3 100644 --- a/plugins/drift/plugin.py +++ b/plugins/drift/plugin.py @@ -140,10 +140,7 @@ async def apply(ctx: Context, config: object) -> None: """Publish the narrow Drift view over one generation-scoped store.""" _ = config - store = DriftStore( - ctx.data_root / "drift.sqlite3", - data_access=ctx.data_access, - ) + store = DriftStore(ctx.data_root / "drift.sqlite3") store.initialize() _ = await ctx.provide(DRIFT_PROPOSALS, _ProposalServices(store)) _ = await ctx.provide(DRIFT_WAKE, _WakeServices(store)) diff --git a/plugins/drift/store.py b/plugins/drift/store.py index e53eb6cc3..8674f168b 100644 --- a/plugins/drift/store.py +++ b/plugins/drift/store.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from datetime import UTC, datetime from pathlib import Path -from typing import Generator, Literal, NotRequired, TypedDict +from typing import Generator, NotRequired, TypedDict _SCHEMA_VERSION = 2 _TABLE_SQL_V1 = """ @@ -97,19 +97,13 @@ class DriftSelectionReceipt(TypedDict): class DriftStore: """Persist Drift proposals and their Turn-bound lifecycle.""" - def __init__( - self, - path: Path, - *, - data_access: Literal["read_write", "read_only"] = "read_write", - ) -> None: + def __init__(self, path: Path) -> None: self.path = path - self.data_access = data_access def initialize(self) -> None: """Create or validate the exact Drift schema.""" - with self._transaction(write=self.data_access == "read_write") as connection: + with self._transaction(write=True) as connection: version = int(connection.execute("PRAGMA user_version").fetchone()[0]) if version != _SCHEMA_VERSION: raise RuntimeError(f"不支持的 Drift schema version: {version}") @@ -478,31 +472,18 @@ def settle_delivery( @contextmanager def _transaction(self, *, write: bool) -> Generator[sqlite3.Connection]: - """Open one mode-aware SQLite transaction.""" + """Open one SQLite transaction.""" - if write and self.data_access == "read_only": - raise PermissionError("Drift read-only candidate cannot write shared data") - if self.data_access == "read_write": - self.path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(self.path) - else: - connection = sqlite3.connect( - self.path.resolve(strict=False).as_uri() + "?mode=ro", uri=True - ) + _ = write + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path) connection.row_factory = sqlite3.Row try: - if self.data_access == "read_write": - connection.execute("PRAGMA journal_mode = WAL") - connection.execute("BEGIN IMMEDIATE") - self._ensure_schema(connection) - else: - connection.execute("PRAGMA query_only = ON") - connection.execute("BEGIN") + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("BEGIN IMMEDIATE") + self._ensure_schema(connection) yield connection - if self.data_access == "read_write": - connection.commit() - else: - connection.rollback() + connection.commit() except BaseException: connection.rollback() raise diff --git a/plugins/eventmail/akashic.plugin.toml b/plugins/eventmail/akashic.plugin.toml index 4fe8945f9..2cd7ab660 100644 --- a/plugins/eventmail/akashic.plugin.toml +++ b/plugins/eventmail/akashic.plugin.toml @@ -3,4 +3,3 @@ name = "eventmail" version = "4.1.0" api_version = 3 entrypoint = "plugin.py" -candidate_data_mode = "shared_read" diff --git a/plugins/eventmail/plugin.py b/plugins/eventmail/plugin.py index 16e7dfc1e..9cc74c07a 100644 --- a/plugins/eventmail/plugin.py +++ b/plugins/eventmail/plugin.py @@ -418,10 +418,7 @@ async def apply(ctx: Context, config: object) -> None: """Publish typed source and consumer views over one EventMail store.""" _ = config - store = EventMailStore( - ctx.data_root / "eventmail.sqlite3", - data_access=ctx.data_access, - ) + store = EventMailStore(ctx.data_root / "eventmail.sqlite3") store.initialize() _ = await ctx.provide( EVENTMAIL_CONTENT_SOURCE, diff --git a/plugins/eventmail/store.py b/plugins/eventmail/store.py index 6e08351cf..b86ffc9b3 100644 --- a/plugins/eventmail/store.py +++ b/plugins/eventmail/store.py @@ -491,19 +491,13 @@ class ContentTransitionResult(TypedDict): class EventMailStore: """Persist Content revisions and expose source- and Wake-scoped transitions.""" - def __init__( - self, - path: Path, - *, - data_access: Literal["read_write", "read_only"] = "read_write", - ) -> None: + def __init__(self, path: Path) -> None: self.path = path - self.data_access = data_access def initialize(self) -> None: """Create or validate the exact schema and SQLite file integrity.""" - with self._transaction(write=self.data_access == "read_write") as connection: + with self._transaction(write=True) as connection: self._validate_schema(connection) result = connection.execute("PRAGMA integrity_check").fetchone() if result is None or result[0] != "ok": @@ -2421,36 +2415,19 @@ def state_counts(self) -> dict[str, int]: @contextmanager def _transaction(self, *, write: bool) -> Generator[sqlite3.Connection]: - """Open one mode-aware SQLite transaction and close it at the boundary.""" + """Open one SQLite transaction and close it at the boundary.""" - # 1. Reject every candidate write at the store's single transaction boundary. - if write and self.data_access == "read_only": - raise PermissionError( - "Content read-only candidate cannot write shared data" - ) - - # 2. Preserve the formal store's serialized transaction and lazy schema setup. - if self.data_access == "read_write": - self.path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(self.path) - else: - database_uri = self.path.resolve(strict=False).as_uri() + "?mode=ro" - connection = sqlite3.connect(database_uri, uri=True) + _ = write + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path) connection.row_factory = sqlite3.Row try: - if self.data_access == "read_write": - _ = connection.execute("PRAGMA journal_mode = WAL") - _ = connection.execute("PRAGMA foreign_keys = ON") - _ = connection.execute("BEGIN IMMEDIATE") - self._ensure_schema(connection) - else: - _ = connection.execute("PRAGMA query_only = ON") - _ = connection.execute("BEGIN") + _ = connection.execute("PRAGMA journal_mode = WAL") + _ = connection.execute("PRAGMA foreign_keys = ON") + _ = connection.execute("BEGIN IMMEDIATE") + self._ensure_schema(connection) yield connection - if self.data_access == "read_write": - connection.commit() - else: - connection.rollback() + connection.commit() except BaseException: connection.rollback() raise diff --git a/plugins/models/akashic.plugin.toml b/plugins/models/akashic.plugin.toml index 60c48924f..b059c900b 100644 --- a/plugins/models/akashic.plugin.toml +++ b/plugins/models/akashic.plugin.toml @@ -3,4 +3,3 @@ name = "models" version = "1.0.0" api_version = 3 entrypoint = "plugin.py" -candidate_data_mode = "shared_read" diff --git a/plugins/models/plugin.py b/plugins/models/plugin.py index 99aa0dc1c..afab94dce 100644 --- a/plugins/models/plugin.py +++ b/plugins/models/plugin.py @@ -40,16 +40,15 @@ async def apply(ctx: Context, config: object) -> None: store = ModelsStore( ctx.workspace_file("model-registry.sqlite3"), backup_dir=ctx.runtime.workspace / "runtime" / "model-backups", - writable=ctx.data_access == "read_write", + writable=True, ) - if ctx.data_access == "read_write": - store.initialize() + store.initialize() state = ModelsState( store, root_instance_token=ctx.root_instance_token, capability_catalog=LiteLlmCapabilityCatalog( ctx.data_root / "litellm-capabilities.json", - writable=ctx.data_access == "read_write", + writable=True, ), ) _ = await ctx.effect( diff --git a/plugins/wake/legacy_rules.py b/plugins/wake/legacy_rules.py index de48bb3be..3678e625b 100644 --- a/plugins/wake/legacy_rules.py +++ b/plugins/wake/legacy_rules.py @@ -7,8 +7,6 @@ from pathlib import Path from typing import cast -from agent.plugin_composition import BeforeTurnCtx - RULES_DIRECTORY = "legacy-rules" RULES_ARCHIVE = "PROACTIVE_CONTEXT.md" RULES_RECEIPT = "receipt.json" @@ -44,18 +42,4 @@ def read_archived_rules(data_root: Path) -> str | None: return content.decode("utf-8").strip() -class ArchivedRules: - """Inject a verified legacy archive into Wake BeforeTurn only.""" - - def __init__(self, data_root: Path) -> None: - self._data_root = data_root - - async def prepare(self, turn: BeforeTurnCtx) -> None: - if turn.channel != "wake": - return - rules = read_archived_rules(self._data_root) - if rules: - turn.extra_hints.append(rules) - - -__all__ = ["ArchivedRules", "read_archived_rules"] +__all__ = ["read_archived_rules"] diff --git a/scripts/measure_production_sloc.py b/scripts/measure_production_sloc.py index 2f6e954f7..6dc7c3de6 100644 --- a/scripts/measure_production_sloc.py +++ b/scripts/measure_production_sloc.py @@ -72,15 +72,11 @@ def production_source_root(path: str) -> str | None: return "main.py" if parts[0] in PYTHON_DIRECTORY_ROOTS: return parts[0] - if parts[0] == "plugin_packages": - return "plugin_packages" if parts[:3] == ["sdk", "python", "src"]: return "sdk/python/src" return None if name.endswith(".ts") or name.endswith(".tsx"): - if parts[0] == "plugin_packages": - return "plugin_packages" if len(parts) >= 4 and parts[0] == "frontend" and parts[2] == "src": return "/".join(parts[:3]) return None diff --git a/scripts/plugin_composition_experiment.py b/scripts/plugin_composition_experiment.py index 2af6ba912..82b9290ed 100644 --- a/scripts/plugin_composition_experiment.py +++ b/scripts/plugin_composition_experiment.py @@ -125,14 +125,27 @@ async def _run(workspace: Path) -> dict[str, object]: # 2. New plugins prove required waiting and optional nested injection. trace = ProbeTrace() - consumer = await root.mount(ProbeConsumer(trace)) + consumer_plugin = ProbeConsumer(trace) + consumer = await root.mount( + consumer_plugin.apply, + name=consumer_plugin.name, + inject=consumer_plugin.inject, + ) pending_receipt = root.receipt() + first_provider_plugin = ProbeProvider("first", trace) provider = await root.mount( - ProbeProvider("first", trace), + first_provider_plugin.apply, + name=first_provider_plugin.name, + inject=first_provider_plugin.inject, runtime=provider_runtime, ) optional_receipt = root.receipt() - _ = await root.mount(ProbeFormatterProvider()) + formatter_plugin = ProbeFormatterProvider() + _ = await root.mount( + formatter_plugin.apply, + name=formatter_plugin.name, + inject=formatter_plugin.inject, + ) ready_receipt = root.receipt() initial_signal = root.context.require(PROBE_SIGNAL) @@ -145,8 +158,11 @@ async def _run(workspace: Path) -> dict[str, object]: ) await provider.dispose() removed_receipt = root.receipt() + second_provider_plugin = ProbeProvider("second", trace) _ = await root.mount( - ProbeProvider("second", trace), + second_provider_plugin.apply, + name=second_provider_plugin.name, + inject=second_provider_plugin.inject, runtime=provider_runtime, ) restored_receipt = root.receipt() diff --git a/tests/test_content_store.py b/tests/test_content_store.py index b74550443..30660a10c 100644 --- a/tests/test_content_store.py +++ b/tests/test_content_store.py @@ -744,50 +744,6 @@ def test_exact_read_rejects_uncheckpointed_wal_instead_of_missing_row(tmp_path) assert store.read_submission("feed-subscriptions", "one") is not None -def test_read_only_store_reads_formal_state_and_rejects_every_write(tmp_path) -> None: - now = datetime(2026, 8, 23, 5, tzinfo=UTC) - path = tmp_path / "content.sqlite3" - formal = EventMailStore(path) - _ = formal.submit("feed", "poll:1", [_item("one", not_before=now)]) - snapshot = formal.snapshot(now) - token = _select(formal, now) - candidate = EventMailStore(path, data_access="read_only") - - candidate.initialize() - assert candidate.snapshot(now)["snapshot_seq"] == snapshot["snapshot_seq"] - assert ( - candidate.selection({"session_id": "wake:fixture", "turn_id": "turn:one"})[ - "selection_token" - ] - == token - ) - assert candidate.unsettled("feed") == () - assert candidate.state_counts() == {"selected": 1} - - with pytest.raises(PermissionError, match="read-only candidate"): - candidate.submit("feed", "poll:2", [_item("two", not_before=now)]) - with pytest.raises(PermissionError, match="read-only candidate"): - candidate.select( - snapshot["items"][0]["ref"], - snapshot["snapshot_seq"], - {"session_id": "wake:candidate", "turn_id": "turn:write"}, - now, - ) - with pytest.raises(PermissionError, match="read-only candidate"): - candidate.transition(token, "ready_for_delivery") - with pytest.raises(PermissionError, match="read-only candidate"): - candidate.ack("feed", "delivery:missing") - - -def test_read_only_initialize_does_not_create_database_or_parent(tmp_path) -> None: - path = tmp_path / "missing" / "content.sqlite3" - - with pytest.raises(sqlite3.OperationalError, match="open database"): - EventMailStore(path, data_access="read_only").initialize() - - assert not path.parent.exists() - - def test_initialize_rejects_unknown_or_malformed_schema(tmp_path) -> None: unknown = tmp_path / "unknown.sqlite3" connection = sqlite3.connect(unknown) diff --git a/tests/test_content_v3_composition.py b/tests/test_content_v3_composition.py index 96320f446..6dffe7462 100644 --- a/tests/test_content_v3_composition.py +++ b/tests/test_content_v3_composition.py @@ -356,14 +356,12 @@ def timer_factory() -> _Timer: assert candidate is not None and candidate.runtime_snapshot is not None candidate_content = candidate.runtime_snapshot.generations["eventmail"] - candidate_path = candidate_content.data_dir / "eventmail.sqlite3" candidate_root = candidate.runtime_snapshot.composition_root assert candidate_root is not None candidate_runtime = candidate_root.plugin_runtime("eventmail") + candidate_path = candidate_runtime.data_dir / "eventmail.sqlite3" assert candidate_content.static_manifest is not None - assert candidate_content.static_manifest.candidate_data_mode == "shared_read" - assert candidate_runtime.data_access == "read_only" - assert candidate_path == content_path + assert candidate_path != content_path candidate_wake = candidate_root.context.require(EVENTMAIL_WAKE) candidate_source = candidate_root.context.require( EVENTMAIL_CONTENT_SOURCE @@ -377,19 +375,18 @@ def timer_factory() -> _Timer: assert "settlement_ref" not in recovered assert candidate_wake.selected() == (recovered,) assert candidate_wake.snapshot(now)["items"] == () - with pytest.raises(PermissionError, match="read-only candidate"): - candidate_source.submit( - "poll:1", - ( - { - "item_id": "forbidden", - "revision": "1", - "payload": {"kind": "candidate-write"}, - "not_before": now, - }, - ), - ) - assert candidate_hint_probe.count == 0 + candidate_source.submit( + "poll:1", + ( + { + "item_id": "candidate-only", + "revision": "1", + "payload": {"kind": "candidate-write"}, + "not_before": now, + }, + ), + ) + assert candidate_hint_probe.count == 1 assert sum(len(timer.handles) for timer in timers) == 1 assert source_store.state(now) == before assert _sqlite_hashes(content_path) == formal_hashes @@ -518,8 +515,7 @@ def submit_until_stopped() -> None: formal_path = ( workspace / "plugin-data" / "eventmail-builtin" / "eventmail.sqlite3" ) - assert candidate_runtime.data_access == "read_only" - assert candidate_runtime.data_dir / "eventmail.sqlite3" == formal_path + assert candidate_runtime.data_dir / "eventmail.sqlite3" != formal_path candidate_wake = candidate_root.context.require(EVENTMAIL_WAKE) candidate_snapshot = cast(dict[str, Any], candidate_wake.snapshot(now)) candidate_count = len(candidate_snapshot["items"]) @@ -533,8 +529,7 @@ def submit_until_stopped() -> None: candidate_source = candidate_root.context.require( EVENTMAIL_CONTENT_SOURCE ).bind("concurrent-candidate-probe") - with pytest.raises(PermissionError, match="read-only candidate"): - candidate_source.submit("poll:forbidden", ()) + candidate_source.submit("poll:candidate-only", ()) finally: stop.set() await writer diff --git a/tests/test_drift_store.py b/tests/test_drift_store.py index 0048076b7..515b3fee4 100644 --- a/tests/test_drift_store.py +++ b/tests/test_drift_store.py @@ -65,17 +65,6 @@ def test_drift_store_cas_loser_cannot_select_same_revision(tmp_path) -> None: assert second["selected"] is False -def test_drift_read_only_candidate_validates_without_writing(tmp_path) -> None: - path = tmp_path / "drift.sqlite3" - formal = DriftStore(path) - formal.initialize() - candidate = DriftStore(path, data_access="read_only") - candidate.initialize() - assert candidate.snapshot(datetime.now(UTC))["proposals"] == () - with pytest.raises(PermissionError, match="read-only candidate"): - candidate.propose("forbidden", "1", {}, datetime.now(UTC)) - - def test_drift_same_turn_second_proposal_is_explicit_cas_loser(tmp_path) -> None: now = datetime(2026, 8, 23, 8, tzinfo=UTC) store = DriftStore(tmp_path / "drift.sqlite3") diff --git a/tests/test_lifecycle_phases.py b/tests/test_lifecycle_phases.py index c8bba688a..c5b6e1772 100644 --- a/tests/test_lifecycle_phases.py +++ b/tests/test_lifecycle_phases.py @@ -132,35 +132,6 @@ def open_observe_db(path: Path) -> sqlite3.Connection: return conn -class _MemoryStatusPluginModule: - slot = "test.memory_status" - requires = ("before_turn.acquire_session", "session:session") - produces = ("session:ctx",) - - async def run(self, frame: BeforeTurnFrame) -> BeforeTurnFrame: - if "session:ctx" in frame.slots: - return frame - state = frame.input - if state.msg.content != "/memory_status": - return frame - session = state.session - if session is None: - return frame - messages = list(getattr(session, "messages", [])) - last = max(0, int(getattr(session, "last_consolidated", 0))) - last = min(last, len(messages)) - frame.slots["session:ctx"] = BeforeTurnCtx( - session_key=state.session_key, - channel=state.msg.channel, - chat_id=state.msg.chat_id, - content=state.msg.content, - timestamp=state.msg.timestamp, - skill_names=[], - history_messages=(), - abort=True, - abort_reply=_format_memory_status_reply(messages, last), - ) - return frame class _DummyOutbound: @@ -171,32 +142,6 @@ async def dispatch(self, outbound: OutboundDispatch) -> ChannelDeliveryReceipt: ) -class _KVCachePluginModule: - slot = "test.kvcache" - requires = ("before_turn.acquire_session", "session:session") - produces = ("session:ctx",) - - def __init__(self, db_path) -> None: - self._db_path = db_path - - async def run(self, frame: BeforeTurnFrame) -> BeforeTurnFrame: - if "session:ctx" in frame.slots: - return frame - state = frame.input - if state.msg.content != "/kvcache": - return frame - frame.slots["session:ctx"] = BeforeTurnCtx( - session_key=state.session_key, - channel=state.msg.channel, - chat_id=state.msg.chat_id, - content=state.msg.content, - timestamp=state.msg.timestamp, - skill_names=[], - history_messages=(), - abort=True, - abort_reply=_build_kvcache_reply(state, self._db_path), - ) - return frame def _format_memory_status_reply( @@ -433,81 +378,8 @@ async def test_before_turn_uses_cli_session_override_context(): assert ctx.chat_id == "7674283004" -@pytest.mark.asyncio -async def test_before_turn_chain_can_abort(): - bus = EventBus() - session = _DummySession("telegram:123") - session_mgr = SimpleNamespace(get_or_create=lambda key: session) - bundle = ContextBundle() - ctx_store = SimpleNamespace(prepare=AsyncMock(return_value=bundle)) - - async def abort_handler(ctx): - ctx.abort = True - ctx.abort_reply = "rate limited" - return ctx - - bus.on(BeforeTurnCtx, abort_handler) - - phase = Phase( - default_before_turn_modules( - bus, - cast(SessionManager, session_mgr), - cast(ContextStore, ctx_store), - plugin_modules=[_MemoryStatusPluginModule()], - ), - frame_factory=BeforeTurnFrame, - ) - msg = _inbound() - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - ctx = await phase.run(state) - assert ctx.abort is True - assert ctx.abort_reply == "rate limited" - - -@pytest.mark.asyncio -async def test_before_turn_memory_status_command_aborts_without_context_prepare(): - bus = EventBus() - session = _DummySession("telegram:123") - session.messages = [ - { - "role": "user", - "content": '内部', - }, - {"role": "user", "content": "帮我看看 Telegram 流式消息为什么重复发送"}, - {"role": "assistant", "content": "已修复"}, - {"role": "user", "content": "再看一下超时问题"}, - ] - session.last_consolidated = 3 - session_mgr = SimpleNamespace(get_or_create=lambda key: session) - ctx_store = SimpleNamespace(prepare=AsyncMock()) - - phase = Phase( - default_before_turn_modules( - bus, - cast(SessionManager, session_mgr), - cast(ContextStore, ctx_store), - plugin_modules=[_MemoryStatusPluginModule()], - ), - frame_factory=BeforeTurnFrame, - ) - msg = InboundMessage( - channel="telegram", - sender="user", - chat_id="123", - content="/memory_status", - timestamp=_now, - ) - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - ctx = await phase.run(state) - assert ctx.abort is True - assert "上次整理到 1 条用户消息之前。" in ctx.abort_reply - assert "帮我看看 Telegram 流式消息为什么重复发送" in ctx.abort_reply - assert "尚未整理的用户消息数:1" in ctx.abort_reply - assert "当前会话消息数:4" in ctx.abort_reply - assert "内部" not in ctx.abort_reply - ctx_store.prepare.assert_not_called() @pytest.mark.asyncio @@ -574,115 +446,9 @@ async def test_before_turn_preserves_generic_turn_effect_metadata(): assert msg.metadata["effects"] == {"post_commit": "suppress"} -@pytest.mark.asyncio -async def test_before_turn_accepts_custom_command_module(): - bus = EventBus() - session = _DummySession("telegram:123") - session_mgr = SimpleNamespace(get_or_create=lambda key: session) - ctx_store = SimpleNamespace(prepare=AsyncMock()) - - class CustomCommandModule: - slot = "test.custom_command" - requires = ("before_turn.acquire_session", "session:session") - produces = ("session:ctx",) - - async def run(self, frame: BeforeTurnFrame) -> BeforeTurnFrame: - state = frame.input - if state.msg.content != "/debug": - return frame - frame.slots["session:ctx"] = BeforeTurnCtx( - session_key=state.session_key, - channel=state.msg.channel, - chat_id=state.msg.chat_id, - content=state.msg.content, - timestamp=state.msg.timestamp, - skill_names=[], - history_messages=(), - abort=True, - abort_reply="debug ok", - ) - return frame - - phase = Phase( - default_before_turn_modules( - bus, - cast(SessionManager, session_mgr), - cast(ContextStore, ctx_store), - plugin_modules=[_MemoryStatusPluginModule(), CustomCommandModule()], - ), - frame_factory=BeforeTurnFrame, - ) - msg = InboundMessage( - channel="telegram", - sender="user", - chat_id="123", - content="/debug", - timestamp=_now, - ) - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - - ctx = await phase.run(state) - assert ctx.abort is True - assert ctx.abort_reply == "debug ok" - ctx_store.prepare.assert_not_called() -@pytest.mark.asyncio -async def test_before_turn_accepts_plugin_modules(): - bus = EventBus() - session = _DummySession("telegram:123") - session_mgr = SimpleNamespace(get_or_create=lambda key: session) - bundle = ContextBundle( - skill_mentions=["memo"], - history_messages=[{"role": "user", "content": "prev"}], - ) - ctx_store = SimpleNamespace(prepare=AsyncMock(return_value=bundle)) - seen: list[str] = [] - - class EarlyPluginModule: - slot = "test.before_turn.early" - requires = ("before_turn.acquire_session", "session:session") - - async def run(self, frame: BeforeTurnFrame) -> BeforeTurnFrame: - seen.append("early") - frame.input.msg.metadata["early_seen"] = True - return frame - - class LatePluginModule: - slot = "test.before_turn.late" - requires = ("before_turn.emit", "session:ctx") - produces = ("session:ctx",) - - async def run(self, frame: BeforeTurnFrame) -> BeforeTurnFrame: - seen.append("late") - ctx = cast(BeforeTurnCtx, frame.slots["session:ctx"]) - ctx.extra_metadata["late_seen"] = ",".join(ctx.skill_names) - frame.slots["session:ctx"] = ctx - frame.slots["session:extra_hint:late"] = "hint from before turn" - return frame - - phase = Phase( - default_before_turn_modules( - bus, - cast(SessionManager, session_mgr), - cast(ContextStore, ctx_store), - plugin_modules=[EarlyPluginModule(), LatePluginModule()], - ), - frame_factory=BeforeTurnFrame, - ) - msg = _inbound() - msg.metadata["seed"] = "x" - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - - ctx = await phase.run(state) - - assert seen == ["early", "late"] - assert state.msg.metadata["early_seen"] is True - assert ctx.extra_metadata["late_seen"] == "memo" - assert ctx.extra_hints == ["hint from before turn"] - ctx_store.prepare.assert_called_once() - @pytest.mark.asyncio async def test_before_turn_projects_durable_execution_turn_id(): @@ -725,78 +491,6 @@ async def test_before_turn_projects_durable_execution_turn_id(): assert ctx.turn_id == "turn:durable" -@pytest.mark.asyncio -async def test_before_turn_kvcache_command(tmp_path): - bus = EventBus() - session = _DummySession("telegram:123") - session_mgr = SimpleNamespace(get_or_create=lambda key: session) - ctx_store = SimpleNamespace(prepare=AsyncMock()) - - db_path = tmp_path / "observe" / "observe.db" - conn = open_observe_db(db_path) - conn.execute( - """INSERT INTO turns (source, session_key, user_msg, llm_output, ts, - react_cache_prompt_tokens, react_cache_hit_tokens) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - [ - "agent", - "telegram:123", - "之前的问题", - "这是之前的回答", - "2026-04-29T16:14:00.123456+00:00", - 52564, - 50560, - ], - ) - conn.execute( - """INSERT INTO turns (source, session_key, user_msg, llm_output, ts, - react_cache_prompt_tokens, react_cache_hit_tokens) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - [ - "agent", - "telegram:123", - "新的问题", - "这是新的回答\n有多行", - "2026-04-29T16:15:00+00:00", - 50000, - 40000, - ], - ) - conn.commit() - conn.close() - - phase = Phase( - default_before_turn_modules( - bus, - cast(SessionManager, session_mgr), - cast(ContextStore, ctx_store), - plugin_modules=[_KVCachePluginModule(db_path)], - ), - frame_factory=BeforeTurnFrame, - ) - msg = InboundMessage( - channel="telegram", - sender="user", - chat_id="123", - content="/kvcache", - timestamp=_now, - ) - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - - ctx = await phase.run(state) - - assert ctx.abort is True - assert "最近 2 轮 KVCache 状态" in ctx.abort_reply - assert "总命中率" in ctx.abort_reply - assert "这是之前的回答" in ctx.abort_reply - assert "这是新的回答" in ctx.abort_reply - assert "4-29 16:14" in ctx.abort_reply - assert "4-29 16:15" in ctx.abort_reply - assert "50,560 / 52,564" in ctx.abort_reply - assert "96.19%" in ctx.abort_reply - assert "80.00%" in ctx.abort_reply - assert ctx.abort_reply.count("\n\n") <= 2 - ctx_store.prepare.assert_not_called() @pytest.mark.asyncio @@ -1016,56 +710,6 @@ async def hint_handler(ctx): assert ctx.extra_hints == ["hint from before turn", "hint from plugin"] -@pytest.mark.asyncio -async def test_before_reasoning_collects_export_slots(): - bus = EventBus() - tools = Mock() - tools.set_context = Mock() - session = _DummySession("telegram:123") - session_mgr = SimpleNamespace( - get_or_create=lambda key: session, - peek_next_message_id=lambda key: "telegram:123:0", - ) - context_builder = Mock() - context_builder.render = Mock(return_value=None) - - class SlotModule: - slot = "test.before_reasoning.slot" - requires = ("before_reasoning.emit", "reasoning:ctx") - - async def run(self, frame: BeforeReasoningFrame) -> BeforeReasoningFrame: - frame.slots["reasoning:extra_hint:test"] = "slot hint" - frame.slots["reasoning:abort_reply"] = "slot abort" - return frame - - phase = Phase( - default_before_reasoning_modules( - bus, - cast(ToolRegistry, tools), - cast(SessionManager, session_mgr), - cast(ContextBuilder, context_builder), - plugin_modules=[SlotModule()], - ), - frame_factory=BeforeReasoningFrame, - ) - msg = _inbound() - before_turn = BeforeTurnCtx( - session_key="telegram:123", - channel=msg.channel, - chat_id=msg.chat_id, - content=msg.content, - timestamp=msg.timestamp, - history_messages=(), - ) - state = TurnState(msg=msg, session_key="telegram:123", dispatch_outbound=True) - state.session = session - - ctx = await phase.run(BeforeReasoningInput(state=state, before_turn=before_turn)) - - assert ctx.extra_hints == ["slot hint"] - assert ctx.abort is True - assert ctx.abort_reply == "slot abort" - context_builder.render.assert_not_called() @pytest.mark.asyncio @@ -1186,113 +830,8 @@ async def append_section(ctx: PromptRenderCtx) -> PromptRenderCtx: assert "Plugin Protocol" in str(result.messages[0]["content"]) -@pytest.mark.asyncio -async def test_prompt_render_chain_respects_disabled_sections(tmp_path): - _ = reset_veda(tmp_path) - - class BottomModule: - slot = "test.prompt.bottom" - requires = ("prompt_render.emit", "prompt:ctx") - produces = ("prompt:ctx",) - - async def run(self, frame: PromptRenderFrame) -> PromptRenderFrame: - ctx = cast(PromptRenderCtx, frame.slots["prompt:ctx"]) - ctx.system_sections_bottom.append( - PromptSectionRender( - name="memes", - content="# Memes\n\n", - is_static=False, - ) - ) - return frame - memory = SimpleNamespace( - read_self=lambda: "", - read_profile=lambda: "", - get_memory_context=lambda: "", - ) - context = ContextBuilder(tmp_path) - phase = Phase( - default_prompt_render_modules( - EventBus(), - context, - plugin_modules=[BottomModule()], - ), - frame_factory=PromptRenderFrame, - ) - - result = await phase.run( - PromptRenderInput( - session_key="k", - channel="cli", - chat_id="ch", - content="hello", - multimodal=True, - media=None, - timestamp=_now, - history=[], - skill_names=None, - disabled_sections={"memes"}, - turn_injection_prompt="", - ) - ) - assert "" not in str(result.messages[0]["content"]) - - -@pytest.mark.asyncio -async def test_prompt_render_collects_export_slots(tmp_path): - _ = reset_veda(tmp_path) - - class SlotModule: - slot = "test.prompt.slot" - requires = ("prompt_render.emit", "prompt:ctx") - - async def run(self, frame: PromptRenderFrame) -> PromptRenderFrame: - frame.slots["prompt:section_top:top_slot"] = "top content" - frame.slots["prompt:section_bottom:bottom_slot"] = PromptSectionRender( - name="bottom_slot", - content="bottom content", - is_static=False, - ) - frame.slots["prompt:extra_hint:test"] = "hint content" - return frame - - memory = SimpleNamespace( - read_self=lambda: "", - read_profile=lambda: "", - get_memory_context=lambda: "", - ) - context = ContextBuilder(tmp_path) - phase = Phase( - default_prompt_render_modules( - EventBus(), - context, - plugin_modules=[SlotModule()], - ), - frame_factory=PromptRenderFrame, - ) - - result = await phase.run( - PromptRenderInput( - session_key="k", - channel="cli", - chat_id="ch", - content="hello", - multimodal=True, - media=None, - timestamp=_now, - history=[], - skill_names=None, - disabled_sections=set(), - turn_injection_prompt="", - ) - ) - rendered = str(result.messages) - - assert "top content" in rendered - assert "bottom content" in rendered - assert "hint content" in rendered @pytest.mark.asyncio @@ -1322,40 +861,6 @@ async def append_hint(ctx: BeforeStepCtx) -> BeforeStepCtx: assert messages == [{"role": "user", "content": "hello"}, expected] -@pytest.mark.asyncio -async def test_before_step_collects_export_slots(): - class SlotModule: - slot = "test.before_step.slot" - requires = ("before_step.emit", "step:ctx") - - async def run(self, frame: BeforeStepFrame) -> BeforeStepFrame: - frame.slots["step:extra_hint:test"] = "slot step hint" - frame.slots["step:abort_reply"] = "slot stop" - return frame - - phase = Phase( - default_before_step_modules( - EventBus(), - plugin_modules=[SlotModule()], - ), - frame_factory=BeforeStepFrame, - ) - messages = [{"role": "user", "content": "hello"}] - - ctx = await phase.run( - BeforeStepInput( - session_key="k", - channel="c", - chat_id="ch", - iteration=1, - messages=messages, - visible_names=None, - ) - ) - - assert ctx.extra_hints == ["slot step hint"] - assert ctx.early_stop is True - assert ctx.early_stop_reply == "slot stop" @pytest.mark.asyncio @@ -1415,147 +920,8 @@ async def handler(ctx: AfterStepCtx) -> None: assert side_effect == ["ok"] -@pytest.mark.asyncio -async def test_after_step_collects_telemetry_slots_before_fanout(): - bus = EventBus() - seen: list[dict[str, Any]] = [] - - class SlotModule: - slot = "test.after_step.pre" - requires = ("after_step.copy_input", "step:ctx") - - async def run(self, frame: AfterStepFrame) -> AfterStepFrame: - frame.slots["step:telemetry:test"] = {"ok": True} - return frame - - class AfterFanoutSlotModule: - slot = "test.after_step.post" - requires = ("after_step.fanout", "step:ctx") - - async def run(self, frame: AfterStepFrame) -> AfterStepFrame: - frame.slots["step:telemetry:after"] = "done" - frame.slots["step:telemetry:test"] = "overwritten" - return frame - - async def handler(ctx: AfterStepCtx) -> None: - seen.append(dict(ctx.extra_metadata)) - - bus.on(AfterStepCtx, handler) - phase = Phase( - default_after_step_modules( - bus, - plugin_modules=[SlotModule(), AfterFanoutSlotModule()], - ), - frame_factory=AfterStepFrame, - ) - ctx = await phase.run( - AfterStepCtx( - session_key="k", - channel="c", - chat_id="ch", - iteration=0, - context_tokens_estimate=0, - tools_called=(), - partial_reply="ok", - tools_used_so_far=(), - tool_chain_partial=(), - partial_thinking=None, - has_more=True, - ) - ) - - assert seen == [{"test": {"ok": True}}] - assert ctx.extra_metadata == {"test": {"ok": True}, "after": "done"} - - -@pytest.mark.asyncio -async def test_after_reasoning_collects_v3_metadata_and_outbound_slots(): - class SlotModule: - slot = "test.after_reasoning.slot" - requires = ("after_reasoning.emit", "reasoning:ctx") - - async def run(self, frame: AfterReasoningFrame) -> AfterReasoningFrame: - ctx = cast(AfterReasoningCtx, frame.slots["reasoning:ctx"]) - ctx.persist_user_metadata["akasha_reinforce"] = { - "target_message_ids": ["message-1"] - } - ctx.persist_assistant_metadata["citation_ids"] = ["mem_1"] - frame.slots["outbound:metadata:plugin_flag"] = "m" - frame.slots["outbound:media:image"] = ["/tmp/a.png"] - return frame - - session = _DummySession("telegram:123") - msg = _inbound() - msg.metadata["client_message_id"] = "01ARZ3NDEKTSV4RRFFQ69G5FAV" - state = TurnState(msg=msg, session_key=session.key, dispatch_outbound=True) - state.session = session - state.extra_metadata["before_turn_flag"] = "bt" - - async def append_messages( - current: _DummySession, - messages: list[dict[str, object]], - *, - metadata: dict[str, Any] | None = None, - ) -> None: - for index, persisted in enumerate(messages): - persisted["id"] = f"{current.key}:{index}" - - class Importer: - async def import_media( - self, - media: tuple[str, ...], - ) -> tuple[AttachmentRef, ...]: - return tuple( - AttachmentRef( - artifact_id=f"artifact-{index}", - kind=AttachmentKind.IMAGE, - filename=Path(source).name, - media_type="image/png", - size_bytes=index + 1, - sha256=f"{index + 1:064x}", - ) - for index, source in enumerate(media) - ) - - services = SimpleNamespace( - presence=Mock(), - session_manager=SimpleNamespace(append_messages=append_messages), - outbound_attachment_importer=Importer(), - ) - turn_result = TurnRunResult( - reply="reply", - tool_chain=[], - tools_used=[], - media=["/tmp/from-turn.png"], - thinking=None, - streamed=False, - context_retry={}, - ) - phase = Phase( - default_after_reasoning_modules( - EventBus(), - cast(Any, services), - plugin_modules=[SlotModule()], - ), - frame_factory=AfterReasoningFrame, - ) - result = await phase.run(AfterReasoningInput(state=state, turn_result=turn_result)) - assert session.messages[0]["akasha_reinforce"] == { - "target_message_ids": ["message-1"] - } - assert session.messages[0]["client_message_id"] == "01ARZ3NDEKTSV4RRFFQ69G5FAV" - assert session.messages[1]["citation_ids"] == ["mem_1"] - assert session.messages[1]["attachment_ids"] == ["artifact-0", "artifact-1"] - assert result.outbound.metadata["before_turn_flag"] == "bt" - assert result.outbound.metadata["plugin_flag"] == "m" - assert [ref.artifact_id for ref in result.outbound.attachment_refs] == [ - "artifact-0", - "artifact-1", - ] - assert result.outbound.media == [] - assert result.outbound.session_message_id == "telegram:123:1" @pytest.mark.asyncio @@ -1763,99 +1129,8 @@ def fail_before_commit(*args: Any, **kwargs: Any) -> int: reloaded.close() -def test_late_legacy_observer_keeps_existing_phase_dag_contract() -> None: - class LateObserverModule: - slot = "test.after_reasoning.late_observer" - requires = ("after_reasoning.persist_user", "reasoning:persisted_user") - - async def run(self, frame: AfterReasoningFrame) -> AfterReasoningFrame: - return frame - modules = default_after_reasoning_modules( - EventBus(), - cast(Any, SimpleNamespace(presence=None, session_manager=object())), - plugin_modules=[LateObserverModule()], - ) - slots = [module.slot for module in modules] - assert slots.index("after_reasoning.persist_user") < slots.index( - "test.after_reasoning.late_observer" - ) - assert slots.index("test.after_reasoning.late_observer") < slots.index( - "after_reasoning.seal_metadata" - ) - - -@pytest.mark.asyncio -async def test_after_reasoning_persists_mobile_canonical_ids(tmp_path: Path): - class SpoofMetadataModule: - slot = "test.after_reasoning.spoof_client_message_id" - requires = ("after_reasoning.emit", "reasoning:ctx") - - async def run(self, frame: AfterReasoningFrame) -> AfterReasoningFrame: - frame.slots["outbound:metadata:client_message_id"] = ( - "01ARZ3NDEKTSV4RRFFQ69G5FAW" - ) - return frame - - manager = SessionManager(tmp_path / "workspace") - artifact_store = ChannelAttachmentArtifactStore( - workspace=tmp_path / "workspace", - session_store=manager.control_store, - ) - attachment = await artifact_store.import_bytes( - b"mobile-user-attachment", - kind=AttachmentKind.FILE, - filename="note.txt", - media_type="text/plain", - ) - session = manager.get_or_create("mobile:00000000-0000-0000-0000-000000000001") - msg = InboundMessage( - channel="mobile", - sender="device:test", - chat_id="00000000-0000-0000-0000-000000000001", - content="hello", - media=["/proc/self/fd/999"], - metadata={ - "client_message_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", - "attachment_ids": [attachment.artifact_id], - }, - ) - state = TurnState(msg=msg, session_key=session.key, dispatch_outbound=True) - state.session = session - phase = Phase( - default_after_reasoning_modules( - EventBus(), - cast(Any, SimpleNamespace(presence=None, session_manager=manager)), - plugin_modules=[SpoofMetadataModule()], - ), - frame_factory=AfterReasoningFrame, - ) - - result = await phase.run( - AfterReasoningInput( - state=state, - turn_result=TurnRunResult(reply="reply"), - ) - ) - manager.close() - reloaded = SessionManager(tmp_path / "workspace") - messages = reloaded.get_or_create(session.key).messages - - assert messages[0]["client_message_id"] == "01ARZ3NDEKTSV4RRFFQ69G5FAV" - assert result.outbound.metadata["persisted_user_message_id"] == messages[0]["id"] - assert ( - result.outbound.metadata["client_message_id"] - == messages[0]["client_message_id"] - ) - assert result.outbound.session_message_id == messages[1]["id"] - assert messages[0]["attachment_ids"] == [attachment.artifact_id] - assert messages[0].get("media") in (None, []) - assert "/proc/" not in json.dumps(messages[0], ensure_ascii=False) - assert reloaded.control_store.message_attachment_ids( - cast(str, messages[0]["id"]) - ) == (attachment.artifact_id,) - reloaded.close() @pytest.mark.asyncio @@ -2017,95 +1292,6 @@ async def test_after_reasoning_persists_clean_mobile_reply_projection(tmp_path: reloaded.close() -@pytest.mark.asyncio -async def test_after_turn_collects_extra_and_telemetry_slots(): - committed_extra: list[dict[str, object]] = [] - committed_events: list[TurnCommitted] = [] - after_turn_metadata: list[dict[str, object]] = [] - bus = EventBus() - - class ExtraModule: - slot = "test.after_turn.extra" - requires = ("after_turn.build_work", "turn:extra") - - async def run(self, frame: AfterTurnFrame) -> AfterTurnFrame: - frame.slots["turn:extra:plugin_flag"] = "extra" - return frame - - class TelemetryModule: - slot = "test.after_turn.telemetry" - requires = ("after_turn.build_ctx", "turn:ctx") - - async def run(self, frame: AfterTurnFrame) -> AfterTurnFrame: - frame.slots["turn:telemetry:plugin_flag"] = "telemetry" - return frame - - async def committed_handler(event: TurnCommitted) -> None: - committed_events.append(event) - committed_extra.append(dict(event.extra)) - - async def after_turn_handler(ctx: AfterTurnCtx) -> None: - after_turn_metadata.append(dict(ctx.extra_metadata)) - - bus.on(AfterTurnCtx, after_turn_handler) - bus.on(TurnCommitted, committed_handler) - session = _DummySession("telegram:123") - msg = _inbound() - state = TurnState(msg=msg, session_key=session.key, dispatch_outbound=False) - state.session = session - ctx = AfterReasoningCtx( - session_key=session.key, - channel=msg.channel, - chat_id=msg.chat_id, - tools_used=(), - thinking=None, - response_metadata=ResponseMetadata(raw_text="reply"), - streamed=False, - tool_chain=(), - context_retry={}, - reply="reply", - ) - context = Mock() - context.render = Mock(return_value=SimpleNamespace(messages=[])) - context.last_debug_breakdown = [] - phase = Phase( - default_after_turn_modules( - bus, - _DummyOutbound(), - cast(ContextBuilder, context), - plugin_modules=[ExtraModule(), TelemetryModule()], - ), - frame_factory=AfterTurnFrame, - ) - - await phase.run( - TurnSnapshot( - state=state, - outbound=OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="reply", - metadata={ - "persisted_user_message_id": "telegram:123:0", - "persisted_user_message_ids": [ - "telegram:123:0", - "telegram:123:1", - ], - }, - session_message_id="telegram:123:2", - ), - ctx=ctx, - ) - ) - - assert committed_extra[0]["plugin_flag"] == "extra" - assert committed_events[0].persisted_user_message_id == "telegram:123:0" - assert committed_events[0].persisted_user_message_ids == ( - "telegram:123:0", - "telegram:123:1", - ) - assert committed_events[0].assistant_message_id == "telegram:123:2" - assert after_turn_metadata == [{"plugin_flag": "telemetry"}] @contextmanager diff --git a/tests/test_plugin_composition_events.py b/tests/test_plugin_composition_events.py index 34ef9d663..708c3af50 100644 --- a/tests/test_plugin_composition_events.py +++ b/tests/test_plugin_composition_events.py @@ -749,15 +749,22 @@ class Provider: async def apply(self, ctx) -> None: await ctx.provide(DEPENDENCY, "ready") - consumer = await root.mount(Consumer()) - provider = await root.mount(Provider()) + consumer_plugin = Consumer() + consumer = await root.mount( + consumer_plugin.apply, + name=consumer_plugin.name, + inject=consumer_plugin.inject, + ) + provider_plugin = Provider() + provider = await root.mount(provider_plugin.apply, name=provider_plugin.name) root.context.emit(NOTICE, "first") await provider.dispose() assert consumer.state == FiberState.PENDING root.context.emit(NOTICE, "missing") - await root.mount(Provider(), name="replacement") + replacement = Provider() + await root.mount(replacement.apply, name="replacement") root.context.emit(NOTICE, "second") assert observed == ["first", "second"] diff --git a/tests/test_plugin_composition_executor.py b/tests/test_plugin_composition_executor.py index 7ff8991e5..276e388e9 100644 --- a/tests/test_plugin_composition_executor.py +++ b/tests/test_plugin_composition_executor.py @@ -10,16 +10,22 @@ CompositionError, CompositionRoot, ExecutorService, + Fiber, HealthHandle, SyncTask, ) +async def _mount_executor(root: CompositionRoot, max_workers: int) -> Fiber: + service = ExecutorService(max_workers=max_workers) + return await root.mount(service.apply, name=service.name) + + @pytest.mark.asyncio async def test_parallel_sync_runs_concurrently_and_preserves_result_order() -> None: barrier = threading.Barrier(2) root = CompositionRoot("executor-order") - await root.mount(ExecutorService(max_workers=2)) + await _mount_executor(root, 2) executor = root.context.require(EXECUTOR_SERVICE) def run(value: str) -> str: @@ -40,7 +46,7 @@ def run(value: str) -> str: async def test_parallel_sync_waits_all_and_aggregates_errors() -> None: completed: list[str] = [] root = CompositionRoot("executor-errors") - await root.mount(ExecutorService(max_workers=2)) + await _mount_executor(root, 2) executor = root.context.require(EXECUTOR_SERVICE) def fail(name: str, error: Exception) -> None: @@ -65,7 +71,7 @@ def fail(name: str, error: Exception) -> None: @pytest.mark.asyncio async def test_parallel_sync_worker_cannot_access_context() -> None: root = CompositionRoot("executor-context-boundary") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) executor = root.context.require(EXECUTOR_SERVICE) with pytest.raises(BaseExceptionGroup) as caught: @@ -81,7 +87,7 @@ async def test_parallel_sync_worker_cannot_access_context() -> None: @pytest.mark.asyncio async def test_parallel_sync_worker_cannot_mutate_saved_health_handle() -> None: root = CompositionRoot("executor-health-boundary") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) handles: list[HealthHandle] = [] async def plugin(ctx) -> None: @@ -104,7 +110,7 @@ async def plugin(ctx) -> None: @pytest.mark.asyncio async def test_parallel_sync_worker_cannot_read_saved_fiber_handle() -> None: root = CompositionRoot("executor-fiber-boundary") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) handle = await root.context.mount(lambda _: None, name="plugin") executor = root.context.require(EXECUTOR_SERVICE) @@ -123,7 +129,7 @@ async def test_parallel_sync_cancellation_joins_running_thread() -> None: started = threading.Event() release = threading.Event() root = CompositionRoot("executor-cancel") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) executor = root.context.require(EXECUTOR_SERVICE) def run() -> str: @@ -153,7 +159,7 @@ async def test_parallel_sync_cancellation_drops_queued_task() -> None: release = threading.Event() queued_ran = threading.Event() root = CompositionRoot("executor-cancel-queued") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) executor = root.context.require(EXECUTOR_SERVICE) def running() -> str: @@ -186,7 +192,7 @@ def queued() -> str: @pytest.mark.asyncio async def test_executor_provider_dispose_closes_pool_and_removes_service() -> None: root = CompositionRoot("executor-dispose") - provider = await root.mount(ExecutorService(max_workers=1)) + provider = await _mount_executor(root, 1) executor = root.context.require(EXECUTOR_SERVICE) await provider.dispose() @@ -200,7 +206,7 @@ async def test_executor_provider_dispose_closes_pool_and_removes_service() -> No @pytest.mark.asyncio async def test_parallel_sync_empty_batch_is_valid() -> None: root = CompositionRoot("executor-empty") - await root.mount(ExecutorService(max_workers=1)) + await _mount_executor(root, 1) executor = root.context.require(EXECUTOR_SERVICE) assert await executor.parallel_sync(()) == () diff --git a/tests/test_plugin_composition_kernel.py b/tests/test_plugin_composition_kernel.py index 3eff14a47..d56d8825a 100644 --- a/tests/test_plugin_composition_kernel.py +++ b/tests/test_plugin_composition_kernel.py @@ -15,6 +15,7 @@ CompositionOverlay, CompositionRoot, EmitEventKey, + Fiber, FiberState, HealthHandle, ParallelEventKey, @@ -51,6 +52,16 @@ async def apply(self, ctx) -> None: await ctx.provide(GREETING, self.value) +async def _mount_greeting( + root: CompositionRoot, + value: str = "hello", + *, + name: str = "greeting-provider", +) -> Fiber: + plugin = GreetingProvider(value) + return await root.mount(plugin.apply, name=name) + + @pytest.mark.asyncio async def test_overlay_topology_matches_formal_event_key_order(tmp_path: Path) -> None: first = EmitEventKey[str]("fixture.first") @@ -254,34 +265,6 @@ async def parent(ctx) -> None: assert observed == [data_root, data_root] -@pytest.mark.asyncio -async def test_data_access_is_core_assigned_and_shared_by_nested_fibers( - tmp_path: Path, -) -> None: - runtime = PluginRuntime( - plugin_id="probe@builtin", - generation_id="test-generation", - plugin_dir=tmp_path / "plugin", - data_dir=tmp_path / "plugin-data" / "probe-builtin", - workspace=tmp_path, - config=None, - data_access="read_only", - ) - observed: list[str] = [] - - async def child(ctx) -> None: - observed.append(ctx.data_access) - - async def parent(ctx) -> None: - observed.append(ctx.data_access) - _ = await ctx.mount(child, name="child") - - root = CompositionRoot("data-access") - _ = await root.mount(parent, name="parent", runtime=runtime) - - assert observed == ["read_only", "read_only"] - - @pytest.mark.asyncio async def test_workspace_root_is_declared_and_shared_by_nested_fibers(tmp_path) -> None: memes = tmp_path / "memes" @@ -322,6 +305,24 @@ def test_data_root_requires_core_assigned_plugin_runtime() -> None: assert caught.value.code == "PLUGIN_RUNTIME_UNAVAILABLE" +@pytest.mark.asyncio +async def test_public_mount_rejects_object_apply_abi() -> None: + class LegacyPlugin: + async def apply(self, _ctx) -> None: + return None + + root = CompositionRoot("callable-only") + legacy = LegacyPlugin() + with pytest.raises(TypeError, match="callable"): + await root.mount(legacy) # type: ignore[arg-type] + + async def parent(ctx) -> None: + with pytest.raises(TypeError, match="child callable"): + await ctx.mount(legacy) # type: ignore[arg-type] + + _ = await root.mount(parent, name="parent") + + @pytest.mark.asyncio async def test_required_dependency_follows_provider_lifecycle() -> None: events: list[str] = [] @@ -338,11 +339,17 @@ async def apply(self, ctx) -> None: ) root = CompositionRoot("required-lifecycle") - consumer = await root.mount(Consumer()) + consumer_plugin = Consumer() + consumer = await root.mount( + consumer_plugin.apply, + name=consumer_plugin.name, + inject=consumer_plugin.inject, + ) assert consumer.state == FiberState.PENDING assert root.receipt().required_pending == ("consumer",) - first_provider = await root.mount(GreetingProvider("first")) + first_plugin = GreetingProvider("first") + first_provider = await root.mount(first_plugin.apply, name=first_plugin.name) assert consumer.state == FiberState.ACTIVE assert events == ["load:first"] assert root.receipt().ready is True @@ -351,7 +358,8 @@ async def apply(self, ctx) -> None: assert consumer.state == FiberState.PENDING assert events == ["load:first", "unload"] - await root.mount(GreetingProvider("second")) + second_plugin = GreetingProvider("second") + await root.mount(second_plugin.apply, name=second_plugin.name) assert consumer.state == FiberState.ACTIVE assert events == ["load:first", "unload", "load:second"] @@ -372,7 +380,8 @@ async def use_formatter(inner) -> None: await ctx.inject((FORMATTER,), use_formatter, name="optional-formatter") root = CompositionRoot("optional-inject") - parent = await root.mount(Parent()) + parent_plugin = Parent() + parent = await root.mount(parent_plugin.apply, name=parent_plugin.name) receipt = root.receipt() assert parent.state == FiberState.ACTIVE assert receipt.ready is True @@ -385,7 +394,8 @@ class FormatterProvider: async def apply(self, ctx) -> None: await ctx.provide(FORMATTER, lambda value: value.upper()) - await root.mount(FormatterProvider()) + formatter_plugin = FormatterProvider() + await root.mount(formatter_plugin.apply, name=formatter_plugin.name) assert events == ["READY"] assert root.receipt().optional_pending == () @@ -617,7 +627,7 @@ async def test_stale_dependency_epoch_never_becomes_active() -> None: apply_gate = asyncio.Event() events: list[str] = [] root = CompositionRoot("stale-epoch") - provider = await root.mount(GreetingProvider()) + provider = await _mount_greeting(root) async def consume(ctx) -> None: events.append(f"start:{ctx.require(GREETING)}") @@ -722,11 +732,8 @@ def broken(fiber) -> None: @pytest.mark.asyncio async def test_duplicate_provider_fails_without_replacing_first_owner() -> None: root = CompositionRoot("duplicate-service") - first = await root.mount(GreetingProvider("first")) - duplicate = await root.mount( - GreetingProvider("second"), - name="second-provider", - ) + first = await _mount_greeting(root, "first") + duplicate = await _mount_greeting(root, "second", name="second-provider") assert first.state == FiberState.ACTIVE assert duplicate.state == FiberState.FAILED assert root.context.require(GREETING) == "first" @@ -736,7 +743,7 @@ async def test_duplicate_provider_fails_without_replacing_first_owner() -> None: @pytest.mark.asyncio async def test_root_disposal_drains_children_effects_and_services() -> None: root = CompositionRoot("root-dispose") - await root.mount(GreetingProvider()) + await _mount_greeting(root) await root.dispose() receipt = root.receipt() assert receipt.fibers == () @@ -770,7 +777,7 @@ async def cleanup() -> None: async def test_provider_cleanup_survives_all_dependent_cleanup_failures() -> None: cleaned: list[str] = [] root = CompositionRoot("dependent-cleanup-failure") - provider = await root.mount(GreetingProvider()) + provider = await _mount_greeting(root) async def consume(ctx) -> None: name = ctx.fiber.name @@ -789,7 +796,7 @@ def fail_cleanup() -> None: assert sorted(cleaned) == ["consumer-a", "consumer-b"] assert provider.state == FiberState.DISPOSED assert root.context.get(GREETING) is None - replacement = await root.mount(GreetingProvider(), name="replacement-provider") + replacement = await _mount_greeting(root, name="replacement-provider") assert replacement.state == FiberState.ACTIVE assert root.context.require(GREETING) == "hello" @@ -1054,7 +1061,7 @@ async def dispose_snapshot(snapshot) -> None: compiler = RuntimeSnapshotCompiler() stable = compiler.compile({}, snapshot_revision="stable") root = CompositionRoot("candidate-ready") - await root.mount(GreetingProvider()) + await _mount_greeting(root) candidate = compiler.compile( {}, snapshot_revision="candidate", @@ -1084,7 +1091,7 @@ async def dispose_snapshot(snapshot) -> None: @pytest.mark.asyncio async def test_snapshot_store_rejects_topology_drift_after_compile() -> None: root = CompositionRoot("candidate-drift") - provider = await root.mount(GreetingProvider()) + provider = await _mount_greeting(root) await root.mount( lambda _: None, name="consumer", @@ -1160,7 +1167,7 @@ async def apply(ctx) -> None: @pytest.mark.asyncio async def test_provider_restart_alone_invalidates_sealed_revision() -> None: root = CompositionRoot("service-revision") - provider = await root.mount(GreetingProvider()) + provider = await _mount_greeting(root) compiler = RuntimeSnapshotCompiler() candidate = compiler.compile({}, composition_root=root) compiled = candidate.composition_topology @@ -1316,7 +1323,8 @@ async def apply_group(group_ctx) -> None: async def test_topology_view_identity_includes_declared_dependencies() -> None: async def build(dependency: ServiceKey[object]) -> str: root = CompositionRoot("dependency-view") - await root.mount(GreetingProvider()) + greeting_plugin = GreetingProvider() + await root.mount(greeting_plugin.apply, name=greeting_plugin.name) class FormatterProvider: name = "formatter-provider" @@ -1325,7 +1333,8 @@ class FormatterProvider: async def apply(self, ctx) -> None: await ctx.provide(FORMATTER, lambda value: value) - await root.mount(FormatterProvider()) + formatter_plugin = FormatterProvider() + await root.mount(formatter_plugin.apply, name=formatter_plugin.name) await root.mount( lambda _: None, name="consumer", @@ -1342,7 +1351,7 @@ async def apply(self, ctx) -> None: @pytest.mark.asyncio async def test_promotion_rechecks_candidate_topology_after_behavior_probe() -> None: root = CompositionRoot("candidate-promotion-recheck") - provider = await root.mount(GreetingProvider()) + provider = await _mount_greeting(root) await root.mount( lambda _: None, name="consumer", @@ -1360,7 +1369,7 @@ async def test_promotion_rechecks_candidate_topology_after_behavior_probe() -> N with pytest.raises(RuntimeError, match="组合拓扑未就绪"): await store.promote_latest() - _ = await root.mount(GreetingProvider()) + _ = await _mount_greeting(root) assert root.topology_identity() == candidate.composition_topology.identity # type: ignore[union-attr] with pytest.raises(RuntimeError, match="发生过结构变化"): store.seal_candidate_validation(candidate) @@ -1488,7 +1497,7 @@ async def test_promotion_requires_sealed_receipt_and_rejects_later_write( root = CompositionRoot("sealed-validation", audit=audit) data = PluginDataAccess(tmp_path, audit).for_plugin("probe") data.write_text("state.json", "first") - _ = await root.mount(GreetingProvider()) + _ = await _mount_greeting(root) compiler = RuntimeSnapshotCompiler() candidate = compiler.compile({}, composition_root=root) store = RuntimeSnapshotStore() diff --git a/tests/test_plugin_composition_lifecycle.py b/tests/test_plugin_composition_lifecycle.py index 5b302bdbc..d7aeb3417 100644 --- a/tests/test_plugin_composition_lifecycle.py +++ b/tests/test_plugin_composition_lifecycle.py @@ -35,7 +35,6 @@ default_prompt_render_modules, ) from agent.lifecycle.types import AfterReasoningCtx, BeforeTurnCtx, PromptRenderCtx -from agent.lifecycle.composition import observe_composition_domain_event from agent.plugin_composition import ( Bail, CompositionError, @@ -120,101 +119,8 @@ def _answer_ctx() -> AfterReasoningCtx: ) -@pytest.mark.asyncio -async def test_prompt_seam_runs_before_legacy_phase_modules() -> None: - order: list[str] = [] - root = CompositionRoot("prompt-seam") - - async def plugin(ctx) -> None: - await ctx.on(PROMPT_RENDER_EVENT, lambda _: order.append("composition")) - - class LegacyModule: - slot = "legacy.prompt" - requires = ("prompt_render.emit", "prompt:ctx") - - async def run(self, frame): - order.append("legacy-phase") - return frame - - await root.mount(plugin, name="prompt-plugin") - bus = EventBus() - bus.on(PromptRenderCtx, lambda _: order.append("event-bus")) - modules = default_prompt_render_modules( - bus, - cast(Any, object()), - plugin_modules=cast(Any, [LegacyModule()]), - ) - slots = [cast(str, getattr(module, "slot")) for module in modules] - frame = PromptRenderFrame( - input=cast(Any, None), - slots={"prompt:ctx": _prompt_ctx()}, - ) - async with _bound_root(root): - for module in modules[ - slots.index("prompt_render.emit") : slots.index("legacy.prompt") + 1 - ]: - frame = await module.run(frame) - assert order == ["event-bus", "composition", "legacy-phase"] - assert slots.index("legacy.prompt") < slots.index("prompt_render.collect_exports") - - -@pytest.mark.asyncio -async def test_context_prepared_seam_runs_after_legacy_before_turn_modules() -> None: - order: list[str] = [] - observed: list[BeforeTurnCtx] = [] - root = CompositionRoot("context-prepared-seam") - - async def plugin(ctx) -> None: - def observe(payload: BeforeTurnCtx) -> None: - order.append("composition") - assert payload.extra_hints == ["legacy hint"] - observed.append(payload) - - await ctx.on(CONTEXT_PREPARED_EVENT, observe) - - class LegacyModule: - slot = "legacy.before_turn" - requires = ("before_turn.emit", "session:ctx") - - async def run(self, frame): - order.append("legacy-phase") - frame.slots["session:extra_hint:legacy"] = "legacy hint" - return frame - - await root.mount(plugin, name="context-plugin") - bus = EventBus() - bus.on(BeforeTurnCtx, lambda _: order.append("event-bus")) - modules = default_before_turn_modules( - bus, - cast(Any, object()), - cast(Any, object()), - plugin_modules=cast(Any, [LegacyModule()]), - ) - slots = [cast(str, getattr(module, "slot")) for module in modules] - payload = _before_turn_ctx() - frame = BeforeTurnFrame( - input=cast(Any, None), - slots={"session:ctx": payload}, - ) - - async with _bound_root(root): - for module in modules[ - slots.index("before_turn.emit") : slots.index( - "before_turn.composition_context_prepared" - ) - + 1 - ]: - frame = await module.run(frame) - - assert order == ["event-bus", "legacy-phase", "composition"] - assert observed == [payload] - assert ( - slots.index("before_turn.collect_exports") - < slots.index("before_turn.composition_context_prepared") - < slots.index("before_turn.return") - ) @pytest.mark.asyncio @@ -283,67 +189,6 @@ async def test_lifecycle_seam_rejects_released_owner_lease() -> None: assert caught.value.code == "RUNTIME_SNAPSHOT_BINDING_INACTIVE" -@pytest.mark.asyncio -async def test_answer_seams_preserve_legacy_module_positions() -> None: - order: list[str] = [] - root = CompositionRoot("answer-seam") - - async def plugin(ctx) -> None: - await ctx.on( - AFTER_REASONING_PREPROCESS_EVENT, - lambda _: order.append("preprocess"), - ) - await ctx.on( - AFTER_REASONING_CLEANUP_EVENT, - lambda _: order.append("cleanup"), - ) - - class LegacyPre: - slot = "legacy.answer_pre" - requires = ("after_reasoning.build_ctx", "reasoning:ctx") - - async def run(self, frame): - order.append("legacy-pre") - return frame - - class LegacyPost: - slot = "legacy.answer_post" - requires = ("after_reasoning.emit", "reasoning:ctx") - - async def run(self, frame): - order.append("legacy-post") - return frame - - await root.mount(plugin, name="answer-plugin") - bus = EventBus() - bus.on(AfterReasoningCtx, lambda _: order.append("event-bus")) - modules = default_after_reasoning_modules( - bus, - cast(Any, object()), - plugin_modules=cast(Any, [LegacyPre(), LegacyPost()]), - ) - slots = [cast(str, getattr(module, "slot")) for module in modules] - frame = AfterReasoningFrame( - input=cast(Any, None), - slots={"reasoning:ctx": _answer_ctx()}, - ) - - async with _bound_root(root): - for module in modules[ - slots.index("legacy.answer_pre") : slots.index( - "after_reasoning.composition_cleanup" - ) - + 1 - ]: - frame = await module.run(frame) - - assert order == [ - "legacy-pre", - "preprocess", - "event-bus", - "legacy-post", - "cleanup", - ] @pytest.mark.asyncio @@ -460,7 +305,7 @@ async def new_plugin(ctx) -> None: @pytest.mark.asyncio -async def test_after_turn_committed_event_runs_after_legacy_fanout() -> None: +async def test_after_turn_committed_event_runs_after_core_fanout() -> None: order: list[str] = [] observed: list[TurnCommitted] = [] root = CompositionRoot("after-turn-committed") @@ -528,7 +373,7 @@ def test_after_turn_event_contract_imports_without_phase_runtime() -> None: @pytest.mark.asyncio -async def test_after_turn_committed_event_keeps_legacy_path_without_root() -> None: +async def test_after_turn_committed_event_keeps_core_path_without_root() -> None: observed: list[TurnCommitted] = [] bus = EventBus() bus.on(TurnCommitted, observed.append) @@ -543,60 +388,6 @@ async def test_after_turn_committed_event_keeps_legacy_path_without_root() -> No assert observed == [frame.slots["turn:committed"]] -@pytest.mark.asyncio -async def test_domain_observe_event_runs_after_legacy_fanout() -> None: - order: list[str] = [] - observed: dict[str, object] = {} - root = CompositionRoot("domain-observe-order") - - def observe(name: str): - def callback(event: object) -> None: - order.append(f"composition.{name}") - observed[name] = event - - return callback - - async def plugin(ctx) -> None: - await ctx.on(RETRIEVAL_COMPLETED_EVENT, observe("retrieval")) - await ctx.on(MEMORY_WRITTEN_EVENT, observe("memory")) - - await root.mount(plugin, name="domain-observer") - bus = EventBus() - bus.on(RetrievalCompleted, lambda event: order.append("legacy.retrieval")) - bus.on(MemoryWritten, lambda event: order.append("legacy.memory")) - retrieval = _retrieval_completed_event() - memory = _memory_written_event() - - async with _bound_root(root): - await bus.fanout(retrieval) - await bus.fanout(memory) - - assert order == [ - "legacy.retrieval", - "composition.retrieval", - "legacy.memory", - "composition.memory", - ] - assert observed == { - "retrieval": retrieval, - "memory": memory, - } - - -@pytest.mark.asyncio -async def test_domain_observe_event_is_not_skipped_without_legacy_handlers() -> None: - observed: list[MemoryWritten] = [] - root = CompositionRoot("domain-observe-no-legacy") - - async def plugin(ctx) -> None: - await ctx.on(MEMORY_WRITTEN_EVENT, observed.append) - - await root.mount(plugin, name="domain-observer") - event = _memory_written_event() - async with _bound_root(root): - await EventBus().fanout(event) - - assert observed == [event] @pytest.mark.asyncio @@ -614,16 +405,29 @@ async def second_plugin(ctx) -> None: await first.mount(first_plugin, name="first-observer") await second.mount(second_plugin, name="second-observer") event = _memory_written_event() - bus = EventBus() - async with _bound_root(first): - await bus.fanout(event) + await observe_composition_event(MEMORY_WRITTEN_EVENT, event) async with _bound_root(second): - await bus.fanout(event) + await observe_composition_event(MEMORY_WRITTEN_EVENT, event) assert observed == ["first", "second"] +@pytest.mark.asyncio +async def test_event_bus_does_not_bridge_into_plugin_composition() -> None: + observed: list[MemoryWritten] = [] + root = CompositionRoot("event-bus-is-core-only") + + async def plugin(ctx) -> None: + await ctx.on(MEMORY_WRITTEN_EVENT, observed.append) + + await root.mount(plugin, name="domain-observer") + async with _bound_root(root): + await EventBus().fanout(_memory_written_event()) + + assert observed == [] + + @pytest.mark.asyncio async def test_domain_observe_event_rejects_inherited_wrong_task_binding() -> None: root = CompositionRoot("domain-observe-wrong-task") @@ -719,8 +523,9 @@ async def plugin(ctx) -> None: ) async with _bound_root(root): - await observe_composition_domain_event( - build_retrieval_completed(request, result) + await observe_composition_event( + RETRIEVAL_COMPLETED_EVENT, + build_retrieval_completed(request, result), ) assert len(observed) == 1 @@ -779,19 +584,6 @@ def _committed_event() -> TurnCommitted: ) -def _retrieval_completed_event() -> RetrievalCompleted: - return RetrievalCompleted( - session_key="session", - channel="test", - chat_id="chat", - query="query", - orig_query=None, - hits=[], - injected_count=0, - route_decision=None, - ) - - def _memory_written_event() -> MemoryWritten: return MemoryWritten( session_key="session", diff --git a/tests/test_plugin_composition_loader.py b/tests/test_plugin_composition_loader.py index 4ade07342..b24aa8c72 100644 --- a/tests/test_plugin_composition_loader.py +++ b/tests/test_plugin_composition_loader.py @@ -269,20 +269,13 @@ def _write_static_v3_manifest( root: Path, name: str, version: str, - *, - candidate_data_mode: str | None = None, ) -> None: - candidate_data = ( - "" - if candidate_data_mode is None - else f"candidate_data_mode = {candidate_data_mode!r}\n" - ) (root / "akashic.plugin.toml").write_text( "schema_version = 1\n" f"name = {name!r}\n" f"version = {version!r}\n" "api_version = 3\n" - f"entrypoint = 'plugin.py'\n{candidate_data}", + "entrypoint = 'plugin.py'\n", encoding="utf-8", ) @@ -300,28 +293,23 @@ def _shared_writer_source(version: str) -> str: async def apply(ctx, config): database = ctx.data_root / 'writer.sqlite3' root_token = id(ctx._root_instance_token()) - if ctx.data_access == 'read_only': - connection = sqlite3.connect(f'file:{{database}}?mode=ro', uri=True) - connection.execute('SELECT COUNT(*) FROM writes').fetchone() - connection.close() - else: - ctx.data_root.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(database) - connection.execute( - 'CREATE TABLE IF NOT EXISTS owner (' - 'slot INTEGER PRIMARY KEY CHECK (slot = 1), version TEXT NOT NULL)' - ) - connection.execute( - 'CREATE TABLE IF NOT EXISTS trace (' - 'seq INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL)' - ) - connection.execute( - 'CREATE TABLE IF NOT EXISTS writes (' - 'seq INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT NOT NULL, ' - 'root_token INTEGER NOT NULL)' - ) - connection.commit() - connection.close() + ctx.data_root.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.execute( + 'CREATE TABLE IF NOT EXISTS owner (' + 'slot INTEGER PRIMARY KEY CHECK (slot = 1), version TEXT NOT NULL)' + ) + connection.execute( + 'CREATE TABLE IF NOT EXISTS trace (' + 'seq INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL)' + ) + connection.execute( + 'CREATE TABLE IF NOT EXISTS writes (' + 'seq INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT NOT NULL, ' + 'root_token INTEGER NOT NULL)' + ) + connection.commit() + connection.close() async def started(_event): global writer_task @@ -375,7 +363,7 @@ def _sqlite_scalar(database: Path, query: str) -> object: @pytest.mark.asyncio -async def test_shared_read_candidate_uses_formal_data_without_copy( +async def test_candidate_uses_isolated_data_copy( tmp_path: Path, ) -> None: _ = _write_plugin( @@ -385,7 +373,6 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( "name = 'isolated_reader'\n" "version = '1.0.0'\n" "async def apply(ctx, config):\n" - " assert ctx.data_access == 'read_write'\n" " ctx.data_root.mkdir(parents=True, exist_ok=True)\n" " (ctx.data_root / 'isolated.txt').write_text('isolated')\n", ) @@ -397,7 +384,6 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( "version = '1.0.0'\n" "async def apply(ctx, config):\n" " import sqlite3\n" - " assert ctx.data_access == 'read_write'\n" " ctx.data_root.mkdir(parents=True, exist_ok=True)\n" " connection = sqlite3.connect(ctx.data_root / 'state.sqlite3')\n" " connection.execute('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)')\n" @@ -405,12 +391,7 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( " connection.commit()\n" " connection.close()\n", ) - _write_static_v3_manifest( - plugin_dir, - "shared_reader", - "1.0.0", - candidate_data_mode="shared_read", - ) + _write_static_v3_manifest(plugin_dir, "shared_reader", "1.0.0") manager = _manager(tmp_path) await manager.load_all() stable = manager.generation("shared_reader") @@ -418,11 +399,10 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( assert stable is not None and isolated is not None assert stable.source_type == "builtin" assert stable.static_manifest is not None - assert stable.static_manifest.candidate_data_mode == "shared_read" database = stable.data_dir / "state.sqlite3" sparse = stable.data_dir / "large.sparse" with sparse.open("wb") as stream: - stream.truncate(512 * 1024 * 1024) + stream.truncate(1024 * 1024) formal_inode = database.stat().st_ino formal_digest = hashlib.sha256(database.read_bytes()).hexdigest() proactive = tmp_path / "workspace" / "proactive.db" @@ -438,7 +418,7 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( "version = '2.0.0'\n" "async def apply(ctx, config):\n" " import json, sqlite3\n" - " assert ctx.data_access == 'read_only'\n" + " (ctx.data_root / 'candidate-marker').write_text('candidate')\n" " database = ctx.data_root / 'state.sqlite3'\n" " connection = sqlite3.connect(f'file:{database}?mode=ro', uri=True)\n" " rows = connection.execute('SELECT COUNT(*) FROM items').fetchone()[0]\n" @@ -454,18 +434,14 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( " )\n", encoding="utf-8", ) - _write_static_v3_manifest( - plugin_dir, - "shared_reader", - "2.0.0", - candidate_data_mode="shared_read", - ) + _write_static_v3_manifest(plugin_dir, "shared_reader", "2.0.0") candidate = await manager.prepare_candidate("shared_reader") assert candidate is not None and candidate.runtime_snapshot is not None assert candidate.validation_workspace is not None - assert candidate.validation_data_inventory == () + assert "state.sqlite3" in candidate.validation_data_inventory + assert "large.sparse" in candidate.validation_data_inventory candidate_root = candidate.runtime_snapshot.composition_root assert candidate_root is not None candidate_fibers = { @@ -473,8 +449,7 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( } candidate_runtime = candidate_fibers["shared_reader"].runtime assert candidate_runtime is not None - assert candidate_runtime.data_dir == stable.data_dir - assert candidate_runtime.data_access == "read_only" + assert candidate_runtime.data_dir != stable.data_dir assert "isolated_reader" not in candidate_fibers assert (isolated.data_dir / "isolated.txt").read_text() == "isolated" validation_root = candidate.validation_workspace.parent @@ -484,13 +459,12 @@ async def test_shared_read_candidate_uses_formal_data_without_copy( "rows": 1, "write_rejected": True, } - assert not tuple(validation_root.rglob("state.sqlite3")) - assert not tuple(validation_root.rglob("large.sparse")) + assert len(tuple(validation_root.rglob("state.sqlite3"))) == 1 + assert len(tuple(validation_root.rglob("large.sparse"))) == 1 + assert len(tuple(validation_root.rglob("candidate-marker"))) == 1 assert not tuple(validation_root.rglob("proactive.db")) assert not tuple(validation_root.rglob("wake_proactive.db")) - assert sum(path.stat().st_blocks * 512 for path in validation_root.rglob("*")) < ( - 1024 * 1024 - ) + assert not (stable.data_dir / "candidate-marker").exists() assert database.stat().st_ino == formal_inode assert hashlib.sha256(database.read_bytes()).hexdigest() == formal_digest assert proactive.stat().st_ino == proactive_inode @@ -659,24 +633,17 @@ async def test_candidate_cannot_remove_service_required_by_stable_plugin( @pytest.mark.asyncio @pytest.mark.parametrize("owner_commit_fails", [False, True]) -@pytest.mark.parametrize("new_data_mode", ["shared_read", "isolated_copy"]) -async def test_shared_read_direct_publish_drains_old_writer_before_new_start( +async def test_isolated_candidate_publish_drains_old_writer_before_new_start( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, owner_commit_fails: bool, - new_data_mode: str, ) -> None: plugin_dir = _write_plugin( tmp_path / "plugins", "shared_writer", _shared_writer_source("v1"), ) - _write_static_v3_manifest( - plugin_dir, - "shared_writer", - "v1", - candidate_data_mode="shared_read", - ) + _write_static_v3_manifest(plugin_dir, "shared_writer", "v1") manager = _manager(tmp_path) await manager.load_all() stable = manager.generation("shared_writer") @@ -693,21 +660,13 @@ async def test_shared_read_direct_publish_drains_old_writer_before_new_start( _shared_writer_source("v2"), encoding="utf-8", ) - _write_static_v3_manifest( - plugin_dir, - "shared_writer", - "v2", - candidate_data_mode=new_data_mode, - ) + _write_static_v3_manifest(plugin_dir, "shared_writer", "v2") candidate = await manager.prepare_candidate("shared_writer") assert candidate is not None assert candidate.instance.module.writer_task is None assert candidate.runtime_snapshot is not None candidate_root = candidate.runtime_snapshot.composition_root assert candidate_root is not None - assert candidate_root.plugin_runtime("shared_writer").data_access == ( - "read_only" if new_data_mode == "shared_read" else "read_write" - ) if owner_commit_fails: def fail_owner_commit(*_args: object) -> None: @@ -802,7 +761,7 @@ def fail_owner_commit(*_args: object) -> None: @pytest.mark.asyncio -async def test_shared_read_formal_rebuild_rejects_other_topology_drift( +async def test_formal_rebuild_rejects_candidate_topology_drift( tmp_path: Path, ) -> None: plugin_dir = _write_plugin( @@ -810,12 +769,7 @@ async def test_shared_read_formal_rebuild_rejects_other_topology_drift( "shared_writer", _shared_writer_source("v1"), ) - _write_static_v3_manifest( - plugin_dir, - "shared_writer", - "v1", - candidate_data_mode="shared_read", - ) + _write_static_v3_manifest(plugin_dir, "shared_writer", "v1") manager = _manager(tmp_path) await manager.load_all() stable = manager.generation("shared_writer") @@ -830,16 +784,11 @@ async def test_shared_read_formal_rebuild_rejects_other_topology_drift( mutant = _shared_writer_source("v2").replace( " await ctx.on(RUNTIME_STOPPING, stopping)\n", " await ctx.on(RUNTIME_STOPPING, stopping)\n" - " if ctx.data_access == 'read_write':\n" + " if 'plugin-validation' not in str(ctx.data_root):\n" " await ctx.on(RUNTIME_STARTED, lambda _: None)\n", ) (plugin_dir / "plugin.py").write_text(mutant, encoding="utf-8") - _write_static_v3_manifest( - plugin_dir, - "shared_writer", - "v2", - candidate_data_mode="shared_read", - ) + _write_static_v3_manifest(plugin_dir, "shared_writer", "v2") candidate = await manager.prepare_candidate("shared_writer") assert candidate is not None @@ -4042,12 +3991,10 @@ async def test_installed_v3_candidate_incident_overflow_blocks_promotion( @pytest.mark.asyncio @pytest.mark.parametrize("owner_commit_fails", [False, True]) -@pytest.mark.parametrize("new_data_mode", ["shared_read", "isolated_copy"]) -async def test_installed_v3_shared_handoff_success_and_owner_failure( +async def test_installed_v3_isolated_handoff_success_and_owner_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, owner_commit_fails: bool, - new_data_mode: str, ) -> None: plugin_base = tmp_path / "home" / "cache" / "lab" / "installed_v3" stable_artifact = plugin_base / ".artifacts" / "1.0.0-aaaa" @@ -4081,18 +4028,8 @@ async def test_installed_v3_shared_handoff_success_and_owner_failure( source.replace("version = '1.0.0'", "version = '2.0.0'"), encoding="utf-8", ) - _write_static_v3_manifest( - stable_artifact, - "installed_v3", - "1.0.0", - candidate_data_mode="shared_read", - ) - _write_static_v3_manifest( - latest_artifact, - "installed_v3", - "2.0.0", - candidate_data_mode=new_data_mode, - ) + _write_static_v3_manifest(stable_artifact, "installed_v3", "1.0.0") + _write_static_v3_manifest(latest_artifact, "installed_v3", "2.0.0") stable_pointer = ArtifactPointer(".artifacts/1.0.0-aaaa") latest_pointer = ArtifactPointer(".artifacts/2.0.0-bbbb") write_pointers(plugin_base, stable=stable_pointer, latest=stable_pointer) diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index f24d5a073..f268274c6 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -2,7 +2,6 @@ import asyncio import json -import logging import os import shutil import sys @@ -252,53 +251,6 @@ def cancelled() -> None: assert [failure.resource for failure in failures] == ["cancelled"] -@pytest.mark.asyncio -async def test_plugin_scope_reports_failed_task_and_is_idempotent(): - scope = PluginScope("task-failure") - cleaned: list[str] = [] - - async def fail() -> None: - raise RuntimeError("task failed") - - task = scope.create_task(fail(), name="worker") - with pytest.raises(RuntimeError, match="task failed"): - await task - scope.defer("marker", lambda: cleaned.append("marker")) - - failures = await scope.aclose() - - assert cleaned == ["marker"] - assert [(failure.resource, failure.error) for failure in failures] == [ - ("task:worker", "task failed") - ] - assert await scope.aclose() == [] - - -@pytest.mark.asyncio -async def test_plugin_scope_reports_task_failure_before_close(caplog): - scope = PluginScope("task-runtime-failure") - - async def fail() -> None: - raise RuntimeError("runtime task failed") - - with caplog.at_level(logging.ERROR, logger="agent.plugins.scope"): - task = scope.create_task(fail(), name="runtime-worker") - await asyncio.sleep(0) - await asyncio.sleep(0) - - assert task.done() - record = next( - record for record in caplog.records if record.name == "agent.plugins.scope" - ) - assert record.exc_info is not None - assert record.exc_info[0] is RuntimeError - assert "runtime task failed" in caplog.text - failures = await scope.aclose() - assert [(failure.resource, failure.error) for failure in failures] == [ - ("task:runtime-worker", "runtime task failed") - ] - - @pytest.mark.asyncio async def test_plugin_scope_finishes_cleanup_after_external_cancellation(): scope = PluginScope("cancelled-close") @@ -333,21 +285,12 @@ async def cleanup() -> None: @pytest.mark.asyncio -async def test_closed_plugin_scope_does_not_create_resources(): +async def test_closed_plugin_scope_does_not_accept_cleanup(): scope = PluginScope("closed") - bus = EventBus() _ = await scope.aclose() with pytest.raises(RuntimeError, match="作用域已关闭"): - _ = scope.subscribe(bus, str, lambda _event: None) - - async def wait() -> None: - await asyncio.Event().wait() - - with pytest.raises(RuntimeError, match="作用域已关闭"): - _ = scope.create_task(wait()) - - assert bus.handler_count() == 0 + scope.defer("late", lambda: None) @pytest.mark.asyncio diff --git a/tests/test_plugin_packages.py b/tests/test_plugin_packages.py deleted file mode 100644 index 336d7060f..000000000 --- a/tests/test_plugin_packages.py +++ /dev/null @@ -1,108 +0,0 @@ -from pathlib import Path -from typing import Any - -import pytest - -from agent.plugins.manifest import ( - load_package_manifest, - write_package_manifest, - write_plugin_manifest, -) -from agent.plugins.packages import discover_plugin_packages -from agent.plugins.manager import PluginManager -from bus.event_bus import EventBus - - -def test_manager_discover_reads_each_package_file_once( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = Path(__file__).resolve().parents[1] - manager = PluginManager( - [root / "plugins"], - event_bus=EventBus(), - workspace=tmp_path / "workspace", - installed_cache_root=tmp_path / "cache", - ) - original_read_text = Path.read_text - reads: list[Path] = [] - - def record_read(path: Path, *args: Any, **kwargs: Any) -> str: - if path.name == "package.toml": - reads.append(path) - return original_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", record_read) - - mods = manager.discover() - - assert reads == [] - assert {mod["name"] for mod in mods} == { - "akasha", - "compaction", - "eventmail", - "models", - "markdown_memory", - "openai-compatible", - "opencode-go", - "codex", - "computer", - "conversation-ui", - "drift", - "runtime-ui", - "scheduler", - "shell-ui", - "subagent", - "wake", - "workbench-ui", - } - - -def test_manager_can_disable_one_builtin_without_hiding_installed_plugins( - tmp_path: Path, -) -> None: - root = Path(__file__).resolve().parents[1] - manager = PluginManager( - [root / "plugins"], - event_bus=EventBus(), - workspace=tmp_path / "workspace", - installed_cache_root=tmp_path / "cache", - disabled_builtin_plugins=frozenset({"subagent"}), - ) - - assert "subagent" not in {item["name"] for item in manager.discover()} - - -def test_plugin_manifest_write_preserves_packages(tmp_path: Path) -> None: - (tmp_path / "manifest.toml").write_text( - '[plugins]\n\n[packages."example-bundle"]\nenabled = true\n', - encoding="utf-8", - ) - - write_plugin_manifest({"feed@lab": True}, plugins_home=tmp_path) - - assert load_package_manifest(tmp_path) == {"example-bundle": True} - - write_package_manifest({"example-bundle": False}, plugins_home=tmp_path) - - assert load_package_manifest(tmp_path) == {"example-bundle": False} - - -def test_package_manifest_rejects_non_schema_values(tmp_path: Path) -> None: - package_dir = tmp_path / "plugin_packages" / "broken" - package_dir.mkdir(parents=True) - - (package_dir / "package.toml").write_text( - '[package]\nid = "broken"\nmembers = ["broken"]\n' 'dashboard = "false"\n', - encoding="utf-8", - ) - with pytest.raises(ValueError, match="dashboard 无效"): - discover_plugin_packages(tmp_path) - - (package_dir / "package.toml").write_text( - '[package]\nid = "broken"\nmembers = ["broken"]\n' - 'provides = "proactive.runtime"\n', - encoding="utf-8", - ) - with pytest.raises(ValueError, match="provides 无效"): - discover_plugin_packages(tmp_path) diff --git a/tests/test_plugin_static_manifest.py b/tests/test_plugin_static_manifest.py index f811a7075..37657227a 100644 --- a/tests/test_plugin_static_manifest.py +++ b/tests/test_plugin_static_manifest.py @@ -76,14 +76,13 @@ def test_static_manifest_is_import_free_and_exposes_runtime_policy( assert manifest.name == "calendar" assert manifest.version == "3.0.0" assert manifest.entrypoint == "plugin.py" - assert manifest.candidate_data_mode == "isolated_copy" assert manifest.requirements == ("mcp/requirements.txt",) assert manifest.python[0].runtime_root == "mcp" assert manifest.exclude_data_paths == (".env", "token.json") assert len(manifest.identity_digest) == 64 -def test_static_manifest_shared_read_is_validated_identity(tmp_path: Path) -> None: +def test_static_manifest_rejects_removed_candidate_data_mode(tmp_path: Path) -> None: root = tmp_path / "calendar" (root / "mcp").mkdir(parents=True) (root / "plugin.py").write_text("", encoding="utf-8") @@ -97,37 +96,7 @@ def test_static_manifest_shared_read_is_validated_identity(tmp_path: Path) -> No encoding="utf-8", ) - shared = load_static_plugin_manifest(root) - manifest_path.write_text(_manifest(), encoding="utf-8") - isolated = load_static_plugin_manifest(root) - - assert shared.candidate_data_mode == "shared_read" - assert isolated.candidate_data_mode == "isolated_copy" - assert shared.identity_digest != isolated.identity_digest - - -@pytest.mark.parametrize( - "candidate_data_mode", - ['"writer_handoff"', "[]", "{}"], -) -def test_static_manifest_rejects_invalid_candidate_data_mode( - tmp_path: Path, - candidate_data_mode: str, -) -> None: - root = tmp_path / "calendar" - (root / "mcp").mkdir(parents=True) - (root / "plugin.py").write_text("", encoding="utf-8") - (root / "mcp" / "requirements.txt").write_text("", encoding="utf-8") - (root / "akashic.plugin.toml").write_text( - _manifest().replace( - 'entrypoint = "plugin.py"\n\n', - 'entrypoint = "plugin.py"\n' - f"candidate_data_mode = {candidate_data_mode}\n\n", - ), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match="candidate_data_mode"): + with pytest.raises(ValueError, match="未知字段.*candidate_data_mode"): load_static_plugin_manifest(root) diff --git a/tests/test_plugin_v3_only_surface.py b/tests/test_plugin_v3_only_surface.py new file mode 100644 index 000000000..0f3a683b5 --- /dev/null +++ b/tests/test_plugin_v3_only_surface.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import inspect +from pathlib import Path +from types import ModuleType + +import pytest + +from agent.lifecycle.phases.after_reasoning import default_after_reasoning_modules +from agent.lifecycle.phases.after_step import default_after_step_modules +from agent.lifecycle.phases.after_turn import default_after_turn_modules +from agent.lifecycle.phases.before_reasoning import default_before_reasoning_modules +from agent.lifecycle.phases.before_step import default_before_step_modules +from agent.lifecycle.phases.before_turn import default_before_turn_modules +from agent.lifecycle.phases.prompt_render import default_prompt_render_modules +from agent.plugins.composable import ComposablePlugin +from agent.plugins.manifest import load_plugin_manifest + + +@pytest.mark.parametrize( + "factory", + ( + default_before_turn_modules, + default_before_reasoning_modules, + default_prompt_render_modules, + default_before_step_modules, + default_after_step_modules, + default_after_reasoning_modules, + default_after_turn_modules, + ), +) +def test_core_phase_factories_have_no_plugin_module_injection(factory) -> None: + assert "plugin_modules" not in inspect.signature(factory).parameters + + +def test_plugin_loader_rejects_v2_module() -> None: + module = ModuleType("removed_api") + module.api_version = 2 + module.name = "removed-api" + module.version = "1.0.0" + module.apply = lambda ctx, config: None + + with pytest.raises(ValueError, match="api_version = 3"): + ComposablePlugin.from_module(module) + + +def test_plugin_manifest_rejects_removed_package_shell(tmp_path: Path) -> None: + (tmp_path / "manifest.toml").write_text( + '[plugins]\n\n[packages."legacy"]\nenabled = true\n', + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="不再支持 \\[packages\\]"): + load_plugin_manifest(tmp_path) diff --git a/tests/test_proactive_feedback_emotion_interop.py b/tests/test_proactive_feedback_emotion_interop.py index 68ffd3e09..c3a280f4a 100644 --- a/tests/test_proactive_feedback_emotion_interop.py +++ b/tests/test_proactive_feedback_emotion_interop.py @@ -219,7 +219,6 @@ async def _mount( workspace=sandbox / "workspace", config=None, workspace_roots=workspace_roots, - data_access="read_write", ), ) diff --git a/tests/test_proactive_island_handoff.py b/tests/test_proactive_island_handoff.py index 3c94e2d12..593d21bda 100644 --- a/tests/test_proactive_island_handoff.py +++ b/tests/test_proactive_island_handoff.py @@ -34,9 +34,8 @@ inventory_digest, inventory_workspace, ) -from agent.lifecycle.types import BeforeTurnCtx from plugins.eventmail.store import EventMailStore -from plugins.wake.legacy_rules import ArchivedRules +from plugins.wake.legacy_rules import read_archived_rules from agent.migrations.proactive_island.wake_rules import WakeRulesArchiveAdapter from scripts.proactive_island_handoff import main as handoff_main from tests.fixtures.legacy_wake_state import ( @@ -772,8 +771,7 @@ def test_wake_rules_archive_keeps_exact_bytes_and_verified_lineage( assert plan_cli(workspace).status is HandoffStatus.APPLIED -@pytest.mark.asyncio -async def test_archived_rules_inject_only_into_wake_before_turn(tmp_path: Path) -> None: +def test_archived_rules_are_read_from_handoff_archive(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "PROACTIVE_CONTEXT.md").write_text( @@ -786,26 +784,9 @@ async def test_archived_rules_inject_only_into_wake_before_turn(tmp_path: Path) ).status is HandoffStatus.APPLIED ) - archived = ArchivedRules(workspace / "plugin-data" / "wake-builtin") - - def context(channel: str) -> BeforeTurnCtx: - return BeforeTurnCtx( - session_key=f"{channel}:one", - channel=channel, - chat_id="one", - content="check", - timestamp=datetime(2026, 8, 23, tzinfo=UTC), - history_messages=(), - turn_id="turn:one", - ) - - wake = context("wake") - passive = context("telegram") - await archived.prepare(wake) - await archived.prepare(passive) - - assert wake.extra_hints == ["# exact legacy rules"] - assert passive.extra_hints == [] + assert read_archived_rules( + workspace / "plugin-data" / "wake-builtin" + ) == "# exact legacy rules" @pytest.mark.parametrize( diff --git a/tests/test_production_sloc.py b/tests/test_production_sloc.py index c50e51960..06d7e5e8c 100644 --- a/tests/test_production_sloc.py +++ b/tests/test_production_sloc.py @@ -111,8 +111,6 @@ def test_source_set_includes_only_approved_production_extensions_and_roots() -> included = ( "main.py", "agent/core/runtime.py", - "plugin_packages/example/plugin.py", - "plugin_packages/example/view.tsx", "sdk/python/src/sdk.py", "frontend/chat/src/main.tsx", ) @@ -124,7 +122,6 @@ def test_source_set_includes_only_approved_production_extensions_and_roots() -> "frontend/chat/src/styles.css", "frontend/chat/src/types.d.ts", "frontend/chat/dist/bundle.js", - "plugin_packages/vendor/third_party.py", ) assert all(sloc.is_production_source_path(path) for path in included) diff --git a/tests/test_runtime_smoke.py b/tests/test_runtime_smoke.py index 725a72d36..a6eeeafac 100644 --- a/tests/test_runtime_smoke.py +++ b/tests/test_runtime_smoke.py @@ -909,7 +909,7 @@ def test_init_workspace_creates_expected_assets(tmp_path): encoding="utf-8" ) assert not (workspace / "PROACTIVE_CONTEXT.md").exists() - assert (workspace / "mcp" / "servers").is_dir() + assert not (workspace / "mcp").exists() assert not (workspace / "proactive_sources.json").exists() assert not (workspace / "proactive.db").exists() assert (workspace / "skills").is_dir() diff --git a/tests/test_workspace_mcp_removed.py b/tests/test_workspace_mcp_removed.py index 95f976f7a..9dbfdb323 100644 --- a/tests/test_workspace_mcp_removed.py +++ b/tests/test_workspace_mcp_removed.py @@ -29,3 +29,12 @@ def test_workspace_mcp_manager_owner_and_builtin_skill_are_removed() -> None: assert not ( Path(__file__).parents[1] / "skills/manage-workspace-mcp" / "SKILL.md" ).exists() + + +def test_workspace_init_does_not_create_removed_mcp_directories() -> None: + source = (Path(__file__).parents[1] / "bootstrap/init_workspace.py").read_text( + encoding="utf-8" + ) + + assert '"mcp"' not in source + assert '"mcp/servers"' not in source diff --git a/tests_scenarios/contracts/coverage-baseline.json b/tests_scenarios/contracts/coverage-baseline.json index 2168eee07..41f64ff45 100644 --- a/tests_scenarios/contracts/coverage-baseline.json +++ b/tests_scenarios/contracts/coverage-baseline.json @@ -1,7 +1,7 @@ { "acceptedGaps": [], "base": "683b4791b0c25e3e899d65fe7c028514240414b3", - "catalogDigest": "36956ec187526488e53096bf64b8bd5d8e6a49cf2af85825da0fac61a2f79f47", + "catalogDigest": "7cb0c976b1628a12e6650b79b2728b16fc19c52ecd055fe8156ae8a51536f962", "purpose": "approved_contract_mapping", "coveredP0": { "companion_control": [ diff --git a/tests_scenarios/contracts/impact.toml b/tests_scenarios/contracts/impact.toml index 4b1eb8e56..7a3ad66da 100644 --- a/tests_scenarios/contracts/impact.toml +++ b/tests_scenarios/contracts/impact.toml @@ -144,8 +144,6 @@ paths = [ ] deleted_paths = [ "agent/tool_hooks/**", - "plugin_packages/default-proactive/**", - "plugin_packages/wake-proactive/**", ] depends_on = ["lifecycle"] scenarios = ["plugin_generation_contract", "plugin_workload_contract", "plugin_uninstall_drain_finality", "mcp_process_lifecycle", "plugin_domain_observe_events"] diff --git a/tests_scenarios/contracts/scenarios.toml b/tests_scenarios/contracts/scenarios.toml index 06d19cda4..53f693961 100644 --- a/tests_scenarios/contracts/scenarios.toml +++ b/tests_scenarios/contracts/scenarios.toml @@ -32,8 +32,7 @@ companion_receipt_deletes_valid_result = "tests/semantic/test_companion_contract companion_shell_cleanup_rewrites_turn = "tests/semantic/test_companion_contract.py::test_shell_cleanup_rejects_turn_rewrite_mutant" companion_control_replay_drops_live_event = "tests/semantic/test_companion_contract.py::test_control_replay_rejects_live_drop_mutant" companion_dashboard_uses_html_sink = "tests/semantic/test_companion_contract.py::test_dashboard_rejects_html_sink_mutant" -domain_event_legacy_order = "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_runs_after_legacy_fanout" -domain_event_no_handler_early_return = "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_is_not_skipped_without_legacy_handlers" +domain_event_bus_bridge = "tests/test_plugin_composition_lifecycle.py::test_event_bus_does_not_bridge_into_plugin_composition" domain_event_candidate_binding = "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_uses_bound_candidate_root" domain_event_wrong_task_fallback = "tests/test_plugin_composition_lifecycle.py::test_event_bus_rejects_inherited_wrong_task_binding" retrieval_event_payload = "tests/test_plugin_composition_lifecycle.py::test_retrieval_completed_event_payload" @@ -380,8 +379,7 @@ command = [ "-m", "pytest", "-q", - "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_runs_after_legacy_fanout", - "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_is_not_skipped_without_legacy_handlers", + "tests/test_plugin_composition_lifecycle.py::test_event_bus_does_not_bridge_into_plugin_composition", "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_uses_bound_candidate_root", "tests/test_plugin_composition_lifecycle.py::test_domain_observe_event_rejects_inherited_wrong_task_binding", "tests/test_plugin_composition_lifecycle.py::test_event_bus_rejects_inherited_wrong_task_binding", @@ -391,7 +389,7 @@ command = [ ] observes = [ "domain_event_identity", - "legacy_before_composition", + "event_bus_composition_isolation", "candidate_root_binding", "wrong_task_failure", "event_bus_wrong_task_failure", @@ -400,8 +398,7 @@ observes = [ "leaf_contract_import", ] mutants = [ - "domain_event_legacy_order", - "domain_event_no_handler_early_return", + "domain_event_bus_bridge", "domain_event_candidate_binding", "domain_event_wrong_task_fallback", "retrieval_event_payload", @@ -473,7 +470,7 @@ command = [ "tests/test_turn_pipelines.py::test_process_direct_runs_concurrently_with_another_session", "tests/test_turn_pipelines.py::test_process_direct_waits_for_the_same_session_lane", "tests/test_plugin_hot_reload.py::test_runtime_snapshot_latest_requires_explicit_selector_and_promotion", - "tests/test_plugin_composition_loader.py::test_installed_v3_shared_handoff_success_and_owner_failure", + "tests/test_plugin_composition_loader.py::test_candidate_uses_isolated_data_copy", "tests/test_plugin_hot_reload.py::test_startup_recovers_installed_candidate_from_durable_pointers", "tests/test_plugin_composition_loader.py::test_installed_v3_candidate_health_blocks_promotion_until_recovered", "tests/test_plugin_hot_reload.py::test_passive_runtime_admission_holds_one_snapshot",