fix: harden task recovery and DR contract handling
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(ROOT/'coordinator'), str(ROOT/'robobrain'), str(ROOT/'ros2/robobrain_services')]
|
||||
from robot_robobrain.backends import FixtureBackend
|
||||
from robot_robobrain.service import BrainService
|
||||
from robot_robobrain.observations import Observation
|
||||
from robot_bt_coordinator.plan_v2 import make_plan
|
||||
|
||||
class ConsistencyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.site = json.loads((ROOT/'config/sim_site_object_table.json').read_text(encoding='utf-8'))
|
||||
self.slots = {'items': [{'target_name':'water', 'quantity':2, 'source_location':'shelf_A'}, {'target_name':'doll', 'quantity':1, 'source_location':'shelf_A'}], 'destination':'tote_A'}
|
||||
self.instruction = '把货架A的两瓶水和一个玩偶放到周转箱A'
|
||||
|
||||
def run_plan(self, slots, revision=1, confirmed=False, instruction=None):
|
||||
text = instruction or self.instruction
|
||||
service = BrainService(FixtureBackend(json.dumps(make_plan(text, slots, 'OBJECT_TABLE'))), self.tmp.name)
|
||||
return service.plan(dict(task_id='t', task_revision=revision, planning_generation=1,
|
||||
instruction=text, known_info=slots, context=self.site,
|
||||
constraints={'route':'OBJECT_TABLE', 'clarification_confirmed':confirmed}, timeout=1))
|
||||
|
||||
def test_initial_explicit_quantity_order_item_destination_conflicts_fail(self):
|
||||
variants=[]
|
||||
p=copy.deepcopy(self.slots);p['items'][0]['quantity']=1;variants.append(p)
|
||||
p=copy.deepcopy(self.slots);p['items'].reverse();variants.append(p)
|
||||
p=copy.deepcopy(self.slots);p['items'][0]['target_name']='doll';variants.append(p)
|
||||
p=copy.deepcopy(self.slots);p['destination']='tote_B';variants.append(p)
|
||||
for slots in variants:
|
||||
with self.subTest(slots=slots):
|
||||
result=self.run_plan(slots)
|
||||
self.assertEqual(result['status'], 'FAILED')
|
||||
self.assertEqual(result['error_code'], 'SEMANTIC_MISMATCH')
|
||||
self.assertTrue(Path(result['record_ref']).is_file())
|
||||
|
||||
def test_revision_alone_and_initial_confirmation_marker_do_not_override(self):
|
||||
slots=copy.deepcopy(self.slots);slots['items'][0]['quantity']=1
|
||||
for revision,confirmed in [(2,False),(1,True),(2,'true')]:
|
||||
with self.subTest(revision=revision,confirmed=confirmed):
|
||||
self.assertEqual(self.run_plan(slots,revision,confirmed)['status'],'FAILED')
|
||||
|
||||
def test_confirmed_clarification_can_override_original(self):
|
||||
slots=copy.deepcopy(self.slots);slots['items'][0]['quantity']=1
|
||||
self.assertEqual(self.run_plan(slots,2,True)['status'],'PLAN_READY')
|
||||
|
||||
def test_matching_and_unrecognized_language_are_not_false_proofs(self):
|
||||
self.assertEqual(self.run_plan(self.slots)['status'],'PLAN_READY')
|
||||
self.assertEqual(self.run_plan(self.slots,instruction='carry out the supplied item list')['status'],'PLAN_READY')
|
||||
|
||||
def test_shelf_non_success_retains_observation_identity(self):
|
||||
path=Path(self.tmp.name)/'image.png';path.write_bytes(b'fixture')
|
||||
obs=Observation('observation-7',100,'camera',str(path),'observe_A',1,'shelf_A')
|
||||
goal=dict(task_id='t',subtask_id='s',target_ref='water',source_region_ref='shelf_A',observation_station_id='observe_A',station_registry_version=1,capture_after=90,timeout=1)
|
||||
for status in ['NOT_FOUND','AMBIGUOUS']:
|
||||
result=BrainService(FixtureBackend(json.dumps({'status':status})),self.tmp.name).shelf(goal,obs,110)
|
||||
self.assertEqual(result.get('observation_id'),'observation-7')
|
||||
self.assertEqual(result.get('observed_at'),100)
|
||||
self.assertNotIn('column_id',result)
|
||||
|
||||
def test_unexpected_backend_failure_is_archived(self):
|
||||
def broken(_): raise RuntimeError('model driver crashed')
|
||||
service=BrainService(FixtureBackend(broken),self.tmp.name)
|
||||
goal=dict(task_id='t',task_revision=1,planning_generation=1,instruction=self.instruction,known_info=self.slots,context=self.site,constraints={'route':'OBJECT_TABLE'},timeout=1)
|
||||
try:
|
||||
result=service.plan(goal)
|
||||
except RuntimeError:
|
||||
self.fail('unexpected backend exception escaped without diagnostic result')
|
||||
self.assertEqual(result['status'],'FAILED')
|
||||
self.assertEqual(result['error_code'],'INFERENCE_FAILED')
|
||||
self.assertIn('model driver crashed',json.loads(Path(result['record_ref']).read_text())['result']['message'])
|
||||
|
||||
class AdapterConfigTests(unittest.TestCase):
|
||||
def test_missing_loader_configuration_has_actionable_error(self):
|
||||
from robot_robobrain.model_adapter import create
|
||||
for config in [{}, {'loader':'missing-separator','model':{},'task_mapping':{}}]:
|
||||
try:
|
||||
create(config)
|
||||
except Exception as ex:
|
||||
self.assertIsInstance(ex,ValueError)
|
||||
self.assertIn('loader',str(ex))
|
||||
else:self.fail('invalid loader configuration accepted')
|
||||
|
||||
def test_single_image_adapter_rejects_dense_frames_before_loader_import(self):
|
||||
from robot_robobrain.model_adapter import create
|
||||
config={'loader':'deployment_models:create_robobrain','model':{'checkpoint':'/not/present'},'task_mapping':{'dense_feedback':'general'}}
|
||||
try:create(config)
|
||||
except Exception as ex:
|
||||
self.assertIsInstance(ex,ValueError)
|
||||
self.assertIn('dense',str(ex))
|
||||
else:self.fail('single-image adapter accepted dense task mapping')
|
||||
|
||||
class ServiceBoundaryTests(unittest.TestCase):
|
||||
def test_malformed_ros_request_is_archived_without_parsing_it(self):
|
||||
from robobrain_services import nodes
|
||||
self.assertTrue(callable(getattr(nodes,'guarded_work',None)), 'ROS worker needs a failure-archiving boundary')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service=BrainService(FixtureBackend('{}'),tmp)
|
||||
request=types.SimpleNamespace(task_id='t',instruction='original text',known_info_json='{malformed',timeout=types.SimpleNamespace(sec=1,nanosec=0))
|
||||
def bad_request(): raise ValueError('invalid JSON input')
|
||||
result=nodes.guarded_work(service,'plan',request,bad_request)
|
||||
self.assertEqual(result['error_code'],'SERVICE_ERROR')
|
||||
record=json.loads(Path(result['record_ref']).read_text())
|
||||
self.assertEqual(record['input']['known_info_json'],'{malformed')
|
||||
self.assertEqual(record['input']['timeout']['sec'],1)
|
||||
self.assertIn('invalid JSON input',record['result']['message'])
|
||||
|
||||
def test_archive_failure_still_returns_terminal_failure(self):
|
||||
from robobrain_services import nodes
|
||||
self.assertTrue(callable(getattr(nodes,'guarded_work',None)), 'ROS worker needs a failure-archiving boundary')
|
||||
class UnavailableArchive:
|
||||
def _record(self,*args):raise OSError('disk full')
|
||||
def failure():raise RuntimeError('service crashed')
|
||||
result=nodes.guarded_work(UnavailableArchive(),'shelf',{'task_id':'t'},failure)
|
||||
self.assertEqual(result['status'],'FAILED')
|
||||
self.assertEqual(result.get('record_ref'),'')
|
||||
self.assertIn('archive',result['message'])
|
||||
self.assertIn('disk full',result['message'])
|
||||
|
||||
def test_success_result_is_not_rewritten(self):
|
||||
from robobrain_services import nodes
|
||||
self.assertTrue(callable(getattr(nodes,'guarded_work',None)), 'ROS worker needs a failure-archiving boundary')
|
||||
expected={'status':'PLAN_READY','record_ref':'existing'}
|
||||
self.assertIs(nodes.guarded_work(None,'plan',{},lambda:expected),expected)
|
||||
|
||||
class LocalizeDiagnosticTests(unittest.TestCase):
|
||||
def test_non_success_keeps_observation_without_inventing_point(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path=Path(tmp)/'image.png';path.write_bytes(b'fixture')
|
||||
obs=Observation('obs3d',100,'camera',str(path),'observe_A',1,'shelf_A','cal-1',7)
|
||||
goal=dict(task_id='t',subtask_id='s',target_ref='water',capture_after=90,timeout=1)
|
||||
for status in ('NOT_FOUND','AMBIGUOUS'):
|
||||
result=BrainService(FixtureBackend(json.dumps({'status':status})),tmp).localize(goal,obs,110)
|
||||
self.assertEqual(result.get('observation_id'),'obs3d')
|
||||
self.assertEqual(result.get('observed_at'),100)
|
||||
self.assertEqual(result.get('calibration_id'),'cal-1')
|
||||
self.assertEqual(result.get('geometry_epoch'),7)
|
||||
self.assertFalse(result['geometry_valid'])
|
||||
self.assertNotIn('target_point',result)
|
||||
|
||||
class DemoClarificationTests(unittest.TestCase):
|
||||
def test_demo_propagates_trusted_clarification_marker(self):
|
||||
from robot_robobrain.demo_backend import BrainDemoBackend
|
||||
backend=BrainDemoBackend.__new__(BrainDemoBackend)
|
||||
backend.plans=[];backend.site={'execution_route':'OBJECT_TABLE'}
|
||||
goals=[]
|
||||
backend.brain=types.SimpleNamespace(plan=lambda goal:(goals.append(goal) or {'status':'FAILED','record_ref':'r'}))
|
||||
backend.emit=lambda event:None
|
||||
task={'task_id':'t','task_revision':2,'planning_generation':3,'clarification_confirmed':True,'request':{'instruction':'original','known_info':{}}}
|
||||
backend.start_planning(task)
|
||||
self.assertIs(goals[0]['constraints'].get('clarification_confirmed'),True)
|
||||
Reference in New Issue
Block a user