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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user