Skip to content

fix(skills): order the distillation cursor by epoch, not naive-local ISO (#13948) - #13990

Merged
mrveiss merged 3 commits into
Dev_new_guifrom
issue-13948-dst
Aug 11, 2026
Merged

mrveiss merged 3 commits into
Dev_new_guifrom
issue-13948-dst

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #13948

Thinking Path

_select_pending_sessions compared updated_at <= cursor lexicographically, justified by a comment stating ISO-8601 sorts lexicographically. That holds only at a fixed UTC offset — and the values are naive local time (datetime.fromtimestamp(st_mtime).isoformat(), the #13856 shape).

At the autumn DST fallback the local clock repeats an hour. A session written during the repeated hour renders as a string comparing below a cursor already advanced past it, so it is skipped permanently: up to an hour of conversations, once a year, with nothing in the logs. The cursor's whole stated guarantee is "bounded re-work, never a silently skipped conversation" — this broke precisely that.

Decision: emit the raw mtime rather than parse the ISO string back

The obvious fix is to parse updatedAt into an epoch at comparison time. That does not work, and the reason is the bug itself: during the repeated hour the naive-local string names two instants. Parsing it resolves to one of them arbitrarily — ambiguous exactly where correctness is needed.

So session_listing now emits updatedAtEpoch (the raw st_mtime) alongside the existing ISO fields, from both producers that feed list_sessions_fast. The ISO fields are untouched, so no API contract changes and the frontend is unaffected. ISO-string parsing survives only as a fallback for entries from a producer that does not carry the field, and for migrating the stored cursor.

Decision: migrate the durable cursor, never reset it

The cursor lives in Redis and the issue explicitly calls out that a reset re-distils the whole corpus at real LLM spend. _cursor_to_epoch reads the current epoch format and falls back to the legacy ISO one, so an existing deployment migrates in place on the first read. A test pins that a legacy cursor still excludes everything before it — migration must not re-distil either.

One behaviour change worth flagging

A session with no usable timestamp was silently skipped; it is now skipped with a warning. It genuinely cannot be distilled — the cursor could not advance past it, so it would repeat every run forever — but "loud and skipped" beats "silent and skipped" for the same class of defect this issue is about.

What Changed

  • chat_history/session_listing.py — updatedAtEpoch: stat.st_mtime on both list_sessions_fast producers (the stat-based entry and the orphaned-terminal recovery).
  • services/skill_management/skill_distillation_scheduler.py — _entry_epoch / _iso_to_epoch / _cursor_to_epoch; numeric comparison and sort; cursor read/write in epoch seconds; the false monotonicity comment replaced with what is actually guaranteed.

Verification

$ pytest autobot-backend/services/skill_management autobot-backend/chat_history \
         autobot-backend/tests/test_skill_distillation_scheduler.py -q
186 passed

29 new tests. They use a real DST boundary (Europe/Riga, 2026-10-25, where 03:30 local occurs twice) rather than a synthetic offset, because the defect only exists where a wall clock repeats. One test asserts the premise — that the two instants render as the same string — so the rest cannot pass vacuously.

Mutation-tested rather than merely re-run:

mutation result
revert to the ISO string comparison (the defect) KILLED (4)
ignore updatedAtEpoch, parse the ambiguous string KILLED (4)
legacy ISO cursor discarded (silent reset) KILLED (2)
boolean epoch accepted (True is an int — would rewind the cursor to 1970) KILLED (1)
unusable timestamp skipped silently KILLED (1)
cursor truncated to whole seconds KILLED (1)

One mutant survives and is equivalent, not a gap: writing the cursor as a raw float instead of repr(float(...)). redis-py stringifies on the wire either way, so the round-trip is identical; the explicit repr(float(...)) is kept because it also normalises an int input.

Lint: black, isort, flake8 clean.

Note on this issue's history

PR #13949 is merged and its body says Closes #13948, but it addresses an unrelated CI problem (git environment isolation in co_change_test.py). The reference is a wrong issue number. This issue was still fully unfixed on Dev_new_gui — verified at skill_distillation_scheduler.py:401 before starting — and would have been closed by anyone sweeping merged PRs.

Model Used

Claude Opus 5

…ISO (#13948)

_select_pending_sessions compared `updated_at <= cursor` lexicographically, on a
comment claiming ISO-8601 sorts lexicographically. That holds only at a fixed UTC
offset, and the values are naive LOCAL time (datetime.fromtimestamp(st_mtime)).

At the autumn DST fallback the local clock repeats an hour, so a session written
in the repeated hour renders as a string comparing below an already-advanced
cursor and is skipped permanently — up to an hour of conversations, once a year,
with nothing in the logs. The cursor's stated guarantee is "bounded re-work,
never a silently skipped conversation"; this broke exactly that.

session_listing now emits updatedAtEpoch (raw st_mtime) alongside the ISO fields,
from both producers that feed list_sessions_fast. Parsing the ISO string back was
not an option: during the repeated hour it names two instants, so it is
ambiguous precisely where correctness is needed. The ISO fields are untouched, so
no API contract changes.

The durable cursor migrates rather than resets: _cursor_to_epoch reads the epoch
format and falls back to the legacy ISO one. A reset would re-distil the whole
corpus at real LLM cost, which is why the issue called it out.

A session with no usable timestamp is now skipped with a warning instead of
silently — it cannot advance the cursor, so distilling it would repeat forever,
but the skip should at least be visible.

Three existing tests asserted the raw stored string. They now assert which
conversation the cursor names, via _cursor_to_epoch — behaviour, not format.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

#13948)

Review findings on PR #13990, all fixed.

The migration docstring claimed "worst case, one hour is re-distilled". That was
wrong in both directions: a naive-local string read under a different process
timezone shifts by the full offset delta, and resolving it LATE skips
conversations silently — the same failure class as the bug being fixed.

_iso_to_epoch now returns the earliest instant the string could name, via
min(fold=0, fold=1). Inside a DST-repeated hour that is the safe one of the two
candidates; outside it both folds agree and the reading is exact, so migration
costs nothing in the normal case. I first used a blunt 26h backwards bias and
reverted it: it re-distilled a day of conversations on every migration, which is
the LLM spend this issue explicitly warned against, and it was guesswork where
`fold` gives the exact answer.

Also:
- non-finite cursors rejected. float("nan") parses, and a nan cursor compares
  False against everything, silently turning the gate into a no-op so every
  conversation looks pending on every run.
- an unreadable cursor now logs before falling back to a first run. That is the
  more expensive failure (full-corpus re-distillation) and it was the one case
  the module handled silently, against its own stated philosophy.
- _select_pending_sessions' cursor type hint corrected to float | None.

The fold test pins the process timezone with a fixture. Without it the test
passed in Riga and failed in UTC — a test that only holds on the machine that
wrote it is worse than no test. Suite verified green under TZ=UTC, Europe/Riga,
America/New_York, Pacific/Kiritimati and Asia/Kolkata.
@mrveiss

mrveiss commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed — one was more than a doc nit

The verdict was SAFE with four non-blocking items. Three were small; the first turned out to be a real correctness issue in the migration path, so I want to be explicit about it.

1. The migration could skip silently — fixed

My docstring claimed "worst case, one hour is re-distilled". That was wrong in both directions. A naive-local string read under a different process timezone shifts by the full offset delta, and resolving it late skips every conversation between the true instant and the over-estimate — permanently and silently. That is the same failure class as the bug this PR fixes, reintroduced in the migration path.

_iso_to_epoch now returns the earliest instant the string could name: min(fold=0, fold=1).

Worth recording that my first attempt was worse. I biased the migrated cursor backwards by a blunt 26h (the widest UTC offset span). It killed the mutants, but it re-distilled a full day of conversations on every migration — the exact LLM spend this issue warns about — and it was guesswork in a place where fold gives the exact answer. Inside the repeated hour fold names the two candidate instants, so taking the minimum is precise; outside it both folds agree and migration costs nothing at all.

The residual TZ-change exposure (a deployment changing its TZ between writing and reading a legacy cursor) cannot be recovered by any local-time parse. It predates this fix, applies only to the one-off migration, and ends once the cursor is rewritten in epoch form. It is now stated accurately in the docstring rather than papered over.

2. Non-finite cursors — fixed

float("nan") parses. A nan cursor compares False against everything, silently turning the gate into a no-op so every conversation looks pending on every run. Rejected now, with a log.

3. Silent cursor corruption — fixed

An unreadable cursor fell back to "first run" with no log. That is the more expensive failure (full-corpus re-distillation) and was the one case handled silently, against the module's own stated philosophy. It logs now.

4. Stale type hint — fixed

_select_pending_sessions(cursor: str | None) → float | None.

On the test-portability point

You were right to check this, and the answer was no. test_an_ambiguous_legacy_cursor_resolves_to_the_EARLIER_instant passed in Europe/Riga and failed in UTC: it mixed a Riga-derived wall-clock string with process-local parsing. A test that only holds on the machine that wrote it is worse than no test, so it now pins the process timezone with a fixture (monkeypatch.setenv + time.tzset()), which is the honest way to test something that is defined in terms of local time.

Verified green under five timezones:

TZ=UTC                -> 192 passed
TZ=Europe/Riga        -> 192 passed
TZ=America/New_York   -> 192 passed
TZ=Pacific/Kiritimati -> 192 passed
TZ=Asia/Kolkata       -> 192 passed

Mutation battery, re-run under TZ=UTC so it cannot pass on local luck — all seven killed:

mutation result
fold resolved LATE (skips the repeated hour) KILLED (1)
revert to the ISO string comparison (the defect) KILLED (4)
ignore updatedAtEpoch KILLED (4)
legacy cursor discarded (silent reset) KILLED (4)
non-finite cursor accepted KILLED (4)
boolean epoch accepted KILLED (1)
unusable timestamp skipped silently KILLED (1)

flake8 clean.

Accepted without change

  • The third entry producer in session_listing.py (_process_chat_file_for_listing, used only by list_sessions()) has no updatedAtEpoch. Confirmed it never reaches the scheduler — the only path in is list_sessions_fast(). Out of scope here; noted as a latent inconsistency if a future caller of list_sessions() needs epoch-accurate ordering.
  • cursor or "(start)" treating epoch 0.0 as falsy — unreachable (1970-01-01), not worth the churn.

@mrveiss
mrveiss merged commit cfd069a into Dev_new_gui Aug 11, 2026
61 checks passed
@mrveiss
mrveiss deleted the issue-13948-dst branch August 11, 2026 09:06
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.

1 participant