From 99fd07d85ec846d09fc1727fc426d43a2205f952 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:41:57 +0800 Subject: [PATCH 1/5] feat(coding-agent): add desktop cron task metadata --- .../src/core/scheduled-session.ts | 16 + .../coding-agent/src/core/session-manager.ts | 14 +- .../coding-agent/src/features/step-cron.ts | 439 ++++++++++++++++-- packages/coding-agent/src/index.ts | 6 + packages/coding-agent/test/step-cron.test.ts | 79 +++- 5 files changed, 498 insertions(+), 56 deletions(-) create mode 100644 packages/coding-agent/src/core/scheduled-session.ts diff --git a/packages/coding-agent/src/core/scheduled-session.ts b/packages/coding-agent/src/core/scheduled-session.ts new file mode 100644 index 00000000..db7a118d --- /dev/null +++ b/packages/coding-agent/src/core/scheduled-session.ts @@ -0,0 +1,16 @@ +/** + * Sessions created by the desktop cron scheduler rather than by a user. + * + * They live in a separate session directory and never enter the normal + * history/search/resume paths. The header marker exists so a session can still + * be recognized when it is opened by id. + */ + +export const CRON_SESSION_ORIGIN = "cron"; +export const CRON_SESSION_ID_PREFIX = "cron-"; + +export type SessionOrigin = "subagent" | "cron"; + +export function isScheduledTaskSession(session: { readonly id: string; readonly origin?: unknown }): boolean { + return session.origin === CRON_SESSION_ORIGIN || session.id.startsWith(CRON_SESSION_ID_PREFIX); +} diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e20136a8..fc5aca1f 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -19,7 +19,6 @@ import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; -import { CHILD_SESSION_ORIGIN } from "./child-session.ts"; import { type BashExecutionMessage, type CustomMessage, @@ -27,6 +26,7 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.ts"; +import type { SessionOrigin } from "./scheduled-session.ts"; import { createSessionSummary, foldSessionSummaryEntry, resolveSessionModifiedTime } from "./session-summary.ts"; import { createUsageLineage } from "./usage-lineage.ts"; @@ -40,8 +40,8 @@ export interface SessionHeader { cwd: string; /** Path of the session this one was forked or continued from. */ parentSession?: string; - /** Set by a child agent (subagent lane or workflow agent) on the session it creates. */ - origin?: typeof CHILD_SESSION_ORIGIN; + /** Set by a child agent or the desktop cron scheduler on the session it creates. */ + origin?: SessionOrigin; /** Id of the session whose agent spawned this child, when known. */ parentSessionId?: string; } @@ -49,7 +49,7 @@ export interface SessionHeader { export interface NewSessionOptions { id?: string; parentSession?: string; - origin?: typeof CHILD_SESSION_ORIGIN; + origin?: SessionOrigin; parentSessionId?: string; } @@ -190,8 +190,8 @@ export interface SessionInfo { name?: string; /** Path to the parent session (if this session was forked). */ parentSessionPath?: string; - /** Header `origin`; `isChildAgentSession` tells child-agent sessions apart. */ - origin?: typeof CHILD_SESSION_ORIGIN; + /** Header `origin`; identifies child-agent and scheduled-task sessions. */ + origin?: SessionOrigin; created: Date; modified: Date; messageCount: number; @@ -716,7 +716,7 @@ async function buildSessionInfo(filePath: string): Promise { cwd, name: summary.name, parentSessionPath, - ...(header.origin === CHILD_SESSION_ORIGIN ? { origin: header.origin } : {}), + ...(header.origin ? { origin: header.origin } : {}), created: new Date(header.timestamp), modified: new Date(resolveSessionModifiedTime(header, summary.lastActivityTime, stats.mtime.getTime())), messageCount: summary.messageCount, diff --git a/packages/coding-agent/src/features/step-cron.ts b/packages/coding-agent/src/features/step-cron.ts index 10c9239a..92ac9bdf 100644 --- a/packages/coding-agent/src/features/step-cron.ts +++ b/packages/coding-agent/src/features/step-cron.ts @@ -26,7 +26,7 @@ import type { StepTelemetryProperties, StepTelemetryReporter } from "../step/tel const TICK_INTERVAL_MS = 1_000; /** Cross-process pickup cadence; firing precision comes from the in-memory view. */ const DURABLE_REFRESH_INTERVAL_MS = 30_000; -const MAX_JOBS = 50; +const DEFAULT_MAX_JOBS = 50; const AUTO_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; const MAX_SEARCH_MINUTES = 366 * 24 * 60; const MAX_PROMPT_LENGTH = 16_000; @@ -35,8 +35,15 @@ const MAX_LOCK_AGE_MS = 60_000; type TimerHandle = ReturnType; +export type CronJobStatus = "enabled" | "paused" | "expired" | "completed"; +export type CronRunStatus = "delivered" | "failed" | "missed" | "skipped" | "blocked"; + +/** + * A scheduled job. `schemaVersion: 1` is the original CLI record; v2 adds the + * optional desktop fields below. The CLI ignores fields it does not use. + */ export interface CronJob { - schemaVersion: 1; + schemaVersion: 1 | 2; id: string; cron: string; prompt: string; @@ -46,6 +53,52 @@ export interface CronJob { nextFireAt: number; lastFiredAt?: number; autoExpireAt?: number; + workspace?: string; + taskSessionId?: string; + title?: string; + status?: CronJobStatus; + enabled?: boolean; + inputs?: { skills?: string[] }; + model?: { provider: string; modelId?: string; thinkingLevel?: string }; + permissionPreset?: string; + delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; + expiresAt?: number; + runCount?: number; + lastRunId?: string; + lastStatus?: CronRunStatus; + lastError?: string; + origin?: "agent" | "desktop" | "legacy"; + jitter?: boolean; +} + +export interface CronCreateOptions { + workspace?: string; + taskSessionId?: string; + title?: string; + status?: CronJobStatus; + enabled?: boolean; + inputs?: { skills?: string[] }; + model?: { provider: string; modelId?: string; thinkingLevel?: string }; + permissionPreset?: string; + delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; + expiresAt?: number; + origin?: "agent" | "desktop" | "legacy"; + /** null keeps the record indefinitely; undefined keeps the legacy seven-day guard for recurring jobs. */ + autoExpireMs?: number | null; + jitter?: boolean; +} + +export interface CronUpdatePatch { + cron?: string; + prompt?: string; + title?: string; + inputs?: { skills?: string[] }; + model?: { provider: string; modelId?: string; thinkingLevel?: string }; + permissionPreset?: string; + delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; + expiresAt?: number | null; + status?: CronJobStatus; + recurring?: boolean; } export interface CronCreateResult { @@ -291,7 +344,7 @@ function isStaleLock(lockPath: string, maxAgeMs: number): boolean { function parsePersistedJob(value: unknown): CronJob | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const candidate = value as Record; - if (candidate.schemaVersion !== 1 || candidate.durable !== true) return undefined; + if ((candidate.schemaVersion !== 1 && candidate.schemaVersion !== 2) || candidate.durable !== true) return undefined; if ( typeof candidate.id !== "string" || !candidate.id.trim() || @@ -305,15 +358,64 @@ function parsePersistedJob(value: unknown): CronJob | undefined { ) { return undefined; } + const numbers = [ + candidate.createdAt, + candidate.nextFireAt, + candidate.lastFiredAt === undefined ? 0 : candidate.lastFiredAt, + candidate.autoExpireAt === undefined ? 0 : candidate.autoExpireAt, + candidate.expiresAt === undefined ? 0 : candidate.expiresAt, + ]; + if (!numbers.every((value) => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 8.64e15)) + return undefined; + if (candidate.workspace !== undefined && (typeof candidate.workspace !== "string" || !candidate.workspace.trim())) + return undefined; + if ( + candidate.taskSessionId !== undefined && + (typeof candidate.taskSessionId !== "string" || !candidate.taskSessionId.trim()) + ) + return undefined; + if (candidate.title !== undefined && (typeof candidate.title !== "string" || candidate.title.length > 200)) + return undefined; + if ( + candidate.status !== undefined && + !["enabled", "paused", "expired", "completed"].includes(candidate.status as string) + ) + return undefined; + if (candidate.inputs !== undefined) { + const inputs = candidate.inputs as Record; + if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) return undefined; + if (inputs.skills !== undefined) { + if (!Array.isArray(inputs.skills) || inputs.skills.some((skill) => typeof skill !== "string")) + return undefined; + } + } + if (candidate.model !== undefined) { + const model = candidate.model as Record; + if (!model || typeof model !== "object" || typeof model.provider !== "string" || !model.provider.trim()) + return undefined; + } + if (candidate.permissionPreset !== undefined && typeof candidate.permissionPreset !== "string") return undefined; + if (candidate.delivery !== undefined) { + const delivery = candidate.delivery as Record; + if (!delivery || typeof delivery !== "object" || Array.isArray(delivery)) return undefined; + if (delivery.lark !== undefined) { + const lark = delivery.lark as Record; + if (!lark || typeof lark !== "object" || typeof lark.target !== "string" || !lark.target.trim()) + return undefined; + } + } + if (candidate.runCount !== undefined && (typeof candidate.runCount !== "number" || candidate.runCount < 0)) + return undefined; + if (candidate.lastRunId !== undefined && typeof candidate.lastRunId !== "string") return undefined; if ( - ![ - candidate.createdAt, - candidate.nextFireAt, - candidate.lastFiredAt === undefined ? 0 : candidate.lastFiredAt, - candidate.autoExpireAt === undefined ? 0 : candidate.autoExpireAt, - ].every((value) => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 8.64e15) + candidate.lastStatus !== undefined && + !["delivered", "failed", "missed", "skipped", "blocked"].includes(candidate.lastStatus as string) ) return undefined; + if (candidate.lastError !== undefined && typeof candidate.lastError !== "string") return undefined; + if (candidate.origin !== undefined && !["agent", "desktop", "legacy"].includes(candidate.origin as string)) + return undefined; + if (candidate.jitter !== undefined && typeof candidate.jitter !== "boolean") return undefined; try { SimpleCronExpression.parse(candidate.cron); } catch { @@ -321,7 +423,7 @@ function parsePersistedJob(value: unknown): CronJob | undefined { } return { ...candidate, - schemaVersion: 1, + schemaVersion: candidate.schemaVersion, id: candidate.id, cron: candidate.cron, prompt: candidate.prompt, @@ -331,7 +433,7 @@ function parsePersistedJob(value: unknown): CronJob | undefined { nextFireAt: candidate.nextFireAt, ...(typeof candidate.lastFiredAt === "number" ? { lastFiredAt: candidate.lastFiredAt } : {}), ...(typeof candidate.autoExpireAt === "number" ? { autoExpireAt: candidate.autoExpireAt } : {}), - }; + } as CronJob; } export interface StepCronRuntimeOptions { @@ -346,11 +448,37 @@ export interface StepCronRuntimeOptions { storagePath?: string; storeFactory?: (filePath: string) => CronFileStore; isIdle?: () => boolean; + /** Per-job delivery gate; defaults to `isIdle`. */ + canDeliver?: (job: CronJob) => boolean; + /** Batch missed-notice gate; defaults to `isIdle`. */ + canDeliverMissed?: () => boolean; + maxJobs?: number; sendMessage?: (message: CronDelivery) => void; telemetry?: StepTelemetryReporter; warn?: (message: string) => void; } +/** The narrow surface a host facade needs to expose to the extension. */ +export interface CronRuntimeLike { + start(cwd: string, trusted: boolean): void; + setContext(cwd: string): void; + create( + cron: string, + prompt: string, + recurring?: boolean, + durable?: boolean, + trusted?: boolean, + options?: CronCreateOptions, + ): CronCreateResult; + list(): CronJob[]; + delete(id: string): boolean; + update?(id: string, patch: CronUpdatePatch): CronJob; + setEnabled?(id: string, enabled: boolean): boolean; + runNow?(id: string): CronJob; + onTurnEnd(): void; + stop(): void; +} + export interface CronDelivery { kind: "fire" | "missed"; job?: CronJob; @@ -367,6 +495,9 @@ export class StepCronRuntime { private readonly storagePath?: string; private readonly storeFactory: (filePath: string) => CronFileStore; private readonly isIdle: () => boolean; + private readonly canDeliver?: (job: CronJob) => boolean; + private readonly canDeliverMissed?: () => boolean; + private readonly maxJobs: number; private readonly sendMessage: (message: CronDelivery) => void; private readonly telemetry?: StepTelemetryReporter; private readonly warn: (message: string) => void; @@ -392,6 +523,9 @@ export class StepCronRuntime { this.storagePath = options.storagePath; this.storeFactory = options.storeFactory ?? ((filePath) => new CronFileStore(filePath, { warn: options.warn })); this.isIdle = options.isIdle ?? (() => true); + this.canDeliver = options.canDeliver; + this.canDeliverMissed = options.canDeliverMissed; + this.maxJobs = options.maxJobs ?? DEFAULT_MAX_JOBS; this.sendMessage = options.sendMessage ?? (() => {}); this.telemetry = options.telemetry; this.warn = options.warn ?? ((message) => console.warn(`[step-cron] ${message}`)); @@ -418,7 +552,14 @@ export class StepCronRuntime { this.currentCwd = path.resolve(cwd); } - create(cron: string, prompt: string, recurring = true, durable = false, trusted = true): CronCreateResult { + create( + cron: string, + prompt: string, + recurring = true, + durable = false, + trusted = true, + options: CronCreateOptions = {}, + ): CronCreateResult { const normalizedCron = cron.trim(); const expression = SimpleCronExpression.parse(normalizedCron); const normalizedPrompt = prompt.trim(); @@ -433,20 +574,49 @@ export class StepCronRuntime { } const createdAt = this.readNow(); const nominalNext = expression.next(createdAt); + const desktopFields = + options.workspace !== undefined || + options.taskSessionId !== undefined || + options.title !== undefined || + options.status !== undefined || + options.inputs !== undefined || + options.model !== undefined || + options.permissionPreset !== undefined || + options.delivery !== undefined || + options.expiresAt !== undefined || + options.origin !== undefined || + options.jitter === false; + const autoExpireAt = + options.autoExpireMs === null + ? undefined + : recurring + ? createdAt + (options.autoExpireMs ?? AUTO_EXPIRE_MS) + : undefined; const job = this.withLatestJobs(() => { - if (this.jobs.size >= MAX_JOBS) - throw new Error(`At most ${MAX_JOBS} cron jobs can be scheduled; delete an existing job first`); + if (this.jobs.size >= this.maxJobs) + throw new Error(`At most ${this.maxJobs} cron jobs can be scheduled; delete an existing job first`); const id = this.uniqueId(); const next: CronJob = { - schemaVersion: 1, + schemaVersion: desktopFields ? 2 : 1, id, cron: normalizedCron, prompt: normalizedPrompt, recurring, durable, createdAt, - nextFireAt: this.jitter(nominalNext, createdAt, id, recurring, expression), - ...(recurring ? { autoExpireAt: createdAt + AUTO_EXPIRE_MS } : {}), + nextFireAt: this.jitter(nominalNext, createdAt, id, recurring, expression, options.jitter !== false), + ...(options.workspace ? { workspace: path.resolve(options.workspace) } : {}), + ...(options.taskSessionId ? { taskSessionId: options.taskSessionId } : {}), + ...(options.title ? { title: options.title.slice(0, 200) } : {}), + ...(options.status ? { status: options.status } : {}), + ...(options.inputs ? { inputs: { skills: [...(options.inputs.skills ?? [])] } } : {}), + ...(options.model ? { model: { ...options.model } } : {}), + ...(options.permissionPreset ? { permissionPreset: options.permissionPreset } : {}), + ...(options.delivery ? { delivery: structuredClone(options.delivery) } : {}), + ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}), + ...(options.origin ? { origin: options.origin } : {}), + ...(options.jitter === false ? { jitter: false } : {}), + ...(autoExpireAt !== undefined ? { autoExpireAt } : {}), }; this.jobs.set(id, next); return { ...next }; @@ -455,6 +625,77 @@ export class StepCronRuntime { return { job, nextFireAt: job.nextFireAt }; } + update(id: string, patch: CronUpdatePatch): CronJob { + const job = this.withLatestJobs(() => { + const current = this.jobs.get(id); + if (!current) throw new Error(`No cron job with id ${id}`); + const next: CronJob = { ...current }; + if (patch.cron !== undefined) { + next.cron = patch.cron.trim(); + SimpleCronExpression.parse(next.cron); + } + if (patch.prompt !== undefined) { + const prompt = patch.prompt.trim(); + if (!prompt) throw new Error("cron_update requires a non-empty prompt"); + if (prompt.length > MAX_PROMPT_LENGTH) + throw new Error(`Cron prompt exceeds ${MAX_PROMPT_LENGTH} characters`); + next.prompt = prompt; + } + if (patch.title !== undefined) next.title = patch.title.slice(0, 200); + if (patch.inputs !== undefined) next.inputs = { skills: [...(patch.inputs.skills ?? [])] }; + if (patch.model !== undefined) next.model = { ...patch.model }; + if (patch.permissionPreset !== undefined) next.permissionPreset = patch.permissionPreset; + if (patch.delivery !== undefined) next.delivery = structuredClone(patch.delivery); + if ("expiresAt" in patch) { + if (patch.expiresAt === null) delete next.expiresAt; + else if (patch.expiresAt !== undefined) next.expiresAt = patch.expiresAt; + } + if (patch.status !== undefined) { + next.status = patch.status; + next.enabled = patch.status === "enabled"; + } + if (patch.recurring !== undefined) next.recurring = patch.recurring; + if (next.schemaVersion === 1) next.schemaVersion = 2; + const now = this.readNow(); + const expression = SimpleCronExpression.parse(next.cron); + if ( + next.status === "enabled" && + (patch.cron !== undefined || patch.recurring !== undefined || next.nextFireAt <= now) + ) { + next.nextFireAt = this.jitter( + expression.next(now), + now, + id, + next.recurring, + expression, + next.jitter !== false, + ); + } + this.jobs.set(id, next); + return { ...next }; + }); + return job; + } + + setEnabled(id: string, enabled: boolean): boolean { + try { + this.update(id, { status: enabled ? "enabled" : "paused" }); + return true; + } catch { + return false; + } + } + + runNow(id: string): CronJob { + const job = this.withLatestJobs(() => { + const current = this.jobs.get(id); + if (!current) throw new Error(`No cron job with id ${id}`); + this.fire(current, this.readNow(), true); + return { ...current }; + }); + return job; + } + list(): CronJob[] { this.refreshDurableNow(); return [...this.jobs.values()] @@ -517,21 +758,27 @@ export class StepCronRuntime { this.refreshDurable(); const now = this.readNow(); if ( - [...this.jobs.values()].some( - (job) => - job.durable && (job.nextFireAt <= now || (job.autoExpireAt !== undefined && job.autoExpireAt <= now)), - ) + [...this.jobs.values()].some((job) => { + if (!job.durable) return false; + const expires = this.expirationOf(job); + return job.nextFireAt <= now || (expires !== undefined && expires <= now); + }) ) { this.withLatestJobs(() => { // Import the complete file before any delivery can change persisted state. for (const job of [...this.jobs.values()]) { if (!job.durable) continue; - if (job.autoExpireAt !== undefined && job.autoExpireAt <= now) { + if (job.status !== undefined && job.status !== "enabled") continue; + const expires = this.expirationOf(job); + if (expires !== undefined && expires <= now) { if (job.nextFireAt <= now) { - if (this.isIdle()) this.fire(job, now); + if (this.deliveryAllowed(job)) this.fire(job, now); else this.defer(job); } else { - this.removeJob(job.id); + if (job.status !== undefined) { + job.status = "expired"; + job.nextFireAt = now; + } else this.removeJob(job.id); this.emit("cron_expired", { id: job.id, recurring: job.recurring }); } continue; @@ -542,7 +789,14 @@ export class StepCronRuntime { } else { try { const expression = SimpleCronExpression.parse(job.cron); - job.nextFireAt = this.jitter(expression.next(now), now, job.id, true, expression); + job.nextFireAt = this.jitter( + expression.next(now), + now, + job.id, + true, + expression, + job.jitter !== false, + ); } catch (error) { // Keep the record, but do not let one bad schedule block project recovery. this.reportFailure( @@ -567,13 +821,17 @@ export class StepCronRuntime { private drain(now: number): void { this.deliverMissed(); for (const job of [...this.jobs.values()]) { + if (job.status !== undefined && job.status !== "enabled") continue; if (this.isExpiredBeforeDelivery(job, now)) { - this.removeJob(job.id); + if (job.status !== undefined) { + job.status = "expired"; + job.nextFireAt = now; + } else this.removeJob(job.id); this.emit("cron_expired", { id: job.id, recurring: job.recurring }); continue; } if (!this.isDue(job, now)) continue; - if (!this.isIdle()) { + if (!this.deliveryAllowed(job)) { this.defer(job); continue; } @@ -586,9 +844,23 @@ export class StepCronRuntime { return job.nextFireAt <= now && !this.pendingMissed.has(job.id); } + private expirationOf(job: CronJob): number | undefined { + return job.expiresAt ?? job.autoExpireAt; + } + /** Reached its expiry before the next occurrence, so it leaves without a final delivery. */ private isExpiredBeforeDelivery(job: CronJob, now: number): boolean { - return job.autoExpireAt !== undefined && now >= job.autoExpireAt && job.nextFireAt > now; + const expires = this.expirationOf(job); + return expires !== undefined && now >= expires && job.nextFireAt > now; + } + + private deliveryAllowed(job: CronJob): boolean { + if (job.status !== undefined && job.status !== "enabled") return false; + return this.canDeliver ? this.canDeliver(job) : this.isIdle(); + } + + private missedAllowed(): boolean { + return this.canDeliverMissed ? this.canDeliverMissed() : this.isIdle(); } /** @@ -597,22 +869,25 @@ export class StepCronRuntime { * every one-second tick just to re-mark the same jobs deferred. */ private needsLockedDrain(now: number): boolean { - const idle = this.isIdle(); - if (idle && this.pendingMissed.size > 0) return true; + if (this.missedAllowed() && this.pendingMissed.size > 0) return true; for (const job of this.jobs.values()) { + if (job.status !== undefined && job.status !== "enabled") continue; if (this.isExpiredBeforeDelivery(job, now)) return true; - if (idle && this.isDue(job, now)) return true; + if (this.deliveryAllowed(job) && this.isDue(job, now)) return true; } return false; } /** `drain`'s busy-host branch, which is in-memory bookkeeping only. */ private deferDue(now: number): void { - for (const job of this.jobs.values()) if (this.isDue(job, now)) this.defer(job); + for (const job of this.jobs.values()) { + if (job.status !== undefined && job.status !== "enabled") continue; + if (this.isDue(job, now)) this.defer(job); + } } private deliverMissed(): void { - if (this.pendingMissed.size === 0 || !this.isIdle()) return; + if (this.pendingMissed.size === 0 || !this.missedAllowed()) return; const jobs = [...this.pendingMissed].flatMap((id) => { const job = this.jobs.get(id); return job ? [{ ...job }] : []; @@ -627,35 +902,64 @@ export class StepCronRuntime { ); return; } - for (const job of jobs) this.removeJob(job.id); + for (const job of jobs) { + if (job.status !== undefined) { + const current = this.jobs.get(job.id); + if (current) { + current.status = "completed"; + current.nextFireAt = this.readNow(); + current.lastStatus = "missed"; + } + } else this.removeJob(job.id); + } this.failures.delete("missed"); } - private fire(job: CronJob, now: number): void { - const expired = job.autoExpireAt !== undefined && now >= job.autoExpireAt; + private fire(job: CronJob, now: number, manual = false): void { + const expires = this.expirationOf(job); + const expired = expires !== undefined && now >= expires; let nextFireAt: number | undefined; try { - if (job.recurring && !expired) { + if (job.recurring && !expired && !manual) { const expression = SimpleCronExpression.parse(job.cron); - nextFireAt = this.jitter(expression.next(now), now, job.id, true, expression); + nextFireAt = this.jitter(expression.next(now), now, job.id, true, expression, job.jitter !== false); } this.sendMessage({ kind: "fire", job: { ...job } }); } catch (error) { - this.reportFailure( - `delivery:${job.id}`, - `Unable to deliver cron ${job.id}: ${error instanceof Error ? error.message : String(error)}`, - ); + const message = error instanceof Error ? error.message : String(error); + if (job.schemaVersion === 2 || job.status !== undefined) { + job.lastStatus = "failed"; + job.lastError = message; + } + this.reportFailure(`delivery:${job.id}`, `Unable to deliver cron ${job.id}: ${message}`); return; } this.failures.delete(`delivery:${job.id}`); this.deferred.delete(job.id); - if (nextFireAt === undefined) this.removeJob(job.id); - else { - job.lastFiredAt = now; + job.lastFiredAt = now; + if (job.schemaVersion === 2 || job.status !== undefined) { + job.runCount = (job.runCount ?? 0) + 1; + job.lastStatus = "delivered"; + delete job.lastError; + } + if (manual) { + this.emit("cron_fired", { recurring: job.recurring }); + return; + } + if (nextFireAt === undefined) { + if (job.status !== undefined) { + job.status = "completed"; + job.nextFireAt = now; + } else this.removeJob(job.id); + } else { job.nextFireAt = nextFireAt; + if (job.recurring && expired) { + if (job.status !== undefined) job.status = "expired"; + else this.removeJob(job.id); + this.emit("cron_expired", { id: job.id, recurring: true }); + } } this.emit("cron_fired", { recurring: job.recurring }); - if (job.recurring && expired) this.emit("cron_expired", { id: job.id, recurring: true }); } private defer(job: CronJob): void { @@ -764,8 +1068,9 @@ export class StepCronRuntime { id: string, recurring: boolean, expression: SimpleCronExpression, + enabled = true, ): number { - if (this.jitterRatio === 0) return timestamp; + if (!enabled || this.jitterRatio === 0) return timestamp; const sample = this.random?.() ?? createHash("sha256").update(id).digest().readUInt32BE(0) / 0xffff_ffff; const fraction = Number.isFinite(sample) ? Math.max(0, Math.min(1, sample)) : 0; if (recurring) { @@ -813,10 +1118,21 @@ export const CronCreateParams = Type.Object({ export const CronDeleteParams = Type.Object({ id: Type.String({ description: "Cron job id" }) }); +export const CronUpdateParams = Type.Object({ + id: Type.String({ description: "Cron job id" }), + cron: Type.Optional(Type.String({ description: "Replacement five-field local-time cron expression" })), + prompt: Type.Optional(Type.String({ description: "Replacement prompt" })), + title: Type.Optional(Type.String({ description: "Task title" })), + skills: Type.Optional(Type.Array(Type.String(), { description: "Skills loaded when the task runs" })), + enabled: Type.Optional(Type.Boolean({ description: "Enable or pause the task" })), + expiresAt: Type.Optional(Type.Union([Type.Number(), Type.Null()], { description: "Expiry epoch ms, or null" })), +}); + export interface StepCronExtensionOptions { telemetry?: StepTelemetryReporter; enabled?: boolean; - runtime?: StepCronRuntime; + readOnly?: boolean; + runtime?: CronRuntimeLike; } function envFlagEnabled(value: string | undefined): boolean { @@ -922,6 +1238,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, promptSnippet: "Schedule recurring or one-shot calendar work", parameters: CronCreateParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { + if (options.readOnly) throw new Error("This task session cannot create cron jobs"); currentContext = ctx; runtime.setContext(ctx.cwd); const recurring = params.recurring !== false; @@ -949,12 +1266,34 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, promptSnippet: "Delete a scheduled cron job", parameters: CronDeleteParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { + if (options.readOnly) throw new Error("This task session cannot delete cron jobs"); currentContext = ctx; runtime.setContext(ctx.cwd); const found = runtime.delete(params.id); return jsonResult({ id: params.id, found }); }, }); + pi.registerTool({ + name: "cron_update", + label: "Update cron job", + description: "Update a scheduled cron job: title, expression, prompt, skills, enabled/paused, or expiry.", + promptSnippet: "Update a scheduled cron job", + parameters: CronUpdateParams, + execute: async (_id, params, _signal, _onUpdate, ctx) => { + if (!runtime.update) throw new Error("This host does not support updating cron jobs"); + currentContext = ctx; + runtime.setContext(ctx.cwd); + const patch: CronUpdatePatch = {}; + if (params.cron !== undefined) patch.cron = params.cron; + if (params.prompt !== undefined) patch.prompt = params.prompt; + if (params.title !== undefined) patch.title = params.title; + if (params.skills !== undefined) patch.inputs = { skills: params.skills }; + if (params.enabled !== undefined) patch.status = params.enabled ? "enabled" : "paused"; + if (params.expiresAt !== undefined) patch.expiresAt = params.expiresAt; + const job = runtime.update(params.id, patch); + return jsonResult(job); + }, + }); pi.registerCommand("cron", { description: "Inspect or delete cron jobs", @@ -964,6 +1303,10 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, const tokens = args.trim().split(/\s+/u).filter(Boolean); try { if (tokens[0] === "delete" || tokens[0] === "remove") { + if (options.readOnly) { + ctx.ui.notify("This task session cannot delete cron jobs", "warning"); + return; + } const id = tokens[1]; if (!id || tokens.length !== 2) { ctx.ui.notify("Usage: /cron delete ", "warning"); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index a58ac742..fd3daabe 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -312,6 +312,12 @@ export { DefaultResourceLoader, loadProjectContextFiles, } from "./core/resource-loader.ts"; +export { + CRON_SESSION_ID_PREFIX, + CRON_SESSION_ORIGIN, + isScheduledTaskSession, + type SessionOrigin, +} from "./core/scheduled-session.ts"; // SDK for programmatic usage export { AgentSessionRuntime, diff --git a/packages/coding-agent/test/step-cron.test.ts b/packages/coding-agent/test/step-cron.test.ts index b46fad98..53eafb8b 100644 --- a/packages/coding-agent/test/step-cron.test.ts +++ b/packages/coding-agent/test/step-cron.test.ts @@ -212,7 +212,7 @@ test("cron extension exposes three tools and steer delivery", async () => { vi.useFakeTimers(); const harness = createHarness(); createStepCronExtension({ enabled: true })(harness.api); - expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list"]); + expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list", "cron_update"]); for (const handler of harness.handlers.get("session_start") ?? []) await handler({ type: "session_start" } as never, harness.ctx); const create = harness.tools.get("cron_create")!; @@ -746,3 +746,80 @@ test("an unschedulable persisted recurrence does not block other jobs on restart runtime.stop(); } }); + +describe("desktop cron task fields", () => { + test("creates a v2 task with task session metadata and updates it", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const directory = makeDirectory(); + const runtime = new StepCronRuntime({ + now: () => now, + jitterRatio: 0, + storagePath: path.join(directory, "tasks.json"), + }); + const created = runtime.create("0 9 * * *", "desktop task", true, true, true, { + workspace: directory, + taskSessionId: "cron-task-1", + status: "enabled", + title: "Task", + inputs: { skills: ["skill-a"] }, + autoExpireMs: null, + jitter: false, + origin: "desktop", + }).job; + expect(created.schemaVersion).toBe(2); + expect(created.taskSessionId).toBe("cron-task-1"); + expect(created.status).toBe("enabled"); + expect(created.autoExpireAt).toBeUndefined(); + const updated = runtime.update(created.id, { + title: "Task 2", + inputs: { skills: ["skill-b"] }, + status: "paused", + }); + expect(updated.title).toBe("Task 2"); + expect(updated.inputs?.skills).toEqual(["skill-b"]); + expect(updated.status).toBe("paused"); + expect(runtime.setEnabled(created.id, true)).toBe(true); + expect(runtime.list()[0]?.status).toBe("enabled"); + }); + + test("runNow hands off without advancing the schedule or completing a one-shot", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const deliveries: CronDelivery[] = []; + const runtime = new StepCronRuntime({ + now: () => now, + jitterRatio: 0, + sendMessage: (delivery) => deliveries.push(delivery), + }); + const job = runtime.create("0 9 * * *", "manual", false, false, true, { + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + const next = job.nextFireAt; + runtime.runNow(job.id); + expect(deliveries).toHaveLength(1); + expect(runtime.list()[0]?.nextFireAt).toBe(next); + expect(runtime.list()[0]?.status).toBe("enabled"); + }); + + test("canDeliver gates each job and maxJobs is configurable", () => { + let now = Date.UTC(2026, 0, 1, 12, 0); + let allow = false; + const deliveries: CronDelivery[] = []; + const runtime = new StepCronRuntime({ + now: () => now, + jitterRatio: 0, + maxJobs: 1, + canDeliver: () => allow, + sendMessage: (delivery) => deliveries.push(delivery), + }); + runtime.create("* * * * *", "gated", false, false); + expect(() => runtime.create("* * * * *", "second", false, false)).toThrow(/At most 1/); + now += 60_000; + runtime.tick(); + expect(deliveries).toHaveLength(0); + allow = true; + runtime.tick(); + expect(deliveries).toHaveLength(1); + }); +}); From 80956baa25354dc1e3aaf1a13f35664f07913c32 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:43:56 +0800 Subject: [PATCH 2/5] feat(coding-agent): complete desktop cron task metadata --- .../coding-agent/src/core/session-manager.ts | 19 +++ .../coding-agent/src/features/step-cron.ts | 109 ++++++++++++------ packages/coding-agent/src/features/step.ts | 16 ++- packages/coding-agent/src/index.ts | 5 + packages/coding-agent/test/step-cron.test.ts | 73 +++++++++++- .../test/subagent-child-env.test.ts | 9 +- 6 files changed, 185 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index fc5aca1f..70e640e5 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -981,6 +981,25 @@ export class SessionManager { return this.persist; } + /** + * Persist a newly created session before its first turn. Host-created task + * sessions need a file that can be reopened after a restart. + */ + persistNewSessionHeader(): string | undefined { + if (!this.persist || !this.sessionFile || this.flushed) return this.sessionFile; + const fd = openSync(this.sessionFile, "wx"); + try { + for (const entry of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(entry)}\n`); + } + } finally { + closeSync(fd); + } + this.flushed = true; + this._notifyFilePersisted(this.sessionFile, "create"); + return this.sessionFile; + } + getCwd(): string { return this.cwd; } diff --git a/packages/coding-agent/src/features/step-cron.ts b/packages/coding-agent/src/features/step-cron.ts index 92ac9bdf..e202c3b5 100644 --- a/packages/coding-agent/src/features/step-cron.ts +++ b/packages/coding-agent/src/features/step-cron.ts @@ -61,6 +61,7 @@ export interface CronJob { inputs?: { skills?: string[] }; model?: { provider: string; modelId?: string; thinkingLevel?: string }; permissionPreset?: string; + fullAccessAcknowledged?: boolean; delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; expiresAt?: number; runCount?: number; @@ -72,6 +73,7 @@ export interface CronJob { } export interface CronCreateOptions { + id?: string; workspace?: string; taskSessionId?: string; title?: string; @@ -80,6 +82,7 @@ export interface CronCreateOptions { inputs?: { skills?: string[] }; model?: { provider: string; modelId?: string; thinkingLevel?: string }; permissionPreset?: string; + fullAccessAcknowledged?: boolean; delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; expiresAt?: number; origin?: "agent" | "desktop" | "legacy"; @@ -94,11 +97,14 @@ export interface CronUpdatePatch { title?: string; inputs?: { skills?: string[] }; model?: { provider: string; modelId?: string; thinkingLevel?: string }; - permissionPreset?: string; + permissionPreset?: string | null; + fullAccessAcknowledged?: boolean | null; delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; expiresAt?: number | null; status?: CronJobStatus; recurring?: boolean; + taskSessionId?: string; + runCount?: number; } export interface CronCreateResult { @@ -395,6 +401,8 @@ function parsePersistedJob(value: unknown): CronJob | undefined { return undefined; } if (candidate.permissionPreset !== undefined && typeof candidate.permissionPreset !== "string") return undefined; + if (candidate.fullAccessAcknowledged !== undefined && typeof candidate.fullAccessAcknowledged !== "boolean") + return undefined; if (candidate.delivery !== undefined) { const delivery = candidate.delivery as Record; if (!delivery || typeof delivery !== "object" || Array.isArray(delivery)) return undefined; @@ -483,6 +491,8 @@ export interface CronDelivery { kind: "fire" | "missed"; job?: CronJob; jobs?: CronJob[]; + /** A host-initiated run (`runNow`) leaves the schedule untouched. */ + manual?: boolean; } export class StepCronRuntime { @@ -564,6 +574,8 @@ export class StepCronRuntime { const expression = SimpleCronExpression.parse(normalizedCron); const normalizedPrompt = prompt.trim(); if (!normalizedPrompt) throw new Error("cron_create requires a non-empty prompt"); + if (options.permissionPreset === "full-access" && options.fullAccessAcknowledged !== true) + throw new Error("Full access cron jobs require an explicit risk acknowledgment"); if (normalizedPrompt.length > MAX_PROMPT_LENGTH) throw new Error(`Cron prompt exceeds ${MAX_PROMPT_LENGTH} characters`); if (durable && !trusted) throw new Error("Durable cron jobs require a trusted project"); @@ -582,6 +594,7 @@ export class StepCronRuntime { options.inputs !== undefined || options.model !== undefined || options.permissionPreset !== undefined || + options.fullAccessAcknowledged !== undefined || options.delivery !== undefined || options.expiresAt !== undefined || options.origin !== undefined || @@ -595,7 +608,11 @@ export class StepCronRuntime { const job = this.withLatestJobs(() => { if (this.jobs.size >= this.maxJobs) throw new Error(`At most ${this.maxJobs} cron jobs can be scheduled; delete an existing job first`); - const id = this.uniqueId(); + const requestedId = options.id?.trim(); + if (requestedId !== undefined && !/^[A-Za-z0-9_-]{1,64}$/u.test(requestedId)) + throw new Error("Cron job id may only contain letters, digits, underscore, and hyphen"); + const id = requestedId || this.uniqueId(); + if (this.jobs.has(id)) throw new Error(`Cron job id ${id} already exists`); const next: CronJob = { schemaVersion: desktopFields ? 2 : 1, id, @@ -612,6 +629,7 @@ export class StepCronRuntime { ...(options.inputs ? { inputs: { skills: [...(options.inputs.skills ?? [])] } } : {}), ...(options.model ? { model: { ...options.model } } : {}), ...(options.permissionPreset ? { permissionPreset: options.permissionPreset } : {}), + ...(options.fullAccessAcknowledged ? { fullAccessAcknowledged: true } : {}), ...(options.delivery ? { delivery: structuredClone(options.delivery) } : {}), ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}), ...(options.origin ? { origin: options.origin } : {}), @@ -644,24 +662,38 @@ export class StepCronRuntime { if (patch.title !== undefined) next.title = patch.title.slice(0, 200); if (patch.inputs !== undefined) next.inputs = { skills: [...(patch.inputs.skills ?? [])] }; if (patch.model !== undefined) next.model = { ...patch.model }; - if (patch.permissionPreset !== undefined) next.permissionPreset = patch.permissionPreset; + if (patch.permissionPreset !== undefined) { + if (patch.permissionPreset === null) delete next.permissionPreset; + else next.permissionPreset = patch.permissionPreset; + } + if (patch.fullAccessAcknowledged !== undefined) { + if (patch.fullAccessAcknowledged === null) delete next.fullAccessAcknowledged; + else next.fullAccessAcknowledged = patch.fullAccessAcknowledged || undefined; + } if (patch.delivery !== undefined) next.delivery = structuredClone(patch.delivery); if ("expiresAt" in patch) { if (patch.expiresAt === null) delete next.expiresAt; else if (patch.expiresAt !== undefined) next.expiresAt = patch.expiresAt; } if (patch.status !== undefined) { + if (!["enabled", "paused", "expired", "completed"].includes(patch.status)) + throw new Error(`Unknown cron job status: ${String(patch.status)}`); next.status = patch.status; next.enabled = patch.status === "enabled"; } if (patch.recurring !== undefined) next.recurring = patch.recurring; + if (patch.taskSessionId !== undefined) next.taskSessionId = patch.taskSessionId; + if (patch.runCount !== undefined) next.runCount = Math.max(0, Math.floor(patch.runCount)); + if ( + (patch.permissionPreset !== undefined || patch.fullAccessAcknowledged !== undefined) && + next.permissionPreset === "full-access" && + next.fullAccessAcknowledged !== true + ) + throw new Error("Full access cron jobs require an explicit risk acknowledgment"); if (next.schemaVersion === 1) next.schemaVersion = 2; const now = this.readNow(); const expression = SimpleCronExpression.parse(next.cron); - if ( - next.status === "enabled" && - (patch.cron !== undefined || patch.recurring !== undefined || next.nextFireAt <= now) - ) { + if (patch.cron !== undefined || patch.recurring !== undefined || patch.status === "enabled") { next.nextFireAt = this.jitter( expression.next(now), now, @@ -924,7 +956,7 @@ export class StepCronRuntime { const expression = SimpleCronExpression.parse(job.cron); nextFireAt = this.jitter(expression.next(now), now, job.id, true, expression, job.jitter !== false); } - this.sendMessage({ kind: "fire", job: { ...job } }); + this.sendMessage({ kind: "fire", job: { ...job }, ...(manual ? { manual: true } : {}) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (job.schemaVersion === 2 || job.status !== undefined) { @@ -946,18 +978,19 @@ export class StepCronRuntime { this.emit("cron_fired", { recurring: job.recurring }); return; } - if (nextFireAt === undefined) { + if (job.recurring && expired) { + if (job.status !== undefined) { + job.status = "expired"; + job.nextFireAt = now; + } else this.removeJob(job.id); + this.emit("cron_expired", { id: job.id, recurring: true }); + } else if (nextFireAt === undefined) { if (job.status !== undefined) { job.status = "completed"; job.nextFireAt = now; } else this.removeJob(job.id); } else { job.nextFireAt = nextFireAt; - if (job.recurring && expired) { - if (job.status !== undefined) job.status = "expired"; - else this.removeJob(job.id); - this.emit("cron_expired", { id: job.id, recurring: true }); - } } this.emit("cron_fired", { recurring: job.recurring }); } @@ -1132,6 +1165,8 @@ export interface StepCronExtensionOptions { telemetry?: StepTelemetryReporter; enabled?: boolean; readOnly?: boolean; + /** Register `cron_update`; off for the CLI so its tool surface stays unchanged. */ + allowUpdate?: boolean; runtime?: CronRuntimeLike; } @@ -1273,28 +1308,30 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, return jsonResult({ id: params.id, found }); }, }); - pi.registerTool({ - name: "cron_update", - label: "Update cron job", - description: "Update a scheduled cron job: title, expression, prompt, skills, enabled/paused, or expiry.", - promptSnippet: "Update a scheduled cron job", - parameters: CronUpdateParams, - execute: async (_id, params, _signal, _onUpdate, ctx) => { - if (!runtime.update) throw new Error("This host does not support updating cron jobs"); - currentContext = ctx; - runtime.setContext(ctx.cwd); - const patch: CronUpdatePatch = {}; - if (params.cron !== undefined) patch.cron = params.cron; - if (params.prompt !== undefined) patch.prompt = params.prompt; - if (params.title !== undefined) patch.title = params.title; - if (params.skills !== undefined) patch.inputs = { skills: params.skills }; - if (params.enabled !== undefined) patch.status = params.enabled ? "enabled" : "paused"; - if (params.expiresAt !== undefined) patch.expiresAt = params.expiresAt; - const job = runtime.update(params.id, patch); - return jsonResult(job); - }, - }); - + if (options.allowUpdate) { + pi.registerTool({ + name: "cron_update", + label: "Update cron job", + description: "Update a scheduled cron job: title, expression, prompt, skills, enabled/paused, or expiry.", + promptSnippet: "Update a scheduled cron job", + parameters: CronUpdateParams, + execute: async (_id, params, _signal, _onUpdate, ctx) => { + if (options.readOnly) throw new Error("This task session cannot update cron jobs"); + if (!runtime.update) throw new Error("This host does not support updating cron jobs"); + currentContext = ctx; + runtime.setContext(ctx.cwd); + const patch: CronUpdatePatch = {}; + if (params.cron !== undefined) patch.cron = params.cron; + if (params.prompt !== undefined) patch.prompt = params.prompt; + if (params.title !== undefined) patch.title = params.title; + if (params.skills !== undefined) patch.inputs = { skills: params.skills }; + if (params.enabled !== undefined) patch.status = params.enabled ? "enabled" : "paused"; + if (params.expiresAt !== undefined) patch.expiresAt = params.expiresAt; + const job = runtime.update(params.id, patch); + return jsonResult(job); + }, + }); + } pi.registerCommand("cron", { description: "Inspect or delete cron jobs", handler: async (args: string, ctx: ExtensionCommandContext) => { diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index c947bb0d..c6c75a4b 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -174,11 +174,13 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi * grant options ("always" persists the acknowledgment) and false when the * dialog is dismissed, so callers keep the previous tier. */ - const confirmFullAccessRisk = async (ui: ExtensionUIContext): Promise => { + const confirmFullAccessRisk = async (ui: ExtensionUIContext, allowAlways = true): Promise => { const generation = permissionSessionGeneration; if (typeof ui.select !== "function") return false; const selected = await ui.select(`${FULL_ACCESS_RISK_TITLE}\n\n${FULL_ACCESS_RISK_MESSAGE}`, [ - ...FULL_ACCESS_RISK_OPTIONS, + FULL_ACCESS_RISK_OPTIONS[0], + ...(allowAlways ? [FULL_ACCESS_RISK_OPTIONS[1]] : []), + FULL_ACCESS_RISK_OPTIONS[2], ]); if (generation !== permissionSessionGeneration) return false; const choice = parseFullAccessRiskChoice(selected); @@ -459,6 +461,7 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi const handlePermissionCommand = async (args: string, ctx: ExtensionCommandContext): Promise => { const generation = permissionSessionGeneration; const requested = args.trim().toLowerCase(); + const sessionOnly = /(^|\s)--session(?:\s|$)/u.test(requested); if (requested === "--cycle" || requested === "cycle") { // Full Access is gated by the risk acknowledgment; a canceled dialog // keeps the current tier instead of moving. @@ -483,14 +486,14 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi return; } - let selected = requested; + let selected = requested.replace(/(^|\s)--session(?:\s|$)/gu, " ").trim(); if (!selected) { const options = permissionsPresetsForSelector(); selected = (await ctx.ui.select("Permission mode", options))?.trim().toLowerCase() ?? ""; if (!selected || generation !== permissionSessionGeneration) return; } if (normalizeStepPermissionPresetId(selected) === "full-access" && !isFullAccessAcknowledged()) { - const granted = await confirmFullAccessRisk(ctx.ui); + const granted = await confirmFullAccessRisk(ctx.ui, !sessionOnly); if (generation !== permissionSessionGeneration) return; if (!granted) { ctx.ui.notify("Full access was not enabled.", "warning"); @@ -506,8 +509,9 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi applyPermissionState(ctx, state, { source: "command", persist: - state.preset !== "full-access" || - options.stepSettings?.()?.getStepSettings().fullAccessAcknowledged === true, + !sessionOnly && + (state.preset !== "full-access" || + options.stepSettings?.()?.getStepSettings().fullAccessAcknowledged === true), }); }; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index fd3daabe..1af7765b 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -514,11 +514,16 @@ export { } from "./features/step.ts"; export { createStepCapabilitiesExtensionInline } from "./features/step-capabilities.ts"; export { + type CronCreateOptions, type CronCreateResult, type CronDelivery, CronFileStore, type CronFileStoreOptions, type CronJob, + type CronJobStatus, + type CronRunStatus, + type CronRuntimeLike, + type CronUpdatePatch, createStepCronExtension, SimpleCronExpression, type StepCronExtensionOptions, diff --git a/packages/coding-agent/test/step-cron.test.ts b/packages/coding-agent/test/step-cron.test.ts index 53eafb8b..b6ec8433 100644 --- a/packages/coding-agent/test/step-cron.test.ts +++ b/packages/coding-agent/test/step-cron.test.ts @@ -208,10 +208,10 @@ function createHarness(): Harness { return { api, tools, handlers, sent, ctx, commands }; } -test("cron extension exposes three tools and steer delivery", async () => { +test("cron extension exposes create/list/update/delete and steer delivery", async () => { vi.useFakeTimers(); const harness = createHarness(); - createStepCronExtension({ enabled: true })(harness.api); + createStepCronExtension({ enabled: true, allowUpdate: true })(harness.api); expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list", "cron_update"]); for (const handler of harness.handlers.get("session_start") ?? []) await handler({ type: "session_start" } as never, harness.ctx); @@ -233,6 +233,35 @@ test("cron extension exposes three tools and steer delivery", async () => { await handler({ type: "session_shutdown" } as never, harness.ctx); }); +test("a task session cannot create, update, or delete cron jobs", async () => { + const harness = createHarness(); + createStepCronExtension({ enabled: true, readOnly: true, allowUpdate: true })(harness.api); + await emitCron(harness, "session_start"); + const create = harness.tools.get("cron_create")!; + await expect( + create.execute("create", { cron: "* * * * *", prompt: "nested" } as never, undefined, undefined, harness.ctx), + ).rejects.toThrow("cannot create"); + await expect( + harness.tools + .get("cron_update")! + .execute("update", { id: "x", title: "y" } as never, undefined, undefined, harness.ctx), + ).rejects.toThrow("cannot update"); + await expect( + harness.tools.get("cron_delete")!.execute("delete", { id: "x" } as never, undefined, undefined, harness.ctx), + ).rejects.toThrow("cannot delete"); + const listed = (await harness.tools + .get("cron_list")! + .execute("list", {} as never, undefined, undefined, harness.ctx)) as { content: Array<{ text: string }> }; + expect(JSON.parse(listed.content[0]!.text)).toEqual([]); + await emitCron(harness, "session_shutdown"); +}); + +test("the CLI cron extension keeps its original tool surface", () => { + const harness = createHarness(); + createStepCronExtension({ enabled: true })(harness.api); + expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list"]); +}); + test("durable creation is rejected in an untrusted project", async () => { const harness = createHarness(); Object.assign(harness.ctx, { isProjectTrusted: () => false }); @@ -762,6 +791,8 @@ describe("desktop cron task fields", () => { status: "enabled", title: "Task", inputs: { skills: ["skill-a"] }, + permissionPreset: "full-access", + fullAccessAcknowledged: true, autoExpireMs: null, jitter: false, origin: "desktop", @@ -769,6 +800,7 @@ describe("desktop cron task fields", () => { expect(created.schemaVersion).toBe(2); expect(created.taskSessionId).toBe("cron-task-1"); expect(created.status).toBe("enabled"); + expect(created.fullAccessAcknowledged).toBe(true); expect(created.autoExpireAt).toBeUndefined(); const updated = runtime.update(created.id, { title: "Task 2", @@ -802,6 +834,43 @@ describe("desktop cron task fields", () => { expect(runtime.list()[0]?.status).toBe("enabled"); }); + test("accepts a caller-provided id and lets the host attach a task session", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + const job = runtime.create("0 9 * * *", "named", true, false, true, { + id: "task-a", + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(job.id).toBe("task-a"); + runtime.update(job.id, { taskSessionId: "cron-task-a" }); + expect(runtime.list()[0]?.taskSessionId).toBe("cron-task-a"); + expect(() => + runtime.create("0 9 * * *", "duplicate", true, false, true, { + id: "task-a", + status: "enabled", + autoExpireMs: null, + jitter: false, + }), + ).toThrow(/already exists/); + }); + + test("full access requires an explicit acknowledgment on create and update", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + expect(() => + runtime.create("* * * * *", "unsafe", true, false, true, { + permissionPreset: "full-access", + }), + ).toThrow(/risk acknowledgment/); + const created = runtime.create("* * * * *", "safe", true, false, true, { + permissionPreset: "full-access", + fullAccessAcknowledged: true, + }).job; + expect(() => runtime.update(created.id, { fullAccessAcknowledged: false })).toThrow(/risk acknowledgment/); + }); + test("canDeliver gates each job and maxJobs is configurable", () => { let now = Date.UTC(2026, 0, 1, 12, 0); let allow = false; diff --git a/packages/coding-agent/test/subagent-child-env.test.ts b/packages/coding-agent/test/subagent-child-env.test.ts index d99513bf..20a0397a 100644 --- a/packages/coding-agent/test/subagent-child-env.test.ts +++ b/packages/coding-agent/test/subagent-child-env.test.ts @@ -88,14 +88,19 @@ test("every rpc child is stripped of scheduling authority", () => { vi.stubEnv("STEP_DISABLE_CRON", ""); vi.stubEnv("STEP_DISABLE_GOAL", ""); vi.stubEnv("STEP_DISABLE_SCHEDULE", ""); - expect(registeredTools(createStepCronExtension())).toEqual(["cron_create", "cron_delete", "cron_list"]); + expect(registeredTools(createStepCronExtension({ allowUpdate: true }))).toEqual([ + "cron_create", + "cron_delete", + "cron_list", + "cron_update", + ]); expect(registeredTools(createStepGoalExtension()).length).toBeGreaterThan(0); const childEnv = buildSubagentChildEnv(runInput()); vi.stubEnv("STEP_DISABLE_CRON", String(childEnv.STEP_DISABLE_CRON)); vi.stubEnv("STEP_DISABLE_GOAL", String(childEnv.STEP_DISABLE_GOAL)); - expect(registeredTools(createStepCronExtension())).toEqual([]); + expect(registeredTools(createStepCronExtension({ allowUpdate: true }))).toEqual([]); expect(registeredTools(createStepGoalExtension())).toEqual([]); }); From ec26a0199562e63b68c23ddc67862e8c5f3c3ac0 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:19:02 +0800 Subject: [PATCH 3/5] feat(coding-agent): support session-bound cron and interval schedules --- .../coding-agent/src/features/step-cron.ts | 113 +++++++++++++----- packages/coding-agent/test/step-cron.test.ts | 56 +++++++-- 2 files changed, 132 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/src/features/step-cron.ts b/packages/coding-agent/src/features/step-cron.ts index e202c3b5..5032158c 100644 --- a/packages/coding-agent/src/features/step-cron.ts +++ b/packages/coding-agent/src/features/step-cron.ts @@ -48,13 +48,17 @@ export interface CronJob { cron: string; prompt: string; recurring: boolean; + /** Explicit interval schedule; when set, cron is only a validation/fallback expression. */ + intervalHours?: number; + /** One-shot absolute fire time. */ + runAt?: number; durable: boolean; createdAt: number; nextFireAt: number; lastFiredAt?: number; autoExpireAt?: number; workspace?: string; - taskSessionId?: string; + sessionId?: string; title?: string; status?: CronJobStatus; enabled?: boolean; @@ -75,7 +79,7 @@ export interface CronJob { export interface CronCreateOptions { id?: string; workspace?: string; - taskSessionId?: string; + sessionId?: string; title?: string; status?: CronJobStatus; enabled?: boolean; @@ -83,6 +87,8 @@ export interface CronCreateOptions { model?: { provider: string; modelId?: string; thinkingLevel?: string }; permissionPreset?: string; fullAccessAcknowledged?: boolean; + intervalHours?: number; + runAt?: number; delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; expiresAt?: number; origin?: "agent" | "desktop" | "legacy"; @@ -103,7 +109,8 @@ export interface CronUpdatePatch { expiresAt?: number | null; status?: CronJobStatus; recurring?: boolean; - taskSessionId?: string; + intervalHours?: number | null; + runAt?: number | null; runCount?: number; } @@ -364,6 +371,14 @@ function parsePersistedJob(value: unknown): CronJob | undefined { ) { return undefined; } + if ( + candidate.intervalHours !== undefined && + (typeof candidate.intervalHours !== "number" || + !Number.isInteger(candidate.intervalHours) || + candidate.intervalHours < 1 || + candidate.intervalHours > 168) + ) + return undefined; const numbers = [ candidate.createdAt, candidate.nextFireAt, @@ -375,10 +390,7 @@ function parsePersistedJob(value: unknown): CronJob | undefined { return undefined; if (candidate.workspace !== undefined && (typeof candidate.workspace !== "string" || !candidate.workspace.trim())) return undefined; - if ( - candidate.taskSessionId !== undefined && - (typeof candidate.taskSessionId !== "string" || !candidate.taskSessionId.trim()) - ) + if (candidate.sessionId !== undefined && (typeof candidate.sessionId !== "string" || !candidate.sessionId.trim())) return undefined; if (candidate.title !== undefined && (typeof candidate.title !== "string" || candidate.title.length > 200)) return undefined; @@ -585,10 +597,17 @@ export class StepCronRuntime { this.currentStore = this.storeFactory(filePath); } const createdAt = this.readNow(); - const nominalNext = expression.next(createdAt); + if ( + options.intervalHours !== undefined && + (!Number.isInteger(options.intervalHours) || options.intervalHours < 1 || options.intervalHours > 168) + ) + throw new Error("Cron interval must be a whole number between 1 and 168 hours"); + const nominalNext = + options.runAt ?? + (options.intervalHours ? createdAt + options.intervalHours * 60 * 60 * 1000 : expression.next(createdAt)); const desktopFields = options.workspace !== undefined || - options.taskSessionId !== undefined || + options.sessionId !== undefined || options.title !== undefined || options.status !== undefined || options.inputs !== undefined || @@ -598,7 +617,9 @@ export class StepCronRuntime { options.delivery !== undefined || options.expiresAt !== undefined || options.origin !== undefined || - options.jitter === false; + options.jitter === false || + options.intervalHours !== undefined || + options.runAt !== undefined; const autoExpireAt = options.autoExpireMs === null ? undefined @@ -619,11 +640,13 @@ export class StepCronRuntime { cron: normalizedCron, prompt: normalizedPrompt, recurring, + ...(options.intervalHours ? { intervalHours: options.intervalHours } : {}), + ...(options.runAt !== undefined ? { runAt: options.runAt } : {}), durable, createdAt, nextFireAt: this.jitter(nominalNext, createdAt, id, recurring, expression, options.jitter !== false), ...(options.workspace ? { workspace: path.resolve(options.workspace) } : {}), - ...(options.taskSessionId ? { taskSessionId: options.taskSessionId } : {}), + ...(options.sessionId ? { sessionId: options.sessionId } : {}), ...(options.title ? { title: options.title.slice(0, 200) } : {}), ...(options.status ? { status: options.status } : {}), ...(options.inputs ? { inputs: { skills: [...(options.inputs.skills ?? [])] } } : {}), @@ -682,7 +705,14 @@ export class StepCronRuntime { next.enabled = patch.status === "enabled"; } if (patch.recurring !== undefined) next.recurring = patch.recurring; - if (patch.taskSessionId !== undefined) next.taskSessionId = patch.taskSessionId; + if (patch.intervalHours !== undefined) { + if (patch.intervalHours === null) delete next.intervalHours; + else next.intervalHours = patch.intervalHours; + } + if (patch.runAt !== undefined) { + if (patch.runAt === null) delete next.runAt; + else next.runAt = patch.runAt; + } if (patch.runCount !== undefined) next.runCount = Math.max(0, Math.floor(patch.runCount)); if ( (patch.permissionPreset !== undefined || patch.fullAccessAcknowledged !== undefined) && @@ -693,15 +723,23 @@ export class StepCronRuntime { if (next.schemaVersion === 1) next.schemaVersion = 2; const now = this.readNow(); const expression = SimpleCronExpression.parse(next.cron); - if (patch.cron !== undefined || patch.recurring !== undefined || patch.status === "enabled") { - next.nextFireAt = this.jitter( - expression.next(now), - now, - id, - next.recurring, - expression, - next.jitter !== false, - ); + if (next.intervalHours !== undefined) { + if (!Number.isInteger(next.intervalHours) || next.intervalHours < 1 || next.intervalHours > 168) + throw new Error("Cron interval must be a whole number between 1 and 168 hours"); + } + if ( + patch.cron !== undefined || + patch.recurring !== undefined || + patch.intervalHours !== undefined || + patch.runAt !== undefined || + patch.status === "enabled" + ) { + next.nextFireAt = + patch.runAt ?? + next.runAt ?? + (next.intervalHours !== undefined + ? now + next.intervalHours * 60 * 60 * 1000 + : this.jitter(expression.next(now), now, id, next.recurring, expression, next.jitter !== false)); } this.jobs.set(id, next); return { ...next }; @@ -821,14 +859,10 @@ export class StepCronRuntime { } else { try { const expression = SimpleCronExpression.parse(job.cron); - job.nextFireAt = this.jitter( - expression.next(now), - now, - job.id, - true, - expression, - job.jitter !== false, - ); + job.nextFireAt = + job.intervalHours !== undefined + ? now + job.intervalHours * 60 * 60 * 1000 + : this.jitter(expression.next(now), now, job.id, true, expression, job.jitter !== false); } catch (error) { // Keep the record, but do not let one bad schedule block project recovery. this.reportFailure( @@ -954,7 +988,10 @@ export class StepCronRuntime { try { if (job.recurring && !expired && !manual) { const expression = SimpleCronExpression.parse(job.cron); - nextFireAt = this.jitter(expression.next(now), now, job.id, true, expression, job.jitter !== false); + nextFireAt = + job.intervalHours !== undefined + ? now + job.intervalHours * 60 * 60 * 1000 + : this.jitter(expression.next(now), now, job.id, true, expression, job.jitter !== false); } this.sendMessage({ kind: "fire", job: { ...job }, ...(manual ? { manual: true } : {}) }); } catch (error) { @@ -1188,6 +1225,19 @@ function formatCronStatus(jobs: readonly CronJob[]): string { .join("\n"); } +async function requireCronApproval( + ctx: ExtensionContext, + action: "create" | "update" | "delete", + detail: string, +): Promise { + if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") throw new Error(`Cron ${action} requires user approval`); + const approved = await ctx.ui.confirm( + `Approve scheduled task ${action}?`, + `${detail}\n\nAgent-created scheduled-task changes require your approval.`, + ); + if (!approved) throw new Error(`Cron ${action} was not approved`); +} + /** Register expression-based Cron tools; this extension has no Loop dependency. */ export function createStepCronExtension(options: StepCronExtensionOptions = {}): ExtensionFactory { return (pi: ExtensionAPI): void => { @@ -1274,6 +1324,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronCreateParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot create cron jobs"); + await requireCronApproval(ctx, "create", `${params.cron} — ${params.prompt.slice(0, 120)}`); currentContext = ctx; runtime.setContext(ctx.cwd); const recurring = params.recurring !== false; @@ -1302,6 +1353,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronDeleteParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot delete cron jobs"); + await requireCronApproval(ctx, "delete", params.id); currentContext = ctx; runtime.setContext(ctx.cwd); const found = runtime.delete(params.id); @@ -1317,6 +1369,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronUpdateParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot update cron jobs"); + await requireCronApproval(ctx, "update", params.id); if (!runtime.update) throw new Error("This host does not support updating cron jobs"); currentContext = ctx; runtime.setContext(ctx.cwd); diff --git a/packages/coding-agent/test/step-cron.test.ts b/packages/coding-agent/test/step-cron.test.ts index b6ec8433..e855183f 100644 --- a/packages/coding-agent/test/step-cron.test.ts +++ b/packages/coding-agent/test/step-cron.test.ts @@ -175,13 +175,13 @@ function createHarness(): Harness { const sent: Array<{ message: Record; options?: Record }> = []; const ctx = { mode: "rpc", - hasUI: false, + hasUI: true, cwd: makeDirectory(), isIdle: () => true, hasPendingMessages: () => false, hasPendingUserDecision: () => false, isProjectTrusted: () => true, - ui: { notify: vi.fn() }, + ui: { notify: vi.fn(), confirm: vi.fn().mockResolvedValue(true) }, sessionManager: { getSessionId: () => "cron-session", getEntries: () => [] }, } as unknown as ExtensionContext; const api = { @@ -262,6 +262,27 @@ test("the CLI cron extension keeps its original tool surface", () => { expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list"]); }); +test("agent-created cron mutations require user approval", async () => { + const harness = createHarness(); + createStepCronExtension({ enabled: true })(harness.api); + await emitCron(harness, "session_start"); + const confirm = harness.ctx.ui.confirm as ReturnType; + confirm.mockResolvedValueOnce(false); + await expect( + harness.tools + .get("cron_create")! + .execute( + "create", + { cron: "* * * * *", prompt: "needs approval" } as never, + undefined, + undefined, + harness.ctx, + ), + ).rejects.toThrow("not approved"); + expect(harness.tools.get("cron_create")).toBeDefined(); + await emitCron(harness, "session_shutdown"); +}); + test("durable creation is rejected in an untrusted project", async () => { const harness = createHarness(); Object.assign(harness.ctx, { isProjectTrusted: () => false }); @@ -787,7 +808,7 @@ describe("desktop cron task fields", () => { }); const created = runtime.create("0 9 * * *", "desktop task", true, true, true, { workspace: directory, - taskSessionId: "cron-task-1", + sessionId: "cron-task-1", status: "enabled", title: "Task", inputs: { skills: ["skill-a"] }, @@ -798,7 +819,7 @@ describe("desktop cron task fields", () => { origin: "desktop", }).job; expect(created.schemaVersion).toBe(2); - expect(created.taskSessionId).toBe("cron-task-1"); + expect(created.sessionId).toBe("cron-task-1"); expect(created.status).toBe("enabled"); expect(created.fullAccessAcknowledged).toBe(true); expect(created.autoExpireAt).toBeUndefined(); @@ -834,18 +855,18 @@ describe("desktop cron task fields", () => { expect(runtime.list()[0]?.status).toBe("enabled"); }); - test("accepts a caller-provided id and lets the host attach a task session", () => { + test("accepts a caller-provided id and a bound session", () => { const now = Date.UTC(2026, 0, 1, 12, 0); const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); const job = runtime.create("0 9 * * *", "named", true, false, true, { id: "task-a", + sessionId: "session-a", status: "enabled", autoExpireMs: null, jitter: false, }).job; expect(job.id).toBe("task-a"); - runtime.update(job.id, { taskSessionId: "cron-task-a" }); - expect(runtime.list()[0]?.taskSessionId).toBe("cron-task-a"); + expect(runtime.list()[0]?.sessionId).toBe("session-a"); expect(() => runtime.create("0 9 * * *", "duplicate", true, false, true, { id: "task-a", @@ -871,6 +892,27 @@ describe("desktop cron task fields", () => { expect(() => runtime.update(created.id, { fullAccessAcknowledged: false })).toThrow(/risk acknowledgment/); }); + test("supports explicit hour intervals and absolute one-shot times", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + const interval = runtime.create("* * * * *", "interval", true, false, true, { + intervalHours: 5, + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(interval.intervalHours).toBe(5); + expect(interval.nextFireAt).toBe(now + 5 * 60 * 60 * 1000); + const runAt = now + 2 * 60 * 60 * 1000; + const once = runtime.create("* * * * *", "once", false, false, true, { + runAt, + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(once.nextFireAt).toBe(runAt); + }); + test("canDeliver gates each job and maxJobs is configurable", () => { let now = Date.UTC(2026, 0, 1, 12, 0); let allow = false; From 1c02f51f68f40c78d41c95b14a9055cd480fd3c2 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:42:23 +0800 Subject: [PATCH 4/5] fix(coding-agent): keep one schedule shape per cron task A task could carry both a one-shot time (`runAt`) and a repeating interval, and `update()` preferred whichever it found first. Converting a one-shot task into a repeating one therefore kept the old absolute time, so the next fire was computed from a schedule that no longer applied; converting back lost the interval. Reject a mixed shape at both ends and clear the field that no longer applies. Other defects fixed in the same pass, all in the shared cron runtime: - `runAt` and `expiresAt` are validated everywhere. An unvalidated value used to reach the runtime and made the whole record unreadable after a restart. - An expired or completed task reopens only when an edit restores a future schedule. Re-enabling a one-shot whose time has passed requires a new time, because the next tick would otherwise deliver it immediately. - `CRON_MAX_PROMPT_LENGTH` and `CRON_MAX_TITLE_LENGTH` are exported so a host and the agent tools enforce the same cap. A task the agent could create must also be editable in a UI that validates it. - `ownsJob` lets a host deliver and mutate only the records it owns. A CLI session uses it to ignore tasks bound to a desktop conversation, which it could neither see nor deliver into. - Approval is a host capability (`requireApproval`). The CLI and headless modes keep scheduling; a host that asks but has no dialog surface is refused rather than silently allowed. - `ExtensionUIDialogOptions.once` keeps a standing session grant from covering a later dialog about a durable object. - Timer callbacks read the extension context through `withLiveContext`. A context made stale by a session replacement throws on access, and an uncaught throw in a timer takes down the host instead of skipping one notification. - Remove `scheduled-session.ts`, which only widened a session `origin` type for a value no consumer used. Session headers keep the child-agent marker. Tests cover the schedule-shape conversions, time validation, reopening a finished task, host ownership boundaries, and the shared limits. --- .../coding-agent/src/core/extensions/types.ts | 9 + .../src/core/scheduled-session.ts | 16 -- .../coding-agent/src/core/session-manager.ts | 14 +- .../coding-agent/src/features/step-cron.ts | 249 ++++++++++++++++-- packages/coding-agent/src/index.ts | 9 +- packages/coding-agent/test/step-cron.test.ts | 198 +++++++++++++- 6 files changed, 428 insertions(+), 67 deletions(-) delete mode 100644 packages/coding-agent/src/core/scheduled-session.ts diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 07d8d949..e41e4a0b 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -114,6 +114,15 @@ export interface ExtensionUIDialogOptions { * drift is visible because no output follows the dialog to fill the gap back in. */ overlay?: boolean; + /** + * Ask about one action and forget the answer. + * + * A host grants standing permissions by tool name ("allow for this session"). + * A dialog that manages a durable object — a scheduled task, say — must not + * join that grant: one approval would otherwise silently cover every later + * change of the same kind. + */ + once?: boolean; /** * Reference lines for an input dialog, rendered under the title and above the * editable row. Use them to show the accepted spellings of an answer that has diff --git a/packages/coding-agent/src/core/scheduled-session.ts b/packages/coding-agent/src/core/scheduled-session.ts deleted file mode 100644 index db7a118d..00000000 --- a/packages/coding-agent/src/core/scheduled-session.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Sessions created by the desktop cron scheduler rather than by a user. - * - * They live in a separate session directory and never enter the normal - * history/search/resume paths. The header marker exists so a session can still - * be recognized when it is opened by id. - */ - -export const CRON_SESSION_ORIGIN = "cron"; -export const CRON_SESSION_ID_PREFIX = "cron-"; - -export type SessionOrigin = "subagent" | "cron"; - -export function isScheduledTaskSession(session: { readonly id: string; readonly origin?: unknown }): boolean { - return session.origin === CRON_SESSION_ORIGIN || session.id.startsWith(CRON_SESSION_ID_PREFIX); -} diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 70e640e5..4b1cfb89 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -19,6 +19,7 @@ import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { CHILD_SESSION_ORIGIN } from "./child-session.ts"; import { type BashExecutionMessage, type CustomMessage, @@ -26,7 +27,6 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.ts"; -import type { SessionOrigin } from "./scheduled-session.ts"; import { createSessionSummary, foldSessionSummaryEntry, resolveSessionModifiedTime } from "./session-summary.ts"; import { createUsageLineage } from "./usage-lineage.ts"; @@ -40,8 +40,8 @@ export interface SessionHeader { cwd: string; /** Path of the session this one was forked or continued from. */ parentSession?: string; - /** Set by a child agent or the desktop cron scheduler on the session it creates. */ - origin?: SessionOrigin; + /** Set by a child agent (subagent lane or workflow agent) on the session it creates. */ + origin?: typeof CHILD_SESSION_ORIGIN; /** Id of the session whose agent spawned this child, when known. */ parentSessionId?: string; } @@ -49,7 +49,7 @@ export interface SessionHeader { export interface NewSessionOptions { id?: string; parentSession?: string; - origin?: SessionOrigin; + origin?: typeof CHILD_SESSION_ORIGIN; parentSessionId?: string; } @@ -190,8 +190,8 @@ export interface SessionInfo { name?: string; /** Path to the parent session (if this session was forked). */ parentSessionPath?: string; - /** Header `origin`; identifies child-agent and scheduled-task sessions. */ - origin?: SessionOrigin; + /** Header `origin`; `isChildAgentSession` tells child-agent sessions apart. */ + origin?: typeof CHILD_SESSION_ORIGIN; created: Date; modified: Date; messageCount: number; @@ -716,7 +716,7 @@ async function buildSessionInfo(filePath: string): Promise { cwd, name: summary.name, parentSessionPath, - ...(header.origin ? { origin: header.origin } : {}), + ...(header.origin === CHILD_SESSION_ORIGIN ? { origin: header.origin } : {}), created: new Date(header.timestamp), modified: new Date(resolveSessionModifiedTime(header, summary.lastActivityTime, stats.mtime.getTime())), messageCount: summary.messageCount, diff --git a/packages/coding-agent/src/features/step-cron.ts b/packages/coding-agent/src/features/step-cron.ts index 5032158c..5107eb6b 100644 --- a/packages/coding-agent/src/features/step-cron.ts +++ b/packages/coding-agent/src/features/step-cron.ts @@ -14,7 +14,7 @@ import { } from "node:fs"; import path from "node:path"; import type { AgentToolResult } from "@step-harness/agent-core"; -import { Type } from "typebox"; +import { type Static, Type } from "typebox"; import type { ExtensionAPI, ExtensionCommandContext, @@ -29,13 +29,27 @@ const DURABLE_REFRESH_INTERVAL_MS = 30_000; const DEFAULT_MAX_JOBS = 50; const AUTO_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; const MAX_SEARCH_MINUTES = 366 * 24 * 60; -const MAX_PROMPT_LENGTH = 16_000; +/** + * The one limit every writer shares: the desktop form, the HTTP routes and the + * agent tools. A task the agent can create must also be editable in the UI, so + * a host must not define a stricter cap of its own. + */ +export const CRON_MAX_PROMPT_LENGTH = 8 * 1024; +export const CRON_MAX_TITLE_LENGTH = 30; +const MAX_PROMPT_LENGTH = CRON_MAX_PROMPT_LENGTH; const MAX_CRON_LENGTH = 200; const MAX_LOCK_AGE_MS = 60_000; type TimerHandle = ReturnType; export type CronJobStatus = "enabled" | "paused" | "expired" | "completed"; +/** + * Why a task is in its current status when the status alone is ambiguous. + * + * `session_missing` is the one the host sets: the bound session file was + * deleted, so the task can never deliver and is paused rather than retried. + */ +export type CronStatusReason = "session_missing"; export type CronRunStatus = "delivered" | "failed" | "missed" | "skipped" | "blocked"; /** @@ -59,7 +73,15 @@ export interface CronJob { autoExpireAt?: number; workspace?: string; sessionId?: string; + /** + * Set when the host created the bound session for this task rather than + * binding a session the user already had. The sidebar uses it to place the + * session under scheduled tasks instead of guessing from its cwd. + */ + sessionCreated?: boolean; title?: string; + /** Why the task is not running, when its status alone does not say it. */ + statusReason?: CronStatusReason; status?: CronJobStatus; enabled?: boolean; inputs?: { skills?: string[] }; @@ -80,8 +102,10 @@ export interface CronCreateOptions { id?: string; workspace?: string; sessionId?: string; + sessionCreated?: boolean; title?: string; status?: CronJobStatus; + statusReason?: CronStatusReason; enabled?: boolean; inputs?: { skills?: string[] }; model?: { provider: string; modelId?: string; thinkingLevel?: string }; @@ -108,6 +132,8 @@ export interface CronUpdatePatch { delivery?: { lark?: { target: string; format?: "markdown" | "text" } }; expiresAt?: number | null; status?: CronJobStatus; + /** Host note for why a task was paused; null clears it. */ + statusReason?: CronStatusReason | null; recurring?: boolean; intervalHours?: number | null; runAt?: number | null; @@ -394,6 +420,8 @@ function parsePersistedJob(value: unknown): CronJob | undefined { return undefined; if (candidate.title !== undefined && (typeof candidate.title !== "string" || candidate.title.length > 200)) return undefined; + if (candidate.sessionCreated !== undefined && typeof candidate.sessionCreated !== "boolean") return undefined; + if (candidate.statusReason !== undefined && candidate.statusReason !== "session_missing") return undefined; if ( candidate.status !== undefined && !["enabled", "paused", "expired", "completed"].includes(candidate.status as string) @@ -476,6 +504,14 @@ export interface StepCronRuntimeOptions { sendMessage?: (message: CronDelivery) => void; telemetry?: StepTelemetryReporter; warn?: (message: string) => void; + /** + * Which records this host delivers and may change. + * + * Durable records are shared by file path, so a project folder can hold a + * record another host owns. Unowned records are still read and written back + * unchanged; they are only never fired, listed or mutated here. + */ + ownsJob?: (job: CronJob) => boolean; } /** The narrow surface a host facade needs to expose to the extension. */ @@ -520,6 +556,7 @@ export class StepCronRuntime { private readonly canDeliver?: (job: CronJob) => boolean; private readonly canDeliverMissed?: () => boolean; private readonly maxJobs: number; + private readonly ownsJob: (job: CronJob) => boolean; private readonly sendMessage: (message: CronDelivery) => void; private readonly telemetry?: StepTelemetryReporter; private readonly warn: (message: string) => void; @@ -548,6 +585,7 @@ export class StepCronRuntime { this.canDeliver = options.canDeliver; this.canDeliverMissed = options.canDeliverMissed; this.maxJobs = options.maxJobs ?? DEFAULT_MAX_JOBS; + this.ownsJob = options.ownsJob ?? (() => true); this.sendMessage = options.sendMessage ?? (() => {}); this.telemetry = options.telemetry; this.warn = options.warn ?? ((message) => console.warn(`[step-cron] ${message}`)); @@ -602,8 +640,20 @@ export class StepCronRuntime { (!Number.isInteger(options.intervalHours) || options.intervalHours < 1 || options.intervalHours > 168) ) throw new Error("Cron interval must be a whole number between 1 and 168 hours"); + if (options.intervalHours !== undefined && !recurring) + throw new Error("Cron interval jobs repeat; set recurring to true or omit intervalHours"); + if (options.runAt !== undefined) { + if (!Number.isFinite(options.runAt)) throw new Error("Cron runAt must be a finite timestamp"); + if (recurring) throw new Error("Only a one-shot cron job takes a runAt time"); + // A one-shot time in the past would fire on the next tick, which reads as + // "ran immediately" rather than "scheduled". + if (options.runAt <= createdAt) throw new Error("A one-shot cron job must be scheduled in the future"); + } + const normalizedTitle = options.title?.trim(); + if (normalizedTitle && normalizedTitle.length > CRON_MAX_TITLE_LENGTH) + throw new Error(`Cron title exceeds ${CRON_MAX_TITLE_LENGTH} characters`); const nominalNext = - options.runAt ?? + (!recurring ? options.runAt : undefined) ?? (options.intervalHours ? createdAt + options.intervalHours * 60 * 60 * 1000 : expression.next(createdAt)); const desktopFields = options.workspace !== undefined || @@ -641,13 +691,14 @@ export class StepCronRuntime { prompt: normalizedPrompt, recurring, ...(options.intervalHours ? { intervalHours: options.intervalHours } : {}), - ...(options.runAt !== undefined ? { runAt: options.runAt } : {}), + ...(!recurring && options.runAt !== undefined ? { runAt: options.runAt } : {}), durable, createdAt, nextFireAt: this.jitter(nominalNext, createdAt, id, recurring, expression, options.jitter !== false), ...(options.workspace ? { workspace: path.resolve(options.workspace) } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), - ...(options.title ? { title: options.title.slice(0, 200) } : {}), + ...(options.sessionCreated ? { sessionCreated: true } : {}), + ...(normalizedTitle ? { title: normalizedTitle } : {}), ...(options.status ? { status: options.status } : {}), ...(options.inputs ? { inputs: { skills: [...(options.inputs.skills ?? [])] } } : {}), ...(options.model ? { model: { ...options.model } } : {}), @@ -668,8 +719,7 @@ export class StepCronRuntime { update(id: string, patch: CronUpdatePatch): CronJob { const job = this.withLatestJobs(() => { - const current = this.jobs.get(id); - if (!current) throw new Error(`No cron job with id ${id}`); + const current = this.readableJob(id, "update"); const next: CronJob = { ...current }; if (patch.cron !== undefined) { next.cron = patch.cron.trim(); @@ -682,7 +732,11 @@ export class StepCronRuntime { throw new Error(`Cron prompt exceeds ${MAX_PROMPT_LENGTH} characters`); next.prompt = prompt; } - if (patch.title !== undefined) next.title = patch.title.slice(0, 200); + if (patch.title !== undefined) { + if (patch.title.length > CRON_MAX_TITLE_LENGTH) + throw new Error(`Cron title exceeds ${CRON_MAX_TITLE_LENGTH} characters`); + next.title = patch.title; + } if (patch.inputs !== undefined) next.inputs = { skills: [...(patch.inputs.skills ?? [])] }; if (patch.model !== undefined) next.model = { ...patch.model }; if (patch.permissionPreset !== undefined) { @@ -703,6 +757,12 @@ export class StepCronRuntime { throw new Error(`Unknown cron job status: ${String(patch.status)}`); next.status = patch.status; next.enabled = patch.status === "enabled"; + // A status the user set themselves replaces whatever the host recorded. + if (patch.status !== "paused") delete next.statusReason; + } + if (patch.statusReason !== undefined) { + if (patch.statusReason === null) delete next.statusReason; + else next.statusReason = patch.statusReason; } if (patch.recurring !== undefined) next.recurring = patch.recurring; if (patch.intervalHours !== undefined) { @@ -713,6 +773,15 @@ export class StepCronRuntime { if (patch.runAt === null) delete next.runAt; else next.runAt = patch.runAt; } + if (next.recurring && patch.runAt !== undefined && patch.runAt !== null) + throw new Error("A repeating cron job takes a schedule, not a one-shot runAt"); + // One schedule shape per task. `runAt` is a one-shot time and + // `intervalHours` is a repeating interval, so converting between the two + // kinds has to drop the field that no longer applies: a stale `runAt` + // otherwise wins the `nextFireAt` calculation below, and a converted + // `once -> daily` task fires at its old absolute time or immediately. + if (next.recurring) delete next.runAt; + else delete next.intervalHours; if (patch.runCount !== undefined) next.runCount = Math.max(0, Math.floor(patch.runCount)); if ( (patch.permissionPreset !== undefined || patch.fullAccessAcknowledged !== undefined) && @@ -727,19 +796,55 @@ export class StepCronRuntime { if (!Number.isInteger(next.intervalHours) || next.intervalHours < 1 || next.intervalHours > 168) throw new Error("Cron interval must be a whole number between 1 and 168 hours"); } + if (patch.runAt !== undefined && patch.runAt !== null) { + if (!Number.isFinite(patch.runAt)) throw new Error("Cron runAt must be a finite timestamp"); + if (patch.runAt <= now) throw new Error("A one-shot cron job must be scheduled in the future"); + } + if (patch.expiresAt !== undefined && patch.expiresAt !== null && !Number.isFinite(patch.expiresAt)) + throw new Error("Cron expiry must be a finite timestamp or null"); + + // Reopening: a task that used up its schedule comes back to life only when + // the edit gives it a future schedule again. An explicit status in the same + // patch wins over this, so "pause" stays pause. + const extendedExpiry = + next.expiresAt !== undefined && patch.expiresAt !== undefined && patch.expiresAt !== null; + const scheduledOneShot = + !next.recurring && patch.runAt !== undefined && patch.runAt !== null && patch.runAt > now; + if (patch.status === undefined && (next.status === "expired" || next.status === "completed")) { + const reopened = + (next.status === "expired" && extendedExpiry && next.expiresAt !== undefined && next.expiresAt > now) || + (next.status === "completed" && scheduledOneShot); + if (reopened) { + next.status = "enabled"; + next.enabled = true; + delete next.statusReason; + } + } + + // A one-shot task fires at its `runAt`. Re-enabling one whose time has + // already passed would deliver on the next tick, which reads as an + // immediate run; the user has to pick a new time instead. + if (!next.recurring && patch.status === "enabled" && patch.runAt === undefined) { + if (next.runAt === undefined || next.runAt <= now) + throw new Error("A one-shot task needs a future time before it can run again"); + } + if ( patch.cron !== undefined || patch.recurring !== undefined || patch.intervalHours !== undefined || patch.runAt !== undefined || + patch.expiresAt !== undefined || patch.status === "enabled" ) { - next.nextFireAt = - patch.runAt ?? - next.runAt ?? - (next.intervalHours !== undefined - ? now + next.intervalHours * 60 * 60 * 1000 - : this.jitter(expression.next(now), now, id, next.recurring, expression, next.jitter !== false)); + if (!next.recurring) + next.nextFireAt = + next.runAt ?? this.jitter(expression.next(now), now, id, false, expression, next.jitter !== false); + else + next.nextFireAt = + next.intervalHours !== undefined + ? now + next.intervalHours * 60 * 60 * 1000 + : this.jitter(expression.next(now), now, id, true, expression, next.jitter !== false); } this.jobs.set(id, next); return { ...next }; @@ -758,8 +863,7 @@ export class StepCronRuntime { runNow(id: string): CronJob { const job = this.withLatestJobs(() => { - const current = this.jobs.get(id); - if (!current) throw new Error(`No cron job with id ${id}`); + const current = this.readableJob(id, "run"); this.fire(current, this.readNow(), true); return { ...current }; }); @@ -769,13 +873,14 @@ export class StepCronRuntime { list(): CronJob[] { this.refreshDurableNow(); return [...this.jobs.values()] + .filter((job) => this.ownsJob(job)) .sort((left, right) => left.nextFireAt - right.nextFireAt || left.id.localeCompare(right.id)) .map((job) => ({ ...job })); } delete(id: string): boolean { const found = this.withLatestJobs(() => { - if (!this.jobs.has(id)) return false; + if (!this.jobs.has(id) || !this.ownsJob(this.jobs.get(id)!)) return false; this.removeJob(id); return true; }); @@ -887,6 +992,7 @@ export class StepCronRuntime { private drain(now: number): void { this.deliverMissed(); for (const job of [...this.jobs.values()]) { + if (!this.ownsJob(job)) continue; if (job.status !== undefined && job.status !== "enabled") continue; if (this.isExpiredBeforeDelivery(job, now)) { if (job.status !== undefined) { @@ -937,6 +1043,7 @@ export class StepCronRuntime { private needsLockedDrain(now: number): boolean { if (this.missedAllowed() && this.pendingMissed.size > 0) return true; for (const job of this.jobs.values()) { + if (!this.ownsJob(job)) continue; if (job.status !== undefined && job.status !== "enabled") continue; if (this.isExpiredBeforeDelivery(job, now)) return true; if (this.deliveryAllowed(job) && this.isDue(job, now)) return true; @@ -947,6 +1054,7 @@ export class StepCronRuntime { /** `drain`'s busy-host branch, which is in-memory bookkeeping only. */ private deferDue(now: number): void { for (const job of this.jobs.values()) { + if (!this.ownsJob(job)) continue; if (job.status !== undefined && job.status !== "enabled") continue; if (this.isDue(job, now)) this.defer(job); } @@ -956,7 +1064,7 @@ export class StepCronRuntime { if (this.pendingMissed.size === 0 || !this.missedAllowed()) return; const jobs = [...this.pendingMissed].flatMap((id) => { const job = this.jobs.get(id); - return job ? [{ ...job }] : []; + return job && this.ownsJob(job) ? [{ ...job }] : []; }); if (jobs.length === 0) return; try { @@ -1045,6 +1153,14 @@ export class StepCronRuntime { this.failures.delete(`delivery:${id}`); } + /** A job this host may change, or an error saying whose it is. */ + private readableJob(id: string, action: "run" | "update"): CronJob { + const job = this.jobs.get(id); + if (!job) throw new Error(`No cron job with id ${id}`); + if (!this.ownsJob(job)) throw new Error(`Cron job ${id} belongs to another host and cannot be ${action}`); + return job; + } + /** * Adopt an existing project store. Creating one belongs to `create`, so a * session that never schedules durable work performs no storage I/O. @@ -1202,6 +1318,15 @@ export interface StepCronExtensionOptions { telemetry?: StepTelemetryReporter; enabled?: boolean; readOnly?: boolean; + /** + * Ask the user before an agent creates, updates or deletes a task. + * + * This is a host capability, not a product default: the desktop host has a + * page that can show the dialog and wants the gate, while a CLI session (and + * any print / RPC host with no dialog surface) would otherwise lose the + * ability to schedule work altogether. + */ + requireApproval?: boolean; /** Register `cron_update`; off for the CLI so its tool surface stays unchanged. */ allowUpdate?: boolean; runtime?: CronRuntimeLike; @@ -1225,15 +1350,58 @@ function formatCronStatus(jobs: readonly CronJob[]): string { .join("\n"); } +/** + * Describe a pending `cron_update` by what the task is now, not by its id. + * + * The approval dialog is the only place a person sees what they are agreeing + * to, and an id alone says nothing about which scheduled work changes. + */ +function describeUpdateTarget(runtime: CronRuntimeLike, params: Static): string { + const job = runtime.list().find((candidate) => candidate.id === params.id); + const current = job ? `${job.title ?? job.prompt.slice(0, 60)} (${job.cron})` : `unknown task ${params.id}`; + const changes = [ + params.cron === undefined ? undefined : `schedule -> ${params.cron}`, + params.prompt === undefined ? undefined : "prompt", + params.title === undefined ? undefined : `title -> ${params.title}`, + params.skills === undefined ? undefined : "skills", + params.enabled === undefined ? undefined : params.enabled ? "resume" : "pause", + params.expiresAt === undefined ? undefined : "expiry", + ].filter((value): value is string => value !== undefined); + return `${current}\n${changes.length > 0 ? `changes: ${changes.join(", ")}` : "no changes"}`; +} + +/** + * Read from a captured extension context, tolerating a stale one. + * + * A ctx goes stale the moment its session is replaced — fork, newSession, + * switchSession or reload — and touching a stale ctx throws. Timer callbacks can + * land in that window, and an uncaught throw there takes down the whole host + * rather than just skipping one notification, so treat it as "nothing to read". + * + * The plan-mode gate is read through the same helper: it asks the session + * whether a human decision is pending, and a stale ctx cannot answer. + */ +function withLiveContext(context: ExtensionContext | undefined, read: (ctx: ExtensionContext) => T): T | undefined { + if (!context) return undefined; + try { + return read(context); + } catch { + return undefined; + } +} + async function requireCronApproval( ctx: ExtensionContext, action: "create" | "update" | "delete", detail: string, ): Promise { - if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") throw new Error(`Cron ${action} requires user approval`); + // `once` marks the dialog as a decision about one scheduled task, so a + // standing "allow for this session" cannot silently cover every later + // scheduled-task change in the conversation. const approved = await ctx.ui.confirm( `Approve scheduled task ${action}?`, `${detail}\n\nAgent-created scheduled-task changes require your approval.`, + { once: true }, ); if (!approved) throw new Error(`Cron ${action} was not approved`); } @@ -1242,21 +1410,44 @@ async function requireCronApproval( export function createStepCronExtension(options: StepCronExtensionOptions = {}): ExtensionFactory { return (pi: ExtensionAPI): void => { if (options.enabled === false || envFlagEnabled(process.env.STEP_DISABLE_CRON)) return; + /** + * The approval gate is a host capability. A host that asks has a dialog + * surface; a host that does not must not lose the ability to schedule, and a + * host whose context cannot show a dialog at all is refused rather than + * silently allowed. + */ + const approve = (ctx: ExtensionContext, action: "create" | "update" | "delete", detail: string) => { + if (!options.requireApproval) return; + if (!ctx.hasUI) throw new Error(`Cron ${action} requires user approval, but this host has no dialog surface`); + return requireCronApproval(ctx, action, detail); + }; let currentContext: ExtensionContext | undefined; const runtime = options.runtime ?? new StepCronRuntime({ telemetry: options.telemetry, + // A durable record bound to a session belongs to the host that owns + // that session. A CLI session loading the same project folder must not + // fire, list, or delete it: it has no way to see that session, and + // firing it would deliver a desktop task's prompt into this terminal + // conversation instead. + ownsJob: (job) => job.sessionId === undefined, isIdle: () => Boolean( - currentContext?.isIdle() && - !currentContext.hasPendingMessages() && - // A job whose delivery would start a turn stays due instead of - // being fired into a queue, so an approval does not replay work the - // user may reject. - currentContext.hasPendingUserDecision?.() !== true, + withLiveContext( + currentContext, + (ctx) => + ctx.isIdle() && + !ctx.hasPendingMessages() && + // A job whose delivery would start a turn stays due instead of + // being fired into a queue, so an approval does not replay work + // the user may reject. + ctx.hasPendingUserDecision?.() !== true, + ), ), - warn: (message) => currentContext?.ui.notify(message, "warning"), + warn: (message) => { + withLiveContext(currentContext, (ctx) => ctx.ui.notify(message, "warning")); + }, sendMessage: (delivery) => { if (delivery.kind === "missed") { const jobs = delivery.jobs ?? []; @@ -1324,7 +1515,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronCreateParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot create cron jobs"); - await requireCronApproval(ctx, "create", `${params.cron} — ${params.prompt.slice(0, 120)}`); + await approve(ctx, "create", `${params.cron} - ${params.prompt.slice(0, 120)}`); currentContext = ctx; runtime.setContext(ctx.cwd); const recurring = params.recurring !== false; @@ -1353,7 +1544,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronDeleteParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot delete cron jobs"); - await requireCronApproval(ctx, "delete", params.id); + await approve(ctx, "delete", params.id); currentContext = ctx; runtime.setContext(ctx.cwd); const found = runtime.delete(params.id); @@ -1369,7 +1560,7 @@ Scheduling: recurring jobs get a stable task-ID offset of up to 30 minutes late, parameters: CronUpdateParams, execute: async (_id, params, _signal, _onUpdate, ctx) => { if (options.readOnly) throw new Error("This task session cannot update cron jobs"); - await requireCronApproval(ctx, "update", params.id); + await approve(ctx, "update", describeUpdateTarget(runtime, params)); if (!runtime.update) throw new Error("This host does not support updating cron jobs"); currentContext = ctx; runtime.setContext(ctx.cwd); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1af7765b..89bda053 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -312,12 +312,6 @@ export { DefaultResourceLoader, loadProjectContextFiles, } from "./core/resource-loader.ts"; -export { - CRON_SESSION_ID_PREFIX, - CRON_SESSION_ORIGIN, - isScheduledTaskSession, - type SessionOrigin, -} from "./core/scheduled-session.ts"; // SDK for programmatic usage export { AgentSessionRuntime, @@ -514,6 +508,8 @@ export { } from "./features/step.ts"; export { createStepCapabilitiesExtensionInline } from "./features/step-capabilities.ts"; export { + CRON_MAX_PROMPT_LENGTH, + CRON_MAX_TITLE_LENGTH, type CronCreateOptions, type CronCreateResult, type CronDelivery, @@ -523,6 +519,7 @@ export { type CronJobStatus, type CronRunStatus, type CronRuntimeLike, + type CronStatusReason, type CronUpdatePatch, createStepCronExtension, SimpleCronExpression, diff --git a/packages/coding-agent/test/step-cron.test.ts b/packages/coding-agent/test/step-cron.test.ts index e855183f..952d155f 100644 --- a/packages/coding-agent/test/step-cron.test.ts +++ b/packages/coding-agent/test/step-cron.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "../src/core/extensions/types.ts"; import { + CRON_MAX_PROMPT_LENGTH, + CRON_MAX_TITLE_LENGTH, type CronDelivery, CronFileStore, type CronJob, @@ -262,25 +264,50 @@ test("the CLI cron extension keeps its original tool surface", () => { expect([...harness.tools.keys()].sort()).toEqual(["cron_create", "cron_delete", "cron_list"]); }); -test("agent-created cron mutations require user approval", async () => { - const harness = createHarness(); - createStepCronExtension({ enabled: true })(harness.api); - await emitCron(harness, "session_start"); - const confirm = harness.ctx.ui.confirm as ReturnType; +test("the approval gate is a host choice, and a host that asks gets one dialog per change", async () => { + // The CLI host does not pass `requireApproval`: scheduling work is its own + // user-driven command surface, and a print / RPC session has no page to show + // a dialog on. A host that opts in does. + const cli = createHarness(); + createStepCronExtension({ enabled: true })(cli.api); + await emitCron(cli, "session_start"); + const cliConfirm = cli.ctx.ui.confirm as ReturnType; + await cli.tools + .get("cron_create")! + .execute("create", { cron: "* * * * *", prompt: "no gate here" } as never, undefined, undefined, cli.ctx); + expect(cliConfirm).not.toHaveBeenCalled(); + await emitCron(cli, "session_shutdown"); + + const desktop = createHarness(); + createStepCronExtension({ enabled: true, requireApproval: true })(desktop.api); + await emitCron(desktop, "session_start"); + const confirm = desktop.ctx.ui.confirm as ReturnType; confirm.mockResolvedValueOnce(false); await expect( - harness.tools + desktop.tools .get("cron_create")! .execute( "create", { cron: "* * * * *", prompt: "needs approval" } as never, undefined, undefined, - harness.ctx, + desktop.ctx, ), ).rejects.toThrow("not approved"); - expect(harness.tools.get("cron_create")).toBeDefined(); - await emitCron(harness, "session_shutdown"); + // The dialog is per scheduled task, never part of a session-wide grant. + expect(confirm.mock.calls[0]?.[2]).toMatchObject({ once: true }); + await emitCron(desktop, "session_shutdown"); +}); + +test("a host without a dialog surface refuses rather than silently scheduling", async () => { + const harness = createHarness(); + Object.assign(harness.ctx, { hasUI: false }); + createStepCronExtension({ enabled: true, requireApproval: true })(harness.api); + await expect( + harness.tools + .get("cron_create")! + .execute("create", { cron: "* * * * *", prompt: "no dialog" } as never, undefined, undefined, harness.ctx), + ).rejects.toThrow("no dialog surface"); }); test("durable creation is rejected in an untrusted project", async () => { @@ -892,6 +919,159 @@ describe("desktop cron task fields", () => { expect(() => runtime.update(created.id, { fullAccessAcknowledged: false })).toThrow(/risk acknowledgment/); }); + test("one-shot and repeating schedules cannot leak into each other", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + const runAt = now + 2 * 60 * 60 * 1000; + const once = runtime.create("0 9 * * *", "once", false, false, true, { + runAt, + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(once.runAt).toBe(runAt); + expect(once.nextFireAt).toBe(runAt); + + // `once -> daily`: the old absolute time must not decide the next fire. + const daily = runtime.update(once.id, { recurring: true, cron: "0 9 * * *" }); + expect(daily.recurring).toBe(true); + expect(daily.runAt).toBeUndefined(); + expect(daily.nextFireAt).toBe(SimpleCronExpression.parse("0 9 * * *").next(now)); + + // …and back: `daily -> once` carries an explicit time. + const backToOnce = runtime.update(once.id, { recurring: false, runAt: now + 60 * 60 * 1000 }); + expect(backToOnce.intervalHours).toBeUndefined(); + expect(backToOnce.nextFireAt).toBe(now + 60 * 60 * 1000); + + // A repeating task cannot take a one-shot time. + expect(() => + runtime.update(once.id, { + recurring: true, + runAt: now + 60 * 60 * 1000, + }), + ).toThrow(/one-shot/); + }); + + test("a one-shot time must be in the future on create and update", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + expect(() => + runtime.create("0 9 * * *", "past", false, false, true, { + runAt: now - 60_000, + status: "enabled", + autoExpireMs: null, + jitter: false, + }), + ).toThrow(/future/); + expect(() => + runtime.create("0 9 * * *", "nan", false, false, true, { + runAt: Number.NaN, + status: "enabled", + autoExpireMs: null, + jitter: false, + }), + ).toThrow(/finite/); + const once = runtime.create("0 9 * * *", "ok", false, false, true, { + runAt: now + 60_000, + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(() => runtime.update(once.id, { runAt: now - 1 })).toThrow(/future/); + expect(() => runtime.update(once.id, { runAt: Number.POSITIVE_INFINITY })).toThrow(/finite/); + expect(() => runtime.update(once.id, { expiresAt: Number.NaN })).toThrow(/finite/); + }); + + test("editing an expiry or a one-shot time reopens a finished task", () => { + let now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + const expired = runtime.create("0 9 * * *", "expiring", true, false, true, { + status: "expired", + expiresAt: now - 60_000, + autoExpireMs: null, + jitter: false, + }).job; + const renewed = runtime.update(expired.id, { expiresAt: now + 24 * 60 * 60 * 1000 }); + expect(renewed.status).toBe("enabled"); + expect(renewed.enabled).toBe(true); + + const completed = runtime.create("0 9 * * *", "done", false, false, true, { + runAt: now + 60_000, + status: "completed", + autoExpireMs: null, + jitter: false, + }).job; + expect(runtime.update(completed.id, { runAt: now + 60 * 60 * 1000 }).status).toBe("enabled"); + + // An edit that does not restore a future schedule must not reopen it. + const stillExpired = runtime.update(expired.id, { status: "expired" }); + expect(runtime.update(stillExpired.id, { title: "renamed" }).status).toBe("expired"); + + // Re-enabling a one-shot whose time already passed would fire it at once on + // the next tick, so it needs a new time instead. + const stale = runtime.create("0 9 * * *", "stale", false, false, true, { + runAt: now + 60_000, + status: "paused", + autoExpireMs: null, + jitter: false, + }).job; + now += 10 * 60_000; + expect(() => runtime.update(stale.id, { status: "enabled" })).toThrow(/future time/); + expect(runtime.update(stale.id, { status: "enabled", runAt: now + 60_000 }).status).toBe("enabled"); + }); + + test("a host only sees and fires the records it owns", () => { + let now = Date.UTC(2026, 0, 1, 12, 0); + const deliveries: CronDelivery[] = []; + const runtime = new StepCronRuntime({ + now: () => now, + jitterRatio: 0, + ownsJob: (job) => job.sessionId === undefined, + sendMessage: (delivery) => deliveries.push(delivery), + }); + const mine = runtime.create("* * * * *", "mine", false, false, true, { + status: "enabled", + // One-shot at the next tick, so the drain loop has real work to do. + runAt: now + 60_000, + autoExpireMs: null, + jitter: false, + }).job; + const foreign = runtime.create("* * * * *", "desktop", true, false, true, { + sessionId: "session-desktop", + status: "enabled", + autoExpireMs: null, + jitter: false, + }).job; + expect(runtime.list().map((job) => job.id)).toEqual([mine.id]); + expect(runtime.delete(foreign.id)).toBe(false); + expect(() => runtime.runNow(foreign.id)).toThrow(/another host/); + expect(() => runtime.update(foreign.id, { title: "hijack" })).toThrow(/another host/); + now += 60_000; + runtime.tick(); + expect(deliveries.map((delivery) => delivery.job?.id)).toEqual([mine.id]); + }); + + test("prompt and title limits are shared with every host", () => { + const now = Date.UTC(2026, 0, 1, 12, 0); + const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); + const created = runtime.create("0 9 * * *", "x", true, false, true, { status: "enabled" }).job; + expect(() => runtime.create("0 9 * * *", "y".repeat(CRON_MAX_PROMPT_LENGTH + 1), true, false, true)).toThrow( + /exceeds 8192/, + ); + expect(() => runtime.update(created.id, { prompt: "y".repeat(CRON_MAX_PROMPT_LENGTH + 1) })).toThrow( + /exceeds 8192/, + ); + expect(() => + runtime.create("0 9 * * *", "z", true, false, true, { + title: "t".repeat(CRON_MAX_TITLE_LENGTH + 1), + status: "enabled", + }), + ).toThrow(/title exceeds 30/); + expect(() => runtime.update(created.id, { title: "t".repeat(CRON_MAX_TITLE_LENGTH + 1) })).toThrow( + /title exceeds 30/, + ); + }); + test("supports explicit hour intervals and absolute one-shot times", () => { const now = Date.UTC(2026, 0, 1, 12, 0); const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); From 8677a978fb491be97dc110fb07315c6370369f91 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:31:20 +0800 Subject: [PATCH 5/5] fix(coding-agent): restore the kernel cron limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit narrowed `CRON_MAX_PROMPT_LENGTH` from 16,000 to 8 KiB and `CRON_MAX_TITLE_LENGTH` from 200 to 30, on the reasoning that a host form should not enforce a stricter cap than the agent tools. The reasoning was right; the change was in the wrong layer. Both constants were 16,000 / 200 since the runtime was first written, and `parsePersistedJob` validates loaded records against the same prompt value. A task saved before the change with a 10 KB prompt would therefore be rejected as an unsupported record on the next start and silently stop being scheduled — the narrowing would have deleted data without saying so. A product that wants a shorter prompt for its own form applies that cap at its own boundary. The kernel keeps the value the reader shares. Also record what the two do on overflow, which was never written down: an oversized prompt is rejected, an oversized title is truncated. A title is presentation; a prompt is the work. --- .../coding-agent/src/features/step-cron.ts | 33 ++++++++++--------- packages/coding-agent/test/step-cron.test.ts | 25 +++++++------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/features/step-cron.ts b/packages/coding-agent/src/features/step-cron.ts index 5107eb6b..b549fedf 100644 --- a/packages/coding-agent/src/features/step-cron.ts +++ b/packages/coding-agent/src/features/step-cron.ts @@ -30,12 +30,19 @@ const DEFAULT_MAX_JOBS = 50; const AUTO_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; const MAX_SEARCH_MINUTES = 366 * 24 * 60; /** - * The one limit every writer shares: the desktop form, the HTTP routes and the - * agent tools. A task the agent can create must also be editable in the UI, so - * a host must not define a stricter cap of its own. + * Kernel ceilings, not a product policy. A host that shows a form may apply a + * stricter cap of its own; it must not narrow these, because the + * persisted-record reader shares them — a prompt written by an earlier build + * that exceeded a narrowed value would be treated as an unsupported record and + * silently stop being scheduled. + * + * The two behave differently on purpose, matching the original records: an + * oversized prompt is rejected, while an oversized title is truncated. A title + * is presentation; a prompt is what the task will do, so silently shortening it + * would change the work. */ -export const CRON_MAX_PROMPT_LENGTH = 8 * 1024; -export const CRON_MAX_TITLE_LENGTH = 30; +export const CRON_MAX_PROMPT_LENGTH = 16_000; +export const CRON_MAX_TITLE_LENGTH = 200; const MAX_PROMPT_LENGTH = CRON_MAX_PROMPT_LENGTH; const MAX_CRON_LENGTH = 200; const MAX_LOCK_AGE_MS = 60_000; @@ -418,7 +425,10 @@ function parsePersistedJob(value: unknown): CronJob | undefined { return undefined; if (candidate.sessionId !== undefined && (typeof candidate.sessionId !== "string" || !candidate.sessionId.trim())) return undefined; - if (candidate.title !== undefined && (typeof candidate.title !== "string" || candidate.title.length > 200)) + if ( + candidate.title !== undefined && + (typeof candidate.title !== "string" || candidate.title.length > CRON_MAX_TITLE_LENGTH) + ) return undefined; if (candidate.sessionCreated !== undefined && typeof candidate.sessionCreated !== "boolean") return undefined; if (candidate.statusReason !== undefined && candidate.statusReason !== "session_missing") return undefined; @@ -649,9 +659,6 @@ export class StepCronRuntime { // "ran immediately" rather than "scheduled". if (options.runAt <= createdAt) throw new Error("A one-shot cron job must be scheduled in the future"); } - const normalizedTitle = options.title?.trim(); - if (normalizedTitle && normalizedTitle.length > CRON_MAX_TITLE_LENGTH) - throw new Error(`Cron title exceeds ${CRON_MAX_TITLE_LENGTH} characters`); const nominalNext = (!recurring ? options.runAt : undefined) ?? (options.intervalHours ? createdAt + options.intervalHours * 60 * 60 * 1000 : expression.next(createdAt)); @@ -698,7 +705,7 @@ export class StepCronRuntime { ...(options.workspace ? { workspace: path.resolve(options.workspace) } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), ...(options.sessionCreated ? { sessionCreated: true } : {}), - ...(normalizedTitle ? { title: normalizedTitle } : {}), + ...(options.title ? { title: options.title.slice(0, CRON_MAX_TITLE_LENGTH) } : {}), ...(options.status ? { status: options.status } : {}), ...(options.inputs ? { inputs: { skills: [...(options.inputs.skills ?? [])] } } : {}), ...(options.model ? { model: { ...options.model } } : {}), @@ -732,11 +739,7 @@ export class StepCronRuntime { throw new Error(`Cron prompt exceeds ${MAX_PROMPT_LENGTH} characters`); next.prompt = prompt; } - if (patch.title !== undefined) { - if (patch.title.length > CRON_MAX_TITLE_LENGTH) - throw new Error(`Cron title exceeds ${CRON_MAX_TITLE_LENGTH} characters`); - next.title = patch.title; - } + if (patch.title !== undefined) next.title = patch.title.slice(0, CRON_MAX_TITLE_LENGTH); if (patch.inputs !== undefined) next.inputs = { skills: [...(patch.inputs.skills ?? [])] }; if (patch.model !== undefined) next.model = { ...patch.model }; if (patch.permissionPreset !== undefined) { diff --git a/packages/coding-agent/test/step-cron.test.ts b/packages/coding-agent/test/step-cron.test.ts index 952d155f..bc6bd81c 100644 --- a/packages/coding-agent/test/step-cron.test.ts +++ b/packages/coding-agent/test/step-cron.test.ts @@ -1051,25 +1051,26 @@ describe("desktop cron task fields", () => { expect(deliveries.map((delivery) => delivery.job?.id)).toEqual([mine.id]); }); - test("prompt and title limits are shared with every host", () => { + test("the kernel keeps generic ceilings the persisted reader shares", () => { const now = Date.UTC(2026, 0, 1, 12, 0); const runtime = new StepCronRuntime({ now: () => now, jitterRatio: 0 }); const created = runtime.create("0 9 * * *", "x", true, false, true, { status: "enabled" }).job; + // 16,000 is the kernel ceiling; a host form may be stricter, but narrowing + // this value would make an earlier record with a longer prompt unreadable. + expect(CRON_MAX_PROMPT_LENGTH).toBe(16_000); + expect(CRON_MAX_TITLE_LENGTH).toBe(200); expect(() => runtime.create("0 9 * * *", "y".repeat(CRON_MAX_PROMPT_LENGTH + 1), true, false, true)).toThrow( - /exceeds 8192/, + /exceeds 16000/, ); expect(() => runtime.update(created.id, { prompt: "y".repeat(CRON_MAX_PROMPT_LENGTH + 1) })).toThrow( - /exceeds 8192/, - ); - expect(() => - runtime.create("0 9 * * *", "z", true, false, true, { - title: "t".repeat(CRON_MAX_TITLE_LENGTH + 1), - status: "enabled", - }), - ).toThrow(/title exceeds 30/); - expect(() => runtime.update(created.id, { title: "t".repeat(CRON_MAX_TITLE_LENGTH + 1) })).toThrow( - /title exceeds 30/, + /exceeds 16000/, ); + // A title over the ceiling is truncated rather than rejected, as before. + const longTitle = runtime.create("0 9 * * *", "z", true, false, true, { + title: "t".repeat(CRON_MAX_TITLE_LENGTH + 10), + status: "enabled", + }).job; + expect(longTitle.title?.length).toBe(CRON_MAX_TITLE_LENGTH); }); test("supports explicit hour intervals and absolute one-shot times", () => {