The agent needs to perceive times in the user's timezone, not UTC.
Dropping this in the v1→v2 port produced a class of bugs where the agent
would schedule tasks for the wrong hour, suggest dinner at midnight, etc.
This restores v1 parity.
Container side:
- New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with
isValidTimezone / resolveTimezone / formatLocalTime, plus:
* TIMEZONE constant resolved at load from process.env.TZ (host sets this
from src/container-runner.ts:254)
* parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in
`tz`, returns the corresponding UTC Date. Strings with Z or offset
are passed through.
- formatter.ts:
* formatMessages() now prepends <context timezone="IANA"/>\n — matches
v1 src/v1/router.ts:20-22
* formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a
home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM"
* reply_to="<id>" attribute + <quoted_message from="X">Y</quoted_message>
element — matches v1 format exactly; old <reply-to/> shape is gone
* stripInternalTags() exported for the dispatch path to reuse
- poll-loop.ts uses the exported stripInternalTags() instead of inline regex.
- mcp-tools/scheduling.ts:
* schedule_task/update_task descriptions now explicitly document that
processAfter accepts either UTC or naive local time (interpreted in
the user's TZ from the context header)
* handlers normalize through parseZonedToUtc() and store a UTC ISO
Host side:
- src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to
CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00
UTC instead of 09:00 user-local — this was the v1 behavior
(src/v1/task-scheduler.ts:20-49).
Tests:
- container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts
+ new parseZonedToUtc cases
- container/agent-runner/src/formatter.test.ts — context header, reply_to,
quoted_message, XML escaping, stripInternalTags (ported from v1
formatting.test.ts)
- src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed
rows only cloned when recurrence is set
Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
/**
|
|
* Timezone utilities — mirror of src/timezone.ts (host).
|
|
*
|
|
* The container can't import from src/ (separate tsconfig, different runtime).
|
|
* Kept deliberately byte-aligned with the host module so behaviour is the
|
|
* same on both sides of the session-DB boundary.
|
|
*
|
|
* TIMEZONE is resolved once at module load from process.env.TZ (which the host
|
|
* sets from its own TIMEZONE constant when spawning the container; see
|
|
* src/container-runner.ts). Invalid values fall back to UTC.
|
|
*/
|
|
|
|
/**
|
|
* Check whether a timezone string is a valid IANA identifier
|
|
* that Intl.DateTimeFormat can use.
|
|
*/
|
|
export function isValidTimezone(tz: string): boolean {
|
|
try {
|
|
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
|
*/
|
|
export function resolveTimezone(tz: string): string {
|
|
return isValidTimezone(tz) ? tz : 'UTC';
|
|
}
|
|
|
|
/**
|
|
* Convert a UTC ISO timestamp to a localized display string.
|
|
* Uses the Intl API (no external dependencies).
|
|
* Falls back to UTC if the timezone is invalid.
|
|
*/
|
|
export function formatLocalTime(utcIso: string, timezone: string): string {
|
|
const date = new Date(utcIso);
|
|
return date.toLocaleString('en-US', {
|
|
timeZone: resolveTimezone(timezone),
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
}
|
|
|
|
function resolveContainerTimezone(): string {
|
|
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
|
for (const tz of candidates) {
|
|
if (tz && isValidTimezone(tz)) return tz;
|
|
}
|
|
return 'UTC';
|
|
}
|
|
|
|
export const TIMEZONE = resolveContainerTimezone();
|
|
|
|
/**
|
|
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
|
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
|
* offset info (`Z` or `±HH:MM`) are passed through to the Date constructor
|
|
* unchanged.
|
|
*
|
|
* Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that
|
|
* UTC instant is called in `tz`, then invert the offset. Near DST boundaries
|
|
* this can be off by an hour for ~1h of wall-clock time per year; acceptable
|
|
* for scheduling where the agent normally picks round-hour targets.
|
|
*/
|
|
export function parseZonedToUtc(input: string, tz: string): Date {
|
|
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
|
|
if (hasOffset) return new Date(input);
|
|
|
|
const zone = resolveTimezone(tz);
|
|
const asIfUtc = new Date(input + 'Z');
|
|
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
|
|
|
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: zone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
const parts = Object.fromEntries(
|
|
fmt
|
|
.formatToParts(asIfUtc)
|
|
.filter((p) => p.type !== 'literal')
|
|
.map((p) => [p.type, p.value]),
|
|
);
|
|
const hour = parts.hour === '24' ? '00' : parts.hour;
|
|
const zonedAsUtcMs = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(hour),
|
|
Number(parts.minute),
|
|
Number(parts.second),
|
|
);
|
|
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
|
|
return new Date(asIfUtc.getTime() - offsetMs);
|
|
}
|