fix: retain planning feedback and add acceptance probes
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""Content identities with explicit scope; source files do not attest remote binaries."""
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from .plan import canonical
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical(value).encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def missing(reason):
|
||||
return {'status': 'missing', 'reason': reason}
|
||||
|
||||
|
||||
def file_identity(path, root):
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return missing('source file unavailable')
|
||||
return {'status': 'captured', 'scope': 'source_only',
|
||||
'path': path.relative_to(root).as_posix(),
|
||||
'sha256': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def capture_sources(root=None):
|
||||
"""Capture once at coordinator startup, never assert this is a binary build ID."""
|
||||
root = Path(root) if root is not None else Path(__file__).resolve().parents[2]
|
||||
code = missing('source checkout or git unavailable')
|
||||
try:
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', '-C', str(root), *args],
|
||||
stderr=subprocess.DEVNULL, timeout=3).decode().strip()
|
||||
# Installed packages inside an unrelated git checkout must not inherit its identity.
|
||||
if (root / '.git').exists():
|
||||
commit = git('rev-parse', 'HEAD')
|
||||
dirty = bool(git('status', '--porcelain', '--untracked-files=normal'))
|
||||
code = {'status': 'captured', 'scope': 'checkout_at_coordinator_start',
|
||||
'commit': commit, 'dirty': dirty,
|
||||
'binary_build_identity': missing('build attestation unavailable')}
|
||||
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||
pass
|
||||
definitions = sorted((root / 'ros2/bt_skill_interfaces').glob('*/*'))
|
||||
files = [file_identity(p, root) for p in definitions if p.suffix in ('.msg', '.srv', '.action')]
|
||||
idl = {'status': 'captured', 'scope': 'source_only', 'files': files,
|
||||
'sha256': digest(files)} if files else missing('IDL source unavailable')
|
||||
code_files = []
|
||||
for folder in ('coordinator', 'core', 'robobrain', 'ros2'):
|
||||
for path in sorted((root / folder).rglob('*')):
|
||||
if path.suffix in ('.py', '.cpp', '.hpp', '.h', '.cmake') or path.name == 'CMakeLists.txt':
|
||||
code_files.append(file_identity(path, root))
|
||||
content = ({'status': 'captured', 'scope': 'source_only_at_coordinator_start',
|
||||
'sha256': digest(code_files), 'files': code_files}
|
||||
if code_files else missing('code source unavailable'))
|
||||
return {'code': code, 'code_content': content, 'xml': file_identity(root / 'ros2/bt_executor/trees/fixed_workflow.xml', root),
|
||||
'idl': idl, 'planner_source': file_identity(root / 'robobrain/robot_robobrain/service.py', root)}
|
||||
|
||||
|
||||
def execution_versions(sources, task, backend_config):
|
||||
# Hash configuration only: credentials and operator identities must not be
|
||||
# copied into task events. Remote service configuration is explicitly unknown.
|
||||
config = {'context': task['context'], 'backend': backend_config}
|
||||
return {'provenance_schema': 1, 'sources': sources,
|
||||
'runtime_config': {'status': 'captured', 'scope': 'coordinator_dispatch', 'sha256': digest(config)},
|
||||
'approved_plan': {'status': 'captured', 'sha256': digest(task['execution_plan'])},
|
||||
'instruction': {'status': 'captured', 'sha256': digest(task['request']['instruction'])},
|
||||
'planning_record_ref': task.get('planning_record_ref'),
|
||||
'instruction_template': missing('remote planner template not attested; inspect linked planning record if available'),
|
||||
'deployed_executor': missing('loaded XML, generated IDL, binary and executor parameters are not attested by current transport')}
|
||||
@@ -3,6 +3,7 @@
|
||||
No ROS import or execution transport exists in this module. Sensor/model inference
|
||||
is not rerun, and this result must never be used as live robot state.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
@@ -11,7 +12,7 @@ from .plan import validate_plan
|
||||
from .plan_v2 import instances
|
||||
|
||||
def replay_events(events):
|
||||
status='RECEIVED';version=0;cursor=0;delivered={};stages=[];plans=[];errors=[];progress=[];reconciliations=[]
|
||||
status='RECEIVED';version=0;cursor=0;delivered={};stages=[];plans=[];errors=[];progress=[];reconciliations=[];planning_progress=[]
|
||||
for e in events:
|
||||
if type(e['event_id']) is not int or e['event_id']<=cursor:raise ValueError('non-monotonic event cursor')
|
||||
cursor=e['event_id']
|
||||
@@ -20,6 +21,8 @@ def replay_events(events):
|
||||
version=e['status_version'];status=e['status']
|
||||
if e.get('error_code'):errors.append(e['error_code'])
|
||||
if e['kind']=='progress':progress.append({k:e[k] for k in ('event_id','run_id','stage','sequence','detail') if k in e})
|
||||
elif e['kind']=='planning_progress':
|
||||
planning_progress.append(deepcopy(e['payload']))
|
||||
elif e['kind']=='plan_approved':
|
||||
plans.append({'run_id':e['run_id'],'plan':validate_plan(e['plan'])})
|
||||
elif e['kind']=='delivery_committed':
|
||||
@@ -31,7 +34,7 @@ def replay_events(events):
|
||||
elif e['kind']=='reconciliation':
|
||||
reconciliations.append({k:e[k] for k in ('event_id','run_id','resolution','evidence_ref','credited_items') if k in e})
|
||||
if status=='SUCCEEDED' and (not plans or set(delivered)!=set(range(len(instances(plans[-1]['plan']))))):raise ValueError('success without committed delivery')
|
||||
return {'mode':'offline_audit_replay','status':status,'status_version':version,'last_event_id':cursor,'completed_quantity':len(delivered),'plans':plans,'results':stages,'progress':progress,'reconciliations':reconciliations,'errors':errors,'connects_to_robot':False}
|
||||
return {'mode':'offline_audit_replay','status':status,'status_version':version,'last_event_id':cursor,'completed_quantity':len(delivered),'plans':plans,'results':stages,'progress':progress,'planning_progress':planning_progress,'reconciliations':reconciliations,'errors':errors,'connects_to_robot':False}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser();p.add_argument('--db',required=True);p.add_argument('--task-id',required=True);args=p.parse_args()
|
||||
|
||||
@@ -135,9 +135,37 @@ class RosBackend:
|
||||
constraints['clarification_confirmed'] = True
|
||||
goal.constraints_json = canonical(constraints)
|
||||
self._duration(goal.timeout, self.config.get('planning_timeout', 25))
|
||||
future = self._planner.send_goal_async(goal)
|
||||
future = self._planner.send_goal_async(
|
||||
goal, feedback_callback=lambda feedback: self._plan_feedback(key, feedback))
|
||||
future.add_done_callback(lambda completed: self._plan_accepted(key, completed))
|
||||
|
||||
def _plan_feedback(self, key, wrapped):
|
||||
"""Retain actual Planner fields; feedback never decides task completion."""
|
||||
with self._lock:
|
||||
rec = self._planning.get(key)
|
||||
if rec is None or rec['done']:
|
||||
return
|
||||
try:
|
||||
msg = wrapped.feedback
|
||||
sequence, phase = msg.sequence, msg.phase
|
||||
sec, nanosec = msg.stamp.sec, msg.stamp.nanosec
|
||||
if (type(sequence) is not int or not 0 < sequence < 2**32 or
|
||||
sequence <= rec.get('feedback_sequence', 0) or
|
||||
type(phase) is not int or not 0 <= phase <= 255 or
|
||||
type(sec) is not int or not -(2**31) <= sec < 2**31 or
|
||||
type(nanosec) is not int or not 0 <= nanosec < 1_000_000_000 or
|
||||
not isinstance(msg.message, str)):
|
||||
return
|
||||
event = dict(type='planning_progress', task_id=rec['task_id'],
|
||||
task_revision=rec['task_revision'],
|
||||
planning_generation=rec['planning_generation'],
|
||||
stamp=dict(sec=sec, nanosec=nanosec), sequence=sequence,
|
||||
phase=phase, message=msg.message)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return
|
||||
rec['feedback_sequence'] = sequence
|
||||
self._emit(event)
|
||||
|
||||
def _plan_accepted(self, key, future):
|
||||
with self._lock:
|
||||
rec = self._planning.get(key)
|
||||
|
||||
@@ -10,6 +10,7 @@ from .plan import canonical, text, validate_known, validate_plan
|
||||
from .store import Store
|
||||
from .plan_v2 import instances, item_plan
|
||||
from .intent import conflicts_with_instruction
|
||||
from .provenance import capture_sources, execution_versions
|
||||
|
||||
TERMINAL = {'SUCCEEDED','FAILED','CANCELED','EXPIRED'}
|
||||
EXECUTION = {'READY','EXECUTING','PAUSING','CANCELING','INTERVENTION_REQUIRED'}
|
||||
@@ -30,6 +31,7 @@ class Coordinator:
|
||||
self.robots=set(robots);self.site=site if site is not None else demo_site()
|
||||
self.clock=clock;self.queue_timeout=queue_timeout;self.closed=False
|
||||
self.steady=steady;self.planning_timeout=planning_timeout;self._planning_started={}
|
||||
self._source_provenance=capture_sources()
|
||||
with self.lock,self.store.db:
|
||||
for t in self.store.active():
|
||||
if t['status'] in EXECUTION or (t['status']=='PAUSED' and t.get('motion_dispatched')):
|
||||
@@ -221,7 +223,17 @@ class Coordinator:
|
||||
self._set(t,'QUEUED' if t['planning_attempts']<2 else 'FAILED',error)
|
||||
def _event(self,t,e):
|
||||
typ=e.get('type')
|
||||
if typ=='plan':
|
||||
if typ=='planning_progress':
|
||||
if t['status']!='PLANNING' or e.get('task_revision')!=t['task_revision'] or e.get('planning_generation')!=t['planning_generation']:return
|
||||
seq=e.get('sequence');phase=e.get('phase');stamp=e.get('stamp')
|
||||
if type(seq) is not int or not 0<seq<2**32 or type(phase) is not int or not 0<=phase<256 or not isinstance(e.get('message'),str):return
|
||||
if not isinstance(stamp,dict) or type(stamp.get('sec')) is not int or not -(2**31)<=stamp['sec']<2**31 or type(stamp.get('nanosec')) is not int or not 0<=stamp['nanosec']<10**9:return
|
||||
previous=t.get('planning_feedback_sequence',0) if t.get('planning_feedback_generation')==t['planning_generation'] else 0
|
||||
if seq<=previous:return
|
||||
t['planning_feedback_generation']=t['planning_generation'];t['planning_feedback_sequence']=seq
|
||||
self.store.event(t,'planning_progress',{'payload':e},self.clock())
|
||||
self.store.put(t)
|
||||
elif typ=='plan':
|
||||
self.store.event(t,'planning_result',{'payload':e},self.clock())
|
||||
if t['status']!='PLANNING' or e.get('task_revision')!=t['task_revision'] or e.get('planning_generation')!=t['planning_generation']:return
|
||||
if self.steady()-self._planning_started.get(t['task_id'],self.steady())>=self.planning_timeout:
|
||||
@@ -252,8 +264,9 @@ class Coordinator:
|
||||
except (ApiError,ValueError,KeyError,TypeError) as ex:
|
||||
self._planning_failure(t,getattr(ex,'code','INVALID_PLAN'));return
|
||||
t['plan']=p;t['plan_version']=p['plan_version'];t['active_item_index']=0;t['requested_quantity']=len(instances(p))
|
||||
t['planning_record_ref']=e.get('planning_record_ref')
|
||||
t['run_id']=str(uuid.uuid4())
|
||||
self.store.event(t,'plan_approved',{'plan':p,'task_revision':t['task_revision'],'planning_generation':t['planning_generation'],'run_id':t['run_id']},self.clock())
|
||||
self.store.event(t,'plan_approved',{'plan':p,'task_revision':t['task_revision'],'planning_generation':t['planning_generation'],'run_id':t['run_id'],'planning_record_ref':t['planning_record_ref']},self.clock())
|
||||
self._dispatch_item(t,initial=True)
|
||||
elif typ in {'execution_result','progress','cancel_ack','advisory'}:
|
||||
if e.get('run_id')!=t['run_id'] or t['status'] in TERMINAL or not t['motion_dispatched']:return
|
||||
@@ -325,7 +338,10 @@ class Coordinator:
|
||||
t['context']={**self.site,'robot_id':t['robot_id'],'target_id':slots['target_name'],'source_shelf':source['shelf_id'],'destination_id':dest['region_ref'],'observe_location':source['observe_location'],'destination_location':dest['location'],'task_revision':t['task_revision']}
|
||||
if t['plan']['schema_version']==2:t['context'].update(item_index=index,route=t['plan']['route'])
|
||||
self._set(t,'READY');t['motion_dispatched']=True;t['stop_confirmed']=False;self._set(t,'EXECUTING')
|
||||
self.store.event(t,'execution_dispatched',{'run_id':t['run_id'],'item_index':index,'context':t['context'],'versions':{'coordinator':'1.2.0','schema':t['plan']['schema_version'],'tree':'1.2.0'}},self.clock())
|
||||
versions=execution_versions(self._source_provenance,t,{
|
||||
'adapter':getattr(self.backend,'config',{}),
|
||||
'coordinator':{'queue_timeout':self.queue_timeout,'planning_timeout':self.planning_timeout,'robots':sorted(self.robots)}})
|
||||
self.store.event(t,'execution_dispatched',{'run_id':t['run_id'],'item_index':index,'context':t['context'],'versions':versions},self.clock())
|
||||
self.store.db.commit()
|
||||
try:self.backend.start_execution(t)
|
||||
except Exception:self._set(t,'INTERVENTION_REQUIRED','DISPATCH_ACCEPTANCE_UNKNOWN')
|
||||
|
||||
Reference in New Issue
Block a user