steam-backlog-enforcer/steam_backlog_enforcer/_playtime.py
Krzysztof kuhy Rudnicki 26a2aabbc7 leisure Phase 2 (dormant): bill, warn and kill on the leisure budget from go-live
Dormant until earned_time.leisure_active(gaming day) -- the switch file
written by `leisure-watch go-live`. From that day:

- enforced `seconds` are billed by the shadow rule (lock pause, fail-closed,
  once per wall clock); budget from plain resolve(); warnings and the cutoff
  say "Leisure".
- at 0, beside the unchanged Steam path, _leisure_kill SIGTERMs (SIGKILL 30 s
  later, per process) every focus/media pid in now.json except browsers,
  every failclosed browser pid, and -- with now.json untrusted -- every
  browser, media player and wsg-grabber reviewer. Root/system, lockers and
  the enforcer's own chain are never signalled. Kills go to budget.jsonl.
- tests: an autouse fixture redirects both switch readers, so no test reads
  /etc/leisure-watch/leisure_from; existing tests unchanged.

Re-pins earned-time to v0.15.0 (leisure_active).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 16:40:49 +02:00

243 lines
9.2 KiB
Python

"""Daily gaming-time budget: accounting, day rollover and cutoff policy.
Tracks how long the user has spent gaming during the current *gaming day* and,
once the budget is exhausted, drives the cutoff sequence implemented in
:mod:`steam_backlog_enforcer._playtime_block`.
Three design points that are load-bearing:
**The gaming day starts at 06:00 local, not midnight.** A session that runs
past midnight belongs to the day it started in; a midnight boundary would hand
out a fresh 8-hour budget in the middle of a late session. Rollover is checked
lazily on every tick against a stored day key rather than scheduled, so it
survives suspend, reboot and daemon restarts with no timer of its own.
**Elapsed time is measured from wall-clock deltas, clamped.** Counting "one
tick = one interval" silently under-counts whenever the loop is slow and
over-counts nothing; measuring the raw delta over-counts wildly across a
suspend. Clamping each delta to a small multiple of the tick interval makes
suspends, missed ticks, daemon restarts and NTP steps all behave.
**Launchers are matched by cmdline, not by ``comm``.** ``/usr/bin/lutris`` is a
``#!/usr/bin/env python3`` script, so the kernel records its ``comm`` as
``python3``. The scan (``leisure_watch.procscan``, shared with the leisure
watcher) therefore reads argv as well as ``comm``.
**Shadow leisure time rides the same tick** (:mod:`_shadow`, contract C4),
from the same stamp and clamp, before ``accumulate``; under a total block it
is not billed. From the go-live day (:mod:`_leisure_mode`, Phase 2) its rule
bills the enforced count too, and at 0 :mod:`_leisure_kill` ends leisure apps.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
import logging
import time
from typing import TYPE_CHECKING
from leisure_watch.procscan import qualifying_pids
from steam_backlog_enforcer import config as config_mod
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._leisure_mode import Tick, bill_tick
from steam_backlog_enforcer._playtime_block import mounted_targets
from steam_backlog_enforcer._playtime_budget import roll_over
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_sustain_block,
_warn,
reconcile_for,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeRules,
PlaytimeState,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._shadow import observe_shadow, shadow_accumulate
from steam_backlog_enforcer._total_block import is_total_block_active
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_session import PlaytimeSession
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
def playtime_tick(
config: Config,
*,
interval: float,
session: PlaytimeSession,
demo: bool = False,
) -> None:
"""Account for this tick's gaming time and enforce the daily budget.
Called first in every enforce-loop iteration, before any other guard, so
that time is still counted (and a 06:00 release still happens) in the
situations where the rest of the loop early-returns.
A qualifying process running is sufficient on its own — there is no
engagement gate on top of it.
Args:
config: Loaded user configuration.
interval: Nominal seconds between enforce-loop ticks.
session: Cross-tick logging state.
demo: Whether this is a short-budget demo run.
"""
now = datetime.now(UTC).astimezone()
rules = rules_for(config, demo=demo)
state = roll_over(_state_or_recover(rules, now=now), day_key=gaming_day_key(now))
# Recorded every tick, total-block branch included: it is what the next
# 06:00 roll-over measures this day's leftover against. A high-water mark,
# because earners can reset at midnight -- before the gaming day ends --
# and a 05:59 dip must not erase the evening's earned-but-unspent time.
state = replace(
state,
budget_seconds=max(state.budget_seconds, rules.budget_seconds),
shadow_budget_seconds=max(
state.shadow_budget_seconds, rules.shadow_budget_seconds
),
)
if is_total_block_active():
# The total block runs `pacman -R steam` every tick; our bind mounts
# would make that fail EBUSY. It is strictly stronger — get out of its
# way, and stop accruing against a budget nobody can spend.
reconcile_for(rules, should_block=False)
save_state(state, demo=demo)
session.history.observe(state, demo=demo)
return
owners = qualifying_pids(rules, config_mod.CONFIG_FILE)
pids = set(owners)
shadow = observe_shadow(owners, stamp=now.timestamp())
state = shadow_accumulate(
state, stamp=now.timestamp(), interval=interval, verdict=shadow
)
tick = Tick(now=now, interval=interval, owners=owners, verdict=shadow)
state, rules = bill_tick(state, rules, tick)
state = _policy(state, rules, now=now)
if rules.leisure:
session.leisure.enforce(state, rules, verdict=shadow, stamp=now.timestamp())
session.journal.observe(pids, state, rules=rules, now_monotonic=time.monotonic())
session.shadow.observe(shadow, state)
save_state(state, demo=demo)
session.history.observe(state, demo=demo)
def _state_or_recover(rules: PlaytimeRules, *, now: datetime) -> PlaytimeState:
"""Load state, synthesising a fail-closed record if it is unusable.
If the state file is gone but the binaries are still masked, the only safe
reading is that the file was deleted to lift the block. Synthesising an
exhausted budget keeps it in force; the normal 06:00 rollover then lifts it
with no special case.
Args:
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
Loaded or synthesised state.
"""
loaded = load_state(demo=rules.demo)
if loaded is not None:
return loaded
stamp = now.timestamp()
fresh = PlaytimeState(day_key=gaming_day_key(now), last_tick_at=stamp)
if not mounted_targets():
return fresh
logger.warning("Playtime state missing while blocked; failing closed.")
return replace(fresh, seconds=rules.budget_seconds, blocked_at=stamp)
def _release_after_raise(
state: PlaytimeState,
rules: PlaytimeRules,
) -> PlaytimeState:
"""Clear block bookkeeping after the day's budget rose past what was spent.
Only ``roll_over`` at the 06:00 boundary ever reset ``blocked_at`` before
the workout coupling existed, because the budget could not move within a
day. It can now: logging a workout adds 2h and solving a LeetCode problem
adds 1h, and the mounts come down on the very next tick. The trigger is
``state.seconds < rules.budget_seconds`` -- the number alone, never which
earner moved it -- so both raises release identically. Two things have to
come down with them.
``blocked_at`` — otherwise ``is_blocked()`` stays true and ``build_today``
keeps reporting ``"blocked": true``, so the UI would claim you were blocked
while gaming worked fine. That is the exact "state disagrees with reality"
failure the mount-visibility check exists to catch.
``warned_seconds`` — the thresholds are keyed on seconds *remaining*, so a
raise makes remaining climb back through values already recorded as fired.
Left alone, the extra hour or two would arrive with no warnings at all.
Args:
state: Accounting state for the current gaming day.
rules: Policy for this tick, holding the newly raised budget.
Returns:
The state with block bookkeeping cleared.
"""
logger.warning(
"Gaming budget rose to %.1fh past the %.0fs already spent — releasing "
"the block and re-arming warnings for the remaining time.",
rules.budget_seconds / 3600,
state.seconds,
)
return replace(state, blocked_at=0.0, warned_seconds=[])
def _policy(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Apply warnings, the cutoff, and block upkeep to *state*.
Args:
state: Accounting state after this tick's accumulation.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
The updated state.
"""
if not rules.enforcement:
# A kill switch must still release: leaving live mounts behind would
# make "disabled" mean "permanently blocked".
reconcile_for(rules, should_block=False)
return state
if state.seconds < rules.budget_seconds:
reconcile_for(rules, should_block=False)
if state.is_blocked():
state = _release_after_raise(state, rules)
# A raise that never reached the cutoff lifts remaining back above
# thresholds already fired; re-arm them or the new end comes unwarned.
remaining = rules.budget_seconds - state.seconds
rearmed = [t for t in state.warned_seconds if t >= remaining]
if rearmed != state.warned_seconds:
state = replace(state, warned_seconds=rearmed)
return _warn(state, rules)
if not state.is_blocked():
return _begin_cutoff(state, rules, now=now)
return _sustain_block(state, rules, now=now)