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,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)}`;