Add Phase 2 durable workstation agent job routing

This commit is contained in:
A R R R Associates
2026-08-22 11:50:20 +05:30
parent c8b7dcb805
commit f7bf1437ef
9 changed files with 657 additions and 10 deletions
+257
View File
@@ -0,0 +1,257 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.modules.documents.models import ERPAgentJob, ERPAgentJobEvent, ERPWorkstationAgent
LEASE_SECONDS = 45
DEFAULT_MAX_ATTEMPTS = 3
TERMINAL_STATUSES = {"succeeded", "failed", "cancelled"}
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _json_dump(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
def _json_load(value: str | None, default: Any) -> Any:
if not value:
return default
try:
parsed = json.loads(value)
return parsed
except Exception:
return default
def _event(db, job: ERPAgentJob, event_type: str, *, detail: str | None = None, actor_user_id: int | None = None) -> None:
db.add(
ERPAgentJobEvent(
job_id=job.id,
tenant_id=job.tenant_id,
branch_id=job.branch_id,
workstation_agent_id=job.workstation_agent_id,
event_type=str(event_type or "event")[:40],
detail=(detail or None),
actor_user_id=actor_user_id,
occurred_at_utc=_utcnow(),
)
)
def enqueue_agent_job(
db,
*,
workstation_agent_id: int,
action: str,
payload: dict[str, Any] | None = None,
idempotency_key: str | None = None,
priority: int = 5,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
created_by_user_id: int | None = None,
) -> ERPAgentJob:
workstation = db.get(ERPWorkstationAgent, int(workstation_agent_id))
if not workstation or not workstation.is_active:
raise ValueError("The selected ERP workstation agent is unavailable or inactive.")
action_value = str(action or "").strip()
if not action_value:
raise ValueError("Agent job action is required.")
idem = str(idempotency_key or "").strip() or None
if idem:
existing = db.execute(
select(ERPAgentJob).where(
ERPAgentJob.workstation_agent_id == workstation.id,
ERPAgentJob.idempotency_key == idem,
)
).scalar_one_or_none()
if existing:
return existing
job = ERPAgentJob(
job_uuid=uuid4().hex,
tenant_id=workstation.tenant_id,
branch_id=workstation.branch_id,
storage_node_id=workstation.storage_node_id,
workstation_agent_id=workstation.id,
action=action_value[:120],
payload_json=_json_dump(payload or {}),
idempotency_key=idem[:200] if idem else None,
status="queued",
priority=max(0, min(int(priority), 100)),
max_attempts=max(1, min(int(max_attempts), 20)),
attempts=0,
created_by_user_id=created_by_user_id,
created_at_utc=_utcnow(),
updated_at_utc=_utcnow(),
)
db.add(job)
try:
db.flush()
except IntegrityError:
db.rollback()
if idem:
existing = db.execute(
select(ERPAgentJob).where(
ERPAgentJob.workstation_agent_id == workstation.id,
ERPAgentJob.idempotency_key == idem,
)
).scalar_one_or_none()
if existing:
return existing
raise
_event(db, job, "queued", actor_user_id=created_by_user_id)
return job
def claim_jobs_for_workstation(db, *, storage_node_id: int, agent_instance_id: str, limit: int = 10) -> list[dict[str, Any]]:
instance_id = str(agent_instance_id or "").strip()
if not instance_id:
return []
workstation = db.execute(
select(ERPWorkstationAgent).where(
ERPWorkstationAgent.storage_node_id == int(storage_node_id),
ERPWorkstationAgent.agent_instance_id == instance_id,
ERPWorkstationAgent.is_active.is_(True),
)
).scalar_one_or_none()
if not workstation:
return []
now = _utcnow()
# Expired claims become retryable. Jobs that have exhausted their attempts fail.
expired = list(
db.execute(
select(ERPAgentJob).where(
ERPAgentJob.workstation_agent_id == workstation.id,
ERPAgentJob.status == "claimed",
ERPAgentJob.lease_expires_at_utc.is_not(None),
ERPAgentJob.lease_expires_at_utc < now,
)
).scalars().all()
)
for job in expired:
if int(job.attempts or 0) >= int(job.max_attempts or DEFAULT_MAX_ATTEMPTS):
job.status = "failed"
job.failed_at_utc = now
job.last_error = job.last_error or "Agent job lease expired after maximum retry attempts."
_event(db, job, "failed", detail=job.last_error)
else:
job.status = "queued"
job.claimed_at_utc = None
job.lease_expires_at_utc = None
_event(db, job, "lease_expired", detail="Job returned to queue for retry.")
stmt = (
select(ERPAgentJob)
.where(
ERPAgentJob.workstation_agent_id == workstation.id,
ERPAgentJob.status == "queued",
ERPAgentJob.attempts < ERPAgentJob.max_attempts,
)
.order_by(ERPAgentJob.priority.desc(), ERPAgentJob.created_at_utc.asc(), ERPAgentJob.id.asc())
.limit(max(1, min(int(limit), 25)))
)
rows = list(db.execute(stmt).scalars().all())
payloads: list[dict[str, Any]] = []
for job in rows:
job.status = "claimed"
job.attempts = int(job.attempts or 0) + 1
job.claimed_at_utc = now
job.lease_expires_at_utc = now + timedelta(seconds=LEASE_SECONDS)
job.updated_at_utc = now
_event(db, job, "claimed", detail=f"attempt={job.attempts}")
payloads.append(
{
"job_uuid": job.job_uuid,
"action": job.action,
"payload": _json_load(job.payload_json, {}),
"idempotency_key": job.idempotency_key or job.job_uuid,
"attempt": int(job.attempts or 0),
"max_attempts": int(job.max_attempts or DEFAULT_MAX_ATTEMPTS),
"lease_seconds": LEASE_SECONDS,
}
)
return payloads
def complete_agent_job(
db,
*,
storage_node_id: int,
agent_instance_id: str,
message: dict[str, Any],
) -> ERPAgentJob | None:
instance_id = str(agent_instance_id or "").strip()
job_uuid = str(message.get("job_uuid") or "").strip()
if not instance_id or not job_uuid:
return None
workstation = db.execute(
select(ERPWorkstationAgent).where(
ERPWorkstationAgent.storage_node_id == int(storage_node_id),
ERPWorkstationAgent.agent_instance_id == instance_id,
ERPWorkstationAgent.is_active.is_(True),
)
).scalar_one_or_none()
if not workstation:
return None
job = db.execute(
select(ERPAgentJob).where(
ERPAgentJob.job_uuid == job_uuid,
ERPAgentJob.workstation_agent_id == workstation.id,
)
).scalar_one_or_none()
if not job:
return None
if job.status in TERMINAL_STATUSES:
return job
now = _utcnow()
ok = bool(message.get("ok"))
job.result_json = _json_dump(message.get("result") or {}) if ok else None
job.last_error = None if ok else str(message.get("error") or "Agent job failed.")[:4000]
job.agent_completed_at_utc = now
job.updated_at_utc = now
job.lease_expires_at_utc = None
if ok:
job.status = "succeeded"
job.completed_at_utc = now
_event(db, job, "succeeded")
elif int(job.attempts or 0) < int(job.max_attempts or DEFAULT_MAX_ATTEMPTS) and bool(message.get("retryable", False)):
job.status = "queued"
job.claimed_at_utc = None
_event(db, job, "retry_queued", detail=job.last_error)
else:
job.status = "failed"
job.failed_at_utc = now
_event(db, job, "failed", detail=job.last_error)
return job
def cancel_agent_job(db, job: ERPAgentJob, *, actor_user_id: int | None = None, reason: str | None = None) -> ERPAgentJob:
if job.status in TERMINAL_STATUSES:
return job
job.status = "cancelled"
job.cancelled_at_utc = _utcnow()
job.updated_at_utc = job.cancelled_at_utc
job.lease_expires_at_utc = None
_event(db, job, "cancelled", detail=reason, actor_user_id=actor_user_id)
return job
def list_recent_agent_jobs(db, *, tenant_id: int | None = None, branch_id: int | None = None, limit: int = 25) -> list[ERPAgentJob]:
stmt = select(ERPAgentJob).order_by(ERPAgentJob.created_at_utc.desc(), ERPAgentJob.id.desc())
if tenant_id is not None:
stmt = stmt.where(ERPAgentJob.tenant_id == int(tenant_id))
if branch_id is not None:
stmt = stmt.where(ERPAgentJob.branch_id == int(branch_id))
stmt = stmt.limit(max(1, min(int(limit), 200)))
return list(db.execute(stmt).scalars().all())