Просмотр исходного кода

feat(script-build): fence ownership and candidate writes

SamLee 1 день назад
Родитель
Сommit
6d8234eb69

+ 70 - 5
script_build_host/src/script_build_host/application/mission_service.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import asyncio
 import asyncio
 from collections.abc import AsyncIterator
 from collections.abc import AsyncIterator
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
+from contextvars import ContextVar
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from typing import Any, Protocol
 from typing import Any, Protocol
 from uuid import uuid4
 from uuid import uuid4
@@ -48,6 +49,7 @@ from .mission_factory import ScriptMissionFactory
 
 
 PHASE_ONE_CAPABILITY_BOUNDARY = "PHASE_ONE_CAPABILITY_BOUNDARY"
 PHASE_ONE_CAPABILITY_BOUNDARY = "PHASE_ONE_CAPABILITY_BOUNDARY"
 PHASE_TWO_CANDIDATE_PORTFOLIO_READY = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
 PHASE_TWO_CANDIDATE_PORTFOLIO_READY = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
+_FENCED_BUILD_ID: ContextVar[int | None] = ContextVar("script_build_fenced_build", default=None)
 
 
 
 
 class BuildTransitionGate:
 class BuildTransitionGate:
@@ -78,6 +80,7 @@ class StartScriptBuildCommand:
     runtime_prompt_manifest: tuple[dict[str, Any], ...] = ()
     runtime_prompt_manifest: tuple[dict[str, Any], ...] = ()
     datasource_manifest: dict[str, Any] = field(default_factory=dict)
     datasource_manifest: dict[str, Any] = field(default_factory=dict)
     model_manifest: dict[str, Any] = field(default_factory=dict)
     model_manifest: dict[str, Any] = field(default_factory=dict)
+    root_trace_id: str | None = None
 
 
 
 
 @dataclass(frozen=True, slots=True)
 @dataclass(frozen=True, slots=True)
@@ -254,6 +257,8 @@ class ScriptMissionService:
         phase_two_required_presets: tuple[str, ...] = (),
         phase_two_required_presets: tuple[str, ...] = (),
         phase_two_boundary_verifier: PhaseTwoBoundaryVerifier | None = None,
         phase_two_boundary_verifier: PhaseTwoBoundaryVerifier | None = None,
         phase_two_model_manifest: dict[str, Any] | None = None,
         phase_two_model_manifest: dict[str, Any] | None = None,
+        owner_lease_factory: Any | None = None,
+        fenced_command_gate: Any | None = None,
     ) -> None:
     ) -> None:
         self.runner = runner
         self.runner = runner
         self.coordinator = coordinator
         self.coordinator = coordinator
@@ -273,14 +278,17 @@ class ScriptMissionService:
             else BuildTransitionGate()
             else BuildTransitionGate()
         )
         )
         self.runtime_manifest_provider = runtime_manifest_provider
         self.runtime_manifest_provider = runtime_manifest_provider
-        if enabled_phase not in {1, 2}:
-            raise ValueError("enabled_phase must be 1 or 2")
+        if enabled_phase not in {1, 2, 3}:
+            raise ValueError("enabled_phase must be 1, 2 or 3")
         self.enabled_phase = enabled_phase
         self.enabled_phase = enabled_phase
         self.phase_two_prompt_requests = phase_two_prompt_requests
         self.phase_two_prompt_requests = phase_two_prompt_requests
         self.phase_two_required_presets = frozenset(phase_two_required_presets)
         self.phase_two_required_presets = frozenset(phase_two_required_presets)
         self.phase_two_boundary_verifier = phase_two_boundary_verifier
         self.phase_two_boundary_verifier = phase_two_boundary_verifier
         self.phase_two_model_manifest = dict(phase_two_model_manifest or {})
         self.phase_two_model_manifest = dict(phase_two_model_manifest or {})
+        self.owner_lease_factory = owner_lease_factory
+        self.fenced_command_gate = fenced_command_gate
         self._runs: dict[int, asyncio.Task[None]] = {}
         self._runs: dict[int, asyncio.Task[None]] = {}
+        self._owner_tokens: dict[int, Any] = {}
         self._phase_advance_locks: dict[int, asyncio.Lock] = {}
         self._phase_advance_locks: dict[int, asyncio.Lock] = {}
 
 
     async def start(self, command: StartScriptBuildCommand) -> ScriptMissionStartResult:
     async def start(self, command: StartScriptBuildCommand) -> ScriptMissionStartResult:
@@ -306,6 +314,7 @@ class ScriptMissionService:
             raise ProtocolViolation("agent_config must remain an object after redaction")
             raise ProtocolViolation("agent_config must remain an object after redaction")
         if not isinstance(safe_strategies, dict):
         if not isinstance(safe_strategies, dict):
             raise ProtocolViolation("strategies_config must remain an object after redaction")
             raise ProtocolViolation("strategies_config must remain an object after redaction")
+        root_trace_id = command.root_trace_id or str(uuid4())
         script_build_id = await self.legacy_state.create(
         script_build_id = await self.legacy_state.create(
             execution_id=command.execution_id,
             execution_id=command.execution_id,
             topic_build_id=command.topic_build_id,
             topic_build_id=command.topic_build_id,
@@ -314,6 +323,7 @@ class ScriptMissionService:
             agent_config=safe_config,
             agent_config=safe_config,
             data_source_url=command.data_source_url,
             data_source_url=command.data_source_url,
             strategies_config=safe_strategies,
             strategies_config=safe_strategies,
+            root_trace_id=root_trace_id,
         )
         )
         try:
         try:
             runtime_datasources = (
             runtime_datasources = (
@@ -342,7 +352,6 @@ class ScriptMissionService:
             snapshot = await self.input_snapshots.freeze(assembled)
             snapshot = await self.input_snapshots.freeze(assembled)
             if self.enabled_phase >= 2:
             if self.enabled_phase >= 2:
                 self._require_phase_two_prompts(snapshot)
                 self._require_phase_two_prompts(snapshot)
-            root_trace_id = str(uuid4())
             binding = await self.bindings.create(
             binding = await self.bindings.create(
                 script_build_id=script_build_id,
                 script_build_id=script_build_id,
                 root_trace_id=root_trace_id,
                 root_trace_id=root_trace_id,
@@ -358,8 +367,13 @@ class ScriptMissionService:
                 error_summary=type(exc).__name__,
                 error_summary=type(exc).__name__,
             )
             )
             raise
             raise
+        lease = None
+        if self.owner_lease_factory is not None:
+            lease = self.owner_lease_factory(script_build_id)
+            token = await lease.acquire(script_build_id)
+            self._owner_tokens[script_build_id] = token
         task = asyncio.create_task(
         task = asyncio.create_task(
-            self.run(script_build_id), name=f"script-build:{script_build_id}"
+            self._run_owned(script_build_id, lease), name=f"script-build:{script_build_id}"
         )
         )
         self._runs[script_build_id] = task
         self._runs[script_build_id] = task
         task.add_done_callback(lambda done: self._discard_run(script_build_id, done))
         task.add_done_callback(lambda done: self._discard_run(script_build_id, done))
@@ -370,9 +384,31 @@ class ScriptMissionService:
             input_snapshot_id=snapshot.snapshot_id,
             input_snapshot_id=snapshot.snapshot_id,
         )
         )
 
 
