carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Scheduled ticks and hibernation — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Stop a platform-delivered scheduled tick from resetting an instance's idle clock, without trading one resident process for hundreds of wake-path traversals a day.
6
7**Architecture:** Three independent pieces. (1) The activator gains a non-stamping sibling of `ServeHTTP` that the schedule deliverer calls instead, so a tick wakes and holds the instance but never credits it as activity. (2) `sweepOnce` gains a guard that refuses to hibernate a host whose next platform timer is due sooner than the idle window, fed by one new pre-lock registry query. (3) The box computes whether a schedule's interval keeps its instance resident and reports it, so `carlos schedule ls` can say so.
8
9**Tech Stack:** Go 1.x, SQLite (`internal/registry`), no new dependencies.
10
11**Spec:** `docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md`
12
13## Global Constraints
14
15- **This change must be inert on the flagship as configured.** Idle is 15m, the tightest schedule on the fleet is 5m, and the guard's answer at those numbers is "stay awake". Any test or manual check that shows `jam.bac.oncarlos.com` beginning to hibernate on this branch is a bug, not a win. (Spec §4.)
16- **Wire shapes are additive.** A `schedule-status/<host>.json` written before this change must still parse. Never remove or retype an existing field. (AGENTS.md, "Rules this repo has paid for".)
17- **Build gate before any commit lands:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean over the whole tree. `make test` covers the last three.
18- **Run Go commands with `TMPDIR=/tmp`**, or three unrelated socket tests fail with phantom errors (they fail on untouched `main` too). Sandbox note from AGENTS.md: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
19- **CI is the `amadan` remote, not GitHub.** A branch pushed only to `origin` has been gated by nothing. Push to `amadan` and read the run before calling it green.
20- **`MinEvery = time.Minute`** (`internal/schedule/spec.go:20`) — the tightest schedule anyone can declare. Worst case for any calculation here is 1,440 ticks a day.
21- **Nothing in this plan adds a registry column, a tuning constant, a wire marker, or a `NextDue` change.** All four were considered and rejected in the spec (§2, §3.1, §5).
22
23---
24
25### Task 1: `EarliestTimers` — one query the guard can afford
26
27The guard needs "when is the next platform tick due, per host" as a single read it can do *before* taking the activator's mutex. `sweepOnce` already establishes that pattern for `DueInstances` and says why: "it's a registry read, and nothing under a.mu should wait on SQLite" (`internal/activator/activator.go:911`).
28
29**Files:**
30- Modify: `internal/registry/timers.go` (append after `DueTimers`)
31- Test: `internal/registry/timers_test.go`
32
33**Interfaces:**
34- Consumes: nothing from earlier tasks.
35- Produces: `func (r *Registry) EarliestTimers() (map[string]int64, error)` — host → the smallest `next_at > 0` among that host's timer rows. Hosts with no armed rows are absent from the map. Task 3 consumes it.
36
37- [ ] **Step 1: Write the failing test**
38
39In `internal/registry/timers_test.go`, following the file's existing setup helper for a temp registry:
40
41```go
42func TestEarliestTimersPerHost(t *testing.T) {
43 r := openTestRegistry(t)
44 rows := []Timer{
45 // Two rows for one host: the earliest wins.
46 {Host: "a.example", Name: "late", Kind: "schedule", Spec: "every 1h", Path: "/x", NextAt: 5000, DueAt: 5000},
47 {Host: "a.example", Name: "soon", Kind: "schedule", Spec: "every 5m", Path: "/y", NextAt: 1200, DueAt: 1200},
48 // A second host is reported independently.
49 {Host: "b.example", Name: "only", Kind: "schedule", Spec: "every 5m", Path: "/z", NextAt: 3000, DueAt: 3000},
50 // next_at == 0 means "not armed" and must never appear.
51 {Host: "c.example", Name: "parked", Kind: "schedule", Spec: "every 5m", Path: "/p", NextAt: 0, DueAt: 0},
52 }
53 for _, tm := range rows {
54 if err := r.UpsertTimer(tm); err != nil {
55 t.Fatalf("UpsertTimer %s/%s: %v", tm.Host, tm.Name, err)
56 }
57 }
58
59 got, err := r.EarliestTimers()
60 if err != nil {
61 t.Fatalf("EarliestTimers: %v", err)
62 }
63 want := map[string]int64{"a.example": 1200, "b.example": 3000}
64 if !reflect.DeepEqual(got, want) {
65 t.Errorf("EarliestTimers = %v, want %v", got, want)
66 }
67}
68
69func TestEarliestTimersEmpty(t *testing.T) {
70 r := openTestRegistry(t)
71 got, err := r.EarliestTimers()
72 if err != nil {
73 t.Fatalf("EarliestTimers: %v", err)
74 }
75 if len(got) != 0 {
76 t.Errorf("EarliestTimers on an empty table = %v, want empty", got)
77 }
78}
79```
80
81`openTestRegistry(t) *Registry` is the package's existing helper (`registry_test.go:469`) — verified, use it. There is no `must` helper in this package; the explicit error check above is the house style. `reflect` must be in the test file's imports.
82
83- [ ] **Step 2: Run the test and watch it fail**
84
85Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v`
86Expected: FAIL — `r.EarliestTimers undefined (type *Registry has no field or method EarliestTimers)`.
87
88- [ ] **Step 3: Implement it**
89
90Append to `internal/registry/timers.go`:
91
92```go
93// EarliestTimers is the hibernation guard's batch: for every host with at
94// least one armed row, the soonest next_at among them. One query, because
95// the activator's sweep reads it BEFORE taking its mutex (activator.go's
96// "nothing under a.mu should wait on SQLite") and a per-entry query inside
97// the sweep loop would put SQLite exactly where that comment forbids it.
98//
99// next_at = 0 is "not armed" — a parked cron, or a row converge wrote and
100// the runner has not yet dated — and is excluded here rather than by the
101// caller, matching DueTimers' own `next_at > 0` gate.
102func (r *Registry) EarliestTimers() (map[string]int64, error) {
103 rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`)
104 if err != nil {
105 return nil, err
106 }
107 defer rows.Close()
108 out := make(map[string]int64)
109 for rows.Next() {
110 var host string
111 var next int64
112 if err := rows.Scan(&host, &next); err != nil {
113 return nil, err
114 }
115 out[host] = next
116 }
117 return out, rows.Err()
118}
119```
120
121- [ ] **Step 4: Run the test and watch it pass**
122
123Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v`
124Expected: PASS, both tests.
125
126- [ ] **Step 5: Full package gate, then commit**
127
128```bash
129TMPDIR=/tmp go test ./internal/registry -count=1
130gofmt -l internal/registry
131git add internal/registry/timers.go internal/registry/timers_test.go
132git commit -m "registry: EarliestTimers, one query for the hibernation guard"
133```
134
135---
136
137### Task 2: A tick that wakes but does not count
138
139`activatorDeliverer.Deliver` reaches the instance through `Service.ServeHTTP` (`internal/host/schedule_wiring.go:330`), the same door the edge uses, so `beginRequest`'s release stamps `lastActive` (`internal/activator/activator.go:594`) and every tick buys another full idle window.
140
141The fix mirrors `BeginSidecarRequest` (`activator.go:526`) — activity-invisible traffic — and differs in one way: a tick *wakes* a sleeping instance where a sidecar request is refused.
142
143**Files:**
144- Modify: `internal/activator/activator.go` (`beginRequest`, `releaseFunc`, `ServeHTTP`)
145- Test: `internal/activator/activator_test.go`
146
147**Interfaces:**
148- Consumes: nothing from earlier tasks.
149- Produces:
150 - `func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool` — same contract as `ServeHTTP` (returns false for a route it does not manage) but the request earns no activity credit. Task 4 consumes it.
151 - Unexported: `func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error)` and `func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool`.
152
153- [ ] **Step 1: Write the failing test**
154
155In `internal/activator/activator_test.go`, using whatever harness the existing wake/sleep tests use to build a `*Service` with a fake backend and a controllable clock:
156
157```go
158// A scheduled tick must not move lastActive: it is the platform's own clock
159// poking the instance, not a visitor. A tick that counted would make any
160// schedule tighter than the idle window a permanent keep-awake.
161func TestServeScheduledDoesNotStampActivity(t *testing.T) {
162 const host = "tick.bab.oncarlos.com"
163 a, _, clk, _ := testActivator(t, time.Hour)
164 rt := testRoute(host)
165 arrangeAwake(t, a, rt) // a real beginRequest round trip, then release
166
167 a.mu.Lock()
168 before := a.entries[rt.Host].lastActive
169 a.mu.Unlock()
170
171 clk.advance(time.Minute)
172
173 rec := httptest.NewRecorder()
174 req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil)
175 if !a.ServeScheduled(rec, req, rt, okHandler()) {
176 t.Fatal("ServeScheduled returned false for a managed route")
177 }
178
179 a.mu.Lock()
180 after := a.entries[rt.Host].lastActive
181 inflight := a.entries[rt.Host].inflight
182 a.mu.Unlock()
183
184 if !after.Equal(before) {
185 t.Errorf("lastActive moved %v -> %v; a scheduled tick must not count as activity", before, after)
186 }
187 if inflight != 0 {
188 t.Errorf("inflight = %d after delivery, want 0 (the hold is released even though the stamp is not written)", inflight)
189 }
190}
191
192// The other half of the same coin, asserted against the first so the two
193// paths can never quietly become one again — which is the bug this fixes.
194func TestServeHTTPStillStampsActivity(t *testing.T) {
195 const host = "visitor.bab.oncarlos.com"
196 a, _, clk, _ := testActivator(t, time.Hour)
197 rt := testRoute(host)
198 arrangeAwake(t, a, rt)
199
200 a.mu.Lock()
201 before := a.entries[rt.Host].lastActive
202 a.mu.Unlock()
203
204 clk.advance(time.Minute)
205
206 rec := httptest.NewRecorder()
207 req := httptest.NewRequest("GET", "http://carlos/", nil)
208 if !a.ServeHTTP(rec, req, rt, okHandler()) {
209 t.Fatal("ServeHTTP returned false for a managed route")
210 }
211
212 a.mu.Lock()
213 after := a.entries[rt.Host].lastActive
214 a.mu.Unlock()
215
216 if !after.After(before) {
217 t.Errorf("lastActive did not move on a visitor request (%v -> %v)", before, after)
218 }
219}
220
221// A tick is a wake source and must stay one: an instance asleep when its
222// timer comes due has to be woken by the delivery itself.
223func TestServeScheduledWakesASleepingInstance(t *testing.T) {
224 const host = "asleep.bab.oncarlos.com"
225 a, _, _, _ := testActivator(t, time.Hour) // no entries yet: the host is asleep
226 rt := testRoute(host)
227
228 rec := httptest.NewRecorder()
229 req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil)
230 if !a.ServeScheduled(rec, req, rt, okHandler()) {
231 t.Fatal("ServeScheduled returned false for a managed route")
232 }
233 if rec.Code != http.StatusOK {
234 t.Fatalf("status %d, want 200 — the tick must wake the instance, not be refused", rec.Code)
235 }
236 a.mu.Lock()
237 e := a.entries[rt.Host]
238 a.mu.Unlock()
239 if e == nil || e.state != actAwake {
240 t.Fatal("instance is not awake after a scheduled delivery")
241 }
242}
243```
244
245**The helpers above are placeholders. These are the real ones, verified in `internal/activator/activator_test.go` — use them:**
246
247- `testActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *registry.Registry)` (line 350) — builds the Service over the fake backend with `a.now = clk.now` already wired.
248- `testRoute(host string) registry.Route` (line 363) — a hibernate-flagged instance route.
249- `clk.advance(d)` (line 339) — moves the fake clock.
250- `waitFor(t, what string, cond func() bool)` (line 387) — poll for an async stop; `beginStopLocked` runs on its own goroutine, so never assert hibernation synchronously.
251
252To arrange an awake entry, do what `newTestServiceAwake` (line 375) does: a real `beginRequest` round trip, then `release()`. For the proxy argument, pass an inline `http.HandlerFunc` that writes the status you want to assert — do not dial `testRoute`'s placeholder socket.
253
254- [ ] **Step 2: Run the tests and watch them fail**
255
256Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v`
257Expected: FAIL — `a.ServeScheduled undefined`.
258
259- [ ] **Step 3: Thread `stamp` through the three functions**
260
261In `internal/activator/activator.go`, rename `beginRequest` to `begin` and give it the flag. Keep a `beginRequest` wrapper so no other call site changes:
262
263```go
264// begin registers a request against rt's instance, waking it if asleep.
265//
266// stamp is what separates a visitor from the platform's own clock. Both take
267// the in-flight count — a delivery in progress must never be cut off by a
268// concurrent sweep — but only a visitor's release moves lastActive. A
269// scheduled tick that stamped would reset the idle window on every fire, so
270// any schedule tighter than that window would pin its instance awake forever
271// for however little work the handler does.
272func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error) {
273 // ... body unchanged from beginRequest, except the actAwake arm:
274 // return a.releaseFunc(e, stamp), nil
275}
276
277// beginRequest is begin for member traffic: activity counts.
278func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) {
279 return a.begin(ctx, rt, true)
280}
281```
282
283`releaseFunc` takes the flag and guards only the stamp — the decrement is unconditional:
284
285```go
286func (a *Service) releaseFunc(e *actEntry, stamp bool) func() {
287 var once sync.Once
288 return func() {
289 once.Do(func() {
290 a.mu.Lock()
291 e.inflight--
292 if stamp {
293 e.lastActive = a.now()
294 }
295 a.mu.Unlock()
296 })
297 }
298}
299```
300
301Then split `ServeHTTP`'s body into `serve` and give it two doors. Move the entire existing `ServeHTTP` body into `serve`, changing exactly one line — `a.beginRequest(r.Context(), rt)` becomes `a.begin(r.Context(), rt, stamp)`:
302
303```go
304func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
305 return a.serve(w, r, rt, proxy, true)
306}
307
308// ServeScheduled is ServeHTTP for a platform-delivered scheduled tick: same
309// wake, same in-flight hold, same error mapping — no activity credit.
310//
311// A dedicated method rather than a wire marker like SidecarHeader, and the
312// difference is the delivery path, not taste. The sidecar dials its
313// instance's public URL, so its traffic arrives through the edge and the
314// request is the only place a signal could ride. A tick never leaves the
315// process (schedule_wiring.go: "the tick never leaves the box"), so the call
316// site IS the signal, and there is no header for an outsider to send.
317func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
318 return a.serve(w, r, rt, proxy, false)
319}
320
321func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool {
322 // ... today's ServeHTTP body verbatim, with the one call swapped.
323}
324```
325
326Leave the sidecar branch inside `serve` exactly as it is. A tick cannot carry `SidecarHeader` (nothing sets it on the delivery request), so the branch is unreachable from `ServeScheduled` and costs nothing to keep in one place.
327
328- [ ] **Step 4: Run the tests and watch them pass**
329
330Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v`
331Expected: PASS, all three.
332
333- [ ] **Step 5: Full package gate, then commit**
334
335The whole activator suite matters here — `begin` and `releaseFunc` are on every request path.
336
337```bash
338TMPDIR=/tmp go test ./internal/activator -count=1
339gofmt -l internal/activator
340git add internal/activator/activator.go internal/activator/activator_test.go
341git commit -m "activator: a scheduled tick wakes and holds, but earns no activity credit"
342```
343
344---
345
346### Task 3: The guard — don't sleep into an imminent tick
347
348Task 2 alone would turn a 5-minute schedule into 288 wake/sleep cycles a day, each a full SQLite checkpoint-and-restore traversal, on the path where #200 records an unclosed integrity gap. The guard is what makes Task 2 safe.
349
350**Threshold is `cfg.Idle` itself** — no new constant (spec §3.2, Paul's ruling). If the gap to the next tick is shorter than the window we would have stayed awake for anyway, staying awake *is* the cheaper answer.
351
352**Files:**
353- Modify: `internal/activator/activator.go` (`DueStore` interface, `sweepOnce`)
354- Test: `internal/activator/activator_test.go`
355
356**Interfaces:**
357- Consumes: `(*registry.Registry).EarliestTimers() (map[string]int64, error)` from Task 1.
358- Produces: `DueStore` gains `EarliestTimers() (map[string]int64, error)`. Any test fake implementing `DueStore` must grow the method.
359
360- [ ] **Step 1: Write the failing test**
361
362```go
363// The guard: a host whose next platform tick is nearer than the idle window
364// stays resident. Sleeping would buy a few minutes of absence at the price of
365// a full checkpoint-and-restore round trip, and then an immediate wake.
366func TestSweepKeepsHostAwakeWhenATickIsImminent(t *testing.T) {
367 const host = "imminent.bab.oncarlos.com"
368 a, _, clk, reg := testActivator(t, 15*time.Minute)
369 rt := testRoute(host)
370 arrangeAwake(t, a, rt)
371
372 // Next tick in 5 minutes: inside the window.
373 armTimer(t, reg, host, clk.now().Add(5*time.Minute))
374 clk.advance(16 * time.Minute) // lastActive is now well past the window
375
376 a.sweepOnce()
377
378 a.mu.Lock()
379 e := a.entries[rt.Host]
380 a.mu.Unlock()
381 if e == nil || e.state != actAwake {
382 t.Fatal("host hibernated with a tick due inside the idle window")
383 }
384}
385
386// Beyond the window, the sleep is worth taking.
387func TestSweepHibernatesWhenTheNextTickIsFarOff(t *testing.T) {
388 const host = "faroff.bab.oncarlos.com"
389 a, _, clk, reg := testActivator(t, 15*time.Minute)
390 rt := testRoute(host)
391 arrangeAwake(t, a, rt)
392
393 // Next tick in an hour: well beyond the window.
394 armTimer(t, reg, host, clk.now().Add(time.Hour))
395 clk.advance(16 * time.Minute)
396
397 a.sweepOnce()
398
399 waitFor(t, "the host to hibernate with its next tick an hour away", func() bool {
400 a.mu.Lock()
401 defer a.mu.Unlock()
402 return a.entries[rt.Host] == nil
403 })
404}
405
406// A host with no timer rows is untouched by the guard — the overwhelming
407// majority of instances, and the case that must not regress.
408func TestSweepHibernatesAHostWithNoTimers(t *testing.T) {
409 const host = "untimed.bab.oncarlos.com"
410 a, _, clk, _ := testActivator(t, 15*time.Minute) // no timer rows at all
411 rt := testRoute(host)
412 arrangeAwake(t, a, rt)
413 clk.advance(16 * time.Minute)
414
415 a.sweepOnce()
416
417 waitFor(t, "a host with no timers to hibernate as it always did", func() bool {
418 a.mu.Lock()
419 defer a.mu.Unlock()
420 return a.entries[rt.Host] == nil
421 })
422}
423
424// An overdue row (next_at already in the past — the runner has not got to it
425// yet) is the most imminent tick there is, so it holds the host too.
426func TestSweepKeepsHostAwakeForAnOverdueTick(t *testing.T) {
427 const host = "overdue.bab.oncarlos.com"
428 a, _, clk, reg := testActivator(t, 15*time.Minute)
429 rt := testRoute(host)
430 arrangeAwake(t, a, rt)
431
432 // next_at already behind us: the runner will fire it within 15s.
433 armTimer(t, reg, host, clk.now().Add(-time.Minute))
434 clk.advance(16 * time.Minute)
435
436 a.sweepOnce()
437
438 a.mu.Lock()
439 e := a.entries[rt.Host]
440 a.mu.Unlock()
441 if e == nil || e.state != actAwake {
442 t.Fatal("host hibernated with an overdue tick pending")
443 }
444}
445```
446
447Write these two helpers once in `activator_test.go` and use them from all four tests (and from Task 2's, for `arrangeAwake`):
448
449```go
450// arrangeAwake drives rt's host to actAwake the way the rest of this file
451// does — a real beginRequest round trip — so lastActive is stamped and the
452// entry exists.
453func arrangeAwake(t *testing.T, a *Service, rt registry.Route) {
454 t.Helper()
455 release, err := a.beginRequest(context.Background(), rt)
456 if err != nil {
457 t.Fatalf("beginRequest %s: %v", rt.Host, err)
458 }
459 release()
460}
461
462// armTimer puts one armed schedule row on host, due at next.
463func armTimer(t *testing.T, reg *registry.Registry, host string, next time.Time) {
464 t.Helper()
465 if err := reg.UpsertTimer(registry.Timer{
466 Host: host, Name: "sweep", Kind: "schedule", Spec: "every 5m",
467 Path: "/jobs/sweep", NextAt: next.Unix(), DueAt: next.Unix(),
468 }); err != nil {
469 t.Fatalf("UpsertTimer %s: %v", host, err)
470 }
471}
472```
473
474`okHandler()` is the same idea for the proxy argument — an `http.Handler` writing 200 — and belongs beside them.
475
476**There is no `DueStore` fake in this package, and you do not need one.** `testActivator` (line 350) already wires a real `*registry.Registry` as the activator's `DueStore` and returns it. Insert real rows with `reg.UpsertTimer(registry.Timer{...})` and let the guard read them through the `EarliestTimers` query Task 1 added — that makes this test a genuine integration of the two halves rather than a mock of one. Arrange the awake entry as in Task 2, use `clk.advance` to push past the idle window, and assert hibernation with `waitFor` (the stop path is asynchronous).
477
478- [ ] **Step 2: Run the tests and watch them fail**
479
480Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v`
481Expected: FAIL — the fake does not satisfy `DueStore`, or the guard tests hibernate when they should not.
482
483- [ ] **Step 3: Extend the interface and the sweep**
484
485In `internal/activator/activator.go`, add to `DueStore` (around line 173):
486
487```go
488type DueStore interface {
489 SetNextDue(host string, due int64) error
490 DueInstances(now int64) ([]registry.Route, error)
491 // EarliestTimers is the hibernation guard's input: host -> soonest armed
492 // timer. Read with DueInstances, before the sweep takes a.mu.
493 EarliestTimers() (map[string]int64, error)
494}
495```
496
497In `sweepOnce`, join the existing pre-lock read (immediately after the `DueInstances` call, still above `a.mu.Lock()`):
498
499```go
500 // The guard's input, read here for the same reason the due query is:
501 // nothing under a.mu may wait on SQLite. A failure degrades to "no
502 // timers", i.e. today's behaviour — a guard that cannot read must not
503 // pin every instance on the box awake.
504 earliest, err := a.due.EarliestTimers()
505 if err != nil {
506 log.Printf("activator: earliest-timer sweep: %v — hibernating without the schedule guard", err)
507 earliest = nil
508 }
509```
510
511Then in the hibernate arm, replacing the existing `if e.inflight == 0 && ...` block:
512
513```go
514 if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle {
515 // Don't sleep into an imminent tick. A wake/sleep pair is a full
516 // checkpoint-and-restore round trip; spending one to be absent
517 // for less time than we would have idled anyway is a straight
518 // loss. The threshold is the idle window itself rather than a
519 // constant of its own: it is already the deployment's stated
520 // opinion about how long residency is worth holding.
521 if next, ok := earliest[e.host]; ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle {
522 continue
523 }
524 log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
525 a.beginStopLocked(e)
526 continue
527 }
528```
529
530**(overruled — Paul, review round 1, 2026-08-31)** The snippet above and the
531following paragraph originally had the guard's `continue` skip the
532convergence check below it, on the theory that a host held awake for a tick
533converges at its "next genuine sleep." That reasoning is wrong: a tick-held
534host, by construction, never reaches a next sleep — that is precisely what
535the guard guarantees — so it would serve a stale build forever after a
536`carlos ship`, with nothing else to close the gap (`Bounce` only fires on
537config change). The fix is to let the guard's decision fall through to the
538convergence check instead of skipping it:
539
540```go
541 if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle {
542 next, ok := earliest[e.host]
543 heldForTick := ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle
544 if !heldForTick {
545 log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
546 a.beginStopLocked(e)
547 continue
548 }
549 // Fall through to the convergence check below: a host held awake
550 // for its schedule never reaches a "next wake" to converge on, so
551 // it is now one of the always-awake hosts that arm was written
552 // for — the same class as a sidecar host that never sleeps.
553 }
554```
555
556A tick-held host is, in other words, exactly the class the convergence arm's
557own comment already names: "the always-awake ones — notably every sidecar
558host, which never sleeps." It now reaches that arm on every sweep, the same
559as a sidecar, so a ship behind a tick-held host converges within one grace
560window rather than never.
561
562- [ ] **Step 4: Amend the one existing test the guard changes**
563
564`internal/host/schedule_wake_test.go`'s `TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain` freezes the Runner's clock at `time.Date(2026, 8, 23, 13, 7, 0, 0, time.UTC)` while the activator in `wakeFixture` runs on real wall time. After the tick fires, `MarkTimerFired` advances the row to the next 6h occurrence *after that frozen clock* — a timestamp days in the past relative to the activator. The new guard reads that as "a tick is overdue, stay awake", and the test's step 6 ("the sweep hibernates it") would spin to its 30s deadline and fail.
565
566The guard is right and the fixture is not: in production an overdue row is the most imminent tick there is, because the runner fires it within 15s. So fix the clock, not the guard. Change the one line:
567
568```go
569 now := time.Now().UTC()
570```
571
572Every other assertion still holds — `occurrence` stays `now.Unix() - 5`, so the `X-Carlos-Schedule-At` check is unaffected, and step 5's `tm.NextAt > now.Unix()` still holds because the next 6h boundary after a real now is still ahead of it.
573
574Do not change anything else in that file; Task 4 owns it next.
575
576- [ ] **Step 5: Run the tests and watch them pass**
577
578Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v`
579Expected: PASS, all four.
580Run: `TMPDIR=/tmp go test ./internal/host -run TestScheduledTickWakes -count=1 -v`
581Expected: PASS — the amended test still proves the whole wake story.
582
583- [ ] **Step 6: Full package gate, then commit**
584
585```bash
586TMPDIR=/tmp go test ./internal/activator ./internal/host -count=1
587gofmt -l internal/activator internal/host
588git add internal/activator/activator.go internal/activator/activator_test.go internal/host/schedule_wake_test.go
589git commit -m "activator: don't hibernate into a tick due inside the idle window"
590```
591
592---
593
594### Task 4: Point the deliverer at the new door
595
596**Files:**
597- Modify: `internal/host/schedule_wiring.go:326-333` (the handler inside `Deliver`)
598- Test: `internal/host/schedule_wake_test.go`
599
600**Interfaces:**
601- Consumes: `(*activator.Service).ServeScheduled` from Task 2.
602- Produces: nothing new.
603
604- [ ] **Step 1: Write the failing test**
605
606In `internal/host/schedule_wake_test.go`. `lastActive` is unexported and lives in another package, so this test asserts the *behaviour* that flows from it rather than the field — which is the better test anyway, because it is the bug stated as an observation:
607
608```go
609// The bug, as a test: a schedule that fires more often than the idle window
610// used to pin its instance awake forever, because each tick stamped activity
611// and bought another full window. With the tick activity-invisible, the
612// instance sleeps between ticks and wakes again for the next one.
613//
614// No timer rows are armed here, deliberately: this exercises the delivery
615// leg alone. The sweep guard (which reads the timers table) would correctly
616// hold a host awake for a tick due this soon, and that is Task 3's test.
617func TestDeliveryDoesNotHoldTheInstanceAwake(t *testing.T) {
618 const host = "ticky.bab.oncarlos.com"
619 const idle = 300 * time.Millisecond
620 reg, inst, svc, _ := wakeFixture(t, host, idle, func(w http.ResponseWriter, r *http.Request) {
621 w.WriteHeader(http.StatusNoContent)
622 })
623 rt, err := reg.Get(host)
624 if err != nil {
625 t.Fatalf("get route: %v", err)
626 }
627 d := &activatorDeliverer{svc: svc}
628
629 // Tick twice per idle window for a few windows. Under the old accounting
630 // the instance could never reach the sweep's threshold; now it does,
631 // between ticks, and the next tick wakes it again.
632 deadline := time.Now().Add(20 * time.Second)
633 for inst.startCount() < 2 && time.Now().Before(deadline) {
634 req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil)
635 if _, err := d.Deliver(context.Background(), *rt, req); err != nil {
636 t.Fatalf("Deliver: %v", err)
637 }
638 time.Sleep(idle / 2)
639 }
640 if n := inst.startCount(); n < 2 {
641 t.Fatalf("the instance started %d time(s): it never hibernated between ticks, so the tick is still buying residency", n)
642 }
643}
644
645// The app-facing half of the tick contract is untouched by any of this.
646func TestDeliveryStillCarriesScheduleAtHeader(t *testing.T) {
647 const host = "hdr.bab.oncarlos.com"
648 seen := make(chan string, 1)
649 reg, _, svc, _ := wakeFixture(t, host, time.Hour, func(w http.ResponseWriter, r *http.Request) {
650 select {
651 case seen <- r.Header.Get("X-Carlos-Schedule-At"):
652 default:
653 }
654 w.WriteHeader(http.StatusNoContent)
655 })
656 rt, err := reg.Get(host)
657 if err != nil {
658 t.Fatalf("get route: %v", err)
659 }
660 d := &activatorDeliverer{svc: svc}
661
662 req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil)
663 req.Header.Set("X-Carlos-Schedule-At", "1788126300")
664 status, err := d.Deliver(context.Background(), *rt, req)
665 if err != nil {
666 t.Fatalf("Deliver: %v", err)
667 }
668 if status != http.StatusNoContent {
669 t.Fatalf("status = %d, want 204", status)
670 }
671 select {
672 case got := <-seen:
673 if got != "1788126300" {
674 t.Errorf("handler saw X-Carlos-Schedule-At = %q, want it passed through unchanged", got)
675 }
676 case <-time.After(10 * time.Second):
677 t.Fatal("the handler never ran")
678 }
679}
680```
681
682`wakeFixture(t, host, idle, handler) (*registry.Registry, *fakeInstance, *activator.Service, string)` (line 157) and `fakeInstance.startCount()` (line 108) are this file's existing helpers — verified, use them. `reg.Get(host)` returns `(*registry.Route, error)` (`internal/registry/registry.go:667`).
683
684- [ ] **Step 2: Run the tests and watch them fail**
685
686Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v`
687Expected: FAIL on the first — activity is stamped, because `Deliver` still calls `ServeHTTP`.
688
689- [ ] **Step 3: Swap the call**
690
691In `internal/host/schedule_wiring.go`, inside `Deliver`'s handler:
692
693```go
694 h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
695 // ServeScheduled, not ServeHTTP: a tick wakes the instance and is
696 // held in flight for its duration, but must not reset the idle
697 // clock. Reports false for a route it does not manage (not an
698 // instance, or not hibernate-flagged); that route is proxied
699 // straight, the same call shape the edge itself uses.
700 if d.svc == nil || !d.svc.ServeScheduled(w, r, rt, proxy) {
701 proxy.ServeHTTP(w, r)
702 }
703 })
704```
705
706Then update the `activatorDeliverer` doc comment (`schedule_wiring.go:213-220`), whose first bullet now understates what happens — it should read that a hibernating instance wakes for its tick and is held awake for the tick's duration, **and that the tick alone does not keep it awake afterwards.**
707
708- [ ] **Step 4: Run the tests and watch them pass**
709
710Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v`
711Expected: PASS, both.
712
713- [ ] **Step 5: Full package gate, then commit**
714
715```bash
716TMPDIR=/tmp go test ./internal/host -count=1
717gofmt -l internal/host
718git add internal/host/schedule_wiring.go internal/host/schedule_wake_test.go
719git commit -m "host: schedule ticks deliver through the non-stamping activator door"
720```
721
722---
723
724### Task 5: `holds_awake` — let the box say what it knows
725
726The box is the only place that holds both numbers: the timer rows and the activator's idle window. So the box decides and reports; the CLI only renders.
727
728**Files:**
729- Modify: `internal/schedule/status.go:24` (add the field)
730- Modify: `internal/schedule/converge.go:313-334` (`StatusFor`, `toStatus`, plus a new `holdsAwake`)
731- Modify: `internal/schedule/converge.go` (`Converger` struct: an `Idle` field) and `internal/schedule/runner.go:53` (`Runner` struct: an `Idle` field)
732- Modify: `internal/host/schedule_wiring.go` (`newScheduleDeps`: pass the window through)
733- Modify: `internal/activator/activator.go` (expose the window)
734- Test: `internal/schedule/converge_test.go`, `internal/schedule/status_test.go`
735
736**Interfaces:**
737- Consumes: nothing from earlier tasks.
738- Produces:
739 - `TimerStatus.HoldsAwake bool` with JSON tag `holds_awake`. Task 6 renders it.
740 - `func (a *Service) IdleWindow() time.Duration`.
741 - `StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error)` — one added parameter.
742
743- [ ] **Step 1: Write the failing test**
744
745```go
746func TestHoldsAwakeWhenTheIntervalIsInsideTheIdleWindow(t *testing.T) {
747 now := time.Unix(1788120000, 0).UTC()
748 cases := []struct {
749 name string
750 kind string
751 spec string
752 idle time.Duration
753 want bool
754 }{
755 {"tighter than the window", KindSchedule, "every 5m", 15 * time.Minute, true},
756 {"exactly the window", KindSchedule, "every 15m", 15 * time.Minute, true},
757 {"wider than the window", KindSchedule, "every 1h", 15 * time.Minute, false},
758 {"tight, short window", KindSchedule, "every 5m", 90 * time.Second, false},
759 {"a once-timer never holds", KindOnce, "at 1788130000", 15 * time.Minute, false},
760 {"hibernation not armed", KindSchedule, "every 1m", 0, false},
761 {"hourly cron", KindSchedule, "cron 0 * * * *", 15 * time.Minute, false},
762 {"every-minute cron", KindSchedule, "cron * * * * *", 15 * time.Minute, true},
763 {"unparseable spec is not a claim", KindSchedule, "banana", 15 * time.Minute, false},
764 }
765 for _, tc := range cases {
766 t.Run(tc.name, func(t *testing.T) {
767 got := holdsAwake(registry.Timer{Kind: tc.kind, Spec: tc.spec}, tc.idle, now)
768 if got != tc.want {
769 t.Errorf("holdsAwake(%q, idle=%s) = %v, want %v", tc.spec, tc.idle, got, tc.want)
770 }
771 })
772 }
773}
774
775// The wire shape is additive: an object written before this field existed
776// must still parse, and read back as false.
777func TestStatusWithoutHoldsAwakeStillParses(t *testing.T) {
778 raw := []byte(`{"schema":1,"host":"h","box":"b","reported_at":"2026-08-30T21:40:05Z","timers":[{"name":"sweep","kind":"schedule","spec":"every 5m","path":"/jobs/sweep","next_at":1788126300}]}`)
779 var st Status
780 if err := json.Unmarshal(raw, &st); err != nil {
781 t.Fatalf("unmarshal a pre-holds_awake status: %v", err)
782 }
783 if len(st.Timers) != 1 || st.Timers[0].HoldsAwake {
784 t.Errorf("HoldsAwake = %v, want false for an object written before the field existed", st.Timers[0].HoldsAwake)
785 }
786}
787```
788
789- [ ] **Step 2: Run the tests and watch them fail**
790
791Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v`
792Expected: FAIL — `undefined: holdsAwake`, and `st.Timers[0].HoldsAwake` undefined.
793
794- [ ] **Step 3: Implement the field, the predicate and the plumbing**
795
796`internal/schedule/status.go`, appended to `TimerStatus` (additive, last field):
797
798```go
799 // HoldsAwake is the box's warning: this row fires at least as often as
800 // the deployment's idle window, so its instance never hibernates between
801 // ticks and accrues instance-hours around the clock. Computed by the box,
802 // which is the only place that holds both numbers. False from a box that
803 // predates the field, and false wherever hibernation is not armed.
804 HoldsAwake bool `json:"holds_awake,omitempty"`
805```
806
807`internal/schedule/converge.go`, next to `toStatus`:
808
809```go
810// holdsAwake reports whether t's interval is tight enough that its instance
811// never gets to sleep: the gap between consecutive occurrences is no wider
812// than idle, so the activator's guard (and, before it, the tick's own wake)
813// keeps the process resident indefinitely.
814//
815// The gap comes from two Next calls rather than Spec.Every, so a cron gets a
816// real answer instead of a special case. idle <= 0 means hibernation is not
817// armed on this box: nothing holds anything awake, because nothing sleeps.
818// A once-timer fires once and cannot hold anything. An unparseable spec is
819// not evidence of a problem, so it makes no claim.
820func holdsAwake(t registry.Timer, idle time.Duration, now time.Time) bool {
821 if idle <= 0 || t.Kind != KindSchedule {
822 return false
823 }
824 sp, err := ParseSpec(t.Spec)
825 if err != nil {
826 return false
827 }
828 first := sp.Next(now)
829 if first.IsZero() {
830 return false
831 }
832 second := sp.Next(first)
833 if second.IsZero() {
834 return false
835 }
836 return second.Sub(first) <= idle
837}
838```
839
840`toStatus` and `StatusFor` take the window:
841
842```go
843func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error) {
844 // ... unchanged, except:
845 // st.Timers = append(st.Timers, toStatus(r, idle, now))
846}
847
848func toStatus(t registry.Timer, idle time.Duration, now time.Time) TimerStatus {
849 return TimerStatus{
850 Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path,
851 NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt,
852 LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts,
853 HoldsAwake: holdsAwake(t, idle, now),
854 }
855}
856```
857
858Add `Idle time.Duration` to both `Converger` (`converge.go:36` area) and `Runner` (`runner.go:53` area), and pass it at each `StatusFor` call site (`converge.go:174`, `runner.go:179`).
859
860Expose the window from the activator, in `internal/activator/activator.go`:
861
862```go
863// IdleWindow is cfg.Idle, for the box-side reporters that must tell a member
864// whether their schedule is tight enough to hold the instance resident. Read
865// only; the window is fixed at construction from CARLOS_HIBERNATE_IDLE.
866func (a *Service) IdleWindow() time.Duration { return a.cfg.Idle }
867```
868
869And thread it in `newScheduleDeps` (`internal/host/schedule_wiring.go:66`), where `svc` may legitimately be nil:
870
871```go
872 // Zero when no activator is armed on this box: nothing hibernates, so no
873 // schedule can hold anything awake, and holdsAwake reports false for all
874 // of them.
875 var idle time.Duration
876 if svc != nil {
877 idle = svc.IdleWindow()
878 }
879```
880
881Set `Idle: idle` on both the `Runner` literal and the `NewConverger` call. If `NewConverger`'s signature has no room, add the field after construction — follow whichever shape that constructor already uses.
882
883- [ ] **Step 4: Run the tests and watch them pass**
884
885Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v`
886Expected: PASS, all cases.
887
888- [ ] **Step 5: Full gate across the three touched packages, then commit**
889
890```bash
891TMPDIR=/tmp go test ./internal/schedule ./internal/host ./internal/activator -count=1
892gofmt -l internal/schedule internal/host internal/activator
893git add internal/schedule internal/host/schedule_wiring.go internal/activator/activator.go
894git commit -m "schedule: report when a schedule's interval holds its instance awake"
895```
896
897---
898
899### Task 6: Say it in `carlos schedule ls`
900
901**Files:**
902- Modify: `schedule.go:337-350` (the per-timer render loop)
903- Test: `schedule_test.go`
904
905**Interfaces:**
906- Consumes: `schedule.TimerStatus.HoldsAwake` from Task 5.
907- Produces: nothing.
908
909- [ ] **Step 1: Write the failing test**
910
911In `schedule_test.go`, following how the existing render tests capture output:
912
913```go
914func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) {
915 var b bytes.Buffer
916 now := time.Unix(1788126000, 0).UTC()
917 inst := schedule.Status{
918 Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding",
919 ReportedAt: now.Format(time.RFC3339),
920 Timers: []schedule.TimerStatus{{
921 Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m",
922 Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(),
923 LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1,
924 HoldsAwake: true,
925 }},
926 }
927 renderScheduleInstance(&b, inst, now)
928
929 out := b.String()
930 if !strings.Contains(out, "sweep") {
931 t.Fatalf("row missing from output:\n%s", out)
932 }
933 if !strings.Contains(out, "never hibernates") {
934 t.Errorf("no holds-awake warning in output:\n%s", out)
935 }
936}
937
938func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) {
939 var b bytes.Buffer
940 now := time.Unix(1788126000, 0).UTC()
941 inst := schedule.Status{
942 Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding",
943 ReportedAt: now.Format(time.RFC3339),
944 Timers: []schedule.TimerStatus{{
945 Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h",
946 Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(),
947 }},
948 }
949 renderScheduleInstance(&b, inst, now)
950
951 if strings.Contains(b.String(), "never hibernates") {
952 t.Errorf("warned about a 6-hourly schedule:\n%s", b.String())
953 }
954}
955```
956
957- [ ] **Step 2: Run the tests and watch them fail**
958
959Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v`
960Expected: FAIL — the warning string is absent.
961
962- [ ] **Step 3: Render it**
963
964In `schedule.go`, inside the per-timer loop, after the existing `fmt.Fprintf(w, " %-*snext %s last %s\n", ...)`:
965
966```go
967 if t.HoldsAwake {
968 // The box worked this out; we only pass it on. Worth a whole
969 // line rather than a marker on the row: the member's next
970 // question is "so what?", and the answer is what they are being
971 // billed for.
972 fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "")
973 }
974```
975
976- [ ] **Step 4: Run the tests and watch them pass**
977
978Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v`
979Expected: PASS, both.
980
981- [ ] **Step 5: Whole-repo gate, then commit**
982
983```bash
984TMPDIR=/tmp go build ./... && TMPDIR=/tmp go vet ./... && TMPDIR=/tmp make test
985gofmt -l .
986git add schedule.go schedule_test.go
987git commit -m "cli: schedule ls says when a schedule holds its instance awake"
988```
989
990---
991
992### Task 7: Gate on real CI and open the PR
993
994**Files:** none.
995
996- [ ] **Step 1: Push to the CI remote**
997
998```bash
999git push amadan HEAD:james-hogging
1000```
1001
1002- [ ] **Step 2: Read the run**
1003
1004`10-gate` is `go build ./...` then `make test`; `20-cross@5` adds darwin/windows/linux-arm64 compiles. Do not proceed until it is green — a branch pushed only to `origin` has been gated by nothing.
1005
1006- [ ] **Step 3: Confirm the change is inert, on paper**
1007
1008Before opening the PR, state in its body what the flagship will do after the roll and why nothing should visibly change: idle 15m, tightest schedule 5m, guard holds. Name the two apps to watch — `jam.bac.oncarlos.com` must stay resident, `seapointish.bag.oncarlos.com` must keep doing ~10 wake/sleep cycles a day. A change in either is a bug.
1009
1010- [ ] **Step 4: Open the PR**
1011
1012```bash
1013git push origin james-hogging
1014gh pr create --repo carlosframework/platform \
1015 --title "Scheduled ticks must not hold an instance awake" \
1016 --body "..." # spec link, the inert-on-roll argument, the two apps to watch
1017```
1018
1019---
1020
1021## Self-Review
1022
1023**Spec coverage.** §3.1 → Tasks 2 and 4. §3.2 → Tasks 1 and 3. §3.3 → Tasks 5 and 6. §4 (inert on the flagship) → the Global Constraints and Task 7 Step 3. §5's exclusions are constraints, not tasks, and are listed in Global Constraints. §6's test list maps onto the tests in Tasks 2-5. §7 → Task 7.
1024
1025**Placeholders.** None. Every step carries the code it needs. Test helper names are explicitly flagged as stand-ins for whatever each file already provides, with the instruction to reuse rather than invent — that is a real instruction, not a deferral.
1026
1027**Type consistency.** `EarliestTimers() (map[string]int64, error)` is defined in Task 1, added to `DueStore` in Task 3, and called as `earliest[e.host]` there. `ServeScheduled` has the same four-argument signature as `ServeHTTP` in Tasks 2 and 4. `holdsAwake(registry.Timer, time.Duration, time.Time) bool` is defined and called with that order in Task 5. `HoldsAwake` is the field name in Tasks 5 and 6; `holds_awake` is the JSON tag in both.
1028
1029**One risk worth naming for the executor:** Task 2 renames a function on the hot path of every request (`beginRequest` → `begin` with a wrapper). If the package has other callers of `beginRequest` or `releaseFunc` beyond the ones named here, update them to the wrapper rather than threading `stamp` further — only the schedule deliverer should ever pass false.
1030