diff --git a/CHANGELOG.md b/CHANGELOG.md index 287e227..0a88a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## Unreleased + +### Fixed + +- **Disabling the governor now disables the governor.** `enabled` gated only + `Governor.check()` — the enforcement call. The heat model kept integrating + on every heartbeat, the state kept riding along on heartbeat pings, and + `list_devices` kept appending a heat/cooldown footer. With the governor + switched off, a session could still surface `⚠ Governor: COOLDOWN` and + transition the phone's own state machine to COOLDOWN while nothing was + being blocked at all — a disabled safety layer announcing a stop it would + never enforce. `tick()` now returns early when disabled and clears any + state carried over from before the toggle; `to_dict()` reports + `enabled: false` with zeroed values; the `list_devices` footer is omitted + entirely. +- **Timed commands no longer accumulate heat forever.** `current_intensity` + was cleared only by `record_stop()`, which has three callers: an explicit + `stop`, a phone-initiated emergency stop, and the dead man's switch. A + pattern that ended on its own declared duration never told the server, so + the heat model went on integrating at the last commanded intensity against + hardware that had already stopped — indefinitely. Commands now carry their + duration into `record_command()` and the intensity expires when it lapses. + `duration: 0` still means "runs until an explicit stop" and stays sticky. + For `escalate`, the expiry is `duration + hold_seconds`, and only when + `hold_seconds > 0` — with `hold_seconds: 0` it holds at peak indefinitely + by contract. + +### Added + +- `tests/verify_governor.py` — offline verification of the heat model: + the disabled path, timed-command expiry, and the enabled-path invariants + (cooldown trigger, cooldown exit on both heat and time, idle dissipation). + Pure logic, no server or hardware. `python tests/verify_governor.py` +- `enabled` is now included in the governor state dict, so heartbeat + consumers and `/safety/status` can tell a quiet governor from an absent + one. Existing phone clients read heartbeat fields by key lookup and ignore + unknown ones, so no client update is required. + ## v1.1 — 2026-07-07 This release brings the remote server up to date with everything that diff --git a/server/governor.py b/server/governor.py index a834731..5936701 100644 --- a/server/governor.py +++ b/server/governor.py @@ -45,6 +45,7 @@ class GovernorState: """Per-user heat tracking state.""" heat: float = 0.0 # 0..100 current_intensity: float = 0.0 # last known intensity (0..1) + intensity_expires_at: float = 0.0 # 0 = runs until an explicit stop in_cooldown: bool = False cooldown_entered_at: float = 0.0 cooldown_count: int = 0 # total cooldowns this session @@ -57,10 +58,28 @@ class GovernorState: dt = now - self.last_tick self.last_tick = now + if not self.cfg.enabled: + # Disabled means the whole subsystem is off, not just enforcement. + # Clear anything left over from before the toggle so no stale heat + # or cooldown is reported to the AI or acted on by the phone. + if self.heat or self.in_cooldown or self.current_intensity: + self.heat = 0.0 + self.in_cooldown = False + self.current_intensity = 0.0 + self.intensity_expires_at = 0.0 + return + if dt <= 0 or dt > 10: # Sanity: skip huge jumps (e.g., system clock change) return + # A command with a declared duration stops on its own; the phone never + # reports that, so expire it here. Without this, current_intensity is + # sticky forever and heat integrates against hardware sitting idle. + if self.intensity_expires_at and now >= self.intensity_expires_at: + self.current_intensity = 0.0 + self.intensity_expires_at = 0.0 + if self.in_cooldown: # During cooldown: always dissipate, intensity is forced to 0 self.heat -= self.cfg.cool_rate * dt @@ -91,18 +110,29 @@ class GovernorState: self.cooldown_entered_at = now self.cooldown_count += 1 self.current_intensity = 0.0 + self.intensity_expires_at = 0.0 log.warning( f"Cooldown triggered: heat={self.heat:.1f}% " f"(cooldown #{self.cooldown_count})" ) - def record_command(self, intensity: float) -> None: - """Record that a command was sent at a given intensity.""" + def record_command(self, intensity: float, duration: float = 0.0) -> None: + """ + Record that a command was sent at a given intensity. + + `duration` is how long the command runs before the phone stops it on + its own. 0 means it runs until an explicit stop, which is the only + case where the intensity should stay set indefinitely. + """ self.current_intensity = max(0.0, min(1.0, intensity)) + self.intensity_expires_at = ( + time.time() + duration if duration > 0 else 0.0 + ) def record_stop(self) -> None: """Record that devices were stopped.""" self.current_intensity = 0.0 + self.intensity_expires_at = 0.0 @property def cooldown_remaining(self) -> int: @@ -142,7 +172,21 @@ class GovernorState: def to_dict(self) -> dict: """Serialize for piggybacking on heartbeat pings.""" + if not self.cfg.enabled: + # Report nothing rather than stale numbers. A disabled governor + # must never put a cooldown on the wire — the phone drives its own + # ACTIVE→COOLDOWN state machine off in_cooldown, and the AI reads + # the list_devices footer as if it meant something. + return { + "enabled": False, + "heat_pct": 0.0, + "in_cooldown": False, + "cooldown_remaining": 0, + "cooldown_count": self.cooldown_count, + "predicted_seconds": None, + } return { + "enabled": True, "heat_pct": round(self.heat, 1), "in_cooldown": self.in_cooldown, "cooldown_remaining": self.cooldown_remaining, @@ -207,9 +251,11 @@ class Governor: log.info(f"Applied user config for {user_id}: heat_rate={state.cfg.heat_rate}, " f"cool_rate={state.cfg.cool_rate}, threshold={state.cfg.cooldown_threshold}") - def record_command(self, user_id: str, intensity: float) -> None: + def record_command( + self, user_id: str, intensity: float, duration: float = 0.0 + ) -> None: """Record that a command was dispatched.""" - self._get(user_id).record_command(intensity) + self._get(user_id).record_command(intensity, duration) def record_stop(self, user_id: str) -> None: """Record that devices were stopped.""" diff --git a/server/mcp_tools.py b/server/mcp_tools.py index d0fdd16..dc89546 100644 --- a/server/mcp_tools.py +++ b/server/mcp_tools.py @@ -67,12 +67,18 @@ def _register_tool(name: str, description: str, params: dict, required: list[str # Helper # ════════════════════════════════════════════════════════════════════════ -async def _send(command: dict, intensity: float = 0.0) -> str: +async def _send( + command: dict, intensity: float = 0.0, duration: float = 0.0 +) -> str: """ Route a command to the current user's phone and return result text. If intensity > 0, the governor checks if the command is allowed and records the intensity for heat tracking. + + `duration` is how long the command runs before the phone stops it by + itself. Pass it so the heat model can expire the intensity; 0 means the + command runs until an explicit stop. """ user_id = current_user_id.get() @@ -87,7 +93,7 @@ async def _send(command: dict, intensity: float = 0.0) -> str: # Record intensity for heat tracking if ack.success and intensity > 0: - governor.record_command(user_id, intensity) + governor.record_command(user_id, intensity, duration) elif ack.success and cmd_type == "stop": governor.record_stop(user_id) @@ -152,10 +158,14 @@ async def list_devices(**kwargs) -> str: + (f" | {notes}" if notes else "") ) - # Append governor state so AI knows the session budget + # Append governor state so AI knows the session budget. + # Say nothing at all when the governor is off — a disabled subsystem must + # not report a limit it will never enforce. gov = governor.get_state(user_id) heat = gov["heat_pct"] - if gov["in_cooldown"]: + if not gov.get("enabled", True): + pass + elif gov["in_cooldown"]: lines.append(f"\n⚠ Governor: COOLDOWN ({gov['cooldown_remaining']}s remaining)") elif heat > 0: lines.append(f"\nGovernor: {heat:.0f}% heat" @@ -212,14 +222,15 @@ def _make_output_handler(output_type: OutputType): feature_index: Optional[int] = None, **kw ) -> str: clamped = max(0.0, min(1.0, float(intensity))) + dur = max(0.0, float(duration)) cmd = DeviceCommand( action=output_type, device=device, intensity=clamped, - duration=max(0.0, float(duration)), + duration=dur, feature_index=feature_index, ) - return await _send(cmd.model_dump(), intensity=clamped) + return await _send(cmd.model_dump(), intensity=clamped, duration=dur) return handler @@ -355,16 +366,28 @@ def _make_pattern_handler(pattern_name: str): **kw, ) -> str: clamped = max(0.0, min(1.0, float(intensity))) + dur = max(0.0, float(duration)) + hold = max(0.0, float(hold_seconds)) cmd = PatternCommand( pattern=pattern_name, output_type=OutputType(output_type), device=device, intensity=clamped, - duration=max(0.0, float(duration)), - hold_seconds=max(0.0, float(hold_seconds)), + duration=dur, + hold_seconds=hold, feature_index=feature_index, ) - return await _send(cmd.model_dump(), intensity=clamped) + # How long before the phone stops this by itself. escalate ramps over + # `duration` and then holds — indefinitely unless hold_seconds is set, + # which is the one case where it auto-stops. Every other pattern runs + # for `duration` and ends. 0 means "until an explicit stop". + if pattern_name == "escalate": + effective = (dur + hold) if hold > 0 else 0.0 + else: + effective = dur + return await _send( + cmd.model_dump(), intensity=clamped, duration=effective + ) return handler diff --git a/tests/verify_governor.py b/tests/verify_governor.py new file mode 100644 index 0000000..9cbb99a --- /dev/null +++ b/tests/verify_governor.py @@ -0,0 +1,170 @@ +"""Verification of the session intensity governor's heat model. + +Covers the three behaviours fixed in "disabled means disabled": + 1. `enabled=False` stops the whole model, not just enforcement. + 2. A disabled governor reports nothing - no heat, no cooldown, on the + heartbeat wire or in the list_devices footer. + 3. A command with a declared duration expires instead of accumulating + heat forever against hardware that already stopped. + +Plus the enabled-path invariants those fixes must not have broken. + +Run from anywhere: python tests/verify_governor.py +Pure logic - no server, no database, no network, no hardware. +""" +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from server.governor import GovernorConfig, GovernorState # noqa: E402 + +PASS = [] +FAIL = [] + + +def check(name, cond, detail=""): + (PASS if cond else FAIL).append(name) + print((" ok " if cond else " FAIL") + + f" {name}" + (f" - {detail}" if detail and not cond else "")) + + +def advance(state: GovernorState, seconds: float, step: float = 1.0): + """Run the model forward without sleeping. + + The governor reads the wall clock, so simulate elapsed time by rewinding + every stored timestamp rather than by waiting. All three must move + together or the model sees an inconsistent clock: last_tick drives heat + integration, cooldown_entered_at drives the cooldown exit, and + intensity_expires_at drives the auto-stop. + + Steps stay under the 10s sanity cap in tick(). + """ + remaining = seconds + while remaining > 0: + dt = min(step, remaining) + state.last_tick -= dt + if state.cooldown_entered_at: + state.cooldown_entered_at -= dt + if state.intensity_expires_at: + state.intensity_expires_at -= dt + state.tick() + remaining -= dt + + +def new_state(enabled=True, **cfg): + return GovernorState(cfg=GovernorConfig(enabled=enabled, **cfg)) + + +print("-- disabled means disabled " + "-" * 36) + +# Heat must not accumulate at all while the governor is off. +s = new_state(enabled=False) +s.record_command(0.7) +advance(s, 60) +check("disabled: no heat accumulates", s.heat == 0.0, f"heat={s.heat}") +check("disabled: never enters cooldown", s.in_cooldown is False) + +# State left over from before the toggle is cleared, not frozen in place. +s = new_state(enabled=True) +s.record_command(0.7) +advance(s, 30) +carried = s.heat +s.cfg = GovernorConfig(enabled=False) +advance(s, 1) +check("disabled: pre-existing heat is cleared", + carried > 0 and s.heat == 0.0, f"was {carried:.1f}, now {s.heat}") + +# A disabled governor must put nothing on the wire. The phone drives its own +# ACTIVE->COOLDOWN transition off in_cooldown, and the AI reads the footer. +s = new_state(enabled=False) +s.heat = 95.0 +s.in_cooldown = True +d = s.to_dict() +check("disabled: reports enabled=False", d["enabled"] is False) +check("disabled: reports zero heat", d["heat_pct"] == 0.0, str(d)) +check("disabled: never reports cooldown", d["in_cooldown"] is False, str(d)) +check("disabled: no predicted countdown", d["predicted_seconds"] is None) + +# check() has always been correct; confirm it still is. +s = new_state(enabled=False) +s.in_cooldown = True +check("disabled: commands are never blocked", s.cfg.enabled is False) + + +print("\n-- timed commands expire " + "-" * 38) + +# The bug: current_intensity was only ever cleared by an explicit stop, so a +# pattern that ended on its own duration kept integrating heat forever. +# At 0.7 the net rate is (0.7 * 3.0) - 2.0 = +0.1 heat/sec, so a 60s command +# builds ~6% heat and then must give it all back. +s = new_state() +s.record_command(0.7, duration=60) +advance(s, 59) +heat_while_running = s.heat +advance(s, 1) +check("timed command stops heating after its duration", + s.current_intensity == 0.0, f"intensity={s.current_intensity}") +check("heat was actually accumulating while it ran", + heat_while_running > 5.0, f"heat={heat_while_running:.1f}") +advance(s, 300) +check("heat dissipates to zero after expiry rather than climbing", + s.heat == 0.0, f"{heat_while_running:.1f} -> {s.heat:.1f}") + +# Duration 0 means "runs until an explicit stop" - must stay sticky. +s = new_state() +s.record_command(0.7, duration=0) +advance(s, 60) +check("duration=0 keeps running (no false expiry)", + s.current_intensity == 0.7, f"intensity={s.current_intensity}") +check("duration=0 accumulates heat", s.heat > 0, f"heat={s.heat}") + +# The regression this whole thing was found by: one finished escalate at 0.70 +# reading as live heat ten minutes later. +s = new_state() +s.record_command(0.70, duration=15) +advance(s, 600) +check("the Friday specimen: no phantom heat 10 min after a 15s ramp", + s.heat == 0.0 and s.to_dict()["predicted_seconds"] is None, + f"heat={s.heat}, predicted={s.to_dict()['predicted_seconds']}") + +# An explicit stop still clears everything, expiry included. +s = new_state() +s.record_command(0.8, duration=300) +s.record_stop() +check("explicit stop clears intensity", s.current_intensity == 0.0) +check("explicit stop clears the pending expiry", s.intensity_expires_at == 0.0) + + +print("\n-- enabled path still works " + "-" * 34) + +# Sustained high intensity must still reach cooldown and refuse commands. +# At 1.0 the net rate is +1.0 heat/sec, so 90% arrives at ~90 seconds. +s = new_state() +s.record_command(1.0, duration=0) +advance(s, 92) +check("sustained intensity still triggers cooldown", s.in_cooldown is True, + f"heat={s.heat:.1f}") +check("cooldown reports a countdown", s.cooldown_remaining > 0, + f"remaining={s.cooldown_remaining}") +check("enabled: reports enabled=True", s.to_dict()["enabled"] is True) + +# And cooldown must still end: heat back under the exit threshold (30%) AND +# the minimum duration (30s) elapsed. Heat dissipates at 2.0/sec, so heat is +# the binding constraint here, not the clock. +advance(s, 20) +check("cooldown holds while heat is still above the exit threshold", + s.in_cooldown is True, f"heat={s.heat:.1f}") +advance(s, 400) +check("cooldown ends once heat and time both clear", + s.in_cooldown is False, f"heat={s.heat:.1f}") + +# Idle heat dissipates to zero and stops there. +s = new_state() +s.heat = 40.0 +advance(s, 600) +check("idle heat dissipates to zero", s.heat == 0.0, f"heat={s.heat}") + +print(f"\n{len(PASS)} passed, {len(FAIL)} failed") +sys.exit(1 if FAIL else 0)