mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* feat(persistence): add notification delivery outbox for scheduled task IM push New notification_deliveries table and repository (migration 0012) backing the outbox pattern for issue #4254: idempotent enqueue keyed on task_run_id plus event plus provider plus target, atomic claim of due rows, and exponential-backoff retry state moving pending to sending to sent or failed. Execution status stays in scheduled_task_runs; delivery status lives here. * feat(scheduler): enqueue and deliver scheduled-task notifications over IM channels Closes the loop for issue #4254. On run completion the scheduler writes outbox rows for the task owner's connected channel identities (success and failure events; interrupts stay silent), and a new NotificationDeliveryWorker polls due rows and pushes them through the channel's proactive send path. Channel gains send_notification; WeCom implements it via the WS client's send_message with errcode checking, the base class raises for providers without proactive push. Delivery is best-effort and never shadows execution status: enqueue and send failures are logged and recorded in the outbox, which retries with backoff. Channel liveness is re-checked at delivery time, and the worker stops before the channel service on shutdown. Feature activates only when channel connections are enabled; no new dependencies. Verified end to end against a live WeCom bot: scheduled run completion produced an outbox row that was delivered in under one second. * feat(scheduler): include scheduled run result summary in IM notifications The v1 notification only carried the run outcome skeleton (status, task id, run id). Completed runs now include the agent's final answer so the IM push is actually useful headlessly. - The delivery worker accepts an optional resolve_run_summary(run_id, user_id) resolver; for run_completed events it enriches the rendered text with a "Result:" line (bounded to 1000 chars). The lookup is best-effort: resolver failures or missing summaries degrade to the skeleton text and never block or fail the delivery. - The summary is read at delivery time from runs.last_ai_message (the convenience column written on run completion), not snapshotted into the outbox payload; retried deliveries re-read the freshest value and the outbox row payload stays skeleton-only. The claimed outbox row is never mutated in place. - Gateway wiring scopes the read to the outbox row's owner_user_id so a stale row cannot pull another user's run content. - Failed runs keep the error-only skeleton: a partial answer on a failed run would be misleading. Tests: 9 new worker cases (rendering incl. truncation, sync/async resolvers, failure/missing fallbacks, no resolver call for failed runs). Full scheduled + outbox + channels regression green (441 passed), ruff check + format clean. * fix(scheduler): address review feedback on notification delivery reliability Review follow-ups for the scheduled-task IM notification outbox: 1. Crash recovery for "sending" rows: claim now stamps updated_at and the worker reconciles rows stuck in "sending" past a 10-minute lease back to "pending" before every claim. Per-row try/except isolates a crashing delivery from the rest of the claimed batch. 2. Double-send race under multiple workers: claim returns exactly the rows its own UPDATE...RETURNING flipped (guarded by status='pending'), so a rival worker's concurrent claim can no longer be handed out twice. 3. Restore non-ASCII comments and drop the BOM accidentally introduced in test_scheduled_task_service.py. 4. Manual "run now" completions no longer enqueue IM notifications; the user is already watching the UI. Missing trigger metadata fails safe towards notifying. 5. Channel-outage failures no longer consume the retry budget: they are parked with count_attempt=False on a flat 15-minute backoff so an hours-long outage cannot exhaust retries and drop the notification. Tests: repository 16, worker 21, scheduled-task service 31; targeted regression across notification/scheduler/channel suites: 511 passed. * fix(scheduler): address round-2 review on IM notification delivery Park WeCom transport/outage failures without burning retries, bound worker stop, include task titles, align enqueue with delivery, and document the IM push behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(scheduler): complete IM notification docs and drop unused list API Add backend/channels/migrations AGENTS notes, CHANGELOG and config.example hints for the outbox push path, and remove the unused list_by_task_run repository method flagged in review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): address round-3 review on IM notification delivery Cap channel-down parking by row age and parked attempts, redact IM egress content, narrow WeCom transport exceptions, add lifespan wiring tests, and run ruff format so lint-backend passes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): redact entire PEM blocks in IM egress text The previous BEGIN-only pattern left key material and the END marker in notifications. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): align 0014 parked_attempts with bootstrap schema tests Backfill existing rows with a temporary server default, then drop it so create_all matches Alembic, and move HEAD anchors to 0014. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): format notification tests and shorten alembic revision id ruff format --check failed on test_notification_delivery_worker.py. 0015_notification_delivery_parked_attempts exceeds alembic_version.version_num (VARCHAR 32) and truncates on Postgres CI; use 0015_parked_attempts instead. * test: fix post-merge CI failures after migration renumber Update migration 0015 test HEAD expectation to 0017_parked_attempts and add queue_timeout_seconds to the lifespan notification worker mock scheduler config. * fix(persistence): chain the notification outbox migrations after 0026 Main's head moved from 0016 to 0026_mcp_task_lease_tokens, so the two outbox revisions are renumbered and re-parented: - 0017_notification_deliveries -> 0027_notification_deliveries - 0018_parked_attempts -> 0028_parked_attempts Only the revision ids, parents and the migration guide change. * fix(scheduler): adapt the notification hook and its docs to main - tests: the completion tests assert on the atomic complete_run record; new cases cover a completion that was not recorded, a task deleted meanwhile, a failed task read, and the outbox being off. - AGENTS.md: drop the root note and shorten the backend note to a pointer; the two notes pushed two effective guidance chains past the 96 KiB limit. - CHANGELOG: move the entry from the released 2.1.0 section to Unreleased and add its link references. * test(persistence): move the chain-head pin to the outbox migrations test_migration_0026 pinned 0026 as the head, so it failed once the outbox revisions chained after it. It now checks chain membership like the older migration tests, and a new test file owns the head pin, checks the parents and the 32-character revision id limit, and runs the upgrade and downgrade. * fix(gateway): wire scheduled-task notifications after the channel service starts The outbox wiring sat in the scheduler block and asked for the running channel service there. #5035 moved the channel service start to after the scheduler, and the two changes merged without a conflict, so on every startup the lookup returned None and the feature disabled itself with the "write-only outbox" warning. The lifespan tests patched the lookup to a constant and kept passing. The wiring now runs in its own step after the channel service start. The scheduler is built as on main, and ScheduledTaskService gains attach_notification_outbox() so the enqueue side is enabled once the delivery worker is running. The guard, the worker and the shutdown order are unchanged. Tests: the lifespan helper can model the real accessor (None until the channel service has started); a new case fails without this fix. * fix(scheduler): enqueue from scheduler start and keep the push when the title read fails Follow-up to the startup-order fix, from review. - The enqueue side is wired with the scheduler again (it only needs the durable table), and only the delivery worker waits for the channel service. Attaching it later left a window after the scheduler started in which a finished run wrote no outbox row. If the channel service is missing or the worker cannot start, the enqueue side is detached, so the outbox still never becomes write-only. - A failed task read after complete_run no longer drops the notification: the title is optional and the message falls back to the task id. A task deleted meanwhile still stays silent. * fix(channels): park WeCom notifications while the SDK socket is closed The WeCom SDK raises a plain RuntimeError when its socket is not open, and send_notification only mapped ConnectionError, TimeoutError and OSError to ChannelUnavailable. A disconnect therefore spent the counted retry budget and settled the row as failed after about 15 minutes, although the docs say transport outages park without consuming retries. send_notification now asks the client's is_connected first. The new test uses a real, never-connected SDK client: it raised RuntimeError after three retries before, and parks now. * docs(scheduler): describe which outcomes notify; mirror the changelog entry - README (en/zh) and CONFIGURATION.md: only runs that finish as success or failed notify; occurrences that end without a finished run (launch error, queue timeout, restart recovery) do not. Parking lasts up to about a day. - CHANGELOG: cite the issue as text like the rest of the file, and add the entry and link reference to CHANGELOG_zh.md. - AGENTS.md: drop the backend pointer so this branch adds nothing to the guidance chains closest to their size limit; the scheduler-side rules and the lifespan order now live in app/channels/AGENTS.md. * docs(changelog): cite #6135 next to #4843 for the notification outbox entry * fix(scheduler,channels): re-check the IM binding at delivery time; park WeCom transport errors, count its rejections Review round 1 on #6135. The delivery worker now takes resolve_connections (the Gateway wires ChannelConnectionRepository.list_connections) and, before the channel liveness check and the summary read, requires the row's owner to still hold a connected (provider, external_account_id). A disconnected target is finalized with mark_failed(terminal=True); a lookup failure or an unexpected result shape parks the row. Terminal outbox rows ignore later status writes. WeCom: the SDK raises RuntimeError from send_message for a socket that is not open and for a dropped connection, but also for a platform rejection ("Reply ack error: errcode=..."). Only that prefix keeps counting; other RuntimeErrors, ConnectionError/TimeoutError/OSError and websockets' ConnectionClosed park the row as ChannelUnavailable. * fix(channels): treat a WeCom ACK rejection as proof the transport is up; pin the rejection prefix through the retry branch and the real connection-row shape --------- Co-authored-by: GaoTianyuanhr <75788039+GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: GaoTianyuanhr <GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
743 lines
30 KiB
Python
743 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import socket
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from deerflow.persistence.scheduled_task_runs import ActiveScheduledRunConflict, ScheduledTaskAdmissionRejected
|
|
from deerflow.runtime import ConflictError, RunRecord
|
|
from deerflow.scheduler.schedules import next_run_at
|
|
from deerflow.trace_context import ensure_trace_context
|
|
from deerflow.utils.thread_id import validate_thread_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Shared so the active-row fast path and the atomic-admission conflict path
|
|
# return byte-identical outcomes for the same active-occurrence condition.
|
|
_ACTIVE_RUN_CONFLICT_ERROR = "task already has an active run"
|
|
_RESTART_RECOVERY_ERROR = "interrupted: gateway restarted before the run reached a terminal state"
|
|
_LEASE_RECOVERY_ERROR = "interrupted: the owning gateway stopped renewing its run lease"
|
|
_QUEUE_TIMEOUT_ERROR = "scheduled task queue wait timeout exceeded"
|
|
|
|
|
|
class ScheduledTaskService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
task_repo,
|
|
task_run_repo,
|
|
launch_run,
|
|
poll_interval_seconds: int,
|
|
lease_seconds: int,
|
|
max_concurrent_runs: int,
|
|
queue_timeout_seconds: int = 3600,
|
|
multi_instance: bool = False,
|
|
run_lease_grace_seconds: int = 10,
|
|
connection_repo=None,
|
|
notification_repo=None,
|
|
) -> None:
|
|
self._task_repo = task_repo
|
|
self._task_run_repo = task_run_repo
|
|
self._launch_run = launch_run
|
|
self._poll_interval_seconds = poll_interval_seconds
|
|
self._lease_seconds = lease_seconds
|
|
self._max_concurrent_runs = max_concurrent_runs
|
|
self._queue_timeout_seconds = queue_timeout_seconds
|
|
self._multi_instance = multi_instance
|
|
self._run_lease_grace_seconds = run_lease_grace_seconds
|
|
# Notification outbox wiring (issue #4254): both repos must be present
|
|
# for the feature to be active; the completion hook only enqueues, the
|
|
# delivery worker sends. Either being None keeps legacy behavior.
|
|
self._connection_repo = connection_repo
|
|
self._notification_repo = notification_repo
|
|
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
|
|
self._task: asyncio.Task | None = None
|
|
self._stop = asyncio.Event()
|
|
self._skip_next_lease_reconciliation = False
|
|
|
|
def detach_notification_outbox(self) -> None:
|
|
"""Stop enqueueing run notifications (issue #4254).
|
|
|
|
The Gateway calls this when the delivery side cannot start, so the outbox
|
|
does not fill with rows nothing will send.
|
|
"""
|
|
self._connection_repo = None
|
|
self._notification_repo = None
|
|
|
|
async def run_once(self, *, now: datetime) -> None:
|
|
if self._multi_instance:
|
|
if self._skip_next_lease_reconciliation:
|
|
self._skip_next_lease_reconciliation = False
|
|
else:
|
|
await self._reconcile_active_state(now=now)
|
|
else:
|
|
await self._task_run_repo.recover_expired_launch_claims(
|
|
error=_LEASE_RECOVERY_ERROR,
|
|
now=now,
|
|
)
|
|
await self._expire_waiting_runs(now=now)
|
|
await self._drain_queue(now=now)
|
|
# Admission and execution capacity are separate. Due occurrences are
|
|
# persisted even when all execution slots are busy; claim_queued_run()
|
|
# applies the global launch budget under the database lock.
|
|
claimed = await self._task_repo.claim_due_tasks(
|
|
now=now,
|
|
lease_owner=self._lease_owner,
|
|
lease_seconds=self._lease_seconds,
|
|
limit=self._max_concurrent_runs,
|
|
)
|
|
for task in claimed:
|
|
await self.dispatch_task(task, now=now, trigger="scheduled")
|
|
|
|
@staticmethod
|
|
def _is_overlap_conflict(exc: Exception) -> bool:
|
|
if isinstance(exc, ConflictError):
|
|
return True
|
|
return isinstance(exc, HTTPException) and exc.status_code == 409
|
|
|
|
@staticmethod
|
|
def _task_status_for_failure(task: dict[str, Any], *, trigger: str) -> str:
|
|
if trigger == "manual":
|
|
# A failed manual trigger must not consume the task's scheduled
|
|
# future: a `once` task with run_at still ahead would otherwise be
|
|
# flipped to "failed" and never claimed again.
|
|
return task.get("status") or "enabled"
|
|
if task["schedule_type"] == "once":
|
|
return "failed"
|
|
return "enabled"
|
|
|
|
@staticmethod
|
|
def _task_status_for_launch(task: dict[str, Any], *, trigger: str) -> str:
|
|
# The task-level status to write once _launch_run has produced a live
|
|
# run. A `once` task stays "running" until handle_run_completion
|
|
# observes the real terminal outcome; declaring "completed" at launch
|
|
# would stick if the run fails or the process dies (startup
|
|
# reconciliation is cancel_stuck_once_tasks).
|
|
if task["schedule_type"] == "once":
|
|
return "running"
|
|
if trigger == "manual" and task.get("status") == "paused":
|
|
return "paused"
|
|
return "enabled"
|
|
|
|
async def dispatch_task(
|
|
self,
|
|
task: dict[str, Any],
|
|
*,
|
|
now: datetime,
|
|
trigger: str,
|
|
) -> dict[str, Any]:
|
|
expected_lease_owner = self._lease_owner if trigger == "scheduled" else None
|
|
execution_thread_id = task.get("thread_id")
|
|
if task.get("context_mode") == "fresh_thread_per_run" or execution_thread_id is None:
|
|
execution_thread_id = str(uuid.uuid4())
|
|
try:
|
|
validate_thread_id(execution_thread_id)
|
|
except ValueError as exc:
|
|
# Rows persisted before the thread-id contract was centralized may
|
|
# hold IDs that were valid then (dots, unlimited length) but fail
|
|
# the canonical pattern now. Route through the normal failure
|
|
# bookkeeping instead of raising: an uncaught ValueError here would
|
|
# surface as HTTP 500 on manual trigger and, in the poller, abort
|
|
# the rest of the claimed batch every cycle while the task itself
|
|
# is never marked with last_error.
|
|
task_status = self._task_status_for_failure(task, trigger=trigger)
|
|
await self._task_repo.update_after_launch(
|
|
task["id"],
|
|
status=task_status,
|
|
next_run_at=next_run_at(
|
|
task["schedule_type"],
|
|
task["schedule_spec"],
|
|
task["timezone"],
|
|
now=now,
|
|
),
|
|
last_run_at=now,
|
|
last_run_id=None,
|
|
last_thread_id=execution_thread_id,
|
|
last_error=str(exc),
|
|
increment_run_count=False,
|
|
expected_lease_owner=expected_lease_owner,
|
|
)
|
|
return {
|
|
"outcome": "failed",
|
|
"task_run_id": None,
|
|
"run_id": None,
|
|
"thread_id": execution_thread_id,
|
|
"error": str(exc),
|
|
}
|
|
active = await self._task_run_repo.get_active_run(task["id"])
|
|
if active is not None:
|
|
if trigger == "scheduled":
|
|
await self._release_admission_lease(task, trigger=trigger)
|
|
return self._existing_active_result(active, execution_thread_id, trigger=trigger)
|
|
|
|
task_run_id = f"task-run-{uuid.uuid4().hex}"
|
|
try:
|
|
await self._task_run_repo.create(
|
|
run_record_id=task_run_id,
|
|
task_id=task["id"],
|
|
thread_id=execution_thread_id,
|
|
scheduled_for=now,
|
|
trigger=trigger,
|
|
status="queued",
|
|
coordinate_with_task=True,
|
|
expected_task_user_id=task.get("user_id"),
|
|
expected_task_status=task.get("status") if trigger == "manual" else None,
|
|
expected_task_updated_at=task.get("updated_at") if trigger == "manual" else None,
|
|
expected_task_lease_owner=self._lease_owner if trigger == "scheduled" else None,
|
|
release_task_lease_status="enabled" if trigger == "scheduled" else None,
|
|
)
|
|
except ActiveScheduledRunConflict:
|
|
active = await self._task_run_repo.get_active_run(task["id"])
|
|
if trigger == "scheduled":
|
|
await self._release_admission_lease(task, trigger=trigger)
|
|
if active is None:
|
|
return self._active_run_conflict_result(execution_thread_id)
|
|
return self._existing_active_result(active, execution_thread_id, trigger=trigger)
|
|
except ScheduledTaskAdmissionRejected as exc:
|
|
if exc.reason == "not_found":
|
|
return {
|
|
"outcome": "not_found",
|
|
"task_run_id": None,
|
|
"run_id": None,
|
|
"thread_id": execution_thread_id,
|
|
"error": "scheduled task no longer exists",
|
|
}
|
|
return {
|
|
"outcome": "conflict",
|
|
"task_run_id": None,
|
|
"run_id": None,
|
|
"thread_id": execution_thread_id,
|
|
"error": "scheduled task changed before trigger admission",
|
|
}
|
|
|
|
# Scheduled admission inserted the queue row and released its parent
|
|
# lease in one transaction. Manual admission verified that this task
|
|
# snapshot was still current under the same parent lock.
|
|
queued = {
|
|
"id": task_run_id,
|
|
"task_id": task["id"],
|
|
"thread_id": execution_thread_id,
|
|
"trigger": trigger,
|
|
}
|
|
return await self._attempt_queued_run(task, queued, now=now)
|
|
|
|
async def _release_admission_lease(self, task: dict[str, Any], *, trigger: str) -> None:
|
|
status = "enabled" if trigger == "scheduled" else (task.get("status") or "enabled")
|
|
await self._task_repo.release_dispatch_lease(
|
|
task["id"],
|
|
expected_lease_owner=self._lease_owner if trigger == "scheduled" else None,
|
|
status=status,
|
|
)
|
|
|
|
async def _attempt_queued_run(
|
|
self,
|
|
task: dict[str, Any],
|
|
queued: dict[str, Any],
|
|
*,
|
|
now: datetime,
|
|
) -> dict[str, Any]:
|
|
"""Turn one queued occurrence into a live run under its own trace scope.
|
|
|
|
The poller is a non-HTTP entry point, so no ``TraceMiddleware`` has
|
|
bound anything: each occurrence opens its own scope rather than
|
|
sharing one id across a whole poll cycle. A manual trigger arrives
|
|
inside a Gateway request and keeps that request's trace instead, so
|
|
the launched run stays correlated with the call that asked for it.
|
|
"""
|
|
with ensure_trace_context():
|
|
return await self._launch_queued_occurrence(task, queued, now=now)
|
|
|
|
async def _launch_queued_occurrence(
|
|
self,
|
|
task: dict[str, Any],
|
|
queued: dict[str, Any],
|
|
*,
|
|
now: datetime,
|
|
) -> dict[str, Any]:
|
|
task_run_id = queued["id"]
|
|
execution_thread_id = queued["thread_id"]
|
|
trigger = queued["trigger"]
|
|
claimed = await self._task_run_repo.claim_queued_run(
|
|
task_run_id,
|
|
lease_owner=self._lease_owner,
|
|
now=now,
|
|
lease_seconds=self._lease_seconds,
|
|
global_max_concurrent_runs=self._max_concurrent_runs,
|
|
)
|
|
if claimed is None:
|
|
return self._queued_result(task_run_id, execution_thread_id)
|
|
|
|
# Track whether _launch_run has produced a live run. A bookkeeping
|
|
# failure after launch must retain the non-terminal slot so a later
|
|
# poll cannot start the same occurrence twice.
|
|
launched_run_id: str | None = None
|
|
launched_thread_id: str | None = None
|
|
launch_succeeded = False
|
|
try:
|
|
result = await self._launch_run(
|
|
thread_id=execution_thread_id,
|
|
assistant_id=task.get("assistant_id"),
|
|
prompt=task["prompt"],
|
|
owner_user_id=task.get("user_id"),
|
|
metadata={
|
|
"scheduled_task_id": task["id"],
|
|
"scheduled_task_run_id": task_run_id,
|
|
"scheduled_trigger": trigger,
|
|
},
|
|
)
|
|
launch_succeeded = True
|
|
launched_run_id = result["run_id"]
|
|
launched_thread_id = result["thread_id"]
|
|
next_at = next_run_at(
|
|
task["schedule_type"],
|
|
task["schedule_spec"],
|
|
task["timezone"],
|
|
now=now,
|
|
)
|
|
task_status = self._task_status_for_launch(task, trigger=trigger)
|
|
await self._record_launched_run(
|
|
task_run_id=task_run_id,
|
|
task_id=task["id"],
|
|
run_id=launched_run_id,
|
|
started_at=now,
|
|
)
|
|
await self._task_repo.update_after_launch(
|
|
task["id"],
|
|
status=task_status,
|
|
next_run_at=next_at,
|
|
last_run_at=now,
|
|
last_run_id=launched_run_id,
|
|
last_thread_id=launched_thread_id,
|
|
last_error=None,
|
|
increment_run_count=True,
|
|
task_run_id=task_run_id,
|
|
# Same race as the run-row write above: a fast-failing run's
|
|
# completion hook may have already finalized a `once` task.
|
|
protect_terminal=True,
|
|
)
|
|
return {
|
|
"outcome": "launched",
|
|
"task_run_id": task_run_id,
|
|
"run_id": launched_run_id,
|
|
"thread_id": launched_thread_id,
|
|
"error": None,
|
|
}
|
|
except Exception as exc:
|
|
if not launch_succeeded and self._is_overlap_conflict(exc):
|
|
await self._task_run_repo.requeue_claimed_run(
|
|
task_run_id,
|
|
lease_owner=self._lease_owner,
|
|
error=str(exc),
|
|
)
|
|
return self._queued_result(task_run_id, execution_thread_id, error=str(exc))
|
|
|
|
next_at = next_run_at(
|
|
task["schedule_type"],
|
|
task["schedule_spec"],
|
|
task["timezone"],
|
|
now=now,
|
|
)
|
|
|
|
if launch_succeeded:
|
|
# _launch_run succeeded, so a run is live even though
|
|
# post-launch bookkeeping raised. Keep the task-run row
|
|
# "running" so it keeps holding the task's single active slot
|
|
# (preventing a duplicate launch on the next dispatch) and
|
|
# persist the run_id on the parent task for recovery /
|
|
# reconciliation / cancellation. These writes are best-effort:
|
|
# if the DB is still down the row stays "queued" -- still
|
|
# active, still holding the slot -- so we log and still report
|
|
# the run as launched so callers know a run is in flight.
|
|
task_status = self._task_status_for_launch(task, trigger=trigger)
|
|
try:
|
|
await self._record_launched_run(
|
|
task_run_id=task_run_id,
|
|
task_id=task["id"],
|
|
run_id=launched_run_id,
|
|
started_at=now,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Scheduled task-run %s: post-launch bookkeeping failed; run %s is still live (task %s)",
|
|
task_run_id,
|
|
launched_run_id,
|
|
task["id"],
|
|
)
|
|
try:
|
|
await self._task_repo.update_after_launch(
|
|
task["id"],
|
|
status=task_status,
|
|
next_run_at=next_at,
|
|
last_run_at=now,
|
|
last_run_id=launched_run_id,
|
|
last_thread_id=launched_thread_id,
|
|
# The bookkeeping exception is an infrastructure-level
|
|
# transient, not a run-level failure: the run launched
|
|
# and is still in flight. Clear last_error like the
|
|
# success path so the task list does not show an error
|
|
# on a task whose run is actively running; the real
|
|
# terminal outcome is written by handle_run_completion.
|
|
# The transient itself is logged above.
|
|
last_error=None,
|
|
increment_run_count=True,
|
|
task_run_id=task_run_id,
|
|
protect_terminal=True,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Scheduled task %s: post-launch update failed; run %s is still live",
|
|
task["id"],
|
|
launched_run_id,
|
|
)
|
|
return {
|
|
"outcome": "launched",
|
|
"task_run_id": task_run_id,
|
|
"run_id": launched_run_id,
|
|
"thread_id": launched_thread_id,
|
|
"error": str(exc),
|
|
}
|
|
|
|
# _launch_run itself failed (or a step before it did): no live run
|
|
# was created, so it is safe to release the active slot.
|
|
finalized = await self._task_run_repo.fail_launching_run(
|
|
task_run_id,
|
|
task_id=task["id"],
|
|
lease_owner=self._lease_owner,
|
|
error=str(exc),
|
|
now=now,
|
|
)
|
|
if not finalized:
|
|
logger.warning(
|
|
"Scheduled task-run %s lost its launch claim before failure bookkeeping; leaving recovery-owned state unchanged",
|
|
task_run_id,
|
|
)
|
|
return self._queued_result(task_run_id, execution_thread_id, error=str(exc))
|
|
return {
|
|
"outcome": "failed",
|
|
"task_run_id": task_run_id,
|
|
"run_id": None,
|
|
"thread_id": execution_thread_id,
|
|
"error": str(exc),
|
|
}
|
|
|
|
async def _record_launched_run(
|
|
self,
|
|
*,
|
|
task_run_id: str,
|
|
task_id: str,
|
|
run_id: str,
|
|
started_at: datetime,
|
|
) -> None:
|
|
updated = await self._task_run_repo.update_status(
|
|
task_run_id,
|
|
status="running",
|
|
run_id=run_id,
|
|
started_at=started_at,
|
|
protect_terminal=True,
|
|
expected_lease_owner=self._lease_owner,
|
|
)
|
|
if updated:
|
|
return
|
|
reconciled = await self._task_run_repo.reconcile_launched_run(
|
|
task_run_id,
|
|
task_id=task_id,
|
|
run_id=run_id,
|
|
started_at=started_at,
|
|
)
|
|
if not reconciled:
|
|
logger.error(
|
|
"Scheduled task-run %s launched durable run %s but could not restore its occurrence association",
|
|
task_run_id,
|
|
run_id,
|
|
)
|
|
|
|
def _active_run_conflict_result(self, thread_id: str) -> dict[str, Any]:
|
|
"""Manual-trigger response when the task already has an active run.
|
|
|
|
Nothing was scheduled to happen, so no run-history row is recorded; the
|
|
router maps this to a 409.
|
|
"""
|
|
return {
|
|
"outcome": "conflict",
|
|
"task_run_id": None,
|
|
"run_id": None,
|
|
"thread_id": thread_id,
|
|
"error": _ACTIVE_RUN_CONFLICT_ERROR,
|
|
}
|
|
|
|
def _existing_active_result(
|
|
self,
|
|
active: dict[str, Any],
|
|
thread_id: str,
|
|
*,
|
|
trigger: str,
|
|
) -> dict[str, Any]:
|
|
if active["status"] == "queued":
|
|
return self._queued_result(active["id"], active["thread_id"])
|
|
return self._active_run_conflict_result(thread_id)
|
|
|
|
@staticmethod
|
|
def _queued_result(
|
|
task_run_id: str,
|
|
thread_id: str,
|
|
*,
|
|
error: str | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"outcome": "queued",
|
|
"task_run_id": task_run_id,
|
|
"run_id": None,
|
|
"thread_id": thread_id,
|
|
"error": error,
|
|
}
|
|
|
|
async def _drain_queue(self, *, now: datetime) -> None:
|
|
queued_rows = await self._task_run_repo.list_queued_runs(limit=max(16, self._max_concurrent_runs * 4))
|
|
for queued in queued_rows:
|
|
await self._task_repo.release_queued_admission_lease(queued["task_id"])
|
|
task = await self._task_repo.get_internal(queued["task_id"])
|
|
if task is None:
|
|
await self._task_run_repo.update_status(
|
|
queued["id"],
|
|
status="interrupted",
|
|
error="scheduled task was deleted while queued",
|
|
finished_at=now,
|
|
)
|
|
continue
|
|
# Pausing suppresses automatic occurrences, but a manual trigger is
|
|
# an explicit request and has always been allowed to run without
|
|
# resuming the schedule. A later pause still cancels an already
|
|
# queued manual row atomically in pause_with_queue_cancellation().
|
|
if task.get("status") == "paused" and queued["trigger"] != "manual":
|
|
await self._task_run_repo.update_status(
|
|
queued["id"],
|
|
status="interrupted",
|
|
error="scheduled task was paused while queued",
|
|
finished_at=now,
|
|
)
|
|
continue
|
|
await self._attempt_queued_run(task, queued, now=now)
|
|
|
|
async def _expire_waiting_runs(self, *, now: datetime) -> None:
|
|
await self._task_run_repo.expire_queued_runs(
|
|
created_before=now - timedelta(seconds=self._queue_timeout_seconds),
|
|
error=_QUEUE_TIMEOUT_ERROR,
|
|
now=now,
|
|
)
|
|
|
|
async def handle_run_completion(self, record: RunRecord) -> None:
|
|
metadata = record.metadata or {}
|
|
task_id = metadata.get("scheduled_task_id")
|
|
task_run_id = metadata.get("scheduled_task_run_id")
|
|
user_id = record.user_id
|
|
if not isinstance(task_id, str) or not isinstance(task_run_id, str) or not user_id:
|
|
return
|
|
|
|
terminal_status: Literal["success", "failed", "interrupted"] | None
|
|
if record.status.value == "success":
|
|
terminal_status = "success"
|
|
error = None
|
|
elif record.status.value == "interrupted":
|
|
# Distinct from "failed": an interrupt (user cancel, same-thread
|
|
# takeover) carries no error and is not an execution failure.
|
|
terminal_status = "interrupted"
|
|
error = record.error or "run was interrupted before completion"
|
|
elif record.status.value in {"error", "timeout"}:
|
|
terminal_status = "failed"
|
|
error = record.error
|
|
else:
|
|
terminal_status = None
|
|
error = record.error
|
|
if terminal_status is None:
|
|
return
|
|
|
|
completed = await self._task_repo.complete_run(
|
|
task_id,
|
|
user_id=user_id,
|
|
task_run_id=task_run_id,
|
|
run_id=record.run_id,
|
|
status=terminal_status,
|
|
error=error,
|
|
finished_at=datetime.now(UTC),
|
|
)
|
|
if not completed:
|
|
# The occurrence was not this run's to complete (missing row, another
|
|
# run, another owner): nothing was recorded, so nothing is announced.
|
|
return
|
|
|
|
if metadata.get("scheduled_trigger") == "manual":
|
|
# A manual "run now" happens with the user watching the UI; the
|
|
# IM push would only echo what they already see. Missing
|
|
# metadata fails safe towards notifying.
|
|
return
|
|
|
|
if self._notification_repo is None or self._connection_repo is None:
|
|
return
|
|
# complete_run commits the outcome atomically without returning the task,
|
|
# so the title is read afterwards. A task deleted meanwhile stays silent.
|
|
# A failed read must not shadow the committed outcome or drop the push:
|
|
# the title is optional and the message falls back to the task id.
|
|
task_title = None
|
|
try:
|
|
task = await self._task_repo.get(task_id, user_id=user_id)
|
|
except Exception:
|
|
logger.exception("[Scheduler] failed to load task %s for notifications", task_id)
|
|
else:
|
|
if task is None:
|
|
return
|
|
task_title = task.get("title")
|
|
|
|
await self._enqueue_run_notifications(
|
|
task_id=task_id,
|
|
task_run_id=task_run_id,
|
|
run_id=record.run_id,
|
|
user_id=user_id,
|
|
terminal_status=terminal_status,
|
|
error=error,
|
|
task_title=task_title,
|
|
)
|
|
|
|
async def _enqueue_run_notifications(
|
|
self,
|
|
*,
|
|
task_id: str,
|
|
task_run_id: str,
|
|
run_id: str | None,
|
|
user_id: str,
|
|
terminal_status: str,
|
|
error: str | None,
|
|
task_title: str | None = None,
|
|
) -> None:
|
|
"""Write durable outbox rows for the run outcome (issue #4254).
|
|
|
|
Best-effort by design: a notification failure is logged and swallowed
|
|
so delivery problems can never shadow the execution status written
|
|
above. Interrupted runs are user-initiated cancels and intentionally
|
|
produce no notification.
|
|
"""
|
|
if self._notification_repo is None or self._connection_repo is None:
|
|
return
|
|
event = {"success": "run_completed", "failed": "run_failed"}.get(terminal_status)
|
|
if event is None:
|
|
return
|
|
try:
|
|
connections = await self._connection_repo.list_connections(user_id)
|
|
except Exception:
|
|
logger.exception("[Scheduler] failed to list channel connections for notifications")
|
|
return
|
|
payload = {
|
|
"run_status": terminal_status,
|
|
"error": error,
|
|
"task_id": task_id,
|
|
"task_title": task_title,
|
|
}
|
|
for connection in connections:
|
|
if connection.get("status") != "connected":
|
|
continue
|
|
provider = connection.get("provider")
|
|
target = connection.get("external_account_id")
|
|
if not provider or not target:
|
|
continue
|
|
try:
|
|
await self._notification_repo.enqueue(
|
|
task_id=task_id,
|
|
task_run_id=task_run_id,
|
|
run_id=run_id,
|
|
event=event,
|
|
provider=provider,
|
|
target=target,
|
|
owner_user_id=user_id,
|
|
payload=payload,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"[Scheduler] failed to enqueue notification task_run=%s provider=%s target=%s",
|
|
task_run_id,
|
|
provider,
|
|
target,
|
|
)
|
|
|
|
async def start(self) -> None:
|
|
if self._task is not None:
|
|
return
|
|
restart_error = _RESTART_RECOVERY_ERROR
|
|
if self._multi_instance:
|
|
await self._reconcile_active_state(now=datetime.now(UTC))
|
|
self._skip_next_lease_reconciliation = True
|
|
else:
|
|
# This destructive sweep is safe only while Gateway lifespan awaits
|
|
# start(): no request or poll admission can create a run owned by
|
|
# this process yet. Complete occurrence -> parent recovery before
|
|
# returning; moving either pass into run_once() can interrupt live
|
|
# work or race manual admission.
|
|
try:
|
|
stale = await self._task_run_repo.mark_stale_active_runs(error=restart_error)
|
|
if stale:
|
|
logger.warning("Marked %d stale scheduled task run(s) as interrupted after restart", stale)
|
|
except Exception:
|
|
logger.exception("Failed to sweep stale scheduled task runs at startup")
|
|
raise
|
|
try:
|
|
# The run rows above are only half the story: a launched `once`
|
|
# task is parked in "running" until the (now dead) completion hook
|
|
# would have finalized it.
|
|
stuck = await self._task_repo.cancel_stuck_once_tasks(error=restart_error)
|
|
if stuck:
|
|
logger.warning("Reconciled %d stuck once task(s) after restart", stuck)
|
|
except Exception:
|
|
logger.exception("Failed to reconcile stuck once tasks at startup")
|
|
raise
|
|
self._stop.clear()
|
|
self._task = asyncio.create_task(self._run_loop())
|
|
|
|
async def _reconcile_active_state(self, *, now: datetime) -> None:
|
|
error = _LEASE_RECOVERY_ERROR
|
|
try:
|
|
stale = await self._task_run_repo.reconcile_active_runs(
|
|
error=error,
|
|
now=now,
|
|
lease_grace_seconds=self._run_lease_grace_seconds,
|
|
)
|
|
if stale:
|
|
logger.warning("Marked %d stale scheduled task run(s) as interrupted after lease reconciliation", stale)
|
|
except Exception:
|
|
logger.exception("Failed to reconcile scheduled task runs with leases")
|
|
try:
|
|
stuck = await self._task_repo.reconcile_stuck_once_tasks(
|
|
error=error,
|
|
now=now,
|
|
lease_grace_seconds=self._run_lease_grace_seconds,
|
|
)
|
|
if stuck:
|
|
logger.warning("Reconciled %d stuck once task(s) after lease reconciliation", stuck)
|
|
except Exception:
|
|
logger.exception("Failed to reconcile once tasks with leases")
|
|
|
|
async def stop(self) -> None:
|
|
if self._task is None:
|
|
return
|
|
self._stop.set()
|
|
await self._task
|
|
self._task = None
|
|
|
|
async def _run_loop(self) -> None:
|
|
while not self._stop.is_set():
|
|
try:
|
|
await self.run_once(now=datetime.now(UTC))
|
|
except Exception:
|
|
# A transient DB error (e.g. SQLite "database is locked") must
|
|
# not kill the poller task for the rest of the process life.
|
|
logger.exception("Scheduled task poll failed; retrying next interval")
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._stop.wait(),
|
|
timeout=self._poll_interval_seconds,
|
|
)
|
|
except TimeoutError:
|
|
continue
|