Budlee commited on
Commit
c8fc16a
·
verified ·
1 Parent(s): 21e9b08

Filesystem state persistence with flock to survive @spaces.GPU fork

Browse files
Files changed (1) hide show
  1. game/scheduler.py +110 -26
game/scheduler.py CHANGED
@@ -19,18 +19,21 @@ Reference: https://huggingface.co/docs/hub/spaces-zerogpu
19
 
20
  from __future__ import annotations
21
 
 
 
22
  import random
23
  import threading
24
  import time
25
  import traceback
26
  from collections import deque
 
27
  from pathlib import Path
28
 
29
  from smolagents import CodeAgent
30
  from smolagents.monitoring import LogLevel
31
 
32
  from .actions import advance, build_tools
33
- from .character import Character
34
  from .dreams import run_dream
35
  from .interpreter import SharedInterpreter
36
  from .perception import system_prompt_for, task_prompt_for
@@ -38,6 +41,9 @@ from .sentiment import classify_board
38
  from .smol_adapter import BackendModel
39
  from .world import GRID_H, GRID_W, PLACES, BoardPost, ShellLine, WorldState
40
 
 
 
 
41
  TICK_SECONDS = 0.4
42
  GOAL_CHECK_INTERVAL_TICKS = 60
43
  MAX_AGENT_STEPS = 2
@@ -58,6 +64,93 @@ class Townlet:
58
  # thread (no LLM work), drained by the @spaces.GPU endpoint.
59
  self._pending_decisions: deque[str] = deque()
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  # ----- lifecycle -----
62
 
63
  def start(self) -> None:
@@ -83,12 +176,12 @@ class Townlet:
83
  # ----- mutation API -----
84
 
85
  def add_character(self, char: Character) -> None:
86
- with self._mutex:
87
  self.world.characters[char.name] = char
88
  self._persist_character(char)
89
 
90
  def set_personality(self, name: str, personality: str) -> bool:
91
- with self._mutex:
92
  c = self.world.characters.get(name)
93
  if c is None:
94
  return False
@@ -101,7 +194,7 @@ class Townlet:
101
 
102
  if model_id not in ROSTER:
103
  return False
104
- with self._mutex:
105
  c = self.world.characters.get(name)
106
  if c is None:
107
  return False
@@ -110,20 +203,17 @@ class Townlet:
110
  return True
111
 
112
  def set_paused(self, paused: bool) -> None:
113
- self.world.paused = paused
 
114
 
115
  # ----- snapshot -----
116
 
117
  def snapshot(self) -> dict:
118
- # Advance the world clock on every snapshot poll too — keeps the
119
- # animation smooth between tick_decisions calls. Cheap (no LLM).
120
- import os
121
- print(
122
- f"[townlet] SNAPSHOT pid={os.getpid()} tid={threading.get_ident()} "
123
- f"T[id={id(self):x}] tick={self.world.tick}",
124
- flush=True,
125
- )
126
- self.tick_world(2)
127
  with self._mutex:
