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:
100
src/modules/scheduling/recurrence.test.ts
Normal file
100
src/modules/scheduling/recurrence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
|
||||
Reference in New Issue
Block a user