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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,56 @@ OpenCode will automatically load the plugin when you start the TUI.
3. **Execution** - Sends prompt to session when timer fires
4. **Cleanup** - Removes reminders when session deleted

## Multi-Process Semantics

When independent OpenCode server processes load the same reminder directory on one host, an
occurrence lease prevents them from submitting the same prompt concurrently. Leases and reminder
JSON updates use same-directory atomic filesystem operations. A losing process keeps an unref'd
reconciliation timer, reloads durable state, and schedules the winner's next recurring occurrence.
Post-prompt recurrence updates and every plugin cancellation/restore cleanup share a short-lived
per-reminder mutation lock, so the durable read-and-transition cannot race a deletion. The lock is
not held while the prompt API runs.

Reconciliation retries use capped exponential backoff. A recurring occurrence found overdue during
startup retains its skip-overdue policy across read or atomic-write failures, so unchanged old JSON
is never reinterpreted as a normal occurrence and prompted. Normal runtime failures retain the
occurrence for an at-least-once retry; the retry history resets after durable state advances into the
future.

Within one server process, plugin generations also fence hot reloads. Reinitialization clears old
timers, aborts active requests, waits for mutations already in progress, and prevents stale timer or
reconciliation callbacks from changing replacement state. Cancellation aborts the active local
request immediately, then uses the same mutation lock as cross-process transitions.

Reconciliation reads are fenced per reminder and revalidated under the mutation lock. Once local
cancellation returns, a read that completed before deletion cannot restore stale list state or a
timer, even if its continuation resumes later.

If durable cancellation fails, the plugin re-reads authoritative state through the same fence and
lock protocol, restores a delayed timer, and returns the original deletion error. The reminder stays
visible and can be cancelled again without restarting the server.

This is deliberately not a claim of crash-proof exactly-once delivery. Any process, storage, or lock
failure after the prompt API accepts a request but before its durable transition can lead to retry.
That is at-least-once crash semantics; true exactly-once delivery requires idempotency support in the
prompt API. Likewise, cancellation does not hold a lock across prompting: if an owner has already
passed durable validation, a prompt can still be submitted even when cancellation returns first.
The post-prompt durable transition remains fenced, so cancellation cannot intentionally resurrect or
reschedule the reminder after deletion.

The lease protocol assumes same-host processes and a local filesystem with atomic hard links,
exclusive create, and rename. It is not intended to coordinate hosts or provide NFS/distributed
filesystem safety. Linux detects a dead or reused PID using `/proc/<pid>/stat` process start time.
Other platforms may acquire leases normally, but only reclaim a lease when `process.kill(pid, 0)`
reports `ESRCH`; a reused PID is conservatively treated as live and can leave a stale lease until
manual cleanup rather than risk a duplicate prompt.

A stale `.recover` guard is never reclaimed automatically. This avoids a replacement race that could
delete a live guard or lease, at the cost of manual cleanup after a recovery-process crash.

Reminders capture the creating tool context's optional agent name and pass it back to the prompt API
when they execute. Existing reminder files without `agent` remain valid and omit the prompt field.

## Data Storage

Reminders stored in:
Expand Down
68 changes: 49 additions & 19 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import { Plugin } from "@opencode-ai/plugin"
import { logger } from "./logger"
import type { State, PluginConfig } from "./types"
import { ReminderSchema } from "./types"
import { getStorageDir, deleteReminder, listReminders } from "./storage"
import { scheduleTimer, cancelReminder } from "./scheduler"
import { getStorageDir, listReminders } from "./storage"
import {
beginSchedulerGeneration,
isSchedulerGenerationCurrent,
waitForSchedulerMutations,
scheduleTimer,
cancelReminder,
cleanupReminderSnapshot,
} from "./scheduler"
import { createReminderAddTool } from "./tools/reminderadd"
import { createReminderListTool } from "./tools/reminderlist"
import { createReminderRemoveTool } from "./tools/reminderremove"

