This commit is contained in:
2026-07-26 17:24:49 +02:00
parent b8277d6351
commit a3963611f0
23 changed files with 1499 additions and 189 deletions
+10 -8
View File
@@ -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)
+22 -9
View File
@@ -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()
+57 -16
View File
@@ -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
+215
View File
@@ -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
+74 -6
View File
@@ -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(
+86 -19
View File
@@ -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))