+    async def start_result_by_root(self, root_trace_id: str) -> ScriptMissionStartResult:
+        binding = await self.bindings.get_by_root(root_trace_id)
+        return ScriptMissionStartResult(
+            script_build_id=binding.script_build_id,
+            status=await self.legacy_state.get_status(binding.script_build_id),
+            root_trace_id=binding.root_trace_id,
+            input_snapshot_id=str(binding.input_snapshot_id),
+        )
+
     async def run(self, script_build_id: int) -> None:
     async def run(self, script_build_id: int) -> None:
         await self.run_to_enabled_phase(script_build_id)
         await self.run_to_enabled_phase(script_build_id)
 
 
+    async def _run_owned(self, script_build_id: int, lease: Any | None) -> None:
+        try:
+            token = self._owner_tokens.get(script_build_id)
+            if token is not None and self.fenced_command_gate is not None:
+                await self.fenced_command_gate.verify(token)
+            await self.run(script_build_id)
+            if token is not None and self.fenced_command_gate is not None:
+                await self.fenced_command_gate.verify(token)
+        finally:
+            self._owner_tokens.pop(script_build_id, None)
+            if lease is not None:
+                await lease.release()
+
     async def run_to_enabled_phase(self, script_build_id: int) -> None:
     async def run_to_enabled_phase(self, script_build_id: int) -> None:
         await self.run_phase_one(script_build_id)
         await self.run_phase_one(script_build_id)
         if (
         if (
@@ -1087,7 +1123,9 @@ class ScriptMissionService:
                 return StopResult(script_build_id, BuildStatus.STOPPED)
                 return StopResult(script_build_id, BuildStatus.STOPPED)
             if status in {BuildStatus.FAILED, BuildStatus.SUCCESS}:
             if status in {BuildStatus.FAILED, BuildStatus.SUCCESS}:
                 return StopResult(script_build_id, status)
                 return StopResult(script_build_id, status)
-            if status is not BuildStatus.STOPPING:
+            if self.fenced_command_gate is not None:
+                await self.fenced_command_gate.request_stop(script_build_id)
+            elif status is not BuildStatus.STOPPING:
                 await self.legacy_state.set_status(script_build_id, BuildStatus.STOPPING)
                 await self.legacy_state.set_status(script_build_id, BuildStatus.STOPPING)
         try:
         try:
             binding = await self.bindings.get_by_build(script_build_id)
             binding = await self.bindings.get_by_build(script_build_id)
@@ -1149,10 +1187,37 @@ class ScriptMissionService:
         )
         )
 
 
     async def ensure_dispatch_allowed(self, script_build_id: int) -> None:
     async def ensure_dispatch_allowed(self, script_build_id: int) -> None:
+        if _FENCED_BUILD_ID.get() == script_build_id:
+            return
+        token = self._owner_tokens.get(script_build_id)
+        if token is not None and self.fenced_command_gate is not None:
+            await self.fenced_command_gate.verify(token)
         status = await self.legacy_state.get_status(script_build_id)
         status = await self.legacy_state.get_status(script_build_id)
         if status in {BuildStatus.STOPPING, BuildStatus.STOPPED}:
         if status in {BuildStatus.STOPPING, BuildStatus.STOPPED}:
             raise ProtocolViolation("new dispatch is forbidden after stop intent")
             raise ProtocolViolation("new dispatch is forbidden after stop intent")
 
 
