diff --git a/ops/dashboard/src/nanobot_ops_dashboard/app.py b/ops/dashboard/src/nanobot_ops_dashboard/app.py index 251795a..43c48d0 100644 --- a/ops/dashboard/src/nanobot_ops_dashboard/app.py +++ b/ops/dashboard/src/nanobot_ops_dashboard/app.py @@ -143,6 +143,76 @@ def _promotion_replay_readiness_from_promotions(promotions: list[dict] | None) - return {'schema_version': 'promotion-replay-readiness-v1', 'state': 'ready', 'reason': 'no_blocked_promotions'} +def _promotion_source_commit_blocker_resolved(promotion_readiness: dict | None) -> bool: + if not isinstance(promotion_readiness, dict): + return False + checks = promotion_readiness.get('readiness_checks') if isinstance(promotion_readiness.get('readiness_checks'), dict) else {} + missing_inputs = checks.get('missing_inputs') if isinstance(checks.get('missing_inputs'), list) else [] + readiness_reasons = promotion_readiness.get('readiness_reasons') if isinstance(promotion_readiness.get('readiness_reasons'), list) else [] + return bool( + checks.get('provenance_complete') is True + and 'source_commit' not in {str(item) for item in missing_inputs} + and 'source_commit_missing' not in {str(item) for item in readiness_reasons} + ) + + +def _source_commit_blocker(value: dict | None) -> bool: + if not isinstance(value, dict): + return False + markers = { + value.get('failure_class'), + value.get('reason'), + value.get('blocked_next_step'), + value.get('recommended_next_action'), + } + return any(str(item) in {'source_commit_missing', 'supply_source_commit_or_policy_override'} for item in markers if item is not None) + + +def _demote_resolved_source_commit_blocker(current_blocker: dict | None, control_plane: dict | None, promotion_readiness: dict | None) -> tuple[dict | None, dict | None]: + if not _promotion_source_commit_blocker_resolved(promotion_readiness): + return current_blocker, control_plane + next_action = promotion_readiness.get('recommended_next_action') or 'review_promotion_candidate' + blocker = dict(current_blocker) if isinstance(current_blocker, dict) else current_blocker + if _source_commit_blocker(blocker): + blocker = dict(blocker) + blocker.update({ + 'kind': 'unknown', + 'failure_class': None, + 'reason': 'none', + 'blocked_next_step': next_action, + 'recommended_next_action': next_action, + 'resolved_stale_blocker': 'source_commit_missing', + 'resolution_source': 'promotion_replay_readiness', + }) + plane = dict(control_plane) if isinstance(control_plane, dict) else control_plane + if isinstance(plane, dict): + control_blocker = plane.get('current_blocker') + if _source_commit_blocker(control_blocker): + control_blocker = dict(control_blocker) + control_blocker.update({ + 'kind': 'unknown', + 'failure_class': None, + 'reason': 'none', + 'blocked_next_step': next_action, + 'recommended_next_action': next_action, + 'resolved_stale_blocker': 'source_commit_missing', + 'resolution_source': 'promotion_replay_readiness', + }) + plane['current_blocker'] = control_blocker + blocker_summary = plane.get('blocker_summary') + if _source_commit_blocker(blocker_summary): + blocker_summary = dict(blocker_summary) + blocker_summary.update({ + 'state': 'clear', + 'reason': 'none', + 'recommended_next_action': next_action, + 'resolved_stale_blocker': 'source_commit_missing', + 'resolution_source': 'promotion_replay_readiness', + }) + plane['blocker_summary'] = blocker_summary + return blocker, plane + + def _env(cfg: DashboardConfig) -> Environment: templates = cfg.project_root / 'src' / 'nanobot_ops_dashboard' / 'templates' return Environment( @@ -3915,6 +3985,7 @@ def app(environ, start_response): ) if promotion_replay_readiness is not None: control_plane['promotion_replay_readiness'] = promotion_replay_readiness + current_blocker, control_plane = _demote_resolved_source_commit_blocker(current_blocker, control_plane, promotion_replay_readiness) overview_subagent_cycle_id = None if subagent_latest_event and isinstance(subagent_latest_event.get('detail'), dict): detail = subagent_latest_event['detail'] diff --git a/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py b/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py index fccd6b8..2262add 100644 --- a/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py +++ b/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py @@ -3022,6 +3022,67 @@ def test_api_system_hydrates_unknown_blocker_from_autonomy_verdict(tmp_path: Pat assert control['blocker_summary']['source'] == 'autonomy_verdict' +def test_api_demotes_stale_source_commit_blocker_when_promotion_provenance_is_complete(tmp_path: Path) -> None: + project_root = tmp_path / 'dashboard' + repo_root = tmp_path / 'nanobot' + db = tmp_path / 'dashboard.sqlite3' + init_db(db) + eeepc_raw = { + 'outbox': { + 'process_reflection': { + 'failure_class': 'source_commit_missing', + 'improvement_score': None, + }, + 'goal': { + 'follow_through': { + 'blocked_next_step': 'supply_source_commit_or_policy_override', + }, + }, + }, + 'reachability': {'reachable': True}, + } + insert_collection(db, { + 'collected_at': '2999-05-02T11:07:00Z', + 'source': 'eeepc', + 'status': 'PASS', + 'active_goal': 'goal-bootstrap', + 'current_task': 'Synthesize one new bounded improvement candidate from retired lanes', + 'raw_json': json.dumps(eeepc_raw), + }) + upsert_event(db, { + 'collected_at': '2999-05-02T11:07:00Z', + 'source': 'eeepc', + 'event_type': 'promotion', + 'identity_key': 'promotion-provenance-complete', + 'title': 'promotion-provenance-complete | not_ready_for_policy_review | not_ready_for_policy_review', + 'status': 'not_ready_for_policy_review', + 'detail_json': json.dumps({ + 'candidate_path': '/var/lib/eeepc-agent/self-evolving-agent/state/promotions/promotion-provenance-complete.json', + 'artifact_path': '/var/lib/eeepc-agent/self-evolving-agent/state/improvements/materialized-cycle.json', + 'decision_record': 'blocked_not_ready', + 'accepted_record': 'not_created_not_ready', + 'readiness_checks': {'schema_version': 'promotion-readiness-inputs-v1', 'artifact_present': True, 'evidence_refs_present': True, 'provenance_complete': True, 'missing_inputs': []}, + 'readiness_reasons': [], + 'recommended_next_action': 'ready_for_policy_review', + 'governance_packet': {'review_packet_status': 'blocked_not_ready', 'review_status': 'not_ready_for_policy_review', 'decision': 'not_ready_for_policy_review'}, + }), + }) + cfg = DashboardConfig(project_root=project_root, nanobot_repo_root=repo_root, db_path=db, eeepc_ssh_host='eeepc', eeepc_ssh_key=tmp_path / 'missing-key', eeepc_state_root='/var/lib/eeepc-agent/self-evolving-agent/state') + app = create_app(cfg) + + system = _call_json(app, '/api/system') + mission = _call_json(app, '/api/mission-control') + + readiness = system['control_plane']['promotion_replay_readiness'] + assert readiness['readiness_checks']['provenance_complete'] is True + assert readiness['readiness_checks']['missing_inputs'] == [] + assert readiness['readiness_reasons'] == [] + assert system['control_plane']['current_blocker'].get('failure_class') != 'source_commit_missing' + assert mission['current_blocker']['reason'] != 'source_commit_missing' + assert 'source_commit_missing' not in mission['headline'] + + + def test_subagent_visibility_preserves_generation_scoped_identity(tmp_path: Path): repo = tmp_path / 'repo' state = repo / 'workspace' / 'state'