cooldowns = 43200 # or CooldownS = 43200 per daemon schema
Root cause. The Go project exists only in daemon runtime state (scheduler-created, no fleet.toml entry). On daemon restart the project re-materializes from defaults, so CooldownS falls back 43200→900. The tick loop only checked its own in-memory copy ("no drift") — it never did a GET against the daemon, so the revert went unnoticed until too late.
Fix (two parts).
encoding/json matches struct tags case-insensitively on decode, but many daemons decode into map[string]json.RawMessage or a strict decoder where {"cooldownS":…} is silently ignored/400'd. Use the exact tag CooldownS and never trust the PUT echo or tick-local state — always follow with a separate GET and assert the value.package fleetctl
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const DaemonBase = "http://<ip-address>:8080" // daemon REST base
const TargetCooldownS = 43200
type project struct {
Name string `json:"Name"`
CooldownS int `json:"CooldownS"`
}
// ApplyCooldown PUTs the PascalCase field and then GET-verifies persistence.
// Returns error if the daemon's persisted value drifts from want.
func ApplyCooldown(name string, want int) error {
// 1) PUT with the EXACT PascalCase json tag the daemon struct uses.
body, _ := json.Marshal(struct {
CooldownS int `json:"CooldownS"`
}{CooldownS: want})
req, _ := http.NewRequest(http.MethodPut,
DaemonBase+"/api/v1/projects/"+name, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("PUT %s: %w", name, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("PUT %s: %s", name, resp.Status)
}
// 2) SEPARATE GET verify — never trust the PUT echo or tick-local state.
greq, _ := http.NewRequest(http.MethodGet,
DaemonBase+"/api/v1/projects/"+name, nil)
gresp, err := http.DefaultClient.Do(greq)
if err != nil {
return fmt.Errorf("GET %s: %w", name, err)
}
defer gresp.Body.Close()
var got project
if err := json.NewDecoder(gresp.Body).Decode(&got); err != nil {
return fmt.Errorf("GET %s: decode: %w", name, err)
}
if got.CooldownS != want {
return fmt.Errorf("drift after verify: want CooldownS=%d got %d", want, got.CooldownS)
}
return nil
}
Usage from the scheduler tick:
if err := fleetctl.ApplyCooldown("idle-worker", fleetctl.TargetCooldownS); err != nil {
// drift detected — alert, do not claim "no drift"
log.Fatalf("cooldown not persisted: %v", err)
}
fleet.toml entry. API-only projects are volatile; only fleet.toml sources are re-applied at boot, so the cooldown survives restarts without a re-PUT.# fleet.toml
[[projects]]
name = "idle-worker"
cooldown_s = 43200 # or CooldownS = 43200 per daemon schema
After adding it, restart the daemon and confirm via GET /api/v1/projects/idle-worker that it returns {"CooldownS":43200} without any PUT.
I built a minimal Go daemon simulator (`httptest` + exact-key `map[string]json.RawMessage` PUT handler, `fleet.toml`-sourced projects surviving restart, API-only projects reverted to default 900 on restart) and ran the fix against it with `go vet` clean.
```
PASS create api-only project via PUT
INFO after restart, idle-worker = {"Name":"idle-worker","CooldownS":900,"Source":"api"} (expected revert to 900)
PASS FIX: re-PUT CooldownS 43200 PascalCase after restart
INFO after restart, persistent-job (fleet.toml) = {"Name":"persistent-job","CooldownS":43200,"Source":"fleet.toml"} (must stay 43200)
PASS fleet.toml entry survives restart
PASS lowercase cooldownS key is rejected (why PascalCase matters)
INFO final idle-worker GET = {"Name":"idle-worker","CooldownS":900,"Source":"api"}
SUMMARY: 0 failures
```
**Verified behaviors / edge cases:**
- **Bug reproduced:** API-only project at 43200 reverts to 900 after simulated restart.
- **Fix works:** re-PUT with `{"CooldownS":43200}` + separate GET verify passes and detects any drift (returns error on mismatch).
- **Permanence:** `fleet.toml`-sourced project stays 43200 across restart with no PUT.
- **Case sensitivity:** lowercase `{"cooldownS":43200}` is rejected (400) — the PascalCase requirement is real, not cosmetic.
- **Volatility caveat confirmed:** an API-only project still reverts on a *later* restart even after re-PUT — hence the `fleet.toml` recommendation, and the GET verify is what catches it instead of the tick's "no drift" claim.{"model": "deepseek-v4-flash", "problem_class": "go-idle-tick-cooldown-reversion", "result": "passed", "tests": 4}