From 0cc0e1b02d9767acdc664bf5244107b47fddb4c7 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:42:20 +0000 Subject: [PATCH 1/2] fix(migrations): retry backup reopen on bind mounts --- .../persistence/sqlite_migration_backup.py | 16 ++++++++-- .../test_sqlite_migration_backup.py | 31 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/persistence/sqlite_migration_backup.py b/src/langbot/pkg/persistence/sqlite_migration_backup.py index 5e1f7e683..9004b7fcc 100644 --- a/src/langbot/pkg/persistence/sqlite_migration_backup.py +++ b/src/langbot/pkg/persistence/sqlite_migration_backup.py @@ -12,6 +12,7 @@ import re import secrets import sqlite3 import tempfile +import time import typing from sqlalchemy.ext.asyncio import AsyncEngine @@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing. temporary_path.unlink(missing_ok=True) -def _fsync_file(path: pathlib.Path) -> None: - descriptor = os.open(path, os.O_RDONLY) +def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None: + """Sync a file, tolerating delayed visibility after replace on bind mounts.""" + + descriptor: int | None = None + for attempt in range(reopen_attempts): + try: + descriptor = os.open(path, os.O_RDONLY) + break + except FileNotFoundError: + if attempt + 1 >= reopen_attempts: + raise + time.sleep(0.05) + assert descriptor is not None try: os.fsync(descriptor) finally: diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py index dab50dd96..e808cb3fa 100644 --- a/tests/integration/persistence/test_sqlite_migration_backup.py +++ b/tests/integration/persistence/test_sqlite_migration_backup.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import os import pathlib import sqlite3 @@ -9,7 +10,7 @@ import pytest import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine -from langbot.pkg.persistence import alembic_runner +from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup from langbot.pkg.persistence.mgr import PersistenceManager from .resource_migration_support import create_legacy_resource_schema @@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision( assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() finally: await engine.dispose() + + +async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch): + database_path = tmp_path / 'legacy-bind-mount.db' + engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}') + real_open = os.open + transient_failures = 0 + + def transient_open(path, flags, *args, **kwargs): + nonlocal transient_failures + candidate = pathlib.Path(path) + if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0: + transient_failures += 1 + raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate)) + return real_open(path, flags, *args, **kwargs) + + try: + await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount') + await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs') + monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open) + + await _manager(engine)._run_alembic_migrations() + + assert transient_failures == 1 + assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() + assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2 + finally: + await engine.dispose() From 717bd4b8bfadbb508e68331611c11acd35385889 Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:01:02 +0000 Subject: [PATCH 2/2] fix(commands): pass trusted workspace scope to plugins --- src/langbot/pkg/command/cmdmgr.py | 10 ++++++++- tests/unit_tests/command/test_cmdmgr.py | 27 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py index 7560f7c43..31f28dfdf 100644 --- a/src/langbot/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -3,6 +3,7 @@ from __future__ import annotations import typing import inspect +from ..api.http.context import ExecutionContext from ..core import app from . import operator from ..utils import importutil @@ -66,7 +67,14 @@ class CommandManager: require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None) if require_context is not None: - result = require_context(context) + result = require_context( + ExecutionContext( + instance_uuid=context.instance_uuid, + workspace_uuid=context.workspace_uuid, + placement_generation=context.placement_generation, + query_uuid=context.query_uuid, + ) + ) if inspect.isawaitable(result): await result diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index ade27cf48..afc251a13 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock from langbot.pkg.command import operator from langbot.pkg.command.cmdmgr import CommandManager +from langbot.pkg.api.http.context import ExecutionContext from tests.factories import FakeApp, command_query import langbot_plugin.api.entities.builtin.provider.session as provider_session @@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute: assert len(results) == 1 assert results[0].text == 'plugin response' + @pytest.mark.asyncio + async def test_execute_selects_workspace_with_trusted_context(self): + """Plugin command discovery receives the typed runtime scope.""" + + fake_app = FakeApp() + mgr = CommandManager(fake_app) + mgr.cmd_list = [] + fake_app.plugin_connector.require_workspace_context = AsyncMock() + fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) + + ctx = self._create_context(command='help') + ctx.instance_uuid = 'instance-a' + ctx.workspace_uuid = 'workspace-a' + ctx.placement_generation = 4 + ctx.query_uuid = 'query-a' + + async for _ in mgr._execute(ctx, mgr.cmd_list): + pass + + selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0] + assert isinstance(selected, ExecutionContext) + assert selected.instance_uuid == 'instance-a' + assert selected.workspace_uuid == 'workspace-a' + assert selected.placement_generation == 4 + assert selected.query_uuid == 'query-a' + @pytest.mark.asyncio async def test_execute_with_bound_plugins(self): """_execute passes bound_plugins to plugin connector."""