feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)
Engine honours org policy and monthly budget, legacy runner skips engine tasks, sandbox session provisioned on demand, project fallback for threads, reviewer fallback, tenant checks on project ids, memory reflection respects grants, expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
36564e3f2b
commit
fef5a9deab
@@ -13,7 +13,8 @@ from typing import TYPE_CHECKING
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
|
||||
from app.domain.sandbox.models import SessionStatus
|
||||
from app.core.errors import AppError
|
||||
from app.domain.sandbox.models import SessionStatus, WorkspaceStatus
|
||||
from app.domain.workforce.models import Task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -87,11 +88,13 @@ def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
|
||||
class _EngineSandboxSessions:
|
||||
"""The sandbox session the engine's turns run in.
|
||||
|
||||
The engine does not *create* sandboxes: a workspace and a running session
|
||||
belong to the project and are started through the sandbox flow. The engine
|
||||
only reuses the active session of the task's project. When there is none it
|
||||
returns ``""``, and the tests gate treats that as red and sends the work
|
||||
back — never "merge untested because there was no container".
|
||||
The engine reuses the active session of the task's project. When there is
|
||||
none, it starts the project's workspace session itself (see ``_provision``):
|
||||
a fresh organization cannot start a sandbox through the API, because sandbox
|
||||
mutations are owner-only, and without a session every tool turn fails. When
|
||||
the session cannot be started it returns ``""``, and the tests gate treats
|
||||
that as red and sends the work back — never "merge untested because there
|
||||
was no container".
|
||||
"""
|
||||
|
||||
#: A session in one of these states can still run a command.
|
||||
@@ -107,6 +110,7 @@ class _EngineSandboxSessions:
|
||||
self.session = session
|
||||
|
||||
async def for_task(self, task: Task) -> str:
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxSessionRepository,
|
||||
WorkspaceRepository,
|
||||
@@ -114,9 +118,12 @@ class _EngineSandboxSessions:
|
||||
|
||||
if not task.project_id:
|
||||
return ""
|
||||
workspaces = await WorkspaceRepository(self.session).list_for_project(
|
||||
task.project_id
|
||||
)
|
||||
# A task only ever runs in its own organization's project, whatever the row says.
|
||||
own = await ProjectRepository(self.session).list_for_org(task.organization_id)
|
||||
if task.project_id not in {project.id for project in own}:
|
||||
return ""
|
||||
found = await WorkspaceRepository(self.session).list_for_project(task.project_id)
|
||||
workspaces = [w for w in found if w.organization_id == task.organization_id]
|
||||
sessions = SandboxSessionRepository(self.session)
|
||||
for workspace in workspaces:
|
||||
for sandbox_session in await sessions.list_for_workspace(workspace.id):
|
||||
@@ -125,7 +132,66 @@ class _EngineSandboxSessions:
|
||||
and sandbox_session.external_session_id
|
||||
):
|
||||
return sandbox_session.external_session_id
|
||||
return ""
|
||||
return await self._provision(task)
|
||||
|
||||
async def _provision(self, task: Task) -> str:
|
||||
"""Start the project's workspace session and return its id, or ``""``.
|
||||
|
||||
A project keeps one workspace: the newest one that is not destroyed is
|
||||
reused, and only a project with none gets a new workspace, built from the
|
||||
platform template for the configured provider. A failure is recorded and
|
||||
never raised, so the turn runs without a container and the gates say so.
|
||||
"""
|
||||
from app.application.container import build_sandbox_service
|
||||
from app.core.config import settings
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.sandbox import (
|
||||
SandboxTemplateRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
|
||||
project_id = task.project_id
|
||||
if not project_id:
|
||||
return ""
|
||||
metadata = {"task_id": task.id, "project_id": project_id}
|
||||
service = build_sandbox_service(self.session)
|
||||
try:
|
||||
found = await WorkspaceRepository(self.session).list_for_project(project_id)
|
||||
workspaces = [w for w in found if w.organization_id == task.organization_id]
|
||||
workspace = next(
|
||||
(w for w in workspaces if w.status != WorkspaceStatus.DESTROYED), None
|
||||
)
|
||||
if workspace is None:
|
||||
templates = await SandboxTemplateRepository(self.session).list()
|
||||
if not templates:
|
||||
await record_error(
|
||||
kind="engine.sandbox_provision_failed",
|
||||
message="no sandbox template is seeded",
|
||||
organization_id=task.organization_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ""
|
||||
preferred = [
|
||||
t for t in templates if str(t.provider) == settings.sandbox_provider
|
||||
]
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=task.organization_id,
|
||||
project_id=project_id,
|
||||
template_id=(preferred or templates)[0].id,
|
||||
)
|
||||
started = await service.start_session(
|
||||
organization_id=task.organization_id, workspace_id=workspace.id
|
||||
)
|
||||
except AppError as exc:
|
||||
await record_error(
|
||||
kind="engine.sandbox_provision_failed",
|
||||
message=type(exc).__name__,
|
||||
exc=exc,
|
||||
organization_id=task.organization_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ""
|
||||
return started.external_session_id or ""
|
||||
|
||||
|
||||
def _build_manager(
|
||||
|
||||
Reference in New Issue
Block a user