From 324a7fa39b626e2e6ad2bcd39ae634297dcc4c63 Mon Sep 17 00:00:00 2001 From: ozand Date: Sat, 2 May 2026 17:56:25 +0300 Subject: [PATCH] fix: advance supplied promotion readiness state --- nanobot/runtime/coordinator.py | 26 +++++--- nanobot/runtime/state.py | 16 +++++ tests/test_runtime_coordinator.py | 104 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/nanobot/runtime/coordinator.py b/nanobot/runtime/coordinator.py index 0c88bc4..c87b9d9 100644 --- a/nanobot/runtime/coordinator.py +++ b/nanobot/runtime/coordinator.py @@ -3811,10 +3811,16 @@ async def run_self_evolving_cycle( candidate_id=promotion_candidate_id, now=_utc_now(now), ) - decision_record_value = str(readiness_result.get("readiness_packet_path")) - accepted_record_value = str(readiness_result.get("readiness_packet_path")) - experiment["decision_record"] = "blocked_not_ready" - experiment["accepted_record"] = "not_created_not_ready" + readiness_inputs_supplied = readiness_inputs_result.get("state") == "ready_for_policy_review" + decision_record_value = "pending_operator_review_packet" if readiness_inputs_supplied else "blocked_not_ready" + accepted_record_value = None if readiness_inputs_supplied else "not_created_not_ready" + if readiness_inputs_supplied: + review_status = "ready_for_policy_review" + decision = "ready_for_policy_review" + experiment["review_status"] = review_status + experiment["decision"] = decision + experiment["decision_record"] = decision_record_value + experiment["accepted_record"] = accepted_record_value experiment["readiness_packet_path"] = readiness_result.get("readiness_packet_path") experiment["readiness_checks"] = readiness_inputs_result.get("readiness_checks") experiment["readiness_reasons"] = readiness_inputs_result.get("readiness_reasons") @@ -3822,8 +3828,10 @@ async def run_self_evolving_cycle( experiment["recommended_next_action"] = readiness_inputs_result.get("recommended_next_action") final_promotion_record = { **final_promotion_record, - "decision_record": "blocked_not_ready", - "accepted_record": "not_created_not_ready", + "review_status": review_status, + "decision": decision, + "decision_record": decision_record_value, + "accepted_record": accepted_record_value, "readiness_packet_path": readiness_result.get("readiness_packet_path"), "readiness_checks": readiness_inputs_result.get("readiness_checks"), "readiness_reasons": readiness_inputs_result.get("readiness_reasons"), @@ -3831,11 +3839,11 @@ async def run_self_evolving_cycle( "recommended_next_action": readiness_inputs_result.get("recommended_next_action"), "governance_packet": { **(final_promotion_record.get("governance_packet") if isinstance(final_promotion_record.get("governance_packet"), dict) else {}), - "review_packet_status": "blocked_not_ready", + "review_packet_status": "pending_operator_review" if readiness_inputs_supplied else "blocked_not_ready", "review_status": review_status, "decision": decision, - "decision_record": "blocked_not_ready", - "accepted_record": "not_created_not_ready", + "decision_record": decision_record_value, + "accepted_record": accepted_record_value, "readiness_packet_path": readiness_result.get("readiness_packet_path"), "readiness_blocker": readiness_inputs_result, }, diff --git a/nanobot/runtime/state.py b/nanobot/runtime/state.py index d0139f7..35d42db 100644 --- a/nanobot/runtime/state.py +++ b/nanobot/runtime/state.py @@ -1280,6 +1280,22 @@ def load_runtime_state_from_root(state_root: Path, source_kind: str = "workspace promotion_readiness_reasons=promotion_readiness_reasons, promotion_recommended_next_action=promotion_recommended_next_action, ) + elif decision == 'ready_for_policy_review' or review_status == 'ready_for_policy_review': + promotion_replay_readiness = _promotion_replay_readiness_payload( + state='ready_for_policy_review', + reason='promotion_candidate_ready_for_policy_review', + promotion_candidate_id=promotion_candidate_id, + review_status=review_status, + decision=decision, + promotion_candidate_path=promotion_candidate_path, + promotion_artifact_path=promotion_artifact_path, + promotion_decision_record=promotion_decision_record, + promotion_accepted_record=promotion_accepted_record, + promotion_patch_bundle_path=promotion_patch_bundle_path, + promotion_readiness_checks=promotion_readiness_checks, + promotion_readiness_reasons=promotion_readiness_reasons, + promotion_recommended_next_action=promotion_recommended_next_action, + ) elif decision in {'not_ready_for_policy_review', 'pending'} or review_status == 'not_ready_for_policy_review': not_ready_state = 'blocked' if promotion_decision_record == 'blocked_not_ready' or promotion_accepted_record == 'not_created_not_ready' else 'not_ready' promotion_replay_readiness = _promotion_replay_readiness_payload( diff --git a/tests/test_runtime_coordinator.py b/tests/test_runtime_coordinator.py index bda1411..06de6b0 100644 --- a/tests/test_runtime_coordinator.py +++ b/tests/test_runtime_coordinator.py @@ -347,6 +347,110 @@ def test_cycle_writes_pass_report_when_gate_is_fresh(tmp_path): assert history["current_task_id"] == "record-reward" +def test_cycle_promotes_supplied_readiness_inputs_to_ready_for_policy_review(tmp_path, monkeypatch): + approvals_dir = tmp_path / "state" / "approvals" + approvals_dir.mkdir(parents=True) + expires_at = datetime(2026, 4, 15, 13, 0, tzinfo=timezone.utc) + (approvals_dir / "apply.ok").write_text( + json.dumps({"expires_at_utc": expires_at.isoformat(), "ttl_minutes": 60}), + encoding="utf-8", + ) + + def supplied_readiness_inputs(*, candidate_id, now, **_kwargs): + return { + "schema_version": "promotion-readiness-inputs-blocker-v1", + "state": "ready_for_policy_review", + "reason": "promotion_readiness_inputs_supplied", + "promotion_candidate_id": candidate_id, + "missing_inputs": [], + "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", + "completed_at_utc": now.isoformat().replace("+00:00", "Z"), + } + + monkeypatch.setattr("nanobot.runtime.coordinator.supply_missing_promotion_readiness_inputs", supplied_readiness_inputs) + execute = AsyncMock(return_value="agent completed bounded work") + now = expires_at - timedelta(minutes=30) + + summary = asyncio.run( + run_self_evolving_cycle( + workspace=tmp_path, + tasks="check open tasks", + execute_turn=execute, + now=now, + ) + ) + + assert "PASS" in summary + runtime = load_runtime_state(tmp_path) + report = _read_json(runtime["report_path"]) + candidate_path = tmp_path / "state" / "promotions" / f"{report['promotion_candidate_id']}.json" + candidate = _read_json(candidate_path) + latest = _read_json(tmp_path / "state" / "promotions" / "latest.json") + report_index = _read_json(tmp_path / "state" / "outbox" / "report.index.json") + + assert report["review_status"] == "ready_for_policy_review" + assert report["decision"] == "ready_for_policy_review" + assert candidate["review_status"] == "ready_for_policy_review" + assert candidate["decision"] == "ready_for_policy_review" + assert candidate["decision_record"] == "pending_operator_review_packet" + assert candidate["accepted_record"] is None + assert candidate["readiness_checks"]["missing_inputs"] == [] + assert candidate["readiness_blocker"]["state"] == "ready_for_policy_review" + assert candidate["recommended_next_action"] == "ready_for_policy_review" + assert candidate["governance_packet"]["review_packet_status"] == "pending_operator_review" + assert candidate["governance_packet"]["decision"] == "ready_for_policy_review" + assert latest["review_status"] == "ready_for_policy_review" + assert latest["governance_packet"]["review_packet_status"] == "pending_operator_review" + assert report_index["promotion"]["review_status"] == "ready_for_policy_review" + assert report_index["promotion"]["decision"] == "ready_for_policy_review" + + +def test_load_runtime_state_classifies_ready_for_policy_review_as_pending_review(tmp_path): + state_root = tmp_path / "state" + promotions_dir = state_root / "promotions" + promotions_dir.mkdir(parents=True) + candidate_path = promotions_dir / "promotion-ready.json" + promotion = { + "schema_version": "promotion-record-v1", + "promotion_candidate_id": "promotion-ready", + "candidate_path": str(candidate_path), + "review_status": "ready_for_policy_review", + "decision": "ready_for_policy_review", + "decision_record": "pending_operator_review_packet", + "accepted_record": None, + "artifact_path": str(state_root / "improvements" / "materialized.json"), + "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", + "promotion_provenance": {"source_commit": "abc123", "build_recipe_hash": "recipe"}, + } + candidate_path.write_text(json.dumps(promotion), encoding="utf-8") + (promotions_dir / "latest.json").write_text(json.dumps(promotion), encoding="utf-8") + + runtime = load_runtime_state(tmp_path) + + replay = runtime["promotion_replay_readiness"] + assert runtime["review_status"] == "ready_for_policy_review" + assert runtime["decision"] == "ready_for_policy_review" + assert replay["state"] == "ready_for_policy_review" + assert replay["reason"] == "promotion_candidate_ready_for_policy_review" + assert replay["recommended_next_action"] == "ready_for_policy_review" + + def test_cycle_consumes_correlated_subagent_bridge_result_into_canonical_budget(tmp_path, monkeypatch): approvals_dir = tmp_path / "state" / "approvals" approvals_dir.mkdir(parents=True)