Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
DevPlace CI / test (push) Failing after 44m45s
DevPlace CI / test (push) Failing after 44m45s
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -268,12 +268,12 @@ func TestSessionLimitsAnswerWithDonorCodes(t *testing.T) {
|
||||
_ = c.WriteClose(CloseGoingAway, "bye")
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended"})
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
|
||||
})
|
||||
h.session.limiter.AddBytes(1 << 40)
|
||||
err := h.run(t)
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "suspended" {
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "revoked" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,8 @@ func TestFatalTicketStatuses(t *testing.T) {
|
||||
}{
|
||||
{401, "", "unauthorized"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"no_consent"}}`, "no_consent"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"suspended"}}`, "suspended"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"revoked"}}`, "revoked"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"kill_switch"}}`, "kill_switch"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -349,14 +350,120 @@ func TestBackoffStaysInsideBounds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFatalReasonsMatchPlan(t *testing.T) {
|
||||
for _, reason := range []string{"revoked", "suspended", "kill_switch", "no_consent"} {
|
||||
for _, reason := range []string{"revoked", "kill_switch", "no_consent"} {
|
||||
if !FatalReasons[reason] {
|
||||
t.Errorf("%s must stop reconnecting", reason)
|
||||
}
|
||||
}
|
||||
for _, reason := range []string{"heartbeat", "ticket", "capacity"} {
|
||||
for _, reason := range []string{"heartbeat", "ticket", "capacity", "suspended"} {
|
||||
if FatalReasons[reason] {
|
||||
t.Errorf("%s must reconnect", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sleepLog struct{ waits []time.Duration }
|
||||
|
||||
func (l *sleepLog) sleep(_ context.Context, d time.Duration) { l.waits = append(l.waits, d) }
|
||||
|
||||
func fixedNow(text string) func() time.Time {
|
||||
moment, _ := time.Parse(time.RFC3339, text)
|
||||
return func() time.Time { return moment }
|
||||
}
|
||||
|
||||
func TestSuspendedTicketWaitsUntilTheEndThenRetries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
until string
|
||||
want time.Duration
|
||||
}{
|
||||
{"future", "2026-01-01T01:00:00Z", time.Hour},
|
||||
{"empty", "", 300 * time.Second},
|
||||
{"garbage", "tomorrow", 300 * time.Second},
|
||||
{"expired", "2025-12-31T00:00:00Z", 60 * time.Second},
|
||||
{"far", "2030-01-01T00:00:00Z", 24 * time.Hour},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
body, _ := json.Marshal(map[string]any{"error": map[string]any{"status": 403, "message": "x", "reason": "suspended", "until": item.until}})
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
session.jitter = func() float64 { return 0 }
|
||||
session.now = fixedNow("2026-01-01T00:00:00Z")
|
||||
slept := &sleepLog{}
|
||||
session.sleep = slept.sleep
|
||||
var fatal *FatalError
|
||||
if err := session.Run(context.Background()); !errors.As(err, &fatal) || calls.Load() != 2 {
|
||||
t.Fatalf("err %v calls %d", err, calls.Load())
|
||||
}
|
||||
if len(slept.waits) != 1 || slept.waits[0] != item.want {
|
||||
t.Fatalf("waits %v want %v", slept.waits, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendedKickHoldsThenReconnects(t *testing.T) {
|
||||
h := newHarness(t, func(n int, c *Conn) {
|
||||
readFrame(t, c)
|
||||
writeFrame(t, c, welcome())
|
||||
if n == 1 {
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended", "until": "2026-01-01T00:10:00Z"})
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
|
||||
})
|
||||
h.session.now = fixedNow("2026-01-01T00:00:00Z")
|
||||
slept := &sleepLog{}
|
||||
h.session.sleep = slept.sleep
|
||||
var fatal *FatalError
|
||||
if err := h.run(t); !errors.As(err, &fatal) || fatal.Reason != "revoked" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
if h.conns.Load() != 2 || len(slept.waits) != 1 || slept.waits[0] != 10*time.Minute {
|
||||
t.Fatalf("conns %d waits %v", h.conns.Load(), slept.waits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendedWaitHonoursCancel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"status":403,"message":"x","reason":"suspended","until":""}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if err := session.Run(ctx); err != nil || time.Since(started) > 5*time.Second {
|
||||
t.Fatalf("err %v after %v", err, time.Since(started))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeDecodesDeviceLabel(t *testing.T) {
|
||||
frame, err := decodeFrame([]byte(`{"type":"welcome","device_uid":"d","device_label":"Laptop","settings":{"pause_on_battery":true}}`))
|
||||
if err != nil || frame.DeviceLabel != "Laptop" || !frame.Settings.PauseOnBattery {
|
||||
t.Fatalf("%+v %v", frame, err)
|
||||
}
|
||||
kick, _ := decodeFrame([]byte(`{"type":"kick","reason":"suspended","until":"2026-01-01T00:00:00Z"}`))
|
||||
if kick.Until == "" {
|
||||
t.Fatal("until not decoded")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user