Background & scheduled tasks

Both schedulers follow one rule: persist first, then queue. Background tasks handle one-off heavy work (embeddings, graph, Wiki, board execution); scheduled tasks handle recurring work (skills, AI tasks, graph rebuild, vector rebuild). The former de-duplicates with a database unique index plus an in-memory semaphore; the latter decides timing from NextRunAt and cron expressions.

Enqueue coordinator semaphore-serialised + batch dedupe persist, then queue in memory Single consumer claim marks it Running 5 task branches Sweep & recovery scans every 2 minutes only stale beyond 5 minutes Scheduled tasks interval or 5-field cron due when NextRunAt <= now Task types: GenerateEmbedding · GenerateKnowledgeItemEmbedding · GraphExtract · KanbanTaskExecute · WikiGenerate Shutdown requeues; explicit cancel marks failure Executors: Skill · AiTask · GraphRebuild · EmbeddingRegenerate
Both paths share the same pattern: persist the intent, run it, then write the outcome back to the same row.

Background task model

Field / constraintNotes
TaskItemTaskType · EntityId · EntityTitle · Status · ErrorMessage · Metadata · StartedAt · CompletedAt · IsDeleted
Status0 queued · 1 running · 2 completed · 3 failed
Unique indexIX_TaskItems_ActiveUniqueness filtered to "queued or running and not deleted", so one (TaskType, EntityId) can only have one active task
IndexesTaskType · Status · IsDeleted

Enqueue and dedupe

  1. A SemaphoreSlim(1, 1) serialises "check, insert, enqueue" so concurrent enqueues cannot duplicate.
  2. Batch enqueues dedupe (TaskType, EntityId) pairs inside the batch first.
  3. Targets that already have an active record (queued or running) are skipped.
  4. The record is saved with SaveChangesAsync before QueueAsync puts it in the in-memory queue.
  5. The consumer claims the record, marking it running, and writes that back to the database (ClaimAsync).
The queue itself is in memory and the database is the source of truth: after a restart, startup recovery plus the periodic sweep pick up what the queue lost instead of persisting the queue.

Consumption, sweep and recovery

ItemBehaviour
ConsumerOne background service loops over the queue; there is no multi-threaded consumption
Startup recoveryRecovers leftover tasks (queued or stuck records) before entering the consumption loop
Sweep intervalSweepInterval = 2 minutes
StalenessOnly records untouched for more than StaleThreshold = 5 minutes are recovered, so long-running work is not mistaken for a hang
ShutdownAn in-flight task is reset to queued with a cleared start time and the message "service stopped, task requeued"
Explicit cancelMarked failed with "task cancelled"
Execution errorMarked failed with the exception message
Wrap-upThe finally block writes completion and update timestamps regardless of outcome

Task types

TypeWhat it does
GenerateEmbeddingNote vectorisation (see Chunking & vector index)
GenerateKnowledgeItemEmbeddingVectorisation for file and URL knowledge items
GraphExtractGraph extraction for a single note (see Knowledge graph extraction)
KanbanTaskExecuteAutomated board task execution (see Task board & automation)
WikiGenerateWhole-set Wiki generation, with the model id carried in the task Metadata (see Wiki generation)

Scheduled task model and endpoints

Field / endpointNotes
ScheduledTaskName · Description · TaskKind · TargetId / TargetName · Parameters · ScheduleType · IntervalMinutes · CronExpression · IsEnabled · NextRunAt · LastRunAt · LastStatus · LastError · MaxRetries · RetryCount · TopicId
TaskKindSkill · AiTask · GraphRebuild · EmbeddingRegenerate
ScheduleTypeInterval (minutes) or Cron (5 fields: minute hour day month weekday)
Execution recordScheduledTaskExecution: Status (Running / Success / Failed) · ErrorMessage · Result · RetryAttempt · IsManual · start/end times
EndpointsGET /api/scheduled-tasks · GET /api/scheduled-tasks/{id} · GET /api/scheduled-tasks/target-options · POST · PUT · DELETE · POST {id}/toggle · POST {id}/run · GET {id}/executions

Next run time

ModeHow it is computed
IntervalBase is LastRunAt ?? now; next = base + IntervalMinutes. If that is already in the past it rolls to "now + interval", so a long backlog of windows is not replayed
CronCronos parses the 5-field expression and computes the next occurrence
Due checkTasks where IsEnabled && NextRunAt <= now
Run nowPOST {id}/run sets NextRunAt to now for the runner to pick up and returns a placeholder execution record (status queued, IsManual = true)
Update semanticsSaving the configuration resets RetryCount = 0 and recomputes NextRunAt
Delete semanticsSoft delete: IsDeleted = true · IsEnabled = false · NextRunAt = null

Executors

KindBehaviour
SkillRuns a database skill or a local skill: when TargetId starts with local: it resolves to a local skill (see Extensions & tools)
AiTaskParses systemPrompt / prompt from the Parameters JSON, or uses the raw text as the prompt; usage source scheduled
GraphRebuildWalks every note and rebuilds the graph
EmbeddingRegenerateWalks every note and rebuilds embeddings with force: true

Validation, failure and observability

  • Create/update validation rejects unsupported TaskKind; Cron mode requires an expression; Interval mode requires a positive interval; Skill requires TargetId; AiTask requires Parameters.
  • MaxRetries is clamped to 0 or more; a failure while computing the next run is logged as a warning and returns nothing (no schedule for that pass).
  • Database side: Name / TaskKind / ScheduleType are required and CronExpression / LastStatus / LastError have length limits; queries filter soft deletes.
  • Observability: status, error, next run and last status are on the list; history comes from /executions (duration and manual flag included).
  • Logs come from the background services themselves (sweep, recovery, failures); there is no unified scheduled-task log prefix.

Practice notes

  • "One active task per target" is deliberate: clicking vector rebuild twice will not enqueue ten jobs, it reuses the existing one.
  • Long tasks that stay silent for over 5 minutes look stale to the sweep; keep status moving, otherwise a restart can re-deliver them.
  • Interval never replays missed windows: after a day of sleep, scheduling restarts from now.
  • "Rebuild graph" and "rebuild vectors" are full-scale operations — confirm the embedding model and dimensions have not changed first (see Chunking & vector index).