diff --git a/loopx/control_plane/runtime_timestamp.ts b/loopx/control_plane/runtime_timestamp.ts index 867be7b0fa..e80c89c531 100644 --- a/loopx/control_plane/runtime_timestamp.ts +++ b/loopx/control_plane/runtime_timestamp.ts @@ -1,7 +1,13 @@ -/** Calendar-checked ISO codec; Date.parse alone silently rolls invalid dates. */ +/** Calendar-checked ISO codec; Date.parse alone silently rolls invalid dates. + * RFC 3339 ยง5.6 allows a lowercase "t" separator. Normalize only that + * separator before the existing calendar grammar. */ export function parseIsoTimestamp(value: string): Date | null { + const source = value.trim().replace( + /^(\d{4}-\d{2}-\d{2})t(?=\d{2}:)/u, + "$1T", + ); const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|z|[+-]\d{2}(?::?\d{2})?)?)?$/u.exec( - value.trim(), + source, ); if (match === null) return null; const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction, timezone] = match; @@ -30,7 +36,7 @@ export function parseIsoTimestamp(value: string): Date | null { calendar.getUTCMilliseconds() !== millisecond ) return null; if (hourText === undefined) return calendar; - let text = value.trim().replace(" ", "T").replace(/z$/u, "Z"); + let text = source.replace(" ", "T").replace(/z$/u, "Z"); if (fraction !== undefined) text = text.replace(`.${fraction}`, `.${fraction.slice(0, 3)}`); if (timezone === undefined) text += "Z"; else text = text.replace(/([+-]\d{2})$/u, "$1:00"); diff --git a/loopx/control_plane/scheduler/state_store.ts b/loopx/control_plane/scheduler/state_store.ts index 53d498034b..81f8ecb6b9 100644 --- a/loopx/control_plane/scheduler/state_store.ts +++ b/loopx/control_plane/scheduler/state_store.ts @@ -17,6 +17,7 @@ import { requireNonEmptyString as requiredString, requireStringLiteral, } from "../runtime_decode.ts"; +import { parseIsoTimestamp } from "../runtime_timestamp.ts"; export const SCHEDULER_STATE_SCHEMA_VERSION = "loopx_scheduler_state_v0"; export const SCHEDULER_HOST_UPDATE_FAILURE_SCHEMA_VERSION = @@ -216,14 +217,26 @@ export function normalizeSchedulerHostUpdateFailures( return normalized.slice(-HOST_UPDATE_FAILURE_CACHE_LIMIT); } +/** Calendar-checked ISO instant. Blank is null; a missing timezone means UTC. */ export function schedulerTimestampMilliseconds(value: unknown): number | null { const text = trimmed(value); if (!text) return null; - const timezoneAware = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/.test(text) - ? text - : `${text}Z`; - const parsed = Date.parse(timezoneAware); - return Number.isNaN(parsed) ? null : parsed; + const parsed = parseIsoTimestamp(text); + return parsed === null ? null : parsed.getTime(); +} + +/** Blank stays null. Present text that is not a calendar-checked ISO timestamp is rejected. */ +export function requireSchedulerTimestampMilliseconds( + value: unknown, + label: string, +): number | null { + const text = trimmed(value); + if (!text) return null; + const parsed = schedulerTimestampMilliseconds(text); + if (parsed === null) { + throw new EffectRuntimeRequestError(`${label} must be an ISO timestamp`); + } + return parsed; } export function retainedSchedulerHostUpdateFailures( @@ -232,7 +245,10 @@ export function retainedSchedulerHostUpdateFailures( observedHostRrule: unknown = null, ): JsonObject[] { const failures = normalizeSchedulerHostUpdateFailures(value); - const now = schedulerTimestampMilliseconds(referenceTime) ?? Date.now(); + const now = requireSchedulerTimestampMilliseconds( + referenceTime, + "reference_time", + ) ?? Date.now(); const cutoff = now - HOST_UPDATE_FAILURE_TTL_MS; const expectedHostRrule = normalizeSchedulerRrule(observedHostRrule); return failures.filter((failure) => { diff --git a/loopx/control_plane/scheduler/state_transition_rules.ts b/loopx/control_plane/scheduler/state_transition_rules.ts index 79d9f8ceaf..cc5c634320 100644 --- a/loopx/control_plane/scheduler/state_transition_rules.ts +++ b/loopx/control_plane/scheduler/state_transition_rules.ts @@ -10,6 +10,7 @@ import { normalizeSchedulerHostUpdateFailures, normalizeSchedulerRrule, retainedSchedulerHostUpdateFailures, + requireSchedulerTimestampMilliseconds, rruleForMinutes, schedulerRruleIntervalMinutes, schedulerTimestampMilliseconds, @@ -359,17 +360,40 @@ function schedulerProgressionIntervalElapsed( schedulerState: JsonObject, currentTime: string, ): boolean { - const updatedAt = schedulerTimestampMilliseconds(schedulerState.updated_at); - const current = schedulerTimestampMilliseconds(currentTime); const appliedInterval = schedulerRruleIntervalMinutes( schedulerState.last_applied_rrule, ); - if (updatedAt === null || current === null || appliedInterval === null) { - return true; - } + // Missing interval or blank updated_at stays elapsed. A present illegal + // calendar is rejected instead of rolled or treated as elapsed. + if (appliedInterval === null) return true; + const updatedAt = requireSchedulerTimestampMilliseconds( + schedulerState.updated_at, + "updated_at", + ); + if (updatedAt === null) return true; + const current = requireSchedulerTimestampMilliseconds( + currentTime, + "current_time", + ); + if (current === null) return true; return current >= updatedAt + appliedInterval * 60_000; } +// evaluateCadence reads the old clock only after identity matches and the +// same-identity path is comparing the applied interval. An obsolete receipt +// must not block identity_reset, an unacknowledged retry, or a held initial. +function cadenceConsumesAppliedInterval( + statePresent: boolean, + identityMatches: boolean, + hasHostUpdateFailures: boolean, + currentCadenceAcknowledged: boolean, + advanceSameIdentity: boolean, +): boolean { + if (!statePresent || !identityMatches) return false; + if (hasHostUpdateFailures && !currentCadenceAcknowledged) return false; + return advanceSameIdentity; +} + function monitorRruleAppliedWithinStaleTolerance( cadenceClass: string, lastAppliedRrule: string, @@ -400,6 +424,7 @@ function evaluateBackoff(request: JsonObject): SchedulerBackoffTransitionResult "identity_signature", ); const currentTime = requiredString(request.current_time, "current_time"); + requireSchedulerTimestampMilliseconds(currentTime, "current_time"); const cadenceClass = requiredString(request.cadence_class, "cadence_class"); const staleToleranceMinutes = requiredInteger( request.stale_tolerance_minutes, @@ -428,23 +453,33 @@ function evaluateBackoff(request: JsonObject): SchedulerBackoffTransitionResult : ""; const currentCadenceAcknowledged = Boolean(appliedTargetRrule) && normalizeSchedulerRrule(lastAppliedRrule) === appliedTargetRrule; + const statePresent = Object.keys(schedulerState).length > 0; + const identityMatches = statePresent && + schedulerState.reset_token === resetToken && + schedulerState.identity_signature === identitySignature; + const advanceSameIdentity = requiredBoolean( + request.advance_same_identity, + "advance_same_identity", + ); + const hasHostUpdateFailures = allHostUpdateFailures.length > 0; + const appliedIntervalElapsed = cadenceConsumesAppliedInterval( + statePresent, + identityMatches, + hasHostUpdateFailures, + currentCadenceAcknowledged, + advanceSameIdentity, + ) + ? schedulerProgressionIntervalElapsed(schedulerState, currentTime) + : false; const cadence = evaluateCadence({ progression_size: progressionMinutes.length, - state_present: Object.keys(schedulerState).length > 0, - identity_matches: Object.keys(schedulerState).length > 0 && - schedulerState.reset_token === resetToken && - schedulerState.identity_signature === identitySignature, + state_present: statePresent, + identity_matches: identityMatches, applied_index: appliedIndex, current_cadence_acknowledged: currentCadenceAcknowledged, - advance_same_identity: requiredBoolean( - request.advance_same_identity, - "advance_same_identity", - ), - applied_interval_elapsed: schedulerProgressionIntervalElapsed( - schedulerState, - currentTime, - ), - has_host_update_failures: allHostUpdateFailures.length > 0, + advance_same_identity: advanceSameIdentity, + applied_interval_elapsed: appliedIntervalElapsed, + has_host_update_failures: hasHostUpdateFailures, }); const currentIndex = cadence.current_index; const currentInterval = progressionMinutes[currentIndex]; diff --git a/tests/control_plane_ts/scheduler_state_store.test.ts b/tests/control_plane_ts/scheduler_state_store.test.ts index 01cd69dca3..c269df5886 100644 --- a/tests/control_plane_ts/scheduler_state_store.test.ts +++ b/tests/control_plane_ts/scheduler_state_store.test.ts @@ -9,6 +9,7 @@ import { loadSchedulerState, normalizeSchedulerState, schedulerStatePath, + schedulerTimestampMilliseconds, SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, SCHEDULER_STATE_STORE_REQUEST_SCHEMA, writeSchedulerState, @@ -160,6 +161,76 @@ test("failure retention accepts the ISO offsets persisted by Python", () => { assert.equal((result as unknown[]).length, 1); }); +function hostUpdateFailure(failedAt: string) { + return { + schema_version: "scheduler_host_update_failure_v0", + target_rrule: "FREQ=MINUTELY;INTERVAL=3", + observed_host_rrule: "FREQ=MINUTELY;INTERVAL=30", + failure_kind: "timeout", + failure_count: 1, + failed_at: failedAt, + }; +} + +test("scheduler timestamp codec rejects illegal calendars and keeps offset instants", () => { + assert.equal(schedulerTimestampMilliseconds("2026-02-31T00:00:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-02-29T00:00:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2024-02-29T00:00:00Z"), Date.UTC(2024, 1, 29)); + assert.equal(schedulerTimestampMilliseconds("2026-11-31T10:04:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-01-32T12:10:00Z"), null); + assert.equal(schedulerTimestampMilliseconds(" "), null); + assert.equal( + schedulerTimestampMilliseconds("2026-08-25T10:00:00+08:00"), + Date.UTC(2026, 7, 25, 2, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-01-01T12:00:00"), + Date.UTC(2026, 0, 1, 12, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-01-01T12:00:00+0000"), + Date.UTC(2026, 0, 1, 12, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-12-04t01:15:00z"), + Date.UTC(2026, 11, 4, 1, 15, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-12-04t01:15:00z"), + schedulerTimestampMilliseconds("2026-12-04T01:15:00Z"), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-08-25t10:00:00+08:00"), + schedulerTimestampMilliseconds("2026-08-25T10:00:00+08:00"), + ); + assert.equal(schedulerTimestampMilliseconds("2026-11-31t10:04:00z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-02-31t00:00:00z"), null); +}); + +test("failure retention does not roll illegal calendar timestamps into the TTL window", () => { + for (const [failedAt, referenceTime] of [ + ["2026-02-31T00:00:00Z", "2026-03-03T00:00:00Z"], + ["2026-11-31T10:04:00Z", "2026-12-01T10:04:00Z"], + ] as const) { + const retained = evaluateSchedulerStateOperation({ + schema_version: SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, + operation: "retain_failures", + value: [hostUpdateFailure(failedAt)], + reference_time: referenceTime, + }).value; + assert.deepEqual(retained, [], failedAt); + } + assert.throws( + () => evaluateSchedulerStateOperation({ + schema_version: SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, + operation: "retain_failures", + value: [hostUpdateFailure("2026-03-03T00:00:00Z")], + reference_time: "2026-02-31T00:00:00Z", + }), + /reference_time must be an ISO timestamp/, + ); +}); + test("pure scheduler state operations do not mutate caller input", () => { const params = fixture.cases[7].params as Record; const before = structuredClone(params); diff --git a/tests/control_plane_ts/scheduler_state_transition_rules.test.ts b/tests/control_plane_ts/scheduler_state_transition_rules.test.ts index 73cff165ed..5fbfc74349 100644 --- a/tests/control_plane_ts/scheduler_state_transition_rules.test.ts +++ b/tests/control_plane_ts/scheduler_state_transition_rules.test.ts @@ -141,6 +141,22 @@ test("monitor schedule materializes cadence and preserves explicit due time", () assert.equal(explicit.schedule_source, "explicit"); assert.equal(explicit.cadence_seconds, 3600); + const lowercaseDue = evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25T10:00:00Z", + explicit_next_due_at: "2026-12-04t01:15:00z", + }); + assert.equal(lowercaseDue.next_due_at, "2026-12-04t01:15:00z"); + assert.equal(lowercaseDue.schedule_source, "explicit"); + const lowercaseGenerated = evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25t10:00:00z", + cadence: "30 minutes", + }); + assert.equal(lowercaseGenerated.next_due_at, "2026-08-25T10:30:00Z"); + const missing = evaluateSchedulerStateTransition({ schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, operation: "monitor_schedule", @@ -172,6 +188,33 @@ test("monitor schedule rejects malformed timestamps without wall-clock fallback" ); }); +test("monitor schedule rejects illegal calendar timestamps without wall-clock fallback", () => { + for (const generatedAt of ["2026-02-29T00:00:00Z", "2026-02-31T00:00:00Z", "2026-11-31T10:04:00Z", "2026-02-31t00:00:00z"]) { + assert.throws( + () => evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: generatedAt, + cadence: "30 minutes", + }), + /generated_at must be an ISO timestamp/, + generatedAt, + ); + } + for (const explicitNextDueAt of ["2026-02-31T12:00:00Z", "2026-11-31T10:04:00Z", "2026-11-31t10:04:00z"]) { + assert.throws( + () => evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25T10:00:00Z", + explicit_next_due_at: explicitNextDueAt, + }), + /explicit_next_due_at must be an ISO timestamp/, + explicitNextDueAt, + ); + } +}); + function backoffRequest(extra: Record = {}) { return { schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, @@ -234,6 +277,132 @@ test("coarse backoff transition owns cadence, retention, and host decision", () ); }); +test("backoff rejects illegal calendar clocks instead of rolling them", () => { + const schedulerState = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-01-01T12:00:00Z", + }; + const held = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: schedulerState, + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(held.cadence_transition, "hold_until_interval"); + + // Jan 32 is not a Date.parse instant on this runtime; Feb 31 and Nov 31 are + // rolled forward. Neither clock may count as an elapsed backoff interval. + for (const currentTime of [ + "2026-01-32T12:10:00Z", + "2026-02-31T12:10:00Z", + "2026-11-31T12:10:00Z", + ]) { + assert.throws( + () => evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: schedulerState, + current_time: currentTime, + })), + /current_time must be an ISO timestamp/, + currentTime, + ); + } + for (const updatedAt of ["2026-01-32T12:00:00Z", "2026-02-31T12:00:00Z"]) { + assert.throws( + () => evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { ...schedulerState, updated_at: updatedAt }, + current_time: "2026-01-01T12:10:00Z", + })), + /updated_at must be an ISO timestamp/, + updatedAt, + ); + } +}); + +test("backoff identity reset does not read an obsolete illegal updated_at", () => { + const obsolete = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "old-reset-token", + identity_signature: "old-identity", + updated_at: "2026-02-31T12:00:00Z", + }; + const reset = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: obsolete, + reset_token: "new-reset-token", + identity_signature: "new-identity", + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(reset.state_status, "reset_required"); + assert.equal(reset.cadence_transition, "identity_reset"); + assert.equal(reset.current_index, 0); + assert.equal(reset.current_interval_minutes, 15); + assert.equal(reset.current_rrule, "FREQ=MINUTELY;INTERVAL=15"); + assert.equal(reset.host_transition, "apply_required"); + + const legalClock = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { ...obsolete, updated_at: "2026-01-01T12:00:00Z" }, + reset_token: "new-reset-token", + identity_signature: "new-identity", + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(legalClock.cadence_transition, "identity_reset"); + assert.equal(legalClock.current_interval_minutes, 15); + assert.equal(legalClock.host_transition, "apply_required"); + + const retry = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { + progression_index: 0, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=60", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-11-31T12:00:00Z", + host_update_failures: [{ + schema_version: "scheduler_host_update_failure_v0", + target_rrule: "FREQ=MINUTELY;INTERVAL=15", + observed_host_rrule: "FREQ=MINUTELY;INTERVAL=60", + failure_kind: "timeout", + failure_count: 1, + failed_at: "2026-01-01T11:30:00Z", + }], + }, + observed_host_rrule: "RRULE:FREQ=MINUTELY;INTERVAL=60", + })); + assert.equal(retry.cadence_transition, "retry_unacknowledged_failure"); + + const heldInitial = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-01-32T12:00:00Z", + }, + advance_same_identity: false, + })); + assert.equal(heldInitial.cadence_transition, "hold_active_initial"); + assert.equal(heldInitial.current_index, 0); +}); + +test("backoff still treats a missing or blank updated_at as an elapsed interval", () => { + const schedulerState = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + }; + for (const updatedAt of [undefined, "", " "]) { + const state = updatedAt === undefined + ? schedulerState + : { ...schedulerState, updated_at: updatedAt }; + const advanced = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: state, + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(advanced.cadence_transition, "advance_after_interval"); + } +}); + test("coarse backoff boundary rejects malformed state facts", () => { assert.throws( () => evaluateSchedulerStateTransition(backoffRequest({