@@ -90,6 +90,33 @@ def _get_env() -> dict:
9090 return env
9191
9292
93+ def _parse_comparable_datetime (value : Optional [str ]) -> Optional [datetime ]:
94+ """Parse ISO datetimes and collapse aware values into local naive datetimes.
95+
96+ The app historically stored naive local timestamps, but the Electron UI can
97+ submit offset-aware ISO strings for `scheduled_at`. Converting aware values
98+ into the local timezone and stripping tzinfo keeps storage/comparisons
99+ consistent with the rest of the backend while remaining backward compatible
100+ with legacy rows.
101+ """
102+ if not value :
103+ return None
104+ dt = datetime .fromisoformat (value )
105+ if dt .tzinfo is not None :
106+ return dt .astimezone ().replace (tzinfo = None )
107+ return dt
108+
109+
110+ def _normalize_datetime_for_storage (value : Optional [str ]) -> Optional [str ]:
111+ if value is None :
112+ return None
113+ try :
114+ dt = _parse_comparable_datetime (value )
115+ except ValueError :
116+ return value
117+ return dt .isoformat () if dt else None
118+
119+
93120# ──────────────────────────── Models ────────────────────────────
94121
95122
@@ -623,18 +650,25 @@ def get_all_heartbeats(self) -> list[dict]:
623650 return [self ._deserialize_heartbeat (r ) for r in rows ]
624651
625652 def get_due_heartbeats (self ) -> list [dict ]:
626- now = datetime .now ().isoformat ()
627653 with self .lock :
628654 rows = self .conn .execute (
629655 """
630656 SELECT * FROM heartbeats
631657 WHERE enabled = 1
632658 AND next_run_at IS NOT NULL
633- AND next_run_at <= ?
634- """ ,
635- (now ,),
659+ """
636660 ).fetchall ()
637- return [self ._deserialize_heartbeat (r ) for r in rows ]
661+ now = datetime .now ()
662+ due = []
663+ for row in rows :
664+ heartbeat = self ._deserialize_heartbeat (row )
665+ try :
666+ next_run_at = _parse_comparable_datetime (heartbeat .get ("next_run_at" ))
667+ except ValueError :
668+ continue
669+ if next_run_at and next_run_at <= now :
670+ due .append (heartbeat )
671+ return due
638672
639673 def delete_heartbeat (self , heartbeat_id : int ):
640674 with self .transaction ():
@@ -783,6 +817,8 @@ def update_task(self, task_id: int, **kwargs):
783817 if invalid :
784818 raise ValueError (f"Invalid task column(s): { invalid } " )
785819 with self .lock :
820+ if "next_run_at" in kwargs :
821+ kwargs ["next_run_at" ] = _normalize_datetime_for_storage (kwargs ["next_run_at" ])
786822 kwargs ["updated_at" ] = datetime .now ().isoformat ()
787823 sets = ", " .join (f"{ k } = ?" for k in kwargs )
788824 vals = list (kwargs .values ()) + [task_id ]
@@ -822,17 +858,24 @@ def get_all_tasks(self) -> list[dict]:
822858 return [self ._deserialize_task (r ) for r in rows ]
823859
824860 def get_due_tasks (self ) -> list [dict ]:
825- now = datetime .now ().isoformat ()
826861 with self .lock :
827862 rows = self .conn .execute (
828863 """
829864 SELECT * FROM tasks
830865 WHERE status IN ('pending', 'scheduled')
831- AND (next_run_at IS NULL OR next_run_at <= ?)
832- """ ,
833- (now ,),
866+ """
834867 ).fetchall ()
835- return [self ._deserialize_task (r ) for r in rows ]
868+ now = datetime .now ()
869+ due = []
870+ for row in rows :
871+ task = self ._deserialize_task (row )
872+ try :
873+ next_run_at = _parse_comparable_datetime (task .get ("next_run_at" ))
874+ except ValueError :
875+ continue
876+ if next_run_at is None or next_run_at <= now :
877+ due .append (task )
878+ return due
836879
837880 def add_run (self , task_id : int ) -> int :
838881 with self .lock :
@@ -1195,15 +1238,18 @@ def _tick(self):
11951238 self ._schedule_delayed (task )
11961239 elif task ["schedule_type" ] == "delayed" and task ["status" ] == "scheduled" :
11971240 nra = task .get ("next_run_at" )
1198- if nra and datetime .fromisoformat (nra ) <= datetime .now ():
1241+ run_at = _parse_comparable_datetime (nra ) if nra else None
1242+ if run_at and run_at <= datetime .now ():
11991243 self ._spawn_task (task )
12001244 elif task ["schedule_type" ] == "scheduled_at" and task ["status" ] == "scheduled" :
12011245 nra = task .get ("next_run_at" )
1202- if nra and datetime .fromisoformat (nra ) <= datetime .now ():
1246+ run_at = _parse_comparable_datetime (nra ) if nra else None
1247+ if run_at and run_at <= datetime .now ():
12031248 self ._spawn_task (task )
12041249 elif task ["schedule_type" ] == "cron" and task ["status" ] == "scheduled" :
12051250 nra = task .get ("next_run_at" )
1206- if nra and datetime .fromisoformat (nra ) <= datetime .now ():
1251+ run_at = _parse_comparable_datetime (nra ) if nra else None
1252+ if run_at and run_at <= datetime .now ():
12071253 self ._spawn_task (task )
12081254 due_heartbeats = self .db .get_due_heartbeats ()
12091255 for heartbeat in due_heartbeats :
@@ -1427,7 +1473,7 @@ def _heartbeat_trigger_suppressed(self, heartbeat: dict, dedupe_key: str) -> boo
14271473 triggered_at = existing .get ("triggered_at" )
14281474 if triggered_at :
14291475 try :
1430- triggered_dt = datetime . fromisoformat (triggered_at )
1476+ triggered_dt = _parse_comparable_datetime (triggered_at )
14311477 if cooldown > 0 and datetime .now () < triggered_dt + timedelta (seconds = cooldown ):
14321478 return True
14331479 except ValueError :
@@ -2211,6 +2257,7 @@ def submit_task(self, task: Task, depends_on: list = None) -> int:
22112257 task .status = TaskStatus .SCHEDULED
22122258 if not task .next_run_at :
22132259 raise ValueError ("scheduled_at requires next_run_at to be set" )
2260+ task .next_run_at = _normalize_datetime_for_storage (task .next_run_at )
22142261 elif task .schedule_type == ScheduleType .CRON :
22152262 task .status = TaskStatus .SCHEDULED
22162263 if task .cron_expr :
0 commit comments