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>
13 files changed,
+519
−12
activator.go+7 −4bot_botbuild_test.go+95 −2db.go+21 −0docs/planet-scale.md+14 −2internal/bot/botbuild.go+8 −0internal/bot/charter_test.go+32 −0internal/bot/chartersched.go+116 −3internal/bot/runtime.go+6 −0main.go+3 −0memberbots.go+45 −0memberbots_test.go+52 −0miniapps.go+37 −1miniapps_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) |