Reconcile proven completed inputs without replay and hide internal fact notices
This commit is contained in:
@@ -7,10 +7,28 @@ from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from worker import atomic_json,session_lock
|
||||
from worker import atomic_json,session_lock,reconcile_completed
|
||||
from control import Client
|
||||
from faults import report_process
|
||||
|
||||
|
||||
def recover_worker(cfg,process=None):
|
||||
"""Reconcile a stopped worker under its own lock; never execute its input."""
|
||||
if process is not None and process.poll() is None:return False
|
||||
identity=json.dumps({'control':cfg['control'],'session_id':cfg['session_id']},sort_keys=True)
|
||||
try:
|
||||
with session_lock(identity):
|
||||
path=Path(cfg['state_file']).expanduser()
|
||||
if not path.exists():return False
|
||||
state=json.loads(path.read_text(encoding='utf-8'))
|
||||
if not (state.get('inflight') and state.get('needs_reconciliation')):return False
|
||||
if not cfg.get('thread_id'):return False
|
||||
return reconcile_completed(path,Client(cfg['control']),cfg['session_id'],cfg['thread_id'])
|
||||
except Exception:
|
||||
# Offline, writer still locked, or evidence incomplete: preserve uncertainty.
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--config',required=True)
|
||||
@@ -36,6 +54,8 @@ def main():
|
||||
while True:
|
||||
state_path=Path(cfg['state_file']).expanduser()
|
||||
state=json.loads(state_path.read_text(encoding='utf-8')) if state_path.exists() else {}
|
||||
if recover_worker(cfg,processes.get('worker')):
|
||||
state=json.loads(state_path.read_text(encoding='utf-8'))
|
||||
for role in roles:
|
||||
process=processes.get(role)
|
||||
if process and cfg.get('notification_targets'):
|
||||
@@ -59,9 +79,10 @@ def main():
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for process in processes.values():
|
||||
if process.poll() is None:process.terminate()
|
||||
for process in processes.values():
|
||||
for role,process in processes.items():
|
||||
if role!='worker' and process.poll() is None:process.terminate()
|
||||
for role,process in processes.items():
|
||||
if role=='worker' and process.poll() is None:continue
|
||||
try:process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:process.kill()
|
||||
for stream in logs.values():stream.close()
|
||||
|
||||
@@ -10,7 +10,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from control import Client
|
||||
from worker import atomic_json,session_lock
|
||||
from worker import atomic_json,session_lock,reconcile_completed
|
||||
|
||||
|
||||
def busy(path):
|
||||
@@ -19,28 +19,6 @@ 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)):
|
||||
@@ -116,7 +94,9 @@ class Fleet:
|
||||
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
|
||||
identity=json.dumps({'control':self.cfg['control'],'session_id':binding['session_id']},sort_keys=True)
|
||||
with session_lock(identity):
|
||||
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={}
|
||||
|
||||
+4
-3
@@ -6,7 +6,10 @@ import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
from agent_control.notices import fact_answer_notice
|
||||
from appserver import AppServer
|
||||
from control import Client
|
||||
from worker import atomic_json,session_lock
|
||||
@@ -24,9 +27,7 @@ def notification_text(n):
|
||||
+'\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先读取本决策当前版本与历史;该答复是项目事实资料,不是人类批准。仅可解释或维护本决策,不执行项目方案、代码或系统操作。')
|
||||
return fact_answer_notice(n)
|
||||
action={
|
||||
'session_required':'仅当当前仍未绑定且属于真实待决策事项时,使用宿主受支持工具创建一个独立真实咨询会话,再注册role=decision并绑定。已有会话不得重复创建。不给咨询会话普通控制凭据,只提供本决策有限凭据;无真实事项不得创建验收占位会话。',
|
||||
'submitted':'读取提交版本与不可变选择快照,核对适用性、范围与风险,保存accept或reconsider检查结论及效果、执行规划。不要把提交直接当接受、执行或新增授权。',
|
||||
|
||||
@@ -28,6 +28,31 @@ def atomic_json(path, data):
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
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') or not proof.get('turn_id')
|
||||
or proof.get('thread_id')!=thread_id):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 reply.get('source')!='agent' or reply.get('reply_to')!=original['id']
|
||||
or original.get('session_id')!=session_id
|
||||
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 redact(text):
|
||||
text = re.sub(r'(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)\S+', r'\1[REDACTED]', text)
|
||||
text = re.sub(r'\bsk-[A-Za-z0-9_-]{16,}', '[REDACTED]', text)
|
||||
|
||||
Reference in New Issue
Block a user