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:
@@ -24,6 +24,7 @@ LABEL_MAX = 60
|
||||
EVENT_DETAIL_MAX = 500
|
||||
MEGABYTE = 1024 * 1024
|
||||
BATCH_SIZE = 500
|
||||
COUNTER_ROW_LIMIT = 500
|
||||
HOURS_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
|
||||
PLATFORMS = tuple(sorted({item.adapter for item in SPEC.destinations}))
|
||||
FLAG_OPEN = "open"
|
||||
@@ -714,14 +715,18 @@ def enqueue_request(
|
||||
method: str,
|
||||
adapter: str,
|
||||
mode: str,
|
||||
*,
|
||||
shadow_of: str = "",
|
||||
exclude_device: str = "",
|
||||
) -> str:
|
||||
uid = _uid()
|
||||
created = _execute(
|
||||
"INSERT INTO node_requests (uid, caller_kind, caller_id, url, method, adapter, "
|
||||
"mode, status, device_uid, result_status, result_bytes, result_body, "
|
||||
"result_headers, truncated, error, created_at, assigned_at, finished_at) "
|
||||
"result_headers, truncated, error, device_asn_class, shadow_of, exclude_device, "
|
||||
"created_at, assigned_at, finished_at) "
|
||||
"SELECT :uid, :kind, :caller, :url, :method, :adapter, :mode, 'queued', '', 0, 0, "
|
||||
"'', '', 0, '', :now, '', '' "
|
||||
"'', '', 0, '', '', :shadow_of, :exclude_device, :now, '', '' "
|
||||
"WHERE (SELECT COUNT(*) FROM node_requests WHERE status IN ('queued','assigned')) "
|
||||
"< :max_depth",
|
||||
{
|
||||
@@ -732,6 +737,8 @@ def enqueue_request(
|
||||
"method": method,
|
||||
"adapter": adapter,
|
||||
"mode": mode,
|
||||
"shadow_of": shadow_of,
|
||||
"exclude_device": exclude_device,
|
||||
"now": _now_iso(),
|
||||
"max_depth": LIMITS.max_queue_depth,
|
||||
},
|
||||
@@ -766,12 +773,18 @@ def count_open_requests() -> int:
|
||||
)["c"]
|
||||
|
||||
|
||||
def claim_request(request_uid: str, device_uid: str) -> bool:
|
||||
def claim_request(request_uid: str, device_uid: str, asn_class: str = "") -> bool:
|
||||
return (
|
||||
_execute(
|
||||
"UPDATE node_requests SET status = 'assigned', device_uid = :device, "
|
||||
"assigned_at = :now WHERE uid = :uid AND status = 'queued'",
|
||||
{"device": device_uid, "now": _now_iso(), "uid": request_uid},
|
||||
"device_asn_class = :asn_class, assigned_at = :now "
|
||||
"WHERE uid = :uid AND status = 'queued'",
|
||||
{
|
||||
"device": device_uid,
|
||||
"asn_class": asn_class,
|
||||
"now": _now_iso(),
|
||||
"uid": request_uid,
|
||||
},
|
||||
)
|
||||
== 1
|
||||
)
|
||||
@@ -989,3 +1002,165 @@ def recent_devices(limit: int = ADMIN_LIST_LIMIT) -> list[dict]:
|
||||
n=limit,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def set_class_override(device_uid: str, value: str) -> bool:
|
||||
return (
|
||||
_execute(
|
||||
"UPDATE node_devices SET class_override = :value, updated_at = :now "
|
||||
"WHERE uid = :uid AND deleted_at IS NULL",
|
||||
{"value": value, "now": _now_iso(), "uid": device_uid},
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def touch_devices(device_uids, moment: datetime | None = None) -> int:
|
||||
stamp = (moment or datetime.now(timezone.utc)).isoformat()
|
||||
touched = 0
|
||||
for chunk in _batches(device_uids):
|
||||
placeholders, params = _in_clause(chunk)
|
||||
touched += _execute(
|
||||
f"UPDATE node_devices SET last_seen_at = :now WHERE uid IN ({placeholders})",
|
||||
{"now": stamp, **params},
|
||||
)
|
||||
return touched
|
||||
|
||||
|
||||
def flush_counters(deltas: dict[str, int]) -> int:
|
||||
rows = {name: int(value) for name, value in deltas.items() if int(value) > 0}
|
||||
if not rows:
|
||||
return 0
|
||||
now = _now_iso()
|
||||
with db:
|
||||
for name, value in rows.items():
|
||||
db.executable.execute(
|
||||
text(
|
||||
"INSERT INTO node_counters (name, value, updated_at) VALUES (:name, :value, :now) "
|
||||
"ON CONFLICT(name) DO UPDATE SET value = COALESCE(value, 0) + excluded.value, "
|
||||
"updated_at = excluded.updated_at"
|
||||
),
|
||||
{"name": name, "value": value, "now": now},
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def lifetime_counters(prefix: str = "") -> dict[str, int]:
|
||||
rows = db.query(
|
||||
"SELECT name, COALESCE(value, 0) AS value FROM node_counters "
|
||||
"WHERE name LIKE :prefix ESCAPE '\\' ORDER BY name LIMIT :n",
|
||||
prefix=prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
|
||||
n=COUNTER_ROW_LIMIT,
|
||||
)
|
||||
return {row["name"]: int(row["value"]) for row in rows}
|
||||
|
||||
|
||||
def save_circuits(rows: list[dict]) -> int:
|
||||
now = _now_iso()
|
||||
with db:
|
||||
db.executable.execute(text("DELETE FROM node_circuits"))
|
||||
for row in rows[: LIMITS.circuit_snapshot_max]:
|
||||
db.executable.execute(
|
||||
text(
|
||||
"INSERT INTO node_circuits (key, failures, state, opened_at, updated_at) "
|
||||
"VALUES (:key, :failures, :state, :opened_at, :now)"
|
||||
),
|
||||
{
|
||||
"key": row["key"],
|
||||
"failures": int(row["failures"]),
|
||||
"state": row["state"],
|
||||
"opened_at": float(row["opened_at"]),
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
return min(len(rows), LIMITS.circuit_snapshot_max)
|
||||
|
||||
|
||||
def load_circuits() -> list[dict]:
|
||||
return [
|
||||
dict(row)
|
||||
for row in db.query(
|
||||
"SELECT key, failures, state, opened_at FROM node_circuits LIMIT :n",
|
||||
n=LIMITS.circuit_snapshot_max,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def cache_get(key: str, moment: datetime | None = None) -> dict | None:
|
||||
now = (moment or datetime.now(timezone.utc)).isoformat()
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT status, headers, body, stored_at FROM node_cache "
|
||||
"WHERE key = :key AND expires_at > :now LIMIT 1",
|
||||
key=key,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
return dict(rows[0]) if rows else None
|
||||
|
||||
|
||||
def cache_put(
|
||||
key: str,
|
||||
method: str,
|
||||
url: str,
|
||||
status: int,
|
||||
headers: str,
|
||||
body: str,
|
||||
size: int,
|
||||
ttl_seconds: int,
|
||||
moment: datetime | None = None,
|
||||
) -> bool:
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
now = moment or datetime.now(timezone.utc)
|
||||
return (
|
||||
_execute(
|
||||
"INSERT INTO node_cache (key, method, url, status, headers, body, size, stored_at, expires_at) "
|
||||
"VALUES (:key, :method, :url, :status, :headers, :body, :size, :stored, :expires) "
|
||||
"ON CONFLICT(key) DO UPDATE SET status = excluded.status, headers = excluded.headers, "
|
||||
"body = excluded.body, size = excluded.size, stored_at = excluded.stored_at, "
|
||||
"expires_at = excluded.expires_at",
|
||||
{
|
||||
"key": key,
|
||||
"method": method,
|
||||
"url": url[: LIMITS.url_max_chars],
|
||||
"status": int(status),
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"size": int(size),
|
||||
"stored": now.isoformat(),
|
||||
"expires": (now + timedelta(seconds=ttl_seconds)).isoformat(),
|
||||
},
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def cache_prune(max_bytes: int, moment: datetime | None = None) -> dict[str, int]:
|
||||
now = (moment or datetime.now(timezone.utc)).isoformat()
|
||||
expired = _execute("DELETE FROM node_cache WHERE expires_at <= :now", {"now": now})
|
||||
total = next(iter(db.query("SELECT COALESCE(SUM(size), 0) AS total FROM node_cache")))["total"]
|
||||
evicted = 0
|
||||
while total > max_bytes:
|
||||
rows = list(
|
||||
db.query("SELECT key, size FROM node_cache ORDER BY stored_at, id LIMIT :n", n=BATCH_SIZE)
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
if total <= max_bytes:
|
||||
break
|
||||
evicted += _execute("DELETE FROM node_cache WHERE key = :key", {"key": row["key"]})
|
||||
total -= int(row["size"] or 0)
|
||||
return {"expired": expired, "evicted": evicted, "bytes": max(0, int(total))}
|
||||
|
||||
|
||||
def cache_clear() -> int:
|
||||
return _execute("DELETE FROM node_cache", {})
|
||||
|
||||
|
||||
def cache_stats() -> dict[str, int]:
|
||||
row = next(
|
||||
iter(db.query("SELECT COUNT(*) AS entries, COALESCE(SUM(size), 0) AS bytes FROM node_cache"))
|
||||
)
|
||||
return {"entries": int(row["entries"]), "bytes": int(row["bytes"])}
|
||||
|
||||
Reference in New Issue
Block a user