From 284a37900dfc80e0c5c30b7fb01a13b29b90bb47 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Fri, 4 Sep 2026 17:45:30 +0800 Subject: [PATCH] fix(shared): stop fired one-shot crons resolving to next year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduled-tasks panel derives pending tasks from the Cron*/ScheduleWakeup tool_call items in history and anchors a one-shot cron's single fire time at the owning turn's endedAt. Cron-fire follow-up turns are runtime-internal steers, so one history entry can aggregate several runtime turns and its endedAt keeps advancing — past the one-shot's fire minute. The resolver then skips to the next matching year, and a job that already fired (and was auto-deleted by the scheduler) shows "fires in 364 days" forever, because the phantom fire time is in the future and the fired-row filter can never catch it. - history-apply stamps scheduling tool calls with recordedAtMs, the first-persisted wall-clock sighting; merges keep the first stamp so replayed/retried updates cannot move it. - The deriver resolves a one-shot cron by preferring the runtime-committed nextFireAt line from the persisted output (exact, and also heals existing history), then recordedAtMs, then the turn's START — endedAt is now the last resort. Wakeups anchor at recordedAtMs as well. Model: kimi-code/k3 --- apps/cli/src/lib/AGENTS.md | 7 +- packages/shared/src/acp/history-apply.ts | 23 +++- packages/shared/src/ai.ts | 9 ++ packages/shared/src/message-schemas.ts | 3 + .../src/scheduled-tasks-from-history.ts | 80 +++++++++--- packages/shared/src/schema.ts | 13 +- .../shared/tests/acp-history-apply.test.ts | 65 ++++++++++ .../scheduled-tasks-from-history.test.ts | 120 +++++++++++++++++- 8 files changed, 298 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/lib/AGENTS.md b/apps/cli/src/lib/AGENTS.md index 5a20f1dd7..ebda8d393 100644 --- a/apps/cli/src/lib/AGENTS.md +++ b/apps/cli/src/lib/AGENTS.md @@ -293,7 +293,12 @@ control-plane path is DEPRECATED; do not add functionality to it. whose small `rawInput`/`rawOutput` are kept, whose persisted `title` is pinned to the canonical tool name, and which also record `schedulingTimeZone` (this machine's IANA zone, captured at persist time — cron is local-time to it, so the panel resolves fire times in - that zone via `nextCronFireMs`, not the viewer's browser zone). The deriver reads exactly + that zone via `nextCronFireMs`, not the viewer's browser zone) and `recordedAtMs` (the + first-persisted wall-clock sighting; the turn entry's `endedAt` is NOT a safe creation + anchor because runtime-internal cron-fire steers keep extending the same entry past a + one-shot's fire minute, which rolled the resolved fire to the next year — the "fires in + 364 days" phantom). For one-shot crons the deriver prefers the output's `nextFireAt` + line over any anchor. The deriver reads exactly those fields — do not "clean up" this exception or the panel goes silently empty (unit tests fabricate history and won't catch it). The former `_meta.claudeCode.toolName` carrier is read only by the centralized diff --git a/packages/shared/src/acp/history-apply.ts b/packages/shared/src/acp/history-apply.ts index 0c4da4735..9466d1681 100644 --- a/packages/shared/src/acp/history-apply.ts +++ b/packages/shared/src/acp/history-apply.ts @@ -929,6 +929,8 @@ const mergeToolCallMessage = ( incoming.schedulingTimeZone !== undefined ? incoming.schedulingTimeZone : prev.schedulingTimeZone, + // The first-persisted stamp wins; a replayed/retried update must not move it. + recordedAtMs: prev.recordedAtMs ?? incoming.recordedAtMs, toolName: incoming.toolName ?? prev.toolName, activityKind: incoming.activityKind !== undefined ? incoming.activityKind : prev.activityKind, }; @@ -1567,7 +1569,7 @@ class NotificationOnHistoryApplier { return; } const entryIndex = this.ensureActiveAssistantEntry(); - this.upsertToolCall(entryIndex, message); + this.upsertToolCall(entryIndex, this.stampSchedulingToolCall(message)); this.toolCallEntryIndexById.set(message.toolCallId, entryIndex); return; } @@ -1590,6 +1592,25 @@ class NotificationOnHistoryApplier { } } + /** + * Stamp a scheduling tool call with its first-persisted wall-clock sighting. The + * scheduled-tasks deriver anchors a one-shot cron's fire time at its creation moment, + * and the turn entry's `endedAt` is NOT that moment: cron-fire follow-up turns are + * runtime-internal steers that keep extending the same history entry, so `endedAt` + * can land past the one-shot's fire minute and roll the resolved fire time a year + * forward. Replay imports stamp their own import time — no worse than the turn anchor + * they replace, and the output's `nextFireAt` still wins for one-shots there. + */ + private stampSchedulingToolCall(message: ToolCallMessage): ToolCallMessage { + if (message.recordedAtMs !== undefined) return message; + if (message.toolName === undefined || !SCHEDULING_TOOL_NAMES.has(message.toolName)) { + return message; + } + const recordedAtMs = Date.parse(this.now()); + if (!Number.isFinite(recordedAtMs)) return message; + return { ...message, recordedAtMs }; + } + /** * Update the plan field on the current assistant entry. * Plan is stored directly on the entry, not as a MessageContent item. diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 960b9bf4b..225f98d51 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -1489,6 +1489,15 @@ export type MessageContent = * for scheduling tool calls; see `collectPendingScheduledTasksFromHistory`. */ schedulingTimeZone?: string; + /** + * Epoch ms when this tool call was first persisted by the machine that ran it. + * Only set for scheduling tool calls: the turn entry's timestamps are NOT a safe + * proxy for the creation moment (cron-fire follow-up turns are runtime-internal + * steers, so one history entry can aggregate several runtime turns and its + * `endedAt` keeps advancing past a one-shot's fire minute). See + * `collectPendingScheduledTasksFromHistory`. + */ + recordedAtMs?: number; permissionRequest?: { requestId: string; options: PermissionOption[]; diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index 78e4b1b13..9796d8eeb 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -3154,6 +3154,9 @@ export const NonSystemNoticeMessageContentSchema = z.discriminatedUnion('type', toolName: z.string().optional(), // IANA timezone of the machine that ran a scheduling tool (cron is local-time to it). schedulingTimeZone: z.string().optional(), + // Epoch ms when a scheduling tool call was first persisted (true creation moment; + // turn-level timestamps are not a safe proxy — see `recordedAtMs` in ai.ts). + recordedAtMs: z.number().optional(), permissionRequest: PermissionRequestInfoSchema.optional(), }), z.object({ diff --git a/packages/shared/src/scheduled-tasks-from-history.ts b/packages/shared/src/scheduled-tasks-from-history.ts index 7bf9e716b..6f7623905 100644 --- a/packages/shared/src/scheduled-tasks-from-history.ts +++ b/packages/shared/src/scheduled-tasks-from-history.ts @@ -8,16 +8,24 @@ import type { PendingScheduledTask } from './schema'; * calls this on the history it already renders. * * The persisted `tool_call` keeps `title` (the tool name), `rawInput`, `rawOutput`, `content`, - * `status`, and `schedulingTimeZone` (the creating machine's zone), but not provider metadata. - * So we reconstruct from - * `rawInput` + the owning turn's timestamp: - * - ScheduleWakeup: only the latest matters; scheduledFor ≈ turn end + delaySeconds (no TZ). + * `status`, `schedulingTimeZone` (the creating machine's zone), and `recordedAtMs` (when the + * call was first persisted), but not provider metadata. So we reconstruct from + * `rawInput` + the call's `recordedAtMs` (falling back to the owning turn's timestamps): + * - ScheduleWakeup: only the latest matters; scheduledFor ≈ call time + delaySeconds (no TZ). * - CronCreate: schedule/recurring/prompt come from rawInput.cron/recurring/prompt, and the * cron is local-time to `schedulingTimeZone` — carried onto the task so the UI resolves it - * in the right zone (cron carries no timezone; the machine may differ from the viewer). + * in the right zone (cron carries no timezone; the machine may differ from the viewer). For + * a one-shot (`recurring: false`), the output's `nextFireAt` line is the runtime-committed + * fire time and wins over any re-derivation from the expression. * - CronDelete: removes any cron whose output text contains the deleted id. * - CronList: skipped (its structured jobs aren't persisted). * Fire-time resolution and "already fired -> hide" happen in the UI (see `nextCronFireMs`). + * + * Why the turn entry's `endedAt` is NOT the anchor: cron-fire follow-up turns are + * runtime-internal steers, so one history entry can aggregate several runtime turns and its + * `endedAt` keeps advancing — past a one-shot's fire minute. Anchoring there rolls the + * resolved fire time to the NEXT matching year (a fired job showing "fires in 364 days"), + * and the panel's fired-row filter can never catch it because that phantom time is future. */ const MAX_SUMMARY_LENGTH = 200; @@ -71,18 +79,40 @@ function cleanTask(task: PendingScheduledTask): PendingScheduledTask { return out; } -/** Best-effort anchor for a turn: prefer when it ended, else started, else its timestamp. */ +/** + * Best-effort anchor for a turn when the tool call carries no `recordedAtMs`: prefer when + * it STARTED, else its timestamp; `endedAt` is the last resort (see the module doc — a + * merged entry's `endedAt` can land past a one-shot's fire minute and skip a year ahead). + */ function resolveAnchorMs(entry: ScheduledTaskHistoryEntry): number { - if (typeof entry.endedAt === 'number' && Number.isFinite(entry.endedAt)) return entry.endedAt; if (typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt)) return entry.startedAt; if (entry.timestamp) { const parsed = Date.parse(entry.timestamp); if (!Number.isNaN(parsed)) return parsed; } + if (typeof entry.endedAt === 'number' && Number.isFinite(entry.endedAt)) return entry.endedAt; return 0; } +/** + * The runtime-committed fire time from a CronCreate output's `nextFireAt:` line (local ISO + * with numeric offset, so `Date.parse` reads it exactly). The output format is a deliberate + * one-key-per-line contract (`formatOutput` in the runtime's CronCreate tool), and the text + * survives the history pipeline inside `rawOutput` / `content` (terminal_output). Returns + * undefined when absent or unparseable — the caller falls back to re-deriving from the cron + * expression. + */ +function parseCommittedOneShotFireMs(sourceText: string): number | undefined { + const match = + /nextFireAt:\s*"?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/i.exec( + sourceText + ); + if (!match || match[1] === undefined) return undefined; + const parsed = Date.parse(match[1]); + return Number.isNaN(parsed) ? undefined : parsed; +} + export function collectPendingScheduledTasksFromHistory( entries: readonly ScheduledTaskHistoryEntry[] ): PendingScheduledTask[] { @@ -101,12 +131,13 @@ export function collectPendingScheduledTasksFromHistory( switch (toolName) { case 'ScheduleWakeup': { + const callMs = asFiniteNumber(item.recordedAtMs) ?? anchorMs; const delaySeconds = asFiniteNumber(rawInput.delaySeconds); wakeup = cleanTask({ id: WAKEUP_TASK_ID, kind: 'wakeup', - createdAtMs: anchorMs, - scheduledForMs: delaySeconds !== undefined ? anchorMs + delaySeconds * 1000 : undefined, + createdAtMs: callMs, + scheduledForMs: delaySeconds !== undefined ? callMs + delaySeconds * 1000 : undefined, summary: truncateSummary(asString(rawInput.reason) ?? asString(rawInput.prompt)), }); break; @@ -115,20 +146,30 @@ export function collectPendingScheduledTasksFromHistory( const cron = asString(rawInput.cron); const toolCallId = asString(item.toolCallId); if (!cron || !toolCallId) break; + // The created job's id (needed to match a later CronDelete) and the committed + // one-shot fire time are only in the output text, so keep it for a robust + // substring match and the `nextFireAt` parse. + const sourceText = JSON.stringify([item.rawOutput ?? null, item.content ?? null]); + const recurring = asBoolean(rawInput.recurring); cronByCallId.set(toolCallId, { task: cleanTask({ id: toolCallId, kind: 'cron', - createdAtMs: anchorMs, + // The call's own persist stamp is the true creation moment; the turn anchor + // is only a fallback (see the module doc for why `endedAt` cannot serve). + createdAtMs: asFiniteNumber(item.recordedAtMs) ?? anchorMs, humanSchedule: cron, - recurring: asBoolean(rawInput.recurring), + recurring, summary: truncateSummary(asString(rawInput.prompt)), // Cron is local-time to the machine that created it (recorded at persist time). timeZone: asString(item.schedulingTimeZone), + // One-shot: pin the runtime-committed fire time when the output carried it. + // Only explicit `recurring: false` — an absent flag defaults to recurring in + // the runtime, where nextFireAt is just the FIRST of many fires. + scheduledForMs: + recurring === false ? parseCommittedOneShotFireMs(sourceText) : undefined, }), - // The created job's id (needed to match a later CronDelete) is only in the - // output text, so keep it for a robust substring match. - sourceText: JSON.stringify([item.rawOutput ?? null, item.content ?? null]), + sourceText, }); break; } @@ -151,7 +192,7 @@ export function collectPendingScheduledTasksFromHistory( return wakeup ? [wakeup, ...cronTasks] : cronTasks; } -/** Resolve a task's concrete fire time: wakeups carry it, cron jobs derive it. */ +/** Resolve a task's concrete fire time: wakeups and one-shot crons may carry it, cron jobs derive it. */ export function resolveFireMs(task: PendingScheduledTask, nowMs: number): number | undefined { if (task.kind === 'wakeup') { return typeof task.scheduledForMs === 'number' ? task.scheduledForMs : undefined; @@ -162,8 +203,13 @@ export function resolveFireMs(task: PendingScheduledTask, nowMs: number): number const timeZone = task.timeZone; // Recurring cron: next occurrence relative to now (always upcoming). if (task.recurring) return nextCronFireMs(task.humanSchedule, nowMs, timeZone); - // One-shot cron: its single fire time, anchored at creation — so once it has fired the - // time resolves to the PAST (and the row is hidden) instead of jumping to next year. + // One-shot cron with the runtime-committed fire time (parsed from CronCreate's output): + // exact and anchor-independent, and once it has fired the time is in the PAST so the row + // hides — this is the path that cannot produce a next-year phantom. + if (typeof task.scheduledForMs === 'number') return task.scheduledForMs; + // One-shot cron, legacy fallback: resolve its single fire time anchored at creation — so + // once it has fired the time resolves to the PAST (and the row is hidden) instead of + // jumping to next year. // Anchor just before the START of the creation minute (nextCronFireMs matches strictly // after `from`, rounded up to the next whole minute): a cron scheduled to fire in the // same minute it was created (e.g. "25 16 3 7 *" while the turn ends at 16:25:1x) must diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 0b6d5b484..9e9e7f455 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -741,9 +741,18 @@ export type PendingScheduledTask = { /** Stable id: cron job id, or a fixed key for the session's single pending wakeup. */ id: string; kind: 'cron' | 'wakeup'; - /** When this task set entry was last recorded, epoch ms. */ + /** + * When this task set entry was last recorded, epoch ms. For calls persisted with + * `recordedAtMs` this is the tool call's own first-sighting stamp (the true creation + * moment); older history falls back to the owning turn's START — never its `endedAt`, + * which merged cron-fire turns can push past a one-shot's fire minute. + */ createdAtMs: number; - /** Wakeup fire time (epoch ms). Absent for cron jobs (they use a schedule expression). */ + /** + * Wakeup fire time (epoch ms); also the runtime-committed fire time of a one-shot cron + * whose CronCreate output carried a `nextFireAt` line. Absent for recurring cron jobs + * (they resolve from their schedule expression relative to now). + */ scheduledForMs?: number; /** Cron schedule expression / human-readable schedule string. */ humanSchedule?: string; diff --git a/packages/shared/tests/acp-history-apply.test.ts b/packages/shared/tests/acp-history-apply.test.ts index 5682c847d..de8ae791b 100644 --- a/packages/shared/tests/acp-history-apply.test.ts +++ b/packages/shared/tests/acp-history-apply.test.ts @@ -725,6 +725,71 @@ describe('acp history apply', () => { expect(toolCall?.schedulingTimeZone?.length).toBeGreaterThan(0); }); + it('stamps a scheduling tool call with its first-persisted time and never moves it', () => { + // The scheduled-tasks deriver anchors a one-shot cron at this stamp; the turn entry's + // endedAt cannot serve (merged cron-fire turns push it past the fire minute). + const t0 = '2026-09-03T03:18:43.000+08:00'; + const t1 = '2026-09-04T03:39:15.000+08:00'; + const readCall = (history: ReturnType) => + ((history[0]?.items ?? []) as MessageContent[]).find((i) => i.type === 'tool_call') as + | Extract + | undefined; + + const created = applyNotificationOnHistory( + [], + [ + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'cron-tc-3', + title: 'Scheduling one-shot 33 3 3 9 *', + status: 'in_progress', + rawInput: { cron: '33 3 3 9 *', recurring: false, prompt: 'p' }, + _meta: { lody: { toolName: 'CronCreate' } }, + }), + ], + undefined, + { now: () => t0 } + ); + expect(readCall(created)?.recordedAtMs).toBe(Date.parse(t0)); + + // A later replayed/retried update for the same call must keep the original stamp. + const updated = applyNotificationOnHistory( + created, + [ + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'cron-tc-3', + status: 'completed', + }), + ], + undefined, + { now: () => t1 } + ); + expect(readCall(updated)?.recordedAtMs).toBe(Date.parse(t0)); + }); + + it('does not stamp non-scheduling tool calls', () => { + const history = applyNotificationOnHistory( + [], + [ + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'read-tc-2', + kind: 'read', + status: 'in_progress', + title: 'Read', + _meta: { lody: { toolName: 'Read' } }, + }), + ], + undefined, + { now: () => '2026-09-03T03:18:43.000+08:00' } + ); + const toolCall = ((history[0]?.items ?? []) as MessageContent[]).find( + (i) => i.type === 'tool_call' + ) as Extract | undefined; + expect(toolCall?.recordedAtMs).toBeUndefined(); + }); + it('still strips rawInput/rawOutput for non-scheduling tools', () => { const notifications = [ makeNotification({ diff --git a/packages/shared/tests/scheduled-tasks-from-history.test.ts b/packages/shared/tests/scheduled-tasks-from-history.test.ts index 9892d139b..e6cab2de5 100644 --- a/packages/shared/tests/scheduled-tasks-from-history.test.ts +++ b/packages/shared/tests/scheduled-tasks-from-history.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { MessageContent } from '../src/ai'; import { collectPendingScheduledTasksFromHistory, + resolveFireMs, type ScheduledTaskHistoryEntry, } from '../src/scheduled-tasks-from-history'; @@ -11,7 +12,9 @@ function toolCall(args: { status?: 'pending' | 'in_progress' | 'completed' | 'failed'; rawInput?: Record; rawOutput?: unknown; + content?: unknown; schedulingTimeZone?: string; + recordedAtMs?: number; }): MessageContent { return { type: 'tool_call', @@ -21,7 +24,9 @@ function toolCall(args: { kind: 'other', rawInput: args.rawInput, rawOutput: args.rawOutput, + content: args.content, schedulingTimeZone: args.schedulingTimeZone, + recordedAtMs: args.recordedAtMs, } as MessageContent; } @@ -30,7 +35,7 @@ function entry(endedAt: number, items: MessageContent[]): ScheduledTaskHistoryEn } describe('collectPendingScheduledTasksFromHistory', () => { - it('derives a wakeup with scheduledFor = turn end + delaySeconds', () => { + it('derives a wakeup with scheduledFor = call time + delaySeconds', () => { const endedAt = 1_000_000; const tasks = collectPendingScheduledTasksFromHistory([ entry(endedAt, [ @@ -165,4 +170,117 @@ describe('collectPendingScheduledTasksFromHistory', () => { ]); expect(tasks.map((t) => t.kind)).toEqual(['wakeup', 'cron']); }); + + describe('one-shot cron anchoring', () => { + // Regression for the "fires in 364 days" phantom: cron-fire follow-up turns are + // runtime-internal steers, so one history entry can aggregate several runtime turns. + // The entry's endedAt (03:39) then lands PAST the one-shot's 03:33 fire minute, and + // anchoring the creation there resolves the fire time to the NEXT year — future, so + // the fired-row filter never hides it. All timestamps pinned to +08:00 so the test is + // zone-independent. + const TURN_START = Date.parse('2026-09-03T03:13:39+08:00'); + const CALL_AT = Date.parse('2026-09-03T03:18:43+08:00'); + const FIRE_AT = Date.parse('2026-09-03T03:33:00+08:00'); + const ENTRY_END = Date.parse('2026-09-03T03:39:15+08:00'); + const NEXT_DAY = Date.parse('2026-09-04T03:00:00+08:00'); + + const oneShotCall = (extra: { + recordedAtMs?: number; + content?: unknown; + rawOutput?: unknown; + }): MessageContent => + toolCall({ + toolCallId: 'c1', + title: 'CronCreate', + rawInput: { cron: '33 3 3 9 *', prompt: 'check progress', recurring: false }, + schedulingTimeZone: 'Asia/Shanghai', + ...extra, + }); + + const legacyEntry = (items: MessageContent[]): ScheduledTaskHistoryEntry => ({ + timestamp: new Date(TURN_START).toISOString(), + endedAt: ENTRY_END, + items, + }); + + it('prefers the runtime-committed nextFireAt from the output, immune to the entry times', () => { + // Production persistence shape: the output text lives in a content terminal_output + // block, not rawOutput. + const output = + 'id: 01M1HRYC69Y0J184PQN1GEZHXJ\n' + + 'cron: 33 3 3 9 *\n' + + 'humanSchedule: at 03:33 on day 3 of September\n' + + 'recurring: false\n' + + 'nextFireAt: 2026-09-03T03:33:00.000+08:00'; + const tasks = collectPendingScheduledTasksFromHistory([ + legacyEntry([ + oneShotCall({ content: [{ type: 'terminal_output', output, truncated: false }] }), + ]), + ]); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.scheduledForMs).toBe(FIRE_AT); + // Long after the fire, resolution still returns the committed (past) time — the + // panel's fired-row filter hides it instead of showing next year. + expect(resolveFireMs(tasks[0]!, NEXT_DAY)).toBe(FIRE_AT); + expect(FIRE_AT < NEXT_DAY).toBe(true); + }); + + it('anchors at the call recordedAtMs when the output carried no nextFireAt', () => { + const tasks = collectPendingScheduledTasksFromHistory([ + legacyEntry([oneShotCall({ recordedAtMs: CALL_AT })]), + ]); + expect(tasks[0]?.createdAtMs).toBe(CALL_AT); + expect(tasks[0]?.scheduledForMs).toBeUndefined(); + expect(resolveFireMs(tasks[0]!, NEXT_DAY)).toBe(FIRE_AT); + }); + + it('legacy fallback anchors at the turn START, not its (merged) end', () => { + const tasks = collectPendingScheduledTasksFromHistory([legacyEntry([oneShotCall({})])]); + // Turn-start anchor: the first match after 03:13 is 03:33 the same day — the entry's + // endedAt (03:39) must not push the resolution to next year. + expect(tasks[0]?.createdAtMs).toBe(TURN_START); + expect(resolveFireMs(tasks[0]!, NEXT_DAY)).toBe(FIRE_AT); + }); + + it('ignores a nextFireAt line on recurring jobs (it is only their FIRST fire)', () => { + const tasks = collectPendingScheduledTasksFromHistory([ + entry(1_000, [ + toolCall({ + toolCallId: 'c1', + title: 'CronCreate', + rawInput: { cron: '*/20 * * * *', prompt: 'p', recurring: true }, + rawOutput: + 'id: job1\ncron: */20 * * * *\nrecurring: true\nnextFireAt: 2026-09-03T03:20:00.000+08:00', + }), + ]), + ]); + expect(tasks[0]?.scheduledForMs).toBeUndefined(); + const fire = resolveFireMs(tasks[0]!, NEXT_DAY); + expect(typeof fire).toBe('number'); + expect(fire!).toBeGreaterThan(NEXT_DAY); + }); + + it('ignores an absent/unparseable nextFireAt and falls back to the anchor', () => { + const tasks = collectPendingScheduledTasksFromHistory([ + legacyEntry([oneShotCall({ recordedAtMs: CALL_AT, rawOutput: 'nextFireAt: null' })]), + ]); + expect(tasks[0]?.scheduledForMs).toBeUndefined(); + expect(resolveFireMs(tasks[0]!, NEXT_DAY)).toBe(FIRE_AT); + }); + + it('anchors a wakeup at the call recordedAtMs, not the turn', () => { + const tasks = collectPendingScheduledTasksFromHistory([ + legacyEntry([ + toolCall({ + toolCallId: 'w1', + title: 'ScheduleWakeup', + rawInput: { delaySeconds: 120, reason: 'r' }, + recordedAtMs: CALL_AT, + }), + ]), + ]); + expect(tasks[0]?.createdAtMs).toBe(CALL_AT); + expect(tasks[0]?.scheduledForMs).toBe(CALL_AT + 120_000); + }); + }); });