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
68 changes: 55 additions & 13 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ 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,
finishSchedulerGeneration,
isSchedulerGenerationCurrent,
waitForSchedulerPersistence,
scheduleTimer,
cancelReminder,
deleteStoredReminder,
} 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 +28,7 @@ const RemindersPlugin: Plugin = async (ctx) => {
reminders: new Map(),
timers: new Map(),
projectID: project.id,
generation,
}

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

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

for (const reminder of storedReminders) {
if (!isSchedulerGenerationCurrent(ctx, generation)) {
return {}
}
try {
ReminderSchema.parse(reminder)
} catch (error) {
logger.error(`[RemindersPlugin] Failed to restore reminder:`, error)
if (reminder.id) {
await deleteStoredReminder(reminder.id, ctx, state)
}
invalidCount++
continue
}

try {
// 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)
await deleteStoredReminder(reminder.id, ctx, state)
expiredCount++
continue
}

state.reminders.set(reminder.id, reminder)
await scheduleTimer(reminder, ctx, state, config)
const scheduled = await scheduleTimer(reminder, ctx, state, config, {
persist: false,
cleanupOnPersistenceFailure: false,
})
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 deleteStoredReminder(reminder.id, ctx, state)
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)
}
// Keep a previously valid stored reminder after a transient scheduling or
// persistence failure so a later initialization can retry it.
logger.error(`[RemindersPlugin] Failed to schedule stored reminder ${reminder.id}:`, error)
state.reminders.delete(reminder.id)
invalidCount++
}
}

await finishSchedulerGeneration(ctx, generation)
if (!isSchedulerGenerationCurrent(ctx, generation)) {
return {}
}

logger.info(
`[RemindersPlugin] Timer persistence validation completed: ${storedReminders.length} total, ${restoredCount} restored, ${expiredCount} expired, ${invalidCount} invalid, ${healthyCount} healthy`,
)

// 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 plugin generation clears timers and aborts active executions from the old generation
// - Reminder state persists to storage and is restored on next startup
// - Max reminders per project (50) bounds memory usage

Expand All @@ -105,11 +144,14 @@ 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)) {
cancelledCount++
}
}

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

Expand Down
Loading