ipdate
This commit is contained in:
@@ -88,12 +88,13 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
summary="Queue a prompt to run autonomously on a schedule with full tool access",
|
||||
description=(
|
||||
"The prompt is executed later by a fresh agent that has every tool available, "
|
||||
"running inside the current authenticated session. Scheduling is restricted to "
|
||||
"administrators, is capped per owner, and every recurring task stops by itself."
|
||||
"running inside the current authenticated session. Two rolling 24-hour quotas apply "
|
||||
"per account: how many tasks may be created, and how many task runs may execute. "
|
||||
"Every recurring task also stops by itself. While a task run is executing, members "
|
||||
"may not schedule further work from inside it; administrators may."
|
||||
),
|
||||
handler="task",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
params=(
|
||||
field(
|
||||
"prompt",
|
||||
@@ -143,12 +144,11 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
path="",
|
||||
summary="Update a task's prompt, label, enabled state, or schedule",
|
||||
description=(
|
||||
"Provide schedule fields together with kind to reschedule the task. Restricted to "
|
||||
"administrators."
|
||||
"Provide schedule fields together with kind to reschedule the task. Enabling a task "
|
||||
"from inside a running task is refused for members."
|
||||
),
|
||||
handler="task",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
params=(
|
||||
field("uid", "Uid of the task.", required=True),
|
||||
field("prompt", "New prompt."),
|
||||
@@ -183,10 +183,12 @@ TASK_ACTIONS: tuple[Action, ...] = (
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Trigger a task to execute immediately on the next scheduler tick",
|
||||
description="Restricted to administrators.",
|
||||
description=(
|
||||
"The run still consumes a slot of the account's rolling 24-hour run quota, and is "
|
||||
"postponed when that quota is spent."
|
||||
),
|
||||
handler="task",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
params=(field("uid", "Uid of the task.", required=True),),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
_TASK_RUN = contextvars.ContextVar("devii_task_run", default=False)
|
||||
|
||||
|
||||
def in_task_run() -> bool:
|
||||
return _TASK_RUN.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def task_run_scope() -> Iterator[None]:
|
||||
token = _TASK_RUN.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_TASK_RUN.reset(token)
|
||||
@@ -93,7 +93,7 @@ class TaskController:
|
||||
prompt = str(arguments.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
raise ToolInputError("create_task requires a non-empty prompt.")
|
||||
self._require_automation()
|
||||
self._require_creation()
|
||||
self._require_capacity()
|
||||
schedule = self._build_schedule(arguments)
|
||||
reference = now_utc()
|
||||
@@ -118,7 +118,7 @@ class TaskController:
|
||||
try:
|
||||
self._store.create(record)
|
||||
except AutomationDenied as exc:
|
||||
raise ToolInputError(f"Task refused: {exc.reason}.") from exc
|
||||
raise self._denied(exc) from exc
|
||||
return json.dumps(
|
||||
{"status": "created", "task": _serialize(record, preview=True)},
|
||||
ensure_ascii=False,
|
||||
@@ -144,7 +144,7 @@ class TaskController:
|
||||
|
||||
def update_task(self, arguments: dict[str, Any]) -> str:
|
||||
row = self._require_task(arguments)
|
||||
self._require_automation()
|
||||
self._require_scheduling()
|
||||
reference = now_utc()
|
||||
changes: dict[str, Any] = {}
|
||||
|
||||
@@ -210,7 +210,7 @@ class TaskController:
|
||||
|
||||
def run_task_now(self, arguments: dict[str, Any]) -> str:
|
||||
row = self._require_task(arguments)
|
||||
self._require_automation()
|
||||
self._require_scheduling()
|
||||
self._store.update(
|
||||
row["uid"],
|
||||
{"enabled": True, "status": "pending", "next_run_at": to_iso(now_utc())},
|
||||
@@ -224,13 +224,26 @@ class TaskController:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _require_automation(self) -> None:
|
||||
@staticmethod
|
||||
def _denied(exc: AutomationDenied) -> ToolInputError:
|
||||
if exc.retry_at is None:
|
||||
return ToolInputError(f"Task refused: {exc.reason}.")
|
||||
return ToolInputError(
|
||||
f"Task refused: {exc.reason}. The next slot frees up at "
|
||||
f"{to_iso(exc.retry_at)} UTC."
|
||||
)
|
||||
|
||||
def _require_creation(self) -> None:
|
||||
try:
|
||||
self._store.require_automation()
|
||||
self._store.require_creation_allowed()
|
||||
except AutomationDenied as exc:
|
||||
raise ToolInputError(
|
||||
f"Scheduled tasks are restricted to administrators: {exc.reason}."
|
||||
) from exc
|
||||
raise self._denied(exc) from exc
|
||||
|
||||
def _require_scheduling(self) -> None:
|
||||
try:
|
||||
self._store.require_scheduling_allowed()
|
||||
except AutomationDenied as exc:
|
||||
raise self._denied(exc) from exc
|
||||
|
||||
def _require_capacity(self) -> None:
|
||||
limit = max_active_per_owner()
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, Callable, NamedTuple, Optional
|
||||
|
||||
from .context import in_task_run
|
||||
from .limits import create_quota, owner_is_admin, run_quota
|
||||
from .schedule import (
|
||||
DEFAULT_MAX_RUNS,
|
||||
MAX_LIFETIME_DAYS,
|
||||
@@ -14,31 +16,41 @@ from .schedule import (
|
||||
to_iso,
|
||||
)
|
||||
|
||||
REASON_NOT_ADMIN = "owner is not an administrator"
|
||||
REASON_NOT_A_USER = "tasks belong to a signed-in account"
|
||||
REASON_EXPIRED = "task lifetime expired"
|
||||
REASON_MAX_RUNS = "run limit reached"
|
||||
REASON_BUDGET = "automation budget reached"
|
||||
REASON_FAILURES = "too many consecutive failures"
|
||||
REASON_OWNER_IDLE = "owner has been inactive"
|
||||
REASON_NESTED = "a task run may not schedule more work"
|
||||
REASON_RUN_QUOTA = "daily run limit reached"
|
||||
REASON_BUDGET = "automation budget reached"
|
||||
REASON_CREATE_QUOTA = "daily task limit reached"
|
||||
|
||||
BudgetProbe = Callable[[str, str], bool]
|
||||
|
||||
FIELD_MAX_PER_OWNER = "devii_task_max_per_owner"
|
||||
DEFAULT_MAX_PER_OWNER = 10
|
||||
BUDGET_RETRY_HOURS = 1
|
||||
|
||||
|
||||
class Deferral(NamedTuple):
|
||||
reason: str
|
||||
retry_at: datetime
|
||||
|
||||
|
||||
class AutomationDenied(Exception):
|
||||
def __init__(self, reason: str) -> None:
|
||||
def __init__(self, reason: str, retry_at: Optional[datetime] = None) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
self.retry_at = retry_at
|
||||
|
||||
|
||||
def automation_allowed(owner_kind: str, owner_id: str) -> bool:
|
||||
if owner_kind != "user" or not owner_id:
|
||||
return False
|
||||
from devplacepy.database import get_admin_uids
|
||||
return owner_kind == "user" and bool(owner_id)
|
||||
|
||||
return owner_id in get_admin_uids()
|
||||
|
||||
def nesting_allowed(owner_id: str) -> bool:
|
||||
return not in_task_run() or owner_is_admin(owner_id)
|
||||
|
||||
|
||||
def max_active_per_owner() -> int:
|
||||
@@ -68,16 +80,13 @@ def expiry_of(row: dict[str, Any]) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def refusal(
|
||||
row: dict[str, Any],
|
||||
reference: datetime,
|
||||
budget_exceeded: Optional[BudgetProbe] = None,
|
||||
max_failures: int = 0,
|
||||
def retire_reason(
|
||||
row: dict[str, Any], reference: datetime, max_failures: int = 0
|
||||
) -> Optional[str]:
|
||||
owner_kind = str(row.get("owner_kind") or "")
|
||||
owner_id = str(row.get("owner_id") or "")
|
||||
if not automation_allowed(owner_kind, owner_id):
|
||||
return REASON_NOT_ADMIN
|
||||
return REASON_NOT_A_USER
|
||||
|
||||
expiry = expiry_of(row)
|
||||
if expiry is not None and reference >= expiry:
|
||||
@@ -90,7 +99,39 @@ def refusal(
|
||||
if max_failures > 0 and int(row.get("failure_count") or 0) >= max_failures:
|
||||
return REASON_FAILURES
|
||||
|
||||
if budget_exceeded is not None and budget_exceeded(owner_kind, owner_id):
|
||||
return REASON_BUDGET
|
||||
return None
|
||||
|
||||
|
||||
def budget_deferral(
|
||||
row: dict[str, Any],
|
||||
reference: datetime,
|
||||
budget_exceeded: Optional[BudgetProbe] = None,
|
||||
) -> Optional[Deferral]:
|
||||
if budget_exceeded is None:
|
||||
return None
|
||||
owner_kind = str(row.get("owner_kind") or "")
|
||||
owner_id = str(row.get("owner_id") or "")
|
||||
if not budget_exceeded(owner_kind, owner_id):
|
||||
return None
|
||||
return Deferral(REASON_BUDGET, reference + timedelta(hours=BUDGET_RETRY_HOURS))
|
||||
|
||||
|
||||
def run_deferral(db: Any, row: dict[str, Any], reference: datetime) -> Deferral:
|
||||
owner_kind = str(row.get("owner_kind") or "")
|
||||
owner_id = str(row.get("owner_id") or "")
|
||||
quota = run_quota(db, owner_kind, owner_id, reference)
|
||||
retry_at = quota.free_at or reference + timedelta(hours=BUDGET_RETRY_HOURS)
|
||||
return Deferral(REASON_RUN_QUOTA, retry_at)
|
||||
|
||||
|
||||
def creation_denial(
|
||||
db: Any, owner_kind: str, owner_id: str, reference: datetime
|
||||
) -> Optional[AutomationDenied]:
|
||||
if not automation_allowed(owner_kind, owner_id):
|
||||
return AutomationDenied(REASON_NOT_A_USER)
|
||||
if not nesting_allowed(owner_id):
|
||||
return AutomationDenied(REASON_NESTED)
|
||||
quota = create_quota(db, owner_kind, owner_id, reference)
|
||||
if quota.exceeded:
|
||||
return AutomationDenied(REASON_CREATE_QUOTA, quota.free_at)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
from .schedule import from_iso, to_iso
|
||||
|
||||
logger = logging.getLogger("devii.tasks.limits")
|
||||
|
||||
RUNS_TABLE = "devii_task_runs"
|
||||
TASKS_TABLE = "devii_tasks"
|
||||
WINDOW_HOURS = 24
|
||||
|
||||
FIELD_MEMBER_CREATE = "devii_task_member_create_24h"
|
||||
FIELD_MEMBER_RUNS = "devii_task_member_runs_24h"
|
||||
FIELD_ADMIN_CREATE = "devii_task_admin_create_24h"
|
||||
FIELD_ADMIN_RUNS = "devii_task_admin_runs_24h"
|
||||
|
||||
DEFAULT_MEMBER_CREATE = 5
|
||||
DEFAULT_MEMBER_RUNS = 10
|
||||
DEFAULT_ADMIN_CREATE = 5
|
||||
DEFAULT_ADMIN_RUNS = 100
|
||||
|
||||
RUNS_SQL = (
|
||||
f"SELECT created_at FROM {RUNS_TABLE} WHERE owner_kind = :kind AND owner_id = :owner "
|
||||
"AND created_at >= :cutoff ORDER BY created_at LIMIT :cap"
|
||||
)
|
||||
CREATIONS_SQL = (
|
||||
f"SELECT created_at FROM {TASKS_TABLE} WHERE owner_kind = :kind AND owner_id = :owner "
|
||||
"AND created_at >= :cutoff ORDER BY created_at LIMIT :cap"
|
||||
)
|
||||
RESERVE_SQL = (
|
||||
f"INSERT INTO {RUNS_TABLE} (uid, owner_kind, owner_id, task_uid, created_at) "
|
||||
"SELECT :uid, :kind, :owner, :task, :now WHERE ("
|
||||
f"SELECT COUNT(*) FROM {RUNS_TABLE} WHERE owner_kind = :kind AND owner_id = :owner "
|
||||
"AND created_at >= :cutoff) < :limit"
|
||||
)
|
||||
SAMPLE_CAP = 1000
|
||||
|
||||
|
||||
class Quota(NamedTuple):
|
||||
used: int
|
||||
limit: int
|
||||
free_at: Optional[datetime]
|
||||
|
||||
@property
|
||||
def exceeded(self) -> bool:
|
||||
return self.limit > 0 and self.used >= self.limit
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
if self.limit <= 0:
|
||||
return -1
|
||||
return max(0, self.limit - self.used)
|
||||
|
||||
|
||||
def owner_is_admin(owner_id: str) -> bool:
|
||||
from devplacepy.database import get_admin_uids
|
||||
|
||||
return bool(owner_id) and owner_id in get_admin_uids()
|
||||
|
||||
|
||||
def _setting(name: str, default: int) -> int:
|
||||
from devplacepy.database import get_int_setting
|
||||
|
||||
return get_int_setting(name, default)
|
||||
|
||||
|
||||
def create_limit(is_admin: bool) -> int:
|
||||
if is_admin:
|
||||
return _setting(FIELD_ADMIN_CREATE, DEFAULT_ADMIN_CREATE)
|
||||
return _setting(FIELD_MEMBER_CREATE, DEFAULT_MEMBER_CREATE)
|
||||
|
||||
|
||||
def run_limit(is_admin: bool) -> int:
|
||||
if is_admin:
|
||||
return _setting(FIELD_ADMIN_RUNS, DEFAULT_ADMIN_RUNS)
|
||||
return _setting(FIELD_MEMBER_RUNS, DEFAULT_MEMBER_RUNS)
|
||||
|
||||
|
||||
def window_start(reference: datetime) -> datetime:
|
||||
return reference - timedelta(hours=WINDOW_HOURS)
|
||||
|
||||
|
||||
def _stamps(db: Any, sql: str, table: str, owner_kind: str, owner_id: str, cutoff: str) -> list[str]:
|
||||
if table not in db.tables:
|
||||
return []
|
||||
rows = db.query(
|
||||
sql, kind=owner_kind, owner=owner_id, cutoff=cutoff, cap=SAMPLE_CAP
|
||||
)
|
||||
return [str(row["created_at"]) for row in rows if row.get("created_at")]
|
||||
|
||||
|
||||
def _quota(stamps: list[str], limit: int) -> Quota:
|
||||
used = len(stamps)
|
||||
if limit <= 0 or used < limit:
|
||||
return Quota(used, limit, None)
|
||||
oldest_kept = stamps[used - limit]
|
||||
try:
|
||||
free_at = from_iso(oldest_kept[:19]) + timedelta(hours=WINDOW_HOURS)
|
||||
except ValueError:
|
||||
free_at = None
|
||||
return Quota(used, limit, free_at)
|
||||
|
||||
|
||||
def run_quota(db: Any, owner_kind: str, owner_id: str, reference: datetime) -> Quota:
|
||||
limit = run_limit(owner_is_admin(owner_id))
|
||||
stamps = _stamps(
|
||||
db, RUNS_SQL, RUNS_TABLE, owner_kind, owner_id, to_iso(window_start(reference))
|
||||
)
|
||||
return _quota(stamps, limit)
|
||||
|
||||
|
||||
def create_quota(db: Any, owner_kind: str, owner_id: str, reference: datetime) -> Quota:
|
||||
limit = create_limit(owner_is_admin(owner_id))
|
||||
stamps = _stamps(
|
||||
db,
|
||||
CREATIONS_SQL,
|
||||
TASKS_TABLE,
|
||||
owner_kind,
|
||||
owner_id,
|
||||
to_iso(window_start(reference)),
|
||||
)
|
||||
return _quota(stamps, limit)
|
||||
|
||||
|
||||
def record_run(db: Any, owner_kind: str, owner_id: str, task_uid: str, reference: datetime) -> None:
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
db[RUNS_TABLE].insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"task_uid": task_uid,
|
||||
"created_at": to_iso(reference),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reserve_run(
|
||||
db: Any, owner_kind: str, owner_id: str, task_uid: str, reference: datetime
|
||||
) -> bool:
|
||||
import sqlalchemy
|
||||
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
limit = run_limit(owner_is_admin(owner_id))
|
||||
if RUNS_TABLE not in db.tables:
|
||||
record_run(db, owner_kind, owner_id, task_uid, reference)
|
||||
return True
|
||||
if limit <= 0:
|
||||
record_run(db, owner_kind, owner_id, task_uid, reference)
|
||||
return True
|
||||
params = {
|
||||
"uid": generate_uid(),
|
||||
"kind": owner_kind,
|
||||
"owner": owner_id,
|
||||
"task": task_uid,
|
||||
"now": to_iso(reference),
|
||||
"cutoff": to_iso(window_start(reference)),
|
||||
"limit": limit,
|
||||
}
|
||||
with db:
|
||||
result = db.executable.execute(sqlalchemy.text(RESERVE_SQL), params)
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def insert_task_within_quota(
|
||||
db: Any,
|
||||
record: dict[str, Any],
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
reference: datetime,
|
||||
) -> bool:
|
||||
import sqlalchemy
|
||||
|
||||
limit = create_limit(owner_is_admin(owner_id))
|
||||
if limit <= 0 or TASKS_TABLE not in db.tables:
|
||||
db[TASKS_TABLE].insert(record)
|
||||
return True
|
||||
|
||||
columns = list(record)
|
||||
statement = sqlalchemy.text(
|
||||
f"INSERT INTO {TASKS_TABLE} ({', '.join(columns)}) "
|
||||
f"SELECT {', '.join(':' + name for name in columns)} WHERE ("
|
||||
f"SELECT COUNT(*) FROM {TASKS_TABLE} WHERE owner_kind = :q_kind "
|
||||
"AND owner_id = :q_owner AND created_at >= :q_cutoff) < :q_limit"
|
||||
)
|
||||
params = dict(record)
|
||||
params.update(
|
||||
{
|
||||
"q_kind": owner_kind,
|
||||
"q_owner": owner_id,
|
||||
"q_cutoff": to_iso(window_start(reference)),
|
||||
"q_limit": limit,
|
||||
}
|
||||
)
|
||||
with db:
|
||||
result = db.executable.execute(statement, params)
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def prune_runs(db: Any, reference: datetime, keep_hours: int = WINDOW_HOURS * 2) -> int:
|
||||
if RUNS_TABLE not in db.tables:
|
||||
return 0
|
||||
cutoff = to_iso(reference - timedelta(hours=keep_hours))
|
||||
table = db[RUNS_TABLE]
|
||||
stale = table.count(created_at={"<": cutoff})
|
||||
if stale:
|
||||
table.delete(created_at={"<": cutoff})
|
||||
return stale
|
||||
@@ -6,8 +6,10 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from .context import task_run_scope
|
||||
from .controller import compute_followup
|
||||
from .guards import BudgetProbe, refusal
|
||||
from .guards import BudgetProbe, Deferral, budget_deferral, run_deferral, retire_reason
|
||||
from .limits import reserve_run
|
||||
from .schedule import next_run, now_utc, to_iso
|
||||
from .store import TaskStore, claim, due_rows
|
||||
|
||||
@@ -68,6 +70,46 @@ DEFAULT_MAX_FAILURES = 3
|
||||
DUE_BATCH_LIMIT = 200
|
||||
|
||||
|
||||
def _audit_task_deferred(row: dict[str, Any], reason: str, retry_at: str) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind = row.get("owner_kind") or "user"
|
||||
owner_id = row.get("owner_id") or ""
|
||||
audit.record_system(
|
||||
"devii.task.deferred",
|
||||
actor_kind="user" if owner_kind == "user" else owner_kind,
|
||||
actor_uid=owner_id if owner_kind == "user" else None,
|
||||
actor_role="user" if owner_kind == "user" else owner_kind,
|
||||
origin="scheduler",
|
||||
via_agent=1,
|
||||
result="denied",
|
||||
target_type="task",
|
||||
target_uid=row.get("uid"),
|
||||
summary=f"Devii task {row.get('uid')} postponed - {reason}",
|
||||
metadata={"reason": reason, "retry_at": retry_at},
|
||||
links=[audit.task(row.get("uid"))],
|
||||
)
|
||||
|
||||
|
||||
def defer(store: TaskStore, row: dict[str, Any], postponement: Deferral) -> None:
|
||||
retry_at = to_iso(postponement.retry_at)
|
||||
store.update(
|
||||
row["uid"],
|
||||
{
|
||||
"status": "pending",
|
||||
"next_run_at": retry_at,
|
||||
"last_error": postponement.reason,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Task uid=%s postponed to %s: %s",
|
||||
row.get("uid"),
|
||||
retry_at,
|
||||
postponement.reason,
|
||||
)
|
||||
_audit_task_deferred(row, postponement.reason, retry_at)
|
||||
|
||||
|
||||
def retire(store: TaskStore, row: dict[str, Any], reason: str) -> None:
|
||||
store.update(
|
||||
row["uid"],
|
||||
@@ -94,7 +136,8 @@ async def run_row(
|
||||
logger.info("Executing task uid=%s", uid)
|
||||
|
||||
try:
|
||||
result = await executor(row["prompt"])
|
||||
with task_run_scope():
|
||||
result = await executor(row["prompt"])
|
||||
except Exception as exc: # noqa: BLE001 - surfaced into the task record
|
||||
logger.exception("Task uid=%s crashed", uid)
|
||||
store.update(uid, _failure_changes(row, str(exc), max_failures))
|
||||
@@ -175,14 +218,26 @@ class Scheduler:
|
||||
async def _tick(self) -> None:
|
||||
now = now_utc()
|
||||
for row in self._store.due(to_iso(now)):
|
||||
reason = refusal(row, now)
|
||||
reason = retire_reason(row, now)
|
||||
if reason is not None:
|
||||
retire(self._store, row, reason)
|
||||
continue
|
||||
if not claim(self._store.db, row["uid"]):
|
||||
continue
|
||||
if not self._reserve(row, now):
|
||||
defer(self._store, row, run_deferral(self._store.db, row, now))
|
||||
continue
|
||||
await run_row(row, self._store, self._executor, self._on_event)
|
||||
|
||||
def _reserve(self, row: dict[str, Any], reference: Any) -> bool:
|
||||
return reserve_run(
|
||||
self._store.db,
|
||||
str(row.get("owner_kind") or "user"),
|
||||
str(row.get("owner_id") or ""),
|
||||
row["uid"],
|
||||
reference,
|
||||
)
|
||||
|
||||
|
||||
class GlobalScheduler:
|
||||
def __init__(
|
||||
@@ -242,7 +297,7 @@ class GlobalScheduler:
|
||||
now = now_utc()
|
||||
free = self._max_concurrent - len(self._running)
|
||||
for row in due_rows(self._db, to_iso(now), DUE_BATCH_LIMIT):
|
||||
reason = refusal(row, now, self._budget_exceeded, self._max_failures)
|
||||
reason = retire_reason(row, now, self._max_failures)
|
||||
if reason is not None:
|
||||
retire(self._store_for(row), row, reason)
|
||||
continue
|
||||
@@ -250,10 +305,14 @@ class GlobalScheduler:
|
||||
continue
|
||||
if str(row.get("owner_id") or "") in self._running:
|
||||
continue
|
||||
if self._dispatch(row):
|
||||
postponement = budget_deferral(row, now, self._budget_exceeded)
|
||||
if postponement is not None:
|
||||
defer(self._store_for(row), row, postponement)
|
||||
continue
|
||||
if self._dispatch(row, now):
|
||||
free -= 1
|
||||
|
||||
def _dispatch(self, row: dict[str, Any]) -> bool:
|
||||
def _dispatch(self, row: dict[str, Any], reference: Any) -> bool:
|
||||
resolved = self._resolve(row)
|
||||
if resolved is None:
|
||||
retire(self._store_for(row), row, "owner could not be resolved")
|
||||
@@ -262,6 +321,15 @@ class GlobalScheduler:
|
||||
|
||||
if not claim(self._db, row["uid"]):
|
||||
return False
|
||||
if not reserve_run(
|
||||
self._db,
|
||||
str(row.get("owner_kind") or "user"),
|
||||
str(row.get("owner_id") or ""),
|
||||
row["uid"],
|
||||
reference,
|
||||
):
|
||||
defer(store, row, run_deferral(self._db, row, reference))
|
||||
return False
|
||||
|
||||
owner_id = str(row.get("owner_id") or "")
|
||||
task = asyncio.create_task(
|
||||
|
||||
@@ -9,7 +9,17 @@ from typing import Any
|
||||
import dataset
|
||||
import sqlalchemy
|
||||
|
||||
from .guards import AutomationDenied, REASON_NOT_ADMIN, automation_allowed
|
||||
from .guards import (
|
||||
AutomationDenied,
|
||||
REASON_CREATE_QUOTA,
|
||||
REASON_NESTED,
|
||||
REASON_NOT_A_USER,
|
||||
automation_allowed,
|
||||
creation_denial,
|
||||
nesting_allowed,
|
||||
)
|
||||
from .limits import create_quota, insert_task_within_quota
|
||||
from .schedule import now_utc
|
||||
|
||||
logger = logging.getLogger("devii.tasks.store")
|
||||
|
||||
@@ -21,7 +31,27 @@ INDEXED_COLUMNS = (
|
||||
["next_run_at"],
|
||||
["status"],
|
||||
)
|
||||
ADDED_COLUMNS = (
|
||||
TASK_COLUMNS = (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("label", ""),
|
||||
("prompt", ""),
|
||||
("enabled", True),
|
||||
("status", ""),
|
||||
("created_at", ""),
|
||||
("next_run_at", ""),
|
||||
("last_run_at", ""),
|
||||
("run_count", 0),
|
||||
("last_result", ""),
|
||||
("last_error", ""),
|
||||
("kind", ""),
|
||||
("run_at", ""),
|
||||
("delay_seconds", 0),
|
||||
("every_seconds", 0),
|
||||
("start_at", ""),
|
||||
("cron", ""),
|
||||
("max_runs", 0),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
("notify", 0),
|
||||
@@ -73,12 +103,27 @@ class TaskStore:
|
||||
def _ensure_indexes(self) -> None:
|
||||
if TABLE not in self._db.tables:
|
||||
return
|
||||
table = self._db[TABLE]
|
||||
for column, example in ADDED_COLUMNS:
|
||||
if not table.has_column(column):
|
||||
table.create_column_by_example(column, example)
|
||||
self._ensure_schema()
|
||||
for columns in INDEXED_COLUMNS:
|
||||
table.create_index(columns)
|
||||
try:
|
||||
self._table.create_index(columns)
|
||||
except sqlalchemy.exc.OperationalError as exc:
|
||||
if "already exists" not in str(exc).lower():
|
||||
raise
|
||||
logger.debug("Index on %s was created by another process", columns)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
table = self._db[TABLE]
|
||||
for column, example in TASK_COLUMNS:
|
||||
if table.has_column(column):
|
||||
continue
|
||||
try:
|
||||
table.create_column_by_example(column, example)
|
||||
except sqlalchemy.exc.OperationalError as exc:
|
||||
if "duplicate column" not in str(exc).lower():
|
||||
raise
|
||||
logger.debug("Column %s was added by another process", column)
|
||||
table._reflect_table()
|
||||
|
||||
@property
|
||||
def db(self) -> Any:
|
||||
@@ -91,7 +136,23 @@ class TaskStore:
|
||||
|
||||
def require_automation(self) -> None:
|
||||
if not self.automation_allowed():
|
||||
raise AutomationDenied(REASON_NOT_ADMIN)
|
||||
raise AutomationDenied(REASON_NOT_A_USER)
|
||||
|
||||
def require_scheduling_allowed(self) -> None:
|
||||
self.require_automation()
|
||||
if self._operator:
|
||||
return
|
||||
if not nesting_allowed(self._owner_id):
|
||||
raise AutomationDenied(REASON_NESTED)
|
||||
|
||||
def require_creation_allowed(self) -> None:
|
||||
if self._operator:
|
||||
return
|
||||
denial = creation_denial(
|
||||
self._db, self._owner_kind, self._owner_id, now_utc()
|
||||
)
|
||||
if denial is not None:
|
||||
raise denial
|
||||
|
||||
def count_active(self) -> int:
|
||||
rows = self._table.find(enabled=True, deleted_at=None, **self._scope)
|
||||
@@ -106,16 +167,22 @@ class TaskStore:
|
||||
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
|
||||
|
||||
def create(self, record: dict[str, Any]) -> None:
|
||||
self.require_automation()
|
||||
self._table.insert(
|
||||
{
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"failure_count": 0,
|
||||
**record,
|
||||
**self._scope,
|
||||
}
|
||||
)
|
||||
self.require_creation_allowed()
|
||||
self._ensure_schema()
|
||||
row = {
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
"failure_count": 0,
|
||||
**record,
|
||||
**self._scope,
|
||||
}
|
||||
if self._operator:
|
||||
self._table.insert(row)
|
||||
elif not insert_task_within_quota(
|
||||
self._db, row, self._owner_kind, self._owner_id, now_utc()
|
||||
):
|
||||
quota = create_quota(self._db, self._owner_kind, self._owner_id, now_utc())
|
||||
raise AutomationDenied(REASON_CREATE_QUOTA, quota.free_at)
|
||||
logger.info(
|
||||
"Task created uid=%s owner=%s/%s",
|
||||
record.get("uid"),
|
||||
@@ -139,7 +206,7 @@ class TaskStore:
|
||||
|
||||
def update(self, uid: str, changes: dict[str, Any]) -> None:
|
||||
if changes.get("enabled"):
|
||||
self.require_automation()
|
||||
self.require_scheduling_allowed()
|
||||
changes = {**changes, "uid": uid, **self._scope}
|
||||
self._table.update(changes, ["uid", "owner_kind", "owner_id"])
|
||||
logger.debug("Task updated uid=%s changes=%s", uid, list(changes))
|
||||
|
||||
Reference in New Issue
Block a user