You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.pycreate_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)
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().
Summary
Schedule-triggered executions store
started_atas a naive ISO timestamp (noZsuffix), 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:triggered_by: schedule)triggered_by: self_task)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):
agent_schedules.last_run_atis 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()(noZ) instead of the backend's ISO-Z convention:src/scheduler/database.pycreate_execution()(~L389) — the reportedstarted_atcreate_skipped_execution(~L450), scheduler-sidecomplete_execution(~L514–525),update_schedule_run_times(~L351/354 — whyagent_schedules.last_run_at/next_run_atare naive too), and the process-schedule variants (~L798–1019)The backend writes everything through
utc_now_iso()(src/backend/utils/helpers.py), which emits theZsuffix — 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
parseUTCutility.src/frontend/src/utils/timestamps.jsalready handles timezone-less strings (parseUTCassumes UTC and appendsZ), but several panels hand-rollnew Date(dateStr):src/frontend/src/components/TasksPanel.vue— localformatRelativeTime(the reported surface)src/frontend/src/components/SchedulesPanel.vue,ExecutionsPanel.vue,UnifiedActivityPanel.vue,OverviewPanel.vueArithmetic check: UTC+1 browser at ~11:35 UTC → naive
11:00:00parses 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, soduration_msand analytics are correct. Residual write-side risk of mixed formats in one TEXT column: sub-second lexicographic edge cases inORDER BY/window filters (the Architectural Invariant #16 / #476 class). Display is the only user-visible damage.Acceptance Criteria
src/scheduler/database.py) are serialized as UTC with an explicitZsuffix —started_at,completed_at,last_run_at,next_run_at, and the process-schedule variantssrc/frontend/src/utils/timestamps.jshelpers (parseUTC/formatRelativeTime) instead of rawnew Date()— Tasks, Schedules, Executions, UnifiedActivity, Overview panelsutc_now_iso())Technical Notes
utc_now_iso()equivalent (mirroringsrc/backend/utils/helpers.py) rather than a cross-package import.execution_row_retention_days= 90d of them) and defends against any future stray writer.to_utc_iso().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.