RayMelius Claude Sonnet 4.6 commited on
Commit
22180c1
Β·
1 Parent(s): 83042c0

Fix infinite redeploy loop: push state to separate branch

Browse files

State is now saved to 'simulation-state' branch (not master), so GitHub
pushes never trigger Render auto-deploys. Also removed periodic 96-tick
GitHub push β€” only save on shutdown to minimise API calls.

- load_state_from_github: reads from simulation-state branch (?ref=)
- save_state_to_github: writes to simulation-state branch, auto-creates
branch from master on first save
- simulation_loop: removed periodic GitHub push (shutdown-only now)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. src/soci/api/server.py +41 -26
src/soci/api/server.py CHANGED
@@ -93,12 +93,9 @@ async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0
93
 
94
  await sim.tick()
95
 
96
- # Auto-save every 24 ticks (~6 sim-hours); push to GitHub every 96 ticks (~1 sim-day)
97
  if sim.clock.total_ticks % 24 == 0:
98
  await save_simulation(sim, db, "autosave")
99
- if sim.clock.total_ticks % 96 == 0:
100
- data_dir_bg = Path(os.environ.get("SOCI_DATA_DIR", "data"))
101
- asyncio.create_task(save_state_to_github(data_dir_bg))
102
 
103
  # At high speeds, skip the delay entirely
104
  delay = tick_delay * _sim_speed
@@ -116,30 +113,34 @@ async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0
116
 
117
 
118
  async def load_state_from_github(data_dir: Path) -> bool:
119
- """Fetch autosave.json from GitHub and write it locally so load_simulation() can find it.
 
 
 
120
 
121
  Env vars:
122
- GITHUB_TOKEN β€” personal access token with repo read/write
123
- GITHUB_REPO β€” "owner/repo" e.g. "alice/soci"
124
- GITHUB_STATE_FILE β€” path inside repo (default: "state/autosave.json")
 
125
  """
126
  token = os.environ.get("GITHUB_TOKEN", "")
127
  repo = os.environ.get("GITHUB_REPO", "")
128
  if not token or not repo:
129
  return False
130
  path = os.environ.get("GITHUB_STATE_FILE", "state/autosave.json")
 
 
131
  try:
132
  async with httpx.AsyncClient() as client:
133
  resp = await client.get(
134
  f"https://api.github.com/repos/{repo}/contents/{path}",
135
- headers={
136
- "Authorization": f"token {token}",
137
- "Accept": "application/vnd.github.v3+json",
138
- },
139
  timeout=30.0,
140
  )
141
  if resp.status_code == 404:
142
- logger.info("No GitHub state file found β€” starting fresh")
143
  return False
144
  resp.raise_for_status()
145
  content = base64.b64decode(resp.json()["content"]).decode("utf-8").strip()
@@ -149,7 +150,7 @@ async def load_state_from_github(data_dir: Path) -> bool:
149
  local_path = data_dir / "snapshots" / "autosave.json"
150
  local_path.parent.mkdir(parents=True, exist_ok=True)
151
  local_path.write_text(content, encoding="utf-8")
152
- logger.info(f"Loaded state from GitHub ({len(content):,} bytes)")
153
  return True
154
  except Exception as e:
155
  logger.warning(f"Could not load state from GitHub: {e}")
@@ -157,12 +158,13 @@ async def load_state_from_github(data_dir: Path) -> bool:
157
 
158
 
159
  async def save_state_to_github(data_dir: Path) -> bool:
160
- """Push autosave.json to GitHub for durable cross-deploy persistence."""
161
  token = os.environ.get("GITHUB_TOKEN", "")
162
  repo = os.environ.get("GITHUB_REPO", "")
163
  if not token or not repo:
164
  return False
165
  path = os.environ.get("GITHUB_STATE_FILE", "state/autosave.json")
 
166
  local_path = data_dir / "snapshots" / "autosave.json"
167
  if not local_path.exists():
168
  logger.warning("No autosave.json to push to GitHub")
@@ -170,38 +172,51 @@ async def save_state_to_github(data_dir: Path) -> bool:
170
  try:
171
  content_bytes = local_path.read_bytes()
172
  encoded = base64.b64encode(content_bytes).decode("ascii")
 
173
  async with httpx.AsyncClient() as client:
174
- # Fetch current SHA (needed to update an existing file)
175
  sha: Optional[str] = None
176
  get_resp = await client.get(
177
  f"https://api.github.com/repos/{repo}/contents/{path}",
178
- headers={
179
- "Authorization": f"token {token}",
180
- "Accept": "application/vnd.github.v3+json",
181
- },
182
  timeout=30.0,
183
  )
184
  if get_resp.status_code == 200:
185
  sha = get_resp.json().get("sha")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  body: dict = {
188
- "message": "chore: update simulation state [skip ci]",
189
  "content": encoded,
 
190
  }
191
  if sha:
192
  body["sha"] = sha
193
 
194
  put_resp = await client.put(
195
  f"https://api.github.com/repos/{repo}/contents/{path}",
196
- headers={
197
- "Authorization": f"token {token}",
198
- "Accept": "application/vnd.github.v3+json",
199
- },
200
  json=body,
201
  timeout=60.0,
202
  )
203
  put_resp.raise_for_status()
204
- logger.info(f"Saved state to GitHub ({len(content_bytes):,} bytes)")
205
  return True
