app_multi.py
python · 277 lines · Exact bytes
Version & provenance · service-gpu-c205ec8f7f77
Recorded SHA-256 4fe08b32479e49822c54f37f8d4c23befc4a67b66feaa21b0f60128479e2338eRendered SHA-256 4fe08b32479e49822c54f37f8d4c23befc4a67b66feaa21b0f60128479e2338e
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"""Candidate multi-game controller. Not deployed until independent review."""
2import argparse,collections,os,queue,re,time
3from dataclasses import dataclass
4from pathlib import Path
5import chess
6from app import Service as SingleService,fresh_history,notify
7from core import identity,position_key,atomic_json
8from transport import Stream
9from multi_game import RealtimeGame
10from scheduler import Scheduler,Request,clock_window,realtime_reason,expected_policy_seconds,bullet_clock
11
12@dataclass
13class Slot:
14 gid:str
15 stream:object
16 game:object=None
17 epoch:int=0
18 received:float=0.
19 pending:object=None
20 inflight:object=None
21
22def admission(c,bot,busy=False):
23 if identity(c.get('destUser',{}))!=bot.lower() or not identity(c.get('challenger',{})):return 'generic'
24 if c.get('rated') is not False:return 'casual'
25 if c.get('variant',{}).get('key')!='standard' or c.get('initialFen') not in (None,'startpos',chess.STARTING_FEN):return 'standard'
26 reason=realtime_reason(c.get('timeControl',{}))
27 return reason or ('later' if busy else None)
28
29class Service(SingleService):
30 def __init__(self,args):
31 super().__init__(args)
32 self.cap=int(self.manifest.get('max_games',4));assert 1<=self.cap<=4
33 self.slots={};self.reservations={};self.retired=[];self.scheduler=Scheduler(10)
34 self.owner.poll_floor=8 # soft clock expiry never kills a useful shared worker
35 self.samples=collections.deque(maxlen=64);self.last_snapshot=0.
36 def release_reserved(self,cid):self.reservations.pop(cid,None)
37 def accept_capacity_valid(self,cid):
38 count=len(self.slots)+len(self.reservations)
39 if cid not in self.reservations or count>self.cap:return False
40 bullet=any(r.get('bullet',False) for r in self.reservations.values())
41 for slot in self.slots.values():
42 if not slot.game:return False
43 clock=slot.game.full['clock']
44 bullet=bullet or bullet_clock(clock['initial']/1000,clock['increment']/1000)
45 return not bullet or count==1
46 def start_game(self,gid):
47 if not re.fullmatch('[A-Za-z0-9]{8}',gid) or gid in self.closed or gid in self.slots:return
48 if len(self.slots)>=10:raise RuntimeError('unexpected account concurrency above recovery bound')
49 self.accepts().invalidate(gid)
50 self.reservations.pop(gid,None)
51 self.slots[gid]=Slot(gid,Stream(self.api,'/api/bot/game/stream/'+gid,gid,self.events,self.epochs))
52 self.event('game_stream_started',game=gid)
53 def identity_matches(self,gid,epoch,digest,release=None):
54 slot=self.slots.get(gid);g=slot.game if slot else None
55 return bool(g and slot.epoch==epoch and (release is None or release==self.version) and g.state['status']=='started' and position_key(g.board)==digest)
56 def valid(self,key):return self.identity_matches(*key)
57 def retry_local_slot(self,slot,entry,delay=0):
58 g=slot.game
59 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:
60 slot.pending=(slot.epoch,entry['key'],entry,time.monotonic()+delay)
61 def response(self,key,status,data):
62 if key.startswith('move:'):
63 gid=key.split(':')[1];slot=self.slots.get(gid)
64 if status=='send_ready':
65 try:
66 job=self.jobs.get(key)
67 if slot and slot.game and slot.inflight and slot.inflight[0]==key and job and time.monotonic()<data.expires and (not job[4] or job[4]()):
68 slot.game.mark(slot.inflight[1],'sending');data.allowed=True
69 finally:data.event.set()
70 return
71 self.jobs.pop(key,None)
72 if status=='auth_error':raise RuntimeError('move authorization failed')
73 if not slot or not slot.game or not slot.inflight or slot.inflight[0]!=key:return
74 _,entry=slot.inflight;slot.inflight=None;g=slot.game
75 if status=='ok':g.mark(entry,'accepted');self.event('move_accepted',game=gid,ply=entry['ply'],uci=entry['uci'],source=entry['metrics']['decision_source'])
76 elif status=='cancelled':
77 if entry['submission']=='sending':entry['attempts']=max(0,entry['attempts']-1)
78 g.mark(entry,'local_pending');self.retry_local_slot(slot,entry)
79 else:
80 g.mark(entry,'uncertain' if status in ('uncertain','retryable') else 'rejected')
81 slot.stream.resync.set();self.event('move_resync',game=gid,status=status)
82 return
83 if key=='playing' and status=='ok':
84 job=self.jobs.pop(key,None);games=data.get('nowPlaying',[]);self.synced=True;self.last_snapshot=time.monotonic()
85 for game in games:self.start_game(game.get('gameId',''))
86 self.reconcile_accepts(games,job)
87 return
88 super().response(key,status,data)
89 def windows(self,challenge=None):
90 now=time.monotonic();windows=[];costs=[]
91 for slot in self.slots.values():
92 g=slot.game
93 if g:
94 age=max(0,now-slot.received) if g.board.turn==g.color else 0
95 windows.append(clock_window(g.state,g.color,age));costs.append(expected_policy_seconds(1+g.board.ply()*3))
96 else:windows.append(1.);costs.append(.61)
97 for res in self.reservations.values():windows.append(res['window']);costs.append(.61)
98 if challenge:
99 tc=challenge['timeControl'];remaining=max(tc['limit'],tc['increment'],1)
100 windows.append(clock_window({'wtime':remaining*1000,'winc':tc['increment']*1000},True));costs.append(.61)
101 scale=max(1.,max(self.samples,default=0)*1.1)
102 return windows,[cost*scale for cost in costs]
103 def accepting(self,challenge=None):
104 healthy=bool(self.auth and self.synced and self.online and self.account_live and self.owner.ready and not self.bad_policy
105 and not self.drain and not (self.state/'drain').exists() and self.api.cooldown.remaining()==0
106 and len(self.slots)+len(self.reservations)<self.cap and len(self.retired)<self.cap
107 and __import__('shutil').disk_usage(self.state).free>512*1024**2)
108 if not healthy:return False
109 # The measured CPU burst is unsuitable for multiple simultaneous bullet
110 # clocks. All Bot-API-allowed bullet settings remain supported when idle.
111 if challenge:
112 tc=challenge['timeControl']
113 if bullet_clock(tc['limit'],tc['increment']) and (self.slots or self.reservations):return False
114 for slot in self.slots.values():
115 if not slot.game:return False # wait for authoritative clock metadata
116 clock=slot.game.full['clock']
117 if bullet_clock(clock['initial']/1000,clock['increment']/1000):return False
118 if any(r.get('bullet',False) for r in self.reservations.values()):return False
119 windows,costs=self.windows(challenge)
120 return not windows or sum(costs)<=min(windows)
121 def ingest(self,tag,kind,data,epoch,received):
122 if kind=='auth_error':raise RuntimeError('stream authentication failed')
123 if tag=='account':
124 if kind=='connected':self.account_live=True;self.reconcile();self.api_job('online','GET','/api/users/status?ids='+self.args.bot)
125 elif kind=='disconnected':self.account_live=False;self.event('account_disconnected',reason=data)
126 elif kind=='data':
127 typ=data.get('type')
128 if typ=='challenge':
129 c=data['challenge'];cid=c.get('id','')
130 if not re.fullmatch('[A-Za-z0-9]{8}',cid) or cid in self.reservations:return
131 reason=admission(c,self.args.bot)
132 if not reason and not self.accepting(c):reason='later'
133 if reason:
134 self.api_job('decline:'+cid,'POST',f'/api/challenge/{cid}/decline',{'reason':reason},priority=2)
135 self.event('challenge_declined',id=cid,reason=reason,clock=c.get('timeControl'),capacity=len(self.slots)+len(self.reservations))
136 else:
137 windows,_=self.windows(c);tc=c['timeControl']
138 self.reservations[cid]={'time':time.monotonic(),'window':windows[-1],'bullet':bullet_clock(tc['limit'],tc['increment'])}
139 self.begin_accept(cid)
140 elif typ=='gameStart':self.start_game(data['game'].get('gameId') or data['game']['id'])
141 elif typ in ('challengeCanceled','challengeDeclined'):self.cancel_accept(data.get('challenge',{}).get('id'))
142 return
143 slot=self.slots.get(tag)
144 if not slot or kind!='data':return
145 typ=data.get('type')
146 if typ=='gameFull':
147 if epoch<slot.epoch:return
148 slot.epoch=epoch
149 if slot.game:
150 if not fresh_history(slot.game.state['moves'],data['state']['moves']):self.event('stale_snapshot_ignored',game=tag);return
151 slot.game.ingest(data['state'])
152 else:
153 slot.game=RealtimeGame(data,self.args.bot,self.state/'games')
154 pinned=slot.game.journal.get('release')
155 if pinned and pinned!=self.version:raise RuntimeError('active game pinned to different release')
156 slot.game.journal['release']=self.version;slot.game.save();self.event('game_started',game=tag,release=self.version)
157 fresh=True
158 elif typ=='gameState' and slot.game and epoch==slot.epoch:
159 if not fresh_history(slot.game.state['moves'],data['moves']):self.event('stale_state_ignored',game=tag);return
160 slot.game.ingest(data);fresh=False
161 else:return
162 slot.received=received;g=slot.game
163 if g.state['status']!='started':
164 self.event('game_finished',game=tag,status=g.state['status'],winner=g.state.get('winner'))
165 self.closed.add(tag)
166 if len(self.closed)>256:self.closed.pop()
167 slot.stream.stop.set();self.retired.append(slot.stream);self.slots.pop(tag);self.reservations.pop(tag,None);self.scheduler.remove(tag)
168 return
169 waiting=self.scheduler.waiting.get(tag)
170 if waiting and not self.valid(waiting.key):self.scheduler.remove(tag)
171 if fresh and not slot.inflight:
172 entry=g.journal['decisions'].get(position_key(g.board))
173 if entry and entry['submission']!='accepted' and entry.get('attempts',0)<3:self.retry_local_slot(slot,entry,max(2,self.api.cooldown.remaining()))
174 def decision(self,slot,request,status,answer):
175 if not self.valid(request.key):self.event('stale_inference_discarded',game=request.key[0]);return
176 g=slot.game;digest=request.key[2]
177 if digest in g.journal['decisions']:return
178 if status=='ok':
179 uci,metrics=answer
180 if chess.Move.from_uci(uci) not in g.board.legal_moves:raise ValueError('illegal inference output')
181 self.bad_policy=False
182 else:
183 uci=sorted(g.board.legal_moves,key=lambda m:m.uci())[0].uci()
184 query='not_queried' if status=='context' or 'model not queried' in str(answer) else 'in_progress_at_deadline' if status=='deadline' else 'unavailable'
185 metrics={'decision_source':'legal_fallback','model_query':query,'reason':str(answer)}
186 self.event('explicit_legal_fallback',game=g.id,reason=str(answer));self.bad_policy=True
187 metrics['queue_seconds']=max(0,getattr(request,'dispatched',time.monotonic())-request.arrived)
188 entry={'key':digest,'ply':g.board.ply()+1,'uci':uci,'metrics':metrics,'attempts':0,'submission':'prepared'}
189 g.journal['decisions'][digest]=entry;g.save();self.retry_local_slot(slot,entry)
190 def work(self):
191 try:
192 while True:
193 key,status,answer=self.owner.results.get_nowait();request=self.scheduler.complete(key)
194 slot=self.slots.get(key[0])
195 if request and status=='ok':
196 self.samples.append(answer[1].get('decision_seconds',0)/expected_policy_seconds(1+request.board.ply()*3))
197 if request and slot and not request.expired:self.decision(slot,request,status,answer)
198 else:self.event('late_inference_discarded',game=key[0])
199 except queue.Empty:pass
200 now=time.monotonic();running=self.scheduler.running
201 if running and not running.expired and now>=running.deadline:
202 running.expired=True;slot=self.slots.get(running.key[0])
203 if slot:self.decision(slot,running,'deadline','clock deadline while inference finishes; no worker interruption')
204 for slot in list(self.slots.values()):
205 g=slot.game
206 if not g:continue
207 if slot.pending and not slot.inflight and now>=slot.pending[3] and not self.api.cooldown.remaining():
208 epoch,digest,entry,_=slot.pending;slot.pending=None
209 if self.identity_matches(slot.gid,epoch,digest):
210 key='move:'+slot.gid+':'+digest;gid=slot.gid
211 guard=lambda gid=gid,epoch=epoch,digest=digest:self.identity_matches(gid,epoch,digest)
212 g.mark(entry,'queued');slot.inflight=(key,entry)
213 if not self.api_job(key,'POST',f'/api/bot/game/{gid}/move/{entry["uci"]}',priority=0,guard=guard):
214 slot.inflight=None;g.mark(entry,'local_pending');self.retry_local_slot(slot,entry,.2)
215 digest=position_key(g.board);key=(slot.gid,slot.epoch,digest,self.version)
216 running_same=self.scheduler.running and self.scheduler.running.key==key
217 if not slot.pending and not slot.inflight and not running_same and g.state['status']=='started' and g.board.turn==g.color and digest not in g.journal['decisions'] and not g.board.is_game_over():
218 if 1+g.board.ply()*3+2>1024:
219 request=Request(key,g.board.copy(stack=True),now,now,0)
220 self.decision(slot,request,'context','full history exceeds context; no truncation; model not queried')
221 else:self.scheduler.add(key,g.board.copy(stack=True),clock_window(g.state,g.color,max(0,now-slot.received)),now)
222 # Waiting games have their own clocks even while another request owns
223 # the CPU. Expire them without waiting for that computation to finish.
224 for gid,request in list(self.scheduler.waiting.items()):
225 if time.monotonic()>=request.deadline:
226 self.scheduler.remove(gid);slot=self.slots.get(gid)
227 if slot:self.decision(slot,request,'deadline','clock deadline in inference queue; model not queried')
228 if self.owner.ready and not self.owner.busy:
229 request=self.scheduler.next(self.valid,now)
230 if request:
231 left=request.deadline-time.monotonic();slot=self.slots.get(request.key[0])
232 if left<=.05:
233 self.scheduler.complete(request.key)
234 if slot:self.decision(slot,request,'deadline','clock deadline in inference queue; model not queried')
235 else:
236 request.dispatched=time.monotonic();self.owner.submit(request.key,request.board,min(8,left))
237 def run(self):
238 try:
239 while True:
240 for _ in range(256):
241 try:self.ingest(*self.events.get_nowait())
242 except queue.Empty:break
243 for _ in range(64):
244 try:self.response(*self.api.results.get_nowait())
245 except queue.Empty:break
246 now=time.monotonic();self.retired=[s for s in self.retired if s.thread.is_alive()]
247 for task in self.retry[:]:
248 due,key,job=task
249 if now>=due:self.retry.remove(task);self.api_job(key,*job[:3],priority=job[3],guard=job[4])
250 if self.reservations and now-self.last_reconcile>10:self.reconcile()
251 if self.auth and self.account_live and not self.online and now-self.last_online_check>10:
252 self.last_online_check=now;self.api_job('online','GET','/api/users/status?ids='+self.args.bot)
253 self.work()
254 if self.bad_policy and self.owner.ready and not self.slots:self.bad_policy=False
255 if now-self.last_health>=2:
256 self.last_health=now;ready=self.auth and self.synced and self.account_live and self.online and self.owner.ready
257 games=list(self.slots)
258 atomic_json(self.state/'health.json',dict(updated=time.time(),ready=bool(ready),accepting=self.accepting(),active_games=games,
259 active_game=games[0] if len(games)==1 else None,release=self.version,device=__import__('inference').serving_device(),max_games=self.cap,
260 waiting=len(self.scheduler.waiting),worker_pid=self.owner.p.pid if self.owner.p else None,launcher_pid=os.getpid(),
261 cooldown_seconds=self.api.cooldown.remaining(),draining=self.drain or (self.state/'drain').exists()))
262 if ready and not self.notified:notify('READY=1');self.notified=True;self.event('service_ready',public=True,max_games=self.cap)
263 notify('WATCHDOG=1')
264 if (self.drain or (self.state/'drain').exists()) and not self.slots and not self.reservations:break
265 time.sleep(.01)
266 finally:
267 self.drain=True
268 for slot in self.slots.values():slot.stream.close()
269 for stream in self.retired:stream.close()
270 if self.account:self.account.close()
271 self.api.close();self.owner.close();self.event('service_stopped');self.lock.close()
272 atomic_json(self.state/'health.json',dict(updated=time.time(),ready=False,accepting=False,stopped=True))
273
274def main():
275 p=argparse.ArgumentParser();p.add_argument('--release',type=Path,required=True);p.add_argument('--state',type=Path,required=True)
276 p.add_argument('--token-file',type=Path,required=True);p.add_argument('--bot',default='LittlePly');Service(p.parse_args()).run()
277