Give reminder cancellation its own store and IPC path (V-719)

CancelReminder replaces the cancelled half of MarkReminder, which stays
delivery-only. Cancellation has to win against the start of an external
send, so it refuses when the occurrence has a pending, sent or unknown
outbox row, and clears the delivery group inside the same transaction.
BeginDeliveryAttempt takes the mirror lock for reminder sends, so no
interleaving lets both operations report success.

Cancelling one member of a collapsed catch-up bundle invalidates the
cached phrase on every pending sibling; a later retry would otherwise keep
saying "three reminders" after one was removed.

Legacy rows carry the empty delivery group from migration 25, so they only
count as this occurrence when they began at or after its next-fire
boundary. Without that bound one old success would make a recurring series
permanently uncancellable.

ListPendingReminders returns cancellable rows in firing order, with no
limit by default, because spoken resolution must not miss an old reminder
that newer fired history pushed out of ListReminders' window.

Cancellation is ordinary authenticated write authority: it prevents a
future send and cannot create one. cmd/e2eprobe drives both from outside.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:19:13 +04:00
parent 5b0b29dfad
commit 0b057df2a3
17 changed files with 846 additions and 38 deletions
+3 -1
View File
@@ -605,7 +605,8 @@ type idReq struct {
ID int64 `json:"id"`
}
// markReminderReq — pending→fired|cancelled.
// markReminderReq — pending→fired. Cancellation has its own transactional
// method because it must serialize against delivery attempts.
type markReminderReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
@@ -1000,6 +1001,7 @@ var (
ErrNudgeOutcome = errors.New("ipc: nudge already resolved")
ErrReminderNotFound = errors.New("ipc: reminder not found")
ErrReminderState = errors.New("ipc: reminder not in a mutable state")
ErrReminderInFlight = errors.New("ipc: reminder delivery already started")
ErrUnknownMethod = errors.New("ipc: unknown method")
ErrBadParams = errors.New("ipc: bad params")
// ErrForbidden — the caller's authority doesn't cover this call. The
+15
View File
@@ -74,6 +74,7 @@ var readOnlyMethods = map[Method]bool{
MethodSince: true,
MethodPresence: true,
MethodListReminders: true,
MethodListPendingReminders: true,
MethodRecentOutcomes: true,
MethodRecentFacts: true,
MethodRecentActiveFacts: true,
@@ -327,6 +328,8 @@ func hydrate(e *RpcError) error {
return fmt.Errorf("%w: %s", ErrReminderNotFound, e.Message)
case codeReminderState:
return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
case codeReminderInFlight:
return fmt.Errorf("%w: %s", ErrReminderInFlight, e.Message)
case codeToolNotFound:
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
case codeNoSuchTrace:
@@ -398,6 +401,10 @@ func (c *Client) MarkReminder(ctx context.Context, id int64, status string) erro
return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil)
}
func (c *Client) CancelReminder(ctx context.Context, id int64) error {
return c.call(ctx, MethodCancelReminder, idReq{ID: id}, nil)
}
func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
var out []Reminder
if err := c.call(ctx, MethodListReminders, nReq{N: n}, &out); err != nil {
@@ -406,6 +413,14 @@ func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
return out, nil
}
func (c *Client) ListPendingReminders(ctx context.Context, n int) ([]Reminder, error) {
var out []Reminder
if err := c.call(ctx, MethodListPendingReminders, nReq{N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
var r idResp
if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil {
+2
View File
@@ -42,7 +42,9 @@ type FactAPI interface {
type ReminderAPI interface {
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
CancelReminder(ctx context.Context, id int64) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
ListPendingReminders(ctx context.Context, n int) ([]Reminder, error)
}
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
+47 -2
View File
@@ -245,6 +245,9 @@ func TestStoreAPI_Direct(t *testing.T) {
if err := api.MarkReminder(ctx, 99999, "weird"); !errors.Is(err, ErrReminderState) {
t.Fatalf("MarkReminder weird: got %v, want ErrReminderState", err)
}
if err := api.CancelReminder(ctx, 99999); !errors.Is(err, ErrReminderNotFound) {
t.Fatalf("CancelReminder missing: got %v, want ErrReminderNotFound", err)
}
// resolve nonexistent nudge ⇒ ErrNudgeNotFound
if err := api.ResolveNudge(ctx, 99999, "acted", time.Now()); !errors.Is(err, ErrNudgeNotFound) {
t.Fatalf("ResolveNudge none: got %v, want ErrNudgeNotFound", err)
@@ -256,7 +259,7 @@ func TestStoreAPI_Direct(t *testing.T) {
// the test that catches the boundary bugs: param shape mismatch, sentinel
// code drift, dto mapping, framing interleaving.
func TestClient_E2E(t *testing.T) {
_, _, cli, _ := newServerWithStore(t)
_, _, cli, st := newServerWithStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
@@ -311,11 +314,31 @@ func TestClient_E2E(t *testing.T) {
t.Fatalf("Presence cold-start = %+v, want away/0", pres)
}
// reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState.
// reminder lifecycle: both pending rows cross the wire in firing order;
// one cancels and disappears from that read, while the other still follows
// the existing fired transition.
cancelID, err := cli.CreateReminder(ctx, now.Add(30*time.Minute), `{"text":"cancel me"}`, "")
if err != nil {
t.Fatalf("CreateReminder cancellation candidate: %v", err)
}
rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`, "")
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
pending, err := cli.ListPendingReminders(ctx, 0)
if err != nil || len(pending) != 2 || pending[0].ID != cancelID || pending[1].ID != rid {
t.Fatalf("ListPendingReminders = %+v, %v", pending, err)
}
if err := cli.CancelReminder(ctx, cancelID); err != nil {
t.Fatalf("CancelReminder: %v", err)
}
if err := cli.CancelReminder(ctx, cancelID); !errors.Is(err, ErrReminderState) {
t.Fatalf("CancelReminder twice: got %v, want ErrReminderState", err)
}
pending, err = cli.ListPendingReminders(ctx, 0)
if err != nil || len(pending) != 1 || pending[0].ID != rid {
t.Fatalf("pending after cancellation = %+v, %v", pending, err)
}
if err := cli.MarkReminder(ctx, rid, "fired"); err != nil {
t.Fatalf("MarkReminder fired: %v", err)
}
@@ -323,6 +346,28 @@ func TestClient_E2E(t *testing.T) {
t.Fatalf("MarkReminder twice: got %v, want ErrReminderState", err)
}
flightID, err := cli.CreateReminder(ctx, now.Add(2*time.Hour), `{"text":"already leaving"}`, "")
if err != nil {
t.Fatal(err)
}
storePending, err := st.ListPendingReminders(ctx, 0)
if err != nil || len(storePending) != 1 || storePending[0].ID != flightID {
t.Fatalf("store pending = %+v, %v", storePending, err)
}
const deliveryGroup = "reminder:ipc-in-flight"
if err := st.CacheReminderPhrase(ctx, storePending, deliveryGroup, "leaving", "leaving", "neutral"); err != nil {
t.Fatal(err)
}
if _, err := st.BeginDeliveryAttempt(ctx, "reminder", "", flightID, deliveryGroup, "telegram", "hash", now); err != nil {
t.Fatal(err)
}
if err := cli.MarkReminder(ctx, flightID, "cancelled"); !errors.Is(err, ErrReminderState) {
t.Fatalf("legacy MarkReminder cancellation bypass: got %v, want ErrReminderState", err)
}
if err := cli.CancelReminder(ctx, flightID); !errors.Is(err, ErrReminderInFlight) {
t.Fatalf("CancelReminder in flight: got %v, want ErrReminderInFlight", err)
}
// nudge lifecycle: record → resolve acted → resolve again ⇒ ErrNudgeOutcome.
nid, err := cli.RecordNudge(ctx, "water", "voice", "drink", now)
if err != nil {
+1
View File
@@ -30,6 +30,7 @@ var mapErrPairs = []struct {
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrReminderInFlight", store.ErrReminderInFlight, ErrReminderInFlight},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
+6
View File
@@ -414,9 +414,15 @@ var methodTable = map[Method]handlerFunc{
MethodMarkReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p markReminderReq) error {
return api.MarkReminder(ctx, p.ID, p.Status)
}),
MethodCancelReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p idReq) error {
return api.CancelReminder(ctx, p.ID)
}),
MethodListReminders: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
return api.ListReminders(ctx, p.N)
}),
MethodListPendingReminders: withParamsSlice(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
return api.ListPendingReminders(ctx, p.N)
}),
MethodRecordNudge: withParams(func(ctx context.Context, api CoreAPI, p recordNudgeReq) (idResp, error) {
id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
return idResp{ID: id}, err
+11
View File
@@ -77,6 +77,10 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) CancelReminder(ctx context.Context, id int64) error {
return mapErr(a.s.CancelReminder(ctx, id))
}
// mapRows carries a store read's error through mapErr and converts the rows to
// their wire shape. Every list method here is that one shape.
func mapRows[S any, W any](rows []S, err error, conv func(S) W) ([]W, error) {
@@ -95,6 +99,11 @@ func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error)
return mapRows(rs, err, toReminder)
}
func (a *storeAPI) ListPendingReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListPendingReminders(ctx, n)
return mapRows(rs, err, toReminder)
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
@@ -422,6 +431,8 @@ func mapErr(err error) error {
return ErrReminderNotFound
case errors.Is(err, store.ErrReminderState):
return ErrReminderState
case errors.Is(err, store.ErrReminderInFlight):
return ErrReminderInFlight
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
case errors.Is(err, store.ErrNoSuchTrace):
+6
View File
@@ -47,9 +47,15 @@ func (UnimplementedCoreAPI) CreateReminder(ctx context.Context, fire time.Time,
func (UnimplementedCoreAPI) MarkReminder(ctx context.Context, id int64, status string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) CancelReminder(ctx context.Context, id int64) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) ListPendingReminders(ctx context.Context, n int) ([]Reminder, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
return 0, ErrNotImplemented
}
+18 -13
View File
@@ -20,7 +20,9 @@ const (
MethodPresence Method = "presence"
MethodCreateReminder Method = "create_reminder"
MethodMarkReminder Method = "mark_reminder"
MethodCancelReminder Method = "cancel_reminder"
MethodListReminders Method = "list_reminders"
MethodListPendingReminders Method = "list_pending_reminders"
MethodRecordNudge Method = "record_nudge"
MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes"
@@ -118,19 +120,20 @@ func (e *RpcError) Error() string {
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
// sentinels in api.go 1:1. The string is the contract.
const (
codeNoFact = "no_fact"
codeConfidence = "confidence"
codeVoidsMissing = "voids_missing"
codeNudgeNotFound = "nudge_not_found"
codeNudgeOutcome = "nudge_outcome"
codeReminderMissing = "reminder_not_found"
codeReminderState = "reminder_state"
codeToolNotFound = "tool_not_found"
codeNoSuchTrace = "no_such_trace"
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeForbidden = "forbidden"
codeInternal = "internal"
codeNoFact = "no_fact"
codeConfidence = "confidence"
codeVoidsMissing = "voids_missing"
codeNudgeNotFound = "nudge_not_found"
codeNudgeOutcome = "nudge_outcome"
codeReminderMissing = "reminder_not_found"
codeReminderState = "reminder_state"
codeReminderInFlight = "reminder_in_flight"
codeToolNotFound = "tool_not_found"
codeNoSuchTrace = "no_such_trace"
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeForbidden = "forbidden"
codeInternal = "internal"
)
// codeOf maps a server-side sentinel to its wire code. Anything not matched is
@@ -160,6 +163,8 @@ func codeOf(err error) string {
return codeReminderMissing
case errors.Is(err, ErrReminderState):
return codeReminderState
case errors.Is(err, ErrReminderInFlight):
return codeReminderInFlight
case errors.Is(err, ErrToolNotFound):
return codeToolNotFound
case errors.Is(err, ErrNoSuchTrace):