8000 feat(automations): timers that outlast a restart and fire once by adeelahmad · Pull Request #446 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content

feat(automations): timers that outlast a restart and fire once - #446

Merged
adeelahmad merged 2 commits into
mainfrom
claude/project-thread-gauy4d
Oct 16, 2026
Merged

adeelahmad merged 2 commits into
mainfrom
claude/project-thread-gauy4d

Conversation

@adeelahmad
Copy link
Copy Markdown
Owner

Requested by Adeel · project thread

Description

Before: automations had no way to wait for a time. Nothing in Lens could say "run this at 07:00" or "in two hours" and keep that promise across a restart.

After: Lens keeps timers in the database (app/domain/timers.py, workflow studio module M4, part 1 of 3). A timer fires once when it is due, even with the API and lens worker both looking, and a timer that came due while Lens was off still fires if it is less than an hour late (Settings → Automations → "After Lens was off, still run timers up to"). Older ones count as missed. Nothing changes for an install until an automation uses time.

How: one timer row per key (timer:<hash of key>, indexed by (status, at) and (automation, status)). set makes or replaces one, cancel / cancel_for call them off, and @handler(kind) registers what runs. A thread started inside routines.start (so in the API's background work and in lens worker) looks every 5 seconds and at once after events.wake(). It claims each due timer with a compare-and-set UPDATE ... WHERE status = 'waiting' AND at = $at that only one process wins, then runs the handler. A repeating handler (again, used by schedules in the next PR) is re-armed in that same update, so a crash between firing and re-arming can't lose a schedule. The tidy runs from routines.run_due, once an hour.

Motivation and Context

Workflow studio M4 (/mnt/project-files/workflow-studio/briefs/M4.md, plan section 3.10). M5 (joins: absent, batch) needs set, cancel and handler. M6 needs later, which comes in part 3. The PRs that follow:

  1. This one: the timers core and the catch-up setting.
  2. Schedule, at and relative triggers, the clock.tick kind, and upcoming.
  3. Actions that wait (later, intent.perform).

Defaults I picked (all reversible):

  • At most once. A process that dies while a handler runs leaves that firing done, never repeated. The brief asks for exactly once across processes; this gets that, and on a crash it errs toward not firing twice.
  • On time. A timer counts as on time within 2 minutes of its due time (LATE_SECONDS). Inside that window it fires even with catch_up: 0s, so normal polling is never mistaken for downtime.
  • Missed schedules. A repeating timer that missed several times fires once, for its latest time, but only if that time is inside the catch-up window. Otherwise it counts as missed and waits for its next time. Routines fire their latest missed time whatever its age; automations use the window instead, so a 07:00 message doesn't arrive at 15:00.
  • Missed count. The count goes on automation.missed. M6 owns that table, so the update does nothing until M6 stores automations.
  • Idle cache. "No timer waits" is believed for a minute. The worker can therefore fire a timer the API process just set up to a minute late. This is written under Refine later.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change; ! in the title)

Checklist:

  • The title is a Conventional Commit.
  • My code follows the code style of this project (make hooks checks it on commit).
  • I added or updated tests.
  • My change requires documentation updates, and I have updated docs/ accordingly.
  • Users need more than the title to understand it, so I wrote it under ## Unreleased in CHANGELOG.md. (n/a: nothing user-facing yet beyond one setting)

Done questions (AGENTS.md)

  • Goal: part 1 of M4; merged on green CI (parts 2 and 3 follow).
  • Agents: the setting is in /api/v1/settings/automations and the admin assistant's settings tools (ops_tools.SECTIONS). Timers have no API of their own: M6 exposes upcoming at /api/v1/automations/{id}/upcoming.
  • Zero setup: the table comes from SCHEMA (DEFINE ... IF NOT EXISTS), and the thread starts where the routines' threads do.
  • Fewest clicks: one select with the common choices; the default needs no click.
  • Configurable: automations.catch_up (default 1h) in Settings → Automations; not in the wizard.
  • Light: while no timer waits, the table isn't queried (checked once a minute). With timers, a look is one indexed query every 5 seconds.
  • Private and secure: data stays local. No new outside surface (no route, no input from outside); the per-automation cap (1000 waiting) bounds growth. Threat model n/a.
  • Cost: no model calls.
  • User decides: nothing fires until an automation sets a timer; existing defaults unchanged.
  • Upgrades: new table and indexes only, through SCHEMA; no stored data changes shape.
  • Honest: docs/automations.md labels timers built, and triggers and waiting actions planned.
  • Tests (tests/domain/test_timers.py: once across two processes and four threads, replace and cancel, repeating re-arm, late and missed, latest missed time, cap, tidy, idle cache, the thread, the setting through the API), docs/automations.md (Time), docs/configuration.md, docs/database.md. Run locally on embedded SurrealDB and on a 3.2.4 server. No generated files change.
  • Open: no new dependencies.
  • Reported in the thread; refine-later items are in docs/automations.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KfD6cia42GiEZakbs74jxY


Generated by Claude Code

A timer table and timers.py: set (make or replace by key), cancel,
cancel_for (an automation's), and @handler(kind). A thread started with the
routines' threads (the API's background work and lens worker) looks every
5 seconds and at once after events.wake(), claims each due timer with a
compare-and-set update only one process wins, and runs its handler. A
repeating handler (again) is re-armed in that same update. Timers due while
Lens was off fire late within automations.catch_up (1h, Settings ›
Automations) or are missed and counted on their automation; a repeating one
fires once for its latest time. At most 1000 waiting per automation; ended
ones are tidied after 7 days. No query while no timer waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfD6cia42GiEZakbs74jxY
@adeelahmad adeelahmad self-assigned this Oct 15, 2026
…-gauy4d

# Conflicts:
#	docs/automations.md
#	docs/database.md
#	fastapi_backend/app/domain/store.py
@adeelahmad
adeelahmad merged commit faee64d into main Oct 16, 2026
10 checks passed
@adeelahmad
adeelahmad deleted the claude/project-thread-gauy4d branch October 16, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0