# 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.com` beginning to hibernate on this branch is a bug, not a win. (Spec §4.)
- **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".)
- **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.
- **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`.
- **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.
- **`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 `NextDue` change.** 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 after `DueTimers`)
- Test: `internal/registry/timers_test.go`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- 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.

- [ ] **Step 1: Write the failing test**

In `internal/registry/timers_test.go`, following the file's existing setup helper for a temp registry:

```go
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`:

```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**

```bash
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 as `ServeHTTP` (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)` and `func (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:

```go
// 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 with `a.now = clk.now` already 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; `beginStopLocked` runs 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 `stamp` through 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:

```go
// 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:

```go
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)`:

```go
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.

```bash
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` (`DueStore` interface, `sweepOnce`)
- Test: `internal/activator/activator_test.go`

**Interfaces:**
- Consumes: `(*registry.Registry).EarliestTimers() (map[string]int64, error)` from Task 1.
- Produces: `DueStore` gains `EarliestTimers() (map[string]int64, error)`. Any test fake implementing `DueStore` must grow the method.

- [ ] **Step 1: Write the failing test**

```go
// 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`):

```go
// 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):

```go
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()`):

```go
	// 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:

```go
		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:

```go
		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:

```go
	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**

```bash
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 inside `Deliver`)
- Test: `internal/host/schedule_wake_test.go`

**Interfaces:**
- Consumes: `(*activator.Service).ServeScheduled` from 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:

```go
// 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:

```go
	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**

```bash
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 new `holdsAwake`)
- Modify: `internal/schedule/converge.go` (`Converger` struct: an `Idle` field) and `internal/schedule/runner.go:53` (`Runner` struct: an `Idle` field)
- 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 bool` with JSON tag `holds_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**

```go
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):

```go
	// 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`:

```go
// 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:

```go
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`:

```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:

```go
	// 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**

```bash
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.HoldsAwake` from Task 5.
- Produces: nothing.

- [ ] **Step 1: Write the failing test**

In `schedule_test.go`, following how the existing render tests capture output:

```go
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", ...)`:

```go
		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**

```bash
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**

```bash
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**

```bash
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.