+    async def execute_fenced(self, script_build_id: int, mutation: Any) -> Any:
+        token = self._owner_tokens.get(script_build_id)
+        if token is None or self.fenced_command_gate is None:
+            await self.ensure_dispatch_allowed(script_build_id)
+            return await mutation()
+
+        async def execute(_: Any) -> Any:
+            marker = _FENCED_BUILD_ID.set(script_build_id)
+            try:
+                return await mutation()
+            finally:
+                _FENCED_BUILD_ID.reset(marker)
+
+        return await self.fenced_command_gate.execute(token, execute)
+
+    def register_owner_token(self, token: Any) -> None:
+        self._owner_tokens[token.script_build_id] = token
+
+    def unregister_owner_token(self, token: Any) -> None:
+        if self._owner_tokens.get(token.script_build_id) == token:
+            self._owner_tokens.pop(token.script_build_id, None)
+
     async def _wait_stopped(
     async def _wait_stopped(
         self,
         self,
         root_trace_id: str,
         root_trace_id: str,

+ 16 - 1
script_build_host/src/script_build_host/infrastructure/config.py

@@ -30,6 +30,7 @@ class ScriptBuildSettings(BaseSettings):
     environment: str = "production"
     environment: str = "production"
     read_database_url: SecretStr = Field(repr=False)
     read_database_url: SecretStr = Field(repr=False)
     write_database_url: SecretStr = Field(repr=False)
     write_database_url: SecretStr = Field(repr=False)
+    final_database_url: SecretStr | None = Field(default=None, repr=False)
     agent_data_root: Path
     agent_data_root: Path
     persona_root: Path
     persona_root: Path
     section_pattern_root: Path
     section_pattern_root: Path
@@ -57,7 +58,7 @@ class ScriptBuildSettings(BaseSettings):
     max_image_bytes: int = Field(default=10_000_000, gt=0)
     max_image_bytes: int = Field(default=10_000_000, gt=0)
     max_total_image_bytes: int = Field(default=50_000_000, gt=0)
     max_total_image_bytes: int = Field(default=50_000_000, gt=0)
     enable_image_understanding: bool = False
     enable_image_understanding: bool = False
-    enabled_phase: int = Field(default=2, ge=1, le=2)
+    enabled_phase: int = Field(default=3, ge=1, le=3)
     max_upload_bytes: int = Field(default=5_000_000, gt=0)
     max_upload_bytes: int = Field(default=5_000_000, gt=0)
     max_upload_points: int = Field(default=500, gt=0, le=10_000)
     max_upload_points: int = Field(default=500, gt=0, le=10_000)
     max_upload_items: int = Field(default=5_000, gt=0, le=100_000)
     max_upload_items: int = Field(default=5_000, gt=0, le=100_000)
@@ -90,11 +91,20 @@ class ScriptBuildSettings(BaseSettings):
     def validate_database_separation(self) -> Self:
     def validate_database_separation(self) -> Self:
         read_url = make_url(self.read_database_url.get_secret_value())
         read_url = make_url(self.read_database_url.get_secret_value())
         write_url = make_url(self.write_database_url.get_secret_value())
         write_url = make_url(self.write_database_url.get_secret_value())
+        final_url = make_url(
+            self.final_database_url.get_secret_value()
+            if self.final_database_url is not None
+            else self.write_database_url.get_secret_value()
+        )
         if self.environment.lower() != "test":
         if self.environment.lower() != "test":
             if read_url.get_backend_name() == "sqlite" or write_url.get_backend_name() == "sqlite":
             if read_url.get_backend_name() == "sqlite" or write_url.get_backend_name() == "sqlite":
                 raise ValueError("SQLite database URLs are permitted only in the test environment")
                 raise ValueError("SQLite database URLs are permitted only in the test environment")
             if read_url.username == write_url.username:
             if read_url.username == write_url.username:
                 raise ValueError("production read and write database users must be different")
                 raise ValueError("production read and write database users must be different")
+            if self.final_database_url is None:
+                raise ValueError("production final publisher database URL is required")
+            if final_url.username in {read_url.username, write_url.username}:
+                raise ValueError("production final publisher database user must be dedicated")
         if self.max_total_image_bytes < self.max_image_bytes:
         if self.max_total_image_bytes < self.max_image_bytes:
             raise ValueError("max_total_image_bytes must not be smaller than max_image_bytes")
             raise ValueError("max_total_image_bytes must not be smaller than max_image_bytes")
         return self
         return self
@@ -105,6 +115,11 @@ class ScriptBuildSettings(BaseSettings):
     def write_dsn(self) -> str:
     def write_dsn(self) -> str:
         return self.write_database_url.get_secret_value()
         return self.write_database_url.get_secret_value()
 
 
+    def final_dsn(self) -> str:
+        if self.final_database_url is None:
+            return self.write_dsn()
+        return self.final_database_url.get_secret_value()
+
     def phase_two_limits(self) -> PhaseTwoLimits:
     def phase_two_limits(self) -> PhaseTwoLimits:
         return PhaseTwoLimits(
         return PhaseTwoLimits(
             max_tasks=self.phase_two_max_tasks,
             max_tasks=self.phase_two_max_tasks,

+ 16 - 2
script_build_host/src/script_build_host/infrastructure/db.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
+from sqlalchemy.engine import make_url
 from sqlalchemy.ext.asyncio import (
 from sqlalchemy.ext.asyncio import (
     AsyncEngine,
     AsyncEngine,
     AsyncSession,
     AsyncSession,
@@ -16,21 +17,34 @@ from .config import ScriptBuildSettings
 class DatabaseSessions:
 class DatabaseSessions:
     read_engine: AsyncEngine
     read_engine: AsyncEngine
     write_engine: AsyncEngine
     write_engine: AsyncEngine
+    final_engine: AsyncEngine
     read: async_sessionmaker[AsyncSession]
     read: async_sessionmaker[AsyncSession]
     write: async_sessionmaker[AsyncSession]
     write: async_sessionmaker[AsyncSession]
+    final: async_sessionmaker[AsyncSession]
 
 
     async def dispose(self) -> None:
     async def dispose(self) -> None:
         await self.read_engine.dispose()
         await self.read_engine.dispose()
         if self.write_engine is not self.read_engine:
         if self.write_engine is not self.read_engine:
             await self.write_engine.dispose()
             await self.write_engine.dispose()
+        if self.final_engine not in {self.read_engine, self.write_engine}:
+            await self.final_engine.dispose()
 
 
 
 
 def create_database_sessions(settings: ScriptBuildSettings) -> DatabaseSessions:
 def create_database_sessions(settings: ScriptBuildSettings) -> DatabaseSessions:
-    read_engine = create_async_engine(settings.read_dsn(), pool_pre_ping=True)
-    write_engine = create_async_engine(settings.write_dsn(), pool_pre_ping=True)
+    read_engine = _create_engine(settings.read_dsn())
+    write_engine = _create_engine(settings.write_dsn())
+    final_engine = _create_engine(settings.final_dsn())
     return DatabaseSessions(
     return DatabaseSessions(
         read_engine=read_engine,
         read_engine=read_engine,
         write_engine=write_engine,
         write_engine=write_engine,
+        final_engine=final_engine,
         read=async_sessionmaker(read_engine, expire_on_commit=False),
         read=async_sessionmaker(read_engine, expire_on_commit=False),
         write=async_sessionmaker(write_engine, expire_on_commit=False),
         write=async_sessionmaker(write_engine, expire_on_commit=False),
+        final=async_sessionmaker(final_engine, expire_on_commit=False),
     )
     )
+
+
+def _create_engine(url: str) -> AsyncEngine:
+    if make_url(url).get_backend_name() == "mysql":
+        return create_async_engine(url, pool_pre_ping=True, isolation_level="READ COMMITTED")
+    return create_async_engine(url, pool_pre_ping=True)

+ 250 - 0
script_build_host/src/script_build_host/infrastructure/ownership.py

@@ -0,0 +1,250 @@
+"""Single-host mission ownership and database fencing.
+
+The file descriptor lock remains held for the lifetime of an owner.  Database
+epochs make every later command independently reject a stale process.
+"""
+
+from __future__ import annotations
+
+import fcntl
+import os
+from collections.abc import Awaitable, Callable
+from contextlib import AbstractAsyncContextManager
+from datetime import UTC, datetime
+from pathlib import Path
+from types import TracebackType
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from script_build_host.domain.errors import (
+    BuildNotFound,
+    MissionAlreadyOwned,
+    MissionFencingTokenStale,
+    MissionStopRequested,
+)
+from script_build_host.domain.records import BuildStatus, MissionOwnerToken
+from script_build_host.infrastructure.legacy_tables import (
+    script_build_record,
+    script_build_runtime_record,
+)
+from script_build_host.infrastructure.tables import mission_binding_table
+
+
+class OwnerLease(AbstractAsyncContextManager[MissionOwnerToken]):
+    def __init__(
+        self,
+        sessions: async_sessionmaker[AsyncSession],
+        lock_root: Path,
+        *,
+        owner_instance_id: str | None = None,
+    ) -> None:
+        self._sessions = sessions
+        self._lock_root = lock_root.resolve()
+        self._owner_instance_id = owner_instance_id or str(uuid4())
+        self._fd: int | None = None
+        self._token: MissionOwnerToken | None = None
+
+    async def acquire(self, script_build_id: int) -> MissionOwnerToken:
+        if self._fd is not None:
+            if self._token is None or self._token.script_build_id != script_build_id:
+                raise MissionAlreadyOwned()
+            return self._token
+        self._lock_root.mkdir(parents=True, exist_ok=True)
+        path = self._lock_root / f"script-build-{script_build_id}.owner.lock"
+        fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
+        try:
+            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        except BlockingIOError as exc:
+            os.close(fd)
+            raise MissionAlreadyOwned() from exc
+        try:
+            async with self._sessions() as session, session.begin():
+                row = (
+                    (
+                        await session.execute(
+                            select(mission_binding_table)
+                            .where(mission_binding_table.c.script_build_id == script_build_id)
+                            .with_for_update()
+                        )
+                    )
+                    .mappings()
+                    .one_or_none()
+                )
+                if row is None:
+                    raise BuildNotFound()
+                epoch = int(row["owner_epoch"] or 0) + 1
+                now = datetime.now(UTC)
+                await session.execute(
+                    update(mission_binding_table)
+                    .where(mission_binding_table.c.id == row["id"])
+                    .values(
+                        owner_epoch=epoch,
+                        owner_instance_id=self._owner_instance_id,
+                        owner_acquired_at=now,
+                        updated_at=now,
+                    )
+                )
+                token = MissionOwnerToken(
+                    script_build_id=script_build_id,
+                    root_trace_id=str(row["root_trace_id"]),
+                    owner_epoch=epoch,
+                    stop_epoch=int(row["stop_epoch"] or 0),
+                    owner_instance_id=self._owner_instance_id,
+                )
+        except BaseException:
+            fcntl.flock(fd, fcntl.LOCK_UN)
+            os.close(fd)
+            raise
+        self._fd = fd
+        self._token = token
+        return token
+
+    async def release(self) -> None:
+        fd, token = self._fd, self._token
+        if fd is None:
+            return
+        try:
+            if token is not None:
+                async with self._sessions() as session, session.begin():
+                    await session.execute(
+                        update(mission_binding_table)
+                        .where(
+                            mission_binding_table.c.script_build_id == token.script_build_id,
+                            mission_binding_table.c.owner_epoch == token.owner_epoch,
+                            mission_binding_table.c.owner_instance_id == token.owner_instance_id,
+                        )
+                        .values(
+                            owner_instance_id=None,
+                            owner_acquired_at=None,
+                            updated_at=datetime.now(UTC),
+                        )
+                    )
+        finally:
+            fcntl.flock(fd, fcntl.LOCK_UN)
+            os.close(fd)
+            self._fd = None
+            self._token = None
+
+    async def __aenter__(self) -> MissionOwnerToken:
+        if self._token is None:
+            raise RuntimeError("OwnerLease.acquire() must be called before entering the lease")
+        return self._token
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc: BaseException | None,
+        traceback: TracebackType | None,
+    ) -> None:
+        await self.release()
+
+
+class FencedCommandGate:
+    """Validate one owner token while holding binding/build row locks."""
+
+    def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
+        self._sessions = sessions
+        bind = sessions.kw.get("bind")
+        self._runtime_record = (
+            script_build_runtime_record
+            if getattr(getattr(bind, "dialect", None), "name", None) == "mysql"
+            else script_build_record
+        )
+
+    async def verify(self, token: MissionOwnerToken) -> None:
+        async with self._sessions() as session, session.begin():
+            await self.verify_in_session(session, token)
+
+    async def execute(
+        self,
+        token: MissionOwnerToken,
+        mutation: Callable[[AsyncSession], Awaitable[Any]],
+    ) -> Any:
+        async with self._sessions() as session, session.begin():
+            await self.verify_in_session(session, token)
+            return await mutation(session)
+
+    async def verify_in_session(
+        self,
+        session: AsyncSession,
+        token: MissionOwnerToken,
+        *,
+        allow_success: bool = False,
+    ) -> None:
+        binding = (
+            (
+                await session.execute(
+                    select(mission_binding_table)
+                    .where(mission_binding_table.c.script_build_id == token.script_build_id)
+                    .with_for_update()
+                )
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if binding is None:
+            raise BuildNotFound()
+        if (
+            str(binding["root_trace_id"]) != token.root_trace_id
+            or int(binding["owner_epoch"] or 0) != token.owner_epoch
+            or binding["owner_instance_id"] != token.owner_instance_id
+        ):
+            raise MissionFencingTokenStale()
+        if int(binding["stop_epoch"] or 0) != token.stop_epoch:
+            raise MissionStopRequested()
+        status = await session.scalar(
+            select(script_build_record.c.status)
+            .where(script_build_record.c.id == token.script_build_id)
+            .with_for_update()
+        )
+        if status is None:
+            raise BuildNotFound()
+        if status in {BuildStatus.STOPPING.value, BuildStatus.STOPPED.value}:
+            raise MissionStopRequested()
+        if status == BuildStatus.SUCCESS.value and not allow_success:
+            raise MissionFencingTokenStale()
+
+    async def request_stop(self, script_build_id: int) -> int:
+        """Persist stop intent without acquiring the process OwnerLease."""
+
+        async with self._sessions() as session, session.begin():
+            binding = (
+                (
+                    await session.execute(
+                        select(mission_binding_table)
+                        .where(mission_binding_table.c.script_build_id == script_build_id)
+                        .with_for_update()
+                    )
+                )
+                .mappings()
+                .one_or_none()
+            )
+            if binding is None:
+                raise BuildNotFound()
+            status = await session.scalar(
+                select(script_build_record.c.status)
+                .where(script_build_record.c.id == script_build_id)
+                .with_for_update()
+            )
+            if status is None:
+                raise BuildNotFound()
+            if status in {BuildStatus.SUCCESS.value, BuildStatus.STOPPED.value}:
+                return int(binding["stop_epoch"] or 0)
+            stop_epoch = int(binding["stop_epoch"] or 0) + 1
+            await session.execute(
+                update(mission_binding_table)
+                .where(mission_binding_table.c.id == binding["id"])
+                .values(stop_epoch=stop_epoch, updated_at=datetime.now(UTC))
+            )
+            await session.execute(
+                update(self._runtime_record)
+                .where(self._runtime_record.c.id == script_build_id)
+                .values(status=BuildStatus.STOPPING.value, end_time=None)
+            )
+            return stop_epoch
+
+
+__all__ = ["FencedCommandGate", "OwnerLease"]

+ 29 - 17
script_build_host/src/script_build_host/repositories/legacy_state.py

@@ -1,20 +1,30 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from datetime import UTC, datetime
 from datetime import UTC, datetime
-from typing import Any, cast
+from typing import Any
+from uuid import uuid4
 
 
-from sqlalchemy import CursorResult, insert, select, update
+from sqlalchemy import insert, select, update
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 
 
 from script_build_host.domain.errors import BuildNotFound, ProtocolViolation
 from script_build_host.domain.errors import BuildNotFound, ProtocolViolation
 from script_build_host.domain.records import BuildStatus
 from script_build_host.domain.records import BuildStatus
-from script_build_host.infrastructure.legacy_tables import script_build_record
+from script_build_host.infrastructure.legacy_tables import (
+    script_build_record,
+    script_build_runtime_record,
+)
 from script_build_host.infrastructure.redaction import redact_text
 from script_build_host.infrastructure.redaction import redact_text
 
 
 
 
 class SqlAlchemyLegacyBuildStateRepository:
 class SqlAlchemyLegacyBuildStateRepository:
     def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
     def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None:
         self._sessions = sessions
         self._sessions = sessions
+        bind = sessions.kw.get("bind")
+        self._runtime_record = (
+            script_build_runtime_record
+            if getattr(getattr(bind, "dialect", None), "name", None) == "mysql"
+            else script_build_record
+        )
 
 
     async def create(
     async def create(
         self,
         self,
@@ -26,10 +36,11 @@ class SqlAlchemyLegacyBuildStateRepository:
         agent_config: dict[str, Any] | None,
         agent_config: dict[str, Any] | None,
         data_source_url: str | None,
         data_source_url: str | None,
         strategies_config: dict[str, Any],
         strategies_config: dict[str, Any],
+        root_trace_id: str | None = None,
     ) -> int:
     ) -> int:
         async with self._sessions() as session, session.begin():
         async with self._sessions() as session, session.begin():
             result = await session.execute(
             result = await session.execute(
-                insert(script_build_record).values(
+                insert(self._runtime_record).values(
                     execution_id=execution_id,
                     execution_id=execution_id,
                     topic_build_id=topic_build_id,
                     topic_build_id=topic_build_id,
                     topic_id=topic_id,
                     topic_id=topic_id,
@@ -37,13 +48,14 @@ class SqlAlchemyLegacyBuildStateRepository:
                     agent_config=agent_config,
                     agent_config=agent_config,
                     data_source_url=data_source_url,
                     data_source_url=data_source_url,
                     strategies_config=strategies_config,
                     strategies_config=strategies_config,
+                    reson_trace_id=root_trace_id or str(uuid4()),
                     status=BuildStatus.RUNNING.value,
                     status=BuildStatus.RUNNING.value,
                     is_deleted=False,
                     is_deleted=False,
                     is_favorited=False,
                     is_favorited=False,
                     start_time=datetime.now(UTC),
                     start_time=datetime.now(UTC),
                 )
                 )
             )
             )
-            primary_key = cast(CursorResult[Any], result).inserted_primary_key
+            primary_key = result.inserted_primary_key
             if primary_key is None or primary_key[0] is None:
             if primary_key is None or primary_key[0] is None:
                 raise BuildNotFound()
                 raise BuildNotFound()
             return int(primary_key[0])
             return int(primary_key[0])
@@ -66,14 +78,14 @@ class SqlAlchemyLegacyBuildStateRepository:
             values["end_time"] = datetime.now(UTC)
             values["end_time"] = datetime.now(UTC)
         async with self._sessions() as session, session.begin():
         async with self._sessions() as session, session.begin():
             result = await session.execute(
             result = await session.execute(
-                update(script_build_record)
+                update(self._runtime_record)
                 .where(
                 .where(
-                    script_build_record.c.id == script_build_id,
-                    script_build_record.c.is_deleted.is_(False),
+                    self._runtime_record.c.id == script_build_id,
+                    self._runtime_record.c.is_deleted.is_(False),
                 )
                 )
                 .values(**values)
                 .values(**values)
             )
             )
-            if cast(CursorResult[Any], result).rowcount != 1:
+            if result.rowcount != 1:
                 raise BuildNotFound()
                 raise BuildNotFound()
 
 
     async def get_status(self, script_build_id: int) -> BuildStatus:
     async def get_status(self, script_build_id: int) -> BuildStatus:
@@ -91,14 +103,14 @@ class SqlAlchemyLegacyBuildStateRepository:
     async def project_direction(self, script_build_id: int, legacy_markdown: str) -> None:
     async def project_direction(self, script_build_id: int, legacy_markdown: str) -> None:
         async with self._sessions() as session, session.begin():
         async with self._sessions() as session, session.begin():
             result = await session.execute(
             result = await session.execute(
-                update(script_build_record)
+                update(self._runtime_record)
                 .where(
                 .where(
-                    script_build_record.c.id == script_build_id,
-                    script_build_record.c.is_deleted.is_(False),
+                    self._runtime_record.c.id == script_build_id,
+                    self._runtime_record.c.is_deleted.is_(False),
                 )
                 )
                 .values(script_direction=legacy_markdown)
                 .values(script_direction=legacy_markdown)
             )
             )
-            if cast(CursorResult[Any], result).rowcount != 1:
+            if result.rowcount != 1:
                 raise BuildNotFound()
                 raise BuildNotFound()
 
 
     async def get_direction(self, script_build_id: int) -> str | None:
     async def get_direction(self, script_build_id: int) -> str | None:
@@ -128,14 +140,14 @@ class SqlAlchemyLegacyBuildStateRepository:
         }
         }
         async with self._sessions() as session, session.begin():
         async with self._sessions() as session, session.begin():
             result = await session.execute(
             result = await session.execute(
-                update(script_build_record)
+                update(self._runtime_record)
                 .where(
                 .where(
-                    script_build_record.c.id == script_build_id,
-                    script_build_record.c.is_deleted.is_(False),
+                    self._runtime_record.c.id == script_build_id,
+                    self._runtime_record.c.is_deleted.is_(False),
                 )
                 )
                 .values(**values)
                 .values(**values)
             )
             )
-            if cast(CursorResult[Any], result).rowcount != 1:
+            if result.rowcount != 1:
                 raise BuildNotFound()
                 raise BuildNotFound()
 
 
     async def begin_phase(self, script_build_id: int) -> None:
     async def begin_phase(self, script_build_id: int) -> None:

+ 92 - 91
script_build_host/src/script_build_host/repositories/workspace.py

@@ -36,6 +36,9 @@ from script_build_host.domain.workspaces import (
 )
 )
 from script_build_host.infrastructure.canonical_json import canonical_sha256, normalize_json
 from script_build_host.infrastructure.canonical_json import canonical_sha256, normalize_json
 from script_build_host.infrastructure.legacy_tables import (
 from script_build_host.infrastructure.legacy_tables import (
+    script_build_candidate_element,
+    script_build_candidate_paragraph,
+    script_build_candidate_paragraph_element,
     script_build_element,
     script_build_element,
     script_build_paragraph,
     script_build_paragraph,
     script_build_paragraph_element,
     script_build_paragraph_element,
@@ -71,6 +74,15 @@ class SqlAlchemyCandidateWorkspaceRepository:
     ) -> None:
     ) -> None:
         self._sessions = sessions
         self._sessions = sessions
         self._artifacts = artifacts
         self._artifacts = artifacts
+        bind = sessions.kw.get("bind")
+        use_views = getattr(getattr(bind, "dialect", None), "name", None) == "mysql"
+        self._paragraphs = script_build_candidate_paragraph if use_views else script_build_paragraph
+        self._elements = script_build_candidate_element if use_views else script_build_element
+        self._links = (
+            script_build_candidate_paragraph_element
+            if use_views
+            else script_build_paragraph_element
+        )
 
 
     async def get_or_create(
     async def get_or_create(
         self,
         self,
@@ -214,7 +226,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
                         "LEGACY_REFERENCE_INVALID", "child content range exceeds its parent"
                         "LEGACY_REFERENCE_INVALID", "child content range exceeds its parent"
                     )
                     )
             result = await session.execute(
             result = await session.execute(
-                insert(script_build_paragraph).values(
+                insert(self._paragraphs).values(
                     script_build_id=workspace.script_build_id,
                     script_build_id=workspace.script_build_id,
                     branch_id=workspace.branch_id,
                     branch_id=workspace.branch_id,
                     base_ref_id=None,
                     base_ref_id=None,
@@ -356,11 +368,11 @@ class SqlAlchemyCandidateWorkspaceRepository:
                         )
                         )
                 if values.get("is_active") is False:
                 if values.get("is_active") is False:
                     active_child = await session.scalar(
                     active_child = await session.scalar(
-                        select(script_build_paragraph.c.id).where(
-                            script_build_paragraph.c.script_build_id == workspace.script_build_id,
-                            script_build_paragraph.c.branch_id == workspace.branch_id,
-                            script_build_paragraph.c.parent_id == paragraph_id,
-                            script_build_paragraph.c.is_active.is_(True),
+                        select(self._paragraphs.c.id).where(
+                            self._paragraphs.c.script_build_id == workspace.script_build_id,
+                            self._paragraphs.c.branch_id == workspace.branch_id,
+                            self._paragraphs.c.parent_id == paragraph_id,
+                            self._paragraphs.c.is_active.is_(True),
                         )
                         )
                     )
                     )
                     if active_child is not None:
                     if active_child is not None:
@@ -374,21 +386,20 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 prepared.append((paragraph_id, values))
                 prepared.append((paragraph_id, values))
             for paragraph_id, values in prepared:
             for paragraph_id, values in prepared:
                 await session.execute(
                 await session.execute(
-                    update(script_build_paragraph)
+                    update(self._paragraphs)
                     .where(
                     .where(
-                        script_build_paragraph.c.id == paragraph_id,
-                        script_build_paragraph.c.script_build_id == workspace.script_build_id,
-                        script_build_paragraph.c.branch_id == workspace.branch_id,
+                        self._paragraphs.c.id == paragraph_id,
+                        self._paragraphs.c.script_build_id == workspace.script_build_id,
+                        self._paragraphs.c.branch_id == workspace.branch_id,
                     )
                     )
                     .values(**values)
                     .values(**values)
                 )
                 )
                 if values.get("is_active") is False:
                 if values.get("is_active") is False:
                     await session.execute(
                     await session.execute(
-                        delete(script_build_paragraph_element).where(
-                            script_build_paragraph_element.c.script_build_id
-                            == workspace.script_build_id,
-                            script_build_paragraph_element.c.branch_id == workspace.branch_id,
-                            script_build_paragraph_element.c.paragraph_id == paragraph_id,
+                        delete(self._links).where(
+                            self._links.c.script_build_id == workspace.script_build_id,
+                            self._links.c.branch_id == workspace.branch_id,
+                            self._links.c.paragraph_id == paragraph_id,
                         )
                         )
                     )
                     )
             return tuple(item[0] for item in prepared)
             return tuple(item[0] for item in prepared)
@@ -419,8 +430,8 @@ class SqlAlchemyCandidateWorkspaceRepository:
             dimensions = {str(item.get("维度", "")) for item in existing}
             dimensions = {str(item.get("维度", "")) for item in existing}
             added = [item for item in normalized if item["维度"] not in dimensions]
             added = [item for item in normalized if item["维度"] not in dimensions]
             await session.execute(
             await session.execute(
-                update(script_build_paragraph)
-                .where(script_build_paragraph.c.id == paragraph_id)
+                update(self._paragraphs)
+                .where(self._paragraphs.c.id == paragraph_id)
                 .values(**{column: [*existing, *added], "updated_at": _now()})
                 .values(**{column: [*existing, *added], "updated_at": _now()})
             )
             )
             return len(added)
             return len(added)
@@ -460,8 +471,8 @@ class SqlAlchemyCandidateWorkspaceRepository:
             if deleted_count == 0:
             if deleted_count == 0:
                 raise WorkspaceError("LEGACY_REFERENCE_INVALID", "paragraph atom does not exist")
                 raise WorkspaceError("LEGACY_REFERENCE_INVALID", "paragraph atom does not exist")
             await session.execute(
             await session.execute(
-                update(script_build_paragraph)
-                .where(script_build_paragraph.c.id == paragraph_id)
+                update(self._paragraphs)
+                .where(self._paragraphs.c.id == paragraph_id)
                 .values(**{column: kept, "updated_at": _now()})
                 .values(**{column: kept, "updated_at": _now()})
             )
             )
             return deleted_count
             return deleted_count
@@ -487,13 +498,12 @@ class SqlAlchemyCandidateWorkspaceRepository:
             existing = (
             existing = (
                 (
                 (
                     await session.execute(
                     await session.execute(
-                        select(script_build_element.c.id).where(
-                            script_build_element.c.script_build_id == workspace.script_build_id,
-                            script_build_element.c.branch_id == workspace.branch_id,
-                            script_build_element.c.name == name.strip(),
-                            script_build_element.c.dimension_primary == dimension_primary,
-                            script_build_element.c.dimension_secondary
-                            == dimension_secondary.strip(),
+                        select(self._elements.c.id).where(
+                            self._elements.c.script_build_id == workspace.script_build_id,
+                            self._elements.c.branch_id == workspace.branch_id,
+                            self._elements.c.name == name.strip(),
+                            self._elements.c.dimension_primary == dimension_primary,
+                            self._elements.c.dimension_secondary == dimension_secondary.strip(),
                         )
                         )
                     )
                     )
                 )
                 )
@@ -503,7 +513,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
             if existing is not None:
             if existing is not None:
                 return int(existing)
                 return int(existing)
             result = await session.execute(
             result = await session.execute(
-                insert(script_build_element).values(
+                insert(self._elements).values(
                     script_build_id=workspace.script_build_id,
                     script_build_id=workspace.script_build_id,
                     branch_id=workspace.branch_id,
                     branch_id=workspace.branch_id,
                     base_ref_id=None,
                     base_ref_id=None,
@@ -582,21 +592,20 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 raise WorkspaceError("LEGACY_WRITE_INVALID", "element is_active must be boolean")
                 raise WorkspaceError("LEGACY_WRITE_INVALID", "element is_active must be boolean")
             normalized["updated_at"] = _now()
             normalized["updated_at"] = _now()
             await session.execute(
             await session.execute(
-                update(script_build_element)
+                update(self._elements)
                 .where(
                 .where(
-                    script_build_element.c.id == element_id,
-                    script_build_element.c.script_build_id == workspace.script_build_id,
-                    script_build_element.c.branch_id == workspace.branch_id,
+                    self._elements.c.id == element_id,
+                    self._elements.c.script_build_id == workspace.script_build_id,
+                    self._elements.c.branch_id == workspace.branch_id,
                 )
                 )
                 .values(**normalized)
                 .values(**normalized)
             )
             )
             if normalized.get("is_active") is False:
             if normalized.get("is_active") is False:
                 await session.execute(
                 await session.execute(
-                    delete(script_build_paragraph_element).where(
-                        script_build_paragraph_element.c.script_build_id
-                        == workspace.script_build_id,
-                        script_build_paragraph_element.c.branch_id == workspace.branch_id,
-                        script_build_paragraph_element.c.element_id == element_id,
+                    delete(self._links).where(
+                        self._links.c.script_build_id == workspace.script_build_id,
+                        self._links.c.branch_id == workspace.branch_id,
+                        self._links.c.element_id == element_id,
                     )
                     )
                 )
                 )
 
 
@@ -630,12 +639,11 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 exists = (
                 exists = (
                     (
                     (
                         await session.execute(
                         await session.execute(
-                            select(script_build_paragraph_element.c.id).where(
-                                script_build_paragraph_element.c.script_build_id
-                                == workspace.script_build_id,
-                                script_build_paragraph_element.c.branch_id == workspace.branch_id,
-                                script_build_paragraph_element.c.paragraph_id == paragraph_id,
-                                script_build_paragraph_element.c.element_id == element_id,
+                            select(self._links.c.id).where(
+                                self._links.c.script_build_id == workspace.script_build_id,
+                                self._links.c.branch_id == workspace.branch_id,
+                                self._links.c.paragraph_id == paragraph_id,
+                                self._links.c.element_id == element_id,
                             )
                             )
                         )
                         )
                     )
                     )
@@ -644,7 +652,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 )
                 )
                 if exists is None:
                 if exists is None:
                     await session.execute(
                     await session.execute(
-                        insert(script_build_paragraph_element).values(
+                        insert(self._links).values(
                             script_build_id=workspace.script_build_id,
                             script_build_id=workspace.script_build_id,
                             branch_id=workspace.branch_id,
                             branch_id=workspace.branch_id,
                             paragraph_id=paragraph_id,
                             paragraph_id=paragraph_id,
@@ -681,10 +689,9 @@ class SqlAlchemyCandidateWorkspaceRepository:
             rows = (
             rows = (
                 (
                 (
                     await session.execute(
                     await session.execute(
-                        select(script_build_paragraph_element).where(
-                            script_build_paragraph_element.c.script_build_id
-                            == workspace.script_build_id,
-                            script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                        select(self._links).where(
+                            self._links.c.script_build_id == workspace.script_build_id,
+                            self._links.c.branch_id == workspace.branch_id,
                         )
                         )
                     )
                     )
                 )
                 )
@@ -699,11 +706,10 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 for item in (
                 for item in (
                     (
                     (
                         await session.execute(
                         await session.execute(
-                            select(script_build_paragraph.c.id).where(
-                                script_build_paragraph.c.script_build_id
-                                == workspace.script_build_id,
-                                script_build_paragraph.c.branch_id == workspace.branch_id,
-                                script_build_paragraph.c.is_active.is_(True),
+                            select(self._paragraphs.c.id).where(
+                                self._paragraphs.c.script_build_id == workspace.script_build_id,
+                                self._paragraphs.c.branch_id == workspace.branch_id,
+                                self._paragraphs.c.is_active.is_(True),
                             )
                             )
                         )
                         )
                     )
                     )
@@ -714,10 +720,10 @@ class SqlAlchemyCandidateWorkspaceRepository:
             active_elements = set(
             active_elements = set(
                 (
                 (
                     await session.execute(
                     await session.execute(
-                        select(script_build_element.c.id).where(
-                            script_build_element.c.script_build_id == workspace.script_build_id,
-                            script_build_element.c.branch_id == workspace.branch_id,
-                            script_build_element.c.is_active.is_(True),
+                        select(self._elements.c.id).where(
+                            self._elements.c.script_build_id == workspace.script_build_id,
+                            self._elements.c.branch_id == workspace.branch_id,
+                            self._elements.c.is_active.is_(True),
                         )
                         )
                     )
                     )
                 ).scalars()
                 ).scalars()
@@ -737,11 +743,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
                 )
                 )
             ]
             ]
             if delete_ids:
             if delete_ids:
-                await session.execute(
-                    delete(script_build_paragraph_element).where(
-                        script_build_paragraph_element.c.id.in_(delete_ids)
-                    )
-                )
+                await session.execute(delete(self._links).where(self._links.c.id.in_(delete_ids)))
             return len(delete_ids)
             return len(delete_ids)
 
 
     async def freeze(
     async def freeze(
@@ -984,41 +986,41 @@ class SqlAlchemyCandidateWorkspaceRepository:
             )
             )
         return workspace
         return workspace
 
 
-    @staticmethod
     async def _paragraph(
     async def _paragraph(
+        self,
         session: AsyncSession,
         session: AsyncSession,
         workspace: CandidateWorkspace,
         workspace: CandidateWorkspace,
         paragraph_id: int,
         paragraph_id: int,
         *,
         *,
         active: bool | None = None,
         active: bool | None = None,
     ) -> Mapping[str, Any] | None:
     ) -> Mapping[str, Any] | None:
-        statement = select(script_build_paragraph).where(
-            script_build_paragraph.c.id == paragraph_id,
-            script_build_paragraph.c.script_build_id == workspace.script_build_id,
-            script_build_paragraph.c.branch_id == workspace.branch_id,
+        statement = select(self._paragraphs).where(
+            self._paragraphs.c.id == paragraph_id,
+            self._paragraphs.c.script_build_id == workspace.script_build_id,
+            self._paragraphs.c.branch_id == workspace.branch_id,
         )
         )
         if active is not None:
         if active is not None:
-            statement = statement.where(script_build_paragraph.c.is_active.is_(active))
+            statement = statement.where(self._paragraphs.c.is_active.is_(active))
         return cast(
         return cast(
             Mapping[str, Any] | None,
             Mapping[str, Any] | None,
             (await session.execute(statement)).mappings().one_or_none(),
             (await session.execute(statement)).mappings().one_or_none(),
         )
         )
 
 
-    @staticmethod
     async def _element(
     async def _element(
+        self,
         session: AsyncSession,
         session: AsyncSession,
         workspace: CandidateWorkspace,
         workspace: CandidateWorkspace,
         element_id: int,
         element_id: int,
         *,
         *,
         active: bool | None = None,
         active: bool | None = None,
     ) -> Mapping[str, Any] | None:
     ) -> Mapping[str, Any] | None:
-        statement = select(script_build_element).where(
-            script_build_element.c.id == element_id,
-            script_build_element.c.script_build_id == workspace.script_build_id,
-            script_build_element.c.branch_id == workspace.branch_id,
+        statement = select(self._elements).where(
+            self._elements.c.id == element_id,
+            self._elements.c.script_build_id == workspace.script_build_id,
+            self._elements.c.branch_id == workspace.branch_id,
         )
         )
         if active is not None:
         if active is not None:
-            statement = statement.where(script_build_element.c.is_active.is_(active))
+            statement = statement.where(self._elements.c.is_active.is_(active))
         return cast(
         return cast(
             Mapping[str, Any] | None,
             Mapping[str, Any] | None,
             (await session.execute(statement)).mappings().one_or_none(),
             (await session.execute(statement)).mappings().one_or_none(),
@@ -1030,14 +1032,14 @@ class SqlAlchemyCandidateWorkspaceRepository:
         paragraph_rows = (
         paragraph_rows = (
             (
             (
                 await session.execute(
                 await session.execute(
-                    select(script_build_paragraph)
+                    select(self._paragraphs)
                     .where(
                     .where(
-                        script_build_paragraph.c.script_build_id == workspace.script_build_id,
-                        script_build_paragraph.c.branch_id == workspace.branch_id,
+                        self._paragraphs.c.script_build_id == workspace.script_build_id,
+                        self._paragraphs.c.branch_id == workspace.branch_id,
                     )
                     )
                     .order_by(
                     .order_by(
-                        script_build_paragraph.c.paragraph_index,
-                        script_build_paragraph.c.id,
+                        self._paragraphs.c.paragraph_index,
+                        self._paragraphs.c.id,
                     )
                     )
                 )
                 )
             )
             )
@@ -1047,12 +1049,12 @@ class SqlAlchemyCandidateWorkspaceRepository:
         element_rows = (
         element_rows = (
             (
             (
                 await session.execute(
                 await session.execute(
-                    select(script_build_element)
+                    select(self._elements)
                     .where(
                     .where(
-                        script_build_element.c.script_build_id == workspace.script_build_id,
-                        script_build_element.c.branch_id == workspace.branch_id,
+                        self._elements.c.script_build_id == workspace.script_build_id,
+                        self._elements.c.branch_id == workspace.branch_id,
                     )
                     )
-                    .order_by(script_build_element.c.id)
+                    .order_by(self._elements.c.id)
                 )
                 )
             )
             )
             .mappings()
             .mappings()
@@ -1061,15 +1063,14 @@ class SqlAlchemyCandidateWorkspaceRepository:
         link_rows = (
         link_rows = (
             (
             (
                 await session.execute(
                 await session.execute(
-                    select(script_build_paragraph_element)
+                    select(self._links)
                     .where(
                     .where(
-                        script_build_paragraph_element.c.script_build_id
-                        == workspace.script_build_id,
-                        script_build_paragraph_element.c.branch_id == workspace.branch_id,
+                        self._links.c.script_build_id == workspace.script_build_id,
+                        self._links.c.branch_id == workspace.branch_id,
                     )
                     )
                     .order_by(
                     .order_by(
-                        script_build_paragraph_element.c.paragraph_id,
-                        script_build_paragraph_element.c.element_id,
+                        self._links.c.paragraph_id,
+                        self._links.c.element_id,
                     )
                     )
                 )
                 )
             )
             )
@@ -1164,7 +1165,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
         for item in sorted(paragraphs, key=lambda value: (value.level, value.paragraph_index)):
         for item in sorted(paragraphs, key=lambda value: (value.level, value.paragraph_index)):
             parent_id = paragraph_map.get(item.parent_id) if item.parent_id is not None else None
             parent_id = paragraph_map.get(item.parent_id) if item.parent_id is not None else None
             result = await session.execute(
             result = await session.execute(
-                insert(script_build_paragraph).values(
+                insert(self._paragraphs).values(
                     script_build_id=workspace.script_build_id,
                     script_build_id=workspace.script_build_id,
                     branch_id=workspace.branch_id,
                     branch_id=workspace.branch_id,
                     base_ref_id=item.paragraph_id,
                     base_ref_id=item.paragraph_id,
@@ -1192,7 +1193,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
         element_map: dict[int, int] = {}
         element_map: dict[int, int] = {}
         for item in elements:
         for item in elements:
             result = await session.execute(
             result = await session.execute(
-                insert(script_build_element).values(
+                insert(self._elements).values(
                     script_build_id=workspace.script_build_id,
                     script_build_id=workspace.script_build_id,
                     branch_id=workspace.branch_id,
                     branch_id=workspace.branch_id,
                     base_ref_id=item.element_id,
                     base_ref_id=item.element_id,
@@ -1214,7 +1215,7 @@ class SqlAlchemyCandidateWorkspaceRepository:
             element_id = element_map.get(item.element_id)
             element_id = element_map.get(item.element_id)
             if paragraph_id is not None and element_id is not None:
             if paragraph_id is not None and element_id is not None:
                 await session.execute(
                 await session.execute(
-                    insert(script_build_paragraph_element).values(
+                    insert(self._links).values(
                         script_build_id=workspace.script_build_id,
                         script_build_id=workspace.script_build_id,
                         branch_id=workspace.branch_id,
                         branch_id=workspace.branch_id,
                         paragraph_id=paragraph_id,
                         paragraph_id=paragraph_id,