Skip to content

Timeline aggregator crashes with TypeError on every tick after first block: naive vs aware datetime in get_latest_end #24

@CraftedMark

Description

@CraftedMark

Summary

The timeline aggregator crashes on every tick once at least one timeline block has been written, because store.get_latest_end() returns a naive datetime, while _now() and _stem_to_dt() both return offset-aware datetimes. Their comparison in _capture_stem_in_window raises TypeError.

Result: 1-min timeline blocks stop being produced. Timeline 0 blocks, last end: (none) on openchronicle status. Captures and reductions still work; only the timeline aggregation pipeline is silently broken.

Environment

  • v0.1.0 (current main)
  • macOS 15.x (Darwin 25.5.0), Apple Silicon
  • Python 3.14
  • Installed via bash install.sh
  • Daemon running, capture active, ~30 captures/min in buffer

Repro

  1. Install OpenChronicle.
  2. openchronicle start.
  3. Let it run long enough for latest_end to be persisted to the timeline DB.
  4. Watch ~/.openchronicle/logs/timeline.log — every tick raises:
File ".../openchronicle/timeline/aggregator.py", line 47, in _capture_stem_in_window
    return start <= ts < end
           ^^^^^^^^^^^^^^^^^
TypeError: can't compare offset-naive and offset-aware datetimes

Root cause

src/openchronicle/timeline/store.py lines 90–98:

def get_latest_end(conn: sqlite3.Connection) -> datetime | None:
    row = conn.execute(
        "SELECT end_time FROM timeline_blocks ORDER BY end_time DESC LIMIT 1"
    ).fetchone()
    if not row:
        return None
    try:
        return datetime.fromisoformat(row[0])  # ← may return naive

If end_time was persisted as an ISO string without a TZ offset (which appears to be what the store does), fromisoformat produces a naive datetime. That value flows through tick.pyaggregator.produce_block_for_windowcaptures_in_window_capture_stem_in_window, where it is compared against _stem_to_dt(stem) — always aware (the offset is parsed from the filename like …p07-00).

_now() is also aware (datetime.now().astimezone()), so the first run after install (when latest_end is None and latest_end = current_floor - timedelta(...)) succeeds. The bug only triggers on the second and later runs once a row exists.

Suggested fix

Two options, pick one:

A. Fix at the read boundary (smallest diff):

# store.py — get_latest_end
dt = datetime.fromisoformat(row[0])
if dt.tzinfo is None:
    dt = dt.astimezone()  # treat as local
return dt

B. Fix at the write boundary (cleaner — matches filename TZ encoding):
Persist start_time / end_time as offset-aware ISO strings (e.g. 2026-04-28T12:14:00-07:00), and assert this in tests.

I'd lean toward (B) for symmetry with _stem_to_dt, with a defensive (A) so old rows from existing installs don't poison new tickers.

Defensive belt-and-suspenders

_capture_stem_in_window could normalize both sides:

def _capture_stem_in_window(stem: str, start: datetime, end: datetime) -> bool:
    ts = _stem_to_dt(stem)
    if ts is None:
        return False
    if start.tzinfo is None: start = start.astimezone()
    if end.tzinfo is None: end = end.astimezone()
    return start <= ts < end

Happy to PR if you'd like.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions