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>
1093 lines
42 KiB
Python
1093 lines
42 KiB
Python
"""The manager AI: orders become tasks, tasks become team work.
|
|
|
|
This is the layer that turns a user's sentence into the engine's machinery, and
|
|
it is deliberately the *only* place that interprets the manager's tags:
|
|
|
|
* **3.1 chat** — the user talks to the manager; the manager replies and may
|
|
emit ``CREATE_TASK`` (≤ 3), ``PAUSE_TASK`` / ``RESUME_TASK`` / ``CANCEL_TASK``
|
|
and ``HIRE`` / ``UPDATE_EMPLOYEE`` / ``FIRE``. Tasks get a pipeline state at
|
|
``pending_manager_review`` and are linked to the thread. The prompt asks for
|
|
**atomic tasks that do not touch the same files**, and the chat's model calls
|
|
are billed like every other call.
|
|
* **3.2 team** — hiring picks a *granted* model for the requested neutral tier,
|
|
respects the employee cap and the "last manager" rule, and publishes
|
|
``team.changed``. Users choosing from the UI give a role and a tier only;
|
|
model identity never crosses the API.
|
|
* **3.3 triage** — ``ASSIGN`` (pipeline + assignee), ``REJECT`` or ``ASK_USER``.
|
|
Assignee selection defaults to the **least busy** active employee with the
|
|
role, and a name is matched fuzzily **inside the org only**.
|
|
* **3.4 final / error** — ``COMPLETE`` → ``merge_request``, ``REVISE`` → back to
|
|
the developer with ``cycle + 1``; ``RESOLVE_ERROR`` retries with a hint,
|
|
``CANCEL`` ends the task.
|
|
* **3.5 knowledge** — ``KNOWLEDGE`` tags are recorded through ``MemoryPort``.
|
|
* **3.6 bootstrap** — a new org gets manager + planner + developer + tester on
|
|
the strongest / balanced / fast granted tiers; with nothing granted the
|
|
employees are created inactive and a system message says so. Idempotent.
|
|
|
|
Every query here is tenant-scoped: the manager of org A can never assign an
|
|
employee of org B, even by exact name.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from app.application.engine.events import emit_team_changed
|
|
from app.application.engine.prompt import TurnContext, TurnMessage, build_turn_prompt
|
|
from app.application.engine.protocol import (
|
|
AssignTag,
|
|
ParsedReply,
|
|
TagName,
|
|
assign_of,
|
|
knowledge_items,
|
|
parse_reply,
|
|
task_drafts,
|
|
team_changes,
|
|
)
|
|
from app.application.engine.runner import (
|
|
BillingScope,
|
|
TurnRequest,
|
|
TurnResult,
|
|
TurnRunner,
|
|
call_model,
|
|
classify_error,
|
|
)
|
|
from app.application.engine.state import advance
|
|
from app.application.ports import ModelProviderPort
|
|
from app.application.ports.memory import MemoryPort, NullMemory
|
|
from app.application.ports.model_grants import ModelGrantPort
|
|
from app.application.ports.realtime import emit_task_created
|
|
from app.core.config import settings
|
|
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
|
from app.domain.workforce.engine import EmployeeProfile, ErrorKind
|
|
from app.domain.workforce.models import (
|
|
Employee,
|
|
EmployeeStatus,
|
|
Message,
|
|
MessageRole,
|
|
RoleKey,
|
|
Task,
|
|
TaskStatus,
|
|
Thread,
|
|
)
|
|
from app.domain.workforce.pipeline import (
|
|
Mode,
|
|
PipelineKey,
|
|
Risk,
|
|
Stage,
|
|
TaskKind,
|
|
TaskPipelineState,
|
|
coarse_status,
|
|
resolve_pipeline,
|
|
)
|
|
from app.infrastructure.repositories.ai_control import (
|
|
AiModelRepository,
|
|
ModelCredentialRepository,
|
|
RateCardRepository,
|
|
)
|
|
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
|
from app.infrastructure.repositories.engine import (
|
|
EmployeeProfileRepository,
|
|
PipelineStateRepository,
|
|
StaleStageError,
|
|
)
|
|
from app.infrastructure.repositories.workforce import (
|
|
EmployeeRepository,
|
|
MessageRepository,
|
|
TaskRepository,
|
|
ThreadRepository,
|
|
)
|
|
|
|
#: How long a chat turn may take. Chat is interactive, so it is much shorter
|
|
#: than a task turn's budget.
|
|
CHAT_TURN_TIMEOUT_SECONDS = 120
|
|
#: How many times a chat reply may be corrected for protocol errors.
|
|
CHAT_MAX_ATTEMPTS = 2
|
|
|
|
#: The message a new organization sees when the owner has granted nothing yet.
|
|
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه"
|
|
|
|
#: Bootstrap tiers per role, in the plan's order (strongest / balanced / fast).
|
|
BOOTSTRAP_TIERS: dict[RoleKey, str] = {
|
|
RoleKey.MANAGER: "strong",
|
|
RoleKey.PLANNER: "balanced",
|
|
RoleKey.DEVELOPER: "balanced",
|
|
RoleKey.TESTER: "fast",
|
|
}
|
|
#: When a tier is not granted, fall back down this ladder rather than failing.
|
|
_TIER_FALLBACK: dict[str, tuple[str, ...]] = {
|
|
"strong": ("strong", "balanced", "fast"),
|
|
"balanced": ("balanced", "fast"),
|
|
"fast": ("fast", "balanced"),
|
|
}
|
|
|
|
#: Roles a manager may hire through chat.
|
|
HIRABLE_ROLES: tuple[RoleKey, ...] = (
|
|
RoleKey.MANAGER,
|
|
RoleKey.PLANNER,
|
|
RoleKey.DEVELOPER,
|
|
RoleKey.REVIEWER,
|
|
RoleKey.TESTER,
|
|
)
|
|
|
|
#: Persian labels, so a prompt can show a human-readable roster.
|
|
_ROLE_LABELS: dict[RoleKey, str] = {
|
|
RoleKey.MANAGER: "مدیر",
|
|
RoleKey.PLANNER: "برنامهریز",
|
|
RoleKey.DEVELOPER: "برنامهنویس",
|
|
RoleKey.REVIEWER: "بازبین",
|
|
RoleKey.TESTER: "آزمونگر",
|
|
RoleKey.RESEARCHER: "پژوهشگر",
|
|
RoleKey.CONTENT_CREATOR: "تولیدکنندهٔ محتوا",
|
|
RoleKey.SUPPORT: "پشتیبان",
|
|
}
|
|
|
|
#: The system thread's title, where "no model granted" style notes live.
|
|
SYSTEM_THREAD_TITLE = "پیامهای سامانه"
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class ManagerOutcome:
|
|
"""What a manager turn decided, in the engine's own words."""
|
|
|
|
acted: bool
|
|
decision: str = "idle"
|
|
stage: Stage | None = None
|
|
message: str = ""
|
|
employee_id: str | None = None
|
|
pipeline: PipelineKey | None = None
|
|
#: The runner's error vocabulary, so the scheduler can tell a transient
|
|
#: provider failure from a permanent one without re-classifying a string.
|
|
error_kind: str = ""
|
|
|
|
|
|
def _fuzzy_match(name: str, candidates: list[str]) -> str | None:
|
|
"""Fuzzy name matching **inside one organization**.
|
|
|
|
Exact match first, then a case-insensitive containment either way. Ties are
|
|
resolved by the caller's ordering (least busy first), never by luck.
|
|
"""
|
|
wanted = name.strip().lower()
|
|
if not wanted:
|
|
return None
|
|
for candidate in candidates:
|
|
if candidate.lower() == wanted:
|
|
return candidate
|
|
for candidate in candidates:
|
|
if wanted in candidate.lower():
|
|
return candidate
|
|
for candidate in candidates:
|
|
if candidate.lower() in wanted:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _pick_granted_model(granted, tier: str, *, fallback: bool = True) -> str | None:
|
|
"""The granted model for a tier.
|
|
|
|
``fallback=False`` is the strict mode an explicit hire uses: the tier the
|
|
caller asked for, or nothing. The default is the bootstrap's "whatever is
|
|
granted" behaviour, where a degraded team beats no team.
|
|
"""
|
|
for candidate in _TIER_FALLBACK.get(tier, (tier,)) if fallback else (tier,):
|
|
for model in granted:
|
|
if model.tier == candidate:
|
|
return model.ai_model_id
|
|
return None
|
|
|
|
|
|
def _bootstrap_name(role: RoleKey) -> str:
|
|
return f"{_ROLE_LABELS.get(role, role.value)} یک"
|
|
|
|
|
|
class ManagerTurns:
|
|
"""The manager's voice: chat, triage, final verdict, error handling, team."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
provider: ModelProviderPort,
|
|
employees: EmployeeRepository,
|
|
messages: MessageRepository,
|
|
tasks: TaskRepository,
|
|
threads: ThreadRepository,
|
|
models: AiModelRepository,
|
|
credentials: ModelCredentialRepository,
|
|
rate_cards: RateCardRepository,
|
|
ledger: UsageLedgerRepository,
|
|
grants: ModelGrantPort,
|
|
memory: MemoryPort | None = None,
|
|
profiles: EmployeeProfileRepository | None = None,
|
|
states: PipelineStateRepository | None = None,
|
|
runner: TurnRunner | None = None,
|
|
feed=None,
|
|
) -> None:
|
|
self.provider = provider
|
|
self.employees = employees
|
|
self.messages = messages
|
|
self.tasks = tasks
|
|
self.threads = threads
|
|
self.models = models
|
|
self.credentials = credentials
|
|
self.rate_cards = rate_cards
|
|
self.ledger = ledger
|
|
self.grants = grants
|
|
self.memory = memory or NullMemory()
|
|
self.profiles = profiles
|
|
self.states = states
|
|
self.runner = runner
|
|
self.feed = feed
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 3.6 bootstrap #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None:
|
|
"""Create the starting team of a new organization. Idempotent.
|
|
|
|
With no granted model the employees still exist — inactive — so the
|
|
organization sees its team and the reason it cannot work yet, rather
|
|
than an empty page and a mystery.
|
|
"""
|
|
if await self.employees.list_for_org(organization_id, limit=1):
|
|
return
|
|
granted = await self.grants.granted_models(organization_id=organization_id)
|
|
for role in BOOTSTRAP_TIERS:
|
|
model_id = _pick_granted_model(granted, BOOTSTRAP_TIERS[role])
|
|
employee = await self.employees.create(
|
|
Employee(
|
|
organization_id=organization_id,
|
|
name=_bootstrap_name(role),
|
|
role_key=role,
|
|
project_id=project_id,
|
|
status=(
|
|
EmployeeStatus.ACTIVE if model_id else EmployeeStatus.PAUSED
|
|
),
|
|
)
|
|
)
|
|
if self.profiles is not None:
|
|
await self.profiles.upsert(
|
|
EmployeeProfile(
|
|
employee_id=employee.id,
|
|
ai_model_id=model_id,
|
|
roles=[role.value],
|
|
hired_by="manager",
|
|
)
|
|
)
|
|
if not granted:
|
|
await self._post_system_message(
|
|
organization_id=organization_id,
|
|
project_id=project_id,
|
|
content=NO_MODEL_GRANTED_MESSAGE,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 3.2 team management #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def hire(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
role: RoleKey,
|
|
tier: str,
|
|
name: str = "",
|
|
project_id: str | None = None,
|
|
hired_by: str = "manager",
|
|
) -> Employee:
|
|
"""Hire one employee on a granted model of the requested tier."""
|
|
if role not in HIRABLE_ROLES:
|
|
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 >= 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.
|
|
model_id = _pick_granted_model(granted, tier, fallback=False)
|
|
if model_id is None:
|
|
raise ConflictError("مدلی از این سطح به سازمان شما اختصاص داده نشده است.")
|
|
employee = await self.employees.create(
|
|
Employee(
|
|
organization_id=organization_id,
|
|
name=(name or _bootstrap_name(role)).strip(),
|
|
role_key=role,
|
|
project_id=project_id or await self._project_id_for(organization_id),
|
|
)
|
|
)
|
|
if self.profiles is not None:
|
|
await self.profiles.upsert(
|
|
EmployeeProfile(
|
|
employee_id=employee.id,
|
|
ai_model_id=model_id,
|
|
roles=[role.value],
|
|
hired_by=hired_by,
|
|
)
|
|
)
|
|
await emit_team_changed(
|
|
organization_id=organization_id, change="hired", employee_id=employee.id
|
|
)
|
|
return employee
|
|
|
|
async def fire(self, *, organization_id: str, employee_id: str) -> Employee:
|
|
"""Archive one employee. The last manager may not be fired."""
|
|
employee = await self.employees.get_for_org(organization_id, employee_id)
|
|
if employee is None:
|
|
raise NotFoundError("کارمند یافت نشد.")
|
|
if employee.role_key is RoleKey.MANAGER:
|
|
managers = [
|
|
e
|
|
for e in await self.employees.list_for_org(organization_id)
|
|
if e.role_key is RoleKey.MANAGER and e.status is EmployeeStatus.ACTIVE
|
|
]
|
|
if len(managers) <= 1:
|
|
raise ConflictError("آخرین مدیر سازمان را نمیتوان حذف کرد.")
|
|
employee.status = EmployeeStatus.ARCHIVED
|
|
updated = await self.employees.update(employee)
|
|
if self.profiles is not None:
|
|
await self.profiles.set_busy(employee_id=employee_id, task_id=None)
|
|
await emit_team_changed(
|
|
organization_id=organization_id, change="fired", employee_id=employee_id
|
|
)
|
|
return updated
|
|
|
|
async def update_tier(
|
|
self, *, organization_id: str, employee_id: str, tier: str
|
|
) -> Employee:
|
|
"""Re-point an employee at a granted model of another tier."""
|
|
employee = await self.employees.get_for_org(organization_id, employee_id)
|
|
if employee is None:
|
|
raise NotFoundError("کارمند یافت نشد.")
|
|
granted = await self.grants.granted_models(organization_id=organization_id)
|
|
model_id = _pick_granted_model(granted, tier, fallback=False)
|
|
if model_id is None:
|
|
raise ConflictError("مدلی از این سطح به سازمان شما اختصاص داده نشده است.")
|
|
if self.profiles is not None:
|
|
await self.profiles.set_model(employee_id=employee_id, ai_model_id=model_id)
|
|
await emit_team_changed(
|
|
organization_id=organization_id, change="updated", employee_id=employee_id
|
|
)
|
|
return employee
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 3.1 manager chat #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def chat(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
thread_id: str,
|
|
content: str,
|
|
user_id: str,
|
|
employee_id: str | None = None,
|
|
) -> tuple[Message, Message]:
|
|
"""The manager hears the order, answers it, and may act on it.
|
|
|
|
The reply the user reads is the prose *outside* the tags; the tags are
|
|
executed beside it. That separation is what lets one message create a
|
|
task and still read like a conversation.
|
|
"""
|
|
thread = await self.threads.get_for_org(organization_id, thread_id)
|
|
if thread is None:
|
|
raise NotFoundError("گفتگو یافت نشد.")
|
|
if not content.strip():
|
|
raise ValidationError("متن پیام نمیتواند خالی باشد.")
|
|
|
|
user_message = await self.messages.create(
|
|
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
|
|
)
|
|
employee = await self._manager_of(organization_id, employee_id)
|
|
extra = "\n\n".join(
|
|
part
|
|
for part in (
|
|
await self._roster_text(organization_id),
|
|
await self._tasks_text(organization_id),
|
|
await self._conversation_text(thread),
|
|
)
|
|
if part
|
|
)
|
|
|
|
reply_text = ""
|
|
parsed: ParsedReply | None = None
|
|
deadline = time.monotonic() + CHAT_TURN_TIMEOUT_SECONDS
|
|
context = self._chat_context(
|
|
organization_id=organization_id,
|
|
thread=thread,
|
|
content=content,
|
|
extra=extra,
|
|
history=[],
|
|
)
|
|
for _attempt in range(CHAT_MAX_ATTEMPTS):
|
|
model = await self._model_for(employee, organization_id)
|
|
if model is None:
|
|
await self._system_note(
|
|
thread, "مدیری با مدل فعلاً در دسترس نیست."
|
|
)
|
|
break
|
|
try:
|
|
response = await call_model(
|
|
provider=self.provider,
|
|
credentials=self.credentials,
|
|
rate_cards=self.rate_cards,
|
|
ledger=self.ledger,
|
|
model=model,
|
|
prompt=build_turn_prompt(context),
|
|
scope=BillingScope(
|
|
organization_id=organization_id,
|
|
project_id=thread.project_id,
|
|
employee_id=employee.id if employee else None,
|
|
user_id=user_id,
|
|
),
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - chat must always answer
|
|
await self._system_note(
|
|
thread, f"مدیر موقتاً پاسخ نمیدهد ({classify_error(exc).value})."
|
|
)
|
|
break
|
|
reply_text = response.content
|
|
parsed = parse_reply(
|
|
response.content, role=RoleKey.MANAGER, mode=Mode.CHAT
|
|
)
|
|
if not parsed.errors or parsed.tags:
|
|
break
|
|
# A second chance, with the protocol errors quoted back to it.
|
|
context = self._chat_context(
|
|
organization_id=organization_id,
|
|
thread=thread,
|
|
content=content,
|
|
extra=extra,
|
|
history=[TurnMessage(kind="result", text="\n".join(parsed.errors))],
|
|
)
|
|
if time.monotonic() > deadline:
|
|
break
|
|
|
|
actions: list[str] = []
|
|
if parsed is not None and parsed.tags:
|
|
actions = await self._dispatch_chat_tags(
|
|
parsed=parsed,
|
|
organization_id=organization_id,
|
|
thread=thread,
|
|
user_id=user_id,
|
|
)
|
|
await self._record_knowledge(parsed, organization_id=organization_id)
|
|
|
|
body = (parsed.prose if parsed else "") or reply_text
|
|
if actions:
|
|
body = f"{body}\n\n" + "\n".join(f"• {action}" for action in actions)
|
|
assistant_message = await self.messages.create(
|
|
Message(
|
|
thread_id=thread.id,
|
|
role=MessageRole.ASSISTANT,
|
|
content=body.strip() or "دستور دریافت شد.",
|
|
employee_id=employee.id if employee else None,
|
|
)
|
|
)
|
|
return user_message, assistant_message
|
|
|
|
def _chat_context(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
thread: Thread,
|
|
content: str,
|
|
extra: str,
|
|
history: list[TurnMessage],
|
|
) -> TurnContext:
|
|
return TurnContext(
|
|
organization_id=organization_id,
|
|
task_id=thread.id,
|
|
project_id=thread.project_id,
|
|
task_title="گفتگو با مدیر سازمان",
|
|
task_description=content,
|
|
stage=Stage.PENDING_MANAGER_REVIEW,
|
|
role=RoleKey.MANAGER,
|
|
mode=Mode.CHAT,
|
|
stage_context=extra,
|
|
history=history,
|
|
)
|
|
|
|
async def _dispatch_chat_tags(
|
|
self,
|
|
*,
|
|
parsed: ParsedReply,
|
|
organization_id: str,
|
|
thread: Thread,
|
|
user_id: str,
|
|
) -> list[str]:
|
|
"""Execute the chat's tags, returning one Persian line per action."""
|
|
actions: list[str] = []
|
|
for draft in task_drafts(parsed):
|
|
kind = (
|
|
TaskKind(draft.kind)
|
|
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=project_id,
|
|
thread_id=thread.id,
|
|
created_by_user_id=user_id,
|
|
)
|
|
)
|
|
if self.states is not None:
|
|
await self.states.create(
|
|
TaskPipelineState(
|
|
task_id=task.id,
|
|
organization_id=organization_id,
|
|
project_id=project_id,
|
|
pipeline=resolve_pipeline(
|
|
manager_choice=draft.pipeline or None,
|
|
task_kind=kind,
|
|
has_active_reviewer=await self._has_active_reviewer(organization_id),
|
|
),
|
|
kind=kind,
|
|
risk=Risk.MEDIUM,
|
|
stage=Stage.PENDING_MANAGER_REVIEW,
|
|
depends_on=list(draft.depends_on),
|
|
)
|
|
)
|
|
await emit_task_created(
|
|
task_id=task.id,
|
|
organization_id=organization_id,
|
|
title=task.title,
|
|
status=TaskStatus.NEW.value,
|
|
)
|
|
actions.append(f"کار ساخته شد: {task.title}")
|
|
|
|
for change in team_changes(parsed):
|
|
try:
|
|
if change.action is TagName.HIRE:
|
|
role = (
|
|
RoleKey(change.role)
|
|
if change.role in {r.value for r in RoleKey}
|
|
else RoleKey.DEVELOPER
|
|
)
|
|
employee = await self.hire(
|
|
organization_id=organization_id,
|
|
role=role,
|
|
tier=change.tier or "balanced",
|
|
name=change.name,
|
|
project_id=thread.project_id,
|
|
)
|
|
actions.append(f"کارمند استخدام شد: {employee.name}")
|
|
elif change.action in (TagName.FIRE, TagName.UPDATE_EMPLOYEE):
|
|
employee_id = change.employee_id or await self._employee_id_by_name(
|
|
organization_id, change.name
|
|
)
|
|
if change.action is TagName.FIRE:
|
|
employee = await self.fire(
|
|
organization_id=organization_id, employee_id=employee_id
|
|
)
|
|
actions.append(f"کارمند حذف شد: {employee.name}")
|
|
else:
|
|
await self.update_tier(
|
|
organization_id=organization_id,
|
|
employee_id=employee_id,
|
|
tier=change.tier or "balanced",
|
|
)
|
|
actions.append("سطح کارمند تغییر کرد.")
|
|
except (ConflictError, NotFoundError, ValidationError) as exc:
|
|
actions.append(str(exc))
|
|
|
|
for tag in parsed.tags:
|
|
if tag.name not in (TagName.PAUSE_TASK, TagName.RESUME_TASK, TagName.CANCEL_TASK):
|
|
continue
|
|
task_id = tag.attr("task_id", "").strip()
|
|
state = await self._state_for(organization_id, task_id)
|
|
if state is None or self.states is None:
|
|
actions.append("کاری با این شناسه پیدا نشد.")
|
|
continue
|
|
if tag.name is TagName.CANCEL_TASK:
|
|
target = Stage.CANCELLED
|
|
done = "کار لغو شد."
|
|
elif tag.name is TagName.PAUSE_TASK:
|
|
target = Stage.PAUSED
|
|
done = "کار متوقف شد."
|
|
else:
|
|
target = Stage.PENDING_MANAGER_REVIEW
|
|
done = "کار از سر گرفته شد."
|
|
try:
|
|
await advance(
|
|
self.states,
|
|
task_id=task_id,
|
|
expected_from=state.stage,
|
|
to=target,
|
|
)
|
|
except ConflictError as exc:
|
|
actions.append(str(exc))
|
|
continue
|
|
if self.tasks is not None:
|
|
await self.tasks.set_status(task_id, coarse_status(target))
|
|
actions.append(done)
|
|
return actions
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 3.3 triage #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def triage(
|
|
self,
|
|
*,
|
|
task: Task,
|
|
run_id: str,
|
|
employee: Employee,
|
|
stage_context: str = "",
|
|
) -> ManagerOutcome:
|
|
"""The manager's triage turn: assign, reject, or ask the user."""
|
|
roster = await self._roster_text(task.organization_id)
|
|
result = await self._run(
|
|
await self._request(
|
|
task=task,
|
|
employee=employee,
|
|
role=RoleKey.MANAGER,
|
|
mode=Mode.TRIAGE,
|
|
stage=Stage.PENDING_MANAGER_REVIEW,
|
|
run_id=run_id,
|
|
extra_context="\n\n".join(p for p in (stage_context, roster) if p),
|
|
),
|
|
employee,
|
|
)
|
|
if not result.ok:
|
|
return ManagerOutcome(
|
|
acted=False,
|
|
message=result.error,
|
|
stage=Stage.PENDING_MANAGER_REVIEW,
|
|
error_kind=result.error_kind,
|
|
)
|
|
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.TRIAGE)
|
|
assign = parsed.first(TagName.ASSIGN)
|
|
if assign is not None:
|
|
return await self._apply_assign(task, assign)
|
|
reject = parsed.first(TagName.REJECT)
|
|
if reject is not None:
|
|
await self._move(task, Stage.CANCELLED)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="rejected",
|
|
stage=Stage.CANCELLED,
|
|
message=reject.attr("reason", ""),
|
|
)
|
|
ask = parsed.first(TagName.ASK_USER)
|
|
if ask is not None:
|
|
await self._move(task, Stage.PENDING_USER_DECISION)
|
|
if task.thread_id:
|
|
await self.messages.create(
|
|
Message(
|
|
thread_id=task.thread_id,
|
|
role=MessageRole.ASSISTANT,
|
|
content=ask.body.strip(),
|
|
employee_id=employee.id,
|
|
)
|
|
)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="asked_user",
|
|
stage=Stage.PENDING_USER_DECISION,
|
|
message=ask.body.strip(),
|
|
)
|
|
return ManagerOutcome(
|
|
acted=False, stage=Stage.PENDING_MANAGER_REVIEW, message=result.reply_text
|
|
)
|
|
|
|
async def _apply_assign(self, task: Task, tag) -> ManagerOutcome:
|
|
"""Apply an ``ASSIGN``: pick the assignee and the pipeline."""
|
|
try:
|
|
assign: AssignTag = assign_of(tag)
|
|
except ValueError as exc:
|
|
return ManagerOutcome(
|
|
acted=False, message=str(exc), stage=Stage.PENDING_MANAGER_REVIEW
|
|
)
|
|
assignee = await self._pick_assignee(
|
|
task.organization_id, assign.employee or ""
|
|
)
|
|
if assignee is None:
|
|
return ManagerOutcome(
|
|
acted=False,
|
|
decision="idle",
|
|
message="کارمند مناسبی برای این کار پیدا نشد.",
|
|
stage=Stage.PENDING_MANAGER_REVIEW,
|
|
)
|
|
state = await self._state_for(task.organization_id, task.id)
|
|
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
|
|
if pipeline is not PipelineKey.CODE_TEST
|
|
else Stage.PENDING_IMPLEMENTATION
|
|
)
|
|
await self.tasks.set_assignee(task.id, assignee.id)
|
|
if state is not None and self.states is not None:
|
|
# A lost race here is fine: the advance below still moves the task,
|
|
# and the pipeline column is a hint the next triage re-reads anyway.
|
|
with contextlib.suppress(StaleStageError):
|
|
await self.states.patch(
|
|
task_id=task.id,
|
|
expected_version=state.version,
|
|
pipeline=str(pipeline),
|
|
)
|
|
await self._move(task, first_stage)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="assigned",
|
|
stage=first_stage,
|
|
employee_id=assignee.id,
|
|
pipeline=pipeline,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 3.4 final verdict and error handling #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def final_review(
|
|
self,
|
|
*,
|
|
task: Task,
|
|
run_id: str,
|
|
employee: Employee,
|
|
spec: str = "",
|
|
diff_text: str = "",
|
|
test_output: str = "",
|
|
gate_summary: str = "",
|
|
) -> ManagerOutcome:
|
|
"""The manager's final verdict: merge or send it back."""
|
|
extra = "\n\n".join(p for p in (spec, diff_text, test_output, gate_summary) if p)
|
|
result = await self._run(
|
|
await self._request(
|
|
task=task,
|
|
employee=employee,
|
|
role=RoleKey.MANAGER,
|
|
mode=Mode.FINAL,
|
|
stage=Stage.PENDING_FINAL_REVIEW,
|
|
run_id=run_id,
|
|
extra_context=extra,
|
|
),
|
|
employee,
|
|
)
|
|
if not result.ok:
|
|
return ManagerOutcome(
|
|
acted=False,
|
|
message=result.error,
|
|
stage=Stage.PENDING_FINAL_REVIEW,
|
|
error_kind=result.error_kind,
|
|
)
|
|
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.FINAL)
|
|
if parsed.has(TagName.COMPLETE):
|
|
await self._move(task, Stage.MERGE_REQUEST)
|
|
return ManagerOutcome(
|
|
acted=True, decision="completed", stage=Stage.MERGE_REQUEST
|
|
)
|
|
revise = parsed.first(TagName.REVISE)
|
|
if revise is not None:
|
|
state = await self._state_for(task.organization_id, task.id)
|
|
if state is not None and self.states is not None:
|
|
await self.states.patch(
|
|
task_id=task.id, expected_version=state.version, cycle=state.cycle + 1
|
|
)
|
|
await self._move(task, Stage.PENDING_IMPLEMENTATION)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="revised",
|
|
stage=Stage.PENDING_IMPLEMENTATION,
|
|
message=revise.body.strip(),
|
|
)
|
|
return ManagerOutcome(
|
|
acted=False, stage=Stage.PENDING_FINAL_REVIEW, message=result.reply_text
|
|
)
|
|
|
|
async def error_review(
|
|
self,
|
|
*,
|
|
task: Task,
|
|
run_id: str,
|
|
employee: Employee,
|
|
error_summary: str,
|
|
hint: str = "",
|
|
) -> ManagerOutcome:
|
|
"""The manager's error turn: retry with a hint, or give up."""
|
|
detail = f"خطا:\n{error_summary}"
|
|
if hint:
|
|
detail += f"\n\nراهنمایی قبلی: {hint}"
|
|
result = await self._run(
|
|
await self._request(
|
|
task=task,
|
|
employee=employee,
|
|
role=RoleKey.MANAGER,
|
|
mode=Mode.ERROR,
|
|
stage=Stage.ERROR_REVIEW,
|
|
run_id=run_id,
|
|
extra_context=detail,
|
|
),
|
|
employee,
|
|
)
|
|
if not result.ok:
|
|
return ManagerOutcome(
|
|
acted=False,
|
|
message=result.error,
|
|
stage=Stage.ERROR_REVIEW,
|
|
error_kind=result.error_kind,
|
|
)
|
|
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.ERROR)
|
|
resolve = parsed.first(TagName.RESOLVE_ERROR)
|
|
if resolve is not None:
|
|
await self._move(task, Stage.PENDING_IMPLEMENTATION)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="resolved",
|
|
stage=Stage.PENDING_IMPLEMENTATION,
|
|
message=resolve.attr("hint", ""),
|
|
)
|
|
cancel = parsed.first(TagName.CANCEL)
|
|
if cancel is not None:
|
|
await self._move(task, Stage.CANCELLED)
|
|
return ManagerOutcome(
|
|
acted=True,
|
|
decision="cancelled",
|
|
stage=Stage.CANCELLED,
|
|
message=cancel.attr("reason", ""),
|
|
)
|
|
return ManagerOutcome(
|
|
acted=False, stage=Stage.ERROR_REVIEW, message=result.reply_text
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# internals #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _run(self, request: TurnRequest, employee: Employee) -> TurnResult:
|
|
if self.runner is None:
|
|
return TurnResult(
|
|
ok=False,
|
|
error_kind=ErrorKind.UNAVAILABLE.value,
|
|
error="موتور اجرا در دسترس نیست.",
|
|
)
|
|
return await self.runner.run_turn(request)
|
|
|
|
async def _request(
|
|
self,
|
|
*,
|
|
task: Task,
|
|
employee: Employee,
|
|
role: RoleKey,
|
|
mode: Mode,
|
|
stage: Stage,
|
|
run_id: str,
|
|
extra_context: str = "",
|
|
) -> TurnRequest:
|
|
return TurnRequest(
|
|
organization_id=task.organization_id,
|
|
task=task,
|
|
role=role,
|
|
mode=mode,
|
|
stage=stage,
|
|
run_id=run_id,
|
|
employee=employee,
|
|
model_id=await self._model_id_for(employee, task.organization_id),
|
|
extra_context=extra_context,
|
|
)
|
|
|
|
async def _model_id_for(
|
|
self, employee: Employee | None, organization_id: str
|
|
) -> str | None:
|
|
"""The model id an employee runs on: its profile, or a granted tier."""
|
|
if employee is None:
|
|
return None
|
|
model_id = getattr(employee, "model_id", None)
|
|
if not model_id and self.profiles is not None:
|
|
profile = await self.profiles.get(employee.id)
|
|
model_id = profile.ai_model_id if profile else None
|
|
if not model_id:
|
|
granted = await self.grants.granted_models(organization_id=organization_id)
|
|
model_id = _pick_granted_model(
|
|
granted, BOOTSTRAP_TIERS.get(employee.role_key, "balanced")
|
|
)
|
|
return model_id
|
|
|
|
async def _model_for(self, employee: Employee | None, organization_id: str):
|
|
"""Resolve the model an employee runs on, or ``None``.
|
|
|
|
A manager without a granted model cannot answer: the caller says so in
|
|
the thread rather than raising into the user's face.
|
|
"""
|
|
if employee is None:
|
|
return None
|
|
model_id = await self._model_id_for(employee, organization_id)
|
|
if not model_id:
|
|
return None
|
|
if not await self.grants.is_granted(
|
|
organization_id=organization_id, ai_model_id=model_id
|
|
):
|
|
return None
|
|
return await self.models.get(model_id)
|
|
|
|
async def _manager_of(
|
|
self, organization_id: str, employee_id: str | None
|
|
) -> Employee | None:
|
|
if employee_id:
|
|
return await self.employees.get_for_org(organization_id, employee_id)
|
|
for employee in await self.employees.list_for_org(organization_id):
|
|
if employee.role_key is RoleKey.MANAGER and employee.is_available:
|
|
return employee
|
|
return None
|
|
|
|
async def _pick_assignee(
|
|
self, organization_id: str, wanted: str
|
|
) -> Employee | None:
|
|
"""The employee who takes the task: least busy, or the named one.
|
|
|
|
The name is matched **inside this organization only** — a name from
|
|
another tenant resolves to nothing, which is the isolation rule the
|
|
tests enforce.
|
|
"""
|
|
roster = [
|
|
e
|
|
for e in await self.employees.list_for_org(organization_id)
|
|
if e.is_available and e.role_key is not RoleKey.MANAGER
|
|
]
|
|
if not roster:
|
|
return None
|
|
if wanted:
|
|
match = _fuzzy_match(wanted, [e.name for e in roster])
|
|
for employee in roster:
|
|
if employee.name == match:
|
|
return employee
|
|
return None
|
|
loads = [(await self.tasks.count_open_for_employee(e.id), e) for e in roster]
|
|
loads.sort(key=lambda pair: pair[0])
|
|
return loads[0][1]
|
|
|
|
async def _employee_id_by_name(self, organization_id: str, name: str) -> str:
|
|
roster = await self.employees.list_for_org(organization_id)
|
|
match = _fuzzy_match(name, [e.name for e in roster])
|
|
for employee in roster:
|
|
if employee.name == match:
|
|
return employee.id
|
|
raise NotFoundError("کارمندی با این نام پیدا نشد.")
|
|
|
|
async def _roster_text(self, organization_id: str) -> str:
|
|
"""The team as prompt text: names and roles, never model identity."""
|
|
lines = ["تیم سازمان:"]
|
|
for employee in await self.employees.list_for_org(organization_id):
|
|
status = "فعال" if employee.is_available else "غیرفعال"
|
|
label = _ROLE_LABELS.get(employee.role_key, employee.role_key.value)
|
|
lines.append(f"- {employee.name} ({label}) — {status}")
|
|
return "\n".join(lines)
|
|
|
|
async def _tasks_text(self, organization_id: str) -> str:
|
|
"""The organization's open tasks, so the manager can act on them by id."""
|
|
lines = ["کارهای باز:"]
|
|
for task in await self.tasks.list_for_org(organization_id, limit=20):
|
|
state = await self._state_for(organization_id, task.id)
|
|
stage = state.stage.value if state else task.status.value
|
|
lines.append(f"- {task.id}: {task.title} [{stage}]")
|
|
return "\n".join(lines)
|
|
|
|
async def _conversation_text(self, thread: Thread) -> str:
|
|
history = await self.messages.list_for_thread(thread.id)
|
|
lines = [
|
|
f"{m.role.value}: {m.content}"
|
|
for m in history[-12:]
|
|
if m.role is not MessageRole.SYSTEM
|
|
]
|
|
return ("گفتگو تا کنون:\n" + "\n".join(lines)) if lines else ""
|
|
|
|
async def _state_for(self, organization_id: str, task_id: str) -> TaskPipelineState | None:
|
|
if self.states is None:
|
|
return None
|
|
state = await self.states.get(task_id)
|
|
if state is None or state.organization_id != organization_id:
|
|
return None
|
|
return state
|
|
|
|
async def _move(self, task: Task, to: Stage) -> None:
|
|
"""Move the pipeline state and keep the coarse status truthful."""
|
|
state = await self._state_for(task.organization_id, task.id)
|
|
if state is not None and self.states is not None:
|
|
try:
|
|
await advance(
|
|
self.states, task_id=task.id, expected_from=state.stage, to=to
|
|
)
|
|
except ConflictError:
|
|
return
|
|
await self.tasks.set_status(task.id, coarse_status(to))
|
|
|
|
async def _record_knowledge(
|
|
self, parsed: ParsedReply | None, *, organization_id: str
|
|
) -> None:
|
|
"""3.5: a manager's KNOWLEDGE tags become org memory."""
|
|
if parsed is None:
|
|
return
|
|
for item in knowledge_items(parsed):
|
|
await self.memory.record(
|
|
organization_id=organization_id,
|
|
kind=item.kind if item.kind in ("rule", "fact") else "rule",
|
|
content=item.content,
|
|
source="manager",
|
|
key=item.key or None,
|
|
)
|
|
|
|
async def _post_system_message(
|
|
self, *, organization_id: str, project_id: str | None, content: str
|
|
) -> None:
|
|
thread = await self._system_thread(organization_id, project_id)
|
|
await self.messages.create(
|
|
Message(thread_id=thread.id, role=MessageRole.SYSTEM, content=content)
|
|
)
|
|
|
|
async def _system_thread(
|
|
self, organization_id: str, project_id: str | None
|
|
) -> Thread:
|
|
for thread in await self.threads.list_for_org(organization_id, limit=20):
|
|
if thread.title == SYSTEM_THREAD_TITLE:
|
|
return thread
|
|
return await self.threads.create(
|
|
Thread(
|
|
organization_id=organization_id,
|
|
title=SYSTEM_THREAD_TITLE,
|
|
project_id=project_id,
|
|
)
|
|
)
|
|
|
|
async def _system_note(self, thread: Thread, content: str) -> None:
|
|
await self.messages.create(
|
|
Message(thread_id=thread.id, role=MessageRole.SYSTEM, content=content)
|
|
)
|
|
|
|
async def _project_id_for(self, organization_id: str) -> str | None:
|
|
for employee in await self.employees.list_for_org(organization_id, limit=1):
|
|
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",
|
|
"CHAT_TURN_TIMEOUT_SECONDS",
|
|
"HIRABLE_ROLES",
|
|
"NO_MODEL_GRANTED_MESSAGE",
|
|
"SYSTEM_THREAD_TITLE",
|
|
"ManagerOutcome",
|
|
"ManagerTurns",
|
|
]
|