Post

Recurring Tasks & Expiry Automation

TaskLists become active schedulers — missed deadlines and recurring tasks now fire any executor child, turning checklists into maintenance workflows.

Recurring Tasks & Expiry Automation

From checklist to scheduler

Krill’s TaskList used to be a passive checklist — a handy place to jot down todos, but nothing happened when a due date passed. This release makes the TaskList an active, schedulable workflow primitive: missed tasks fire executeChildren, recurring tasks auto-advance to their next occurrence, and any executor you drop under a TaskList (SMTP, Lambda, Pin, Outgoing Webhook, Compute, MQTT) now runs on every expiry.

The motivating use case is decidedly non-digital: a planted freshwater aquarium weekly water-chemistry panel. Every week the tank needs a round of tests — pH, KH, GH, NO₂, NO₃, NH₃, PO₄, Fe — and skipping a week is easy to do and hard to notice. By turning that weekly routine into a TaskList with seven recurring tasks and one SMTP child, missing a test now emails you instead of silently going stale.

Weekly Water Test Panel

A real-world TaskList for a planted 75-gallon freshwater tank:

Test KitTarget RangeCadence
pH6.4 – 7.0weekly
KH (carbonate hardness)3 – 5 °dKHweekly
GH (general hardness)6 – 10 °dGHweekly
NO₂ (nitrite)0 ppmweekly
NO₃ (nitrate)10 – 30 ppmweekly
NH₃ (ammonia)0 ppmweekly
PO₄ (phosphate)0.5 – 2 ppmweekly
Fe (iron)0.1 – 0.5 ppmweekly

Screenshot: weekly water test TaskList with eight recurring tasks

Each row is a Task with recurrence = "0 0 9 * * MON" (Monday 9am). A single Executor.SMTP under the list sends a reminder email — or you could wire a Executor.Lambda to log the panel into a spreadsheet, a Trigger.OutgoingWebHook to post to a Discord channel, or any combination.

How it works

graph TD
    A[User creates TaskList] --> B[Adds Tasks with due dates & recurrence]
    B --> C{TaskListExpiryTask<br/>polls every 5s}
    C -->|dueDate ≤ now<br/>!isCompleted<br/>!expiredExecuted| D[executeChildren fires]
    D --> E[Any Executor children run:<br/>SMTP / Lambda / Pin / Webhook / …]
    C -->|recurrence non-empty| F[Advance dueDate to next occurrence]
    F --> G[expiredExecuted = false]
    G --> C
    C -->|recurrence empty| H[expiredExecuted = true]
    H -->|no further fires<br/>unless dueDate/isCompleted mutated| C

A server-side poll loop — TaskListExpiryTask — wakes every five seconds, walks every KrillApp.Project.TaskList, and looks for tasks where dueDate != null && dueDate <= now && !isCompleted && !expiredExecuted. When it finds one:

  1. Fires executeChildren on the TaskList node (once per list per tick, even if several tasks expire together).
  2. Marks the task expiredExecuted = true — this is the dedup source of truth, so server restarts don’t re-fire.
  3. If the task has a recurrence cron expression, computes CronLogic.nextExecutionInstant(expression, now) and persists the task with the new dueDate and expiredExecuted = false so it can fire again on the next cycle.

Any code path that mutates dueDate or isCompleted also resets expiredExecuted = false — so a user editing the due date correctly re-arms the expiry.

How expiry escalation works

The TaskList node’s own state reflects how overdue its worst open task is. This color-codes the icon on the dashboard so you can tell from a glance which list needs attention:

Overdue byPriorityNodeState
nothing expired-and-openanyNONE (default color)
≤ 24hany non-HIGHINFO (blue)
1–7 daysany non-HIGHWARN (orange)
> 7 daysany non-HIGHSEVERE (red)
any overdueHIGHSEVERE (red)

HIGH-priority TaskLists short-circuit straight to SEVERE the moment anything slips. This is deliberate — if you’ve tagged a list as HIGH, the point is that any overdue-ness is alarming, not just week-long ones.

The state is computed in two places: server-side on every poll tick (persisted, broadcast via SSE) and client-side in IconManager.getNodeStateColor (derived from meta.tasks on cold launch so the right color appears before the first SSE update arrives). When they disagree, the more severe wins — deliberately conservative, so a stale-but-serene server state can never silence a visibly-overdue list.

Reusable cron editor

The per-task recurrence picker uses exactly the same UI as the CronTimer node — the form was extracted into a public CronExpressionEditor(expression, onChange) composable and both callers now share it. That means every mode (every_seconds, every_minutes, every_hours, daily, weekly, monthly, custom) behaves identically whether you’re configuring a standalone cron trigger or a task’s recurrence.

Screenshot: per-task cron editor expanded inline in EditTaskList

Why this design

  • Poll loop over per-task Job scheduling. A 5-second tick is well below human-facing maintenance precision, and the state machine is stateless across ticks — every edit is automatically picked up on the next iteration. A Jobs-map approach would need complex invalidation on every edit path.
  • expiredExecuted as a persisted boolean. In-memory dedup would re-fire on every server restart; a timestamp field adds state with no consumer. A single boolean cleanly expresses “this has fired; don’t fire again until something resets me.”
  • Frozen session display order in EditTaskList. Checking a task off doesn’t jump the row mid-session — the reorder is persisted and becomes visible next time you open the list, so the experience is less jarring while maintaining a useful sorted order at rest.

Last verified: 2026-04-16

This post is licensed under CC BY 4.0 by Sautner Studio, LLC.