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

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