const RemindersPlugin: Plugin = async (ctx) => {
const { project } = ctx
const generation = beginSchedulerGeneration(ctx)

logger.info(`[RemindersPlugin] Initializing for project ${project.id}`)

Expand All @@ -19,6 +27,7 @@ const RemindersPlugin: Plugin = async (ctx) => {
reminders: new Map(),
timers: new Map(),
projectID: project.id,
generation,
}

// Configuration with defaults
Expand All @@ -38,42 +47,56 @@ const RemindersPlugin: Plugin = async (ctx) => {
let invalidCount = 0
let healthyCount = 0

if (!(await waitForSchedulerMutations(ctx, generation))) return {}
const storedReminders = await listReminders(ctx)

for (const reminder of storedReminders) {
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}

for (const snapshot of storedReminders) {
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
const parsed = ReminderSchema.safeParse(snapshot)
// Persisted filenames are derived from IDs. Only generated UUID snapshots with safe
// timestamps may be re-opened or cleaned; malformed files are left for manual repair.
if (!parsed.success || !isSafeRestoredReminder(parsed.data)) {
logger.error(`[RemindersPlugin] Invalid restored reminder left untouched`)
invalidCount++
continue
}
const reminder = parsed.data
try {
ReminderSchema.parse(reminder)

// Skip session validation during startup - it may not be ready yet
// Session cleanup will happen via event hook when session is actually deleted

if (reminder.time.nextExecution + gracePeriod < now) {
logger.info(`[RemindersPlugin] Reminder ${reminder.id} expired, removing`)
await deleteReminder(reminder.id, ctx)
expiredCount++
if (await cleanupReminderSnapshot(reminder, ctx, state, config)) {
logger.info(`[RemindersPlugin] Reminder ${reminder.id} expired, removing`)
expiredCount++
}
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
continue
}

state.reminders.set(reminder.id, reminder)
await scheduleTimer(reminder, ctx, state, config)
const scheduled = await scheduleTimer(reminder, ctx, state, config, {
persist: false,
skipOverdue: true,
})
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}

// Validate timer was actually created (timer health validation)
const isHealthy = state.timers.has(reminder.id)
const isHealthy = scheduled && state.timers.has(reminder.id)
if (isHealthy) {
restoredCount++
healthyCount++
logger.info(`[RemindersPlugin] Restored and validated reminder ${reminder.id}`)
} else {
await deleteReminder(reminder.id, ctx)
await cleanupReminderSnapshot(reminder, ctx, state, config)
state.reminders.delete(reminder.id)
invalidCount++
logger.error(`[RemindersPlugin] Timer restoration failed for ${reminder.id}, cancelled reminder`)
}
} catch (error) {
logger.error(`[RemindersPlugin] Failed to restore reminder:`, error)
if (reminder.id) {
await deleteReminder(reminder.id, ctx)
}
await cleanupReminderSnapshot(reminder, ctx, state, config)
invalidCount++
}
}
Expand All @@ -85,7 +108,7 @@ const RemindersPlugin: Plugin = async (ctx) => {
// Cleanup considerations:
// - timer.unref() allows clean exit without blocking the process
// - Plugin API currently has no cleanup hook for graceful shutdown
// - On hot-reload, old timers may fire once but won't be rescheduled (state is in new instance)
// - A replacement generation clears timers and aborts active executions from the old instance
// - Reminder state persists to storage and is restored on next startup
// - Max reminders per project (50) bounds memory usage

Expand All @@ -105,20 +128,27 @@ const RemindersPlugin: Plugin = async (ctx) => {

const remindersToCancel = Array.from(state.reminders.values()).filter((r) => r.sessionID === sessionID)

let cancelledCount = 0
for (const reminder of remindersToCancel) {
await cancelReminder(reminder.id, ctx, state)
if (await cancelReminder(reminder.id, ctx, state, config)) cancelledCount++
}

logger.info(`[RemindersPlugin] Cancelled ${remindersToCancel.length} reminders for session ${sessionID}`)
logger.info(`[RemindersPlugin] Cancelled ${cancelledCount} reminders for session ${sessionID}`)
}
},

tool: {
reminderadd: createReminderAddTool(ctx, state, () => config),
reminderlist: createReminderListTool(state),
reminderremove: createReminderRemoveTool(ctx, state),
reminderremove: createReminderRemoveTool(ctx, state, () => config),
},
}
}

function isSafeRestoredReminder(reminder: { id: string; time: { nextExecution: number; created: number } }): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reminder.id)
&& Number.isSafeInteger(reminder.time.created)
&& Number.isSafeInteger(reminder.time.nextExecution)
}

export default RemindersPlugin
175 changes: 175 additions & 0 deletions leases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { link, open, readFile, rm } from "node:fs/promises"
import path from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { getStorageDir } from "./storage"

type Owner = {
pid: number
marker: string
start?: string
token: string
}

export type ExecutionLease = { release: () => Promise<void> }
export type LeaseAcquisition =
| { status: "acquired"; lease: ExecutionLease }
| { status: "contended" }
| { status: "error"; error: unknown }

const processMarker = crypto.randomUUID()
const MUTATION_LOCK_TIMEOUT_MS = 30_000
const MUTATION_LOCK_RETRY_MS = 25

function ownerText(owner: Owner): string {
return `pid=${owner.pid}\nmarker=${owner.marker}\n${owner.start ? `start=${owner.start}\n` : ""}token=${owner.token}\n`
}

function parseOwner(contents: string): Owner | null {
const values = new Map(
contents.split("\n").flatMap((line) => {
const index = line.indexOf("=")
return index > 0 ? [[line.slice(0, index), line.slice(index + 1)]] : []
}),
)
const pid = Number(values.get("pid"))
const marker = values.get("marker")
const token = values.get("token")
const start = values.get("start")
if (!Number.isSafeInteger(pid) || pid <= 0 || !marker || !token) return null
return { pid, marker, token, ...(start ? { start } : {}) }
}