128
  return {
129
  "tick": self.world.tick,
@@ -192,19 +282,13 @@ class Townlet:
192
 
193
  def drain_decisions(self, limit: int = DECISIONS_PER_DRAIN, world_ticks: int = 5) -> int:
194
  """Advance the world clock then pop up to `limit` ready characters
195
- and run their CodeAgent.
196
-
197
- Called from app.py's @spaces.GPU-decorated endpoint so all LLM work
198
- AND world-clock work happens inside the GPU boundary on Zero-GPU.
199
- Keeping both in the same call ensures they share object graph (which
200
- a background daemon thread on Gradio Server does not reliably do).
201
  """
202
- import os
203
- print(
204
- f"[townlet] DRAIN-ENTRY pid={os.getpid()} tid={threading.get_ident()} "
205
- f"T[id={id(self):x}] tick={self.world.tick}",
206
- flush=True,
207
- )
208
  self.tick_world(world_ticks)
209
  with self._mutex:
210
  start_pending = list(self._pending_decisions)
 
19
 
20
  from __future__ import annotations
21
 
22
+ import fcntl
23
+ import json
24
  import random
25
  import threading
26
  import time
27
  import traceback
28
  from collections import deque
29
+ from contextlib import contextmanager
30
  from pathlib import Path
31
 
32
  from smolagents import CodeAgent
33
  from smolagents.monitoring import LogLevel
34
 
35
  from .actions import advance, build_tools
36
+ from .character import Action, Character, default_traits
37
  from .dreams import run_dream
38
  from .interpreter import SharedInterpreter
39
  from .perception import system_prompt_for, task_prompt_for
 
41
  from .smol_adapter import BackendModel
42
  from .world import GRID_H, GRID_W, PLACES, BoardPost, ShellLine, WorldState
43
 
44
+ STATE_PATH = Path("/tmp/townlet/state.json")
45
+ LOCK_PATH = Path("/tmp/townlet/state.lock")
46
+
47
  TICK_SECONDS = 0.4
48
  GOAL_CHECK_INTERVAL_TICKS = 60
49
  MAX_AGENT_STEPS = 2
 
64
  # thread (no LLM work), drained by the @spaces.GPU endpoint.
65
  self._pending_decisions: deque[str] = deque()
66
 
67
+ # ----- shared state via filesystem -----
68
+ #
69
+ # `@spaces.GPU` forks the endpoint into a subprocess, so the parent
70
+ # process (handling non-GPU endpoints like /world_snapshot) and the
71
+ # child (handling /tick_decisions) do NOT share Python memory. We
72
+ # serialise the whole world on every endpoint to keep them in sync.
73
+
74
+ @contextmanager
75
+ def _state_lock(self):
76
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
77
+ lf = open(LOCK_PATH, "w")
78
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
79
+ try:
80
+ self._load_state_unlocked()
81
+ yield
82
+ self._save_state_unlocked()
83
+ finally:
84
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
85
+ lf.close()
86
+
87
+ def _save_state_unlocked(self) -> None:
88
+ state = {
89
+ "tick": self.world.tick,
90
+ "paused": self.world.paused,
91
+ "characters": [c.to_full_dict() for c in self.world.characters.values()],
92
+ "board": [{"author": p.author, "text": p.text, "tick": p.tick} for p in self.world.board],
93
+ "shell_lines": [
94
+ {"author": s.author, "kind": s.kind, "text": s.text, "tick": s.tick}
95
+ for s in self.world.shell_lines
96
+ ],
97
+ "shell_lock_holder": self.world.shell_lock_holder,
98
+ "shell_queue": list(self.world.shell_queue),
99
+ "board_sentiment": self.world.board_sentiment,
100
+ "pending": list(self._pending_decisions),
101
+ }
102
+ tmp = STATE_PATH.with_suffix(".json.tmp")
103
+ tmp.write_text(json.dumps(state))
104
+ tmp.rename(STATE_PATH)
105
+
106
+ def _load_state_unlocked(self) -> bool:
107
+ if not STATE_PATH.exists():
108
+ return False
109
+ try:
110
+ state = json.loads(STATE_PATH.read_text())
111
+ except Exception:
112
+ return False
113
+ new_chars: dict[str, Character] = {}
114
+ for d in state.get("characters", []):
115
+ c = Character(
116
+ name=d["name"],
117
+ personality=d["personality"],
118
+ model_id=d["model_id"],
119
+ sprite_id=d["sprite_id"],
120
+ archetype=d.get("archetype", "wanderer"),
121
+ journal=list(d.get("journal", [])),
122
+ traits=dict(d.get("traits", default_traits())),
123
+ goal=d.get("goal", ""),
124
+ pos=tuple(d["pos"]),
125
+ inventory=dict(d.get("inventory", {"electricity": 0, "water": 0})),
126
+ locker=dict(d.get("locker", {"electricity": 0, "water": 0})),
127
+ alive=bool(d.get("alive", True)),
128
+ )
129
+ a = d.get("current_action")
130
+ if a:
131
+ c.current_action = Action(
132
+ verb=a["verb"],
133
+ params=dict(a.get("params", {})),
134
+ ticks_remaining=int(a.get("ticks_remaining", 0)),
135
+ )
136
+ new_chars[c.name] = c
137
+ self.world.characters = new_chars
138
+ self.world.tick = int(state.get("tick", 0))
139
+ self.world.paused = bool(state.get("paused", False))
140
+ self.world.board = [
141
+ BoardPost(author=p["author"], text=p["text"], tick=p["tick"])
142
+ for p in state.get("board", [])
143
+ ]
144
+ self.world.shell_lines = [
145
+ ShellLine(author=s["author"], kind=s["kind"], text=s["text"], tick=s["tick"])
146
+ for s in state.get("shell_lines", [])
147
+ ]
148
+ self.world.shell_lock_holder = state.get("shell_lock_holder")
149
+ self.world.shell_queue = list(state.get("shell_queue", []))
150
+ self.world.board_sentiment = state.get("board_sentiment", "neutral")
151
+ self._pending_decisions = deque(state.get("pending", []))
152
+ return True
153
+
154
  # ----- lifecycle -----
155
 
156
  def start(self) -> None:
 
176
  # ----- mutation API -----
177
 
178
  def add_character(self, char: Character) -> None:
179
+ with self._state_lock(), self._mutex:
180
  self.world.characters[char.name] = char
181
  self._persist_character(char)
182
 
183
  def set_personality(self, name: str, personality: str) -> bool:
184
+ with self._state_lock(), self._mutex:
185
  c = self.world.characters.get(name)
186
  if c is None:
187
  return False
 
194
 
195
  if model_id not in ROSTER:
196
  return False
197
+ with self._state_lock(), self._mutex:
198
  c = self.world.characters.get(name)
199
  if c is None:
200
  return False
 
203
  return True
204
 
205
  def set_paused(self, paused: bool) -> None:
206
+ with self._state_lock():
207
+ self.world.paused = paused
208
 
209
  # ----- snapshot -----
210
 
211
  def snapshot(self) -> dict:
212
+ with self._state_lock():
213
+ self.tick_world(2)
214
+ return self._snapshot_dict()
215
+
216
+ def _snapshot_dict(self) -> dict:
 
 
 
 
217
  with self._mutex:
218
  return {
219
  "tick": self.world.tick,
 
282
 
283
  def drain_decisions(self, limit: int = DECISIONS_PER_DRAIN, world_ticks: int = 5) -> int:
284
  """Advance the world clock then pop up to `limit` ready characters
285
+ and run their CodeAgent. Wrapped in _state_lock so mutations
286
+ survive the @spaces.GPU subprocess fork.
 
 
 
 
287
  """
288
+ with self._state_lock():
289
+ return self._drain_decisions_inner(limit, world_ticks)
290
+
291
+ def _drain_decisions_inner(self, limit: int, world_ticks: int) -> int:
 
 
292
  self.tick_world(world_ticks)
293
  with self._mutex:
294
  start_pending = list(self._pending_decisions)