"""Runs anlegen, beanspruchen, verlängern und abschließen (→ Konzept 9.3). Alle Zeitpunkte der Warteschlange (available_at, locked_until) kommen von der Uhr der Datenbank, mit der auch verglichen wird; die Uhren von Web und Worker können abweichen. Ein Run ist zugleich der Job. Worker beanspruchen fällige Runs mit `FOR UPDATE SKIP LOCKED` und halten sie über eine Lease, die sie per Heartbeat verlängern. Läuft eine Lease ab, ist der Worker vermutlich abgestürzt; der Run wird erneut eingereiht oder nach zu vielen Versuchen als fehlgeschlagen beendet. """ import uuid from dataclasses import dataclass from datetime import timedelta from typing import Any from sqlalchemy import func, select, update from sqlalchemy.orm import Session, aliased from app import audit from app.approvals.models import CANCELLED, EXECUTING, PENDING, UNKNOWN, ApprovalRequest from app.core.config import Settings from app.core.database import Database, now from app.core.errors import Forbidden, Invalid, NotFound from app.identity import Principal from app.tasks import events, states from app.tasks.models import TaskRun from app.workspaces import service as workspaces from app.workspaces.models import Workspace def create(session: Session, workspace: Workspace, created_by: int, kind: str, input_: dict[str, Any], chat_session_id: int | None = None, parent_run_id: uuid.UUID | None = None) -> TaskRun: run = TaskRun(workspace_id=workspace.id, team_id=workspace.team_id, kind=kind, input=input_, chat_session_id=chat_session_id, parent_run_id=parent_run_id, created_by=created_by) session.add(run) session.flush() events.append(session, run.id, "status", {"status": states.QUEUED}) audit.record(session, "run.created", user_id=created_by, workspace_id=workspace.id, target_kind="run", target_id=run.id, kind=kind) return run def get(session: Session, principal: Principal, run_id: uuid.UUID) -> TaskRun: run = session.get(TaskRun, run_id) if run is None: raise NotFound workspaces.get(session, principal, run.workspace_id) return run def set_status(session: Session, run: TaskRun, status: str, **data: Any) -> None: states.check(run.status, status) run.status = status if status in states.RESTING: run.finished = now() if status != states.RUNNING: run.locked_by = run.locked_until = None events.append(session, run.id, "status", {"status": status, **data}) # Namensraum der Advisory Locks für das Teamlimit (beliebige feste Zahl) TEAM_LOCK = 7101 def claim(session: Session, worker_id: str, settings: Settings) -> TaskRun | None: """Nächsten fälligen Run beanspruchen, unter Beachtung des Limits gleichzeitiger Runs je Team. Das Teamlimit ist hart: Nach der Auswahl wird je Team ein Advisory Lock bis zum Ende der Transaktion gehalten und erneut gezählt. So sieht der nächste Worker desselben Teams den Run bereits als laufend, statt dass mehrere Worker gleichzeitig unter dem Limit zählen. """ other = aliased(TaskRun) running = (select(func.count()).select_from(other) .where(other.team_id == TaskRun.team_id, other.status == states.RUNNING) .scalar_subquery()) run = session.scalar(select(TaskRun) .where(TaskRun.status == states.QUEUED, TaskRun.available_at <= func.now(), running < settings.max_runs_per_team) .order_by(TaskRun.available_at, TaskRun.created) .limit(1) .with_for_update(skip_locked=True)) if run is None: return None session.execute(select(func.pg_advisory_xact_lock(TEAM_LOCK, run.team_id))) running_now = session.scalar(select(func.count()).select_from(TaskRun).where( TaskRun.team_id == run.team_id, TaskRun.status == states.RUNNING)) if running_now >= settings.max_runs_per_team: return None # ein anderer Worker war schneller; der Run bleibt eingereiht set_status(session, run, states.RUNNING) run.attempts += 1 run.locked_by = worker_id run.locked_until = func.now() + timedelta(seconds=settings.run_lease_seconds) run.started = run.started or now() return run @dataclass(frozen=True) class Beat: alive: bool # Lease noch bei diesem Worker cancel: bool # Abbruch angefordert def heartbeat(db: Database, run_id: uuid.UUID, worker_id: str, settings: Settings) -> Beat: with db.session() as session: row = session.execute( update(TaskRun) .where(TaskRun.id == run_id, TaskRun.locked_by == worker_id, TaskRun.status == states.RUNNING) .values(locked_until=func.now() + timedelta(seconds=settings.run_lease_seconds)) .returning(TaskRun.cancel_requested)).first() return Beat(alive=row is not None, cancel=bool(row and row[0])) def owned(session: Session, run_id: uuid.UUID, worker_id: str) -> TaskRun | None: """Den Run nur zurückgeben, wenn dieser Worker die Lease noch hält (gesperrt bis Commit).""" run = session.get(TaskRun, run_id, with_for_update=True) if run is None or run.status != states.RUNNING or run.locked_by != worker_id: return None return run def recover_expired(db: Database, settings: Settings) -> int: """Runs mit abgelaufener Lease wieder einreihen oder nach zu vielen Versuchen beenden. Steckte der Run in einer Schreibaktion, ist ihr Ausgang unklar: Der Run wechselt nach needs_reconciliation, statt die Aktion blind zu wiederholen (→ Konzept 9.2). """ count = 0 with db.session() as session: expired = session.scalars(select(TaskRun) .where(TaskRun.status == states.RUNNING, TaskRun.locked_until < func.now()) .with_for_update(skip_locked=True)).all() for run in expired: worker = run.locked_by writing = session.scalars(select(ApprovalRequest).where(ApprovalRequest.run_id == run.id, ApprovalRequest.status == EXECUTING)).all() if writing: for approval in writing: approval.status = UNKNOWN state = dict(run.state or {}) waiting = dict(state.get("waiting", {})) waiting.update({a.call_id: a.id for a in writing if not a.call_id.startswith("rerequest-")}) run.state = {**state, "waiting": waiting} run.result = {**run.result, "message": "Der Worker fiel während einer Schreibaktion aus. " "Ein Workspace-Admin muss den Abgleich mit dem Zielsystem starten."} set_status(session, run, states.NEEDS_RECONCILIATION, reason="lease_expired") elif run.attempts >= settings.run_max_attempts: run.error = f"Abgebrochen nach {run.attempts} Versuchen (Worker nicht mehr erreichbar)." set_status(session, run, states.FAILED, reason="lease_expired") else: set_status(session, run, states.QUEUED, reason="lease_expired") run.available_at = func.now() audit.record(session, "run.lease_expired", workspace_id=run.workspace_id, target_kind="run", target_id=run.id, attempts=run.attempts, worker=worker) count += 1 return count def request_cancel(session: Session, principal: Principal, run_id: uuid.UUID) -> TaskRun: run = get(session, principal, run_id) if run.status in (states.QUEUED, states.WAITING_APPROVAL, states.WAITING_INPUT): set_status(session, run, states.CANCELLED, by=principal.username) session.execute(update(ApprovalRequest).where(ApprovalRequest.run_id == run.id, ApprovalRequest.status == PENDING) .values(status=CANCELLED, decided_at=now())) elif run.status == states.RUNNING: run.cancel_requested = True else: raise Invalid("Dieser Run läuft nicht mehr.") audit.record(session, "run.cancel_requested", user_id=principal.user_id, workspace_id=run.workspace_id, target_kind="run", target_id=run.id) return run def reconcile(session: Session, principal: Principal, run_id: uuid.UUID, give_up: bool = False) -> TaskRun: """Nach unklarem Ausgang einer Schreibaktion (→ Konzept 9.2): Workspace-Admin startet den Abgleich (die Tools prüfen dabei den Zielzustand, bevor sie etwas tun) oder beendet den Run.""" run = get(session, principal, run_id) if not workspaces.get(session, principal, run.workspace_id).is_admin: raise Forbidden if run.status != states.NEEDS_RECONCILIATION: raise Invalid("Für diesen Run ist kein Abgleich nötig.") if give_up: run.result = {**run.result, "message": "Nach unklarer Schreibaktion beendet. Bitte das Zielsystem " "(Branch ai/…, Pull Request) von Hand prüfen."} set_status(session, run, states.FAILED, by=principal.username, message=run.result["message"]) else: set_status(session, run, states.QUEUED, resumed=True) run.available_at = func.now() audit.record(session, "run.reconcile", user_id=principal.user_id, workspace_id=run.workspace_id, target_kind="run", target_id=run.id, give_up=give_up) return run