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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user