oficina / calendar Public

reminders: the process delivers on its own once the platform wakes it

A reminder due at 09:50 went at 12:00. The platform did its part: it
asked NextDue at sleep, woke the process at 09:50:28, and then — as
its own activator comment says — left delivery to "the instance's own
scheduler". This app had none. It drained on the 6-hour tick and on
signed-in page views, so the woken process sat through its idle
window, slept, was re-woken every quarter hour for the same past due
time, and sent nothing until the tick.

App.Deliver is the scheduler the platform expected: it drains on
start, then sleeps on NextDue — the same answer the platform sleeps
on, capped at a minute so work queued by a request is noticed — until
main is told to stop after Serve returns. It never touches the request
path, so it cannot reset the idle window or pin the process awake, and
concurrent drains are safe because every queue row is claimed by
identity.

TestTheProcessDeliversWithoutARequest sends one reminder already due
and one due two seconds in, with no tick and no request. AGENTS.md
and the design record the wake as request-less.

The deferred-mail test picks a day with five free slots rather than
assuming today has them; at 23:00 it did not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YA6MJMXWMNfbMhM1UbvKry
Paul Campbell pushed by paul@keymail.dev 310dbf0e1d97bd516a80ebed7c96adb42ed83540 parent 2074881
8 files changed, +214 −17
  • AGENTS.md +8 −0
  • cmd/calendar/main.go +15 −1
  • docs/superpowers/specs/2026-09-02-calendar-design.md +7 −1
  • internal/calendar/deliver.go +77 −0
  • internal/calendar/deliver_test.go +25 −0
  • internal/calendartest/booking_http_test.go +22 −11
  • internal/calendartest/booking_review2_test.go +1 −4
  • internal/calendartest/resident_test.go +59 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 04cd933..c0994b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -154,6 +154,14 @@ relying on remembering one, make it a test instead.
