Files
mjalmoudy94 d532929189 feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
2026-10-14 23:19:05 +03:30

1120 lines
43 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.billing import CreditWalletRepository
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 = "منتظر تخصیص مدل توسط مالک سامانه"
#: The reply a zero-credit organization gets instead of a model call (prepaid, D-B).
CHAT_NO_CREDIT_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,
wallets: CreditWalletRepository | None = 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
self.wallets = wallets
# ------------------------------------------------------------------ #
# 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)
)
# Prepaid: a reply costs a model call, so an org with no credit gets the
# note instead. No model is called and no tag is executed.
if await self._out_of_credit(organization_id):
note = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=CHAT_NO_CREDIT_MESSAGE,
)
)
return user_message, note
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
async def _out_of_credit(self, organization_id: str) -> bool:
"""True when a wired wallet shows no credit left (a missing wallet counts as none)."""
if self.wallets is None:
return False
wallet = await self.wallets.get_for_org(organization_id)
return wallet is None or wallet.balance <= 0
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_NO_CREDIT_MESSAGE",
"CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE",
"SYSTEM_THREAD_TITLE",
"ManagerOutcome",
"ManagerTurns",
]