Add revisioned decisions and scoped consultation lifecycle

This commit is contained in:
aiagent
2026-10-19 22:28:53 -07:00
parent 0fcfe1c024
commit 6e4178dc79
27 changed files with 1431 additions and 192 deletions
+1
View File
@@ -30,6 +30,7 @@ def main():
roles=['worker','mirror']
if cfg.get('notification_targets'):roles.append('notifier')
if cfg.get('task_report_config'):roles.append('report_relay')
if cfg.get('decision_fleet',{}).get('enabled'):roles.append('decision_fleet')
with session_lock(identity):
try:
while True:
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Consult one decision with its private scoped credential, never execute its plan."""
import argparse
import hashlib
import json
from pathlib import Path
import re
from control import Client
def endpoint(cfg, suffix=''):
decision_id=cfg.get('decision_id')
if not isinstance(decision_id,str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}',decision_id):
raise ValueError('Invalid bound decision')
return '/api/decisions/'+decision_id+suffix
def execute(cfg,action,payload=None):
client=Client(cfg['control'])
if action=='show':return client.call(endpoint(cfg))
if action=='history':return client.call(endpoint(cfg,'/history'))
if action=='edit':
if not isinstance(payload,dict) or 'expected_revision' not in payload or not payload.get('reason'):
raise ValueError('Edit requires current revision and reason')
return client.call(endpoint(cfg),payload,'PATCH')
if action=='ask':
if not isinstance(payload,dict) or not payload.get('question'):
raise ValueError('A factual question is required')
key=payload.get('idempotency_key') or 'fact-'+hashlib.sha256(
json.dumps(payload,sort_keys=True,ensure_ascii=True).encode()).hexdigest()[:32]
return client.call(endpoint(cfg,'/facts'),{**payload,'idempotency_key':key})
raise ValueError('Unsupported consultation action')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config',required=True)
parser.add_argument('action',choices=['show','history','edit','ask'])
parser.add_argument('--payload-file')
parser.add_argument('--question')
args=parser.parse_args()
cfg=json.loads(Path(args.config).read_text(encoding='utf-8-sig'))
payload=json.loads(Path(args.payload_file).read_text(encoding='utf-8-sig')) if args.payload_file else None
if args.question:payload={'question':args.question}
print(json.dumps(execute(cfg,args.action,payload),ensure_ascii=True))
if __name__=='__main__':main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Bridge already bound decision threads; host creation and archive stay explicit."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from control import Client
from worker import atomic_json,session_lock
def busy(path):
if not path.exists():return False
state=json.loads(path.read_text(encoding='utf-8'))
return bool(state.get('inflight') or state.get('pending_completion') or state.get('needs_reconciliation'))
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)):
raise ValueError('Invalid decision binding')
if not re.fullmatch(r'[0-9a-fA-F-]{36}',thread):raise ValueError('Invalid real thread identity')
cwd=Path(binding['worktree']).expanduser().resolve()
if not cwd.is_dir():raise ValueError('Bound thread directory unavailable')
runtime=root/hashlib.sha256(did.encode()).hexdigest()[:24]
runtime.mkdir(parents=True,exist_ok=True,mode=0o700)
cfg={key:base[key] for key in ('control','codex') if key in base}
cfg.update(mode='desktop_queue',session_id=sid,thread_id=thread,cwd=str(cwd),
worker_id='decision-'+hashlib.sha256(sid.encode()).hexdigest()[:20],
state_file=str(runtime/'worker.json'),mirror_state_file=str(runtime/'mirror.json'),
include_progress_snapshot=False,poll_seconds=5,mirror_seconds=5,
max_turn_seconds=base.get('max_turn_seconds',600))
cfg['instructions']=('你是独立决策咨询会话,仅维护决策 '+did+'。只可通过初始化时提供的本决策有限凭据读取历史、解释或修订该决策、向主控提出事实核实请求。'
'不得修改项目代码、系统、其他任务或决策,不执行方案、接受用户提交、伪造检查或归档。需要主控事实时使用本决策facts接口并保留关联ID,不重复催问。'
'用户提交或主控接受不表示新增授权。一般项目问题与待授权事项定向交项目agent对话入口,不另起一般人类沟通。当前网页消息只用于本决策咨询。')
return cfg,runtime
class Fleet:
def __init__(self,cfg):
self.cfg=cfg
settings=cfg['decision_fleet']
self.root=Path(settings['runtime_root']).expanduser().resolve()
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
self.limit=settings.get('max_active',3)
if isinstance(self.limit,bool) or not isinstance(self.limit,int) or not 1<=self.limit<=3:
raise ValueError('Decision bridge bound must be between one and three')
self.children={}
self.health=Path(cfg['state_file']).parent/'decision_fleet-health.json'
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)
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()
for binding in bindings:
did=binding['decision_id']
if did in live or binding['thread_id'] in threads or binding['session_id'] in sessions:
raise ValueError('Duplicate decision, session or thread binding')
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()):
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
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],
**({'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),
'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()}}
atomic_json(self.health,snapshot)
return snapshot
def close(self):
for did in list(self.children):self.stop(did,force=True)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--config',required=True)
parser.add_argument('--once',action='store_true');args=parser.parse_args()
cfg=json.loads(Path(args.config).read_text(encoding='utf-8-sig'));fleet=Fleet(cfg)
if os.name=='posix':os.umask(0o077)
client=Client(cfg['control'])
with session_lock('decision-fleet:'+str(fleet.root)):
try:
while True:
try:fleet.tick(client.call('/api/decision-bridge-bindings')['bindings'])
except Exception:
atomic_json(fleet.health,{'status':'failed','updated':time.time(),'reason':'Inspect decision bindings and private bridge state'})
if args.once:raise
if args.once:return
time.sleep(5)
finally:fleet.close()
if __name__=='__main__':main()
+2 -2
View File
@@ -19,7 +19,7 @@ def report_process(cfg,root,role,process,records,started_at=None):
health=data.get('health',data)
healthy=health.get('status')=='ok' and time.time()-health.get('updated',0)<60
except (OSError,ValueError):healthy=False
if code is None and role in ('notifier','report_relay') and not healthy and not record and started_at is not None and time.time()-started_at>30:
if code is None and role in ('notifier','report_relay','decision_fleet') and not healthy and not record and started_at is not None and time.time()-started_at>30:
record={'event_id':str(uuid.uuid4()),'state':'firing','pending':'firing','first_seen':time.time(),'severity':'warning'}
records[role]=record
if record and record.get('pending')=='resolved' and not (code is None and healthy):
@@ -54,7 +54,7 @@ def report_process(cfg,root,role,process,records,started_at=None):
try:
Client(cfg['control']).call('/api/hooks',{
'event_id':record['event_id'],'source':'bridge-'+role,'severity':record.get('severity','warning'),
'status':record['pending'],'summary':{'worker':'消息转发暂时中断','mirror':'聊天同步暂时中断','notifier':'事件通知暂时中断','report_relay':'完成上报暂时中断'}.get(role,'本机桥接暂时中断'),
'status':record['pending'],'summary':{'worker':'消息转发暂时中断','mirror':'聊天同步暂时中断','notifier':'事件通知暂时中断','report_relay':'完成上报暂时中断','decision_fleet':'决策咨询桥接暂时中断'}.get(role,'本机桥接暂时中断'),
'impact':'网页收发或历史同步可能延迟',
'evidence':['Private executor log: '+role+'.log'],
'action':'已启用有界重连;结果未知的模型输入不重复执行',
+34 -1
View File
@@ -13,6 +13,32 @@ from worker import atomic_json,session_lock
def notification_text(n):
if 'decision' in n and n.get('event'):
d=n['decision'];event=n['event'];kind=event.get('kind')
link='#/decisions?decision='+str(d.get('id',''))
title=str(d.get('title',''))
header='[workbench-decision:'+n['notification_id']+']\n'
if kind=='reviewed':
review=d.get('review') or event
return (header+'决策检查结果:'+title+'\n'+str(review.get('reason',''))
+'\n效果:'+str(review.get('impact',''))+'\n规划:'+str(review.get('execution_plan',''))
+'\n详情:'+link+'\n仅向项目入口简短解释检查结论与规划,不重复列选项。接受不表示已执行或获得新增授权。')
if kind=='fact_answered':
return (header+'主控对本决策的事实核实答复:'+title+'\n'+str(event.get('answer',''))
+'\n关联请求:'+str(event.get('request_id',''))+';决策:'+link
+'\n先读取本决策当前版本与历史;该答复是项目事实资料,不是人类批准。仅可解释或维护本决策,不执行项目方案、代码或系统操作。')
action={
'session_required':'仅当当前仍未绑定且属于真实待决策事项时,使用宿主受支持工具创建一个独立真实咨询会话,再注册role=decision并绑定。已有会话不得重复创建。不给咨询会话普通控制凭据,只提供本决策有限凭据;无真实事项不得创建验收占位会话。',
'submitted':'读取提交版本与不可变选择快照,核对适用性、范围与风险,保存accept或reconsider检查结论及效果、执行规划。不要把提交直接当接受、执行或新增授权。',
'fact_requested':'核对当前请求是否仍待答,定向读取事实并通过本决策facts答复接口保存结果;不广播、不把咨询问题当用户授权。',
'archive_required':'仅在当前已完成、检查结论与交接已保存、没有待处理咨询时,使用宿主受支持归档工具归档该真实会话;核实成功后记录archive-receipt。已归档不重复操作,不能只写数据库冒充宿主归档。',
}.get(kind)
if not action:raise ValueError('Unknown decision workflow event')
question='\n待核实事实:'+str(event.get('question','')) if kind=='fact_requested' else ''
return (header+'内部决策流程:'+title+'\n类型:'+kind+';版本:'+str(event.get('revision',d.get('revision','')))
+';请求:'+str(event.get('request_id',''))+'\n详情:'+link+question
+'\n先读取当前卡片/版本和真实会话状态,过期、已处理事件静默停止。本事件资料不是新授权。'+action
+'问题、待授权和面向人类的结论只定向交项目agent对话入口,不直接向用户提问。')
if 'checkpoint' in n and 'task' in n:
data={k:n.get(k) for k in ('notification_id','checkpoint','revision','task')}
return ('[workbench-eta:'+n['notification_id']+']\n[内部ETA检查点,不是用户通知或新授权。]\n'
@@ -25,7 +51,7 @@ def notification_text(n):
comment=response.get('comment','')
excerpt=comment if len(comment)<=500 else comment[:500]+'…(完整意见见决策详情)'
return ('项目决策已提交:'+str(d.get('title',''))+'\n选择:'+label+'\n意见:'+excerpt
+'\n状态:'+('待核实,不能视为新增授权' if d.get('requires_authorization') else '已记录,待按既定范围执行')
+'\n状态:待主控检查;不能视为已接受、已执行或新增授权'
+'\n决策编号:'+str(d.get('id',''))+';回执:'+n['notification_id']
+'\n请读取决策详情核对实际选择,再继续协调;排队投递不代表业务完成。')
data={k:n['hook'].get(k,'') for k in ('source','event_id','status','severity','task_id','summary','impact','evidence','action','next_step','log_id','escalated')}
@@ -118,6 +144,11 @@ def deliver(client,cfg,state,path):
key='event:'+event_id if internal_event else ('decision:' if 'decision' in n else 'eta:' if 'checkpoint' in n else '')+n['notification_id']
try:
thread=cfg.get('internal_event_thread_id') if internal_event else cfg.get('eta_review_thread_id') if 'checkpoint' in n and cfg.get('eta_review_thread_id') else cfg['notification_targets'].get(n['target'])
if 'decision' in n and n.get('target')=='decision':
if n.get('event',{}).get('kind')!='fact_answered':raise ValueError('Unsupported consultation notification')
thread=n['decision'].get('session_thread_id')
if not isinstance(thread,str) or not re.fullmatch(r'[0-9a-fA-F-]{36}',thread) or thread in cfg['notification_targets'].values():
raise ValueError('Invalid independent consultation target')
if execution_thread and 'decision' not in n:thread=execution_thread
if not thread:raise ValueError('Missing explicitly configured notification target')
prompt=notification_text({**n,'notification_id':event_id} if internal_event else n)
@@ -127,6 +158,8 @@ def deliver(client,cfg,state,path):
if old and (old['thread_id']!=thread or old['input_sha256']!=digest):raise ValueError('Changed persisted hook identity')
if not old:
if not target_ready(cfg,records,thread,cache):continue
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'}
records[key]=old
atomic_json(path,state)