Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/cli/src/lib/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion packages/shared/src/acp/history-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
80 changes: 63 additions & 17 deletions packages/shared/src/scheduled-tasks-from-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] {
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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
Expand Down
13 changes: 11 additions & 2 deletions packages/shared/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions packages/shared/tests/acp-history-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof applyNotificationOnHistory>) =>
((history[0]?.items ?? []) as MessageContent[]).find((i) => i.type === 'tool_call') as
| Extract<MessageContent, { type: 'tool_call' }>
| 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<MessageContent, { type: 'tool_call' }> | undefined;
expect(toolCall?.recordedAtMs).toBeUndefined();
});

it('still strips rawInput/rawOutput for non-scheduling tools', () => {
const notifications = [
makeNotification({
Expand Down
Loading
Loading