From 482c83d8f7cb46746931d437d5f705722192843e Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Sun, 20 Sep 2026 20:56:24 +0200 Subject: [PATCH] fix(notaus): preserve park markers on recovery --- CHANGELOG.md | 9 +++ library/notaus_wake_check.py | 18 +++++- library/token_budget_guard.py | 45 +++++++++++--- tests/test_notaus_state_preservation.py | 82 +++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 tests/test_notaus_state_preservation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1f7e3..a5080b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ - **AI Agent Context & Discovery Parity:** Updated `llms.txt` with timestamp `2026-09-22`, updated test baseline, and cross-references to `NOTICE` and `THIRD_PARTY_LICENSES.md`. - **Extended Contract Verification Suite:** Expanded `tests/test_metadata.py` to assert house files existence, NOTICE integrity, SBOM structure, CI workflow hardening, .gitignore coverage, and legal disclaimers. +### Notaus-Aufhebung verlustfrei (2026-09-20) +- **Notaus-Aufhebung verlustfrei:** `token_budget_guard.py` übernimmt die + Parkvermerke `paused_goals` und `paused_agents` bei automatischen + State-Übergängen und meldet eine ausstehende Aufhebung. Der + `notaus_wake_check.py` macht erhaltene Parkkreise beim nächsten Wecken + sichtbar, statt sie still zu übergehen. +- **Tests:** Regressionstests decken den automatischen Rücklauf mit + pausierten Goals/Workern und die Wake-Meldung ab. + ## [0.2.0] - 2026-08-25 (Ergänzt 2026-09-10) ### Pfad B Discoverability, Mermaid-Architektur & Metadaten (2026-09-10) diff --git a/library/notaus_wake_check.py b/library/notaus_wake_check.py index 48e3316..2457665 100644 --- a/library/notaus_wake_check.py +++ b/library/notaus_wake_check.py @@ -37,7 +37,6 @@ import sys import time - STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "state") SPARMODUS_STATE_PATH = os.path.join(STATE_DIR, "sparmodus_state.json") @@ -61,6 +60,20 @@ def _read_json(path: str) -> dict: return {} +def _pending_resume_hint(state: dict) -> str: + """Make preserved park markers visible at the next wake check.""" + parts = [] + goals = state.get("paused_goals") + agents = state.get("paused_agents") + if isinstance(goals, list): + parts.append(f"{len(goals)} pausierte Goals") + if isinstance(agents, list): + parts.append(f"{len(agents)} geparkte Worker") + if not parts: + return "" + return " Aufhebung ausstehend (" + ", ".join(parts) + "); zuerst den vollständigen Parkkreis reaktivieren." + + def main() -> int: _ensure_utf8_stdio() raw = sys.stdin.read() @@ -79,6 +92,7 @@ def main() -> int: reason = state.get("reason", "") set_at = state.get("set_at") prior_mode = state.get("prior_mode") or "off" + pending_hint = _pending_resume_hint(state) known_wake_target = wake_at if isinstance(wake_at, (int, float)) else resets_at @@ -91,6 +105,7 @@ def main() -> int: "Guthaben wieder da, Abschnitt 'Aufheben' aus Skill notaus " f"ausfuehren (Rueckkehr zu Modus '{prior_mode}') statt den Zustand " "zu ignorieren." + + pending_hint ) else: wake_hint = ( @@ -104,6 +119,7 @@ def main() -> int: "GEPARKT bleiben: keine neue Arbeit beginnen, laufende Delegationen " "nicht erweitern, nur den Zustand pruefen und ggf. auf das naechste " "Aufwachfenster verweisen. Details: Skill 'notaus'." + + pending_hint ) print(json.dumps({ diff --git a/library/token_budget_guard.py b/library/token_budget_guard.py index 955be85..8723de5 100644 --- a/library/token_budget_guard.py +++ b/library/token_budget_guard.py @@ -56,7 +56,6 @@ import time from typing import Any - STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "state") HOOKS_DIR = os.path.join(os.path.expanduser("~"), ".claude", "hooks") BRIDGE_PATH = os.path.join(STATE_DIR, "token_budget.json") @@ -182,6 +181,34 @@ def _default_sparmodus_state() -> dict: } +def _preserve_park_fields(previous: dict, current: dict) -> dict: + """Carry the notaus ownership markers across an automatic state reset. + + ``paused_goals`` and ``paused_agents`` are written by ``notaus_tools`` + before the budget hook performs its automatic return. Rebuilding the + state dictionary must not erase those markers: the resume step needs the + exact set that this host parked. + """ + for key in ("paused_goals", "paused_agents"): + if key in previous: + current[key] = previous[key] + return current + + +def _pending_resume_hint(state: dict) -> str: + """Describe preserved park markers without guessing their contents.""" + parts = [] + goals = state.get("paused_goals") + agents = state.get("paused_agents") + if isinstance(goals, list): + parts.append(f"{len(goals)} pausierte Goals") + if isinstance(agents, list): + parts.append(f"{len(agents)} geparkte Worker") + if not parts: + return "" + return " Aufhebung ausstehend (" + ", ".join(parts) + "); zuerst den vollständigen Parkkreis reaktivieren." + + def _compute_transition(mode: str, stage: int) -> str | None: """Liefert den neuen Modus, oder None wenn keine Modusaenderung ansteht.""" if mode == "off": @@ -277,9 +304,10 @@ def main() -> int: # Aktive Ueberwachung: der Hook darf den Modus selbst schalten. target = _compute_transition(mode, stage) if target is not None: + previous_sparmodus_state = sparmodus_state if target == "__RECOVER__": recover_to = sparmodus_state.get("prior_mode") or "off" - sparmodus_state = { + sparmodus_state = _preserve_park_fields(previous_sparmodus_state, { "mode": recover_to, "prior_mode": None, "set_at": time.strftime("%Y-%m-%d %H:%M"), @@ -288,7 +316,8 @@ def main() -> int: "wake_at": None, "resets_at": None, "leader_only": None, - } + }) + pending_hint = _pending_resume_hint(sparmodus_state) message = ( f"AUTOMATISCHE RUECKKEHR aus NOTAUS (5h-Stand {used_pct:.0f}%, " f"erholt) -> neuer Modus '{recover_to}'. Fuehre JETZT Abschnitt " @@ -296,12 +325,12 @@ def main() -> int: "bereits gesetzt, aber USMC-RESUME lesen+fortschreiben, " "erreichbare Teammates per SendMessage reaktivieren und " "Normalbetrieb an den User melden bleiben Aufgabe dieser " - "Session." + f"Session.{pending_hint}" ) elif target == "notaus": wake_at = float(resets_at) if isinstance(resets_at, (int, float)) and resets_at else time.time() + FIVE_HOUR_SECONDS leader_only = not (isinstance(resets_at, (int, float)) and resets_at) - sparmodus_state = { + sparmodus_state = _preserve_park_fields(previous_sparmodus_state, { "mode": "notaus", "prior_mode": mode, "set_at": time.strftime("%Y-%m-%d %H:%M"), @@ -310,7 +339,7 @@ def main() -> int: "wake_at": wake_at, "resets_at": resets_at if isinstance(resets_at, (int, float)) else None, "leader_only": leader_only, - } + }) wake_hint = ( f"Aufwachen um {time.strftime('%Y-%m-%d %H:%M', time.localtime(wake_at))} lokal" if not leader_only @@ -324,7 +353,9 @@ def main() -> int: "State bereits gesetzt." ) elif target == "off": - sparmodus_state = _default_sparmodus_state() + sparmodus_state = _preserve_park_fields( + previous_sparmodus_state, _default_sparmodus_state() + ) sparmodus_state["set_at"] = time.strftime("%Y-%m-%d %H:%M") sparmodus_state["set_by"] = "hook" sparmodus_state["reason"] = f"Automatisches Ende manual-spar, 5h-Stand {used_pct:.0f}% (erholt)" diff --git a/tests/test_notaus_state_preservation.py b/tests/test_notaus_state_preservation.py new file mode 100644 index 0000000..469592a --- /dev/null +++ b/tests/test_notaus_state_preservation.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def _load_library_module(name: str, filename: str): + path = REPO / "library" / filename + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _write(path: Path, value: dict) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + + +def test_budget_recovery_preserves_park_markers_and_reports_pending_resume(tmp_path, monkeypatch, capsys): + guard = _load_library_module("token_budget_guard_test", "token_budget_guard.py") + bridge = tmp_path / "token_budget.json" + state = tmp_path / "sparmodus_state.json" + guard_state = tmp_path / "token_budget_guard_state.json" + config = tmp_path / "token_budget_config.json" + + _write(bridge, {"written_at": time.time(), "five_hour": {"used_percentage": 0}}) + _write( + state, + { + "mode": "notaus", + "prior_mode": "off", + "paused_goals": ["goal-1", "goal-2"], + "paused_agents": [{"name": "worker-1"}], + }, + ) + _write(guard_state, {}) + _write(config, {}) + + monkeypatch.setattr(guard, "BRIDGE_PATH", str(bridge)) + monkeypatch.setattr(guard, "SPARMODUS_STATE_PATH", str(state)) + monkeypatch.setattr(guard, "GUARD_STATE_PATH", str(guard_state)) + monkeypatch.setattr(guard, "CONFIG_PATH", str(config)) + monkeypatch.setattr(sys, "stdin", io.StringIO('{"session_id":"test-session"}')) + + assert guard.main() == 0 + + written = json.loads(state.read_text(encoding="utf-8")) + assert written["mode"] == "off" + assert written["paused_goals"] == ["goal-1", "goal-2"] + assert written["paused_agents"] == [{"name": "worker-1"}] + assert "Aufhebung ausstehend" in capsys.readouterr().out + + +def test_wake_check_reports_preserved_park_markers(tmp_path, monkeypatch, capsys): + wake = _load_library_module("notaus_wake_check_test", "notaus_wake_check.py") + state = tmp_path / "sparmodus_state.json" + _write( + state, + { + "mode": "notaus", + "wake_at": 0, + "prior_mode": "off", + "paused_goals": ["goal-1"] * 18, + "paused_agents": [{"name": "worker-1"}] * 3, + }, + ) + monkeypatch.setattr(wake, "SPARMODUS_STATE_PATH", str(state)) + monkeypatch.setattr(sys, "stdin", io.StringIO("{}")) + + assert wake.main() == 0 + + output = capsys.readouterr().out + assert "Aufhebung ausstehend" in output + assert "18 pausierte Goals" in output + assert "3 geparkte Worker" in output