Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work

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:
2026-10-17 02:23:43 +02:00
co-authored by Claude Sonnet 5.5
parent ce899fc5e1
commit 02ab747cd0
203 changed files with 11624 additions and 301 deletions
+43 -9
View File
@@ -25,10 +25,11 @@ func (e *FatalError) Error() string {
}
type outcome struct {
fatal string
fast bool
stable bool
reason string
fatal string
fast bool
stable bool
reason string
holdUntil time.Time
}
type Session struct {
@@ -40,6 +41,8 @@ type Session struct {
log *Logger
httpc *http.Client
jitter func() float64
now func() time.Time
sleep func(ctx context.Context, d time.Duration)
dialWS func(ctx context.Context, rawURL string) (*Conn, error)
limits ServerLimits
maxBody int
@@ -55,6 +58,8 @@ func NewSession(cfg Config, policy *Policy, limiter *Limiter, fetcher *Fetcher,
log: logger,
httpc: &http.Client{Timeout: time.Duration(TicketTimeoutSeconds) * time.Second, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}},
jitter: rand.Float64,
now: time.Now,
sleep: sleepContext,
dialWS: Dial,
limits: defaultLimits(),
maxBody: DefaultMaxBodyBytes,
@@ -101,6 +106,21 @@ func sleepContext(ctx context.Context, d time.Duration) {
}
}
func (s *Session) holdDelay(until time.Time) time.Duration {
wait := until.Sub(s.now())
wait = max(wait, time.Duration(SuspendedMinSeconds)*time.Second)
wait = min(wait, time.Duration(SuspendedMaxSeconds)*time.Second)
return wait + time.Duration(s.jitter()*float64(SuspendedJitterSeconds)*float64(time.Second))
}
func (s *Session) suspension(until string) outcome {
end := s.now().Add(time.Duration(SuspendedDefaultSeconds) * time.Second)
if moment, err := time.Parse(time.RFC3339, until); err == nil {
end = moment
}
return outcome{reason: "device is suspended", holdUntil: end}
}
func (s *Session) Run(ctx context.Context) error {
attempt := 0
for ctx.Err() == nil {
@@ -115,6 +135,13 @@ func (s *Session) Run(ctx context.Context) error {
attempt = 0
}
var delay time.Duration
if !result.holdUntil.IsZero() {
delay = s.holdDelay(result.holdUntil)
attempt = 0
s.log.Printf("device is suspended until %s, waiting %s", result.holdUntil.UTC().Format(time.RFC3339), delay.Round(time.Second))
s.sleep(ctx, delay)
continue
}
if result.fast {
delay = time.Duration((0.5 + s.jitter()) * float64(FastRetryMillis) * float64(time.Millisecond))
} else {
@@ -122,7 +149,7 @@ func (s *Session) Run(ctx context.Context) error {
attempt++
}
s.log.Printf("disconnected (%s), reconnecting in %s", result.reason, delay.Round(100*time.Millisecond))
sleepContext(ctx, delay)
s.sleep(ctx, delay)
}
return nil
}
@@ -136,6 +163,7 @@ type errorAnswer struct {
Error struct {
Message string `json:"message"`
Reason string `json:"reason"`
Until string `json:"until"`
} `json:"error"`
}
@@ -166,6 +194,9 @@ func (s *Session) fetchTicket(ctx context.Context) (ticketAnswer, outcome) {
var failure errorAnswer
_ = json.Unmarshal(body, &failure)
reason := failure.Error.Reason
if reason == SuspendedReason {
return answer, s.suspension(failure.Error.Until)
}
if FatalReasons[reason] {
return answer, outcome{fatal: reason}
}
@@ -292,13 +323,13 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
return s.protocolFailure(conn, "welcome is not valid json")
}
if welcome.Type == "kick" {
return s.kickOutcome(welcome.Reason)
return s.kickOutcome(welcome.Reason, welcome.Until)
}
if welcome.Type != "welcome" {
return s.protocolFailure(conn, "expected welcome, got "+welcome.Type)
}
s.applyWelcome(welcome)
s.log.Debugf("connected as device %s with %d allowed destinations", welcome.DeviceUID, len(s.policy.Destinations()))
s.log.Debugf("connected as device %s (%s) with %d allowed destinations", welcome.DeviceUID, welcome.DeviceLabel, len(s.policy.Destinations()))
var jobs sync.WaitGroup
tunnels := NewTunnels(s.policy, s.limiter, s.stats, send, s.cfg.MaxTunnels)
@@ -342,7 +373,7 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
case "settings_update":
s.limiter.ApplyServer(nil, frame.Settings)
case "kick":
return s.kickOutcome(frame.Reason)
return s.kickOutcome(frame.Reason, frame.Until)
default:
return s.protocolFailure(conn, "unexpected frame "+frame.Type)
}
@@ -355,7 +386,10 @@ func (s *Session) protocolFailure(conn *Conn, reason string) outcome {
return outcome{reason: "protocol error: " + reason}
}
func (s *Session) kickOutcome(reason string) outcome {
func (s *Session) kickOutcome(reason, until string) outcome {
if reason == SuspendedReason {
return s.suspension(until)
}
if FatalReasons[reason] {
return outcome{fatal: reason}
}