feat(core): cron tools for scheduled prompts by paolodelia99 · Pull Request #44191 · anomalyco/opencode · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
17b354d
feat(cron): add CronJob schema, CronDeliveryPort interface, parseDura…
paolodelia99 Jul 27, 2026
15cc30c
feat(cron): add CronService with fiber loop and min-heap scheduler
paolodelia99 Jul 27, 2026
a264483
feat(cron): add V2 CronDeliveryPort implementation
paolodelia99 Jul 27, 2026
5037941
feat(cron): add legacy CronDeliveryPort implementation
paolodelia99 Jul 27, 2026
819c3f2
feat(cron): add V2 and legacy CronService wiring nodes
paolodelia99 Jul 27, 2026
7c5be42
feat(cron): add V2 cron tools (cron_add, cron_list, cron_delete)
paolodelia99 Jul 27, 2026
9dd157b
feat(cron): add legacy cron tools (cron_add, cron_list, cron_delete)
paolodelia99 Jul 27, 2026
920f384
feat(cron): register V2 cron tools in builtins
paolodelia99 Jul 27, 2026
8b8460a
feat(cron): register legacy cron tools in registry
paolodelia99 Jul 27, 2026
0f14700
feat(cron): add scheduling skill body and register in V2 plugin
paolodelia99 Jul 27, 2026
938d7dd
feat(cron): register scheduling skill in legacy
paolodelia99 Jul 27, 2026
04e58f4
test(cron): add parseDuration and CronService unit tests
paolodelia99 Jul 27, 2026
daec309
fix(cron): resolve typecheck errors in legacy port and node
paolodelia99 Jul 27, 2026
72232f4
fix(cron): remove cron tools from legacy registry (requires V2 services)
paolodelia99 Jul 27, 2026
7949308
fix(cron): remove SessionV2/SessionExecution deps to resolve hoist co…
paolodelia99 Jul 28, 2026
b0479a6
feat(cron): wire legacy cron tools with deferred CronService execution
paolodelia99 Jul 28, 2026
f8498e3
fix(cron): atomic 50-job cap, delivery error logging, proper SessionI…
paolodelia99 Jul 30, 2026
ac63a6a
refactor(cron): thread opaque context through CronJob and delivery port
paolodelia99 Aug 3, 2026
8c954db
fix(cron): replay captured InstanceRef at fire time so legacy ticks r…
paolodelia99 Aug 3, 2026
a51c086
fix(cron): log scheduler-loop delivery defects instead of swallowing …
paolodelia99 Aug 3, 2026
8ef9510
fix(cron): guard against firing a sooner-than-peeked job after a late…
paolodelia99 Aug 3, 2026
5c2ebfb
fix(cron): wake SessionExecution after admitting a queued V2 cron input
paolodelia99 Aug 3, 2026
a1c5452
revert: undo Task 5 V2 SessionExecution wiring to fix startup crash
paolodelia99 Aug 4, 2026
d29c6a5
chore: small adjustement to scheduling prompt
paolodelia99 Aug 18, 2026
4c03768
fix(cron): log non-defect delivery failures before catchCause swallow…
paolodelia99 Aug 18, 2026
2252727
test(cron): cover scheduler edge cases, tools round-trip, and port gaps
paolodelia99 Aug 18, 2026
225c024
test(cron): add failing tests for immediate-fire, busy-poll, and now+…
paolodelia99 Aug 18, 2026
68bf4f4
fix(cron): fire immediately on add, poll on first-run busy, reschedul…
paolodelia99 Aug 18, 2026
43e7b43
test(cron): update existing tests for immediate-fire and now+interval…
paolodelia99 Aug 18, 2026
8ebd988
chore(cron): remove dead V2 port and core cron tool definitions
paolodelia99 Aug 19, 2026
89581d4
feat(cron): coalesce pending deliveries and keep schedule on cadence
paolodelia99 Aug 19, 2026
2af393d
refactor(cron): drop isBusy from CronDeliveryPort interface
paolodelia99 Aug 19, 2026
69b8ad4
refactor(cron): remove isBusy from legacy delivery port
paolodelia99 Aug 19, 2026
5cf1624
test(cron): drop isBusy from service test port mocks
paolodelia99 Aug 19, 2026
dc5b6ea
fix(cron): remove orphaned V2 scheduling skill; lock in runCount-on-fork
paolodelia99 Aug 19, 2026
8416736
fix(cron): reject overflowing durations in parseDuration
paolodelia99 Aug 20, 2026
453d9fe
chore: reformatting
paolodelia99 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
27 changes: 27 additions & 0 deletions packages/core/src/cron/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export * as Duration from "./duration"

import { Effect } from "effect"
import { CronError } from "./port"

const MIN_INTERVAL_MS = 60_000

const RE = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/

