diff --git a/container/agent-runner/src/formatter.test.ts b/container/agent-runner/src/formatter.test.ts
new file mode 100644
index 0000000..e34156c
--- /dev/null
+++ b/container/agent-runner/src/formatter.test.ts
@@ -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 to formatted output', () => {
+ insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' });
+ const result = formatMessages(getPendingMessages());
+ expect(result).toContain(` {
+ const result = formatMessages([]);
+ expect(result).toContain(` block', () => {
+ insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
+ insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
+ const result = formatMessages(getPendingMessages());
+ const ctxIdx = result.indexOf('');
+ 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('Are you coming tonight?');
+ expect(result).toContain('Yes, on my way!');
+ });
+
+ 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: '' },
+ });
+ const result = formatMessages(getPendingMessages());
+ expect(result).toContain('from="A & B"');
+ expect(result).toContain('<script>');
+ expect(result).toContain('"xss"');
+ });
+});
+
+describe('XML escaping', () => {
+ it('escapes <, >, &, " in sender and body', () => {
+ insertMessage('m1', 'chat', {
+ sender: 'A & B ',
+ text: '',
+ });
+ const result = formatMessages(getPendingMessages());
+ expect(result).toContain('sender="A & B <Co>"');
+ expect(result).toContain('<script>alert("xss")</script>');
+ });
+});
+
+describe('stripInternalTags', () => {
+ it('strips single-line internal tags and trims', () => {
+ expect(stripInternalTags('hello secret world')).toBe('hello world');
+ });
+
+ it('strips multi-line internal tags', () => {
+ expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe(
+ 'hello world',
+ );
+ });
+
+ it('strips multiple internal tag blocks', () => {
+ expect(stripInternalTags('ahellob')).toBe('hello');
+ });
+
+ it('returns empty string when input is only internal tags', () => {
+ expect(stripInternalTags('only this')).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('thinkingThe answer is 42')).toBe(
+ 'The answer is 42',
+ );
+ });
+});
diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts
index fbf1ed9..b03f5bd 100644
--- a/container/agent-runner/src/formatter.ts
+++ b/container/agent-runner/src/formatter.ts
@@ -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 `` 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 = `\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 `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`;
+ return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`;
}
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 body.
+ *
+ * Matches v1 format (src/v1/router.ts:10-18): `Y`.
+ * Requires BOTH sender and text — if only id is present the reply_to attribute
+ * on the parent 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${escapeXml(preview)}\n`;
+ const sender = replyTo.sender;
+ const text = replyTo.text;
+ if (!sender || !text) return '';
+ return `\n ${escapeXml(text)}\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, '&').replace(//g, '>').replace(/"/g, '"');
}
+
+/**
+ * Strip `...` 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(/[\s\S]*?<\/internal>/g, '').trim();
+}
diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts
index 168808c..00e41bb 100644
--- a/container/agent-runner/src/mcp-tools/scheduling.ts
+++ b/container/agent-runner/src/mcp-tools/scheduling.ts
@@ -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 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 = { 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;
diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts
index cc26286..742de14 100644
--- a/container/agent-runner/src/poll-loop.ts
+++ b/container/agent-runner/src/poll-loop.ts
@@ -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(/[\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,
diff --git a/container/agent-runner/src/timezone.test.ts b/container/agent-runner/src/timezone.test.ts
new file mode 100644
index 0000000..a4539e9
--- /dev/null
+++ b/container/agent-runner/src/timezone.test.ts
@@ -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');
+ });
+});
diff --git a/container/agent-runner/src/timezone.ts b/container/agent-runner/src/timezone.ts
new file mode 100644
index 0000000..d9a2e1b
--- /dev/null
+++ b/container/agent-runner/src/timezone.ts
@@ -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);
+}
diff --git a/src/modules/scheduling/recurrence.test.ts b/src/modules/scheduling/recurrence.test.ts
new file mode 100644
index 0000000..a70d6c8
--- /dev/null
+++ b/src/modules/scheduling/recurrence.test.ts
@@ -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);
+ });
+});
diff --git a/src/modules/scheduling/recurrence.ts b/src/modules/scheduling/recurrence.ts
index a8a2e5c..d521f95 100644
--- a/src/modules/scheduling/recurrence.ts
+++ b/src/modules/scheduling/recurrence.ts
@@ -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)}`;