206
  except Exception as e:
207
  logger.warning(f"Could not save state to GitHub: {e}")
 
93
 
94
  await sim.tick()
95
 
96
+ # Auto-save every 24 ticks (~6 sim-hours)
97
  if sim.clock.total_ticks % 24 == 0:
98
  await save_simulation(sim, db, "autosave")
 
 
 
99
 
100
  # At high speeds, skip the delay entirely
101
  delay = tick_delay * _sim_speed
 
113
 
114
 
115
  async def load_state_from_github(data_dir: Path) -> bool:
116
+ """Fetch autosave.json from the simulation-state branch on GitHub.
117
+
118
+ Reads from GITHUB_STATE_BRANCH (default: "simulation-state") so pushes
119
+ never touch the master branch and never trigger Render auto-deploys.
120
 
121
  Env vars:
122
+ GITHUB_TOKEN β€” personal access token with repo read/write
123
+ GITHUB_REPO β€” "owner/repo" e.g. "alice/soci"
124
+ GITHUB_STATE_BRANCH β€” branch name (default: "simulation-state")
125
+ GITHUB_STATE_FILE β€” path inside repo (default: "state/autosave.json")
126
  """
127
  token = os.environ.get("GITHUB_TOKEN", "")
128
  repo = os.environ.get("GITHUB_REPO", "")
129
  if not token or not repo:
130
  return False
131
  path = os.environ.get("GITHUB_STATE_FILE", "state/autosave.json")
132
+ branch = os.environ.get("GITHUB_STATE_BRANCH", "simulation-state")
133
+ headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
134
  try:
135
  async with httpx.AsyncClient() as client:
136
  resp = await client.get(
137
  f"https://api.github.com/repos/{repo}/contents/{path}",
138
+ params={"ref": branch},
139
+ headers=headers,
 
 
140
  timeout=30.0,
141
  )
142
  if resp.status_code == 404:
143
+ logger.info(f"No GitHub state on branch '{branch}' β€” starting fresh")
144
  return False
145
  resp.raise_for_status()
146
  content = base64.b64decode(resp.json()["content"]).decode("utf-8").strip()
 
150
  local_path = data_dir / "snapshots" / "autosave.json"
151
  local_path.parent.mkdir(parents=True, exist_ok=True)
152
  local_path.write_text(content, encoding="utf-8")
153
+ logger.info(f"Loaded state from GitHub branch '{branch}' ({len(content):,} bytes)")
154
  return True
155
  except Exception as e:
156
  logger.warning(f"Could not load state from GitHub: {e}")
 
158
 
159
 
160
  async def save_state_to_github(data_dir: Path) -> bool:
161
+ """Push autosave.json to the simulation-state branch (never touches master)."""
162
  token = os.environ.get("GITHUB_TOKEN", "")
163
  repo = os.environ.get("GITHUB_REPO", "")
164
  if not token or not repo:
165
  return False
166
  path = os.environ.get("GITHUB_STATE_FILE", "state/autosave.json")
167
+ branch = os.environ.get("GITHUB_STATE_BRANCH", "simulation-state")
168
  local_path = data_dir / "snapshots" / "autosave.json"
169
  if not local_path.exists():
170
  logger.warning("No autosave.json to push to GitHub")
 
172
  try:
173
  content_bytes = local_path.read_bytes()
174
  encoded = base64.b64encode(content_bytes).decode("ascii")
175
+ headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
176
  async with httpx.AsyncClient() as client:
177
+ # Fetch current file SHA on the state branch (needed to update)
178
  sha: Optional[str] = None
179
  get_resp = await client.get(
180
  f"https://api.github.com/repos/{repo}/contents/{path}",
181
+ params={"ref": branch},
182
+ headers=headers,
 
 
183
  timeout=30.0,
184
  )
185
  if get_resp.status_code == 200:
186
  sha = get_resp.json().get("sha")
187
+ elif get_resp.status_code == 404:
188
+ # Branch or file doesn't exist yet β€” create the branch from master
189
+ ref_resp = await client.get(
190
+ f"https://api.github.com/repos/{repo}/git/ref/heads/master",
191
+ headers=headers,
192
+ timeout=15.0,
193
+ )
194
+ if ref_resp.status_code == 200:
195
+ master_sha = ref_resp.json()["object"]["sha"]
196
+ await client.post(
197
+ f"https://api.github.com/repos/{repo}/git/refs",
198
+ headers=headers,
199
+ json={"ref": f"refs/heads/{branch}", "sha": master_sha},
200
+ timeout=15.0,
201
+ )
202
+ logger.info(f"Created GitHub branch '{branch}' for state storage")
203
 
204
  body: dict = {
205
+ "message": "chore: save simulation state",
206
  "content": encoded,
207
+ "branch": branch,
208
  }
209
  if sha:
210
  body["sha"] = sha
211
 
212
  put_resp = await client.put(
213
  f"https://api.github.com/repos/{repo}/contents/{path}",
214
+ headers=headers,
 
 
 
215
  json=body,
216
  timeout=60.0,
217
  )
218
  put_resp.raise_for_status()
219
+ logger.info(f"Saved state to GitHub branch '{branch}' ({len(content_bytes):,} bytes)")
220
  return True
221
  except Exception as e:
222
  logger.warning(f"Could not save state to GitHub: {e}")