feat(timezone): recreate v1 TZ-aware formatting + scheduling behavior

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>
This commit is contained in:
gavrielc
2026-04-20 01:09:14 +03:00
parent 0283391e0a
commit dcfa12ea06
8 changed files with 549 additions and 30 deletions

View File

@@ -0,0 +1,167 @@
/**
* v1-parity tests for formatter behavior.
*
* Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1
* deletion commit 86becf8). Covers: context timezone header, reply_to +
* quoted_message rendering, XML escaping, and stripInternalTags.
*
* Timestamp-format assertions use `formatLocalTime()` output format, which
* is host locale-dependent for decorators (month abbr, "," separator) but
* stable for the numeric parts we assert on (hour, minute, year).
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getPendingMessages } from './db/messages-in.js';
import { formatMessages, stripInternalTags } from './formatter.js';
import { TIMEZONE } from './timezone.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { timestamp?: string },
) {
const timestamp = opts?.timestamp ?? new Date().toISOString();
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES (?, ?, ?, 'pending', ?)`,
)
.run(id, kind, timestamp, JSON.stringify(content));
}
describe('context timezone header', () => {
it('prepends <context timezone="..."/> to formatted output', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' });
const result = formatMessages(getPendingMessages());
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
});
it('includes the header even when the message list is empty', () => {
const result = formatMessages([]);
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
});
it('header comes before the <messages> block', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
const result = formatMessages(getPendingMessages());
const ctxIdx = result.indexOf('<context');
const msgsIdx = result.indexOf('<messages>');
expect(ctxIdx).toBeGreaterThanOrEqual(0);
expect(msgsIdx).toBeGreaterThan(ctxIdx);
});
});
describe('timestamp formatting', () => {
it('renders time via formatLocalTime (user TZ)', () => {
// 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable)
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' });
const result = formatMessages(getPendingMessages());
// formatLocalTime's format in en-US contains the year and a month abbrev
expect(result).toContain('2026');
expect(result).toMatch(/Jun/);
});
it('uses 12-hour AM/PM format', () => {
// 15:30 UTC — some hour will show with AM or PM depending on TZ
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' });
const result = formatMessages(getPendingMessages());
expect(result).toMatch(/(AM|PM)/);
});
});
describe('reply_to + quoted_message rendering', () => {
it('renders reply_to attribute and quoted_message when all fields present', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'Yes, on my way!',
replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' },
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('reply_to="42"');
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
expect(result).toContain('Yes, on my way!</message>');
});
it('omits reply_to and quoted_message when no reply context', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' });
const result = formatMessages(getPendingMessages());
expect(result).not.toContain('reply_to');
expect(result).not.toContain('quoted_message');
});
it('renders reply_to but omits quoted_message when original content is missing', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'ack',
replyTo: { id: '42', sender: 'Bob' }, // no text
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('reply_to="42"');
expect(result).not.toContain('quoted_message');
});
it('XML-escapes reply context', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'reply',
replyTo: { id: '1', sender: 'A & B', text: '<script>alert("xss")</script>' },
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('from="A &amp; B"');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&quot;xss&quot;');
});
});
describe('XML escaping', () => {
it('escapes <, >, &, " in sender and body', () => {
insertMessage('m1', 'chat', {
sender: 'A & B <Co>',
text: '<script>alert("xss")</script>',
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
expect(result).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
});
describe('stripInternalTags', () => {
it('strips single-line internal tags and trims', () => {
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
});
it('strips multi-line internal tags', () => {
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe(
'hello world',
);
});
it('strips multiple internal tag blocks', () => {
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
});
it('returns empty string when input is only internal tags', () => {
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
});
it('returns input unchanged when there are no internal tags', () => {
expect(stripInternalTags('hello world')).toBe('hello world');
});
it('preserves content that surrounds internal tags', () => {
expect(stripInternalTags('<internal>thinking</internal>The answer is 42')).toBe(
'The answer is 42',
);
});
});

View File

@@ -1,5 +1,6 @@
import { findByRouting } from './destinations.js';
import type { MessageInRow } from './db/messages-in.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';
/**
* Command categories for messages starting with '/'.
@@ -92,10 +93,19 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
/**
* Format a batch of messages_in rows into a prompt string.
*
* Prepends a `<context timezone="<IANA>" />` header so the agent always knows
* what timezone it's in — every timestamp it sees in message bodies is the
* user's local time, and every time it produces (schedules, suggests) should
* be interpreted as local time in that same zone. This header is v1 behavior
* (src/v1/router.ts:20-22); dropping it led to misinterpretations where the
* agent scheduled tasks for the wrong hour.
*
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
*/
export function formatMessages(messages: MessageInRow[]): string {
if (messages.length === 0) return '';
const header = `<context timezone="${escapeXml(TIMEZONE)}" />\n`;
if (messages.length === 0) return header;
// Group by kind
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
@@ -118,7 +128,7 @@ export function formatMessages(messages: MessageInRow[]): string {
parts.push(...systemMessages.map(formatSystemMessage));
}
return parts.join('\n\n');
return header + parts.join('\n\n');
}
function formatChatMessages(messages: MessageInRow[]): string {
@@ -137,9 +147,10 @@ function formatChatMessages(messages: MessageInRow[]): string {
function formatSingleChat(msg: MessageInRow): string {
const content = parseContent(msg.content);
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatTime(msg.timestamp);
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const text = content.text || '';
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : '';
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
@@ -154,7 +165,7 @@ function formatSingleChat(msg: MessageInRow): string {
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
: '';
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {
@@ -179,13 +190,22 @@ function formatSystemMessage(msg: MessageInRow): string {
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
}
/**
* Render the quoted original inside the <message> body.
*
* Matches v1 format (src/v1/router.ts:10-18): `<quoted_message from="X">Y</quoted_message>`.
* Requires BOTH sender and text — if only id is present the reply_to attribute
* on the parent <message> carries the link without an inline preview.
*
* No truncation here (v1 didn't truncate).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatReplyContext(replyTo: any): string {
if (!replyTo) return '';
const sender = replyTo.sender || 'Unknown';
const text = replyTo.text || '';
const preview = text.length > 100 ? text.slice(0, 100) + '…' : text;
return `\n<reply-to sender="${escapeXml(sender)}">${escapeXml(preview)}</reply-to>\n`;
const sender = replyTo.sender;
const text = replyTo.text;
if (!sender || !text) return '';
return `\n <quoted_message from="${escapeXml(sender)}">${escapeXml(text)}</quoted_message>\n`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -213,15 +233,15 @@ function parseContent(json: string): any {
}
}
function formatTime(timestamp: string): string {
try {
const d = new Date(timestamp);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
} catch {
return timestamp;
}
}
function escapeXml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Strip `<internal>...</internal>` blocks from agent output, then trim.
* Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's
* own scratchpad/reasoning before a reply goes out over a channel.
*/
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}

View File

@@ -8,6 +8,7 @@
import { getInboundDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
@@ -35,13 +36,21 @@ export const scheduleTask: McpToolDefinition = {
tool: {
name: 'schedule_task',
description:
'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.',
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
inputSchema: {
type: 'object' as const,
properties: {
prompt: { type: 'string', description: 'Task instructions/prompt' },
processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' },
recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' },
processAfter: {
type: 'string',
description:
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
},
recurrence: {
type: 'string',
description:
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
},
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
},
required: ['prompt', 'processAfter'],
@@ -49,8 +58,17 @@ export const scheduleTask: McpToolDefinition = {
},
async handler(args) {
const prompt = args.prompt as string;
const processAfter = args.processAfter as string;
if (!prompt || !processAfter) return err('prompt and processAfter are required');
const processAfterIn = args.processAfter as string;
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
let processAfter: string;
try {
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${processAfterIn}`);
}
const id = generateId();
const r = routing();
@@ -233,7 +251,11 @@ export const updateTask: McpToolDefinition = {
type: 'string',
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
},
processAfter: { type: 'string', description: 'New ISO timestamp for the next run (optional)' },
processAfter: {
type: 'string',
description:
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
},
script: {
type: 'string',
description: 'New pre-agent script (optional). Pass empty string to clear.',
@@ -248,7 +270,15 @@ export const updateTask: McpToolDefinition = {
const update: Record<string, unknown> = { taskId };
if (typeof args.prompt === 'string') update.prompt = args.prompt;
if (typeof args.processAfter === 'string') update.processAfter = args.processAfter;
if (typeof args.processAfter === 'string') {
try {
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
update.processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${args.processAfter}`);
}
}
// Empty string clears recurrence/script; undefined leaves them as-is.
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;

View File

@@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow }
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -384,10 +384,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
scratchpadParts.push(text.slice(lastIndex));
}
const scratchpad = scratchpadParts
.join('')
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
.trim();
const scratchpad = stripInternalTags(scratchpadParts.join(''));
// Single-destination shortcut: the agent wrote plain text — send to
// the session's originating channel (from session_routing) if available,

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'bun:test';
import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js';
// --- formatLocalTime ---
describe('formatLocalTime', () => {
it('converts UTC to local time display', () => {
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York');
expect(result).toContain('1:30');
expect(result).toContain('PM');
expect(result).toContain('Feb');
expect(result).toContain('2026');
});
it('handles different timezones', () => {
// Same UTC time should produce different local times
const utc = '2026-06-15T12:00:00.000Z';
const ny = formatLocalTime(utc, 'America/New_York');
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
expect(ny).toContain('8:00');
expect(tokyo).toContain('9:00');
});
it('does not throw on invalid timezone, falls back to UTC', () => {
expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow();
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
// Should format as UTC (noon UTC = 12:00 PM)
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('isValidTimezone', () => {
it('accepts valid IANA identifiers', () => {
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
});
it('rejects invalid timezone strings', () => {
expect(isValidTimezone('IST-2')).toBe(false);
expect(isValidTimezone('XYZ+3')).toBe(false);
});
it('rejects empty and garbage strings', () => {
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('resolveTimezone', () => {
it('returns the timezone if valid', () => {
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
});
it('falls back to UTC for invalid timezone', () => {
expect(resolveTimezone('IST-2')).toBe('UTC');
expect(resolveTimezone('')).toBe('UTC');
});
});
describe('parseZonedToUtc', () => {
it('passes strings with Z suffix through unchanged', () => {
const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York');
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
});
it('passes strings with numeric offset through unchanged', () => {
const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York');
expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z');
});
it('interprets naive ISO as wall-clock in the given timezone', () => {
// 09:00 naive in NY in January = 09:00 EST = 14:00 UTC
const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York');
expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z');
});
it('handles a different positive-offset zone', () => {
// 09:00 naive in Tokyo (UTC+9) = 00:00 UTC
const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo');
expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z');
});
it('treats invalid timezone as UTC', () => {
const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone');
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
});
});

View File

@@ -0,0 +1,107 @@
/**
* 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);
}

View File

@@ -0,0 +1,100 @@
/**
* Tests for `handleRecurrence` — specifically the timezone-aware cron
* interpretation ported from v1 (src/v1/task-scheduler.ts).
*
* Core invariant: cron expressions are interpreted in the user's TIMEZONE,
* not UTC. Without this, `"0 9 * * *"` fires at 09:00 UTC instead of 09:00
* user-local — a recurring scheduling bug users can't diagnose.
*/
import fs from 'fs';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
import { insertTask } from './db.js';
import { handleRecurrence } from './recurrence.js';
import type { Session } from '../../types.js';
const TEST_DIR = '/tmp/nanoclaw-recurrence-test';
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
function freshDb() {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
ensureSchema(DB_PATH, 'inbound');
return openInboundDb(DB_PATH);
}
function fakeSession(): Session {
return {
id: 'sess-test',
agent_group_id: 'ag-test',
messaging_group_id: 'mg-test',
thread_id: null,
status: 'active',
created_at: new Date().toISOString(),
last_active: new Date().toISOString(),
container_status: 'stopped',
} as Session;
}
afterEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('handleRecurrence', () => {
it('clones a completed recurring task with a next-run in the future', async () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: '2020-01-01T00:00:00.000Z',
recurrence: '0 9 * * *', // every day at 09:00 (user TZ)
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'daily digest' }),
});
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
await handleRecurrence(db, fakeSession());
const rows = db
.prepare(
`SELECT id, status, process_after, recurrence, series_id FROM messages_in ORDER BY seq`,
)
.all() as Array<{
id: string;
status: string;
process_after: string;
recurrence: string | null;
series_id: string;
}>;
expect(rows).toHaveLength(2);
const original = rows.find((r) => r.id === 'task-1')!;
const follow = rows.find((r) => r.id !== 'task-1')!;
expect(original.recurrence).toBeNull();
expect(follow.status).toBe('pending');
expect(follow.recurrence).toBe('0 9 * * *');
expect(follow.series_id).toBe('task-1');
expect(new Date(follow.process_after).getTime()).toBeGreaterThan(Date.now());
});
it('does not clone rows whose recurrence is already cleared', async () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: '2020-01-01T00:00:00.000Z',
recurrence: null,
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'one-off' }),
});
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
await handleRecurrence(db, fakeSession());
const count = (db.prepare(`SELECT COUNT(*) AS c FROM messages_in`).get() as { c: number }).c;
expect(count).toBe(1);
});
});

View File

@@ -13,6 +13,7 @@
*/
import type Database from 'better-sqlite3';
import { TIMEZONE } from '../../config.js';
import { log } from '../../log.js';
import type { Session } from '../../types.js';
import { clearRecurrence, getCompletedRecurring, insertRecurrence } from './db.js';
@@ -23,7 +24,11 @@ export async function handleRecurrence(inDb: Database.Database, session: Session
for (const msg of recurring) {
try {
const { CronExpressionParser } = await import('cron-parser');
const interval = CronExpressionParser.parse(msg.recurrence);
// Interpret the cron expression in the user's timezone. v1 did this
// (src/v1/task-scheduler.ts:20-49); without it, a task written "0 9 * * *"
// by an agent running in a user's local TZ fires at 09:00 UTC instead of
// 09:00 user-local.
const interval = CronExpressionParser.parse(msg.recurrence, { tz: TIMEZONE });
const nextRun = interval.next().toISOString();
const prefix = msg.kind === 'task' ? 'task' : 'msg';
const newId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;