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
- Install OpenChronicle.
openchronicle start.
- Let it run long enough for
latest_end to be persisted to the timeline DB.
- 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.py → aggregator.produce_block_for_window → captures_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.
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_windowraisesTypeError.Result: 1-min timeline blocks stop being produced.
Timeline 0 blocks, last end: (none)onopenchronicle status. Captures and reductions still work; only the timeline aggregation pipeline is silently broken.Environment
main)bash install.shRepro
openchronicle start.latest_endto be persisted to the timeline DB.~/.openchronicle/logs/timeline.log— every tick raises:Root cause
src/openchronicle/timeline/store.pylines 90–98:If
end_timewas persisted as an ISO string without a TZ offset (which appears to be what the store does),fromisoformatproduces a naivedatetime. That value flows throughtick.py→aggregator.produce_block_for_window→captures_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 (whenlatest_end is Noneandlatest_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):
B. Fix at the write boundary (cleaner — matches filename TZ encoding):
Persist
start_time/end_timeas 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_windowcould normalize both sides:Happy to PR if you'd like.