carlos / platform Public
Scheduled ticks and hibernation — Implementation Plan
> 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.
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.
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.
Tech Stack: Go 1.x, SQLite (internal/registry), no new dependencies.
Spec: docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md
Global Constraints
- 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.combeginning to hibernate on this branch is a bug, not a win. (Spec §4.) - Wire shapes are additive. A
schedule-status/<host>.jsonwritten before this change must still parse. Never remove or retype an existing field. (AGENTS.md, "Rules this repo has paid for".) - Build gate before any commit lands:
go build ./...,go vet ./...,go test ./... -count=1,gofmt -l .clean over the whole tree.make testcovers the last three. - Run Go commands with
TMPDIR=/tmp, or three unrelated socket tests fail with phantom errors (they fail on untouchedmaintoo). Sandbox note from AGENTS.md:GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off. - CI is the
amadanremote, not GitHub. A branch pushed only tooriginhas been gated by nothing. Push toamadanand read the run before calling it green. 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.- Nothing in this plan adds a registry column, a tuning constant, a wire marker, or a
NextDuechange. All four were considered and rejected in the spec (§2, §3.1, §5).
---
Task 1: EarliestTimers — one query the guard can afford
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).
Files:
- Modify:
internal/registry/timers.go(append afterDueTimers) - Test:
internal/registry/timers_test.go
Interfaces:
- Consumes: nothing from earlier tasks.
- Produces:
func (r *Registry) EarliestTimers() (map[string]int64, error)— host → the smallestnext_at > 0among that host's timer rows. Hosts with no armed rows are absent from the map. Task 3 consumes it.
- [ ] Step 1: Write the failing test
In internal/registry/timers_test.go, following the file's existing setup helper for a temp registry:
func TestEarliestTimersPerHost(t *testing.T) {
r := openTestRegistry(t)
rows := []Timer{
// Two rows for one host: the earliest wins.
{Host: "a.example", Name: "late", Kind: "schedule", Spec: "every 1h", Path: "/x", NextAt: 5000, DueAt: 5000},
{Host: "a.example", Name: "soon", Kind: "schedule", Spec: "every 5m", Path: "/y", NextAt: 1200, DueAt: 1200},
// A second host is reported independently.
{Host: "b.example", Name: "only", Kind: "schedule", Spec: "every 5m", Path: "/z", NextAt: 3000, DueAt: 3000},
// next_at == 0 means "not armed" and must never appear.
{Host: "c.example", Name: "parked", Kind: "schedule", Spec: "every 5m", Path: "/p", NextAt: 0, DueAt: 0},
}
for _, tm := range rows {
if err := r.UpsertTimer(tm); err != nil {
t.Fatalf("UpsertTimer %s/%s: %v", tm.Host, tm.Name, err)
}
}
got, err := r.EarliestTimers()
if err != nil {
t.Fatalf("EarliestTimers: %v", err)
}
want := map[string]int64{"a.example": 1200, "b.example": 3000}
if !reflect.DeepEqual(got, want) {
t.Errorf("EarliestTimers = %v, want %v", got, want)
}
}
func TestEarliestTimersEmpty(t *testing.T) {
r := openTestRegistry(t)
got, err := r.EarliestTimers()
if err != nil {
t.Fatalf("EarliestTimers: %v", err)
}
if len(got) != 0 {
t.Errorf("EarliestTimers on an empty table = %v, want empty", got)
}
}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.
- [ ] Step 2: Run the test and watch it fail
Run: TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v Expected: FAIL — r.EarliestTimers undefined (type *Registry has no field or method EarliestTimers).
- [ ] Step 3: Implement it
Append to internal/registry/timers.go:
// EarliestTimers is the hibernation guard's batch: for every host with at
// least one armed row, the soonest next_at among them. One query, because
// the activator's sweep reads it BEFORE taking its mutex (activator.go's
// "nothing under a.mu should wait on SQLite") and a per-entry query inside
// the sweep loop would put SQLite exactly where that comment forbids it.
//
// next_at = 0 is "not armed" — a parked cron, or a row converge wrote and
// the runner has not yet dated — and is excluded here rather than by the
// caller, matching DueTimers' own `next_at > 0` gate.
func (r *Registry) EarliestTimers() (map[string]int64, error) {
rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64)
for rows.Next() {
var host string
var next int64
if err := rows.Scan(&host, &next); err != nil {
return nil, err
}
out[host] = next
}
return out, rows.Err()
}- [ ] Step 4: Run the test and watch it pass
Run: TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v Expected: PASS, both tests.
- [ ] Step 5: Full package gate, then commit
TMPDIR=/tmp go test ./internal/registry -count=1
gofmt -l internal/registry
git add internal/registry/timers.go internal/registry/timers_test.go
git commit -m "registry: EarliestTimers, one query for the hibernation guard"---
Task 2: A tick that wakes but does not count
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.
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.
Files:
- Modify:
internal/activator/activator.go(beginRequest,releaseFunc,ServeHTTP) - Test:
internal/activator/activator_test.go
Interfaces:
- Consumes: nothing from earlier tasks.
- Produces: -
func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool— same contract asServeHTTP(returns false for a route it does not manage) but the request earns no activity credit. Task 4 consumes it. - Unexported:func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error)andfunc (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool.
- [ ] Step 1: Write the failing test
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:
// A scheduled tick must not move lastActive: it is the platform's own clock
// poking the instance, not a visitor. A tick that counted would make any
// schedule tighter than the idle window a permanent keep-awake.
func TestServeScheduledDoesNotStampActivity(t *testing.T) {
const host = "tick.bab.oncarlos.com"
a, _, clk, _ := testActivator(t, time.Hour)
rt := testRoute(host)
arrangeAwake(t, a, rt) // a real beginRequest round trip, then release
a.mu.Lock()
before := a.entries[rt.Host].lastActive
a.mu.Unlock()
clk.advance(time.Minute)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil)
if !a.ServeScheduled(rec, req, rt, okHandler()) {
t.Fatal("ServeScheduled returned false for a managed route")
}
a.mu.Lock()
after := a.entries[rt.Host].lastActive
inflight := a.entries[rt.Host].inflight
a.mu.Unlock()
if !after.Equal(before) {
t.Errorf("lastActive moved %v -> %v; a scheduled tick must not count as activity", before, after)
}
if inflight != 0 {
t.Errorf("inflight = %d after delivery, want 0 (the hold is released even though the stamp is not written)", inflight)
}
}
// The other half of the same coin, asserted against the first so the two
// paths can never quietly become one again — which is the bug this fixes.
func TestServeHTTPStillStampsActivity(t *testing.T) {
const host = "visitor.bab.oncarlos.com"
a, _, clk, _ := testActivator(t, time.Hour)
rt := testRoute(host)
arrangeAwake(t, a, rt)
a.mu.Lock()
before := a.entries[rt.Host].lastActive
a.mu.Unlock()
clk.advance(time.Minute)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://carlos/", nil)
if !a.ServeHTTP(rec, req, rt, okHandler()) {
t.Fatal("ServeHTTP returned false for a managed route")
}
a.mu.Lock()
after := a.entries[rt.Host].lastActive
a.mu.Unlock()
if !after.After(before) {
t.Errorf("lastActive did not move on a visitor request (%v -> %v)", before, after)
}
}
// A tick is a wake source and must stay one: an instance asleep when its
// timer comes due has to be woken by the delivery itself.
func TestServeScheduledWakesASleepingInstance(t *testing.T) {
const host = "asleep.bab.oncarlos.com"
a, _, _, _ := testActivator(t, time.Hour) // no entries yet: the host is asleep
rt := testRoute(host)
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil)
if !a.ServeScheduled(rec, req, rt, okHandler()) {
t.Fatal("ServeScheduled returned false for a managed route")
}
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200 — the tick must wake the instance, not be refused", rec.Code)
}
a.mu.Lock()
e := a.entries[rt.Host]
a.mu.Unlock()
if e == nil || e.state != actAwake {
t.Fatal("instance is not awake after a scheduled delivery")
}
}The helpers above are placeholders. These are the real ones, verified in internal/activator/activator_test.go — use them:
testActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *registry.Registry)(line 350) — builds the Service over the fake backend witha.now = clk.nowalready wired.testRoute(host string) registry.Route(line 363) — a hibernate-flagged instance route.clk.advance(d)(line 339) — moves the fake clock.waitFor(t, what string, cond func() bool)(line 387) — poll for an async stop;beginStopLockedruns on its own goroutine, so never assert hibernation synchronously.
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.
- [ ] Step 2: Run the tests and watch them fail
Run: TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v Expected: FAIL — a.ServeScheduled undefined.
- [ ] Step 3: Thread
stampthrough the three functions
In internal/activator/activator.go, rename beginRequest to begin and give it the flag. Keep a beginRequest wrapper so no other call site changes:
// begin registers a request against rt's instance, waking it if asleep.
//
// stamp is what separates a visitor from the platform's own clock. Both take
// the in-flight count — a delivery in progress must never be cut off by a
// concurrent sweep — but only a visitor's release moves lastActive. A
// scheduled tick that stamped would reset the idle window on every fire, so
// any schedule tighter than that window would pin its instance awake forever
// for however little work the handler does.
func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error) {
// ... body unchanged from beginRequest, except the actAwake arm:
// return a.releaseFunc(e, stamp), nil
}
// beginRequest is begin for member traffic: activity counts.
func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) {
return a.begin(ctx, rt, true)
}releaseFunc takes the flag and guards only the stamp — the decrement is unconditional:
func (a *Service) releaseFunc(e *actEntry, stamp bool) func() {
var once sync.Once
return func() {
once.Do(func() {
a.mu.Lock()
e.inflight--
if stamp {
e.lastActive = a.now()
}
a.mu.Unlock()
})
}
}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):
func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
return a.serve(w, r, rt, proxy, true)
}
// ServeScheduled is ServeHTTP for a platform-delivered scheduled tick: same
// wake, same in-flight hold, same error mapping — no activity credit.
//
// A dedicated method rather than a wire marker like SidecarHeader, and the
// difference is the delivery path, not taste. The sidecar dials its
// instance's public URL, so its traffic arrives through the edge and the
// request is the only place a signal could ride. A tick never leaves the
// process (schedule_wiring.go: "the tick never leaves the box"), so the call
// site IS the signal, and there is no header for an outsider to send.
func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool {
return a.serve(w, r, rt, proxy, false)
}
func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool {
// ... today's ServeHTTP body verbatim, with the one call swapped.
}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.
- [ ] Step 4: Run the tests and watch them pass
Run: TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v Expected: PASS, all three.
- [ ] Step 5: Full package gate, then commit
The whole activator suite matters here — begin and releaseFunc are on every request path.
TMPDIR=/tmp go test ./internal/activator -count=1
gofmt -l internal/activator
git add internal/activator/activator.go internal/activator/activator_test.go
git commit -m "activator: a scheduled tick wakes and holds, but earns no activity credit"---
Task 3: The guard — don't sleep into an imminent tick
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.
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.
Files:
- Modify:
internal/activator/activator.go(DueStoreinterface,sweepOnce) - Test:
internal/activator/activator_test.go
Interfaces:
- Consumes:
(*registry.Registry).EarliestTimers() (map[string]int64, error)from Task 1. - Produces:
DueStoregainsEarliestTimers() (map[string]int64, error). Any test fake implementingDueStoremust grow the method.
- [ ] Step 1: Write the failing test
// The guard: a host whose next platform tick is nearer than the idle window
// stays resident. Sleeping would buy a few minutes of absence at the price of
// a full checkpoint-and-restore round trip, and then an immediate wake.
func TestSweepKeepsHostAwakeWhenATickIsImminent(t *testing.T) {
const host = "imminent.bab.oncarlos.com"
a, _, clk, reg := testActivator(t, 15*time.Minute)
rt := testRoute(host)
arrangeAwake(t, a, rt)
// Next tick in 5 minutes: inside the window.
armTimer(t, reg, host, clk.now().Add(5*time.Minute))
clk.advance(16 * time.Minute) // lastActive is now well past the window
a.sweepOnce()
a.mu.Lock()
e := a.entries[rt.Host]
a.mu.Unlock()
if e == nil || e.state != actAwake {
t.Fatal("host hibernated with a tick due inside the idle window")
}
}
// Beyond the window, the sleep is worth taking.
func TestSweepHibernatesWhenTheNextTickIsFarOff(t *testing.T) {
const host = "faroff.bab.oncarlos.com"
a, _, clk, reg := testActivator(t, 15*time.Minute)
rt := testRoute(host)
arrangeAwake(t, a, rt)
// Next tick in an hour: well beyond the window.
armTimer(t, reg, host, clk.now().Add(time.Hour))
clk.advance(16 * time.Minute)
a.sweepOnce()
waitFor(t, "the host to hibernate with its next tick an hour away", func() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.entries[rt.Host] == nil
})
}
// A host with no timer rows is untouched by the guard — the overwhelming
// majority of instances, and the case that must not regress.
func TestSweepHibernatesAHostWithNoTimers(t *testing.T) {
const host = "untimed.bab.oncarlos.com"
a, _, clk, _ := testActivator(t, 15*time.Minute) // no timer rows at all
rt := testRoute(host)
arrangeAwake(t, a, rt)
clk.advance(16 * time.Minute)
a.sweepOnce()
waitFor(t, "a host with no timers to hibernate as it always did", func() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.entries[rt.Host] == nil
})
}
// An overdue row (next_at already in the past — the runner has not got to it
// yet) is the most imminent tick there is, so it holds the host too.
func TestSweepKeepsHostAwakeForAnOverdueTick(t *testing.T) {
const host = "overdue.bab.oncarlos.com"
a, _, clk, reg := testActivator(t, 15*time.Minute)
rt := testRoute(host)
arrangeAwake(t, a, rt)
// next_at already behind us: the runner will fire it within 15s.
armTimer(t, reg, host, clk.now().Add(-time.Minute))
clk.advance(16 * time.Minute)
a.sweepOnce()
a.mu.Lock()
e := a.entries[rt.Host]
a.mu.Unlock()
if e == nil || e.state != actAwake {
t.Fatal("host hibernated with an overdue tick pending")
}
}Write these two helpers once in activator_test.go and use them from all four tests (and from Task 2's, for arrangeAwake):
// arrangeAwake drives rt's host to actAwake the way the rest of this file
// does — a real beginRequest round trip — so lastActive is stamped and the
// entry exists.
func arrangeAwake(t *testing.T, a *Service, rt registry.Route) {
t.Helper()
release, err := a.beginRequest(context.Background(), rt)
if err != nil {
t.Fatalf("beginRequest %s: %v", rt.Host, err)
}
release()
}
// armTimer puts one armed schedule row on host, due at next.
func armTimer(t *testing.T, reg *registry.Registry, host string, next time.Time) {
t.Helper()
if err := reg.UpsertTimer(registry.Timer{
Host: host, Name: "sweep", Kind: "schedule", Spec: "every 5m",
Path: "/jobs/sweep", NextAt: next.Unix(), DueAt: next.Unix(),
}); err != nil {
t.Fatalf("UpsertTimer %s: %v", host, err)
}
}okHandler() is the same idea for the proxy argument — an http.Handler writing 200 — and belongs beside them.
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).
- [ ] Step 2: Run the tests and watch them fail
Run: TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v Expected: FAIL — the fake does not satisfy DueStore, or the guard tests hibernate when they should not.
- [ ] Step 3: Extend the interface and the sweep
In internal/activator/activator.go, add to DueStore (around line 173):
type DueStore interface {
SetNextDue(host string, due int64) error
DueInstances(now int64) ([]registry.Route, error)
// EarliestTimers is the hibernation guard's input: host -> soonest armed
// timer. Read with DueInstances, before the sweep takes a.mu.
EarliestTimers() (map[string]int64, error)
}In sweepOnce, join the existing pre-lock read (immediately after the DueInstances call, still above a.mu.Lock()):
// The guard's input, read here for the same reason the due query is:
// nothing under a.mu may wait on SQLite. A failure degrades to "no
// timers", i.e. today's behaviour — a guard that cannot read must not
// pin every instance on the box awake.
earliest, err := a.due.EarliestTimers()
if err != nil {
log.Printf("activator: earliest-timer sweep: %v — hibernating without the schedule guard", err)
earliest = nil
}Then in the hibernate arm, replacing the existing if e.inflight == 0 && ... block:
if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle {
// Don't sleep into an imminent tick. A wake/sleep pair is a full
// checkpoint-and-restore round trip; spending one to be absent
// for less time than we would have idled anyway is a straight
// loss. The threshold is the idle window itself rather than a
// constant of its own: it is already the deployment's stated
// opinion about how long residency is worth holding.
if next, ok := earliest[e.host]; ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle {
continue
}
log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
a.beginStopLocked(e)
continue
}(overruled — Paul, review round 1, 2026-08-31) The snippet above and the following paragraph originally had the guard's continue skip the convergence check below it, on the theory that a host held awake for a tick converges at its "next genuine sleep." That reasoning is wrong: a tick-held host, by construction, never reaches a next sleep — that is precisely what the guard guarantees — so it would serve a stale build forever after a carlos ship, with nothing else to close the gap (Bounce only fires on config change). The fix is to let the guard's decision fall through to the convergence check instead of skipping it:
if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle {
next, ok := earliest[e.host]
heldForTick := ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle
if !heldForTick {
log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
a.beginStopLocked(e)
continue
}
// Fall through to the convergence check below: a host held awake
// for its schedule never reaches a "next wake" to converge on, so
// it is now one of the always-awake hosts that arm was written
// for — the same class as a sidecar host that never sleeps.
}A tick-held host is, in other words, exactly the class the convergence arm's own comment already names: "the always-awake ones — notably every sidecar host, which never sleeps." It now reaches that arm on every sweep, the same as a sidecar, so a ship behind a tick-held host converges within one grace window rather than never.
- [ ] Step 4: Amend the one existing test the guard changes
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.
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:
now := time.Now().UTC()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.
Do not change anything else in that file; Task 4 owns it next.
- [ ] Step 5: Run the tests and watch them pass
Run: TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v Expected: PASS, all four. Run: TMPDIR=/tmp go test ./internal/host -run TestScheduledTickWakes -count=1 -v Expected: PASS — the amended test still proves the whole wake story.
- [ ] Step 6: Full package gate, then commit
TMPDIR=/tmp go test ./internal/activator ./internal/host -count=1
gofmt -l internal/activator internal/host
git add internal/activator/activator.go internal/activator/activator_test.go internal/host/schedule_wake_test.go
git commit -m "activator: don't hibernate into a tick due inside the idle window"---
Task 4: Point the deliverer at the new door
Files:
- Modify:
internal/host/schedule_wiring.go:326-333(the handler insideDeliver) - Test:
internal/host/schedule_wake_test.go
Interfaces:
- Consumes:
(*activator.Service).ServeScheduledfrom Task 2. - Produces: nothing new.
- [ ] Step 1: Write the failing test
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:
// The bug, as a test: a schedule that fires more often than the idle window
// used to pin its instance awake forever, because each tick stamped activity
// and bought another full window. With the tick activity-invisible, the
// instance sleeps between ticks and wakes again for the next one.
//
// No timer rows are armed here, deliberately: this exercises the delivery
// leg alone. The sweep guard (which reads the timers table) would correctly
// hold a host awake for a tick due this soon, and that is Task 3's test.
func TestDeliveryDoesNotHoldTheInstanceAwake(t *testing.T) {
const host = "ticky.bab.oncarlos.com"
const idle = 300 * time.Millisecond
reg, inst, svc, _ := wakeFixture(t, host, idle, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
rt, err := reg.Get(host)
if err != nil {
t.Fatalf("get route: %v", err)
}
d := &activatorDeliverer{svc: svc}
// Tick twice per idle window for a few windows. Under the old accounting
// the instance could never reach the sweep's threshold; now it does,
// between ticks, and the next tick wakes it again.
deadline := time.Now().Add(20 * time.Second)
for inst.startCount() < 2 && time.Now().Before(deadline) {
req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil)
if _, err := d.Deliver(context.Background(), *rt, req); err != nil {
t.Fatalf("Deliver: %v", err)
}
time.Sleep(idle / 2)
}
if n := inst.startCount(); n < 2 {
t.Fatalf("the instance started %d time(s): it never hibernated between ticks, so the tick is still buying residency", n)
}
}
// The app-facing half of the tick contract is untouched by any of this.
func TestDeliveryStillCarriesScheduleAtHeader(t *testing.T) {
const host = "hdr.bab.oncarlos.com"
seen := make(chan string, 1)
reg, _, svc, _ := wakeFixture(t, host, time.Hour, func(w http.ResponseWriter, r *http.Request) {
select {
case seen <- r.Header.Get("X-Carlos-Schedule-At"):
default:
}
w.WriteHeader(http.StatusNoContent)
})
rt, err := reg.Get(host)
if err != nil {
t.Fatalf("get route: %v", err)
}
d := &activatorDeliverer{svc: svc}
req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil)
req.Header.Set("X-Carlos-Schedule-At", "1788126300")
status, err := d.Deliver(context.Background(), *rt, req)
if err != nil {
t.Fatalf("Deliver: %v", err)
}
if status != http.StatusNoContent {
t.Fatalf("status = %d, want 204", status)
}
select {
case got := <-seen:
if got != "1788126300" {
t.Errorf("handler saw X-Carlos-Schedule-At = %q, want it passed through unchanged", got)
}
case <-time.After(10 * time.Second):
t.Fatal("the handler never ran")
}
}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).
- [ ] Step 2: Run the tests and watch them fail
Run: TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v Expected: FAIL on the first — activity is stamped, because Deliver still calls ServeHTTP.
- [ ] Step 3: Swap the call
In internal/host/schedule_wiring.go, inside Deliver's handler:
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ServeScheduled, not ServeHTTP: a tick wakes the instance and is
// held in flight for its duration, but must not reset the idle
// clock. Reports false for a route it does not manage (not an
// instance, or not hibernate-flagged); that route is proxied
// straight, the same call shape the edge itself uses.
if d.svc == nil || !d.svc.ServeScheduled(w, r, rt, proxy) {
proxy.ServeHTTP(w, r)
}
})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.
- [ ] Step 4: Run the tests and watch them pass
Run: TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v Expected: PASS, both.
- [ ] Step 5: Full package gate, then commit
TMPDIR=/tmp go test ./internal/host -count=1
gofmt -l internal/host
git add internal/host/schedule_wiring.go internal/host/schedule_wake_test.go
git commit -m "host: schedule ticks deliver through the non-stamping activator door"---
Task 5: holds_awake — let the box say what it knows
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.
Files:
- Modify:
internal/schedule/status.go:24(add the field) - Modify:
internal/schedule/converge.go:313-334(StatusFor,toStatus, plus a newholdsAwake) - Modify:
internal/schedule/converge.go(Convergerstruct: anIdlefield) andinternal/schedule/runner.go:53(Runnerstruct: anIdlefield) - Modify:
internal/host/schedule_wiring.go(newScheduleDeps: pass the window through) - Modify:
internal/activator/activator.go(expose the window) - Test:
internal/schedule/converge_test.go,internal/schedule/status_test.go
Interfaces:
- Consumes: nothing from earlier tasks.
- Produces: -
TimerStatus.HoldsAwake boolwith JSON tagholds_awake. Task 6 renders it. -func (a *Service) IdleWindow() time.Duration. -StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error)— one added parameter.
- [ ] Step 1: Write the failing test
func TestHoldsAwakeWhenTheIntervalIsInsideTheIdleWindow(t *testing.T) {
now := time.Unix(1788120000, 0).UTC()
cases := []struct {
name string
kind string
spec string
idle time.Duration
want bool
}{
{"tighter than the window", KindSchedule, "every 5m", 15 * time.Minute, true},
{"exactly the window", KindSchedule, "every 15m", 15 * time.Minute, true},
{"wider than the window", KindSchedule, "every 1h", 15 * time.Minute, false},
{"tight, short window", KindSchedule, "every 5m", 90 * time.Second, false},
{"a once-timer never holds", KindOnce, "at 1788130000", 15 * time.Minute, false},
{"hibernation not armed", KindSchedule, "every 1m", 0, false},
{"hourly cron", KindSchedule, "cron 0 * * * *", 15 * time.Minute, false},
{"every-minute cron", KindSchedule, "cron * * * * *", 15 * time.Minute, true},
{"unparseable spec is not a claim", KindSchedule, "banana", 15 * time.Minute, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := holdsAwake(registry.Timer{Kind: tc.kind, Spec: tc.spec}, tc.idle, now)
if got != tc.want {
t.Errorf("holdsAwake(%q, idle=%s) = %v, want %v", tc.spec, tc.idle, got, tc.want)
}
})
}
}
// The wire shape is additive: an object written before this field existed
// must still parse, and read back as false.
func TestStatusWithoutHoldsAwakeStillParses(t *testing.T) {
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}]}`)
var st Status
if err := json.Unmarshal(raw, &st); err != nil {
t.Fatalf("unmarshal a pre-holds_awake status: %v", err)
}
if len(st.Timers) != 1 || st.Timers[0].HoldsAwake {
t.Errorf("HoldsAwake = %v, want false for an object written before the field existed", st.Timers[0].HoldsAwake)
}
}- [ ] Step 2: Run the tests and watch them fail
Run: TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v Expected: FAIL — undefined: holdsAwake, and st.Timers[0].HoldsAwake undefined.
- [ ] Step 3: Implement the field, the predicate and the plumbing
internal/schedule/status.go, appended to TimerStatus (additive, last field):
// HoldsAwake is the box's warning: this row fires at least as often as
// the deployment's idle window, so its instance never hibernates between
// ticks and accrues instance-hours around the clock. Computed by the box,
// which is the only place that holds both numbers. False from a box that
// predates the field, and false wherever hibernation is not armed.
HoldsAwake bool `json:"holds_awake,omitempty"`internal/schedule/converge.go, next to toStatus:
// holdsAwake reports whether t's interval is tight enough that its instance
// never gets to sleep: the gap between consecutive occurrences is no wider
// than idle, so the activator's guard (and, before it, the tick's own wake)
// keeps the process resident indefinitely.
//
// The gap comes from two Next calls rather than Spec.Every, so a cron gets a
// real answer instead of a special case. idle <= 0 means hibernation is not
// armed on this box: nothing holds anything awake, because nothing sleeps.
// A once-timer fires once and cannot hold anything. An unparseable spec is
// not evidence of a problem, so it makes no claim.
func holdsAwake(t registry.Timer, idle time.Duration, now time.Time) bool {
if idle <= 0 || t.Kind != KindSchedule {
return false
}
sp, err := ParseSpec(t.Spec)
if err != nil {
return false
}
first := sp.Next(now)
if first.IsZero() {
return false
}
second := sp.Next(first)
if second.IsZero() {
return false
}
return second.Sub(first) <= idle
}toStatus and StatusFor take the window:
func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error) {
// ... unchanged, except:
// st.Timers = append(st.Timers, toStatus(r, idle, now))
}
func toStatus(t registry.Timer, idle time.Duration, now time.Time) TimerStatus {
return TimerStatus{
Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path,
NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt,
LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts,
HoldsAwake: holdsAwake(t, idle, now),
}
}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).
Expose the window from the activator, in internal/activator/activator.go:
// IdleWindow is cfg.Idle, for the box-side reporters that must tell a member
// whether their schedule is tight enough to hold the instance resident. Read
// only; the window is fixed at construction from CARLOS_HIBERNATE_IDLE.
func (a *Service) IdleWindow() time.Duration { return a.cfg.Idle }And thread it in newScheduleDeps (internal/host/schedule_wiring.go:66), where svc may legitimately be nil:
// Zero when no activator is armed on this box: nothing hibernates, so no
// schedule can hold anything awake, and holdsAwake reports false for all
// of them.
var idle time.Duration
if svc != nil {
idle = svc.IdleWindow()
}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.
- [ ] Step 4: Run the tests and watch them pass
Run: TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v Expected: PASS, all cases.
- [ ] Step 5: Full gate across the three touched packages, then commit
TMPDIR=/tmp go test ./internal/schedule ./internal/host ./internal/activator -count=1
gofmt -l internal/schedule internal/host internal/activator
git add internal/schedule internal/host/schedule_wiring.go internal/activator/activator.go
git commit -m "schedule: report when a schedule's interval holds its instance awake"---
Task 6: Say it in carlos schedule ls
Files:
- Modify:
schedule.go:337-350(the per-timer render loop) - Test:
schedule_test.go
Interfaces:
- Consumes:
schedule.TimerStatus.HoldsAwakefrom Task 5. - Produces: nothing.
- [ ] Step 1: Write the failing test
In schedule_test.go, following how the existing render tests capture output:
func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) {
var b bytes.Buffer
now := time.Unix(1788126000, 0).UTC()
inst := schedule.Status{
Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding",
ReportedAt: now.Format(time.RFC3339),
Timers: []schedule.TimerStatus{{
Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m",
Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(),
LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1,
HoldsAwake: true,
}},
}
renderScheduleInstance(&b, inst, now)
out := b.String()
if !strings.Contains(out, "sweep") {
t.Fatalf("row missing from output:\n%s", out)
}
if !strings.Contains(out, "never hibernates") {
t.Errorf("no holds-awake warning in output:\n%s", out)
}
}
func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) {
var b bytes.Buffer
now := time.Unix(1788126000, 0).UTC()
inst := schedule.Status{
Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding",
ReportedAt: now.Format(time.RFC3339),
Timers: []schedule.TimerStatus{{
Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h",
Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(),
}},
}
renderScheduleInstance(&b, inst, now)
if strings.Contains(b.String(), "never hibernates") {
t.Errorf("warned about a 6-hourly schedule:\n%s", b.String())
}
}- [ ] Step 2: Run the tests and watch them fail
Run: TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v Expected: FAIL — the warning string is absent.
- [ ] Step 3: Render it
In schedule.go, inside the per-timer loop, after the existing fmt.Fprintf(w, " %-*snext %s last %s\n", ...):
if t.HoldsAwake {
// The box worked this out; we only pass it on. Worth a whole
// line rather than a marker on the row: the member's next
// question is "so what?", and the answer is what they are being
// billed for.
fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "")
}- [ ] Step 4: Run the tests and watch them pass
Run: TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v Expected: PASS, both.
- [ ] Step 5: Whole-repo gate, then commit
TMPDIR=/tmp go build ./... && TMPDIR=/tmp go vet ./... && TMPDIR=/tmp make test
gofmt -l .
git add schedule.go schedule_test.go
git commit -m "cli: schedule ls says when a schedule holds its instance awake"---
Task 7: Gate on real CI and open the PR
Files: none.
- [ ] Step 1: Push to the CI remote
git push amadan HEAD:james-hogging- [ ] Step 2: Read the run
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.
- [ ] Step 3: Confirm the change is inert, on paper
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.
- [ ] Step 4: Open the PR
git push origin james-hogging
gh pr create --repo carlosframework/platform \
--title "Scheduled ticks must not hold an instance awake" \
--body "..." # spec link, the inert-on-roll argument, the two apps to watch---
Self-Review
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.
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.
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.
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.