Skip to content

Commit 1936f49

Browse files
committed
fix: restore reminders after cancellation failure
1 parent fa9fc6f commit 1936f49

7 files changed

Lines changed: 110 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ Reconciliation reads are fenced per reminder and revalidated under the mutation
9090
cancellation returns, a read that completed before deletion cannot restore stale list state or a
9191
timer, even if its continuation resumes later.
9292

93+
If durable cancellation fails, the plugin re-reads authoritative state through the same fence and
94+
lock protocol, restores a delayed timer, and returns the original deletion error. The reminder stays
95+
visible and can be cancelled again without restarting the server.
96+
9397
This is deliberately not a claim of crash-proof exactly-once delivery. Any process, storage, or lock
9498
failure after the prompt API accepts a request but before its durable transition can lead to retry.
9599
That is at-least-once crash semantics; true exactly-once delivery requires idempotency support in the

‎index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const RemindersPlugin: Plugin = async (ctx) => {
130130

131131
let cancelledCount = 0
132132
for (const reminder of remindersToCancel) {
133-
if (await cancelReminder(reminder.id, ctx, state)) cancelledCount++
133+
if (await cancelReminder(reminder.id, ctx, state, config)) cancelledCount++
134134
}
135135

136136
logger.info(`[RemindersPlugin] Cancelled ${cancelledCount} reminders for session ${sessionID}`)
@@ -140,7 +140,7 @@ const RemindersPlugin: Plugin = async (ctx) => {
140140
tool: {
141141
reminderadd: createReminderAddTool(ctx, state, () => config),
142142
reminderlist: createReminderListTool(state),
143-
reminderremove: createReminderRemoveTool(ctx, state),
143+
reminderremove: createReminderRemoveTool(ctx, state, () => config),
144144
},
145145
}
146146
}

‎scheduler.ts‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ type ProjectRuntime = {
2222
type ReminderFence = {
2323
version: number
2424
reconciliations: number
25+
deletions: number
2526
tombstone: boolean
2627
}
2728

2829
type ScheduleOptions = {
2930
persist?: boolean
3031
skipOverdue?: boolean
3132
retryAttempt?: number
33+
minimumDelayMs?: number
3234
}
3335

3436
const RECONCILIATION_DELAY_MS = 250
@@ -57,6 +59,7 @@ function getRuntime(ctx: PluginInput): ProjectRuntime {
5759
runtimes.set(key, runtime)
5860
}
5961
runtime.fences ??= new Map()
62+
for (const fence of runtime.fences.values()) fence.deletions ??= 0
6063
return runtime
6164
}
6265

