carlos / platform Public

activator: a plan can shorten an entry's idle window

SetIdleFor installs the hook; the sweep takes the smaller of the entry's
own window and the plan's, ignores 0, and never reaches always-on workers.
The agent wires planIdleFor from the plans cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 53394caf6704f072734f703c2a578f7e7abd838c parent 3205be3
5 files changed, +180 −0
  • internal/activator/activator.go +22 −0
  • internal/activator/activator_idlefor_test.go +113 −0
  • internal/host/agent.go +4 −0
  • internal/host/plans_idle_test.go +29 −0
  • internal/host/plans_pass.go +12 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index a0d56891b..dbd881d66 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -1139,6 +1139,11 @@ type Service struct {
// edge chose before recovery moved their upstream.
startupSeenAt atomic.Int64
+ // idleFor is the plans idle hook (plans 2), set through SetIdleFor: given an
+ // entry's route it names the plan's idle window, or 0 for no opinion. It
+ // can only shorten a window. nil means today's behaviour.
+ idleFor atomic.Pointer[func(registry.Route) time.Duration]
+
cfg Config
backend Backend
due DueStore
@@ -4647,6 +4652,11 @@ func (a *Service) sweepOnce() {
if e.workerIdle > 0 {
idleWindow = e.workerIdle
}
+ if f := a.idleFor.Load(); f != nil {
+ if d := (*f)(e.route); d > 0 && d < idleWindow {
+ idleWindow = d
+ }
+ }
if hibernates && e.inflight == 0 && e.cmd == nil && now.Sub(e.lastActive) >= idleWindow {
// Don't sleep into an imminent tick. A wake/sleep pair is a full
// checkpoint-and-restore round trip; spending one to be absent
@@ -5446,3 +5456,15 @@ func (a *Service) serveWakeError(w http.ResponseWriter, r *http.Request, rt regi
}
return true
}
+
+// SetIdleFor installs the plans idle hook (plans 2). A plan can only shorten
+// an entry's idle window: the sweep takes the smaller of the entry's own
+// window and the hook's answer, and ignores 0. Always-on workers have no idle
+// clock, so the hook never reaches them. nil removes the hook.
+func (a *Service) SetIdleFor(f func(registry.Route) time.Duration) {
+ if f == nil {
+ a.idleFor.Store(nil)
+ return
+ }
+ a.idleFor.Store(&f)
+}
diff --git a/internal/activator/activator_idlefor_test.go b/internal/activator/activator_idlefor_test.go
new file mode 100644
index 000000000..7eb8f2835
--- /dev/null
+++ b/internal/activator/activator_idlefor_test.go
@@ -0,0 +1,113 @@
+package activator
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The plans idle hook (plans 2): a plan may only shorten an entry's idle
+// window, never lengthen it, and never touches an always-on worker.
+
+func idleFor(d time.Duration) func(registry.Route) time.Duration {
+ return func(registry.Route) time.Duration { return d }
+}
+
+func wakeInstance(t *testing.T, a *Service, host string) {
+ t.Helper()
+ release, err := a.beginRequest(context.Background(), testRoute(host))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+}
+
+func TestIdleForShortensAnInstance(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, 15*time.Minute)
+ a.SetIdleFor(idleFor(90 * time.Second))
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(80 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("slept before the plan's 90s window")
+ }
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "plan idle sleep", func() bool { return a.entryCount() == 0 })
+}
+
+func TestPlanIdleOnlyShortens(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, 5*time.Minute)
+ a.SetIdleFor(idleFor(15 * time.Minute))
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(5*time.Minute + time.Second)
+ a.sweepOnce()
+ waitFor(t, "box-default sleep", func() bool { return a.entryCount() == 0 })
+}
+
+func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) {
+ t.Parallel()
+ for _, c := range []struct {
+ name string
+ worker int
+ plan, sleep time.Duration
+ }{
+ {"plan shorter than the worker's own", 600, 90 * time.Second, 90 * time.Second},
+ {"worker's own shorter than the plan", 60, 90 * time.Second, 60 * time.Second},
+ } {
+ a, _, clk, _ := testActivator(t, time.Hour)
+ a.SetIdleFor(idleFor(c.plan))
+ rt := testWorkerRoute("bot.x.internal")
+ rt.IdleSeconds = c.worker
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("%s: WakeWorker: %v", c.name, err)
+ }
+ clk.advance(c.sleep - 5*time.Second)
+ a.sweepOnce()
+ if !a.entryAwake("bot.x.internal") {
+ t.Fatalf("%s: slept early", c.name)
+ }
+ clk.advance(6 * time.Second)
+ a.sweepOnce()
+ waitFor(t, c.name, func() bool { return a.entryCount() == 0 })
+ }
+}
+
+func TestIdleForNilOrZeroChangesNothing(t *testing.T) {
+ t.Parallel()
+ for name, hook := range map[string]func(registry.Route) time.Duration{"nil": nil, "zero": idleFor(0)} {
+ a, _, clk, _ := testActivator(t, 10*time.Second)
+ a.SetIdleFor(hook)
+ wakeInstance(t, a, "one.example.com")
+ clk.advance(9 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatalf("%s hook: slept before the box default", name)
+ }
+ clk.advance(2 * time.Second)
+ a.sweepOnce()
+ waitFor(t, name+" hook: default sleep", func() bool { return a.entryCount() == 0 })
+ }
+}
+
+func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) {
+ t.Parallel()
+ a, _, clk, _ := testActivator(t, time.Hour)
+ a.SetIdleFor(idleFor(90 * time.Second))
+ rt := testWorkerRoute("bot.x.internal")
+ rt.Hibernate = false
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ for i := 0; i < 5; i++ {
+ clk.advance(time.Hour)
+ a.sweepOnce()
+ }
+ if a.entryCount() != 1 {
+ t.Fatal("an always-on worker was stopped by the plan's idle window")
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index ee1b40ad7..3229da5c2 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -495,6 +495,10 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// (design §6.8). The registry satisfies RouteLookup with its own
// Get, exactly as DueStore above.
svc.Routes = reg
+ // Plans 2: an account's plan can shorten its entries' idle window. With
+ // plans off (no marker, CARLOS_PLAN_LIMITS=off, or no record) the hook
+ // answers 0 and every window is the box's own.
+ svc.SetIdleFor(planIdleFor)
// Settings spec §4.2: before any process runs a build against an
// instance's database, the build state names it, under the lease. A
// store that failed to start refuses every spawn (never "run
diff --git a/internal/host/plans_idle_test.go b/internal/host/plans_idle_test.go
new file mode 100644
index 000000000..867033b43
--- /dev/null
+++ b/internal/host/plans_idle_test.go
@@ -0,0 +1,29 @@
+package host
+
+// Serial: it writes the package-level plans cache.
+
+import (
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPlanIdleFor(t *testing.T) {
+ t.Cleanup(func() { planLimits.Store(nil) })
+ rt := registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web"}
+ planLimits.Store(nil)
+ if d := planIdleFor(rt); d != 0 {
+ t.Fatalf("plans off: %v, want 0", d)
+ }
+ planLimits.Store(&planLimitsSnapshot{byAccount: map[string]plans.Limits{"bab": {IdleSeconds: 90}}})
+ if d := planIdleFor(rt); d != 90*time.Second {
+ t.Fatalf("bab: %v, want 90s", d)
+ }
+ other := rt
+ other.Account = "bdk"
+ if d := planIdleFor(other); d != 0 {
+ t.Fatalf("account with no record: %v, want 0", d)
+ }
+}
diff --git a/internal/host/plans_pass.go b/internal/host/plans_pass.go
index f2daf51aa..041e2bf63 100644
--- a/internal/host/plans_pass.go
+++ b/internal/host/plans_pass.go
@@ -15,6 +15,7 @@ import (
"os"
"strings"
"sync/atomic"
+ "time"
"github.com/carlosframework/platform/internal/plans"
"github.com/carlosframework/platform/internal/registry"
@@ -115,3 +116,14 @@ func planLimitsFailed(format string, args ...any) {
log.Printf("agent: plans pass: "+format+" (keeping the last good limits)", args...)
}
}
+
+// planIdleFor is the activator's plans idle hook: the route's account's idle
+// window from its limits, or 0 (no opinion) when plans are off or the account
+// has no record.
+func planIdleFor(rt registry.Route) time.Duration {
+ l, ok := planLimitsFor(release.RouteAccount(rt))
+ if !ok || l.IdleSeconds <= 0 {
+ return 0
+ }
+ return time.Duration(l.IdleSeconds) * time.Second
+}