async function linuxProcessStart(pid: number): Promise<string | null> {
try {
const stat = await readFile(`/proc/${pid}/stat`, "utf8")
const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/)
return fields[19] ?? null
} catch {
return null
}
}

async function ownerIsDemonstrablyDead(owner: Owner): Promise<boolean> {
if (process.platform === "linux") {
if (!owner.start) return false
const currentStart = await linuxProcessStart(owner.pid)
if (currentStart !== null) return currentStart !== owner.start
}

// A missing or unreadable /proc entry is not by itself proof of death: it may
// reflect permissions, descriptor exhaustion, or a transient I/O failure.
// kill(pid, 0) lets us reclaim only when the OS specifically reports ESRCH.
try {
process.kill(owner.pid, 0)
return false
} catch (error: any) {
return error?.code === "ESRCH"
}
}

async function createOwnerFile(file: string, owner: Owner): Promise<boolean> {
const candidate = `${file}.${owner.token}.candidate`
let handle
try {
handle = await open(candidate, "wx", 0o600)
await handle.writeFile(ownerText(owner))
await handle.close()
handle = undefined
await link(candidate, file)
return true
} catch (error: any) {
if (error?.code === "EEXIST") return false
throw error
} finally {
await handle?.close()
await rm(candidate, { force: true })
}
}

async function readOwner(file: string): Promise<Owner | null> {
try {
return parseOwner(await readFile(file, "utf8"))
} catch {
return null
}
}

async function recoverDeadOwnerFile(file: string): Promise<boolean> {
const owner = await readOwner(file)
if (!owner || !(await ownerIsDemonstrablyDead(owner))) return false
await rm(file, { force: true })
return true
}

async function removeStaleLease(lease: string, owner: Owner): Promise<boolean> {
const recovery = `${lease}.recover`
// Never reclaim a recovery guard. A crashed recoverer can require manual cleanup,
// but reclaiming it has a check/unlink race that could delete a live replacement guard.
if (!(await createOwnerFile(recovery, owner))) return false

try {
return await recoverDeadOwnerFile(lease)
} finally {
const current = await readOwner(recovery)
if (current?.token === owner.token) await rm(recovery, { force: true })
}
}

async function acquireOwnerLock(file: string, owner: Owner): Promise<LeaseAcquisition> {
for (let attempt = 0; attempt < 2; attempt++) {
if (await createOwnerFile(file, owner)) {
let released = false
return {
status: "acquired",
lease: {
async release() {
if (released) return
released = true
const current = await readOwner(file)
if (current?.token === owner.token) await rm(file, { force: true })
},
},
}
}
if (attempt === 0 && await removeStaleLease(file, owner)) continue
return { status: "contended" }
}
return { status: "contended" }
}

async function newOwner(): Promise<Owner> {
const start = process.platform === "linux" ? await linuxProcessStart(process.pid) : undefined
return { pid: process.pid, marker: processMarker, token: crypto.randomUUID(), ...(start ? { start } : {}) }
}

export async function acquireExecutionLease(
reminderID: string,
occurrence: number,
ctx: PluginInput,
): Promise<LeaseAcquisition> {
try {
const dir = await getStorageDir(ctx)
const leasePath = path.join(dir, `${reminderID}.${occurrence}.lease`)
return await acquireOwnerLock(leasePath, await newOwner())
} catch (error) {
return { status: "error", error }
}
}

export async function acquireMutationLock(reminderID: string, ctx: PluginInput): Promise<ExecutionLease> {
const dir = await getStorageDir(ctx)
const lockPath = path.join(dir, `${reminderID}.mutation.lock`)
const owner = await newOwner()
const deadline = Date.now() + MUTATION_LOCK_TIMEOUT_MS

while (Date.now() < deadline) {
const acquisition = await acquireOwnerLock(lockPath, owner)
if (acquisition.status === "acquired") return acquisition.lease
if (acquisition.status === "error") throw acquisition.error

// Mutations are short and never encompass prompt execution. Waiting makes cancellation
// serializable with an in-flight transition rather than silently abandoning it.
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(MUTATION_LOCK_RETRY_MS, deadline - Date.now())))
}
throw new Error(`Timed out acquiring mutation lock for reminder ${reminderID}`)
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-reminders",
"version": "1.0.1",
"version": "1.0.2",
"description": "Reminder plugin for OpenCode - schedule actions to run at intervals",
"keywords": ["opencode", "reactive", "plugin", "reminders", "scheduler"],
"author": "zelenkovsky",
Expand All @@ -23,6 +23,7 @@
"types.ts",
"storage.ts",
"scheduler.ts",
"leases.ts",
"logger.ts",
"tools/",
"README.md"
Expand Down
Loading