fix(governor): disabled means disabled, and timed commands stop accruing heat

enabled gated only Governor.check(). The heat model, the heartbeat
piggyback and the list_devices footer all ran unconditionally, so a
governor that was switched off could still report heat, still print
COOLDOWN, and still drive the phone's ACTIVE->COOLDOWN transition while
blocking nothing. tick() now returns early when disabled and clears
carried-over state; to_dict() reports enabled:false with zeroed values;
the list_devices footer is omitted.

Separately, current_intensity was only ever cleared by record_stop(),
whose three callers are an explicit stop, a phone emergency stop and the
dead man's switch. A pattern ending on its own duration told the server
nothing, so heat integrated at the last commanded intensity against idle
hardware forever. Commands now pass their duration through to
record_command() and the intensity expires when it lapses. duration:0
still means run-until-stop. For escalate the expiry is duration +
hold_seconds, and only when hold_seconds > 0, per the hold contract.

Adds tests/verify_governor.py: 22 offline checks over the disabled path,
timed-command expiry, and the enabled-path invariants. Existing
verify_server.py still passes 23/23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aletheia
2026-07-27 20:16:24 +02:00
parent b8a52e9658
commit 63d7176ee6
4 changed files with 290 additions and 13 deletions

View File

@@ -1,5 +1,43 @@
# Changelog # 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 ## v1.1 — 2026-07-07
This release brings the remote server up to date with everything that This release brings the remote server up to date with everything that

View File

@@ -45,6 +45,7 @@ class GovernorState:
"""Per-user heat tracking state.""" """Per-user heat tracking state."""
heat: float = 0.0 # 0..100 heat: float = 0.0 # 0..100
current_intensity: float = 0.0 # last known intensity (0..1) 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 in_cooldown: bool = False
cooldown_entered_at: float = 0.0 cooldown_entered_at: float = 0.0
cooldown_count: int = 0 # total cooldowns this session cooldown_count: int = 0 # total cooldowns this session
@@ -57,10 +58,28 @@ class GovernorState:
dt = now - self.last_tick dt = now - self.last_tick
self.last_tick = now 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: if dt <= 0 or dt > 10:
# Sanity: skip huge jumps (e.g., system clock change) # Sanity: skip huge jumps (e.g., system clock change)
return 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: if self.in_cooldown:
# During cooldown: always dissipate, intensity is forced to 0 # During cooldown: always dissipate, intensity is forced to 0
self.heat -= self.cfg.cool_rate * dt self.heat -= self.cfg.cool_rate * dt
@@ -91,18 +110,29 @@ class GovernorState:
self.cooldown_entered_at = now self.cooldown_entered_at = now
self.cooldown_count += 1 self.cooldown_count += 1
self.current_intensity = 0.0 self.current_intensity = 0.0
self.intensity_expires_at = 0.0
log.warning( log.warning(
f"Cooldown triggered: heat={self.heat:.1f}% " f"Cooldown triggered: heat={self.heat:.1f}% "
f"(cooldown #{self.cooldown_count})" f"(cooldown #{self.cooldown_count})"
) )
def record_command(self, intensity: float) -> None: def record_command(self, intensity: float, duration: float = 0.0) -> None:
"""Record that a command was sent at a given intensity.""" """
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.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: def record_stop(self) -> None:
"""Record that devices were stopped.""" """Record that devices were stopped."""
self.current_intensity = 0.0 self.current_intensity = 0.0
self.intensity_expires_at = 0.0
@property @property
def cooldown_remaining(self) -> int: def cooldown_remaining(self) -> int:
@@ -142,7 +172,21 @@ class GovernorState:
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Serialize for piggybacking on heartbeat pings.""" """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 { return {
"enabled": True,
"heat_pct": round(self.heat, 1), "heat_pct": round(self.heat, 1),
"in_cooldown": self.in_cooldown, "in_cooldown": self.in_cooldown,
"cooldown_remaining": self.cooldown_remaining, "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}, " 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}") 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.""" """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: def record_stop(self, user_id: str) -> None:
"""Record that devices were stopped.""" """Record that devices were stopped."""

View File

@@ -67,12 +67,18 @@ def _register_tool(name: str, description: str, params: dict, required: list[str
# Helper # 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. Route a command to the current user's phone and return result text.
If intensity > 0, the governor checks if the command is allowed If intensity > 0, the governor checks if the command is allowed
and records the intensity for heat tracking. 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() 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 # Record intensity for heat tracking
if ack.success and intensity > 0: 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": elif ack.success and cmd_type == "stop":
governor.record_stop(user_id) governor.record_stop(user_id)
@@ -152,10 +158,14 @@ async def list_devices(**kwargs) -> str:
+ (f" | {notes}" if notes else "") + (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) gov = governor.get_state(user_id)
heat = gov["heat_pct"] 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)") lines.append(f"\n⚠ Governor: COOLDOWN ({gov['cooldown_remaining']}s remaining)")
elif heat > 0: elif heat > 0:
lines.append(f"\nGovernor: {heat:.0f}% heat" lines.append(f"\nGovernor: {heat:.0f}% heat"
@@ -212,14 +222,15 @@ def _make_output_handler(output_type: OutputType):
feature_index: Optional[int] = None, **kw feature_index: Optional[int] = None, **kw
) -> str: ) -> str:
clamped = max(0.0, min(1.0, float(intensity))) clamped = max(0.0, min(1.0, float(intensity)))
dur = max(0.0, float(duration))
cmd = DeviceCommand( cmd = DeviceCommand(
action=output_type, action=output_type,
device=device, device=device,
intensity=clamped, intensity=clamped,
duration=max(0.0, float(duration)), duration=dur,
feature_index=feature_index, feature_index=feature_index,
) )
return await _send(cmd.model_dump(), intensity=clamped) return await _send(cmd.model_dump(), intensity=clamped, duration=dur)
return handler return handler
@@ -355,16 +366,28 @@ def _make_pattern_handler(pattern_name: str):
**kw, **kw,
) -> str: ) -> str:
clamped = max(0.0, min(1.0, float(intensity))) clamped = max(0.0, min(1.0, float(intensity)))
dur = max(0.0, float(duration))
hold = max(0.0, float(hold_seconds))
cmd = PatternCommand( cmd = PatternCommand(
pattern=pattern_name, pattern=pattern_name,
output_type=OutputType(output_type), output_type=OutputType(output_type),
device=device, device=device,
intensity=clamped, intensity=clamped,
duration=max(0.0, float(duration)), duration=dur,
hold_seconds=max(0.0, float(hold_seconds)), hold_seconds=hold,
feature_index=feature_index, 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 return handler

170
tests/verify_governor.py Normal file
View File

@@ -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)