Schedule consultation bridges on demand and preserve delivery evidence

This commit is contained in:
aiagent
2026-10-20 00:39:12 -07:00
parent 557f1ee03c
commit 2f633869fe
18 changed files with 483 additions and 56 deletions
+35 -3
View File
@@ -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)