Recurring Tasks & Expiry Automation
TaskLists become active schedulers — missed deadlines and recurring tasks now fire any executor child, turning checklists into maintenance workflows.
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 Kit | Target Range | Cadence |
|---|---|---|
| pH | 6.4 – 7.0 | weekly |
| KH (carbonate hardness) | 3 – 5 °dKH | weekly |
| GH (general hardness) | 6 – 10 °dGH | weekly |
| NO₂ (nitrite) | 0 ppm | weekly |
| NO₃ (nitrate) | 10 – 30 ppm | weekly |
| NH₃ (ammonia) | 0 ppm | weekly |
| PO₄ (phosphate) | 0.5 – 2 ppm | weekly |
| Fe (iron) | 0.1 – 0.5 ppm | weekly |
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:
- Fires
executeChildrenon the TaskList node (once per list per tick, even if several tasks expire together). - Marks the task
expiredExecuted = true— this is the dedup source of truth, so server restarts don’t re-fire. - If the task has a
recurrencecron expression, computesCronLogic.nextExecutionInstant(expression, now)and persists the task with the newdueDateandexpiredExecuted = falseso 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 by | Priority | NodeState |
|---|---|---|
| nothing expired-and-open | any | NONE (default color) |
| ≤ 24h | any non-HIGH | INFO (blue) |
| 1–7 days | any non-HIGH | WARN (orange) |
| > 7 days | any non-HIGH | SEVERE (red) |
| any overdue | HIGH | SEVERE (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.
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.
expiredExecutedas 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.
Related
- Cron Timers — the same scheduling engine, exposed as a standalone Trigger node.
- SMTP Email Alerts — the executor of choice for “email me when I miss a test.”
- Lambda Python Executor — if you want to do something smarter than email on expiry.
Last verified: 2026-04-16