export const parseDuration = (input: string): Effect.Effect<number, CronError> =>
Effect.gen(function* () {
const match = RE.exec(input)
if (!match || (!match[1] && !match[2] && !match[3])) {
return yield* new CronError({ message: `Invalid duration: "${input}". Use format like 5m, 1h, 2h30m, 90s.` })
}
const hours = match[1] ? parseInt(match[1], 10) : 0
const minutes = match[2] ? parseInt(match[2], 10) : 0
const seconds = match[3] ? parseInt(match[3], 10) : 0
const ms = (hours * 3600 + minutes * 60 + seconds) * 1000
if (!Number.isFinite(ms)) {
return yield* new CronError({ message: `Interval too large: "${input}".` })
}
if (ms < MIN_INTERVAL_MS) {
return yield* new CronError({ message: `Interval must be at least 1 minute (60s). Got: ${ms / 1000}s` })
}
return ms
})
19 changes: 19 additions & 0 deletions packages/core/src/cron/job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export * as Cron from "./job"

import { Schema } from "effect"

export const CronJob = Schema.Struct({
id: Schema.String,
sessionID: Schema.String,
prompt: Schema.String,
intervalMs: Schema.Number,
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
createdAt: Schema.Number,
expiresAt: Schema.Number,
nextRunAt: Schema.Number,
lastRunAt: Schema.optional(Schema.Number),
runCount: Schema.Number,
context: Schema.optional(Schema.Unknown),
})
export type CronJob = Schema.Schema.Type<typeof CronJob>
22 changes: 22 additions & 0 deletions packages/core/src/cron/port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export * as CronPort from "./port"

import { Context, Effect, Schema } from "effect"

export class CronDeliveryError extends Schema.TaggedErrorClass<CronDeliveryError>()("Cron.DeliveryError", {
message: Schema.String,
}) {}

export class CronError extends Schema.TaggedErrorClass<CronError>()("Cron.Error", {
message: Schema.String,
}) {}

interface Interface {
readonly deliver: (
sessionID: string,
prompt: string,
opts?: { readonly agent?: string; readonly model?: string; readonly context?: unknown },
) => Effect.Effect<void, CronDeliveryError>
readonly exists: (sessionID: string) => Effect.Effect<boolean>
}

export class CronDeliveryPort extends Context.Service<CronDeliveryPort, Interface>()("@opencode/CronDeliveryPort") {}
189 changes: 189 additions & 0 deletions packages/core/src/cron/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Cause, Clock, Context, Effect, Fiber, Layer, Queue, Ref } from "effect"
import { CronDeliveryPort, CronError } from "./port"
import { CronJob } from "./job"

interface Interface {
readonly add: (input: {
sessionID: string
prompt: string
intervalMs: number
agent?: string
model?: string
context?: unknown
}) => Effect.Effect<CronJob, CronError>
readonly list: (sessionID: string) => Effect.Effect<ReadonlyArray<CronJob>>
readonly remove: (sessionID: string, jobId: string) => Effect.Effect<number>
}

export class CronService extends Context.Service<CronService, Interface>()("@opencode/CronService") {}

const sortByNextRun = (jobs: Array<CronJob>) => [...jobs].sort((a, b) => a.nextRunAt - b.nextRunAt)

const make = Effect.gen(function* () {
const port = yield* CronDeliveryPort
const heap = yield* Ref.make<Array<CronJob>>([])
const wakeQueue = yield* Queue.unbounded<void>()
const PENDING_RESERVED = Symbol("pending-reserved")
type PendingValue = Fiber.Fiber<unknown, unknown> | typeof PENDING_RESERVED
const pending = yield* Ref.make<Map<string, PendingValue>>(new Map())

const insertJob = (job: CronJob) => Ref.update(heap, (jobs) => sortByNextRun([...jobs, job]))

const dequeueJob = () =>
Ref.modify(heap, (jobs) => {
if (jobs.length === 0) return [null as CronJob | null, jobs] as const
return [jobs[0], jobs.slice(1)] as const
})

const heapIsEmpty = () => Ref.get(heap).pipe(Effect.map((jobs) => jobs.length === 0))

const filterInPlace = (predicate: (job: CronJob) => boolean) =>
Ref.modify(heap, (jobs) => {
const kept = jobs.filter(predicate)
return [jobs.length - kept.length, sortByNextRun(kept)] as const
})

const loop = Effect.gen(function* () {
while (true) {
const empty = yield* heapIsEmpty()
if (empty) {
yield* Queue.take(wakeQueue)
continue
}

const top = yield* Ref.get(heap).pipe(Effect.map((jobs) => jobs[0]))
const now = yield* Clock.currentTimeMillis
const waitMs = Math.max(0, top.nextRunAt - now)

const result = yield* Effect.race(
Effect.sleep(waitMs).pipe(Effect.as("tick" as const)),
Queue.take(wakeQueue).pipe(Effect.as("wake" as const)),
)
if (result === "wake") continue

const job = yield* dequeueJob()
if (!job) continue

if (job.id !== top.id) {
// A sooner job was inserted during the sleep and won the heap head.
// Re-queue the job we just popped and re-iterate instead of firing early.
yield* insertJob(job)
continue
}

const current = yield* Clock.currentTimeMillis

if (current >= job.expiresAt) continue

// Reserve the pending slot atomically before forking so a fast delivery
// cannot complete and clear the slot before the fiber reference is stored.
const reserved = yield* Ref.modify(pending, (m) =>
m.has(job.id) ? ([false, m] as const) : ([true, m.set(job.id, PENDING_RESERVED)] as const),
)
if (!reserved) {
// Coalesced: a delivery is already pending for this job. Advance the
// schedule on cadence without forking another delivery.
yield* insertJob({ ...job, nextRunAt: job.nextRunAt + job.intervalMs })
continue
}

const fiber = yield* Effect.forkScoped(
port.deliver(job.sessionID, job.prompt, { agent: job.agent, model: job.model, context: job.context }).pipe(
Effect.catch((e) => Effect.logError("cron delivery failed", { sessionID: job.sessionID, error: String(e) })),
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause)
: Effect.logError("cron delivery defect", { sessionID: job.sessionID, cause: Cause.pretty(cause) }),
),
Effect.ensuring(
Ref.update(pending, (m) => {
if (m.get(job.id) === PENDING_RESERVED || m.get(job.id) === fiber) m.delete(job.id)
return m
}),
),
),
)
yield* Ref.update(pending, (m) => {
if (m.get(job.id) === PENDING_RESERVED) m.set(job.id, fiber)
return m
})

const updated: CronJob = {
...job,
lastRunAt: current,
runCount: job.runCount + 1,
nextRunAt: job.nextRunAt + job.intervalMs,
}
yield* insertJob(updated)
}
})

