Files
behavior-tree/tests/test_coordinator_recovery.py
T

191 lines
14 KiB
Python

import copy
import json
from pathlib import Path
import sqlite3
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path[:0]=[str(ROOT/'coordinator'),str(ROOT/'robobrain')]
from robot_bt_coordinator.service import Coordinator,demo_site
from robot_bt_coordinator.backends import ManualBackend,demo_plan
from robot_bt_coordinator.errors import ApiError
from robot_bt_coordinator.replay import replay_events
from robot_bt_coordinator.store import Store
from robot_bt_coordinator.plan_v2 import make_plan
class TrustedBackend(ManualBackend):
def reconcile(self,task,request):
return dict(verified=True,stop_confirmed=True,holding_state='EMPTY',run_id=task['run_id'],evidence_ref=request['evidence_ref'],receipts=copy.deepcopy(self.receipts),safe_to_retry=self.retry)
class RecoveryTest(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.backend=TrustedBackend();self.backend.receipts=[];self.backend.retry=False
self.site=demo_site();self.site['object_aliases']={'water':['water'],'doll':['doll']}
self.c=Coordinator(self.tmp.name+'/tasks.db',self.backend,['r'],self.site)
def tearDown(self):self.c.close();self.tmp.cleanup()
def start(self):
task=self.c.submit(dict(client_request_id='q',robot_id='r',instruction='move item',known_info={'target_name':'water','source_location':'shelf_A','destination':'tote_A'}))
self.c.tick();task=self.c.get(task['task_id'])
self.backend.emit(dict(type='plan',task_id=task['task_id'],task_revision=task['task_revision'],planning_generation=task['planning_generation'],status='PLAN_READY',plan=demo_plan(task['request']['known_info'])))
self.c.tick();return self.c.get(task['task_id'])
def quarantine(self,task):
self.backend.emit(dict(type='execution_result',task_id=task['task_id'],run_id=task['run_id'],status='INTERVENTION_REQUIRED',completed_quantity=0,stop_confirmed=False))
self.c.tick()
def request(self,task,resolution='resume_task'):
return dict(run_id=task['run_id'],evidence_ref='trusted-proof',resolution=resolution)
def receipt(self,task):
return dict(task_id=task['task_id'],item_index=0,completed_quantity=1,evidence=dict(evidence_id='delivery-proof',target_ref='water',destination_ref='tote_A',passed=True,empty_hand=True,in_destination=True,valid=True))
def test_unsafe_resume_cannot_replay_an_uncertain_manipulation(self):
task=self.start();self.quarantine(task)
with self.assertRaises(ApiError) as error:self.c.intervene(task['task_id'],self.request(task))
self.assertEqual(error.exception.code,'RECONCILIATION_REJECTED')
self.assertEqual(len(self.backend.executions),1)
def test_trusted_receipt_recovers_lost_result_without_redispatch(self):
task=self.start();self.quarantine(task);self.backend.receipts=[self.receipt(task)]
out=self.c.intervene(task['task_id'],self.request(task))
self.assertEqual(out['status'],'SUCCEEDED');self.assertEqual(out['completed_quantity'],1)
self.assertEqual(len(self.backend.executions),1)
replay=replay_events(self.c.events(task['task_id'],limit=500))
self.assertEqual(replay['completed_quantity'],1)
def test_empty_hand_alone_cannot_credit_or_resume(self):
task=self.start();self.quarantine(task)
with self.assertRaises(ApiError):self.c.intervene(task['task_id'],self.request(task))
out=self.c.intervene(task['task_id'],self.request(task,'cancel_task'))
self.assertEqual(out['status'],'CANCELED');self.assertEqual(out['completed_quantity'],0)
def clarification_result(self,task,**changes):
evidence=dict(needs_clarification=True,safe_to_retry=True,empty_hand=True,valid=True,safe_to_release=True,questions=['Which registered shelf should be used?'])
evidence.update(changes)
self.backend.emit(dict(type='execution_result',task_id=task['task_id'],run_id=task['run_id'],status='FAILED',completed_quantity=0,stop_confirmed=True,evidence=evidence))
self.c.tick();return self.c.get(task['task_id'])
def test_verified_runtime_question_returns_to_planning_after_answer(self):
task=self.start();out=self.clarification_result(task)
self.assertEqual(out['status'],'NEEDS_CLARIFICATION');self.assertFalse(out['motion_dispatched'])
self.assertEqual(out['question']['source_run_id'],task['run_id'])
out=self.c.clarify(task['task_id'],dict(question_id=out['question']['question_id'],task_revision=out['task_revision'],known_info={'source_location':'shelf_A'}))
self.c.tick();self.assertEqual(self.c.get(task['task_id'])['status'],'PLANNING')
self.assertTrue(self.backend.plans[-1]['clarification_confirmed'])
self.assertEqual(len(self.backend.executions),1)
def test_runtime_question_without_no_manipulation_proof_is_quarantined(self):
task=self.start();out=self.clarification_result(task,safe_to_retry=False)
self.assertEqual(out['status'],'INTERVENTION_REQUIRED')
self.assertIsNone(out['question'])
def test_ordinary_failure_does_not_fabricate_question(self):
task=self.start();out=self.clarification_result(task,needs_clarification=False)
self.assertEqual(out['status'],'FAILED');self.assertIsNone(out['question'])
def test_operator_replan_requires_no_manipulation_and_no_delivery(self):
task=self.start();self.quarantine(task)
with self.assertRaises(ApiError):self.c.intervene(task['task_id'],self.request(task,'replan_task'))
self.backend.retry=True
out=self.c.intervene(task['task_id'],self.request(task,'replan_task'))
self.assertEqual(out['status'],'NEEDS_CLARIFICATION');self.assertFalse(out['motion_dispatched'])
self.assertEqual(len(self.backend.executions),1)
def test_operator_replan_cannot_rewrite_already_delivered_scope(self):
task=self.start();self.quarantine(task);self.backend.retry=True;self.backend.receipts=[self.receipt(task)]
with self.assertRaises(ApiError):self.c.intervene(task['task_id'],self.request(task,'replan_task'))
self.assertEqual(self.c.get(task['task_id'])['status'],'INTERVENTION_REQUIRED')
def test_question_cannot_hide_a_verified_physical_delivery(self):
task=self.start();proof=self.receipt(task)['evidence']
proof.update(needs_clarification=True,safe_to_retry=True,safe_to_release=True,questions=['Which shelf?'])
self.backend.emit(dict(type='execution_result',task_id=task['task_id'],run_id=task['run_id'],status='FAILED',completed_quantity=1,stop_confirmed=True,evidence=proof));self.c.tick()
out=self.c.get(task['task_id'])
self.assertEqual(out['completed_quantity'],1)
self.assertEqual(out['status'],'INTERVENTION_REQUIRED')
def test_proven_pre_manipulation_resume_uses_new_run(self):
task=self.start();self.quarantine(task);self.backend.retry=True
out=self.c.intervene(task['task_id'],self.request(task))
self.assertEqual(out['status'],'EXECUTING');self.assertNotEqual(out['run_id'],task['run_id'])
self.assertEqual(len(self.backend.executions),2)
def test_paused_execution_accepts_authenticated_reconciliation(self):
task=self.start();self.c.control(task['task_id'],'pause')
self.backend.emit(dict(type='execution_result',task_id=task['task_id'],run_id=task['run_id'],status='CANCELED',completed_quantity=0,stop_confirmed=True))
self.c.tick();self.assertEqual(self.c.get(task['task_id'])['status'],'PAUSED')
self.backend.retry=True
self.assertEqual(self.c.intervene(task['task_id'],self.request(task))['status'],'EXECUTING')
def test_unverified_resume_preserves_paused_state(self):
task=self.start();self.c.control(task['task_id'],'pause')
self.backend.emit(dict(type='execution_result',task_id=task['task_id'],run_id=task['run_id'],status='CANCELED',completed_quantity=0,stop_confirmed=True))
self.c.tick()
with self.assertRaises(ApiError) as error:self.c.control(task['task_id'],'resume')
self.assertEqual(error.exception.code,'RECONCILIATION_REQUIRED')
self.assertEqual(self.c.get(task['task_id'])['status'],'PAUSED')
self.assertEqual(len(self.backend.executions),1)
def test_completed_planning_releases_timer_state(self):
self.start();self.assertEqual(self.c._planning_started,{})
def test_duplicate_recovered_receipts_credit_once_then_start_next_item(self):
self.site['execution_route']='OBJECT_TABLE'
slots=dict(items=[dict(target_name='water',quantity=2,source_location='shelf_A')],destination='tote_A')
task=self.c.submit(dict(client_request_id='multi',robot_id='r',instruction='move two',known_info=slots))
self.c.tick();task=self.c.get(task['task_id'])
self.backend.emit(dict(type='plan',task_id=task['task_id'],task_revision=1,planning_generation=task['planning_generation'],status='PLAN_READY',plan=make_plan('move two',slots,'OBJECT_TABLE')))
self.c.tick();task=self.c.get(task['task_id']);self.quarantine(task)
receipt=self.receipt(task);self.backend.receipts=[receipt,copy.deepcopy(receipt)]
out=self.c.intervene(task['task_id'],self.request(task))
self.assertEqual(out['status'],'EXECUTING');self.assertEqual(out['completed_quantity'],1)
self.assertEqual(out['active_item_index'],1);self.assertNotEqual(out['run_id'],task['run_id'])
self.assertEqual([e['item_index'] for e in self.c.events(task['task_id'],limit=500) if e['kind']=='delivery_committed'],[0])
def test_empty_clarification_cannot_mark_intent_as_confirmed(self):
task=self.c.submit(dict(client_request_id='intent',robot_id='r',instruction='1 water shelf_A tote_A',known_info=dict(target_name='doll')))
with self.assertRaises(ApiError):
self.c.clarify(task['task_id'],dict(question_id=task['question']['question_id'],task_revision=task['task_revision'],known_info={}))
self.assertFalse(self.c.get(task['task_id'])['clarification_confirmed'])
def test_invalid_receipts_are_atomic_and_do_not_release_quarantine(self):
for mutate in [lambda r:r.update(task_id='other'),lambda r:r.update(item_index=True),lambda r:r.update(completed_quantity=2),lambda r:r['evidence'].update(destination_ref='wrong')]:
task=self.start() if not hasattr(self,'current') else self.current
self.current=task;self.quarantine(task)
receipt=self.receipt(task);mutate(receipt);self.backend.receipts=[receipt]
with self.assertRaises(ApiError):self.c.intervene(task['task_id'],self.request(task,'cancel_task'))
self.assertEqual(self.c.get(task['task_id'])['completed_quantity'],0)
self.assertEqual(self.c.get(task['task_id'])['status'],'INTERVENTION_REQUIRED')
def test_tick_avoids_historical_task_scan(self):
for n in range(30):
t=self.c.submit(dict(client_request_id=str(n),robot_id='r',instruction='move'))
self.c.control(t['task_id'],'cancel')
with patch.object(self.c.store,'all',side_effect=AssertionError('historical scan')):
self.c.tick()
def test_progress_replay_preserves_stage_sequence_and_detail(self):
task=self.start()
self.backend.emit(dict(type='progress',task_id=task['task_id'],run_id=task['run_id'],sequence=1,stage='NavigateSource',detail='waiting obstacle'))
self.c.tick()
events=self.c.events(task['task_id'],limit=500)
progress=next(e for e in events if e['kind']=='progress')
self.assertEqual(progress['stage'],'NavigateSource');self.assertEqual(progress['sequence'],1)
self.assertEqual(progress['detail'],'waiting obstacle')
self.assertEqual(replay_events(events)['progress'][-1]['stage'],'NavigateSource')
def test_supplied_slots_conflicting_with_instruction_require_clarification(self):
task=self.c.submit(dict(client_request_id='intent',robot_id='r',instruction='1 water shelf_A tote_A',known_info=dict(target_name='doll',quantity=1,source_location='shelf_A',destination='tote_A')))
self.assertEqual(task['status'],'NEEDS_CLARIFICATION');self.assertFalse(task['clarification_confirmed'])
self.c.tick();self.assertEqual(self.backend.plans,[])
task=self.c.clarify(task['task_id'],dict(question_id=task['question']['question_id'],task_revision=task['task_revision'],known_info=dict(target_name='doll')))
self.assertTrue(task['clarification_confirmed'])
self.c.close();self.c=Coordinator(self.tmp.name+'/tasks.db',self.backend,['r'],self.site)
self.assertTrue(self.c.get(task['task_id'])['clarification_confirmed'])
self.c.tick();self.assertTrue(self.backend.plans[-1]['clarification_confirmed'])
def test_approved_plan_is_independently_checked_against_instruction(self):
task=self.c.submit(dict(client_request_id='intent',robot_id='r',instruction='1 water shelf_A tote_A',known_info={}))
self.c.tick();task=self.c.get(task['task_id'])
plan=demo_plan(dict(target_name='doll',quantity=1,source_location='shelf_A',destination='tote_A'))
self.backend.emit(dict(type='plan',task_id=task['task_id'],task_revision=1,planning_generation=task['planning_generation'],status='PLAN_READY',plan=plan));self.c.tick()
self.assertEqual(self.c.get(task['task_id'])['status'],'NEEDS_CLARIFICATION');self.assertEqual(self.backend.executions,[])
class MigrationTest(unittest.TestCase):
def test_legacy_database_migrates_active_status_without_losing_history(self):
with tempfile.TemporaryDirectory() as folder:
path=folder+'/old.db';db=sqlite3.connect(path)
db.execute('CREATE TABLE tasks(seq INTEGER PRIMARY KEY AUTOINCREMENT,task_id TEXT UNIQUE NOT NULL,robot_id TEXT NOT NULL,request_id TEXT NOT NULL,request_hash TEXT NOT NULL,data TEXT NOT NULL,UNIQUE(robot_id,request_id))')
for n,status in enumerate(('SUCCEEDED','QUEUED','PAUSED')):
db.execute('INSERT INTO tasks(task_id,robot_id,request_id,request_hash,data) VALUES(?,?,?,?,?)',(str(n),'r',str(n),'hash',json.dumps(dict(task_id=str(n),status=status))))
db.commit();db.close();store=Store(path)
try:
self.assertEqual([t['status'] for t in store.active()],['QUEUED','PAUSED'])
self.assertEqual(len(store.all()),3)
finally:store.close()
if __name__=='__main__':unittest.main()