+
+
+
Schedules
+
+ Run Jared prompts on a repository at a predictable local time.
+
+
+
+
+ {schedules.isLoading ? (
+
+
+ Loading schedules…
+
+ ) : schedules.isError ? (
+
+
+ {schedules.error instanceof Error ? schedules.error.message : "Failed to load schedules"}
+
+
+ ) : !schedules.data?.data.length ? (
+
+
+
+ No schedules yet
+
+ Create a recurring prompt for maintenance, review, reporting, or any other repository task.
+
+
+
+ ) : (
+
+ {actionError && (
+
+
+ {actionError instanceof Error ? actionError.message : "Could not update schedule"}
+
+
+ )}
+ {schedules.data.data.map((schedule) => (
+
+
+
+
+ {schedule.name}
+
+ {schedule.repo} · {cadenceLabel(schedule)} · {schedule.timezone}
+
+
+
+
+
+
+
+
+ Next: {formatAt(schedule.nextDueAt, schedule.timezone)}
+
+ Last: {schedule.lastRun ? schedule.lastRun.status : "Never"}
+ {schedule.enabled && schedule.armedRevision !== schedule.revision && (
+ Scheduler recovery pending
+ )}
+ {schedule.lastRun?.artifact_url && (
+
+ Open {schedule.lastRun.artifact_kind ?? "artifact"}
+
+ )}
+ {schedule.lastRun?.entity_key && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
!open && setEditor(undefined)}
+ />
+ !open && setHistoryId(null)}
+ />
+ !open && setConfirm(null)}>
+
+
+
+ {confirm?.kind === "run" ? `Run “${confirm.schedule.name}” now?` : `Archive “${confirm?.schedule.name}”?`}
+
+
+ {confirm?.kind === "run"
+ ? `Jared will run the current prompt on ${confirm.schedule.repo}. This does not change the next scheduled occurrence.`
+ : "This stops future runs but keeps immutable history."}
+
+
+
+ Cancel
+ {
+ event.preventDefault()
+ if (!confirm) return
+ const done = () => setConfirm(null)
+ if (confirm.kind === "run") run.mutate(confirm.schedule.id, { onSettled: done })
+ else archive.mutate(confirm.schedule.id, { onSettled: done })
+ }}
+ >
+ {confirm?.kind === "run" ? "Run now" : "Archive schedule"}
+
+
+
+
+
+ )
+}
diff --git a/apps/server/src/cloudflare.ts b/apps/server/src/cloudflare.ts
index 4344428..f1a8625 100644
--- a/apps/server/src/cloudflare.ts
+++ b/apps/server/src/cloudflare.ts
@@ -15,6 +15,7 @@ import { recordMaintenanceRun } from "./lib/events/maintenance.ts"
import { reconcileStuckDispatched } from "./lib/events/reconcile.ts"
import { deleteExpiredWebhookEvents } from "./lib/events/retention.ts"
import { cloudflareSentryOptions } from "./lib/observability/cloudflare.ts"
+import type { ScheduleArm } from "./lib/schedules/runner.ts"
import type { BaseEnvBindings } from "./types/env/base.ts"
// ContainerProxy is a WorkerEntrypoint the Sandbox DO reaches via
@@ -23,6 +24,7 @@ import type { BaseEnvBindings } from "./types/env/base.ts"
// container fails to start with "ctx.exports.ContainerProxy is undefined".
export { ContainerProxy } from "@cloudflare/sandbox"
export { Sandbox } from "./lib/containers/sandbox.ts"
+export { ScheduleRunner } from "./lib/schedules/runner.ts"
const handlers = {
async scheduled(
@@ -100,6 +102,43 @@ const handlers = {
} catch (_err) {
console.warn("maintenance_runs.record.failed", { failure_class: "contained" })
}
+ // Schedule configuration is authoritative in D1. Re-arm only runners
+ // that did not confirm their current revision. Active runs are included
+ // even when a recurrence was paused or archived: their monitor alarm is
+ // for settlement only and must not strand a leased capacity slot.
+ try {
+ const unarmed = await env.DB.prepare(
+ `SELECT job.id, job.revision, job.next_due_at,
+ EXISTS (
+ SELECT 1 FROM scheduled_job_runs run
+ WHERE run.schedule_id = job.id
+ AND run.status IN ('preparing', 'admitting', 'admitted', 'unknown_admission')
+ ) AS has_active_run
+ FROM scheduled_jobs job
+ WHERE (job.armed_revision IS NULL OR job.armed_revision != job.revision)
+ AND (
+ (job.enabled = 1 AND job.archived_at IS NULL AND job.next_due_at IS NOT NULL)
+ OR EXISTS (
+ SELECT 1 FROM scheduled_job_runs run
+ WHERE run.schedule_id = job.id
+ AND run.status IN ('preparing', 'admitting', 'admitted', 'unknown_admission')
+ )
+ )
+ LIMIT 100`,
+ ).all<{ id: string; revision: number; next_due_at: number | null; has_active_run: number }>()
+ await Promise.all(
+ (unarmed.results ?? []).map((job) =>
+ env.ScheduleRunner.get(env.ScheduleRunner.idFromName(job.id)).arm({
+ scheduleId: job.id,
+ revision: job.revision,
+ nextDueAt: job.next_due_at,
+ wakeAt: job.has_active_run ? controller.scheduledTime : undefined,
+ } satisfies ScheduleArm),
+ ),
+ )
+ } catch (_err) {
+ console.warn("scheduled_jobs.rearm.failed", { failure_class: "contained" })
+ }
span.setAttribute("jared.lifecycle_status", "completed")
span.setAttribute("jared.maintenance.deleted", deleted)
span.setAttribute("jared.maintenance.timed_out", (stuck.meta.changes ?? 0) + reconciled.timedOut)
diff --git a/apps/server/src/components/layout.tsx b/apps/server/src/components/layout.tsx
index 2643f58..ac3c9e2 100644
--- a/apps/server/src/components/layout.tsx
+++ b/apps/server/src/components/layout.tsx
@@ -1,4 +1,4 @@
-import { CaretUpDown, House, Lightning, List, Monitor, Moon, Robot, SignOut, Sun } from "@phosphor-icons/react"
+import { CaretUpDown, House, Lightning, List, Monitor, Moon, Repeat, Robot, SignOut, Sun } from "@phosphor-icons/react"
import { useQueryClient } from "@tanstack/react-query"
import { useTheme } from "next-themes"
import { NavLink, Outlet, useNavigate } from "react-router-dom"
@@ -40,6 +40,7 @@ const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: House },
{ to: "/events", label: "Webhook Events", icon: Lightning },
{ to: "/containers", label: "Agent runs", icon: Robot },
+ { to: "/schedules", label: "Schedules", icon: Repeat },
]
const THEME_OPTIONS = [
diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts
index 0a9c42a..6aa3c87 100644
--- a/apps/server/src/db/schema.ts
+++ b/apps/server/src/db/schema.ts
@@ -176,3 +176,71 @@ export const maintenanceRuns = sqliteTable("maintenance_runs", {
completedAt: integer("completed_at", { mode: "timestamp" }).notNull(),
outcome: text("outcome").notNull(),
})
+
+/** Operator-configured prompt recurrence. D1 is authoritative; the per-job DO only arms alarms. */
+export const scheduledJobs = sqliteTable(
+ "scheduled_jobs",
+ {
+ id: text("id").primaryKey(),
+ name: text("name").notNull(),
+ repo: text("repo").notNull(),
+ prompt: text("prompt").notNull(),
+ cadence: text("cadence").notNull(),
+ localTime: text("local_time").notNull(),
+ timezone: text("timezone").notNull(),
+ dayOfWeek: integer("day_of_week"),
+ dayOfMonth: integer("day_of_month"),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
+ revision: integer("revision").notNull().default(1),
+ armedRevision: integer("armed_revision"),
+ nextDueAt: integer("next_due_at", { mode: "timestamp_ms" }),
+ createdBy: text("created_by").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+ archivedAt: integer("archived_at", { mode: "timestamp_ms" }),
+ },
+ (table) => [
+ index("idx_scheduled_jobs_enabled_due").on(table.enabled, table.nextDueAt),
+ index("idx_scheduled_jobs_archived_updated").on(table.archivedAt, table.updatedAt),
+ ],
+)
+
+/** Immutable execution evidence for one scheduled or operator-confirmed occurrence. */
+export const scheduledJobRuns = sqliteTable(
+ "scheduled_job_runs",
+ {
+ id: text("id").primaryKey(),
+ scheduleId: text("schedule_id").notNull(),
+ scheduleRevision: integer("schedule_revision").notNull(),
+ dedupeKey: text("dedupe_key").notNull(),
+ trigger: text("trigger").notNull(),
+ intendedAt: integer("intended_at", { mode: "timestamp_ms" }).notNull(),
+ repo: text("repo").notNull(),
+ prompt: text("prompt").notNull(),
+ entityKey: text("entity_key"),
+ flueSubmissionId: text("flue_submission_id"),
+ status: text("status").notNull(),
+ attempts: integer("attempts").notNull().default(0),
+ failureReason: text("failure_reason"),
+ artifactUrl: text("artifact_url"),
+ artifactKind: text("artifact_kind"),
+ startedAt: integer("started_at", { mode: "timestamp_ms" }),
+ admittedAt: integer("admitted_at", { mode: "timestamp_ms" }),
+ settledAt: integer("settled_at", { mode: "timestamp_ms" }),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+ },
+ (table) => [
+ uniqueIndex("scheduled_job_runs_schedule_dedupe_unique").on(table.scheduleId, table.dedupeKey),
+ index("idx_scheduled_job_runs_schedule_created").on(table.scheduleId, table.createdAt),
+ index("idx_scheduled_job_runs_status_updated").on(table.status, table.updatedAt),
+ uniqueIndex("scheduled_job_runs_entity_unique").on(table.entityKey),
+ ],
+)
+
+/** Small fixed pool reserving sandbox capacity for scheduled work without consuming all ten instances. */
+export const scheduledRunSlots = sqliteTable("scheduled_run_slots", {
+ slot: integer("slot").primaryKey(),
+ runId: text("run_id"),
+ leaseExpiresAt: integer("lease_expires_at", { mode: "timestamp_ms" }),
+})
diff --git a/apps/server/src/lib/schedules/__tests__/prompt.test.ts b/apps/server/src/lib/schedules/__tests__/prompt.test.ts
new file mode 100644
index 0000000..dd505fb
--- /dev/null
+++ b/apps/server/src/lib/schedules/__tests__/prompt.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest"
+import { formatScheduledPrompt } from "../prompt"
+
+describe("formatScheduledPrompt", () => {
+ it("keeps the operator prompt authoritative and supplies only schedule metadata", () => {
+ const prompt = formatScheduledPrompt({
+ runId: "run-123",
+ scheduleName: "Weekly security maintenance",
+ repo: "sentry-internal/jared",
+ intendedAt: "2026-09-14T09:00:00Z",
+ text: "Review dependencies and create one PR only if fixes are needed.",
+ })
+
+ expect(prompt).toContain("Scheduled run: Weekly security maintenance")
+ expect(prompt).toContain("Repository: sentry-internal/jared")
+ expect(prompt).toContain("Review dependencies and create one PR only if fixes are needed.")
+ expect(prompt).toContain("")
+ expect(prompt).not.toContain("must create a pull request")
+ })
+})
diff --git a/apps/server/src/lib/schedules/__tests__/recurrence.test.ts b/apps/server/src/lib/schedules/__tests__/recurrence.test.ts
new file mode 100644
index 0000000..5abe37d
--- /dev/null
+++ b/apps/server/src/lib/schedules/__tests__/recurrence.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import { nextOccurrences } from "../recurrence"
+
+describe("nextOccurrences", () => {
+ it("keeps a daily schedule at the same local time across daylight saving time", () => {
+ const occurrences = nextOccurrences(
+ { cadence: "daily", time: "09:30", timezone: "America/New_York" },
+ "2026-03-07T15:00:00Z",
+ 3,
+ )
+
+ expect(occurrences).toEqual(["2026-03-08T13:30:00Z", "2026-03-09T13:30:00Z", "2026-03-10T13:30:00Z"])
+ })
+
+ it("runs monthly day 31 on the final day of short months", () => {
+ const occurrences = nextOccurrences(
+ { cadence: "monthly", time: "10:00", timezone: "UTC", dayOfMonth: 31 },
+ "2026-04-01T00:00:00Z",
+ 2,
+ )
+
+ expect(occurrences).toEqual(["2026-04-30T10:00:00Z", "2026-05-31T10:00:00Z"])
+ })
+})
diff --git a/apps/server/src/lib/schedules/__tests__/run-key.test.ts b/apps/server/src/lib/schedules/__tests__/run-key.test.ts
new file mode 100644
index 0000000..29d1e1d
--- /dev/null
+++ b/apps/server/src/lib/schedules/__tests__/run-key.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest"
+import { parseOwnerRepo } from "@/lib/containers/do-prep"
+import { toAgentInstanceId } from "@/lib/containers/ids"
+import { createScheduledEntityKey, scheduledRunMarker } from "../run-key"
+
+describe("scheduled run keys", () => {
+ it("keeps a fresh run isolated while preserving its repository identity", () => {
+ const key = createScheduledEntityKey("sentry-internal/jared", "0f7c7917-bacf-4116-9c1f-33d7bc90b03e")
+
+ expect(parseOwnerRepo(key)).toEqual({ owner: "sentry-internal", repo: "jared", slug: "sentry-internal/jared" })
+ expect(key).toMatch(/^sentry-internal\/jared#s-[0-9a-f]{12}$/)
+ expect(toAgentInstanceId(key)).toHaveLength(key.length)
+ })
+
+ it("uses opaque metadata that can associate a GitHub artifact without granting authority", () => {
+ expect(scheduledRunMarker("run-123")).toBe("")
+ })
+})
diff --git a/apps/server/src/lib/schedules/__tests__/runner-state.test.ts b/apps/server/src/lib/schedules/__tests__/runner-state.test.ts
new file mode 100644
index 0000000..d91f60f
--- /dev/null
+++ b/apps/server/src/lib/schedules/__tests__/runner-state.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from "vitest"
+import { ScheduleRunner } from "../runner"
+import { nextRunnerWakeAt } from "../runner-state"
+
+describe("nextRunnerWakeAt", () => {
+ it("keeps the scheduled due time while waking early to monitor active work", () => {
+ expect(nextRunnerWakeAt({ scheduleDueAt: 360_000, hasActiveRun: true, now: 1_000 })).toBe(121_000)
+ })
+
+ it("does not monitor later than the scheduled occurrence", () => {
+ expect(nextRunnerWakeAt({ scheduleDueAt: 4_000, hasActiveRun: true, now: 3_000 })).toBe(4_000)
+ })
+})
+
+describe("ScheduleRunner arm", () => {
+ it("keeps an alarm for active work even when its recurrence has no next due time", async () => {
+ const storage = {
+ put: vi.fn(),
+ setAlarm: vi.fn(),
+ deleteAlarm: vi.fn(),
+ }
+ const run = vi.fn().mockResolvedValue({ meta: { changes: 1 } })
+ const durableObject = new ScheduleRunner(
+ { storage } as never,
+ { DB: { prepare: () => ({ bind: () => ({ run }) }) } } as never,
+ )
+
+ await durableObject.arm({ scheduleId: "schedule-1", revision: 2, nextDueAt: null, wakeAt: 123_456 })
+
+ expect(storage.setAlarm).toHaveBeenCalledWith(123_456)
+ expect(storage.deleteAlarm).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/server/src/lib/schedules/__tests__/service.test.ts b/apps/server/src/lib/schedules/__tests__/service.test.ts
new file mode 100644
index 0000000..60b8c4d
--- /dev/null
+++ b/apps/server/src/lib/schedules/__tests__/service.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from "vitest"
+import { settleScheduleRuns } from "../service"
+
+function fakeDatabase(rows: Array