Skip to content

bug: schedule-triggered executions store naive started_at (no timezone suffix) — UI shows wrong relative time #1474

Description

@vybe

Summary

Schedule-triggered executions store started_at as a naive ISO timestamp (no Z suffix), while backend-created executions store Z-suffixed UTC. JavaScript parses the naive form as local time, so in any non-UTC browser the agent Tasks list shows schedule-triggered rows shifted older by the viewer's UTC offset — a dispatcher and its self-task workers that started 42 seconds apart appear hours apart.

Context

Operator report from a production instance (deployed at 3d2538c0), 2026-07-06:

Execution Actual start (UTC) UI shows (UTC+1 browser)
Dispatcher (triggered_by: schedule) 11:00:00 "2h ago" ❌
3 workers (triggered_by: self_task) 11:00:23–11:00:42 "33–34m ago" ✅

All four rows started within 42 seconds of each other; the schedule-triggered one appears ~1.6h older. This misled an operator into believing the dispatcher and its self-tasks ran hours apart.

Serialization evidence (same row is mixed — created by the scheduler, finalized by the backend):

# scheduler-created execution — naive, no timezone suffix:
"started_at":   "2026-07-06T11:00:00.207634"
# same row, completed by the backend — Z-suffixed:
"completed_at": "2026-07-06T11:00:42.219882Z"

agent_schedules.last_run_at is also naive ("2026-07-06T11:00:00.616516").

Root Cause

Two layers:

Write side — the standalone scheduler serializes naive timestamps. Cron-fired executions are created by the scheduler container, whose raw-SQL DB layer uses datetime.utcnow().isoformat() (no Z) instead of the backend's ISO-Z convention:

  • src/scheduler/database.py create_execution() (~L389) — the reported started_at
  • also create_skipped_execution (~L450), scheduler-side complete_execution (~L514–525), update_schedule_run_times (~L351/354 — why agent_schedules.last_run_at/next_run_at are naive too), and the process-schedule variants (~L798–1019)

The backend writes everything through utc_now_iso() (src/backend/utils/helpers.py), which emits the Z suffix — its docstring exists precisely "to ensure JavaScript correctly interprets timestamps as UTC." Chat / self_task / MCP executions are created entirely backend-side, hence fully Z-suffixed and rendering correctly.

Read side — frontend panels bypass the existing parseUTC utility. src/frontend/src/utils/timestamps.js already handles timezone-less strings (parseUTC assumes UTC and appends Z), but several panels hand-roll new Date(dateStr):

  • src/frontend/src/components/TasksPanel.vue — local formatRelativeTime (the reported surface)
  • src/frontend/src/components/SchedulesPanel.vue, ExecutionsPanel.vue, UnifiedActivityPanel.vue, OverviewPanel.vue

Arithmetic check: UTC+1 browser at ~11:35 UTC → naive 11:00:00 parses as 11:00 local = 10:00 UTC → 95 min → Math.round(95/60) = "2h ago". Matches the report exactly.

Backend Python read paths are unaffected — parse_iso_timestamp() assumes UTC for naive strings, so duration_ms and analytics are correct. Residual write-side risk of mixed formats in one TEXT column: sub-second lexicographic edge cases in ORDER BY/window filters (the Architectural Invariant #16 / #476 class). Display is the only user-visible damage.

Acceptance Criteria

  • All timestamps written by the standalone scheduler (src/scheduler/database.py) are serialized as UTC with an explicit Z suffix — started_at, completed_at, last_run_at, next_run_at, and the process-schedule variants
  • Frontend panels rendering execution/schedule timestamps use the shared src/frontend/src/utils/timestamps.js helpers (parseUTC / formatRelativeTime) instead of raw new Date() — Tasks, Schedules, Executions, UnifiedActivity, Overview panels
  • Historical naive rows render correctly in the UI (covered by the frontend layer — no data migration required)
  • A schedule-fired execution and a chat/self-task execution started around the same time show consistent relative times in a non-UTC browser
  • Regression test asserting the scheduler's execution-row timestamps are Z-suffixed (format parity with backend utc_now_iso())

Technical Notes

  • The scheduler is a separate package that doesn't import backend helpers — add a local utc_now_iso() equivalent (mirroring src/backend/utils/helpers.py) rather than a cross-package import.
  • Fix both layers: the writer fix stops new divergence; the frontend fix covers historical naive rows (up to execution_row_retention_days = 90d of them) and defends against any future stray writer.
  • Optional third layer, likely unnecessary if both land: normalize timestamps at API read via to_utc_iso().
  • Same bug class as bug(SUB-003): rate-limit events never age out due to SQLite string-compare bug; retries amplify outages #476 (ISO format mismatch breaking comparisons), which produced Invariant #16 (iso_cutoff()) on the backend — this is the scheduler-side and frontend-side completion of that cleanup.

Originally filed by trinity-ops (operator report). Reformatted with root-cause analysis 2026-07-06.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions