Schedule consultation bridges on demand and preserve delivery evidence
This commit is contained in:
+91
-43
@@ -19,6 +19,28 @@ def busy(path):
|
||||
return bool(state.get('inflight') or state.get('pending_completion') or state.get('needs_reconciliation'))
|
||||
|
||||
|
||||
def reconcile_completed(path,client,session_id,thread_id):
|
||||
"""Clear only a proven terminal original execution, never submit another input."""
|
||||
state=json.loads(path.read_text(encoding='utf-8'))
|
||||
record=state.get('inflight') or {}
|
||||
proof=record.get('submission') or {}
|
||||
if not record.get('message_id') or not proof.get('input_sha256'):return False
|
||||
from urllib.parse import quote
|
||||
rows=client.call('/api/messages?session_id='+quote(session_id,safe=''))['messages']
|
||||
original=next((m for m in rows if m['id']==record['message_id']),None)
|
||||
if not original:return False
|
||||
reply=next((m for m in rows if m['id']==original.get('response_id')),None)
|
||||
remote=original.get('submission') or {}
|
||||
if (not reply or original.get('status') not in ('completed','failed')
|
||||
or remote.get('thread_id')!=thread_id
|
||||
or remote.get('input_sha256')!=proof['input_sha256']
|
||||
or (proof.get('turn_id') and remote.get('turn_id')!=proof['turn_id'])):return False
|
||||
state.setdefault('delivery_receipts',{})[record['message_id']]=remote
|
||||
for key in ('inflight','pending_completion','needs_reconciliation'):state.pop(key,None)
|
||||
atomic_json(path,state)
|
||||
return True
|
||||
|
||||
|
||||
def child_config(base,binding,root):
|
||||
did=binding['decision_id'];sid=binding['session_id'];thread=binding['thread_id']
|
||||
if any(not isinstance(v,str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}',v) for v in (did,sid)):
|
||||
@@ -54,20 +76,20 @@ class Fleet:
|
||||
|
||||
def stop(self,did,force=False):
|
||||
child=self.children[did]
|
||||
if not force and busy(Path(child['cfg']['state_file'])):return False
|
||||
for process in child['processes'].values():
|
||||
if process.poll() is None:process.terminate()
|
||||
for process in child['processes'].values():
|
||||
try:process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=10)
|
||||
# Single-cycle children exit naturally; never interrupt a native writer.
|
||||
if any(p.poll() is None for p in child['processes'].values()):return False
|
||||
for stream in child['logs'].values():stream.close()
|
||||
del self.children[did]
|
||||
return True
|
||||
|
||||
def tick(self,bindings):
|
||||
live={}
|
||||
waiting=0;problems=[]
|
||||
threads=set();sessions=set()
|
||||
now=time.time();problems=[];live={};threads=set();sessions=set()
|
||||
journal_path=self.root/'schedule.json'
|
||||
journal=json.loads(journal_path.read_text(encoding='utf-8')) if journal_path.exists() else {}
|
||||
notifications={}
|
||||
if self.cfg.get('notification_state_file'):
|
||||
notification_path=Path(self.cfg['notification_state_file'])
|
||||
if notification_path.exists():notifications=json.loads(notification_path.read_text(encoding='utf-8')).get('deliveries',{})
|
||||
for binding in bindings:
|
||||
did=binding['decision_id']
|
||||
if did in live or binding['thread_id'] in threads or binding['session_id'] in sessions:
|
||||
@@ -75,43 +97,66 @@ class Fleet:
|
||||
live[did]=binding;threads.add(binding['thread_id']);sessions.add(binding['session_id'])
|
||||
for did in list(self.children):
|
||||
binding=live.get(did)
|
||||
if not binding or binding.get('archive_status')=='archived':
|
||||
if not self.stop(did):problems.append('archive_requires_reconciliation:'+did)
|
||||
for did,binding in sorted(live.items()):
|
||||
child=self.children[did]
|
||||
mirror=child['processes'].get('mirror')
|
||||
if mirror and mirror.poll()==0:
|
||||
journal.setdefault(did,{}).setdefault('fact_synced',{}).update(child.get('terminal_facts',{}))
|
||||
if binding and child_config(self.cfg,binding,self.root)[0]!=self.children[did]['cfg']:
|
||||
problems.append('binding_changed:'+did);continue
|
||||
if not self.stop(did) and (not binding or binding.get('archive_status')=='archived'):
|
||||
problems.append('archive_requires_reconciliation:'+did)
|
||||
candidates=[]
|
||||
for did,binding in live.items():
|
||||
if binding.get('archive_status')=='archived':continue
|
||||
candidate,runtime=child_config(self.cfg,binding,self.root)
|
||||
child=self.children.get(did)
|
||||
if child and child['cfg']!=candidate:
|
||||
problems.append('binding_changed:'+did);continue
|
||||
if not child:
|
||||
if len(self.children)>=self.limit:waiting+=1;continue
|
||||
config_path=runtime/'bridge.json'
|
||||
if config_path.exists() and json.loads(config_path.read_text())!=candidate:
|
||||
problems.append('saved_binding_changed:'+did);continue
|
||||
atomic_json(config_path,candidate)
|
||||
child={'cfg':candidate,'runtime':runtime,'path':config_path,'processes':{},'logs':{},'retry_at':{},'started':{}}
|
||||
self.children[did]=child
|
||||
demand=binding.get('demand')
|
||||
state_path=Path(candidate['state_file'])
|
||||
for role in ('worker','mirror'):
|
||||
process=child['processes'].get(role)
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
health=json.loads((runtime/(role+'-health.json')).read_text())
|
||||
healthy=health.get('status')=='ok' and time.time()-health.get('updated',0)<60
|
||||
except (OSError,ValueError):healthy=False
|
||||
if not healthy and time.time()-child['started'].get(role,0)>60:
|
||||
problems.append('bridge_health_stale:'+did+':'+role)
|
||||
continue
|
||||
if role=='worker' and busy(state_path):
|
||||
problems.append('execution_requires_reconciliation:'+did);continue
|
||||
if time.monotonic()<child['retry_at'].get(role,0):continue
|
||||
if role not in child['logs']:child['logs'][role]=open(runtime/(role+'.log'),'a',encoding='utf-8')
|
||||
command=[sys.executable,'-X','utf8',str(Path(__file__).with_name(role+'.py')),'--config',str(child['path'])]
|
||||
child['processes'][role]=subprocess.Popen(command,stdout=child['logs'][role],stderr=child['logs'][role],
|
||||
local_busy=busy(state_path)
|
||||
worker=self.children.get(did,{}).get('processes',{}).get('worker')
|
||||
worker_alive=worker is not None and worker.poll() is None
|
||||
if local_busy and not worker_alive and demand is not None and not demand.get('uncertain',0) and not demand.get('inflight',0):
|
||||
try:
|
||||
if reconcile_completed(state_path,Client(self.cfg['control']),binding['session_id'],binding['thread_id']):local_busy=False
|
||||
except Exception:pass
|
||||
if demand is None:
|
||||
problems.append('queue_summary_unavailable:'+did);demand={}
|
||||
uncertain=bool(demand.get('uncertain',0) or local_busy)
|
||||
facts={key:r for key,r in notifications.items() if r.get('decision_id')==did and r.get('event_kind')=='fact_answered'}
|
||||
terminal_facts={key:str(r.get('completed_at') or r.get('turn_id') or 'done') for key,r in facts.items() if r.get('processing_done')}
|
||||
synced=journal.get(did,{}).get('fact_synced',{})
|
||||
fact_demand=any(not r.get('processing_done') for r in facts.values()) or any(synced.get(key)!=value for key,value in terminal_facts.items())
|
||||
needed=bool(demand.get('queued',0) or demand.get('inflight',0) or uncertain or fact_demand)
|
||||
if not needed:
|
||||
if did in journal:journal[did].pop('waiting_since',None)
|
||||
continue
|
||||
record=journal.setdefault(did,{})
|
||||
record.setdefault('waiting_since',min(now,demand.get('oldest_queued_at') or now))
|
||||
if did in self.children:
|
||||
if uncertain and not worker_alive:problems.append('execution_requires_reconciliation:'+did)
|
||||
continue
|
||||
# Finite human boost: old ordinary work eventually precedes new human work.
|
||||
score=now-record['waiting_since']+(60 if demand.get('human_queued',0) else 0)
|
||||
candidates.append((-score,record['waiting_since'],record.get('last_served',0),did,binding,candidate,runtime,uncertain,terminal_facts))
|
||||
candidates.sort(key=lambda row:row[:4]);waiting=0
|
||||
for _,_,_,did,binding,candidate,runtime,uncertain,terminal_facts in candidates:
|
||||
if len(self.children)>=self.limit:waiting+=1;continue
|
||||
config_path=runtime/'bridge.json'
|
||||
if config_path.exists() and json.loads(config_path.read_text(encoding='utf-8'))!=candidate:
|
||||
problems.append('saved_binding_changed:'+did);continue
|
||||
atomic_json(config_path,candidate)
|
||||
child={'cfg':candidate,'runtime':runtime,'path':config_path,'processes':{},'logs':{},'terminal_facts':terminal_facts}
|
||||
self.children[did]=child
|
||||
journal[did].update(waiting_since=now,last_served=now)
|
||||
atomic_json(journal_path,journal)
|
||||
roles=('mirror',) if uncertain or binding.get('demand',{}).get('inflight',0) or not binding.get('demand',{}).get('queued',0) else ('worker','mirror')
|
||||
if uncertain:problems.append('execution_requires_reconciliation:'+did)
|
||||
for role in roles:
|
||||
stream=open(runtime/(role+'.log'),'a',encoding='utf-8');child['logs'][role]=stream
|
||||
command=[sys.executable,'-X','utf8',str(Path(__file__).with_name(role+'.py')),'--config',str(config_path),'--once']
|
||||
child['processes'][role]=subprocess.Popen(command,stdout=stream,stderr=stream,
|
||||
**({'creationflags':subprocess.CREATE_NO_WINDOW} if os.name=='nt' else {}))
|
||||
child['started'][role]=time.time()
|
||||
child['retry_at'][role]=time.monotonic()+15
|
||||
snapshot={'status':'failed' if problems else 'ok','updated':time.time(),'active':len(self.children),
|
||||
atomic_json(journal_path,journal)
|
||||
snapshot={'status':'failed' if problems else 'ok','updated':now,'active':len(self.children),
|
||||
'waiting':waiting,'problems':problems,'bindings':{did:{'session_id':c['cfg']['session_id'],
|
||||
'thread_id':c['cfg']['thread_id'],'processes':{r:{'pid':p.pid,'running':p.poll() is None}
|
||||
for r,p in c['processes'].items()}} for did,c in self.children.items()}}
|
||||
@@ -119,7 +164,10 @@ class Fleet:
|
||||
return snapshot
|
||||
|
||||
def close(self):
|
||||
for did in list(self.children):self.stop(did,force=True)
|
||||
# Active cycles retain their durable state and host writer locks.
|
||||
for did in list(self.children):
|
||||
for stream in self.children[did]['logs'].values():stream.close()
|
||||
self.stop(did)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in New Issue
Block a user