| 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 | |
| 27 | The 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 | |
| 39 | In `internal/registry/timers_test.go`, following the file's existing setup helper for a temp registry: |
| 40 | |
| 41 | ```go |
| 42 | func 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 | |
| 69 | func 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 | |
| 85 | Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v` |
| 86 | Expected: FAIL — `r.EarliestTimers undefined (type *Registry has no field or method EarliestTimers)`. |
| 87 | |
| 88 | - [ ] **Step 3: Implement it** |
| 89 | |
| 90 | Append 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. |
| 102 | func (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 | |
| 123 | Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v` |
| 124 | Expected: PASS, both tests. |
| 125 | |
| 126 | - [ ] **Step 5: Full package gate, then commit** |
| 127 | |
| 128 | ```bash |
| 129 | TMPDIR=/tmp go test ./internal/registry -count=1 |
| 130 | gofmt -l internal/registry |
| 131 | git add internal/registry/timers.go internal/registry/timers_test.go |
| 132 | git 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 | |
| 141 | The 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 | |
| 155 | In `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. |
| 161 | func 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. |
| 194 | func 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. |
| 223 | func 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 | |
| 252 | To 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 | |
| 256 | Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v` |
| 257 | Expected: FAIL — `a.ServeScheduled undefined`. |
| 258 | |
| 259 | - [ ] **Step 3: Thread `stamp` through the three functions** |
| 260 | |
| 261 | In `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. |
| 272 | func (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. |
| 278 | func (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 |
| 286 | func (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 | |
| 301 | Then 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 |
| 304 | func (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. |
| 317 | func (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 | |
| 321 | func (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 | |
| 326 | Leave 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 | |
| 330 | Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v` |
| 331 | Expected: PASS, all three. |
| 332 | |
| 333 | - [ ] **Step 5: Full package gate, then commit** |
| 334 | |
| 335 | The whole activator suite matters here — `begin` and `releaseFunc` are on every request path. |
| 336 | |
| 337 | ```bash |
| 338 | TMPDIR=/tmp go test ./internal/activator -count=1 |
| 339 | gofmt -l internal/activator |
| 340 | git add internal/activator/activator.go internal/activator/activator_test.go |
| 341 | git 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 | |
| 348 | Task 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. |
| 366 | func 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. |
| 387 | func 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. |
| 408 | func 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. |
| 426 | func 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 | |
| 447 | Write 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. |
| 453 | func 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. |
| 463 | func 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 | |
| 480 | Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v` |
| 481 | Expected: 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 | |
| 485 | In `internal/activator/activator.go`, add to `DueStore` (around line 173): |
| 486 | |
| 487 | ```go |
| 488 | type 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 | |
| 497 | In `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 | |
| 511 | Then 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 |
| 531 | following paragraph originally had the guard's `continue` skip the |
| 532 | convergence check below it, on the theory that a host held awake for a tick |
| 533 | converges at its "next genuine sleep." That reasoning is wrong: a tick-held |
| 534 | host, by construction, never reaches a next sleep — that is precisely what |
| 535 | the 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 |
| 537 | config change). The fix is to let the guard's decision fall through to the |
| 538 | convergence 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 | |
| 556 | A tick-held host is, in other words, exactly the class the convergence arm's |
| 557 | own comment already names: "the always-awake ones — notably every sidecar |
| 558 | host, which never sleeps." It now reaches that arm on every sweep, the same |
| 559 | as a sidecar, so a ship behind a tick-held host converges within one grace |
| 560 | window 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 | |
| 566 | The 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 | |
| 572 | Every 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 | |
| 574 | Do not change anything else in that file; Task 4 owns it next. |
| 575 | |
| 576 | - [ ] **Step 5: Run the tests and watch them pass** |
| 577 | |
| 578 | Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v` |
| 579 | Expected: PASS, all four. |
| 580 | Run: `TMPDIR=/tmp go test ./internal/host -run TestScheduledTickWakes -count=1 -v` |
| 581 | Expected: PASS — the amended test still proves the whole wake story. |
| 582 | |
| 583 | - [ ] **Step 6: Full package gate, then commit** |
| 584 | |
| 585 | ```bash |
| 586 | TMPDIR=/tmp go test ./internal/activator ./internal/host -count=1 |
| 587 | gofmt -l internal/activator internal/host |
| 588 | git add internal/activator/activator.go internal/activator/activator_test.go internal/host/schedule_wake_test.go |
| 589 | git 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 | |
| 606 | In `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. |
| 617 | func 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. |
| 646 | func 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 | |
| 686 | Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v` |
| 687 | Expected: FAIL on the first — activity is stamped, because `Deliver` still calls `ServeHTTP`. |
| 688 | |
| 689 | - [ ] **Step 3: Swap the call** |
| 690 | |
| 691 | In `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 | |
| 706 | Then 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 | |
| 710 | Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v` |
| 711 | Expected: PASS, both. |
| 712 | |
| 713 | - [ ] **Step 5: Full package gate, then commit** |
| 714 | |
| 715 | ```bash |
| 716 | TMPDIR=/tmp go test ./internal/host -count=1 |
| 717 | gofmt -l internal/host |
| 718 | git add internal/host/schedule_wiring.go internal/host/schedule_wake_test.go |
| 719 | git 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 | |
| 726 | The 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 |
| 746 | func 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. |
| 777 | func 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 | |
| 791 | Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v` |
| 792 | Expected: 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. |
| 820 | func 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 |
| 843 | func 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 | |
| 848 | func 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 | |
| 858 | Add `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 | |
| 860 | Expose 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. |
| 866 | func (a *Service) IdleWindow() time.Duration { return a.cfg.Idle } |
| 867 | ``` |
| 868 | |
| 869 | And 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 | |
| 881 | Set `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 | |
| 885 | Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v` |
| 886 | Expected: PASS, all cases. |
| 887 | |
| 888 | - [ ] **Step 5: Full gate across the three touched packages, then commit** |
| 889 | |
| 890 | ```bash |
| 891 | TMPDIR=/tmp go test ./internal/schedule ./internal/host ./internal/activator -count=1 |
| 892 | gofmt -l internal/schedule internal/host internal/activator |
| 893 | git add internal/schedule internal/host/schedule_wiring.go internal/activator/activator.go |
| 894 | git 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 | |
| 911 | In `schedule_test.go`, following how the existing render tests capture output: |
| 912 | |
| 913 | ```go |
| 914 | func 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 | |
| 938 | func 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 | |
| 959 | Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v` |
| 960 | Expected: FAIL — the warning string is absent. |
| 961 | |
| 962 | - [ ] **Step 3: Render it** |
| 963 | |
| 964 | In `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 | |
| 978 | Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v` |
| 979 | Expected: PASS, both. |
| 980 | |
| 981 | - [ ] **Step 5: Whole-repo gate, then commit** |
| 982 | |
| 983 | ```bash |
| 984 | TMPDIR=/tmp go build ./... && TMPDIR=/tmp go vet ./... && TMPDIR=/tmp make test |
| 985 | gofmt -l . |
| 986 | git add schedule.go schedule_test.go |
| 987 | git 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 |
| 999 | git 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 | |
| 1008 | Before 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 |
| 1013 | git push origin james-hogging |
| 1014 | gh 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 | |