fix: retain planning feedback and add acceptance probes
This commit is contained in:
@@ -118,10 +118,20 @@ def run(root, binary, output):
|
||||
events = coordinator.events(first['task_id'],limit=500)
|
||||
planning = [event for event in events if event['kind']=='planning_result']
|
||||
assert planning and planning[-1]['payload']['planning_record_ref'].startswith('sim://plan_task/')
|
||||
planning_feedback = [event['payload'] for event in events if event['kind']=='planning_progress']
|
||||
assert planning_feedback, 'genuine PlanTask feedback was not persisted'
|
||||
assert all(row['sequence'] > 0 and isinstance(row['message'], str) and
|
||||
0 <= row['stamp']['nanosec'] < 1000000000 for row in planning_feedback)
|
||||
assert [row['sequence'] for row in planning_feedback] == sorted(set(row['sequence'] for row in planning_feedback))
|
||||
dispatches = [event for event in events if event['kind']=='execution_dispatched']
|
||||
assert dispatches and dispatches[-1]['versions']['provenance_schema'] == 1
|
||||
assert dispatches[-1]['versions']['runtime_config']['status'] == 'captured'
|
||||
assert any(event['kind']=='delivery_committed' for event in events)
|
||||
report['normal'] = {'task_id':first['task_id'],'run_id':first['run_id'],
|
||||
'status':first['status'],'completed_quantity':first['completed_quantity'],
|
||||
'planning_record_ref':planning[-1]['payload']['planning_record_ref'],
|
||||
'planning_feedback':planning_feedback,
|
||||
'execution_versions':dispatches[-1]['versions'],
|
||||
'counts':counts()}
|
||||
|
||||
second = submit('lost-final-result')
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Real short-duration process tests; these never stand in for an eight-hour run."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOL = ROOT / 'tools/native_soak.py'
|
||||
|
||||
|
||||
class VerdictTests(unittest.TestCase):
|
||||
def test_eight_hour_liveness_is_not_full_dr_acceptance(self):
|
||||
# Classification logic only: this test supplies a number, not duration evidence.
|
||||
spec = importlib.util.spec_from_file_location('native_soak', TOOL)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
report = module.verdict(28800, True)
|
||||
self.assertTrue(report['duration_liveness_pass'])
|
||||
self.assertFalse(report['full_dr_acceptance'])
|
||||
self.assertTrue(report['resource_growth_review_required'])
|
||||
self.assertFalse(report['no_deadlock_claim'])
|
||||
for seconds, progress, gap in [(28799, True, False), (28800, False, False), (28800, True, True)]:
|
||||
self.assertFalse(module.verdict(seconds, progress, sampling_gap=gap)['eight_hour_pass'])
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux /proc required')
|
||||
class NativeSoakTests(unittest.TestCase):
|
||||
def run_monitor(self, directory, *args):
|
||||
output = Path(directory) / 'evidence'
|
||||
process = subprocess.run([sys.executable, str(TOOL), '--output', str(output),
|
||||
'--duration-seconds', '0.45', '--sample-interval-seconds', '0.05',
|
||||
*args], capture_output=True, text=True, timeout=8)
|
||||
self.assertTrue((output / 'summary.json').exists(), process.stderr)
|
||||
return process, json.loads((output / 'summary.json').read_text()), output
|
||||
|
||||
def test_late_increment_cannot_erase_elapsed_progress_deadline(self):
|
||||
# Fault-injection unit test only; this clock is never duration evidence.
|
||||
from unittest.mock import patch
|
||||
spec = importlib.util.spec_from_file_location('native_soak_deadline', TOOL)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
progress = Path(directory) / 'counter.json'
|
||||
progress.write_text('{"completed":0}')
|
||||
output = Path(directory) / 'evidence'
|
||||
clock = [0.0]
|
||||
|
||||
def advance(seconds):
|
||||
clock[0] += seconds
|
||||
# The new completion is only visible after the .12-second deadline.
|
||||
if clock[0] >= .16:
|
||||
progress.write_text('{"completed":1}')
|
||||
|
||||
with patch.object(module.time, 'monotonic', side_effect=lambda: clock[0]), \
|
||||
patch.object(module.time, 'sleep', side_effect=advance), patch('builtins.print'):
|
||||
code = module.main(['--pid', str(os.getpid()), '--output', str(output),
|
||||
'--duration-seconds', '.16', '--sample-interval-seconds', '.08',
|
||||
'--max-progress-gap-seconds', '.12', '--progress-file', str(progress)])
|
||||
report = json.loads((output / 'summary.json').read_text())
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(report['status'], 'failed')
|
||||
self.assertIn('progress deadline', report['reason'])
|
||||
def test_real_short_workload_is_incomplete_and_one_process(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
progress = Path(directory) / 'progress.json'
|
||||
script = ('import json,os,time,sys; from pathlib import Path; p=Path(sys.argv[1]); n=0\n'
|
||||
'while True:\n n+=1; q=p.with_suffix(".tmp"); q.write_text(json.dumps({"completed":n})); os.replace(q,p); time.sleep(.025)\n')
|
||||
process, report, output = self.run_monitor(directory, '--progress-file', str(progress),
|
||||
'--max-progress-gap-seconds', '0.25', '--command', sys.executable, '-u', '-c', script, str(progress))
|
||||
self.assertEqual(process.returncode, 2, process.stderr)
|
||||
self.assertEqual(report['status'], 'qualification_incomplete')
|
||||
self.assertFalse(report['eight_hour_pass'])
|
||||
self.assertGreaterEqual(report['elapsed_seconds'], .45)
|
||||
self.assertTrue(report['progress_observed'])
|
||||
samples = [json.loads(line) for line in (output / 'samples.jsonl').read_text().splitlines()]
|
||||
self.assertGreater(len(samples), 3)
|
||||
self.assertEqual(len({(s['pid'], s['start_ticks']) for s in samples}), 1)
|
||||
self.assertGreater(samples[-1]['rss_bytes'], 0)
|
||||
self.assertIn('cpu_seconds', samples[-1])
|
||||
self.assertIn('fd_count', samples[-1])
|
||||
self.assertFalse(report['no_deadlock_claim'])
|
||||
|
||||
def test_attached_process_is_left_alive_and_no_metric_not_pass(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(10)'])
|
||||
try:
|
||||
process, report, _ = self.run_monitor(directory, '--pid', str(child.pid))
|
||||
self.assertEqual(process.returncode, 2, process.stderr)
|
||||
self.assertIsNone(child.poll())
|
||||
self.assertFalse(report['progress_observed'])
|
||||
self.assertFalse(report['no_deadlock_claim'])
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=3)
|
||||
|
||||
def test_early_exit_fails(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
process, report, _ = self.run_monitor(directory, '--command', sys.executable, '-c', 'pass')
|
||||
self.assertEqual(process.returncode, 1)
|
||||
self.assertEqual(report['status'], 'failed')
|
||||
self.assertIn('process', report['reason'])
|
||||
|
||||
def test_stalled_metric_fails_even_if_process_alive(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
progress = Path(directory) / 'progress.json'
|
||||
progress.write_text('{"completed":1}')
|
||||
process, report, _ = self.run_monitor(directory, '--progress-file', str(progress),
|
||||
'--max-progress-gap-seconds', '.15', '--command', sys.executable, '-c', 'import time; time.sleep(10)')
|
||||
self.assertEqual(process.returncode, 1)
|
||||
self.assertIn('progress', report['reason'])
|
||||
|
||||
def test_journal_size_is_observed(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
journal = Path(directory) / 'goals.log'
|
||||
journal.write_bytes(b'goal\n')
|
||||
process, report, output = self.run_monitor(directory, '--journal', str(journal),
|
||||
'--command', sys.executable, '-c', 'import time; time.sleep(10)')
|
||||
self.assertEqual(process.returncode, 2)
|
||||
sample = json.loads((output / 'samples.jsonl').read_text().splitlines()[-1])
|
||||
self.assertEqual(sample['journal_bytes'][str(journal.resolve())], 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
|
||||
from physics_delivery_probe import verify_delivery
|
||||
|
||||
|
||||
class DeliveryPredicateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.frames = [dict(time=i / 240, body_id=4, destination_id=2,
|
||||
aabb=[[-.02, -.02, .01], [.02, .02, .05]],
|
||||
linear_speed=0., angular_speed=0.,
|
||||
floor_contact=True, held=False) for i in range(121)]
|
||||
self.bounds = [[-.2, -.2, 0.], [.2, .2, .3]]
|
||||
|
||||
def verify(self, frames=None):
|
||||
return verify_delivery(self.frames if frames is None else frames,
|
||||
self.bounds, 4, 2)
|
||||
|
||||
def test_settled_released_object(self):
|
||||
self.assertTrue(self.verify()['verified'])
|
||||
|
||||
def test_reject_each_unsafe_condition(self):
|
||||
for update in ({'held': True}, {'floor_contact': False},
|
||||
{'linear_speed': .1}, {'angular_speed': .3},
|
||||
{'body_id': 8}, {'destination_id': 9},
|
||||
{'aabb': [[.19, 0., .01], [.23, .04, .05]]},
|
||||
{'linear_speed': float('nan')}, {'held': None},
|
||||
{'floor_contact': 'true'}):
|
||||
with self.subTest(update=update):
|
||||
frames = copy.deepcopy(self.frames)
|
||||
frames[-1].update(update)
|
||||
self.assertFalse(self.verify(frames)['verified'])
|
||||
|
||||
def test_missing_or_short_evidence(self):
|
||||
for frames in ([], self.frames[-20:], [{}]):
|
||||
self.assertFalse(self.verify(frames)['verified'])
|
||||
|
||||
def test_time_must_increase_without_sampling_gap(self):
|
||||
for value in (self.frames[-2]['time'], 1., float('nan')):
|
||||
frames = copy.deepcopy(self.frames)
|
||||
frames[-1]['time'] = value
|
||||
self.assertFalse(self.verify(frames)['verified'])
|
||||
|
||||
def test_stability_requires_whole_window(self):
|
||||
frames = copy.deepcopy(self.frames)
|
||||
frames[-40]['held'] = True
|
||||
self.assertFalse(self.verify(frames)['verified'])
|
||||
|
||||
def test_invalid_earlier_timestamp_cannot_be_filtered_out(self):
|
||||
frames = copy.deepcopy(self.frames)
|
||||
frames[-30]['time'] = float('nan')
|
||||
self.assertFalse(self.verify(frames)['verified'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
import sys, threading, tempfile, unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from collections import deque
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/'coordinator'))
|
||||
from robot_bt_coordinator.ros_backend import RosBackend
|
||||
from robot_bt_coordinator.service import Coordinator
|
||||
from robot_bt_coordinator.backends import ManualBackend
|
||||
from robot_bt_coordinator.replay import replay_events
|
||||
|
||||
class PlannerFeedbackTests(unittest.TestCase):
|
||||
def backend(self):
|
||||
b=RosBackend.__new__(RosBackend);b._lock=threading.RLock();b._events=deque();b._planning={};b._closed=False;b.config={}
|
||||
class Planner:
|
||||
def server_is_ready(self):return True
|
||||
def send_goal_async(self,goal,**kwargs):
|
||||
self.callback=kwargs.get('feedback_callback');return NS(add_done_callback=lambda cb:None)
|
||||
b._planner=Planner();b._PlanTask=NS(Goal=lambda:NS(timeout=NS()))
|
||||
b.start_planning(dict(task_id='t',task_revision=1,planning_generation=2,robot_id='r',request={'instruction':'move item'}))
|
||||
return b
|
||||
def feedback(self,sequence=1,phase=7,message='actual phase text'):
|
||||
return NS(feedback=NS(stamp=NS(sec=12,nanosec=345),sequence=sequence,phase=phase,message=message))
|
||||
def test_real_fields_are_copied_without_phase_invention(self):
|
||||
b=self.backend();self.assertTrue(callable(b._planner.callback))
|
||||
b._planner.callback(self.feedback());e=b._events.popleft()
|
||||
self.assertEqual(e,dict(type='planning_progress',task_id='t',task_revision=1,planning_generation=2,stamp={'sec':12,'nanosec':345},sequence=1,phase=7,message='actual phase text'))
|
||||
def test_duplicate_reordered_and_terminal_feedback_ignored(self):
|
||||
b=self.backend();self.assertTrue(callable(b._planner.callback))
|
||||
for seq in (2,2,1):b._planner.callback(self.feedback(seq))
|
||||
b._planning[('t',1,2)]['done']=True;b._planner.callback(self.feedback(3))
|
||||
self.assertEqual(len(b._events),1)
|
||||
def test_invalid_fields_do_not_kill_callback(self):
|
||||
b=self.backend();self.assertTrue(callable(b._planner.callback))
|
||||
for field,value in [('sequence',True),('sequence',-1),('sequence',2**32),('phase',256),('message',object())]:
|
||||
f=self.feedback();setattr(f.feedback,field,value);b._planner.callback(f)
|
||||
f=self.feedback();f.feedback.stamp.nanosec=1000000000;b._planner.callback(f)
|
||||
self.assertFalse(b._events)
|
||||
def test_feedback_is_persisted_and_stale_generation_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path=str(Path(tmp)/'tasks.db');backend=ManualBackend();c=Coordinator(path,backend,{'r'})
|
||||
try:
|
||||
tid=c.submit(dict(client_request_id='req',robot_id='r',instruction='move item',known_info={}))['task_id'];c.tick();t=c.get(tid)
|
||||
event=dict(type='planning_progress',task_id=tid,task_revision=t['task_revision'],planning_generation=t['planning_generation'],stamp={'sec':12,'nanosec':345},sequence=1,phase=7,message='actual phase text')
|
||||
backend.emit(event);backend.emit(event);backend.emit(dict(event,planning_generation=0,sequence=2));c.tick()
|
||||
rows=[e for e in c.events(tid) if e['kind']=='planning_progress'];self.assertEqual(len(rows),1);self.assertEqual(rows[0]['payload'],event)
|
||||
self.assertEqual(c.get(tid)['status'],'PLANNING')
|
||||
# A new generation starts its own sequence; old callbacks cannot
|
||||
# contaminate it, and feedback after leaving planning is ignored.
|
||||
current=c.store.get(tid);current['planning_generation']+=1
|
||||
with c.store.db:c.store.put(current)
|
||||
newer=dict(event,planning_generation=current['planning_generation'],message='next actual phase')
|
||||
backend.emit(dict(event,sequence=99));backend.emit(newer);c.tick()
|
||||
rows=[e['payload'] for e in c.events(tid) if e['kind']=='planning_progress']
|
||||
self.assertEqual(rows,[event,newer])
|
||||
c.control(tid,'cancel');backend.emit(dict(newer,sequence=2));c.tick()
|
||||
self.assertEqual(c.get(tid)['status'],'CANCELED')
|
||||
replay=replay_events(c.events(tid));self.assertEqual(replay['planning_progress'],[event,newer]);self.assertFalse(replay['connects_to_robot'])
|
||||
self.assertEqual([e['payload'] for e in c.events(tid) if e['kind']=='planning_progress'],[event,newer])
|
||||
finally:c.close()
|
||||
c=Coordinator(path,ManualBackend(),{'r'})
|
||||
try:self.assertEqual([e['payload'] for e in c.events(tid) if e['kind']=='planning_progress'],[event,newer])
|
||||
finally:c.close()
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,97 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'coordinator'))
|
||||
from robot_bt_coordinator.provenance import capture_sources, execution_versions
|
||||
import test_coordinator as fixture
|
||||
|
||||
|
||||
class ProvenanceTest(unittest.TestCase):
|
||||
def test_git_commit_and_dirty_are_measured(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
subprocess.run(['git', 'init', '-q', tmp], check=True)
|
||||
subprocess.run(['git', '-C', tmp, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid',
|
||||
'commit', '--allow-empty', '-qm', 'fixture'], check=True)
|
||||
clean = capture_sources(tmp)['code']
|
||||
self.assertEqual(clean['status'], 'captured')
|
||||
self.assertEqual(len(clean['commit']), 40)
|
||||
self.assertFalse(clean['dirty'])
|
||||
Path(tmp, 'changed.py').write_text('changed')
|
||||
dirty = capture_sources(tmp)['code']
|
||||
self.assertTrue(dirty['dirty'])
|
||||
self.assertEqual(clean['commit'], dirty['commit'])
|
||||
def test_actual_planner_prompt_is_hashed_in_linked_record(self):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'robobrain'))
|
||||
from robot_robobrain.service import BrainService, PLANNER_RULES
|
||||
from robot_robobrain.backends import FixtureBackend
|
||||
from robot_bt_coordinator.plan import canonical
|
||||
from test_robobrain import RoboBrainTests
|
||||
requests = []
|
||||
def infer(request):
|
||||
requests.append(request)
|
||||
return '{"missing_information":["which destination?"]}'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = BrainService(FixtureBackend(infer), tmp).plan(RoboBrainTests().goal())
|
||||
record = json.loads(Path(result['record_ref']).read_text())
|
||||
self.assertEqual(record['prompt_provenance']['sha256'], hashlib.sha256(requests[0]['prompt'].encode()).hexdigest())
|
||||
self.assertEqual(record['prompt_provenance']['template_sha256'], hashlib.sha256(PLANNER_RULES.encode()).hexdigest())
|
||||
self.assertEqual(requests[0]['prompt'], PLANNER_RULES + '\nINPUT: ' + canonical(record['input']))
|
||||
def test_source_hash_changes_and_missing_is_explicit(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
xml = root / 'ros2/bt_executor/trees/fixed_workflow.xml'
|
||||
xml.parent.mkdir(parents=True)
|
||||
xml.write_bytes(b'<root/>')
|
||||
first = capture_sources(root)
|
||||
self.assertEqual(first['xml']['sha256'], hashlib.sha256(b'<root/>').hexdigest())
|
||||
self.assertEqual(first['xml']['scope'], 'source_only')
|
||||
self.assertEqual(first['idl']['status'], 'missing')
|
||||
self.assertEqual(first['code']['status'], 'missing')
|
||||
source = root / 'coordinator/example.py'
|
||||
source.parent.mkdir()
|
||||
source.write_text('version_one = True')
|
||||
before = capture_sources(root)['code_content']
|
||||
source.write_text('version_two = True')
|
||||
self.assertNotEqual(before['sha256'], capture_sources(root)['code_content']['sha256'])
|
||||
xml.write_bytes(b'<changed/>')
|
||||
self.assertNotEqual(first['xml'], capture_sources(root)['xml'])
|
||||
|
||||
def test_actual_configuration_hash_is_order_independent_and_sensitive(self):
|
||||
task = {'context': {'a': 1, 'b': 2}, 'execution_plan': {'schema_version': 1},
|
||||
'request': {'instruction': 'move water'}, 'planning_record_ref': '/record/1'}
|
||||
first = execution_versions({}, task, {'timeout': 5, 'recovery_token': 'secret'})
|
||||
task['context'] = {'b': 2, 'a': 1}
|
||||
self.assertEqual(first, execution_versions({}, task, {'recovery_token': 'secret', 'timeout': 5}))
|
||||
task['context']['a'] = 3
|
||||
self.assertNotEqual(first['runtime_config'], execution_versions({}, task, {})['runtime_config'])
|
||||
self.assertNotIn('secret', json.dumps(first))
|
||||
self.assertEqual(first['deployed_executor']['status'], 'missing')
|
||||
self.assertEqual(first['instruction_template']['status'], 'missing')
|
||||
self.assertEqual(first['planning_record_ref'], '/record/1')
|
||||
|
||||
|
||||
class DispatchProvenanceTest(unittest.TestCase):
|
||||
setUp = fixture.CoordinatorTest.setUp
|
||||
tearDown = fixture.CoordinatorTest.tearDown
|
||||
plan = fixture.CoordinatorTest.plan
|
||||
def test_dispatch_identity_survives_database_reopen(self):
|
||||
tid = self.c.submit(fixture.REQ)['task_id']
|
||||
self.plan(tid)
|
||||
events = self.c.store.events(tid, 0, 100)
|
||||
versions = next(e['versions'] for e in events if e['kind'] == 'execution_dispatched')
|
||||
self.assertEqual(versions['runtime_config']['status'], 'captured')
|
||||
self.assertEqual(len(versions['sources']['xml']['sha256']), 64)
|
||||
self.assertIn(versions['sources']['code']['status'], ('captured', 'missing'))
|
||||
import sqlite3
|
||||
with sqlite3.connect(self.db) as connection:
|
||||
persisted = json.loads(connection.execute("SELECT data FROM events WHERE kind='execution_dispatched'").fetchone()[0])
|
||||
self.assertEqual(versions, persisted['versions'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user