app.py
python · 292 lines · Exact bytes
Version & provenance · service-gpu-c205ec8f7f77
Recorded SHA-256 0862f8ef0a30879faad583fea528889ad1ce453e58d5881b888e3b44882f2fe7Rendered SHA-256 0862f8ef0a30879faad583fea528889ad1ce453e58d5881b888e3b44882f2fe7
Apache-2.0 for original LittlePly code, declared by the project owner on 2026-10-28. Historical source bytes and hashes are unchanged. Third-party components retain their own licenses; Lichess data remain CC0-1.0. License notes. Line links are stable within this snapshot. Exact allowlisted source from immutable live GPU release 6133648b2926-c205ec8f7f77, verified against release manifest SHA-256 a1cdc3f5f55c96b3164bc6fd78cb37308d547b2d7f07865dd37ad1448f2f3a1e. Runtime owner confirmed GPU serving, event-stream/model warmup, BOT identity and zero restarts on 2026-10-28. Capacity is up to four casual standard games, with bullet games alone; UltraBullet, correspondence and variants are unsupported. Checkpoint remains 6133648b2926b38542ee3ee1251a1ba111d61da9a8ea69d1c5f0559b09933643. app_multi.py is the multi-game controller; app.py is its shared base, whose legacy standalone admission differs. This is a source subset; host configuration, launcher, state, secrets and private games are excluded. This is a serving change, not a new model or evaluation.
1"""Conservative single-game public Bot API service; no matchmaking or chat."""
2import argparse,fcntl,hashlib,itertools,json,logging,logging.handlers,os,queue,re,signal,socket,sys,time
3from pathlib import Path
4from core import Game,identity,position_key,atomic_json,budget
5from transport import Transport,Stream,Events
6from inference import Owner
7from acceptance import Acceptances
8import chess
9
10def admission(c,bot,busy=False):
11 if busy:return 'later'
12 if identity(c.get('destUser',{}))!=bot.lower():return 'generic'
13 if not identity(c.get('challenger',{})):return 'generic'
14 if c.get('rated') is not False:return 'casual'
15 if c.get('variant',{}).get('key')!='standard' or c.get('initialFen') not in (None,'startpos',chess.STARTING_FEN):return 'standard'
16 tc=c.get('timeControl',{})
17 if tc.get('type')!='clock' or not 600<=tc.get('limit',0)<=1800 or not 5<=tc.get('increment',0)<=30:return 'timeControl'
18 return None
19
20def fresh_history(old,new):
21 a=old.split();b=new.split()
22 return len(b)>=len(a) and b[:len(a)]==a
23
24def notify(message):
25 address=os.environ.get('NOTIFY_SOCKET')
26 if address:
27 if address.startswith('@'):address='\0'+address[1:]
28 with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as s:s.connect(address);s.sendall(message.encode())
29
30def verify_release(root):
31 manifest=json.loads((root/'manifest.json').read_text())
32 if manifest.get('identity_version')==2:
33 identity={k:manifest[k] for k in ('files','entrypoint','max_games')}
34 expected=manifest['checkpoint_sha256'][:12]+'-'+hashlib.sha256(json.dumps(identity,sort_keys=True).encode()).hexdigest()[:12]
35 if manifest['release_id']!=expected or root.resolve().name!=expected:raise ValueError('release configuration identity mismatch')
36 if manifest['files'].get('checkpoint.pt')!=manifest['checkpoint_sha256']:raise ValueError('checkpoint identity mismatch')
37 for name,expected in manifest['files'].items():
38 p=(root/name).resolve()
39 if root.resolve() not in p.parents:raise ValueError('invalid manifest path')
40 h=hashlib.sha256()
41 with p.open('rb') as f:
42 for block in iter(lambda:f.read(1048576),b''):h.update(block)
43 if h.hexdigest()!=expected:raise ValueError('release hash mismatch: '+name)
44 return manifest
45
46class Service:
47 def __init__(self,args):
48 self.args=args;self.root=args.release.resolve();self.manifest=verify_release(self.root)
49 self.version=self.manifest['release_id'];self.state=args.state;self.state.mkdir(parents=True,exist_ok=True)
50 self.lock=(self.state/'account.lock').open('a');fcntl.flock(self.lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
51 self.log=logging.getLogger('littleply');self.log.setLevel(logging.INFO)
52 handler=logging.handlers.RotatingFileHandler(self.state/'events.jsonl',maxBytes=5_000_000,backupCount=3)
53 self.log.addHandler(handler)
54 self.api=Transport(args.token_file);self.owner=Owner(self.root/'checkpoint.pt')
55 self.events=Events();self.epochs=itertools.count(1);self.account=None;self.stream=None
56 self.game=None;self.epoch=0;self.pending=None;self.inflight=None;self.reservation=None;self.drain=False
57 self.auth=False;self.synced=False;self.online=False;self.account_live=False;self.last_health=0;self.notified=False
58 self.jobs={};self.retry=[];self.last_reconcile=0.;self.last_online_check=0.;self.cool_until=0.;self.bad_policy=False;self.closed=set()
59 self.api_job('account','GET','/api/account')
60 signal.signal(signal.SIGTERM,lambda *_:self.shutdown());signal.signal(signal.SIGINT,lambda *_:self.shutdown())
61 def event(self,event,**data):self.log.info(json.dumps(dict(utc=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),event=event,**data)))
62 def shutdown(self):self.drain=True;self.event('draining')
63 def api_job(self,key,method,path,data=None,priority=1,guard=None):
64 if key in self.jobs:return False
65 self.jobs[key]=(method,path,data,priority,guard,time.monotonic())
66 try:self.api.submit(key,method,path,data,priority,guard)
67 except queue.Full:self.jobs.pop(key);self.event('control_queue_full');return False
68 return True
69 def accepts(self):
70 if not hasattr(self,'acceptances'):self.acceptances=Acceptances()
71 return self.acceptances
72 def release_reserved(self,cid):
73 if self.reservation and self.reservation[0]==cid and not self.stream:self.reservation=None
74 def accept_capacity_valid(self,cid):
75 return bool(self.reservation and self.reservation[0]==cid and not self.stream and not self.game)
76 def begin_accept(self,cid):
77 op=self.accepts().begin(cid)
78 guard=lambda:self.accepts().valid(op.key)
79 if not self.api_job(op.key,'POST',f'/api/challenge/{cid}/accept',guard=guard):
80 self.accepts().invalidate(cid);self.release_reserved(cid)
81 def cancel_accept(self,cid):
82 if self.accepts().cancel(cid):self.release_reserved(cid)
83 def reconcile_accepts(self,games,job):
84 for cid in self.accepts().reconcile({g.get('gameId') for g in games},job[5] if job and len(job)>5 else 0,self.account_live):
85 self.release_reserved(cid);self.event('accept_reconciled_absent',id=cid)
86 def reconcile(self):
87 if 'playing' not in self.jobs:self.api_job('playing','GET','/api/account/playing',priority=0);self.last_reconcile=time.monotonic()
88 def start_game(self,gid):
89 if not re.fullmatch(r'[A-Za-z0-9]{8}',gid):return
90 if gid in self.closed:return
91 if self.game and self.game.id==gid:return
92 if self.stream:
93 if self.stream.tag==gid:return
94 self.drain=True;self.event('unexpected_second_game',game=gid);return
95 self.accepts().invalidate(gid)
96 self.reservation=(gid,time.monotonic());self.stream=Stream(self.api,'/api/bot/game/stream/'+gid,gid,self.events,self.epochs)
97 self.event('game_stream_started',game=gid)
98 def response(self,key,status,data):
99 if status=='send_ready':
100 # Queue insertion is not a network attempt. Persist uncertainty only
101 # when the network owner is ready and this exact request is current.
102 try:
103 job=self.jobs.get(key)
104 if key.startswith('accept:'):
105 op=self.accepts().find(key)
106 if op and job and time.monotonic()<data.expires and job[4]() and self.account_live and not self.drain and not (self.state/'drain').exists() and self.accept_capacity_valid(op.cid) and self.accepts().sent(key):data.allowed=True
107 return
108 if self.game and self.inflight and key==self.inflight[0] and job and time.monotonic()<data.expires:
109 guard=job[4]
110 if not guard or guard():
111 self.game.mark(self.inflight[1],'sending');data.allowed=True
112 finally:data.event.set()
113 return
114 job=self.jobs.pop(key,None)
115 if status=='auth_error':self.event('authentication_failed');raise RuntimeError('authentication unavailable')
116 if key.startswith('move:'):
117 if not self.game or not self.inflight or key!=self.inflight[0]:return
118 _,entry=self.inflight;self.inflight=None
119 if status=='ok':self.game.mark(entry,'accepted');self.event('move_accepted',game=self.game.id,ply=entry['ply'],uci=entry['uci'],source=entry['metrics']['decision_source'])
120 elif status=='cancelled':
121 # Transport guarantees that cancelled requests were never sent.
122 if entry['submission']=='sending':entry['attempts']=max(0,entry['attempts']-1)
123 self.game.mark(entry,'local_pending')
124 self.retry_local(entry)
125 else:
126 self.game.mark(entry,'uncertain' if status in ('uncertain','retryable') else 'rejected')
127 self.event('move_resync',game=self.game.id,status=status)
128 if self.stream:self.stream.resync.set()
129 return
130 if key.startswith('accept:'):
131 op=self.accepts().find(key)
132 if not op:return
133 cid=self.accepts().settle(key,status)
134 if cid:self.release_reserved(cid)
135 self.event('accept_result',id=op.cid,status=status)
136 self.reconcile()
137 return
138 if status in ('rate_limited','retryable'):
139 self.event('control_retry',operation=key,status=status)
140 if job and not key.startswith('decline:'):self.retry.append((time.monotonic()+max(5,self.api.cooldown.remaining()),key,job))
141 return
142 if status!='ok':self.event('control_result',operation=key,status=status);return
143 if key=='account':
144 if data.get('id')!=self.args.bot.lower() or data.get('title')!='BOT':raise RuntimeError('BOT identity mismatch')
145 self.auth=True;self.account=Stream(self.api,'/api/stream/event','account',self.events,self.epochs);self.reconcile()
146 self.api_job('online','GET','/api/users/status?ids='+self.args.bot)
147 elif key=='online':self.online=bool(data and data[0].get('online'))
148 elif key=='playing':
149 games=data.get('nowPlaying',[]);self.synced=True
150 if len(games)>1:self.drain=True;self.event('unexpected_multiple_active_games',count=len(games))
151 for g in games[:1]:self.start_game(g.get('gameId',''))
152 self.reconcile_accepts(games,job)
153 def ingest(self,tag,kind,data,epoch,received):
154 if kind=='auth_error':raise RuntimeError('stream authentication unavailable')
155 if tag=='account':
156 if kind=='connected':self.account_live=True;self.reconcile();self.api_job('online','GET','/api/users/status?ids='+self.args.bot)
157 elif kind=='disconnected':self.account_live=False;self.event('account_disconnected',reason=data)
158 elif kind=='data':
159 typ=data.get('type')
160 if typ=='challenge':
161 c=data['challenge'];cid=c.get('id','')
162 if not re.fullmatch(r'[A-Za-z0-9]{8}',cid):return
163 if self.reservation and self.reservation[0]==cid:return
164 reason=admission(c,self.args.bot,not self.accepting())
165 if reason:
166 self.api_job('decline:'+cid,'POST',f'/api/challenge/{cid}/decline',{'reason':reason},priority=2)
167 self.event('challenge_declined',id=cid,reason=reason)
168 else:
169 self.reservation=(cid,time.monotonic())
170 self.begin_accept(cid)
171 elif typ=='gameStart':self.start_game(data['game'].get('gameId') or data['game']['id'])
172 elif typ in ('challengeCanceled','challengeDeclined'):
173 self.cancel_accept(data.get('challenge',{}).get('id'))
174 return
175 if not self.stream or tag!=self.stream.tag or kind!='data':return
176 typ=data.get('type')
177 if typ=='gameFull':
178 if epoch<self.epoch:return
179 self.epoch=epoch
180 if self.game:
181 if not fresh_history(self.game.state['moves'],data['state']['moves']):self.event('stale_snapshot_ignored');return
182 self.game.ingest(data['state'])
183 else:
184 players=[identity(data.get(c,{})) for c in ('white','black')]
185 if self.args.bot.lower() not in players:raise ValueError('wrong bot game')
186 other=players[1] if players[0]==self.args.bot.lower() else players[0]
187 self.game=Game(data,self.args.bot,other,self.state/'games')
188 pinned=self.game.journal.get('release')
189 if pinned and pinned!=self.version:raise RuntimeError('active game belongs to another release; restart pinned release')
190 self.game.journal['release']=self.version;self.game.save();self.event('game_started',game=self.game.id,release=self.version)
191 fresh=True
192 elif typ=='gameState' and self.game and epoch==self.epoch:
193 if not fresh_history(self.game.state['moves'],data['moves']):self.event('stale_state_ignored');return
194 self.game.ingest(data);fresh=False
195 else:return
196 self.state_received=received
197 if self.game.state['status']!='started':
198 self.event('game_finished',game=self.game.id,status=self.game.state['status'],winner=self.game.state.get('winner'))
199 self.closed.add(self.game.id)
200 if len(self.closed)>256:self.closed.pop()
201 self.stream.stop.set();self.stream=None;self.game=None;self.reservation=None;self.pending=None;self.inflight=None
202 self.cool_until=time.monotonic()+15;return
203 if fresh and not self.inflight:
204 entry=self.game.journal['decisions'].get(position_key(self.game.board))
205 if entry and entry['submission']!='accepted' and entry.get('attempts',0)<3:
206 self.pending=(self.epoch,position_key(self.game.board),entry,time.monotonic()+max(2,self.api.cooldown.remaining()))
207 def retry_local(self,entry,delay=0):
208 g=self.game
209 if g and g.state['status']=='started' and g.board.turn==g.color and position_key(g.board)==entry['key'] and chess.Move.from_uci(entry['uci']) in g.board.legal_moves:
210 self.pending=(self.epoch,entry['key'],entry,time.monotonic()+delay)
211 def accepting(self):
212 return bool(self.auth and self.synced and self.online and self.account_live and self.owner.ready and not self.bad_policy
213 and not self.drain and not (self.state/'drain').exists() and not self.game and not self.reservation
214 and time.monotonic()>=self.cool_until and self.api.cooldown.remaining()==0
215 and __import__('shutil').disk_usage(self.state).free>512*1024**2)
216 def identity_matches(self,gid,epoch,digest):
217 g=self.game
218 return bool(g and g.id==gid and self.epoch==epoch and g.state['status']=='started' and position_key(g.board)==digest)
219 def work(self):
220 try:
221 while True:
222 key,status,answer=self.owner.results.get_nowait()
223 gid,epoch,digest=key
224 if not self.identity_matches(gid,epoch,digest):self.event('stale_inference_discarded');continue
225 if status=='ok':
226 uci,metrics=answer;move=chess.Move.from_uci(uci)
227 if move not in self.game.board.legal_moves:raise ValueError('illegal policy output')
228 self.bad_policy=False
229 else:
230 self.bad_policy=True;uci=sorted(self.game.board.legal_moves,key=lambda m:m.uci())[0].uci()
231 metrics={'decision_source':'legal_fallback','model_query':'unavailable','reason':'inference failure: '+str(answer)}
232 self.event('explicit_legal_fallback',game=gid,reason=str(answer))
233 entry={'key':digest,'ply':self.game.board.ply()+1,'uci':uci,'metrics':metrics,'attempts':0,'submission':'prepared'}
234 self.game.journal['decisions'][digest]=entry;self.game.save();self.pending=(epoch,digest,entry,time.monotonic())
235 except queue.Empty:pass
236 if self.pending and not self.inflight and time.monotonic()>=self.pending[3] and not self.api.cooldown.remaining():
237 epoch,digest,entry,_=self.pending;self.pending=None;g=self.game
238 if g and self.identity_matches(g.id,epoch,digest):
239 key='move:'+g.id+':'+digest;gid=g.id
240 guard=lambda:self.identity_matches(gid,epoch,digest)
241 g.mark(entry,'queued');self.inflight=(key,entry)
242 if not self.api_job(key,'POST',f'/api/bot/game/{gid}/move/{entry["uci"]}',priority=0,guard=guard):
243 self.inflight=None;g.mark(entry,'local_pending');self.retry_local(entry,.2)
244 g=self.game
245 if g and not self.pending and not self.inflight and not self.owner.busy and self.owner.ready:
246 digest=position_key(g.board)
247 if g.state['status']=='started' and g.board.turn==g.color and digest not in g.journal['decisions'] and not g.board.is_game_over():
248 self.owner.submit((g.id,self.epoch,digest),g.board.copy(stack=True),budget(g.state,g.color,age=max(0,time.monotonic()-self.state_received)))
249 def run(self):
250 try:
251 while True:
252 for _ in range(256):
253 try:self.ingest(*self.events.get_nowait())
254 except queue.Empty:break
255 for _ in range(64):
256 try:self.response(*self.api.results.get_nowait())
257 except queue.Empty:break
258 now=time.monotonic()
259 for task in self.retry[:]:
260 due,key,job=task
261 if now>=due:
262 self.retry.remove(task)
263 # Never replay an accept after uncertain reservation/state changed.
264 if key.startswith('accept:'):self.reconcile()
265 else:self.api_job(key,*job[:3],priority=job[3],guard=job[4])
266 if self.reservation and not self.game and now-self.last_reconcile>10:self.reconcile()
267 if self.auth and self.account_live and not self.online and now-self.last_online_check>10:
268 self.last_online_check=now;self.api_job('online','GET','/api/users/status?ids='+self.args.bot)
269 self.work()
270 if self.bad_policy and self.owner.ready and not self.game:self.bad_policy=False
271 if now-self.last_health>=2:
272 self.last_health=now
273 ready=self.auth and self.synced and self.account_live and self.online and self.owner.ready
274 atomic_json(self.state/'health.json',dict(updated=time.time(),ready=bool(ready),accepting=self.accepting(),active_game=self.game.id if self.game else None,
275 release=self.version,device=__import__('inference').serving_device(),max_games=1,worker_pid=self.owner.p.pid if self.owner.p else None,
276 launcher_pid=os.getpid(),cooldown_seconds=self.api.cooldown.remaining(),draining=self.drain or (self.state/'drain').exists()))
277 if ready and not self.notified:notify('READY=1');self.notified=True;self.event('service_ready',public=True,max_games=1)
278 notify('WATCHDOG=1')
279 if (self.drain or (self.state/'drain').exists()) and not self.game and not self.reservation:break
280 time.sleep(.05)
281 finally:
282 self.drain=True
283 if self.stream:self.stream.close()
284 if self.account:self.account.close()
285 self.api.close();self.owner.close();self.event('service_stopped');self.lock.close()
286 atomic_json(self.state/'health.json',dict(updated=time.time(),ready=False,accepting=False,stopped=True))
287
288def main():
289 p=argparse.ArgumentParser();p.add_argument('--release',type=Path,required=True);p.add_argument('--state',type=Path,required=True)
290 p.add_argument('--token-file',type=Path,required=True);p.add_argument('--bot',default='LittlePly');args=p.parse_args()
291 Service(args).run()
292