feat(deploy): finalize MVP deployment and file transfer approach

This commit is contained in:
Mikhail Putilovskij 2026-05-02 23:45:52 +03:00
parent 6369721876
commit 0f79494fbe
43 changed files with 3078 additions and 645 deletions

View file

@ -0,0 +1,855 @@
# Matrix Multi-Agent Routing And Restart State Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
---
## File Structure
- Create: `adapter/matrix/agent_registry.py`
Purpose: load and validate the YAML agent registry used by Matrix runtime.
- Create: `adapter/matrix/routed_platform.py`
Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
- Create: `adapter/matrix/handlers/agent.py`
Purpose: implement `!agent` listing and selection behavior.
- Create: `tests/adapter/matrix/test_agent_registry.py`
Purpose: cover YAML loading and registry validation.
- Create: `tests/adapter/matrix/test_routed_platform.py`
Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
- Create: `tests/adapter/matrix/test_agent_handler.py`
Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
- Create: `tests/adapter/matrix/test_restart_persistence.py`
Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
- Create: `config/matrix-agents.example.yaml`
Purpose: document the expected agent registry format.
- Modify: `pyproject.toml`
Purpose: add YAML parsing dependency required by the runtime registry loader.
- Modify: `.env.example`
Purpose: document the config path env var for the Matrix agent registry.
- Modify: `README.md`
Purpose: document the new config file, `!agent`, and restart persistence expectations.
- Modify: `adapter/matrix/store.py`
Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
- Modify: `adapter/matrix/bot.py`
Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
- Modify: `adapter/matrix/handlers/__init__.py`
Purpose: register the new `!agent` command.
- Modify: `adapter/matrix/handlers/chat.py`
Purpose: require a selected agent for `!new` and bind new rooms to that agent.
- Modify: `adapter/matrix/handlers/context_commands.py`
Purpose: keep context commands compatible with local chat ids and routed platform delegation.
- Modify: `adapter/matrix/handlers/settings.py`
Purpose: expose `!agent` in help text.
- Modify: `tests/adapter/matrix/test_dispatcher.py`
Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
- Modify: `tests/adapter/matrix/test_context_commands.py`
Purpose: keep load/reset/context flows aligned with the routed platform facade.
---
### Task 1: Add The Agent Registry And Configuration Wiring
**Files:**
- Create: `adapter/matrix/agent_registry.py`
- Create: `tests/adapter/matrix/test_agent_registry.py`
- Create: `config/matrix-agents.example.yaml`
- Modify: `pyproject.toml`
- Modify: `.env.example`
- Modify: `README.md`
- [ ] **Step 1: Write the failing registry tests**
```python
# tests/adapter/matrix/test_agent_registry.py
from pathlib import Path
import pytest
from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
path = tmp_path / "agents.yaml"
path.write_text(
"agents:\n"
" - id: agent-1\n"
" label: Analyst\n"
" - id: agent-2\n"
" label: Research\n",
encoding="utf-8",
)
registry = load_agent_registry(path)
assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
assert registry.get("agent-1").label == "Analyst"
def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
path = tmp_path / "agents.yaml"
path.write_text(
"agents:\n"
" - id: agent-1\n"
" label: Analyst\n"
" - id: agent-1\n"
" label: Duplicate\n",
encoding="utf-8",
)
with pytest.raises(AgentRegistryError, match="duplicate agent id"):
load_agent_registry(path)
```
- [ ] **Step 2: Run the registry tests to verify they fail**
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
```toml
# pyproject.toml
dependencies = [
"aiogram>=3.4,<4",
"matrix-nio>=0.21",
"pydantic>=2.5",
"structlog>=24.1",
"python-dotenv>=1.0",
"httpx>=0.27",
"aiohttp>=3.9",
"PyYAML>=6.0",
]
```
```python
# adapter/matrix/agent_registry.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import yaml
class AgentRegistryError(ValueError):
pass
@dataclass(frozen=True)
class AgentDefinition:
agent_id: str
label: str
class AgentRegistry:
def __init__(self, agents: list[AgentDefinition]) -> None:
self.agents = agents
self._by_id = {agent.agent_id: agent for agent in agents}
def get(self, agent_id: str) -> AgentDefinition:
try:
return self._by_id[agent_id]
except KeyError as exc:
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
def load_agent_registry(path: str | Path) -> AgentRegistry:
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
entries = raw.get("agents")
if not isinstance(entries, list) or not entries:
raise AgentRegistryError("agents registry must contain a non-empty agents list")
agents: list[AgentDefinition] = []
seen: set[str] = set()
for entry in entries:
agent_id = str(entry.get("id", "")).strip()
label = str(entry.get("label", "")).strip()
if not agent_id or not label:
raise AgentRegistryError("each agent entry requires id and label")
if agent_id in seen:
raise AgentRegistryError(f"duplicate agent id: {agent_id}")
seen.add(agent_id)
agents.append(AgentDefinition(agent_id=agent_id, label=label))
return AgentRegistry(agents)
```
- [ ] **Step 4: Add the example config and runtime wiring docs**
```yaml
# config/matrix-agents.example.yaml
agents:
- id: agent-1
label: Analyst
- id: agent-2
label: Research
```
```env
# .env.example
MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
```
```markdown
# README.md
1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
3. Use `!agent` in Matrix to select the active upstream agent
```
- [ ] **Step 5: Run the registry tests to verify they pass**
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
git commit -m "feat: add matrix agent registry loader"
```
---
### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
**Files:**
- Create: `adapter/matrix/routed_platform.py`
- Create: `tests/adapter/matrix/test_routed_platform.py`
- Modify: `adapter/matrix/bot.py`
- [ ] **Step 1: Write the failing routed-platform tests**
```python
# tests/adapter/matrix/test_routed_platform.py
import pytest
from adapter.matrix.routed_platform import RoutedPlatformClient
from adapter.matrix.store import set_room_meta
from core.chat import ChatManager
from core.store import InMemoryStore
from sdk.interface import MessageResponse
from sdk.prototype_state import PrototypeStateStore
class FakeDelegate:
def __init__(self, agent_id: str) -> None:
self.agent_id = agent_id
self.calls = []
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
self.calls.append((user_id, chat_id, text, attachments))
return MessageResponse(
message_id=user_id,
response=f"{self.agent_id}:{text}",
tokens_used=0,
finished=True,
)
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
async def get_settings(self, user_id: str):
return await PrototypeStateStore().get_settings(user_id)
async def update_settings(self, user_id: str, action):
return None
@pytest.mark.asyncio
async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
store = InMemoryStore()
chat_mgr = ChatManager(None, store)
await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
await set_room_meta(
store,
"!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
)
delegates = {"agent-2": FakeDelegate("agent-2")}
platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
response = await platform.send_message("u1", "C1", "hello")
assert response.response == "agent-2:hello"
assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
```
- [ ] **Step 2: Run the routed-platform tests to verify they fail**
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
```python
# adapter/matrix/routed_platform.py
from __future__ import annotations
from sdk.interface import PlatformClient
class RoutedPlatformClient(PlatformClient):
def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
self._store = store
self._chat_mgr = chat_mgr
self._delegates = delegates
async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
if ctx is None:
raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
delegate = self._delegates[room_meta["agent_id"]]
return delegate, str(room_meta["platform_chat_id"])
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
return await delegate.send_message(user_id, platform_chat_id, text, attachments)
async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
yield chunk
async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
first_delegate = next(iter(self._delegates.values()))
return await first_delegate.get_or_create_user(external_id, platform, display_name)
async def get_settings(self, user_id: str):
first_delegate = next(iter(self._delegates.values()))
return await first_delegate.get_settings(user_id)
async def update_settings(self, user_id: str, action):
first_delegate = next(iter(self._delegates.values()))
await first_delegate.update_settings(user_id, action)
```
```python
# adapter/matrix/bot.py
from adapter.matrix.agent_registry import load_agent_registry
from adapter.matrix.routed_platform import RoutedPlatformClient
def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
if backend != "real":
return MockPlatformClient()
registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
delegates = {
agent.agent_id: RealPlatformClient(
agent_id=agent.agent_id,
agent_base_url=_agent_base_url_from_env(),
prototype_state=PrototypeStateStore(),
platform="matrix",
)
for agent in registry.agents
}
return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
def build_runtime(...):
store = store or InMemoryStore()
chat_mgr = ChatManager(None, store)
platform = platform or _build_platform_from_env(store, chat_mgr)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
)
```
- [ ] **Step 4: Run the routed-platform tests to verify they pass**
Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
git commit -m "feat: add matrix routed platform facade"
```
---
### Task 3: Add `!agent` Selection And Durable User Agent State
**Files:**
- Create: `adapter/matrix/handlers/agent.py`
- Create: `tests/adapter/matrix/test_agent_handler.py`
- Modify: `adapter/matrix/store.py`
- Modify: `adapter/matrix/handlers/__init__.py`
- Modify: `adapter/matrix/handlers/settings.py`
- [ ] **Step 1: Write the failing agent-handler tests**
```python
# tests/adapter/matrix/test_agent_handler.py
import pytest
from adapter.matrix.handlers.agent import make_handle_agent
from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
from core.protocol import IncomingCommand
from core.store import InMemoryStore
class FakeRegistry:
def __init__(self) -> None:
self.agents = [
type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
]
@pytest.mark.asyncio
async def test_agent_command_lists_available_agents():
handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
result = await handler(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
None,
None,
None,
None,
)
assert "1. Analyst" in result[0].text
assert "2. Research" in result[0].text
@pytest.mark.asyncio
async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
store = InMemoryStore()
await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
handler = make_handle_agent(store=store, registry=FakeRegistry())
chat_mgr = type(
"ChatMgr",
(),
{"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
)()
await handler(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
None,
None,
chat_mgr,
None,
)
assert await get_selected_agent_id(store, "u1") == "agent-2"
room_meta = await get_room_meta(store, "!room:example.org")
assert room_meta["agent_id"] == "agent-2"
```
- [ ] **Step 2: Run the agent-handler tests to verify they fail**
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
Expected: FAIL with missing handler or store helpers.
- [ ] **Step 3: Add durable store helpers and implement `!agent`**
```python
# adapter/matrix/store.py
async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
meta = await get_user_meta(store, matrix_user_id) or {}
value = meta.get("selected_agent_id")
return str(value) if value else None
async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
meta = await get_user_meta(store, matrix_user_id) or {}
meta["selected_agent_id"] = agent_id
await set_user_meta(store, matrix_user_id, meta)
async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
meta = dict(await get_room_meta(store, room_id) or {})
meta["agent_id"] = agent_id
await set_room_meta(store, room_id, meta)
```
```python
# adapter/matrix/handlers/agent.py
from __future__ import annotations
from adapter.matrix.store import (
get_room_meta,
get_selected_agent_id,
next_platform_chat_id,
set_platform_chat_id,
set_room_agent_id,
set_selected_agent_id,
)
from core.protocol import IncomingCommand, OutgoingMessage
def make_handle_agent(store, registry):
async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
if not event.args:
current = await get_selected_agent_id(store, event.user_id)
lines = ["Доступные агенты:"]
for index, agent in enumerate(registry.agents, start=1):
marker = " (текущий)" if agent.agent_id == current else ""
lines.append(f"{index}. {agent.label}{marker}")
lines.append("")
lines.append("Выбери агента: !agent <номер>")
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
agent = registry.agents[int(event.args[0]) - 1]
await set_selected_agent_id(store, event.user_id, agent.agent_id)
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
if ctx is not None:
room_meta = await get_room_meta(store, ctx.surface_ref)
if room_meta is not None and not room_meta.get("agent_id"):
await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
if not room_meta.get("platform_chat_id"):
await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
return handle_agent
```
- [ ] **Step 4: Register the command and update help text**
```python
# adapter/matrix/handlers/__init__.py
from adapter.matrix.handlers.agent import make_handle_agent
dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
```
```python
# adapter/matrix/handlers/settings.py
HELP_TEXT = "\n".join(
[
"Команды",
"",
"!agent выбрать активного агента",
"!new [название] создать новый чат",
"!chats список активных чатов",
"!rename <название> переименовать текущий чат",
"!archive архивировать текущий чат",
"!context показать текущее состояние контекста",
"!save [имя] сохранить текущий контекст",
"!load показать сохранённые контексты",
]
)
```
- [ ] **Step 5: Run the agent-handler tests to verify they pass**
Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
git commit -m "feat: add matrix agent selection command"
```
---
### Task 4: Bind Rooms Correctly And Block Stale Chats
**Files:**
- Modify: `adapter/matrix/bot.py`
- Modify: `adapter/matrix/handlers/chat.py`
- Modify: `adapter/matrix/handlers/context_commands.py`
- Modify: `tests/adapter/matrix/test_dispatcher.py`
- Modify: `tests/adapter/matrix/test_context_commands.py`
- [ ] **Step 1: Write the failing dispatcher and context-command tests**
```python
# tests/adapter/matrix/test_dispatcher.py
@pytest.mark.asyncio
async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
client.room_send.assert_awaited_once()
assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
@pytest.mark.asyncio
async def test_new_chat_requires_selected_agent_and_binds_room_meta():
client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
room_put_state=AsyncMock(),
)
runtime = build_runtime(platform=MockPlatformClient(), client=client)
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
result = await runtime.dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
)
room_meta = await get_room_meta(runtime.store, "!r2:example")
assert room_meta["agent_id"] == "agent-2"
assert "Создан чат" in result[0].text
```
```python
# tests/adapter/matrix/test_context_commands.py
@pytest.mark.asyncio
async def test_load_selection_calls_platform_with_local_chat_id():
platform = MatrixCommandPlatform()
runtime = build_runtime(platform=platform)
await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
```
- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
```python
# adapter/matrix/bot.py
from adapter.matrix.store import (
get_selected_agent_id,
get_room_meta,
next_platform_chat_id,
set_platform_chat_id,
set_room_agent_id,
)
async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
room_meta = await get_room_meta(self.runtime.store, room_id)
selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
if not selected_agent_id:
return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
if room_meta is None:
return room_meta, None
if not room_meta.get("agent_id"):
await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
if not room_meta.get("platform_chat_id"):
await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
room_meta = await get_room_meta(self.runtime.store, room_id)
return room_meta, None
if room_meta["agent_id"] != selected_agent_id:
return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
return room_meta, None
```
```python
# adapter/matrix/bot.py
local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
dispatch_chat_id = local_chat_id
if not body.startswith("!"):
room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
if blocking is not None:
await self._send_all(room.room_id, [blocking])
return
incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
```
- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
```python
# adapter/matrix/handlers/chat.py
from adapter.matrix.store import get_selected_agent_id
selected_agent_id = await get_selected_agent_id(store, event.user_id)
if not selected_agent_id:
return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
await set_room_meta(
store,
room_id,
{
"room_type": "chat",
"chat_id": chat_id,
"display_name": room_name,
"matrix_user_id": event.user_id,
"space_id": space_id,
"platform_chat_id": platform_chat_id,
"agent_id": selected_agent_id,
},
)
```
```python
# adapter/matrix/bot.py
room_meta = await get_room_meta(self.runtime.store, room_id)
local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
await self.runtime.platform.send_message(
user_id,
local_chat_id,
LOAD_PROMPT.format(name=name),
)
```
- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
git commit -m "feat: bind matrix rooms to selected agents"
```
---
### Task 5: Prove Durable Restart State And Sequence Persistence
**Files:**
- Create: `tests/adapter/matrix/test_restart_persistence.py`
- Modify: `adapter/matrix/store.py`
- Modify: `README.md`
- [ ] **Step 1: Write the failing restart-persistence tests**
```python
# tests/adapter/matrix/test_restart_persistence.py
import pytest
from adapter.matrix.store import (
get_selected_agent_id,
next_platform_chat_id,
set_room_meta,
set_selected_agent_id,
)
from core.store import SQLiteStore
@pytest.mark.asyncio
async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
db_path = tmp_path / "matrix.db"
store = SQLiteStore(str(db_path))
await set_selected_agent_id(store, "u1", "agent-2")
await set_room_meta(
store,
"!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
)
reopened = SQLiteStore(str(db_path))
assert await get_selected_agent_id(reopened, "u1") == "agent-2"
assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
@pytest.mark.asyncio
async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
db_path = tmp_path / "matrix.db"
store = SQLiteStore(str(db_path))
assert await next_platform_chat_id(store) == "1"
assert await next_platform_chat_id(store) == "2"
reopened = SQLiteStore(str(db_path))
assert await next_platform_chat_id(reopened) == "3"
```
- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
```python
# adapter/matrix/store.py
PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
async def next_platform_chat_id(store: StateStore) -> str:
async with _PLATFORM_CHAT_SEQ_LOCK:
data = await store.get(PLATFORM_CHAT_SEQ_KEY)
index = int((data or {}).get("next_platform_chat_index", 1))
await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
return str(index)
```
```markdown
# README.md
- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
- normal restart is supported only when those paths survive container recreation
- staged attachments and pending confirmations are intentionally not restored
```
- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
Expected: PASS
- [ ] **Step 5: Run the combined verification sweep**
Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
git commit -m "test: cover matrix restart state persistence"
```
---
## Self-Review
### Spec coverage
- Multi-agent agent registry: Task 1
- Shared `PlatformClient` preserved via routing facade: Task 2
- `!agent` UX and durable `selected_agent_id`: Task 3
- Unbound room activation, `!new`, stale room rejection: Task 4
- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
### Placeholder scan
- No `TODO`, `TBD`, or “implement later” markers remain.
- Each task includes exact file paths, tests, commands, and minimal code snippets.
### Type consistency
- `selected_agent_id` lives in user metadata throughout the plan.
- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.