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

@@ -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."""

View File

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