mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
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:
38
CHANGELOG.md
38
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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
170
tests/verify_governor.py
Normal file
170
tests/verify_governor.py
Normal 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)
|
||||
Reference in New Issue
Block a user