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>
55 lines
2.1 KiB
TypeScript
55 lines
2.1 KiB
TypeScript
/**
|
|
* Sweep hook for recurring tasks.
|
|
*
|
|
* Every sweep tick, find `messages_in` rows that are `completed` AND still
|
|
* have a `recurrence` cron expression. For each, compute the next run via
|
|
* cron-parser, insert a fresh pending row (copying series_id forward), then
|
|
* clear the recurrence on the original so it isn't re-cloned next tick.
|
|
*
|
|
* Called from `src/host-sweep.ts` inside `MODULE-HOOK:scheduling-recurrence`.
|
|
* When scheduling ships inline (current state through PR #7), the hook is a
|
|
* direct dynamic import. When scheduling moves to the modules branch in
|
|
* PR #8, the install skill re-fills the marker on install.
|
|
*/
|
|
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';
|
|
|
|
export async function handleRecurrence(inDb: Database.Database, session: Session): Promise<void> {
|
|
const recurring = getCompletedRecurring(inDb);
|
|
|
|
for (const msg of recurring) {
|
|
try {
|
|
const { CronExpressionParser } = await import('cron-parser');
|
|
// 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)}`;
|
|
|
|
insertRecurrence(inDb, msg, newId, nextRun);
|
|
clearRecurrence(inDb, msg.id);
|
|
|
|
log.info('Inserted next recurrence', {
|
|
originalId: msg.id,
|
|
newId,
|
|
seriesId: msg.series_id,
|
|
nextRun,
|
|
sessionId: session.id,
|
|
});
|
|
} catch (err) {
|
|
log.error('Failed to compute next recurrence', {
|
|
messageId: msg.id,
|
|
recurrence: msg.recurrence,
|
|
err,
|
|
});
|
|
}
|
|
}
|
|
}
|