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():
|
||||
|
||||
+35
-3
@@ -59,8 +59,26 @@ def notification_text(n):
|
||||
+json.dumps(data,ensure_ascii=False)+'\n请核实事件与影响,按既定权限处理。面向用户仅汇报持续重大影响、需行动或待决策;短暂自动恢复只进日志,不向人类入口转发。若有log_id,给一句摘要并链接日志页#/logs?incident=<log_id>,不复制原载荷;重复状态不打扰。消息已投递不代表故障已修复。')
|
||||
|
||||
|
||||
def target_ready(cfg,records,thread,cache):
|
||||
if not cfg.get('enforce_backpressure'):
|
||||
def schedule_notifications(notifications,state,now):
|
||||
"""Prefer decisions while aging waiting work into FIFO; persist across restarts.
|
||||
|
||||
This changes dispatch order only, never authorization, receipt or event identity.
|
||||
A busy/uncertain target remains blocked regardless of waiting time.
|
||||
"""
|
||||
waiting=state.setdefault('notification_waiting',{})
|
||||
def identity(n):
|
||||
return ('decision:' if 'decision' in n else 'eta:' if 'checkpoint' in n else 'hook:')+n['notification_id']
|
||||
for n in notifications:
|
||||
waiting.setdefault(identity(n),now)
|
||||
def rank(n):
|
||||
first=waiting[identity(n)]
|
||||
priority=0 if 'decision' in n else 1 if n.get('hook',{}).get('severity')=='critical' else 2 if 'hook' in n else 3
|
||||
return (0 if now-first>=120 else priority+1,first,identity(n))
|
||||
return sorted(notifications,key=rank)
|
||||
|
||||
|
||||
def target_ready(cfg,records,thread,cache,force_reconcile=False):
|
||||
if not cfg.get('enforce_backpressure') and not force_reconcile:
|
||||
return True
|
||||
limit=cfg.get('max_inflight_per_target',1)
|
||||
if not isinstance(limit,int) or isinstance(limit,bool) or not 1<=limit<=3:
|
||||
@@ -96,6 +114,15 @@ def deliver(client,cfg,state,path):
|
||||
records=state.setdefault('deliveries',{})
|
||||
errors=[]
|
||||
cache={}
|
||||
# Fact answers enter the native consultation thread directly. Reconcile their
|
||||
# completion even with an empty outbox, so a demand-driven mirror can retire.
|
||||
for thread in {r['thread_id'] for r in records.values()
|
||||
if r.get('event_kind')=='fact_answered' and not r.get('processing_done')}:
|
||||
try:
|
||||
target_ready(cfg,records,thread,cache,force_reconcile=True)
|
||||
except Exception:
|
||||
errors.append('fact_answer_reconciliation_unavailable')
|
||||
atomic_json(path,state)
|
||||
if cfg.get('coalesce_stale_hooks'):
|
||||
# Upgrade old accepted-but-unacked receipts before pruning pending data.
|
||||
# Unknown submissions are protected, never silently cancelled or replayed.
|
||||
@@ -128,7 +155,9 @@ def deliver(client,cfg,state,path):
|
||||
notifications += batch['notifications']
|
||||
queue_counts[endpoint]=batch.get('pending_count',len(batch['notifications']))
|
||||
except Exception:errors.append('outbox_unavailable')
|
||||
notifications.sort(key=lambda n:0 if 'decision' in n else 1 if n.get('hook',{}).get('severity')=='critical' else 2 if 'hook' in n else 3)
|
||||
notifications=schedule_notifications(notifications,state,time.time())
|
||||
# Waiting age must survive offline delivery/ACK failures, not just successful sends.
|
||||
atomic_json(path,state)
|
||||
task_owners={}
|
||||
if cfg.get('execution_notification_targets'):
|
||||
snapshot=client.call('/api/state')
|
||||
@@ -161,6 +190,8 @@ def deliver(client,cfg,state,path):
|
||||
if 'decision' in n:
|
||||
client.call('/api/decision-notifications/submitting',{'notification_id':n['notification_id']})
|
||||
old={'kind':'hook' if 'hook' in n else 'decision' if 'decision' in n else 'eta','notification_ids':([item['notification_id'] for item in notifications if item.get('hook')==n.get('hook')] if internal_event else [n['notification_id']]),'created_at':time.time(),'thread_id':thread,'input_sha256':digest,'state':'submitting'}
|
||||
if 'decision' in n:
|
||||
old.update(decision_id=n['decision']['id'],event_kind=n.get('event',{}).get('kind'))
|
||||
records[key]=old
|
||||
atomic_json(path,state)
|
||||
if 'hook' in n and cfg.get('coalesce_stale_hooks'):
|
||||
@@ -177,6 +208,7 @@ def deliver(client,cfg,state,path):
|
||||
# Crash after queue acceptance: reconcile exact actual input, never requeue.
|
||||
with AppServer(cfg['codex']) as reader:
|
||||
meta,turns=reader.history(thread)
|
||||
if meta.get('id')!=thread:raise ValueError('Unexpected reconciliation thread identity')
|
||||
observed=next((t['id'] for t in turns if any(
|
||||
i.get('type')=='userMessage' and '\n'.join(p.get('text','') for p in i.get('content',[]) if p.get('type')=='text')==prompt
|
||||
for i in t.get('items',[]))),None)
|
||||
|
||||
Reference in New Issue
Block a user