@@ -139,7 +142,7 @@ function clearStateReminder(id: string, state: State): void {
139142
function beginReconciliation(id: string, runtime: ProjectRuntime): { fence: ReminderFence; version: number } {
140143
let fence = runtime.fences.get(id)
141144
if (!fence) {
142-
fence = { version: 0, reconciliations: 0, tombstone: false }
145+
fence = { version: 0, reconciliations: 0, deletions: 0, tombstone: false }
143146
runtime.fences.set(id, fence)
144147
}
145148
fence.reconciliations++
@@ -160,21 +163,31 @@ function finishReconciliation(id: string, runtime: ProjectRuntime, fence: Remind
160163
retireFence(id, runtime, fence)
161164
}
162165

163-
function invalidateReminder(id: string, runtime: ProjectRuntime): ReminderFence {
166+
function invalidateReminder(
167+
id: string,
168+
runtime: ProjectRuntime,
169+
): { fence: ReminderFence; version: number } {
164170
let fence = runtime.fences.get(id)
165171
if (!fence) {
166-
fence = { version: 0, reconciliations: 0, tombstone: false }
172+
fence = { version: 0, reconciliations: 0, deletions: 0, tombstone: false }
167173
runtime.fences.set(id, fence)
168174
}
169175
fence.version++
176+
fence.deletions++
170177
fence.tombstone = true
171-
return fence
178+
return { fence, version: fence.version }
179+
}
180+
181+
function finishInvalidation(id: string, runtime: ProjectRuntime, fence: ReminderFence): void {
182+
fence.deletions--
183+
retireFence(id, runtime, fence)
172184
}
173185

174186
function retireFence(id: string, runtime: ProjectRuntime, fence: ReminderFence): void {
175187
if (
176188
runtime.fences.get(id) === fence
177189
&& fence.reconciliations === 0
190+
&& fence.deletions === 0
178191
&& !runtime.executions.has(id)
179192
) {
180193
runtime.fences.delete(id)
@@ -183,17 +196,17 @@ function retireFence(id: string, runtime: ProjectRuntime, fence: ReminderFence):
183196

184197
async function deleteReminderLocked(id: string, ctx: PluginInput, state: State): Promise<void> {
185198
const runtime = getRuntime(ctx)
186-
const fence = invalidateReminder(id, runtime)
199+
const { fence, version } = invalidateReminder(id, runtime)
187200
const execution = stopLocalExecution(id, runtime)
188201
clearStateReminder(id, state)
189202
if (execution && execution.state !== state) clearStateReminder(id, execution.state)
190203
try {
191204
await deleteReminder(id, ctx)
192205
} catch (error) {
193-
fence.tombstone = false
206+
if (runtime.fences.get(id) === fence && fence.version === version) fence.tombstone = false
194207
throw error
195208
} finally {
196-
retireFence(id, runtime, fence)
209+
finishInvalidation(id, runtime, fence)
197210
}
198211
}
199212

@@ -243,7 +256,7 @@ export async function scheduleTimer(
243256
}
244257

245258
const now = Date.now()
246-
const delay = Math.max(0, reminder.time.nextExecution - now)
259+
const delay = Math.max(options.minimumDelayMs ?? 0, reminder.time.nextExecution - now, 0)
247260
const skipOverdue = options.skipOverdue === true && reminder.time.nextExecution < now
248261
const retryAttempt = reminder.time.nextExecution < now ? options.retryAttempt ?? 0 : 0
249262
const timer = setTimeout(async () => {
@@ -292,6 +305,7 @@ function scheduleReconciliation(
292305
await reconcileReminder(id, ctx, state, config, {
293306
skipOverdue: options.skipOverdue,
294307
retryAttempt,
308+
minimumDelayMs: options.minimumDelayMs,
295309
})
296310
}, delay)
297311
timer.unref()
@@ -339,6 +353,7 @@ export async function reconcileReminder(
339353
persist: false,
340354
skipOverdue: options.skipOverdue,
341355
retryAttempt: options.retryAttempt,
356+
minimumDelayMs: options.minimumDelayMs,
342357
})
343358
})
344359
} catch (error) {
@@ -565,10 +580,16 @@ export async function executeReminder(
565580
}
566581
}
567582

568-
export async function cancelReminder(id: string, ctx: PluginInput, state: State): Promise<boolean> {
583+
export async function cancelReminder(
584+
id: string,
585+
ctx: PluginInput,
586+
state: State,
587+
config: PluginConfig,
588+
): Promise<boolean> {
569589
const runtime = getRuntime(ctx)
570-
const fence = invalidateReminder(id, runtime)
590+
const { fence, version } = invalidateReminder(id, runtime)
571591
const execution = stopLocalExecution(id, runtime)
592+
const rollbackState = execution?.state ?? state
572593
clearStateReminder(id, state)
573594
if (execution && execution.state !== state) clearStateReminder(id, execution.state)
574595
try {
@@ -578,13 +599,23 @@ export async function cancelReminder(id: string, ctx: PluginInput, state: State)
578599
clearStateReminder(id, state)
579600
if (replacement && replacement.state !== state) clearStateReminder(id, replacement.state)
580601
})
581-
retireFence(id, runtime, fence)
602+
finishInvalidation(id, runtime, fence)
582603
logger.info(`Reminder ${id} cancelled`)
583604
return true
584605
} catch (error) {
585-
fence.tombstone = false
586-
retireFence(id, runtime, fence)
606+
const ownsFence = runtime.fences.get(id) === fence && fence.version === version
607+
if (ownsFence) fence.tombstone = false
608+
finishInvalidation(id, runtime, fence)
587609
logger.error(`Failed to cancel reminder ${id}:`, error)
610+
if (ownsFence) {
611+
try {
612+
await reconcileReminder(id, ctx, rollbackState, config, {
613+
minimumDelayMs: RECONCILIATION_DELAY_MS,
614+
})
615+
} catch (rollbackError) {
616+
logger.error(`Failed to restore reminder ${id} after cancellation failure:`, rollbackError)
617+
}
618+
}
588619
throw error
589620
}
590621
}

‎test/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ responsible for closing their children during cleanup.
1111

1212
Cancellation tests also pause a completed reconciliation read, finish durable cancellation, and
1313
then release the stale continuation to verify it cannot restore local state or a timer.
14+
Failed-deletion coverage verifies that authoritative state and a future timer are restored before
15+
the original error is returned, allowing a subsequent cancellation attempt to succeed.
1416

1517
The suite intentionally does not claim exact-once prompt delivery across a crash or storage failure
1618
after prompt acceptance. It tests the plugin's same-host coordination and reconciliation behavior.

‎test/integration.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ describe("Integration Tests", () => {
264264
await waitFor(async () => (await loadReminder(reminder.id, ctx))!.time.nextExecution > Date.now())
265265
expect(prompts).toBeLessThanOrEqual(4)
266266
expect(state.timers.has(reminder.id)).toBe(true)
267-
await cancelReminder(reminder.id, ctx, state)
267+
await cancelReminder(reminder.id, ctx, state, config)
268268
})
269269

270270
test("reminderlist tool returns empty for new session", async () => {

‎test/scheduler.test.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,19 +164,62 @@ describe("Scheduler", () => {
164164
expect(state.timers.has(reminder.id)).toBe(true)
165165
expect(state.reminders.has(reminder.id)).toBe(true)
166166

167-
await cancelReminder(reminder.id, ctx, state)
167+
await cancelReminder(reminder.id, ctx, state, config)
168168

169169
expect(state.timers.has(reminder.id)).toBe(false)
170170
expect(state.reminders.has(reminder.id)).toBe(false)
171171
})
172172

173173
test("cancelReminder handles non-existent reminder gracefully", async () => {
174-
await cancelReminder("non-existent", ctx, state)
174+
await cancelReminder("non-existent", ctx, state, config)
175175

176176
expect(state.timers.size).toBe(0)
177177
expect(state.reminders.size).toBe(0)
178178
})
179179

180+
test("failed durable cancellation restores authoritative state for retry", async () => {
181+
const reminder: Reminder = {
182+
id: "rem-cancel-delete-failure",
183+
sessionID: "ses-test",
184+
projectID: state.projectID,
185+
type: "recurring",
186+
interval: 60000,
187+
originalPrompt: "remain retryable",
188+
userDescription: "Cancellation delete failure",
189+
time: { created: Date.now(), nextExecution: Date.now() + 60000 },
190+
status: "active",
191+
}
192+
let prompts = 0
193+
;(ctx.client.session.prompt as any) = async () => {
194+
prompts++
195+
return { data: {}, error: undefined, response: {} }
196+
}
197+
state.reminders.set(reminder.id, reminder)
198+
await scheduleTimer(reminder, ctx, state, config)
199+
200+
const deletionError = Object.assign(new Error("simulated reminder deletion failure"), { code: "EIO" })
201+
const rmFile = fsPromises.rm
202+
const remove = spyOn(fsPromises, "rm").mockImplementation((async (file: any, options: any) => {
203+
if (String(file).endsWith(`${reminder.id}.json`)) throw deletionError
204+
return rmFile(file, options)
205+
}) as any)
206+
try {
207+
await expect(cancelReminder(reminder.id, ctx, state, config)).rejects.toBe(deletionError)
208+
} finally {
209+
remove.mockRestore()
210+
}
211+
212+
expect(await loadReminder(reminder.id, ctx)).toEqual(reminder)
213+
expect(state.reminders.get(reminder.id)).toEqual(reminder)
214+
expect(state.timers.has(reminder.id)).toBe(true)
215+
expect(prompts).toBe(0)
216+
217+
await cancelReminder(reminder.id, ctx, state, config)
218+
expect(await loadReminder(reminder.id, ctx)).toBeNull()
219+
expect(state.reminders.has(reminder.id)).toBe(false)
220+
expect(state.timers.has(reminder.id)).toBe(false)
221+
})
222+
180223
test("executeReminder calls session prompt", async () => {
181224
let promptCalled = false
182225
let capturedPrompt = ""
@@ -428,7 +471,7 @@ describe("Scheduler", () => {
428471
const execution = executeReminder(reminder, ctx, state, config)
429472
await promptStarted
430473
const lock = await acquireMutationLock(reminder.id, ctx)
431-
const cancellation = cancelReminder(reminder.id, ctx, state)
474+
const cancellation = cancelReminder(reminder.id, ctx, state, config)
432475
await lock.release()
433476
await cancellation
434477
finish()
@@ -456,7 +499,7 @@ describe("Scheduler", () => {
456499
await executeReminder(reminder, ctx, state, config)
457500
expect(await loadReminder(reminder.id, ctx)).not.toBeNull()
458501

459-
await cancelReminder(reminder.id, ctx, state)
502+
await cancelReminder(reminder.id, ctx, state, config)
460503
expect(await loadReminder(reminder.id, ctx)).toBeNull()
461504
expect(state.reminders.has(reminder.id)).toBe(false)
462505
expect(state.timers.has(reminder.id)).toBe(false)
@@ -630,7 +673,7 @@ describe("Scheduler", () => {
630673
try {
631674
const reconciliation = reconcileReminder(reminder.id, ctx, state, config)
632675
await readCompleted.promise
633-
await cancelReminder(reminder.id, ctx, state)
676+
await cancelReminder(reminder.id, ctx, state, config)
634677

635678
expect(state.reminders.has(reminder.id)).toBe(false)
636679
expect(state.timers.has(reminder.id)).toBe(false)
@@ -724,7 +767,7 @@ describe("Scheduler", () => {
724767
await scheduleTimer(reminder, ctx, state, config)
725768

726769
const signal = await promptStarted.promise
727-
await cancelReminder(reminder.id, ctx, state)
770+
await cancelReminder(reminder.id, ctx, state, config)
728771
finishPrompt.resolve()
729772
const directory = await getStorageDir(ctx)
730773
await waitFor(async () => {
@@ -780,7 +823,7 @@ describe("Scheduler", () => {
780823
expect((await loadReminder(reminder.id, ctx))?.time.nextExecution).toBe(
781824
replacementReminder.time.nextExecution,
782825
)
783-
await cancelReminder(reminder.id, ctx, replacement)
826+
await cancelReminder(reminder.id, ctx, replacement, config)
784827
})
785828

786829
test("forwards a captured agent and omits it for legacy reminders", async () => {

‎tools/reminderremove.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { tool, type PluginInput } from "@opencode-ai/plugin"
2-
import type { State } from "../types"
2+
import type { PluginConfig, State } from "../types"
33
import { cancelReminder } from "../scheduler"
44
import DESCRIPTION from "./reminderremove.txt"
55
import { logger } from "../logger"
66

7-
export function createReminderRemoveTool(ctx: PluginInput, state: State) {
7+
export function createReminderRemoveTool(
8+
ctx: PluginInput,
9+
state: State,
10+
getConfig: () => PluginConfig,
11+
) {
812
return tool({
913
description: DESCRIPTION,
1014

@@ -33,7 +37,7 @@ export function createReminderRemoveTool(ctx: PluginInput, state: State) {
3337
// Cancel all matching reminders
3438
const cancelledDescriptions: string[] = []
3539
for (const reminder of matches) {
36-
if (await cancelReminder(reminder.id, ctx, state)) {
40+
if (await cancelReminder(reminder.id, ctx, state, getConfig())) {
3741
cancelledDescriptions.push(reminder.userDescription)
3842
logger.info(`[RemindersPlugin] Cancelled reminder ${reminder.id} via user request`)
3943
}

0 commit comments

Comments
 (0)