From 69dc3752c58593b991d32d7de6d3d9e346666bac Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:08:11 -0700 Subject: [PATCH 1/4] fix: align todo timestamp parsing with Python ISO behavior Signed-off-by: mika <211269698+mikamikasuki@users.noreply.github.com> --- loopx/control_plane/runtime_timestamp.ts | 16 ++++++++++++++-- tests/control_plane/test_monitor_state_owner.py | 1 + tests/control_plane_ts/monitor_metadata.test.ts | 10 ++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/runtime_timestamp.ts b/loopx/control_plane/runtime_timestamp.ts index 668e29eb28..7df58d2577 100644 --- a/loopx/control_plane/runtime_timestamp.ts +++ b/loopx/control_plane/runtime_timestamp.ts @@ -69,15 +69,27 @@ export function parseTodoTimestampMicros(value: string): bigint | null { const parsed = /^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?(?:[.,](\d+))?$/.exec(raw); if (!parsed) return null; const hour = Number(parsed[1]), minute = Number(parsed[3] ?? 0), second = Number(parsed[4] ?? 0); - if (!offset && (hour > 23 || minute > 59 || second > 59)) return null; + const fraction = parsed[5] ?? ""; + // datetime.fromisoformat only accepts a fractional component after seconds. + // Python 3.14 also accepts 24:00 as the end of the day, but no other 24-hour + // value; retain that input compatibility in the TypeScript owner. + if (fraction && parsed[4] === undefined) return null; + if (!offset && ( + hour > 24 || minute > 59 || second > 59 || + (hour === 24 && (minute !== 0 || second !== 0 || /[1-9]/u.test(fraction))) + )) return null; const seconds = hour * 3600 + minute * 60 + second; - const micros = BigInt(seconds) * 1000000n + BigInt((parsed[5] ?? "").padEnd(6, "0").slice(0, 6)); + const micros = BigInt(seconds) * 1000000n + BigInt(fraction.padEnd(6, "0").slice(0, 6)); if (offset && micros >= 86400000000n) return null; // Python treats an all-zero offset as UTC even with fractional seconds. return offset && seconds === 0 ? 0n : micros; } const local = clock(parts[1], false); if (local === null) return null; + // Python's datetime range ends at year 9999; 24:00 cannot roll that date + // into year 10000 even though the wall-clock fields are otherwise valid. + if (local === 86400000000n && calendar.getUTCFullYear() === 9999 && + calendar.getUTCMonth() === 11 && calendar.getUTCDate() === 31) return null; let offset = 0n; if (parts[2] && !["Z", "z"].includes(parts[2])) { const parsed = clock(parts[2].slice(1), true); diff --git a/tests/control_plane/test_monitor_state_owner.py b/tests/control_plane/test_monitor_state_owner.py index 0432a6b510..07267453ab 100644 --- a/tests/control_plane/test_monitor_state_owner.py +++ b/tests/control_plane/test_monitor_state_owner.py @@ -81,6 +81,7 @@ def test_monitor_effect_replay_and_generation_are_locked_public_writer_semantics "2030-01-01T12:34:56.123456+08:00", "20300101T123456,123456+0800", "1970-01-01T01:00:00+00:59:59.999999", "1970-01-01T00.1", "1970-02-30", "2021-W53", "1970-01-01T24:00:00", "1970-01-01T01:00+24:00", + "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", "1970-01-01T00:0000", "1970-01-01T0000:00", "tomorrow", "2030", ]) def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index cdd42903a3..0969a85a3e 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -100,9 +100,12 @@ test("poll rejects invalid observation shapes without mutating its input", () => }); test("timestamp codec rejects rollover dates and ordering retains microseconds", () => { - for (const invalid of ["2030-02-30T00:00:00Z", "2030-01-01T24:00:00Z", "2030"]) { + for (const invalid of ["2030-02-30T00:00:00Z", "2030"]) { assert.throws(() => planMonitorMetadata(request({metadata: {expires_at: invalid}})), /timestamp/); } + assert.doesNotThrow(() => planMonitorMetadata(request({ + metadata: {expires_at: "2030-01-01T24:00:00Z"}, + }))); assert.throws(() => planMonitorMetadata(request({ existing: {...existing, last_checked_at: "2030-01-01T01:00:00.000002Z"}, observation: observation({generated_at: "2030-01-01T01:00:00.000001Z", monitor_effect_id: null}), @@ -155,7 +158,10 @@ test("Todo timestamp codec retains Python ISO compatibility, timezone seconds an "1970-01-01T01:00:00+00:59:59.999999"]) { assert.equal(parseTodoTimestampMicros(value), 1n, value); } - for (const value of ["1970-02-30", "2021-W53", "1970-01-01T24:00:00", "1970-01-01T01:00+24:00", + assert.equal(parseTodoTimestampMicros("1970-01-01T24:00:00"), 86400000000n); + for (const value of ["1970-02-30", "2021-W53", "1970-01-01T01:00+24:00", + "1970-01-01T00.1", "1970-01-01T00:00.1", "1970-01-01T24:00:01", + "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", "1970-01-01T00:0000", "1970-01-01T0000:00", "tomorrow", "2030"]) { assert.equal(parseTodoTimestampMicros(value), null, value); } From 5e4f1fc7eacadc00c2c476180159628a3f35614d Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:34:10 -0700 Subject: [PATCH 2/4] fix(todos): keep timestamp grammar stable across Python Signed-off-by: mika <211269698+mikamikasuki@users.noreply.github.com> --- loopx/control_plane/runtime/time.py | 60 ++++++++++++++++++- loopx/control_plane/runtime_timestamp.ts | 11 +--- .../control_plane/test_monitor_state_owner.py | 30 +++++++++- .../control_plane_ts/monitor_metadata.test.ts | 11 ++-- 4 files changed, 96 insertions(+), 16 deletions(-) diff --git a/loopx/control_plane/runtime/time.py b/loopx/control_plane/runtime/time.py index 66282db3a3..e40c5fc200 100644 --- a/loopx/control_plane/runtime/time.py +++ b/loopx/control_plane/runtime/time.py @@ -1,10 +1,63 @@ from __future__ import annotations from datetime import datetime, timezone +import re from typing import Any _MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc) +_ISO_DATE_WITH_SEPARATOR = re.compile( + r"^(?P\d{4}-W\d{2}(?:-[1-7])?|\d{4}W\d{2}[1-7]?|" + r"\d{4}-\d{2}-\d{2}|\d{8})(?P[^Zz])(?P.+)$" +) +_ISO_CLOCK = re.compile(r"^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?([.,]\d+)?$") + + +def _stable_iso_timestamp_text(text: str) -> str | None: + """Normalize reduced fractional clocks and reject version-specific 24:00. + + Python 3.11–3.13 accept fractions after an hour or minute, while 3.14 + accepts 24:00 as end-of-day. Keep the retained reader's wire grammar stable + across supported Python versions: preserve the former inputs and reject the + latter because the TypeScript Todo codec does not carry an end-of-day date + rollover. + """ + match = _ISO_DATE_WITH_SEPARATOR.fullmatch(text) + if match is None: + return text + + clock = match.group("clock") + zone_suffix = "" + if clock.endswith(("Z", "z")): + clock, zone_suffix = clock[:-1], clock[-1:] + else: + zone_index = next((i for i, char in enumerate(clock) if i and char in "+-"), None) + if zone_index is not None: + clock, zone_suffix = clock[:zone_index], clock[zone_index:] + + def normalize_component(component: str, *, local: bool) -> str | None: + parsed = _ISO_CLOCK.fullmatch(component) + if parsed is None: + return component + hour, separator, minute, second, fraction = parsed.groups() + if local and hour == "24": + return None + if fraction is None or second is not None: + return component + return f"{hour}:{minute or '00'}:00{fraction}" + + normalized_clock = normalize_component(clock, local=True) + if normalized_clock is None: + return None + if zone_suffix.startswith(("+", "-")): + normalized_offset = normalize_component(zone_suffix[1:], local=False) + if normalized_offset is None: + return text + zone_suffix = zone_suffix[0] + normalized_offset + + return ( + match.group("date") + match.group("separator") + normalized_clock + zone_suffix + ) def parse_timestamp(value: Any) -> datetime | None: @@ -13,8 +66,13 @@ def parse_timestamp(value: Any) -> datetime | None: text = str(value).strip() if not text: return None + stable_text = _stable_iso_timestamp_text(text) + if stable_text is None: + return None try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00").replace("z", "+00:00")) + parsed = datetime.fromisoformat( + stable_text.replace("Z", "+00:00").replace("z", "+00:00") + ) except ValueError: return None if parsed.tzinfo is None: diff --git a/loopx/control_plane/runtime_timestamp.ts b/loopx/control_plane/runtime_timestamp.ts index 7df58d2577..44cc8ffe06 100644 --- a/loopx/control_plane/runtime_timestamp.ts +++ b/loopx/control_plane/runtime_timestamp.ts @@ -70,13 +70,8 @@ export function parseTodoTimestampMicros(value: string): bigint | null { if (!parsed) return null; const hour = Number(parsed[1]), minute = Number(parsed[3] ?? 0), second = Number(parsed[4] ?? 0); const fraction = parsed[5] ?? ""; - // datetime.fromisoformat only accepts a fractional component after seconds. - // Python 3.14 also accepts 24:00 as the end of the day, but no other 24-hour - // value; retain that input compatibility in the TypeScript owner. - if (fraction && parsed[4] === undefined) return null; if (!offset && ( - hour > 24 || minute > 59 || second > 59 || - (hour === 24 && (minute !== 0 || second !== 0 || /[1-9]/u.test(fraction))) + hour > 23 || minute > 59 || second > 59 )) return null; const seconds = hour * 3600 + minute * 60 + second; const micros = BigInt(seconds) * 1000000n + BigInt(fraction.padEnd(6, "0").slice(0, 6)); @@ -86,10 +81,6 @@ export function parseTodoTimestampMicros(value: string): bigint | null { } const local = clock(parts[1], false); if (local === null) return null; - // Python's datetime range ends at year 9999; 24:00 cannot roll that date - // into year 10000 even though the wall-clock fields are otherwise valid. - if (local === 86400000000n && calendar.getUTCFullYear() === 9999 && - calendar.getUTCMonth() === 11 && calendar.getUTCDate() === 31) return null; let offset = 0n; if (parts[2] && !["Z", "z"].includes(parts[2])) { const parsed = clock(parts[2].slice(1), true); diff --git a/tests/control_plane/test_monitor_state_owner.py b/tests/control_plane/test_monitor_state_owner.py index 07267453ab..e7a7cccea6 100644 --- a/tests/control_plane/test_monitor_state_owner.py +++ b/tests/control_plane/test_monitor_state_owner.py @@ -90,7 +90,7 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, "metadata": {"expires_at": value}, } - # The pre-migration stdlib codec is an independent input-compatibility oracle. + # The retained Python reader defines the stable cross-version wire grammar. if parse_timestamp(value) is None: with pytest.raises(EffectRuntimeRejected, match="timestamp"): effect_runtime_result("todo.monitor_metadata.plan", request) @@ -99,6 +99,34 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): assert result["metadata"]["expires_at"] == value +@pytest.mark.parametrize(("value", "expected_utc"), [ + ("2030-01-01T00.1", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00.1", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00:00+01.1", "2029-12-31T22:59:59.900000+00:00"), +]) +def test_monitor_timestamp_codec_is_stable_across_supported_python_versions(value, expected_utc): + parsed = parse_timestamp(value) + assert parsed is not None + assert parsed.isoformat() == expected_utc + result = effect_runtime_result("todo.monitor_metadata.plan", { + "schema_version": "loopx_todo_monitor_metadata_request_v0", + "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, + "metadata": {"expires_at": value}, + }) + assert result["metadata"]["expires_at"] == value + + +def test_monitor_timestamp_codec_rejects_version_specific_end_of_day(): + value = "2030-01-01T24:00:00" + assert parse_timestamp(value) is None + with pytest.raises(EffectRuntimeRejected, match="timestamp"): + effect_runtime_result("todo.monitor_metadata.plan", { + "schema_version": "loopx_todo_monitor_metadata_request_v0", + "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, + "metadata": {"expires_at": value}, + }) + + @pytest.mark.parametrize("date", ["1970-01-01", "19700101", "1970-W01-4"]) @pytest.mark.parametrize("separator", ["Z", "z"]) def test_public_writer_rejects_timezone_letter_as_date_separator(tmp_path, date, separator): diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index 0969a85a3e..596ecf96fe 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -103,9 +103,9 @@ test("timestamp codec rejects rollover dates and ordering retains microseconds", for (const invalid of ["2030-02-30T00:00:00Z", "2030"]) { assert.throws(() => planMonitorMetadata(request({metadata: {expires_at: invalid}})), /timestamp/); } - assert.doesNotThrow(() => planMonitorMetadata(request({ + assert.throws(() => planMonitorMetadata(request({ metadata: {expires_at: "2030-01-01T24:00:00Z"}, - }))); + })), /timestamp/); assert.throws(() => planMonitorMetadata(request({ existing: {...existing, last_checked_at: "2030-01-01T01:00:00.000002Z"}, observation: observation({generated_at: "2030-01-01T01:00:00.000001Z", monitor_effect_id: null}), @@ -158,9 +158,12 @@ test("Todo timestamp codec retains Python ISO compatibility, timezone seconds an "1970-01-01T01:00:00+00:59:59.999999"]) { assert.equal(parseTodoTimestampMicros(value), 1n, value); } - assert.equal(parseTodoTimestampMicros("1970-01-01T24:00:00"), 86400000000n); + for (const value of ["1970-01-01T00.1", "1970-01-01T00:00.1"]) { + assert.equal(parseTodoTimestampMicros(value), 100000n, value); + } + assert.equal(parseTodoTimestampMicros("1970-01-01T00:00:00+01.1"), -3600100000n); for (const value of ["1970-02-30", "2021-W53", "1970-01-01T01:00+24:00", - "1970-01-01T00.1", "1970-01-01T00:00.1", "1970-01-01T24:00:01", + "1970-01-01T24:00:00", "1970-01-01T24:00:01", "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", "1970-01-01T00:0000", "1970-01-01T0000:00", "tomorrow", "2030"]) { assert.equal(parseTodoTimestampMicros(value), null, value); From ff115d1b13143be69c98ae5ea5a8aa1019cfe1ab Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:07:47 -0700 Subject: [PATCH 3/4] fix(todos): normalize fractional zero offsets Signed-off-by: mika <211269698+mikamikasuki@users.noreply.github.com> --- loopx/control_plane/runtime/time.py | 5 +++++ tests/control_plane/test_monitor_state_owner.py | 3 +++ tests/control_plane_ts/monitor_metadata.test.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/loopx/control_plane/runtime/time.py b/loopx/control_plane/runtime/time.py index e40c5fc200..dbf5eeaf7f 100644 --- a/loopx/control_plane/runtime/time.py +++ b/loopx/control_plane/runtime/time.py @@ -42,6 +42,11 @@ def normalize_component(component: str, *, local: bool) -> str | None: hour, separator, minute, second, fraction = parsed.groups() if local and hour == "24": return None + if not local and hour == "00" and minute in (None, "00") and second in (None, "00"): + # CPython 3.11–3.13 canonicalize a zero-hour offset to UTC even + # when a fractional component follows it. Keep that wire meaning + # on 3.14 too, matching the TypeScript codec's zero-offset rule. + return "00:00:00" if fraction is None or second is not None: return component return f"{hour}:{minute or '00'}:00{fraction}" diff --git a/tests/control_plane/test_monitor_state_owner.py b/tests/control_plane/test_monitor_state_owner.py index e7a7cccea6..bff2dd09d6 100644 --- a/tests/control_plane/test_monitor_state_owner.py +++ b/tests/control_plane/test_monitor_state_owner.py @@ -103,6 +103,9 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): ("2030-01-01T00.1", "2030-01-01T00:00:00.100000+00:00"), ("2030-01-01T00:00.1", "2030-01-01T00:00:00.100000+00:00"), ("2030-01-01T00:00:00+01.1", "2029-12-31T22:59:59.900000+00:00"), + ("2030-01-01T00:00:00+00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00:00:00-00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00:00:00+00:00:00.1", "2030-01-01T00:00:00+00:00"), ]) def test_monitor_timestamp_codec_is_stable_across_supported_python_versions(value, expected_utc): parsed = parse_timestamp(value) diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index 596ecf96fe..7e0010fd9a 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -162,6 +162,10 @@ test("Todo timestamp codec retains Python ISO compatibility, timezone seconds an assert.equal(parseTodoTimestampMicros(value), 100000n, value); } assert.equal(parseTodoTimestampMicros("1970-01-01T00:00:00+01.1"), -3600100000n); + for (const value of ["1970-01-01T00:00:00+00.1", "1970-01-01T00:00:00-00.1", + "1970-01-01T00:00:00+00:00:00.1"]) { + assert.equal(parseTodoTimestampMicros(value), 0n, value); + } for (const value of ["1970-02-30", "2021-W53", "1970-01-01T01:00+24:00", "1970-01-01T24:00:00", "1970-01-01T24:00:01", "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", From 900530f99fdabc0c586f7f0c1c51a14633195cd8 Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:12:43 -0700 Subject: [PATCH 4/4] test(todos): cover fractional zero-offset readback Signed-off-by: mika <211269698+mikamikasuki@users.noreply.github.com> --- tests/control_plane/test_monitor_state_owner.py | 2 ++ tests/control_plane_ts/monitor_metadata.test.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/tests/control_plane/test_monitor_state_owner.py b/tests/control_plane/test_monitor_state_owner.py index bff2dd09d6..386a32c2c4 100644 --- a/tests/control_plane/test_monitor_state_owner.py +++ b/tests/control_plane/test_monitor_state_owner.py @@ -106,6 +106,8 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): ("2030-01-01T00:00:00+00.1", "2030-01-01T00:00:00+00:00"), ("2030-01-01T00:00:00-00.1", "2030-01-01T00:00:00+00:00"), ("2030-01-01T00:00:00+00:00:00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00.1+00.5", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00:00.1+00:00:00.5", "2030-01-01T00:00:00.100000+00:00"), ]) def test_monitor_timestamp_codec_is_stable_across_supported_python_versions(value, expected_utc): parsed = parse_timestamp(value) diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index 7e0010fd9a..36b05b5b00 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -166,6 +166,9 @@ test("Todo timestamp codec retains Python ISO compatibility, timezone seconds an "1970-01-01T00:00:00+00:00:00.1"]) { assert.equal(parseTodoTimestampMicros(value), 0n, value); } + for (const value of ["1970-01-01T00.1+00.5", "1970-01-01T00:00:00.1+00:00:00.5"]) { + assert.equal(parseTodoTimestampMicros(value), 100000n, value); + } for (const value of ["1970-02-30", "2021-W53", "1970-01-01T01:00+24:00", "1970-01-01T24:00:00", "1970-01-01T24:00:01", "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000",