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:
mjalmoudy94
2026-10-14 20:57:56 +03:30
co-authored by Claude Sonnet 5.5
parent 36564e3f2b
commit fef5a9deab
43 changed files with 3024 additions and 221 deletions
+10 -2
View File
@@ -22,8 +22,13 @@ from app.application.container_delivery import (
build_deploy_target_tester,
build_repo_workspace,
)
from app.application.container_engine import build_workforce_bootstrap
from app.application.container_org import build_memory, build_model_grants
from app.application.container_engine import build_scheduler, build_workforce_bootstrap
from app.application.container_org import (
build_memory,
build_memory_service,
build_model_grant_service,
build_model_grants,
)
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
@@ -172,8 +177,11 @@ __all__ = [
"build_deploy_service",
"build_deploy_target_tester",
"build_memory",
"build_memory_service",
"build_model_grant_service",
"build_model_grants",
"build_repo_workspace",
"build_scheduler",
"build_workforce_bootstrap",
"build_knowledge_service",
"build_project_service",
+76 -10
View File
@@ -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(
+14 -2
View File
@@ -32,6 +32,9 @@ from app.infrastructure.repositories.org_grants import (
def build_memory(session: AsyncSession) -> MemoryPort:
"""The real memory service: recall, teach, and one reflection per task.
The reflection runs only on a model the organization was granted, so the
owner's grants decide which model learns on the organization's behalf.
``get_model_provider`` is resolved from ``container``'s namespace rather
than from the registry directly: that module is what the composition root
re-exports, so a deployment (or the test suite) that swaps the adapter in
@@ -39,15 +42,23 @@ def build_memory(session: AsyncSession) -> MemoryPort:
"""
from app.application.container import get_model_provider
return build_memory_service(session, provider=get_model_provider())
return build_memory_service(
session, provider=get_model_provider(), grants=build_model_grants(session)
)
def build_memory_service(
session: AsyncSession,
*,
provider=None,
grants: ModelGrantPort | None = None,
) -> MemoryService:
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
"""The same object, typed for the router (list, teach, pin, confirm, delete).
``grants`` is optional. The router never reflects, and a direct construction
without it keeps the routing-table path; the engine's path (``build_memory``)
always passes it.
"""
if provider is None:
from app.application.container import get_model_provider
@@ -60,6 +71,7 @@ def build_memory_service(
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
provider=provider,
grants=grants,
)
+20 -7
View File
@@ -302,11 +302,12 @@ class ManagerTurns:
raise ValidationError("این نقش قابل استخدام نیست.")
if not tier:
raise ValidationError("سطح کارمند را انتخاب کنید.")
# The owner's per-org policy may lower the global cap, never raise it.
policy = await self.grants.get_org_policy(organization_id)
cap = min(settings.engine_max_employees, policy.max_employees)
count = await self.employees.count_for_org(organization_id)
if count >= settings.engine_max_employees:
raise ConflictError(
f"سقف تعداد کارمندان ({settings.engine_max_employees}) پر شده است."
)
if count >= cap:
raise ConflictError(f"سقف تعداد کارمندان ({cap}) پر شده است.")
granted = await self.grants.granted_models(organization_id=organization_id)
# A hire is an explicit request for a tier: no silent downgrade, or the
# manager would think it hired a strong employee and got a fast one.
@@ -529,12 +530,14 @@ class ManagerTurns:
if draft.kind in {k.value for k in TaskKind}
else TaskKind.FEATURE
)
# A thread made by the web UI has no project; the org's project is the fallback.
project_id = thread.project_id or await self._project_id_for(organization_id)
task = await self.tasks.create(
Task(
organization_id=organization_id,
title=draft.title[:200],
description=draft.description,
project_id=thread.project_id,
project_id=project_id,
thread_id=thread.id,
created_by_user_id=user_id,
)
@@ -544,9 +547,11 @@ class ManagerTurns:
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=thread.project_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=draft.pipeline or None, task_kind=kind
manager_choice=draft.pipeline or None,
task_kind=kind,
has_active_reviewer=await self._has_active_reviewer(organization_id),
),
kind=kind,
risk=Risk.MEDIUM,
@@ -719,6 +724,7 @@ class ManagerTurns:
pipeline = resolve_pipeline(
manager_choice=assign.pipeline or None,
task_kind=state.kind if state else None,
has_active_reviewer=await self._has_active_reviewer(task.organization_id),
)
first_stage = (
Stage.PENDING_ANALYSIS
@@ -1067,6 +1073,13 @@ class ManagerTurns:
return employee.project_id
return None
async def _has_active_reviewer(self, organization_id: str) -> bool:
"""A review stage needs an active reviewer to act on it, or the task dead-ends."""
return any(
e.role_key is RoleKey.REVIEWER and e.is_available
for e in await self.employees.list_for_org(organization_id)
)
__all__ = [
"BOOTSTRAP_TIERS",
+48 -2
View File
@@ -31,6 +31,7 @@ from app.application.engine.runner import TurnRequest, TurnRunner
from app.application.engine.state import advance, park_dependents
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome
from app.application.ports.model_grants import OrgPolicyView
from app.application.ports.repo_workspace import RepoWorkspacePort, WorktreeRef
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings
@@ -165,6 +166,11 @@ class Scheduler:
turn_states = await self.states.list_at_stages(TURN_STAGES, limit=limit)
merge_states = await self.states.list_at_stages(MERGE_STAGES, limit=limit)
# One policy read per organization for the whole tick, shared by both lists.
policies: dict[str, OrgPolicyView] = {}
turn_states = await self._drop_switched_off(turn_states, policies, report)
merge_states = await self._drop_switched_off(merge_states, policies, report)
selected = self._select(turn_states)
report.selected = len(selected)
for state in selected:
@@ -174,6 +180,30 @@ class Scheduler:
await self._guarded(self._merge_step, state, report)
return report
async def _drop_switched_off(
self,
states: list[TaskPipelineState],
policies: dict[str, OrgPolicyView],
report: TickReport,
) -> list[TaskPipelineState]:
"""Keep only the states whose organization has automation switched on.
The owner's switch stops everything up to the merge. A skipped task keeps
its stage, so switching automation back on resumes it where it was.
"""
kept: list[TaskPipelineState] = []
for state in states:
if state.organization_id not in policies:
policies[state.organization_id] = await self.manager.grants.get_org_policy(
state.organization_id
)
if not policies[state.organization_id].automation_enabled:
report.skipped += 1
report.note(f"task {state.task_id}: اتوماسیون این سازمان خاموش است")
continue
kept.append(state)
return kept
async def _guarded(self, step, state: TaskPipelineState, report: TickReport) -> None:
"""One task must never stop the tick: catch, record, continue."""
try:
@@ -590,16 +620,32 @@ class Scheduler:
await self._post_note(task, reason)
async def _budget_reason(self, task: Task) -> str | None:
"""Why this task may not spend a turn now, or ``None`` when it may.
Usage is post-paid: the ledger feeds invoices and nothing debits a wallet
from usage. A zero balance is therefore the normal starting state of every
org, not "out of money", so only an overdrawn wallet (below zero) pauses
work. The owner's spend lever is the org's monthly credit budget.
"""
if self.wallets is not None:
wallet = await self.wallets.get_for_org(task.organization_id)
# No wallet means no budget was ever set: a fresh org must not be
# frozen out of its first task.
if wallet is not None and wallet.balance <= 0:
# frozen out of its first task. A zero balance is not a pause either.
if wallet is not None and wallet.balance < 0:
return BUDGET_PAUSED_MESSAGE
if self.spend is not None and settings.engine_max_task_credits > 0:
used = await self.spend.spend_for_task(task.id)
if used >= settings.engine_max_task_credits:
return "سقف اعتبار این کار پر شده است؛ کار متوقف شد."
if self.spend is not None:
policy = await self.manager.grants.get_org_policy(task.organization_id)
budget = policy.monthly_credit_budget
if budget is not None:
# The month is counted in UTC, the clock the ledger stamps rows with.
month_start = utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
spent = await self.spend.spend_for_org_since(task.organization_id, month_start)
if spent >= budget:
return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد."
return None
async def _retry_or_fail(
@@ -2,6 +2,10 @@
Implemented by the org-platform workstream; used by the engine. FROZEN contract:
see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names.
The contract was extended at integration: ``get_org_policy`` gives the engine the owner's
per-organization policy (automation switch, employee cap, monthly budget). The port only
reads it; the engine applies its own system ceiling (``settings.engine_max_employees``).
"""
from __future__ import annotations
@@ -27,12 +31,48 @@ def tier_for(*, capabilities: list[str] | tuple[str, ...], output_price_per_1m:
return "fast" if output_price_per_1m < 2.0 else "balanced"
class OrgPolicyView(Protocol):
"""Read-only view of one organization's owner policy.
Properties rather than attributes, so both a mutable dataclass (``ModelGrantService``'s
``PolicyView``) and a frozen one (``DefaultOrgPolicy``) satisfy it.
"""
@property
def max_employees(self) -> int: ...
@property
def automation_enabled(self) -> bool: ...
@property
def mcp_enabled(self) -> bool: ...
@property
def monthly_credit_budget(self) -> float | None: ...
@dataclass(slots=True, frozen=True, kw_only=True)
class DefaultOrgPolicy:
"""The policy for an organization the owner has not configured.
``max_employees`` is only the port's default. The engine caps it with
``settings.engine_max_employees`` itself, so this module does not import settings.
"""
max_employees: int = 20
automation_enabled: bool = True
mcp_enabled: bool = True
monthly_credit_budget: float | None = None
@runtime_checkable
class ModelGrantPort(Protocol):
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ...
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ...
async def get_org_policy(self, organization_id: str) -> OrgPolicyView: ...
class AllActiveModelsGrants:
"""Today's behaviour: every active model is usable by every organization.
@@ -48,3 +88,6 @@ class AllActiveModelsGrants:
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
return any(m.ai_model_id == ai_model_id for m in await self._list_active())
async def get_org_policy(self, organization_id: str) -> OrgPolicyView:
return DefaultOrgPolicy()
@@ -13,9 +13,10 @@ task — and this service owns three invariants around those calls:
whose key) already exists updates that entry instead of adding a duplicate,
which is also what lets "the port is 8001" correct "the port is 8000".
3. **A reflection can never break the engine.** ``on_task_finished`` asks one
cheap model for what the task taught. If the call fails, the error is
recorded and the function returns: the task is finished, and the org simply
learned nothing this time.
cheap model the organization is granted for what the task taught. If that
cannot happen (no grant, a failing call), the reason is recorded and the
function returns: the task is finished, and the org simply learned nothing
this time.
Nothing here returns a model name, a provider or an infrastructure detail — the
engine's contract is a block of text.
@@ -27,11 +28,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
from app.application.ports.memory import TaskOutcome
from app.application.ports.model_grants import GrantedModel, ModelGrantPort
from app.application.ports.model_provider import ModelResponse
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact_text
from app.domain.ai_control.models import RateUnit, RoutingMode
from app.domain.ai_control.models import AiModel, RateUnit, RoutingMode
from app.domain.base import utcnow
from app.domain.identity.models import ROLE_RANK, Role
from app.domain.memory.models import (
@@ -100,6 +102,7 @@ class MemoryService:
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
provider: ModelProviderPort,
grants: ModelGrantPort | None = None,
) -> None:
self.session = session
self.memories = memories
@@ -108,6 +111,7 @@ class MemoryService:
self.rate_cards = rate_cards
self.ledger = ledger
self.provider = provider
self.grants = grants
# ------------------------------------------------------------------ #
# MemoryPort #
@@ -371,16 +375,8 @@ class MemoryService:
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
"""One cheap completion about the finished task, priced in the ledger."""
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
model = await self._reflection_model(organization_id=organization_id, task=task)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return ""
pool = await self.credentials.list_active_for_provider(model.provider_id)
response = await self.provider.complete(
@@ -406,6 +402,49 @@ class MemoryService:
await self._record_usage(organization_id=organization_id, response=response)
return response.content or ""
async def _reflection_model(self, *, organization_id: str, task: TaskOutcome) -> AiModel | None:
"""The model one reflection runs on, or ``None`` after recording why not.
With a grant port, an organization reflects only on a model its owner
granted it, cheapest tier first. Without one (a direct construction), the
routing table's cheap mode decides, as it always has.
"""
if self.grants is None:
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return model
chosen = _preferred_grant(
await self.grants.granted_models(organization_id=organization_id)
)
if chosen is None:
# The owner granted this organization no model, so it learns nothing
# from this task. That is a policy outcome, and it is recorded as one.
await record_error(
kind="memory.reflection_no_grant",
message="no model is granted to this organization",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return None
model = await self.models.get(chosen.ai_model_id)
if model is None or not model.is_active:
await record_error(
kind="memory.reflection_model_unavailable",
message="the granted model is missing or inactive",
organization_id=organization_id,
metadata={"task_id": task.task_id, "ai_model_id": chosen.ai_model_id},
)
return None
return model
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
"""Price a reflection exactly like any other model call."""
for unit, quantity in (
@@ -464,6 +503,20 @@ def _kind_order(kind: MemoryKind) -> int:
return _KIND_ORDER.get(kind, 9)
#: The tiers a reflection prefers, cheapest first. A reflection is one short
#: completion; a strong model would charge more for the same two lessons.
_REFLECTION_TIERS = ("fast", "balanced")
def _preferred_grant(granted: list[GrantedModel]) -> GrantedModel | None:
"""The first granted model of the cheapest preferred tier, else the first one."""
for tier in _REFLECTION_TIERS:
for grant in granted:
if grant.tier == tier:
return grant
return granted[0] if granted else None
def _confidence_for(source: MemorySource, fallback: float) -> float:
"""Reflections get half confidence; a human's entry keeps its own."""
if source is MemorySource.REFLECTION:
@@ -42,6 +42,7 @@ from app.domain.projects.models import (
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
@@ -251,10 +252,9 @@ class OrgSetupService:
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
# The response is read before the hooks run: a failed hook rolls the session
# back, and that expires the project row this method still reads.
response = {
"organization": {
"id": org.id,
"name": org.name,
@@ -268,6 +268,10 @@ class OrgSetupService:
"deploy_target_id": deploy_target_id,
}
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return response
async def _create_single_app(
self,
org_id: str,
@@ -387,42 +391,50 @@ class OrgSetupService:
exists without its grants would be an organization whose AI staff cannot
think. An empty default set is legitimate (the owner granted nothing).
"""
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
from app.application.container_org import build_model_grant_service
service = ModelGrantService(
session=self.session,
grants=OrgModelGrantRepository(self.session),
defaults=DefaultModelGrantRepository(self.session),
policies=OrgPolicyRepository(self.session),
)
service = build_model_grant_service(self.session)
return await service.copy_defaults_into(
organization_id=organization_id, actor_user_id=user_id
)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
"""Run the post-commit hooks. A failing hook is recorded, never raised.
A successful hook is committed before the next one runs, so a later failure
cannot roll back earlier work. A failed hook is rolled back, which leaves the
session usable for the next hook and for the response.
"""
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
await self.session.commit()
except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
await record_error(
kind="wizard.bootstrap_failed",
message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
)
await self.session.rollback()
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
await self.session.commit()
except Exception as exc: # noqa: BLE001 - a hook must never fail the wizard
await record_error(
kind="wizard.on_app_created_failed",
message=type(exc).__name__,
exc=exc,
organization_id=organization_id,
metadata={"project_id": project_id},
)
await self.session.rollback()
# ------------------------------------------------------------------
# App profile editing (post-wizard)
@@ -74,6 +74,7 @@ from app.infrastructure.repositories.ai_control import (
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.projects import ProjectRepository
from app.infrastructure.repositories.workforce import (
AgentTemplateRepository,
ArtifactRepository,
@@ -321,6 +322,15 @@ class WorkforceService:
# Threads & messages #
# ------------------------------------------------------------------ #
async def _own_project_or_404(self, *, organization_id: str, project_id: str | None) -> None:
"""A project the caller names must belong to the caller's organization."""
if not project_id:
return
projects = await ProjectRepository(self.threads.session).list_for_org(organization_id)
if project_id not in {project.id for project in projects}:
# Not found rather than forbidden: another tenant's project is not confirmed to exist.
raise NotFoundError("پروژه یافت نشد.")
async def create_thread(
self,
*,
@@ -329,6 +339,7 @@ class WorkforceService:
title: str = "",
project_id: str | None = None,
) -> Thread:
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
return await self.threads.create(
Thread(
organization_id=organization_id,
@@ -381,15 +392,26 @@ class WorkforceService:
def _manager_turns(self):
"""The engine's manager service, wired on this service's session.
Ports that take a session (model grants, memory) are built here rather
than injected: ``container.py`` is a frozen composition root, and the
engine must not widen a constructor it does not own. Sharing the
session is the point — a reply, a task and its usage events belong to
one transaction.
Ports that take a session (model grants, memory, employee profiles,
pipeline states) are built here rather than injected: ``container.py`` is
a frozen composition root, and the engine must not widen a constructor it
does not own. Sharing the session is the point — a reply, a task, its
pipeline state and its usage events belong to one transaction.
The collaborators match ``container_engine._build_manager``. The runner is
left out on purpose: ``chat`` never runs a turn (only triage, final review
and error review do, and the scheduler's manager covers those). Without
``states`` a manager's CREATE_TASK makes a task the engine never owns, and
the legacy drain runs it instead.
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
session = self.tasks.session
return ManagerTurns(
provider=self.provider,
employees=self.employees,
@@ -400,8 +422,10 @@ class WorkforceService:
credentials=self.credentials,
rate_cards=self.rate_cards,
ledger=self.ledger,
grants=build_model_grants(self.tasks.session),
memory=build_memory(self.tasks.session),
grants=build_model_grants(session),
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
)
# ------------------------------------------------------------------ #
@@ -421,6 +445,7 @@ class WorkforceService:
) -> Task:
if not title.strip():
raise ValidationError("عنوان کار نمی‌تواند خالی باشد.")
await self._own_project_or_404(organization_id=organization_id, project_id=project_id)
if assigned_employee_id:
# Validates tenant ownership before we ever store the reference.
await self.get_employee_or_404(