yield* loop.pipe(Effect.forkScoped)

const add = (input: {
sessionID: string
prompt: string
intervalMs: number
agent?: string
model?: string
context?: unknown
}) =>
Effect.gen(function* () {
if (input.intervalMs < 60_000) {
return yield* new CronError({ message: "Interval must be at least 1 minute (60s)" })
}
const exists = yield* port.exists(input.sessionID)
if (!exists) {
return yield* new CronError({ message: `Session ${input.sessionID} not found` })
}
const now = yield* Clock.currentTimeMillis
const job: CronJob = {
id: crypto.randomUUID(),
sessionID: input.sessionID,
prompt: input.prompt,
intervalMs: input.intervalMs,
agent: input.agent,
model: input.model,
createdAt: now,
expiresAt: now + 7 * 24 * 60 * 60 * 1000,
nextRunAt: now,
runCount: 0,
context: input.context,
}
const inserted = yield* Ref.modify(heap, (jobs) => {
const count = jobs.filter((j) => j.sessionID === input.sessionID).length
if (count >= 50) return [false, jobs] as const
return [true, sortByNextRun([...jobs, job])] as const
})
if (!inserted) {
return yield* new CronError({ message: "Maximum 50 cron jobs per session" })
}
yield* Queue.offer(wakeQueue, undefined)
return job
})

const list = (sessionID: string) =>
Ref.get(heap).pipe(Effect.map((jobs) => jobs.filter((j) => j.sessionID === sessionID)))

const remove = (sessionID: string, jobId: string) =>
Effect.gen(function* () {
const before = yield* Ref.get(heap)
const removedIds = new Set(
before.filter((j) => j.sessionID === sessionID && (jobId === "all" || j.id === jobId)).map((j) => j.id),
)
const removed = yield* filterInPlace((j) => j.sessionID !== sessionID || (jobId !== "all" && j.id !== jobId))
yield* Ref.get(pending).pipe(
Effect.flatMap((m) =>
Effect.forEach([...m.entries()], ([id, fiber]) =>
removedIds.has(id) && fiber !== PENDING_RESERVED ? Fiber.interrupt(fiber).pipe(Effect.asVoid) : Effect.void,
),
),
)
yield* Queue.offer(wakeQueue, undefined)
return removed
})

return CronService.of({ add, list, remove })
})

export const layer = Layer.effect(CronService, make)
2 changes: 2 additions & 0 deletions packages/core/src/plugin/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
import schedulingContent from "./skill/scheduling.md" with { type: "text" }

export const CustomizeOpencodeContent = customizeOpencodeContent
export const SchedulingContent = schedulingContent

export const Plugin = define({
id: "skill",
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/plugin/skill/scheduling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
name: scheduling
description: Schedule recurring prompts with cron_add, list with cron_list, cancel with cron_delete. Use when you need recurring reminders, checks, or automated actions.
---

## Adding a schedule

Use `cron_add` to schedule a prompt:

```
cron_add(interval: "5m", prompt: "Check for new issues")
```

Interval format: `5m`, `1h`, `2h30m`, `90s`. Minimum 1m, max 7d lifespan, max 50 jobs/session.

## Listing

Use `cron_list` to see all scheduled jobs, in the following format:

| ID | Prompt | Interval | Run | Next |
| :------------ | :-------------------- | :------- | :-- | :--- |
| `e15f1b62...` | Write "Hello, World!" | 1m | 6 | ~1m |

## Cancelling

`cron_delete(id: "id")` for one, `cron_delete(id: "all")` for all.
77 changes: 77 additions & 0 deletions packages/core/test/cron/duration.test.ts
Loading
Loading