fix: retain planning feedback and add acceptance probes

This commit is contained in:
2026-09-22 00:47:11 +08:00
parent f9d8feb6f0
commit 91bcd92d6b
12 changed files with 871 additions and 10 deletions
@@ -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')}
+5 -2
View File
@@ -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)
+19 -3
View File
@@ -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')