Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
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:
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
+31 -7
View File
@@ -27,6 +27,26 @@ function minutes(text) {
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
}
function windowOpen(from, to, current) {
const start = minutes(from);
const end = minutes(to);
if (start === null || end === null || start === end) {
return true;
}
return start < end ? current >= start && current < end : current >= start || current < end;
}
function zoneMinutes(moment, timeZone) {
let format;
try {
format = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
} catch (error) {
format = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
}
const parts = Object.fromEntries(format.formatToParts(moment).map((part) => [part.type, part.value]));
return Number(parts.hour) * 60 + Number(parts.minute);
}
export class Limits {
constructor(options = {}) {
this.baked = options.baked || LIMITS;
@@ -39,6 +59,7 @@ export class Limits {
this.reconnectMax = this.baked.reconnect_max_seconds;
this.serverBandwidthMb = this.baked.max_donor_mb_per_day;
this.serverConcurrent = this.baked.max_concurrent_per_node;
this.serverWindow = { from: "", to: "", timeZone: "UTC" };
this.local = Limits.normaliseLocal({}, this.baked);
this.inflight = 0;
if (options.local) {
@@ -97,6 +118,11 @@ export class Limits {
this.baked.max_concurrent_per_node,
this.baked.max_concurrent_per_node,
);
this.serverWindow = {
from: typeof values.active_from === "string" ? values.active_from : "",
to: typeof values.active_to === "string" ? values.active_to : "",
timeZone: typeof values.timezone === "string" && values.timezone ? values.timezone : "UTC",
};
}
get maxBodyBytes() {
@@ -137,14 +163,12 @@ export class Limits {
}
inActiveHours() {
const from = minutes(this.local.activeFrom);
const to = minutes(this.local.activeTo);
if (from === null || to === null || from === to) {
return true;
}
const now = this.clock();
const current = now.getHours() * 60 + now.getMinutes();
return from < to ? current >= from && current < to : current >= from || current < to;
if (!windowOpen(this.local.activeFrom, this.local.activeTo, now.getHours() * 60 + now.getMinutes())) {
return false;
}
const { from, to, timeZone } = this.serverWindow;
return windowOpen(from, to, zoneMinutes(now, timeZone));
}
admit(usedBytes) {
+2 -1
View File
@@ -6,13 +6,14 @@ export const STATES = Object.freeze({
ONLINE: "online",
BACKOFF: "backoff",
HALTED: "halted",
SUSPENDED: "suspended",
});
export class State {
constructor(area, clock = () => Date.now()) {
this.area = area;
this.clock = clock;
this.data = { state: STATES.OFF, detail: "", since: clock(), deviceUid: "", lastError: "" };
this.data = { state: STATES.OFF, detail: "", since: clock(), deviceUid: "", deviceLabel: "", suspendedUntil: "", lastError: "" };
this.chain = Promise.resolve();
}
+55 -3
View File
@@ -15,6 +15,7 @@ import {
PROTOCOL_VERSION,
REFUSALS,
SERVER_FRAMES,
SUSPENSION,
} from "./spec.js";
const OPEN = 1;
@@ -35,6 +36,7 @@ export class WsClient {
this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
this.timers = options.timers || globalThis;
this.random = options.random || Math.random;
this.clock = options.clock || (() => Date.now());
this.clientVersion = options.clientVersion || "0.0.0";
this.platform = options.platform || "chrome";
this.baked = options.baked || DESTINATIONS;
@@ -120,6 +122,37 @@ export class WsClient {
await this.state.set({ state: STATES.HALTED, detail, lastError: detail });
}
suspensionDelaySeconds(until) {
const end = Date.parse(until);
const seconds = Number.isNaN(end) ? SUSPENSION.default_seconds : (end - this.clock()) / 1000;
const bounded = Math.min(SUSPENSION.max_seconds, Math.max(SUSPENSION.min_seconds, seconds));
return bounded + this.random() * SUSPENSION.jitter_seconds;
}
async suspend(until) {
if (!this.wanted) {
return;
}
const seconds = this.suspensionDelaySeconds(until);
this.welcomed = false;
this.clearTimers();
this.generation += 1;
this.closeSocket(CLOSE_NORMAL, "suspended");
this.retryTimer = this.timers.setTimeout(() => {
this.retryTimer = null;
this.ensure();
}, seconds * 1000);
const ends = Number.isNaN(Date.parse(until))
? new Date(this.clock() + seconds * 1000).toISOString()
: new Date(until).toISOString();
await this.state.set({
state: STATES.SUSPENDED,
detail: "This device is suspended by the server",
suspendedUntil: ends,
retryInSeconds: Math.round(seconds),
});
}
async fetchTicket() {
const { serverUrl, secret } = this.config.credentials();
const response = await this.fetchImpl(serverUrl + TICKET_PATH, {
@@ -129,14 +162,18 @@ export class WsClient {
cache: "no-store",
});
if (!response.ok) {
let reason = "";
let detail = {};
try {
reason = (await response.json()).reason || "";
const body = await response.json();
detail = body && typeof body.error === "object" && body.error ? body.error : body || {};
} catch (error) {
reason = "";
detail = {};
}
const reason = typeof detail.reason === "string" ? detail.reason : "";
const error = new Error(`ticket refused with ${response.status} ${reason}`.trim());
error.status = response.status;
error.reason = reason;
error.until = typeof detail.until === "string" ? detail.until : "";
throw error;
}
const body = await response.json();
@@ -164,6 +201,10 @@ export class WsClient {
if (generation !== this.generation) {
return;
}
if (error.status === 403 && error.reason === SUSPENSION.reason) {
await this.suspend(error.until);
return;
}
if (HALT_STATUSES.includes(error.status)) {
await this.halt(error.message);
return;
@@ -276,6 +317,8 @@ export class WsClient {
this.runJob(frame);
} else if (frame.type === "settings_update") {
this.limits.applyServerSettings(frame.settings);
} else if (frame.type === "kick" && frame.reason === SUSPENSION.reason) {
await this.suspend(typeof frame.until === "string" ? frame.until : "");
} else if (frame.type === "kick") {
await this.halt(`kicked by the server: ${frame.reason}`);
} else {
@@ -303,6 +346,8 @@ export class WsClient {
state: STATES.ONLINE,
detail: notes,
deviceUid: frame.device_uid,
deviceLabel: typeof frame.device_label === "string" ? frame.device_label : "",
suspendedUntil: "",
lastError: notes,
});
}
@@ -344,6 +389,9 @@ export class WsClient {
}
classifyClose(code, reason) {
if (code === CLOSE_KICKED && reason === SUSPENSION.reason) {
return { action: "suspend", detail: "suspended by the server" };
}
if (code === CLOSE_KICKED) {
return { action: "halt", detail: `closed by the server: ${reason || "kicked"}` };
}
@@ -366,6 +414,10 @@ export class WsClient {
return;
}
const verdict = this.classifyClose(code, reason);
if (verdict.action === "suspend") {
await this.suspend("");
return;
}
if (verdict.action === "halt") {
await this.halt(verdict.detail);
return;
+9
View File
@@ -65,10 +65,19 @@ export const REFUSALS = Object.freeze({
BANDWIDTH_EXHAUSTED: "bandwidth_exhausted",
CONCURRENCY_EXCEEDED: "concurrency_exceeded",
OUTSIDE_HOURS: "outside_hours",
ON_BATTERY: "on_battery",
BODY_TOO_LARGE: "body_too_large",
TIMEOUT: "timeout",
});
export const SUSPENSION = Object.freeze({
reason: "suspended",
min_seconds: 60,
max_seconds: 24 * 60 * 60,
default_seconds: 300,
jitter_seconds: 30,
});
export const FETCH_FAILED = "fetch_failed";
export const SERVER_FRAMES = Object.freeze({