diff --git a/docs/source-read-windows.md b/docs/source-read-windows.md index a13e94b..a100881 100644 --- a/docs/source-read-windows.md +++ b/docs/source-read-windows.md @@ -55,3 +55,21 @@ Betroffen sind `test_model_loop_uses_project_tools_and_compact_previous_result` (Varianten True, unknown, upgrade), `test_started_project_loop_keeps_legacy_prompt_protocol` und `test_budgeted_evidence_and_portable_hybrid_resume`. Keine dieser bestehenden Grenzen wurde für diese Änderung gelockert. `git diff --check` ohne Befund. + +## Explizite Wiederaufnahme nach technischer Reparatur + +`CheckpointStore.advance(..., manual_retry=True)` erlaubt die vom Betreiber +bewusst angeforderte sofortige Wiederaufnahme eines mit `INVALID_LLM_RESPONSES` +blockierten Falls ausschließlich in dessen gespeicherten Fortsetzungszustand. +Ohne dieses ausdrückliche Argument gilt weiterhin `retry_not_before`. + +Die manuelle Wiederaufnahme darf keine Artefakte oder zusätzlichen Dateien +ändern. Sie setzt weder Budget noch fachliche/technische Untersuchungsstände +zurück. `manual_retry: true` wird im neuen unveränderlichen Lifecycle-Checkpoint +protokolliert. Andere Blockierungsgründe, insbesondere `AWAITING_TEST_FEEDBACK`, +werden dadurch nicht entsperrt. Idempotenz und Generationserwartung bleiben aktiv. + +65 lokale Tests für Checkpoints, Queue und asynchrone Tests bestanden. Bug17 +wurde auf ausdrücklichen Nutzerwunsch von Generation 47 auf 48 fortgesetzt; +Artefakthashes und Budgeterhalt sind im separaten Wiederaufnahmebericht gesichert. +Keine kostenpflichtige LLM-Ausführung durch den Entwicklungsagenten. diff --git a/docs/test-results/token-usage/bug17-bounded-full-20261020-manual-resume.json b/docs/test-results/token-usage/bug17-bounded-full-20261020-manual-resume.json new file mode 100644 index 0000000..1c2d54f --- /dev/null +++ b/docs/test-results/token-usage/bug17-bounded-full-20261020-manual-resume.json @@ -0,0 +1,21 @@ +{ + "format": "viano-manual-test-resume", + "date": "2026-10-20", + "run": "bug17-bounded-full-20261020", + "from_generation": 47, + "to_generation": 48, + "state": "INVESTIGATING_HYPOTHESES", + "manual_retry": true, + "prior_retry_not_before": "2026-10-20T20:00:32.238127+00:00", + "reason": "Explicit user-requested test continuation after fixed technical tool failure", + "unchanged_artifact_sha256": { + "investigation_state": "598aeb46462122bd559cee1a7cb162e03db7f0414decb0a8c880d229f240478d", + "budget_ledger": "7e4519804334e16b775a17d89257883fb14c5a05e9d132ba8791156dc9a79c8a", + "case_interpretation": "0fd3c2a1efe0f229592b3038b66db26e704f2fb1a03b7321ceb76450e3f98cb4", + "hypotheses": "30494a06ce373f4c497b3964524cee21bdf5350eac515f7a0022619879d125c8" + }, + "budgets_reset": false, + "invalid_responses_reset": false, + "paid_api_calls": 0, + "package_valid": true +} diff --git a/src/viano_bug_analysis/lifecycle/checkpoints.py b/src/viano_bug_analysis/lifecycle/checkpoints.py index c5e8cf7..25a3ee1 100644 --- a/src/viano_bug_analysis/lifecycle/checkpoints.py +++ b/src/viano_bug_analysis/lifecycle/checkpoints.py @@ -283,7 +283,7 @@ class CheckpointStore: reason: str | None = None, retry_not_before: str | None = None, details: dict | None = None, additions: dict[str, bytes] | None = None, artifacts: dict[str, str | None] | None = None, - expected_generation: int | None = None) -> Path: + expected_generation: int | None = None, manual_retry: bool = False) -> Path: try: if not isinstance(step_id, str) or not step_id.strip(): raise LifecycleError("INVALID_STEP", "A nonblank stable step_id is required") @@ -293,6 +293,10 @@ class CheckpointStore: raise LifecycleError("RESERVED_ARTIFACT", "Lifecycle checkpoint pointer is service-managed") if expected_generation is not None and (isinstance(expected_generation, bool) or not isinstance(expected_generation, int) or expected_generation < 0): raise LifecycleError("INVALID_GENERATION", "expected_generation must be a nonnegative integer") + if type(manual_retry) is not bool: + raise LifecycleError("INVALID_RETRY", "manual_retry must be boolean") + if manual_retry: + details = {**(details or {}), "manual_retry": True} payload = {"target": target, "reason": reason, "retry_not_before": retry_not_before, "details": details or {}, "artifacts": artifacts or {}, "additions": {name: hashlib.sha256(data).hexdigest() for name, data in (additions or {}).items()}} @@ -335,7 +339,11 @@ class CheckpointStore: and review.get('status')=='declined' and review.get('context_ref')==manifest['artifacts'].get('diagnosis_context')): resume=State.INVESTIGATING_HYPOTHESES check_transition(manifest["state"], target, resume) - if manifest["state"] == State.BLOCKED and not self.retry_due(manifest): + if manual_retry and (manifest["state"] != State.BLOCKED + or manifest["routing"]["reason"] != "INVALID_LLM_RESPONSES" + or target != resume or additions or artifacts): + raise LifecycleError("INVALID_RETRY", "Manual repair retry requires an unchanged technical-response block") + if manifest["state"] == State.BLOCKED and not manual_retry and not self.retry_due(manifest): raise LifecycleError("RETRY_NOT_DUE", "Retry timestamp has not been reached") routing = {"destination": "cases", "reason": None, "retry_not_before": None} if target == State.BLOCKED: diff --git a/tests/test_lifecycle_checkpoints.py b/tests/test_lifecycle_checkpoints.py index 4523bfc..c073e02 100644 --- a/tests/test_lifecycle_checkpoints.py +++ b/tests/test_lifecycle_checkpoints.py @@ -270,3 +270,39 @@ def test_empty_array_details_rejected(admitted): with pytest.raises(LifecycleError): store.advance("case-A", "stable-package", State.BUILDING_CASE, step_id="bad", details=[]) assert store.latest("case-A", "stable-package") == original + + +@pytest.mark.parametrize("reason", ["INVALID_LLM_RESPONSES", "RESEARCH_UNAVAILABLE", "AWAITING_TEST_FEEDBACK"]) +def test_manual_repair_retry_is_explicit_scoped_and_preserves_content(admitted,reason): + _, store, _ = admitted + blocked = store.advance("case-A", "stable-package", State.BLOCKED, step_id="repair-block", + reason=reason, retry_not_before="2099-01-01T00:00:00Z") + with pytest.raises(LifecycleError): + store.advance("case-A", "stable-package", State.VALIDATED, step_id="automatic-too-early") + if reason != "INVALID_LLM_RESPONSES": + with pytest.raises(LifecycleError): + store.advance("case-A", "stable-package", State.VALIDATED, step_id="not-repairable",manual_retry=True) + assert store.latest("case-A", "stable-package") == blocked + return + with PackageReader(blocked) as reader: + before = reader.manifest + resumed = store.advance("case-A", "stable-package", State.VALIDATED, step_id="operator-repaired",manual_retry=True) + assert store.advance("case-A", "stable-package", State.VALIDATED, step_id="operator-repaired",manual_retry=True) == resumed + with PackageReader(resumed) as reader: + assert reader.read_bytes("unknown") == b"keep" + assert reader.manifest["analysis_revision"] == before["analysis_revision"] + assert reader.manifest["artifacts"].keys() == before["artifacts"].keys() + assert reader.manifest["routing"]["retry_not_before"] is None + assert checkpoint_chain(reader)[0][1]["details"]["manual_retry"] is True + assert PackageValidator().validate(resumed).valid + + +@pytest.mark.parametrize("changes", [{"additions":{"other":b"no"}}, {"artifacts":{"case":"other"}}, {"manual_retry":1}]) +def test_manual_repair_retry_cannot_mutate_artifacts(admitted,changes): + _, store, _ = admitted + blocked = store.advance("case-A", "stable-package", State.BLOCKED, step_id="block-repair", + reason="INVALID_LLM_RESPONSES",retry_not_before="2099-01-01T00:00:00Z") + with pytest.raises(LifecycleError): + store.advance("case-A", "stable-package", State.VALIDATED, step_id="bad-repair", + **{**{"manual_retry":True},**changes}) + assert store.latest("case-A", "stable-package") == blocked