Skip to content

Fix #1144: sync session timestamps with server clock#1197

Closed
bergeouss wants to merge 2 commits intonesquena:masterfrom
bergeouss:fix/issue-1144-session-time-sync
Closed

Fix #1144: sync session timestamps with server clock#1197
bergeouss wants to merge 2 commits intonesquena:masterfrom
bergeouss:fix/issue-1144-session-time-sync

Conversation

@bergeouss
Copy link
Copy Markdown
Contributor

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/sessions response now includes server_time (epoch seconds) and server_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/sessions now returns server_time (float, epoch seconds) and server_tz (string, UTC offset like "+0800")

Session sidebar (static/sessions.js)

  • New _serverNowMs() helper returns Date.now() - _serverTimeDelta (server-clock approximation)
  • New _serverTzOptions() converts offset string to IANA Etc/GMT-X timezone
  • _formatRelativeSessionTime, _sessionCalendarBoundaries, _sessionTimeBucketLabel, _formatSessionDate all default to _serverNowMs() instead of Date.now()
  • renderSessionListFromCache() uses _serverNowMs() for date grouping
  • Delta is recalculated on every session list fetch

Message timestamps (static/ui.js)

  • _formatMessageFooterTimestamp and tsTitle tooltip now use server timezone via _serverTzOptions()
  • Graceful fallback to browser timezone when not available

Backward compatible

  • Explicit nowMs parameter still works (used by existing tests)
  • Zero skew = identical behavior to before
  • "+0000" or empty _serverTz falls back to browser timezone

Tests (tests/test_issue1144_session_time_sync.py)

  • 18 regression tests covering: backend response, skew compensation, timezone conversion, fallback

Fixes #1144

bergeouss and others added 2 commits April 28, 2026 01:04
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]>
Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Backend (api/routes.py:935-942): /api/sessions now returns server_time (epoch seconds from time.time()) and server_tz (offset string from time.strftime("%z")) alongside the session list.
  2. Frontend (sessions.js): _serverTimeDelta = Date.now() - server_time*1000 is captured per fetch. _serverNowMs() = Date.now() - _serverTimeDelta returns a server-clock approximation. All four time helpers (_formatRelativeSessionTime, _sessionCalendarBoundaries, _sessionTimeBucketLabel, _formatSessionDate) now default to _serverNowMs() instead of Date.now(). Message-footer timestamps and tsTitle tooltip 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 _formatInServerTz with +0530, asserts 02:00 UTC → 7:30 IST AND explicitly :00 is NOT in the result (the broken Etc/GMT-5 would produce 7: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 nowMs parameter still works on every helper (used by existing tests passing Date.now() explicitly).
  • Zero skew (delta=0) → identical behaviour to before.
  • +0000 / empty _serverTz → falls back to browser TZ (no behaviour change for UTC servers).
  • _formatInServerTz not yet loaded (race during initial paint) → bare toLocaleString fallback.

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_sprint3 quirk 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 --check passes on sessions.js and ui.js.
  • time.strftime("%z") defensiveness: empty/UTC/malformed all fall back gracefully; no JS exceptions.
  • Date.now() not removed wholesale: _serverNowMs() itself uses Date.now() for the base, just subtracts the captured delta. The PR also keeps the raw Date.now() calls for cases where wall-clock-now matters (e.g. inside _formatMessageFooterTimestamp to 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.
  • .gitignore graphify-out/: looks like a personal local-tool directory; harmless addition.

Minor observations (non-blocking)

  • The _serverTzOptions fast-path is now somewhat redundant since _formatInServerTz handles whole-hour offsets correctly too. Could be simplified in a follow-up by removing _serverTzOptions entirely 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 renderSessionList periodically; each fetch updates _serverTimeDelta, so long-lived tabs stay correct as either machine drifts. ✅
  • The .gitignore graphify-out/ line could go in .git/info/exclude instead 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.

nesquena-hermes added a commit that referenced this pull request Apr 28, 2026
…, 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.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Merged as v0.50.232 via #1198. Thank you @bergeouss!

JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 29, 2026
…, 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.
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.

The session time of the WebUI cannot be synchronized with the system time.

3 participants