Fix #1144: sync session timestamps with server clock#1197
Fix #1144: sync session timestamps with server clock#1197bergeouss wants to merge 2 commits intonesquena:masterfrom
Conversation
Root cause: All relative-time calculations in the session sidebar
("2 hours ago", "Today", "Yesterday", etc.) used Date.now()
(client-side clock) as reference. When the browser clock and server
clock are out of sync (common with WSL clock drift, Docker containers
in UTC, remote access), session timestamps appear wrong.
Fix:
- Backend: /api/sessions now returns server_time (epoch seconds) and
server_tz (offset string like "+0800") alongside session data.
- sessions.js: Computes _serverTimeDelta = Date.now() - server_time
once per session-list fetch. All time helpers (_formatRelativeSessionTime,
_sessionCalendarBoundaries, _sessionTimeBucketLabel, _formatSessionDate)
now default to _serverNowMs() (Date.now() - delta) instead of Date.now().
- ui.js: _formatMessageFooterTimestamp and tsTitle tooltip now use
_serverTzOptions() to display timestamps in the server's timezone
(via IANA Etc/GMT offset format), falling back to browser timezone
when _serverTzOptions is not available.
Backward-compatible: passing explicit nowMs parameter still works
(used by tests), and when server_time is absent (no skew), behavior
is identical to before.
18 regression tests covering: backend response, skew compensation,
time formatting with skew, timezone conversion, and fallback behavior.
The Etc/GMT-X mapping in _serverTzOptions can only express whole-hour offsets — IANA Etc/GMT zones don't support fractional hours. Users in India (+0530), Iran (+0330/+0430), Newfoundland (-0330), Nepal (+0545), Sri Lanka (+0530), Afghanistan (+0430), and Burma (+0630) — collectively ~1.5 billion people — would see timestamps off by 30 minutes after this PR's whole-hour-only TZ conversion. That's actively WORSE than the pre-PR behaviour for them: pre-PR they saw browser-tz times (consistent with their wall clock), post-PR they'd see times 30 min off from the server. Fix: introduce _formatInServerTz(date, options) that uses offset arithmetic — shift the timestamp by the server's offset, then format with timeZone:'UTC' so no further conversion is applied. The formatted output reads as the wall-clock time in the server's timezone for ANY offset, including fractional. _serverTzOptions is kept for whole-hour fast-path callers that spread its result; updated to return undefined for fractional offsets so it can't silently produce off-by-30-min output via the spread path. ui.js: _formatMessageFooterTimestamp and tsTitle in renderMessages now go through _formatInServerTz with a fallback to bare toLocaleString when sessions.js hasn't loaded yet. Tests: - test_message_footer_timestamp_uses_server_tz: stubs _formatInServerTz with the same offset-arithmetic logic as sessions.js, asserts UTC+8 conversion produces the right wall-clock hour. - test_message_footer_timestamp_handles_fractional_offset: NEW. Stubs _formatInServerTz with +0530 (IST), asserts 02:00 UTC → 07:30 IST and explicitly NOT 07:00 (the broken Etc/GMT-5 result). - test_ui_js_message_timestamp_uses_server_tz: relaxed to accept either _formatInServerTz or _serverTzOptions reference. - test_sessions_js_has_format_in_server_tz_helper: NEW. Verifies the function exists and uses offset arithmetic + UTC formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (approved after fractional-offset fix pushed)
What this ships
@bergeouss's fix for #1144 — the WebUI's session-list relative time labels ("2 hours ago", "Today", "Yesterday") and message-footer timestamps used the BROWSER clock as reference. When client and server clocks are out of sync (WSL clock drift, Docker container running UTC, remote browser access) timestamps appear wrong.
Two coordinated changes:
- Backend (api/routes.py:935-942):
/api/sessionsnow returnsserver_time(epoch seconds fromtime.time()) andserver_tz(offset string fromtime.strftime("%z")) alongside the session list. - Frontend (sessions.js):
_serverTimeDelta = Date.now() - server_time*1000is captured per fetch._serverNowMs() = Date.now() - _serverTimeDeltareturns a server-clock approximation. All four time helpers (_formatRelativeSessionTime,_sessionCalendarBoundaries,_sessionTimeBucketLabel,_formatSessionDate) now default to_serverNowMs()instead ofDate.now(). Message-footer timestamps andtsTitletooltip render in the server's wall-clock timezone.
Plus 18 regression tests in tests/test_issue1144_session_time_sync.py.
Traced against upstream hermes-agent
Pulled fresh nousresearch/hermes-agent tarball. The time.time() and time.strftime("%z") calls are stdlib; the agent doesn't read the new response fields. WebUI-internal additions to the JSON response shape. No agent coupling.
What I caught — fractional-hour offsets actively regressed
The PR's _serverTzOptions() maps +0800 → Etc/GMT-8 etc. — but IANA Etc/GMT-X only expresses whole-hour offsets. For users in fractional-offset timezones (~1.5 billion people: India +0530, Iran +0330/+0430, Newfoundland -0330, Nepal +0545, Sri Lanka +0530, Afghanistan +0430, Burma +0630), the original PR mapped +0530 → Etc/GMT-5 — which is UTC+5, off by 30 minutes from actual IST.
Behavioural confirmation with the live function:
+0530 (IST) → currently Etc/GMT-5 (off by 30 min)
noon UTC formatted in '+0530' option → 17:00
expected: 17:30 (IST), actual diverges by 30 min
This was an active regression for fractional-offset users vs. the pre-PR behaviour (where they'd at least see their browser TZ, which usually matches their server TZ).
What I pushed — be8aee6
(Pushed to @bergeouss's fork via maintainer-edit access.)
Added _formatInServerTz(date, options) helper that uses offset arithmetic — shift the timestamp by the server's offset (full minute resolution), then format with timeZone:'UTC' so no further conversion happens. The output reads as the wall-clock time in the server's timezone for ANY offset, fractional included.
function _formatInServerTz(date, options) {
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {
return date.toLocaleString(undefined, options);
}
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
if (!m) return date.toLocaleString(undefined, options);
const sign = m[1] === '+' ? 1 : -1;
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
return adjusted.toLocaleString(undefined, { ...options, timeZone: 'UTC' });
}_serverTzOptions() is kept for the whole-hour fast path (callers that spread its result), updated to return undefined for fractional offsets so it can't silently produce off-by-30-min output via the spread path.
ui.js _formatMessageFooterTimestamp and tsTitle in renderMessages now use _formatInServerTz (with a fallback to bare toLocaleString when sessions.js hasn't loaded yet — same defensive pattern as before).
Two new tests:
test_message_footer_timestamp_handles_fractional_offset: stubs_formatInServerTzwith+0530, asserts02:00 UTC → 7:30 ISTAND explicitly:00is NOT in the result (the brokenEtc/GMT-5would produce7:00).test_sessions_js_has_format_in_server_tz_helper: locks the function exists and uses offset arithmetic + UTC formatting.
CI re-ran green on be8aee6: 3.11 ✅, 3.12 ✅, 3.13 ✅.
End-to-end trace
Skew compensation (the PR's primary contribution)
Behavioural harness:
zero delta → server time ≈ client time: PASS
client+60s ahead → server time = client - 60s: PASS
client-90s behind → server time = client + 90s: PASS
_serverTimeDelta captured fresh on every renderSessionList() call so it stays correct as machines drift. ✅
Timezone option mapping (whole-hour, post-fix)
+0800 → Etc/GMT-8: PASS
-0500 → Etc/GMT+5: PASS
+0000 → undefined (UTC fallback): PASS
empty → undefined: PASS
invalid → undefined: PASS
+0530 (IST) → undefined (forces _formatInServerTz path): PASS [my fix]
Whole-hour offsets still go through the fast Etc/GMT-X path. Fractional offsets fall to _formatInServerTz for offset arithmetic.
Backward compatibility
- Explicit
nowMsparameter still works on every helper (used by existing tests passingDate.now()explicitly). - Zero skew (delta=0) → identical behaviour to before.
+0000/ empty_serverTz→ falls back to browser TZ (no behaviour change for UTC servers)._formatInServerTznot yet loaded (race during initial paint) → baretoLocaleStringfallback.
Edge-case trace
| Scenario | Pre-fix | Post-PR (whole-hour) | Post-my-fix (fractional) |
|---|---|---|---|
| WSL clock drift 60s ahead, +0800 server | times 60s wrong | corrected via skew | corrected ✅ |
| Docker UTC server, browser EST | shows EST (mismatched) | shows server UTC | shows server UTC ✅ |
| Server +0530 (IST), browser anywhere | browser TZ (usually OK if both IST) | off by 30 min | shows IST correctly ✅ |
| Server +0545 (NPT, Nepal) | browser TZ | off by 45 min | correct ✅ |
| Server -0330 (NDT) | browser TZ | off by 30 min | correct ✅ |
Server-side time.strftime("%z") returns empty |
n/a | undefined → browser TZ | undefined → browser TZ ✅ |
Malformed _serverTz value |
n/a | undefined → browser TZ | undefined → browser TZ ✅ |
Tests
- PR's 18 tests + my 2 new tests: 20/20 pass.
- Local full suite: 2657 passed, 47 skipped, 1 PR-unrelated pre-existing failure (the macOS
test_sprint3quirk that v0.50.231 fixes; this PR is on top of v0.50.230). - CI on PR after my push: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).
- Skew + TZ behavioural harness: PASS for all whole-hour cases AND fractional cases (post-fix).
Other audit — confirmed correct
- JS syntax:
node --checkpasses onsessions.jsandui.js. time.strftime("%z")defensiveness: empty/UTC/malformed all fall back gracefully; no JS exceptions.Date.now()not removed wholesale:_serverNowMs()itself usesDate.now()for the base, just subtracts the captured delta. The PR also keeps the rawDate.now()calls for cases where wall-clock-now matters (e.g. inside_formatMessageFooterTimestampto determine "is this the same day as right-now").- Cooperative with #1186's pre-existing test: full suite shape unchanged except for the macOS quirk.
.gitignoregraphify-out/: looks like a personal local-tool directory; harmless addition.
Minor observations (non-blocking)
- The
_serverTzOptionsfast-path is now somewhat redundant since_formatInServerTzhandles whole-hour offsets correctly too. Could be simplified in a follow-up by removing_serverTzOptionsentirely and routing all callers through_formatInServerTz. Keeping both works. time.strftime("%z")in Python 3.7+ may emit 6-digit offsets (+053000) for unusual sub-minute precision. The current regex requires exactly 4 digits — would fall back to browser TZ for those. Acceptable since sub-minute precision is essentially unused in real-world TZs.- The session-list refresh fires
renderSessionListperiodically; each fetch updates_serverTimeDelta, so long-lived tabs stay correct as either machine drifts. ✅ - The
.gitignoregraphify-out/line could go in.git/info/excludeinstead of the tracked.gitignore, since it's a personal-tool artefact — minor.
Recommendation
Approved (after fractional-offset fix pushed). Solid clock-skew compensation that addresses the actual #1144 user report (WSL drift, Docker TZ mismatch). The whole-hour TZ display via Etc/GMT-X is correct for ~80% of users; my fractional-offset addition closes the gap for the remaining ~1.5B users in IST/Iran/Nepal/Newfoundland-style zones — without their fix the PR would have introduced a 30-minute regression for those users. Pushed _formatInServerTz helper using offset arithmetic + 2 regression tests. CI green on 3.11/3.12/3.13. Parked at approval — ready for the release agent's merge/tag pipeline.
…, timestamp sync (#1198) Batch release v0.50.232 — 4 fixes. ## PRs included | PR | Author | Fix | |---|---|---| | #1192 | @nesquena-hermes | Model chip fuzzy-match false positive (#1188) | | #1193 | @nesquena-hermes | openai-codex not detected in model picker (#1189) | | #1196 | @nesquena-hermes | Workspace files blank after second empty-session reload | | #1197 | @bergeouss | Session timestamps wrong with server/client clock drift (#1144) | All four PRs independently reviewed and approved by @nesquena. ## Integration fixes applied **#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path. ## Test results **2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
|
Merged as v0.50.232 via #1198. Thank you @bergeouss! |
…, timestamp sync (nesquena#1198) Batch release v0.50.232 — 4 fixes. ## PRs included | PR | Author | Fix | |---|---|---| | nesquena#1192 | @nesquena-hermes | Model chip fuzzy-match false positive (nesquena#1188) | | nesquena#1193 | @nesquena-hermes | openai-codex not detected in model picker (nesquena#1189) | | nesquena#1196 | @nesquena-hermes | Workspace files blank after second empty-session reload | | nesquena#1197 | @bergeouss | Session timestamps wrong with server/client clock drift (nesquena#1144) | All four PRs independently reviewed and approved by @nesquena. ## Integration fixes applied **nesquena#1193:** Updated misleading comment — `OPENAI_API_KEY` does NOT authenticate the default Codex OAuth endpoint (that uses `chatgpt.com/backend-api/codex` and requires a separate OAuth flow). The comment now accurately states the known limitation. Also replaced a fragile 400-char source-scan test with an isolation-safe unit test. Note: OAuth-authenticated users already get detected via `hermes_cli.auth` — this fix only addresses the env-var fallback path. ## Test results **2764 passed, 2 skipped** (macOS-only workspace tests). Browser QA: **21/21**. `/api/sessions` confirmed returning `server_time` and `server_tz` fields.
Thinking Path
Root cause: All relative-time calculations in the session sidebar ("2 hours ago", "Today", "Yesterday", etc.) used
Date.now()(client-side clock) as reference. When the browser clock and server clock are out of sync — common with WSL clock drift, Docker containers running in UTC, or remote browser access — session timestamps appear wrong.Fix: The
/api/sessionsresponse now includesserver_time(epoch seconds) andserver_tz(offset string like "+0800"). The JS computes a clock-skew delta once per session-list fetch and uses it to compensate all time calculations. Message footer timestamps also use the server's timezone when available.What Changed
Backend (
api/routes.py)/api/sessionsnow returnsserver_time(float, epoch seconds) andserver_tz(string, UTC offset like "+0800")Session sidebar (
static/sessions.js)_serverNowMs()helper returnsDate.now() - _serverTimeDelta(server-clock approximation)_serverTzOptions()converts offset string to IANAEtc/GMT-Xtimezone_formatRelativeSessionTime,_sessionCalendarBoundaries,_sessionTimeBucketLabel,_formatSessionDateall default to_serverNowMs()instead ofDate.now()renderSessionListFromCache()uses_serverNowMs()for date groupingMessage timestamps (
static/ui.js)_formatMessageFooterTimestampandtsTitletooltip now use server timezone via_serverTzOptions()Backward compatible
nowMsparameter still works (used by existing tests)"+0000"or empty_serverTzfalls back to browser timezoneTests (
tests/test_issue1144_session_time_sync.py)Fixes #1144