**Ticks**
- **A `time.Ticker` is not a scheduler.** The instance hibernates.
+- **The platform's due-time wake is REQUEST-LESS.** It spawns the
+ process at `NextDue` and sends nothing; "the instance's own scheduler
+ delivers" is the activator's own comment. `App.Deliver` is that
+ scheduler: it drains on start and sleeps on `NextDue` while the
+ process is resident, and `main` runs it beside `Serve`. Delivery
+ must never depend on a tick or a page view arriving.
+ `TestTheProcessDeliversWithoutARequest` stands in front of it. The
+ first deploy without it delivered a 09:50 reminder at 12:00.
- **`NextDue` is the earliest CLAIMABLE instant** across reminders, hold
expiries, owed booking mail and IP-forgets. New scheduled work goes
here, never in a goroutine. Cheap work runs before delivery;
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go
index 78a4e27..ddafc10 100644
--- a/cmd/calendar/main.go
+++ b/cmd/calendar/main.go
@@ -13,6 +13,7 @@
package main
import (
+ "context"
"log/slog"
"os"
"strings"
@@ -83,7 +84,20 @@ func main() {
// The same page a handler's own error path renders, so a panic and
// a handled failure look identical to whoever hits one.
opts.ErrorPage = calendar.ErrorPage
- if err := rastrillo.Serve(opts); err != nil {
+
+ // The other half of the wake. The platform spawns the process at
+ // NextDue and sends it no request; delivery is the process's own
+ // job from the moment it is up until it is hibernated. Serve
+ // returns on SIGTERM, after which the loop is told to stop and
+ // waited for, so a drain in flight finishes its bookkeeping before
+ // the database closes.
+ ctx, stop := context.WithCancel(context.Background())
+ delivered := make(chan struct{})
+ go func() { defer close(delivered); app.Deliver(ctx) }()
+ err = rastrillo.Serve(opts)
+ stop()
+ <-delivered
+ if err != nil {
logger.Error("serve failed", "err", err)
os.Exit(1)
}
diff --git a/docs/superpowers/specs/2026-09-02-calendar-design.md b/docs/superpowers/specs/2026-09-02-calendar-design.md
index 50449ba..2950719 100644
--- a/docs/superpowers/specs/2026-09-02-calendar-design.md
+++ b/docs/superpowers/specs/2026-09-02-calendar-design.md
@@ -173,7 +173,13 @@ A forever-daily standup costs one row; firing it computes the next.
1. `Options.NextDue` answers the activator's poll with
`MIN(due_at)`. The platform hibernates knowing when to wake, and
- wakes the process for it. This is the precision path.
+ wakes the process for it. This is the precision path — and the wake
+ is request-less: the activator spawns the process and sends it
+ nothing. `App.Deliver`, the process's resident scheduler, drains on
+ start and then sleeps on `NextDue` until it is hibernated, so what
+ the wake was for goes at once. (Added 2026-09-10: without it the
+ wake put the process up for a quarter hour, sent nothing, and the
+ 6-hour tick delivered two hours late.)
2. The declared schedule, **every 6 hours**, POSTs `/jobs/reminders`.
Six and not five minutes: `CARLOS_HIBERNATE_IDLE` defaults to 15
minutes, so every tick buys a quarter hour of billed residency — a
diff --git a/internal/calendar/deliver.go b/internal/calendar/deliver.go
new file mode 100644
index 0000000..b7c4b15
--- /dev/null
+++ b/internal/calendar/deliver.go
@@ -0,0 +1,77 @@
+package calendar
+
+import (
+ "context"
+ "time"
+)
+
+// residentRecheck caps how long the resident scheduler sleeps between
+// looks at NextDue. Work queued by a request while the process is up —
+// a hold whose expiry is thirty minutes off, a reminder rule just
+// created — does not poke the loop; it is noticed at the next look.
+// One cheap query a minute against a process that is billed by the
+// quarter hour anyway.
+const residentRecheck = time.Minute
+
+// Deliver is the resident scheduler: the process's own delivery of
+// scheduled work while it is up. It drains once at once, then sleeps
+// until the earliest claimable instant NextDue reports (or
+// residentRecheck, whichever is sooner), drains, and repeats until ctx
+// ends.
+//
+// This exists because the platform's due-time wake is REQUEST-LESS.
+// The activator asks /api/next-due at sleep, records the answer, and at
+// that instant spawns the process — and then nothing. Its own comment
+// says "once up, the instance's own scheduler delivers". Before this
+// loop the app had no scheduler of its own: it drained on the 6-hour
+// tick and on signed-in page views, so a reminder due at 09:50 on an
+// instance nobody was looking at woke the process at 09:50, sat unsent
+// through the idle window, and went at the 12:00 tick, two hours and
+// ten minutes late — after the activator had re-woken the process for
+// the same past due time every quarter hour in between.
+//
+// It is not a time.Ticker standing in for a scheduler. The platform
+// still owns the wake; this loop is what runs BETWEEN the wake and the
+// hibernation, and it sleeps on NextDue, the same answer the platform
+// sleeps on. It never touches the request path, so it does not reset
+// the idle window and cannot pin the process awake. A drain that runs
+// here concurrently with a tick or a page view is safe: every queue row
+// is claimed by identity (B22), and a row one worker holds the other
+// cannot take.
+func (a *App) Deliver(ctx context.Context) {
+ for {
+ now := time.Now()
+ sent, err := a.h.drain(ctx, now)
+ switch {
+ case ctx.Err() != nil:
+ return
+ case err != nil:
+ a.logger.Error("calendar: resident drain", "stage", "resident.drain", "err", scrubErr(err))
+ case sent > 0:
+ a.logger.Info("calendar: resident drain delivered", "sent", sent)
+ }
+ wait := untilNextDue(a.store.NextDueAt(ctx), time.Now())
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(wait):
+ }
+ }
+}
+
+// untilNextDue is how long the resident scheduler sleeps: until due,
+// capped at residentRecheck, and never negative. Zero (nothing
+// scheduled) is the cap: something may be queued by then.
+func untilNextDue(due, now time.Time) time.Duration {
+ if due.IsZero() {
+ return residentRecheck
+ }
+ wait := due.Sub(now)
+ if wait > residentRecheck {
+ return residentRecheck
+ }
+ if wait < 0 {
+ return 0
+ }
+ return wait
+}
diff --git a/internal/calendar/deliver_test.go b/internal/calendar/deliver_test.go
new file mode 100644
index 0000000..62e9235
--- /dev/null
+++ b/internal/calendar/deliver_test.go
@@ -0,0 +1,25 @@
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestUntilNextDueSleepsToTheDueTimeOrTheCap(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ cases := []struct {
+ name string
+ due time.Time
+ want time.Duration
+ }{
+ {"nothing scheduled waits the cap", time.Time{}, residentRecheck},
+ {"due in ten seconds waits ten seconds", now.Add(10 * time.Second), 10 * time.Second},
+ {"due in an hour waits the cap", now.Add(time.Hour), residentRecheck},
+ {"already due waits nothing", now.Add(-time.Minute), 0},
+ }
+ for _, c := range cases {
+ if got := untilNextDue(c.due, now); got != c.want {
+ t.Errorf("%s: %v, want %v", c.name, got, c.want)
+ }
+ }
+}
diff --git a/internal/calendartest/booking_http_test.go b/internal/calendartest/booking_http_test.go
index 6d37680..012dae8 100644
--- a/internal/calendartest/booking_http_test.go
+++ b/internal/calendartest/booking_http_test.go
@@ -70,25 +70,36 @@ var (
// firstDaySlots opens the page, follows the first day link, and returns
// that day's path and its slot starts.
func firstDaySlots(t *testing.T, h http.Handler, path string) (string, []string) {
+ t.Helper()
+ return daySlotsAtLeast(t, h, path, 1)
+}
+
+// daySlotsAtLeast is firstDaySlots for a test that needs n slots on one
+// day: the first day is today, and how much of today is left depends
+// on the clock the test runs under.
+func daySlotsAtLeast(t *testing.T, h http.Handler, path string, n int) (string, []string) {
t.Helper()
page := strangerGet(t, h, path)
if page.Code != 200 {
t.Fatalf("page: %d\n%s", page.Code, page.Body.String())
}
- m := reDay.FindStringSubmatch(page.Body.String())
- if m == nil {
+ days := reDay.FindAllStringSubmatch(page.Body.String(), -1)
+ if days == nil {
t.Fatalf("no day links:\n%s", page.Body.String())
}
- dayPath := path + "?day=" + m[1] + "&zone=UTC"
- day := strangerGet(t, h, dayPath)
- var starts []string
- for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) {
- starts = append(starts, s[1])
- }
- if len(starts) == 0 {
- t.Fatalf("no slots on the day page:\n%s", day.Body.String())
+ for _, m := range days {
+ dayPath := path + "?day=" + m[1] + "&zone=UTC"
+ day := strangerGet(t, h, dayPath)
+ var starts []string
+ for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) {
+ starts = append(starts, s[1])
+ }
+ if len(starts) >= n {
+ return dayPath, starts
+ }
}
- return dayPath, starts
+ t.Fatalf("no day with %d free slots", n)
+ return "", nil
}
func holdForm(start, name, address string) url.Values {
diff --git a/internal/calendartest/booking_review2_test.go b/internal/calendartest/booking_review2_test.go
index dd5d957..48370f3 100644
--- a/internal/calendartest/booking_review2_test.go
+++ b/internal/calendartest/booking_review2_test.go
@@ -166,10 +166,7 @@ func TestADeferredHoldMailIsNotReportedSent(t *testing.T) {
"availability": {"every day 0-24"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}})
postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}})
path := strings.TrimSpace(rePublicURL.FindString(getAs(t, h, session, "/settings/booking").Body.String()))
- _, starts := firstDaySlots(t, h, path)
- if len(starts) < 5 {
- t.Fatalf("only %d slots on the first day", len(starts))
- }
+ _, starts := daySlotsAtLeast(t, h, path, 5)
// Four holds whose mail is stuck in the sender: every slot taken.
mailer.armed.Store(true)
diff --git a/internal/calendartest/resident_test.go b/internal/calendartest/resident_test.go
new file mode 100644
index 0000000..8ba3c81
--- /dev/null
+++ b/internal/calendartest/resident_test.go
@@ -0,0 +1,59 @@
+package calendartest
+
+import (
+ "context"
+ "net/url"
+ "testing"
+ "time"
+)
+
+// The platform's due-time wake is request-less: it spawns the process
+// at NextDue and nothing else. Delivery must not need a tick or a page
+// view — the process drains on its own once it is up, and re-arms on
+// NextDue while it stays up. Observed on 2026-09-07: a reminder due
+// 09:50 woke the instance at 09:50:28, went unsent through the idle
+// window, and was delivered by the 12:00 tick.
+func TestTheProcessDeliversWithoutARequest(t *testing.T) {
+ mailer := &pickyMailer{}
+ app := newAppWithMailer(t, mailer)
+ session := app.SignInForTest(t, "someone@example.com")
+ h := app.Handler()
+ mailer.reset()
+
+ // One reminder already due (a meeting a minute off, warned ten
+ // minutes ahead), and one due two seconds from now, which only a
+ // re-armed scheduler can send in time for this test.
+ now := time.Now().UTC()
+ createEvent(t, h, session, url.Values{
+ "title": {"Already due"}, "start": {now.Add(time.Minute).Format("2006-01-02T15:04")},
+ "end": {now.Add(16 * time.Minute).Format("2006-01-02T15:04")},
+ "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}})
+ createEvent(t, h, session, url.Values{
+ "title": {"Due shortly"}, "start": {now.Add(3 * time.Hour).Format("2006-01-02T15:04")},
+ "end": {now.Add(4 * time.Hour).Format("2006-01-02T15:04")},
+ "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}})
+ if _, err := app.DBForTest().Exec(`UPDATE reminder_queues SET due_at = ? WHERE due_at > ?`,
+ time.Now().Add(2*time.Second).Unix(), time.Now().Unix()); err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { defer close(done); app.Deliver(ctx) }()
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) && !(mailer.deliveredContaining("Already due") && mailer.deliveredContaining("Due shortly")) {
+ time.Sleep(50 * time.Millisecond)
+ }
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Deliver did not stop when its context ended")
+ }
+ if !mailer.deliveredContaining("Already due") {
+ t.Error("the reminder already due was not sent by the process on its own")
+ }
+ if !mailer.deliveredContaining("Due shortly") {
+ t.Error("the reminder due two seconds in was not sent: the scheduler did not re-arm on NextDue")
+ }
+}