The AI workforce engine's driver. One tick moves every ready task one step:
a turn, a gate, or a platform merge. The AI never runs git.
- scheduler.py: tick() over TURN_STAGES/MERGE_STAGES; selection caps
(per-org 3, global 8); optimistic advance so two workers cannot both win;
worktree created for code pipelines, released on completion/cancel/failure
(kept during error_review); budget pause before any turn is spent; merge
conflict back-off; transient-failure retries then the manager's error turn.
- gates.py: diff guard (empty/.git/CI/symlink/oversize), no-progress hash,
tests in the sandbox, green gate that only trusts a green tests row or an
explicit waiver. Every verdict is a gate_results row + a task.gate event.
Fixed forbidden_paths: lstrip("./") stripped all leading dots, hiding
.git and .github from the guard.
- container_engine.build_scheduler + worker drives the engine tick.
- ManagerOutcome.error_kind; TaskSpendRepository.spend_for_task.
- New settings engine_max_concurrent_turns, engine_max_turns_per_org,
engine_tests_timeout_seconds, engine_max_merge_conflicts,
engine_max_task_credits (+ .env.example).
- tests: test_engine_gates.py (13), test_engine_flow.py (11).
Checks: pytest 746 passed/1 skipped, ruff clean, mypy clean (160 files).
1080 lines
41 KiB
Python
1080 lines
41 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("سطح کارمند را انتخاب کنید.")
|
|
count = await self.employees.count_for_org(organization_id)
|
|
if count >= settings.engine_max_employees:
|
|
raise ConflictError(
|
|
f"سقف تعداد کارمندان ({settings.engine_max_employees}) پر شده است."
|
|
)
|
|
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
|
|
)
|
|
task = await self.tasks.create(
|
|
Task(
|
|
organization_id=organization_id,
|
|
title=draft.title[:200],
|
|
description=draft.description,
|
|
project_id=thread.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=thread.project_id,
|
|
pipeline=resolve_pipeline(
|
|
manager_choice=draft.pipeline or None, task_kind=kind
|
|
),
|
|
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,
|
|
)
|
|
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
|
|
|
|
|
|
__all__ = [
|
|
"BOOTSTRAP_TIERS",
|
|
"CHAT_TURN_TIMEOUT_SECONDS",
|
|
"HIRABLE_ROLES",
|
|
"NO_MODEL_GRANTED_MESSAGE",
|
|
"SYSTEM_THREAD_TITLE",
|
|
"ManagerOutcome",
|
|
"ManagerTurns",
|
|
]
|