eleven / messenger Public

server: charter-wake registration — hibernated instances wake for member-bot schedules (#721)

The last always-on gap: member-bot charters run in the sidecar and their
schedules live in instance-key-encrypted blobs, so a sleeping instance's
charter work waited for a member request. Close it end to end:

- The charter host reports its EARLIEST next run to the instance after every
  schedule recompute (connect/slow-tick reconcile, each run's re-arm, build,
  update, remove) via POST /api/charter-next-due — bot-device-token gated,
  the declareBotKind pattern (the sidecar never holds the admin token). One
  bare timestamp; charter content stays ciphertext. Rides the marked
  transport, so reporting stamps no activity.
- Stored as a config key (charter_next_due — the update_pin idiom, no schema
  change); GET /api/next-due answers MIN(schedule queue, charter report), so
  the activator's existing due-time index wakes for charters with zero
  activator changes.
- Staleness: a past charter report is ignored at probe time — it changes only
  when the sidecar re-reports, so honoring it would re-wake after every
  sleep. At most one wasted wake, never a loop; a past SCHEDULE deliver_at
  stays honored (delivery consumes the row — self-limiting).
- Due-wake boot promptness: a live charter with no pending alarm at connect
  (its tick was delivered while no host was listening — the co-spawn race)
  is armed at NOW instead of its next occurrence, so the woken charter
  actually runs instead of skipping a day; a duplicate-tick guard covers the
  sliver where the original tick was in flight after all.

Tests: endpoint auth gates, combineNextDue MIN + staleness asymmetry (unit +
endpoint), earliestCharterDue (pure), and e2e — the report lands at
reconcile/build/re-arm, and a consumed-unheard alarm runs at connect
(mutation-verified: fails without the catch-up arm).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8e150cb296a12ecd27323f7049fae44747e10ac0 parent 2d83143
13 files changed, +519 −12
  • activator.go +7 −4
  • bot_botbuild_test.go +95 −2
  • db.go +21 −0
  • docs/planet-scale.md +14 −2
  • internal/bot/botbuild.go +8 −0
  • internal/bot/charter_test.go +32 −0
  • internal/bot/chartersched.go +116 −3
  • internal/bot/runtime.go +6 −0
  • main.go +3 −0
  • memberbots.go +45 −0
  • memberbots_test.go +52 −0
  • miniapps.go +37 −1
  • miniapps_test.go +83 −0
diff --git a/activator.go b/activator.go
index 7c839bd..7883039 100644
--- a/activator.go
+++ b/activator.go
@@ -25,10 +25,13 @@ package main
// sidecar marks them (bot.SidecarHeader) and the activator excludes them from
// the idle clock — otherwise the pair could never sleep, and an orphaned
// sidecar's reconnect loop would wake the instance right back (see serveHTTP).
-// One honest gap: member-bot charters run IN the sidecar (docs/member-bots.md),
-// so a sleeping instance's charter schedules don't fire until something else
-// wakes it — the due-time index below covers server-side schedules only. The
-// hardened dedicated-sidecar tier (bot keys off-box) stays a non-goal here.
+// Member-bot charters (docs/member-bots.md) run IN the sidecar, and their
+// schedules live in blobs the server can't read — the former honest gap. It's
+// closed by charter-wake registration: the sidecar reports its earliest
+// charter run to the instance (POST /api/charter-next-due), the instance folds
+// it into GET /api/next-due (combineNextDue, miniapps.go), and the due-time
+// index below wakes for it like any scheduled delivery — no activator changes.
+// The hardened dedicated-sidecar tier (bot keys off-box) stays a non-goal here.
//
// Opt-in is PER HOST (routes.hibernate, registry.go): with the bucket env set,
// only flagged instances are activator-managed; the rest stay on their systemd
diff --git a/bot_botbuild_test.go b/bot_botbuild_test.go
index 1e1944c..886eb9c 100644
--- a/bot_botbuild_test.go
+++ b/bot_botbuild_test.go
@@ -147,6 +147,12 @@ func TestBotBuildsMemberBot(t *testing.T) {
t.Fatalf("unexpected bot id %q", bots[0].ID)
}
+ // The build reported the charter wake-time (recomputeCharterDue), so a
+ // hibernating instance would wake for the new bot's first occurrence.
+ waitFor(t, "charter due reported after build", func() bool {
+ return s.db.charterNextDue() > time.Now().Unix()
+ })
+
// The blob decrypts (instance key) to the charter: persona, schedule, the
// DISCOVERED feed on the allowlist (the member only ever named the site),
// the dedicated thread.
@@ -357,8 +363,14 @@ func TestCharterTickRunsTask(t *testing.T) {
go eng.Run(ctx)
awaitReady(t, ready)
- // Connect reconciled: one pending alarm. The sweep delivers it; the task
- // posts into the charter's thread under the persona name.
+ // Connect reconciled — and reported the charter wake-time, so a hibernating
+ // instance would be woken for this schedule (charter-wake registration).
+ waitFor(t, "charter due reported", func() bool {
+ return s.db.charterNextDue() > 0
+ })
+
+ // One pending alarm. The sweep delivers it; the task posts into the
+ // charter's thread under the persona name.
waitFor(t, "task result posted", func() bool {
s.sweepDueScheduled()
msgs, _ := s.db.messagesAfter(threadID, 0, 10)
@@ -388,3 +400,84 @@ func TestCharterTickRunsTask(t *testing.T) {
return s.db.countScheduledMessages(selfID) >= 1
})
}
+
+// TestCharterMissedAlarmRunsAtConnect pins the due-wake boot path of
+// charter-wake registration: a live charter whose alarm was consumed while no
+// host was listening (the hibernation wake race — the instance's scheduler
+// fires the tick before the co-spawned sidecar's cursor baseline) must run
+// promptly at connect, not silently skip to its next occurrence. The seeded
+// charter's daily slot is ~12 hours away, so a run inside the test window can
+// only be the connect-time catch-up arm.
+func TestCharterMissedAlarmRunsAtConnect(t *testing.T) {
+ fakeAI := scriptedAnthropic(t,
+ []map[string]any{{"type": "text", "text": "Here is the edition you were owed."}},
+ )
+ t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ ownerUID, _ := s.db.deviceTokenUser(owner.Token)
+ instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
+ threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
+
+ // A charter with NO pending alarm — exactly the state after a tick was
+ // delivered into a host-less self-thread. Its daily slot is far away.
+ farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
+ charter := map[string]any{
+ "v": 1, "name": "owed-note", "title": "Owed Note", "glyph": "📬",
+ "schedule": map[string]any{"at": farSlot},
+ "task": map[string]any{"prompt": "Say what was missed."},
+ "thread": threadID, "owner": ownerUID,
+ }
+ blob, err := e2ee.EncryptJSON(instKey, charter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
+ req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-owed01", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
+ }
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ // The connect-time reconcile arms the owed run at NOW; the sweep delivers
+ // it and the task posts — hours before the schedule's next slot.
+ waitFor(t, "owed run posted at connect", func() bool {
+ s.sweepDueScheduled()
+ msgs, _ := s.db.messagesAfter(threadID, 0, 10)
+ return len(msgs) > 0
+ })
+ msgs, _ := s.db.messagesAfter(threadID, 0, 10)
+ var out struct {
+ Name string `json:"name"`
+ Text string `json:"text"`
+ }
+ if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &out); err != nil {
+ t.Fatalf("decrypt: %v", err)
+ }
+ if out.Name != "Owed Note" || !strings.Contains(out.Text, "owed") {
+ t.Fatalf("bad catch-up post: %+v", out)
+ }
+
+ // The run's re-arm reported the wake-time: the next occurrence (the far
+ // slot), so a hibernating instance would sleep until then, not loop.
+ waitFor(t, "wake-time reported", func() bool {
+ due := s.db.charterNextDue()
+ return due > time.Now().Add(time.Hour).Unix()
+ })
+}
diff --git a/db.go b/db.go
index cb7f7c9..c6a98a2 100644
--- a/db.go
+++ b/db.go
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"errors"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -183,6 +184,26 @@ func (db *DB) updatePin() string {
}
func (db *DB) setUpdatePin(v string) error { return db.setConfig("update_pin", v) }
+// charterNextDue is the sidecar-reported earliest next run across this space's
+// member-bot charters (unix seconds; 0 or absent = none). Charters themselves
+// are instance-key-encrypted blobs the server can't read (memberbots.go), so
+// the charter host reports this one timestamp so a hibernating instance can be
+// woken in time to run them (see getNextDue / postCharterNextDue). A config
+// key, not a table: it's a single space-wide value with last-write-wins
+// semantics, exactly the update_pin idiom — and no schema change at all.
+func (db *DB) charterNextDue() int64 {
+ v, _, _ := db.getConfig("charter_next_due")
+ n, _ := strconv.ParseInt(v, 10, 64)
+ if n < 0 {
+ return 0
+ }
+ return n
+}
+
+func (db *DB) setCharterNextDue(due int64) error {
+ return db.setConfig("charter_next_due", strconv.FormatInt(due, 10))
+}
+
// DB wraps the SQLite connection. Note: the server never stores message
// plaintext or any encryption key. Messages live here only as opaque
// ciphertext blobs produced by the browser.
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
index b18110c..95d7e8d 100644
--- a/docs/planet-scale.md
+++ b/docs/planet-scale.md
@@ -162,8 +162,20 @@ admin-gated, over the socket) and records it in the registry
wakes any sleeping host whose time has arrived, no client request needed.
Worst-case lateness ≈ one sweep interval (≤30 s) + spawn + one scheduler tick
(≤5 s). One honest gap the issue glossed: **member-bot charter schedules run
-in the sidecar** (`docs/member-bots.md`), so they don't wake a sleeping
-instance — the index covers server-side schedules only.
+in the sidecar** (`docs/member-bots.md`), so they didn't wake a sleeping
+instance — the index covered server-side schedules only. Closed by
+**charter-wake registration**: after every schedule recompute the charter host
+reports its earliest next run to the instance (`POST /api/charter-next-due`,
+bot-token-gated — one bare timestamp; the charters stay ciphertext), the
+instance folds it into `GET /api/next-due`, and the same index wakes for it —
+zero activator changes. A stale report from a dead sidecar is ignored once
+past (it changes only when the sidecar re-reports, so honoring it would re-wake
+after every sleep): at most one wasted wake, never a loop — and a sidecar that
+can't come up couldn't run the charter anyway. The due-wake's other half lives
+in the sidecar: a co-spawned host that loses the delivery race (the instance's
+scheduler fires the tick before the host's cursor baseline) treats a live
+charter with no pending alarm at connect as owing a run now, so the woken
+charter actually runs instead of skipping to its next occurrence.
**Sidecar co-spawn** (PR #670). A host with a bot sidecar staged
(`/data/<host>-bot.env` — the same file `lchat-bot@.service` reads) gets its
diff --git a/internal/bot/botbuild.go b/internal/bot/botbuild.go
index 1147dbc..621247a 100644
--- a/internal/bot/botbuild.go
+++ b/internal/bot/botbuild.go
@@ -398,6 +398,10 @@ func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, request
}
}
r.armCharter(selfID, selfKey, id, charter.Schedule.next(time.Now()))
+ // The store changed shape — refresh the instance's charter wake-time so a
+ // hibernating instance is woken for the new/updated schedule
+ // (chartersched.go reportCharterDue).
+ r.recomputeCharterDue(instKey)
// Deliver the dry run as the first edition — proof, not promise. The key
// comes back from createCharterThread only on the create path; on updates
@@ -464,6 +468,10 @@ func (r *Runtime) runBotRemove(tc *ThreadCtx, botID, requesterUID string) Reply
}
}
}
+ // The retired charter may have been the earliest — refresh the instance's
+ // charter wake-time so it doesn't wake a hibernating space for a bot that
+ // no longer exists.
+ r.recomputeCharterDue(instKey)
return Reply{Text: fmt.Sprintf("Done — %s %s is retired and won't run again. Its chat and everything it posted stay yours; hide the chat if you like.", c.Title, c.Glyph)}
}
diff --git a/internal/bot/charter_test.go b/internal/bot/charter_test.go
index 8e68c60..fdad9cc 100644
--- a/internal/bot/charter_test.go
+++ b/internal/bot/charter_test.go
@@ -90,6 +90,38 @@ func TestScheduleNext(t *testing.T) {
}
}
+// earliestCharterDue: the wake-time the host reports to the instance — the
+// soonest next occurrence across live charters, 0 with none.
+func TestEarliestCharterDue(t *testing.T) {
+ mk := func(spec ScheduleSpec) *Charter {
+ if err := spec.validate(); err != nil {
+ t.Fatal(err)
+ }
+ return &Charter{Schedule: spec}
+ }
+ now := time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC)
+
+ if got := earliestCharterDue(map[string]*Charter{}, now); got != 0 {
+ t.Fatalf("no charters: got %d, want 0", got)
+ }
+
+ live := map[string]*Charter{
+ "bot-a": mk(ScheduleSpec{Every: "6h"}), // 16:00
+ "bot-b": mk(ScheduleSpec{At: "12:30"}), // 12:30 UTC today — the soonest
+ "bot-c": mk(ScheduleSpec{Every: "180m"}), // 13:00
+ }
+ want := time.Date(2026, 7, 9, 12, 30, 0, 0, time.UTC).Unix()
+ if got := earliestCharterDue(live, now); got != want {
+ t.Fatalf("earliest = %d, want %d (12:30 UTC)", got, want)
+ }
+
+ // The result is always strictly in the future (Schedule.next's contract) —
+ // the report can never be born stale.
+ if got := earliestCharterDue(live, now); got <= now.Unix() {
+ t.Fatalf("earliest %d not after now %d", got, now.Unix())
+ }
+}
+
func TestValidateCharter(t *testing.T) {
good := func() *Charter {
return &Charter{
diff --git a/internal/bot/chartersched.go b/internal/bot/chartersched.go
index 1327a49..b4293be 100644
--- a/internal/bot/chartersched.go
+++ b/internal/bot/chartersched.go
@@ -9,6 +9,16 @@ package bot
// alarms survive sidecar restarts for free — every connect (and a slow
// background tick) reconciles charters against pending alarms. The server
// learns only (thread, time), which it already learns for every reminder.
+//
+// Hibernation (charter-wake registration): after every schedule recompute the
+// host also reports the EARLIEST next run to the instance (reportCharterDue →
+// POST /api/charter-next-due), which the router's due-time index folds in —
+// so a hibernating instance is woken for charter work even when the queue's
+// alarm row was consumed while no host was listening. And because a due-wake's
+// co-spawned host can lose the delivery race (the instance's scheduler fires
+// the tick before this host's cursor baseline), the connect-time reconcile
+// treats a live charter with no pending alarm as owing a run NOW — armed at
+// now, delivered within a scheduler tick, run through the normal path.
import (
"time"
@@ -91,10 +101,83 @@ func (r *Runtime) charterReconcile(atConnect bool) {
if armed[id] {
continue
}
- r.armCharter(selfID, selfKey, id, c.Schedule.next(now))
+ when := c.Schedule.next(now)
+ if atConnect {
+ // A live charter with NO pending alarm at connect means its alarm
+ // fired while no host was listening (delivered into the self-thread
+ // after the last host stopped — the hibernation due-wake case: the
+ // instance's scheduler can beat this host's connect by a beat and
+ // the cursor baseline swallows the tick) — or the alarm was never
+ // armed at all. Either way the schedule owes a run NOW, not at the
+ // next occurrence (a daily bot would otherwise skip a whole day
+ // every time its wake lost that race). Arm at now: the server
+ // delivers within one scheduler tick and the run flows through the
+ // ordinary charterTick path — same crash-recovery ordering, no
+ // second execution path. (charterTick's duplicate guard covers the
+ // sliver where the original tick was in flight after all.)
+ when = now
+ }
+ r.armCharter(selfID, selfKey, id, when)
+ }
+ // Report the charter due-time so the schedule survives hibernation: the
+ // instance stores the earliest next run (a bare timestamp — the charters
+ // themselves stay ciphertext to it) and the router's due-time index wakes
+ // the sleeping instance for it. Reported on every reconcile — connect and
+ // the slow tick — so a value from a dead host heals as soon as any host
+ // connects. Zero (no live charters) is reported too: it clears a stale
+ // value that would otherwise buy one pointless wake.
+ r.reportCharterDue(earliestCharterDue(live, now))
+}
+
+// earliestCharterDue is the soonest next occurrence across live charters
+// (unix seconds; 0 = none). Pure — the clock is a parameter, like
+// ScheduleSpec.next. It recomputes next(now) rather than reading the armed
+// alarms: an armed alarm may be sooner (e.g. an interval schedule armed a
+// while ago), but the alarm is ALSO a server-side scheduled message the
+// instance already folds into its next-due answer, so the earlier truth
+// still wins — this report is the backstop for exactly the alarms the queue
+// has already consumed.
+func earliestCharterDue(live map[string]*Charter, now time.Time) int64 {
+ var due int64
+ for _, c := range live {
+ if n := c.Schedule.next(now).Unix(); due == 0 || n < due {
+ due = n
+ }
+ }
+ return due
+}
+
+// reportCharterDue posts the earliest charter run to the instance
+// (POST /api/charter-next-due, memberbots.go). Best-effort — a failed report
+// costs promptness on the next hibernated occurrence (the instance wakes for
+// a member request instead), never correctness — and it rides r.client's
+// marked transport like every sidecar request, so it stamps no activity.
+func (r *Runtime) reportCharterDue(due int64) {
+ if err := r.post("/api/charter-next-due", map[string]any{
+ "token": r.Session.Token, "due": due,
+ }, nil); err != nil {
+ r.logf("charters: report next due: %v", err)
}
}
+// recomputeCharterDue re-derives and reports the earliest charter run — for
+// call sites that just mutated the store (build, update, remove) rather than
+// folded it (botbuild.go).
+func (r *Runtime) recomputeCharterDue(instKey []byte) {
+ entries, err := r.listCharters(instKey)
+ if err != nil {
+ r.logf("charters: recompute next due: %v", err)
+ return
+ }
+ live := map[string]*Charter{}
+ for _, e := range entries {
+ if !e.Charter.Paused {
+ live[e.ID] = e.Charter
+ }
+ }
+ r.reportCharterDue(earliestCharterDue(live, time.Now()))
+}
+
// armCharter queues one alarm for a charter.
func (r *Runtime) armCharter(selfID string, selfKey []byte, charterID string, when time.Time) {
payload := map[string]any{"type": "bot.tick", "bot": charterID, "ts": time.Now().UnixMilli()}
@@ -109,20 +192,50 @@ func (r *Runtime) armCharter(selfID string, selfKey []byte, charterID string, wh
// armed before the run, so a task that crashes the process can't silence the
// bot forever (a duplicate alarm from that ordering is dropped by reconcile).
func (r *Runtime) charterTick(selfID string, selfKey []byte, tp *tickPayload) {
+ // Duplicate-tick guard: the connect-time catch-up arm (charterReconcile)
+ // re-arms a charter whose alarm looks consumed-unheard, but a delivery can
+ // land in the sliver between the connect's cursor baseline and the
+ // reconcile's pending-alarm read — then BOTH the original tick and the
+ // catch-up alarm arrive. Two ticks for one charter closer together than
+ // any expressible schedule are that race, not a run owed twice: drop the
+ // second without re-arming (the first already armed the next occurrence).
+ if last, ok := r.charterRuns[tp.Bot]; ok && time.Since(last) < minEvery()/2 {
+ r.logf("charters: tick %s: duplicate within %s — dropped", tp.Bot, minEvery()/2)
+ return
+ }
instKey, err := r.instanceKey()
if err != nil {
r.logf("charters: tick %s: instance key: %v", tp.Bot, err)
return
}
- c, err := r.fetchCharter(tp.Bot, instKey)
+ // One store read serves both needs: the ticked charter, and the live set
+ // for the wake-time report below.
+ entries, err := r.listCharters(instKey)
if err != nil {
r.logf("charters: tick %s: %v", tp.Bot, err)
return
}
+ var c *Charter
+ live := map[string]*Charter{}
+ for _, e := range entries {
+ if !e.Charter.Paused {
+ live[e.ID] = e.Charter
+ }
+ if e.ID == tp.Bot {
+ c = e.Charter
+ }
+ }
if c == nil || c.Paused {
- return // deleted or paused since the alarm was set — don't re-arm
+ // Deleted or paused since the alarm was set — don't re-arm, but do
+ // refresh the report: this alarm may have been the earliest.
+ r.reportCharterDue(earliestCharterDue(live, time.Now()))
+ return
}
r.armCharter(selfID, selfKey, tp.Bot, c.Schedule.next(time.Now()))
+ // Re-report after each run's re-arm, so a hibernating instance's stored
+ // wake-time always tracks the freshest schedule.
+ r.reportCharterDue(earliestCharterDue(live, time.Now()))
+ r.charterRuns[tp.Bot] = time.Now()
key, ok := r.threadKeyFor(c.Thread)
if !ok {
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 95207d7..130f7b7 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -91,6 +91,11 @@ type Runtime struct {
// there until the script ends or the entry expires — the fail-safe for a
// client that dies mid-flow. Frame goroutine only.
muted map[string]time.Time
+
+ // charterRuns records when each charter last ran — the duplicate-tick
+ // guard for the connect-time catch-up arm (see charterTick). Frame
+ // goroutine only, like muted.
+ charterRuns map[string]time.Time
}
// ThreadCtx hands a Handler the thread's services: decrypted history, the
@@ -205,6 +210,7 @@ func (r *Runtime) Run(ctx context.Context) error {
r.cursors = map[string]int64{}
r.agents = map[string]bool{}
r.muted = map[string]time.Time{}
+ r.charterRuns = map[string]time.Time{}
r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`)
r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`)
if r.AIKey != "" {
diff --git a/main.go b/main.go
index 5d19e37..03a699f 100644
--- a/main.go
+++ b/main.go
@@ -357,6 +357,9 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/member-bots", s.listMemberBotsHandler)
mux.HandleFunc("PUT /api/member-bots/{id}", s.putMemberBotHandler)
mux.HandleFunc("DELETE /api/member-bots/{id}", s.deleteMemberBotHandler)
+ // The charter host's wake-time report (bot-token-gated; folded into
+ // GET /api/next-due so hibernated charters still fire on time).
+ mux.HandleFunc("POST /api/charter-next-due", s.postCharterNextDue)
// The sandbox frame document: an opaque-origin cage for member-app code.
mux.HandleFunc("GET /app-sandbox", s.serveAppSandbox)
diff --git a/memberbots.go b/memberbots.go
index 0489f8f..268c794 100644
--- a/memberbots.go
+++ b/memberbots.go
@@ -124,6 +124,51 @@ func (s *Server) putMemberBotHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
}
+// postCharterNextDue receives the charter host's wake-time report: the
+// EARLIEST next run across every member-bot charter the sidecar hosts for this
+// instance ({token, due} — unix seconds; 0 = no live charters). The activator's
+// due-time index (activator.go) reads it back through GET /api/next-due, so a
+// hibernating instance is woken in time for schedules only the sidecar can see.
+//
+// Privacy: this makes one charter wake-time server-visible metadata. That is
+// the same class of leak as scheduled messages, which already carry a plaintext
+// deliver_at (miniapps.go postSchedule) — indeed the charters' armed alarms ARE
+// scheduled messages (internal/bot/chartersched.go). The charter's CONTENT
+// (name, sources, prompt, even which charter is next) stays encrypted; the
+// server learns only "wake me at T", and only the single earliest T at that.
+//
+// Auth: the sidecar is an ordinary E2EE member — it holds device tokens, never
+// the admin token — so this is member-token-gated and then narrowed to
+// bot-kind users, the declareBotKind pattern (a sidecar self-reporting a fact
+// about what it hosts). The value is deliberately low-stakes: any member can
+// already cause wakes (a request wakes; POST /api/schedule feeds the same
+// index), so a forged report buys an attacker nothing new — the agent gate is
+// narrowness, not a security boundary.
+func (s *Server) postCharterNextDue(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Due int64 `json:"due"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok || !s.db.isAgent(uid) {
+ httpError(w, http.StatusForbidden, "bot token required")
+ return
+ }
+ if body.Due < 0 {
+ httpError(w, http.StatusBadRequest, "bad due time")
+ return
+ }
+ if err := s.db.setCharterNextDue(body.Due); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// deleteMemberBotHandler removes a charter: its author or owner retiring their
// own, or an admin moderating one they can't read.
func (s *Server) deleteMemberBotHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/memberbots_test.go b/memberbots_test.go
index 123f1c8..3963c7f 100644
--- a/memberbots_test.go
+++ b/memberbots_test.go
@@ -111,6 +111,58 @@ func TestMemberBotOwnershipGates(t *testing.T) {
}
}
+// The charter wake-time report (postCharterNextDue): the right principal is a
+// bot's own device token — a person's member token and a stranger are both
+// turned away, and the reported value round-trips through the store.
+func TestCharterNextDueReport(t *testing.T) {
+ ts, s := newTestServer(t)
+ alice := provisionNamed(t, ts.URL, "Alice", false)
+ agent := provisionNamed(t, ts.URL, "Eleven", true)
+
+ report := func(token string, due int64) int {
+ t.Helper()
+ raw, _ := json.Marshal(map[string]any{"token": token, "due": due})
+ resp, err := http.Post(ts.URL+"/api/charter-next-due", "application/json", bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ // A bot's device token is accepted and the value lands.
+ if code := report(agent.Token, 1234567890); code != http.StatusOK {
+ t.Fatalf("agent report: %d", code)
+ }
+ if got := s.db.charterNextDue(); got != 1234567890 {
+ t.Fatalf("stored due = %d, want 1234567890", got)
+ }
+ // Last write wins; zero clears.
+ if code := report(agent.Token, 0); code != http.StatusOK {
+ t.Fatalf("agent clear: %d", code)
+ }
+ if got := s.db.charterNextDue(); got != 0 {
+ t.Fatalf("cleared due = %d, want 0", got)
+ }
+
+ // A person's member token is refused (bot-kind only), a stranger is
+ // refused, and neither write reaches the store.
+ if code := report(alice.Token, 42); code != http.StatusForbidden {
+ t.Fatalf("member report: want 403, got %d", code)
+ }
+ if code := report("nonsense", 42); code != http.StatusForbidden {
+ t.Fatalf("stranger report: want 403, got %d", code)
+ }
+ if got := s.db.charterNextDue(); got != 0 {
+ t.Fatalf("refused writes must not land: %d", got)
+ }
+
+ // A negative due is malformed.
+ if code := report(agent.Token, -5); code != http.StatusBadRequest {
+ t.Fatalf("negative due: want 400, got %d", code)
+ }
+}
+
func TestMemberBotOwnerCap(t *testing.T) {
ts, _ := newTestServer(t)
alice := provisionNamed(t, ts.URL, "Alice", false)
diff --git a/miniapps.go b/miniapps.go
index 354840a..0138664 100644
--- a/miniapps.go
+++ b/miniapps.go
@@ -148,6 +148,10 @@ func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) {
// the clear (the schedule's (thread, time) metadata), but every data endpoint
// carries auth, and the activator already holds the instance's admin token via
// the env file it spawns instances with.
+//
+// The answer folds in the sidecar-reported charter due-time (memberbots.go
+// postCharterNextDue), so member-bot charters — whose schedules live in
+// blobs only the sidecar can read — wake a sleeping instance too.
func (s *Server) getNextDue(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
@@ -157,7 +161,39 @@ func (s *Server) getNextDue(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"due": due})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "due": combineNextDue(due, s.db.charterNextDue(), time.Now().Unix()),
+ })
+}
+
+// combineNextDue folds the charter host's reported due-time into the schedule
+// queue's earliest deliver_at: the sooner of the two wins, with 0 meaning
+// "nothing" on either side.
+//
+// The asymmetry is deliberate: a PAST schedule deliver_at is honored (delivery
+// consumes the row, so a past value only exists until the instance's next
+// scheduler sweep — waking for it is exactly right), but a PAST charter
+// due-time is ignored. The charter value has no consumption mechanism on the
+// server — it changes only when the sidecar re-reports — and this probe runs
+// at sleep time, after a full awake window. A value still in the past here
+// means the sidecar had that whole window to act on it and re-report, and
+// didn't (dead, keyless, or gone): honoring it would re-wake the instance
+// immediately after every sleep, forever. Ignoring it caps the damage at the
+// one wake the report legitimately bought and degrades to pre-index behavior
+// (sleep until a request) — which is also CORRECT, not just safe: charters run
+// in the sidecar, so waking an instance whose sidecar can't come up buys their
+// schedules nothing. No clearing is needed — an ignored past value is inert,
+// and any live sidecar overwrites it within one reconcile. (Same no-ping-pong
+// reasoning as the activator's due-time index: wake/sleep cycles can never be
+// spaced closer than the idle window, and a stale value never re-fires.)
+func combineNextDue(schedDue, charterDue, now int64) int64 {
+ if charterDue <= 0 || charterDue < now {
+ return schedDue
+ }
+ if schedDue == 0 || charterDue < schedDue {
+ return charterDue
+ }
+ return schedDue
}
// getThreadStateHandler returns a slot's snapshot: {blob, version}. version 0 +
diff --git a/miniapps_test.go b/miniapps_test.go
index 9c53cb9..60d05ac 100644
--- a/miniapps_test.go
+++ b/miniapps_test.go
@@ -202,6 +202,89 @@ func TestNextDue(t *testing.T) {
}
}
+// combineNextDue: the MIN composition behind /api/next-due, and the staleness
+// rule that keeps a dead sidecar's report from wake-looping a hibernating
+// instance.
+func TestCombineNextDue(t *testing.T) {
+ const now = int64(1_000_000)
+ cases := []struct {
+ name string
+ sched, charter, want int64
+ }{
+ {"neither", 0, 0, 0},
+ {"only messages", now + 100, 0, now + 100},
+ {"only charter", 0, now + 200, now + 200},
+ {"both, schedule sooner", now + 100, now + 200, now + 100},
+ {"both, charter sooner", now + 300, now + 200, now + 200},
+ // Staleness: a past charter report is ignored — it changes only when
+ // the sidecar re-reports, so honoring it would re-wake the instance
+ // after every sleep, forever (at-most-one wasted wake, never a loop).
+ {"past charter ignored", 0, now - 10, 0},
+ {"past charter never shadows the queue", now + 100, now - 10, now + 100},
+ {"charter due exactly now still fires", 0, now, now},
+ // The asymmetry: a past SCHEDULE deliver_at is honored — delivery
+ // consumes the row, so waking for it is right and self-limiting.
+ {"past schedule honored", now - 10, 0, now - 10},
+ }
+ for _, c := range cases {
+ if got := combineNextDue(c.sched, c.charter, now); got != c.want {
+ t.Errorf("%s: combineNextDue(%d, %d) = %d, want %d", c.name, c.sched, c.charter, got, c.want)
+ }
+ }
+}
+
+// /api/next-due folds the sidecar-reported charter due-time in with the
+// schedule queue — the endpoint-level view of combineNextDue.
+func TestNextDueFoldsCharterDue(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tok, _, tid := joinedMember(t, srv, "Sam")
+
+ getDue := func() int64 {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+"/api/next-due", nil)
+ req.Header.Set("Authorization", "Bearer admintok")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET /api/next-due: %v %d", err, resp.StatusCode)
+ }
+ var body struct {
+ Due int64 `json:"due"`
+ }
+ decodeJSON(t, resp, &body)
+ return body.Due
+ }
+
+ now := time.Now().Unix()
+
+ // Only a charter report → it is the answer.
+ if err := srv.db.setCharterNextDue(now + 600); err != nil {
+ t.Fatal(err)
+ }
+ if due := getDue(); due != now+600 {
+ t.Fatalf("charter only: due = %d, want %d", due, now+600)
+ }
+
+ // A sooner scheduled message wins; a later one doesn't.
+ postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, now+300))
+ if due := getDue(); due != now+300 {
+ t.Fatalf("sooner schedule: due = %d, want %d", due, now+300)
+ }
+ if err := srv.db.setCharterNextDue(now + 60); err != nil {
+ t.Fatal(err)
+ }
+ if due := getDue(); due != now+60 {
+ t.Fatalf("sooner charter: due = %d, want %d", due, now+60)
+ }
+
+ // A stale (past) charter report is ignored — the queue's answer stands.
+ if err := srv.db.setCharterNextDue(now - 60); err != nil {
+ t.Fatal(err)
+ }
+ if due := getDue(); due != now+300 {
+ t.Fatalf("stale charter: due = %d, want %d", due, now+300)
+ }
+}
+
// Cancellation is member-gated; cancelling an already-fired/unknown id is quiet.
func TestScheduleCancel(t *testing.T) {
ts, srv := newTestServer(t)