Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min, residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent 2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00, while the Part 2 leg that completed that morning is clean at -0.16. The terminal reconcile scans the whole event partition, so the old leg supplied the majority of the IME samples and fired against a build the device had self-updated to minutes earlier. Real skew, 12 days old, already fixed - and >=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat it. Event rows carry no agent version, so the split is drawn on ReceivedAt: ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg upload gap (self-metrics every few minutes, session_stalled at 60 min idle, maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid residual, conformity AND the distinct-batch guard now all run on the same era-filtered sample set; the batch counters used to come from the full scan. SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST samples (chunked trim) - keeping the head would have blinded the detector on exactly the sessions large enough to reach it. The ops event reports eraStartUtc plus the excluded per-side counts, so an inherited leg stays visible without being alarmed on. Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly those three red). The handover fact also asserts that the old leg judged on its own still fires, so it is the window that silences it, not a moved threshold. Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| concept | CMTrace Time Resolution — Per-Line Self-Anchoring | How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so. | src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime |
|
2026-09-01 |
Problem
A CMTrace line carries local time and no offset — IME 1.104 writes DateTime.Now.TimeOfDay
in both of its trace listeners. Converting such a line with the reader's own
TimeZoneInfo.Local silently assumes the writing and reading processes agree on the zone.
They do not: each caches its zone at process start and follows neither a later tzutil nor
a Windows auto-timezone change. The error is Offset_writer − Offset_reader — zero only
when both believe the same thing, right or wrong. Field measurement over 11,068 sessions
found 26 sessions with a real error, from +1 h to −17 h.
Two designs failed before this one:
- Fix the reader's belief (
TimeZoneInfo.ClearCachedData, live OS offset): makes the agent isolated-correct and thereby breaks the majority of sessions, where two equally stale beliefs cancel. - One measured offset per file (agent 2.0.1410, reverted in
04b1a7c6): a single log file holds lines from multiple writer eras.AgentExecutor.logis written by short-lived child processes whose zone belief flips per process — interleaved in one file, and flipping back (fixture:tests/fixtures/cmtrace-logs/agentexecutor-two-writer-eras-v1.cmtrace). Any cross-line anchor — per file, or nearest-in-write-order — applies one era's offset to the other era's lines. In the field this shiftedscript_startedevents by −9 h against their correct completions.
Schema
The resolution rule, in order:
-
Writer-declared bias (
+480suffix): authoritative, used as-is. Never a calibration anchor — it needs no measurement and must not overwrite one. -
Per-line self-anchoring — only for lines read in a provably fresh pass:
offset_line = round((local − agentUtcNow) / 15 min) · 15 min lineUtc = local − offset_lineEvery real UTC offset is a whole multiple of 15 minutes, so the rounding absorbs poll and flush latency. No cross-line state exists, so interleaved eras resolve line by line — the property the fixture test pins. The line's own timestamp still contributes sub-poll precision and ordering (two lines 6 ms apart stay 6 ms apart; a plain
occurredAt = nowcould not do that). Guards: grid residual ≤ 2 min, offset within UTC−12 … UTC+14. -
Uniform reader-zone fallback for everything else: wrong in absolute terms whenever the writer held a different belief, but self-consistent — both ends of a duration are wrong by the same amount, so derived durations stay right. The revert's core lesson: partially corrected is strictly worse than uniformly wrong.
Freshness is what makes rule 2 sound, and it is strict:
- Every poll look at a file stamps
LastCheckedUtc(LogFilePositionTracker.MarkChecked), including "no new data" and an empty first sight — so a file first seen empty (AgentExecutor.logappearing mid-enrollment) anchors from its very first content. - A pass's lines are fresh only if the file was previously observed in this process and
the gap since the last look is ≤
FreshLineMaxAge(30 s — far above the 100 ms poll, far below the 13-minute zone where a line's age could round onto the offset grid). - A restart-restored position bookmark deliberately carries no freshness: the first pass after a restart reads downtime backlog and must never anchor.
Provenance (sourceOffsetOrigin in event DataJson): bias | line-anchored |
reader-zone-fallback (plus retired calibrated from 2.0.1410 events). The applied
offset is always recorded (sourceOffsetMinutes), the raw local time too
(sourceLocalTs), so any event can be re-derived later. The per-file measurement still
runs, purely observationally (measuredWriterOffsetMinutes, Info log when it disagrees
with the process zone).
Constraints
- An anchored line resolves to
now ± 2 minby construction. The accepted edge: a freshly written line carrying a replayed old timestamp whose age is exactly N×15 min ± 2 min anchors to ≈now instead of its replayed past — bounded error, and sub-24 h replays are treated as current by the historic-replay guard anyway. - Snapshot readers (
StallProbeCollector,LogParserCollector) cannot tail and therefore cannot prove freshness — they stay on the marked fallback. This is a documented limit, not an omission; sharing the per-file measurement with them would re-apply exactly what the revert removed. - Mixed pairs remain possible across a restart (start resolved from backlog fallback, completion line-anchored) and are recognizable via provenance.
Line parsing (bounded cost)
CmTraceLogParser.TryParseLine (Shared) is the single CMTrace line parser for the agent
(ImeLogTracker, LogParserCollector, StallProbeCollector) and the backend
(TestLogPatternFunction). Both feed it input an outsider influences: tenant-supplied
sample lines (200 x 8 KB per request) on the backend, and on the agent every tailed log
file, including entries that IME assembles from installer or script output. It is
therefore not one regex. The former shape — a greedy (?<message>.*) in front of the
]LOG]!><time=" literal, unanchored, no match timeout — re-tried every <![LOG[
occurrence as a fresh start and backtracked the message group across every later character
for each: quadratic in line length (an 8 KB line of repeated prefixes and ] filler cost
~7 million engine steps; a 1 MB assembled entry would have spun the SYSTEM agent for
hours and blacked out IME tracking for the rest of the enrollment).
The parser reproduces exactly what that regex returned, in linear time: the message begins
after the first <![LOG[ (a later start could only match when the first one does, since
.* spans the difference) and ends at the last ]LOG]!><time=" occurrence whose trailer
parses — earlier occurrences are tried in turn, which is the order greedy backtracking
visited them. The trailer is checked with a \G-anchored regex that has no ambiguous
quantifier and a 1-second match timeout as a backstop; a timeout means "not a CMTrace
line" (callers already have that path: raw-text matching in the tracker, parse_failed
on the backend), never an exception. CmTraceLogParserTests pins the equivalence against
the legacy regex over the shapes that matter (multiline messages, bias suffix, trailer
literal inside the message, a last trailer that fails to parse, BOM prefix) and the
reported worst case at 200 lines.
On the agent, ImeLogTracker additionally caps an assembled multiline entry at 100 raw
lines and 1 MB of characters (MaxMultiLineBufferChars). A capped entry is dropped
and logged at Warning — visible at the default Info level, so a real IME entry that
large shows up in the client log and can be acted on. The remaining physical lines of a
dropped entry are skipped until it closes or a new <![LOG[ entry begins; previously they
fell through to raw-text pattern matching, so fragments of a discarded entry could fire
patterns without CMTrace context.
Backend regression tripwire
The backend watches for the failure mode this design eliminates. On every terminal ingest
batch, the counter reconcile's existing event-partition scan additionally collects
Δ = ReceivedAt − OccurredUtc per event, split by Source == "ImeLogTracker" versus the
rest (CmTraceSkewTripwire, hooked in EventIngestProcessor). If
median(Δ_IME) − median(Δ_other) is a clean 15-minute multiple (residual < 2 min — the
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
upload batches per side, and ≥ 80 % of the individual IME deltas (relative to the other
side's median) themselves sit within 2 min of some grid multiple — added after the 2026-08-28
soak, when relaunched agents re-tailing the IME log produced one burst holding most of a
session's IME samples with a continuum of ages whose median hit the grid by chance (7 false
fires in a week, 0 true) — a CmTraceTimeSkewRegression ops event fires (category Agent,
warning), carrying all numbers plus the session's sourceOffsetOrigin histogram.
Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring
regression); measuredWriterOffsetMinutes is never consulted (sticky after era
flip-backs). Kill switch: app setting CmTraceSkewTripwireDisabled. Goal state: the event
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
Samples are windowed to the session's most recent INGEST ERA before any of that is
computed: the batch stamps of both sides are walked backwards from the newest one and the
era ends at the first ReceivedAt gap wider than 2 h. A session partition is not one agent
run — a pre-provisioning session's Part 1 is written by whatever agent build was current
weeks earlier, and its events stay in the partition forever. Field 2026-09-01 (sessions
e797117b / c06d639d / d7c8032b, one tenant): 26 IME samples at exactly −60 min from a
2026-08-20 technician leg under agent 2.0.1409 — before per-line anchoring — outnumbered the
3…9 clean samples of the user leg that completed that morning and fired the tripwire against
2.0.1445, a build the device had self-updated to minutes earlier. The skew was real, 12 days
old, and already fixed; no scan-wide statistic can tell those apart, because event rows carry
no agent version. The era boundary can. eraStartUtc and the excluded per-side sample counts
travel in the ops event, so an inherited leg stays visible without being alarmed on. The
2 h threshold sits above any in-leg upload gap (a live agent uploads self-metrics every few
minutes, reports session_stalled at 60 min idle, and the maintenance sweep classifies
silence at 2 h) and below every pre-provisioning handover.
Device-clock problems (the customer-actionable cousin) are covered separately by the
clock_skew analyze condition behind ANALYZE-DEV-008, which excludes IME-derived events
precisely so an anchoring regression can never surface as a customer finding.
Citations
CmTraceOffsetCalibrator.TryMeasureOffset— the pure grid measurement, shared by anchoring and the observational per-file path.CmTraceSkewTripwire+CmTraceSkewTripwireTests— the backend tripwire above.RuleEngine.ConditionEvaluators.EvaluateClockSkewCondition+RuleEngineClockSkewTests— the customer-facing device-clock metrics (batch-median frames, spool-spread filter, plateau step detection, end-state persistence).ImeLogTracker.LogProcessing.csResolveEntryUtc— the resolution order above.ImeLogTrackerLineAnchoringTests— resolves-to-T across writer beliefs, interleaved eras in one chunk, freshness boundaries (first sight, empty first sight, poll gap, restart restore), the grid-replay edge, and the field fixture replayed at production poll cadence.CmTraceCalibratorFileIsolationTests— pins per-file isolation of the observational measurement (session e9753578 logged a cross-file pairing the committed code could not be shown to produce; the mechanism remains open, the resolution path is immune by design, andCalibrateFromlogs the anchor timestamp as a tripwire).tasks/todo.md(2026-08-20) — full evidence trail: field measurements, the revert, the two-writer-era fixture, and the disproven foreign-binary hypothesis (the SHA mismatch was the known release race against the 5-minute AdminConfiguration cache).CmTraceLogParser.TryParseLine+CmTraceLogParserTests— the linear parser and its legacy-regex equivalence oracle;ImeLogTracker.LogProcessing.csmultiline buffering (MaxMultiLineBufferLines/MaxMultiLineBufferChars,skippingDroppedEntry).