Add revisioned decisions and scoped consultation lifecycle
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user