The enforce loop now waits on an inotify watch of log.json's directory
(_file_wake, ctypes, no new dependency) instead of a plain sleep, so a
credited workout moves the gaming budget within the tick. Earner answers
(workout, LeetCode, reading) are memoised with the source file's
(inode, mtime_ns, size) stamp, so a rewritten file re-asks at once instead
of after the 60 s TTL. Any inotify setup or read failure warns and falls
back to the plain sleep; the stamp still catches the change next tick.
Tests: conftest stubs the wake factory and points workout_log_path at a
tmp path, so no test ever watches the real screen-locker tree.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
earned_time 0.4.0 only changes shutdown resolution and adds the workout
ledger reader; every gaming_minutes this daemon reads is identical for every
input, so no code change here. Also brings aiohttp 3.14.4 and types-requests
2.33.0.20261006 to newest stable, which the dependency-freshness gate needs
before any commit to these manifests can land.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG
The slowest workflow took ~75 s: pip installs (~20 s each), a serial
1764-test pytest run (43 s), and one pre-commit job running mypy and pylint
back to back (49 s).
- Tests: setup-uv with a cached wheel store, pytest -n auto.
- pre-commit: mypy / pylint / everything else as three parallel matrix
jobs, each with its own hook-env cache; installs only pre-commit plus the
two imports the system hooks need instead of all of requirements.txt.
No check was removed from CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
Requiring 100% locked the user onto one game for weeks, which proved
ineffective. The assignment and every manual pick now release as soon as one
achievement is unlocked after the game was assigned (Steam unlock_time vs the
pick's started_at / State.current_assigned_at).
- finished_app_ids still means 100% complete; a game released below 100% gets
released_at plus a 7-day cooldown and returns to the pool.
- Next pick: least recently assigned first, then shortest HLTB time.
- Releasing never evicts: released/expired picks stay installed and playable
until the user explicitly chooses another game (new pick-manual, or
accepting the next assignment); with no replacement the current game stays.
- Tampering detection judges a released game by unlocks after its release.
- The daemon reloads every State field each tick so its sweep save cannot
overwrite cooldowns or assignment times written by the CLI.
- State.load backfills assignment times once and persists them; load-time
migrations moved to _state_migrations.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
require_snapshot_games / snapshot_game_name (_snapshot), apply_configured_pick (_actions) and hide_others_and_report (library_hider) replace copies spread across the CLI, MCP and stats paths. Tests retargeted to the new owners; test_main_pick_manual split to stay under 250 lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D55DvFEHiwcPN59kwPqwiU
SDL3's HIDAPI lg4ff driver advertises an effect-status query it cannot
answer; BeamNG.drive 0.39 native treats the failed query as a dead effect
and the wheel goes limp (12k "Failed to get joystick3 FFB effect status"
lines per session). Steam's environment on this box is exactly this
allowlist, so the hint has to live here, not in /etc/environment.
The narrow hint only demotes the Logitech wheel to the kernel driver;
gamepads keep HIDAPI (rumble, LEDs over Bluetooth).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
Steam's srt-logger runs inside steam-backlog-enforcer.service's cgroup
and mirrors Steam's console into journald on its own — the DEVNULL
stderr the spawn already uses does not reach it, and the env carried
SRT_LOG_TO_JOURNAL=0, which is not the variable it reads. One boot on
2026-09-12 wrote 481k "joystick FFB effect status" lines (99% of the
journal) and evicted two weeks of history from a 300M journal, along
with the evidence for that night's curfew failure.
- desktop_env_args(): SRT_LOGGER_USE_JOURNAL=0, the variable srt-logger
actually honours. Steam keeps its own logs/console-linux.txt.
- Unit: LogFilterPatterns for the two known flood patterns plus a
per-unit rate limit, so a future Steam that ignores the variable still
cannot take the journal with it.
The unit file is deployed and daemon-reloaded; the running service is
not restarted, because Steam is its child and a restart would kill a
live game. The env fix takes effect on the next enforcer start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mLnUeoJ3mUwGRTowTUH6d
The 250-line split left function-sized copies behind: the confidence-only
batch fetch was the search half of _fetch_batch verbatim (now
_search_batch, and _SearchCtx carries one _HLTBExtras instead of two of
its dicts); the workout and LeetCode bonuses each had the same loopback
GET and answer cache (now _status_api); the poll-count refetch lived in
both _cmd_done and _polls_reporting (now refetch_poll_counts); the
candidate-selection prelude in both scanning and _scanning_assign; the
/etc/hosts rewrite loop in both block and unblock (now
rewrite_hosts_lines with a per-line rule); the timed HLTB fetch and its
rate line; and the whitelist constants each module only half used.
_hltb_cached's dead facade constants go too. Tests patch where the code
now lives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
The pinned howlongtobeatpy (1.0.23) takes a user agent in
send_website_request_getcode and dropped parse_all_scripts; the call
still matched 1.0.20, which only worked because the deployed env was
behind its own pin. Now on the pinned API, and the env upgraded.
Three modules carried the same executable/path constant block since the
250-line split; it is _store_tools. Four carried the same "restore the
hours a refetch lost" loop; it is restore_prior_hours. Both detached
launches go through one helper that returns the process. Thirteen
try/except/else blocks are flat, dataclass fields come from
dataclasses.fields, and the rest are one-line fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
hltb, _cmd_done, _whitelist and _actions each kept a module __getattr__
that served names moved out in the 250-line split, via importlib to
dodge the resulting import cycle. pylint cannot see through that, and
graded every such import as no-name-in-module (26 of them). The 32
import statements now name the real module; with nothing left going
through the facades, they are gone.
Also the pylint hook on 4.0.8 with the source-only scope config, the
three implicit-booleaness disables the reference repos carry, the
http.server do_GET admission, and the third-party pins the hook was
missing (aiohttp, websockets, howlongtobeatpy). Gate still 8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
Also bumps mcp 2.2.0, ruff 0.16.7, types-requests 2.33.0.20260906 and, in
web/, react 19.3.0, vitest 5.0.0 (+ coverage-v8), eslint 10.10.0,
typescript-eslint 8.70.0, vite 8.3.0 and the small ones, so the freshness
gate lets this through. Web lint/build/tests/coverage (100 %) unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
Keeps the dependency-freshness gate green; unrelated to the engagement
gating removal in an earlier commit. Not installed in this dev
environment (pip is externally-managed here), so this is a manifest-only
bump — CI installs it fresh and is the strict authority per the
dependency-freshness rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps the dependency-freshness gate green; unrelated to the engagement
gating removal in the previous commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A qualifying game process running is now sufficient on its own to bill
the budget. Previously a tick only counted if the screen wasn't locked,
the game's window was X11-focused, and input hadn't gone idle for 5
minutes — with an idle-grace refund on top. That leniency is gone
entirely: no focus check, no idle check, no screen-lock check, no
refund. Deletes the engagement-probing modules (_playtime_engagement,
_engagement_probes, _engagement_types, _x_focus, _x_probe, _screen_hold,
_controller_input) and their config fields, simplifies the audit log
and budget-view/web session schema accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
report_leetcode_incident appends to leetcode_bonus_incidents.log at the repo
root -- a real artifact you read to find out why an hour went missing.
test_budget_resolve.py drives the fail-closed path without stubbing it, so the
first full run put two fabricated incidents into the live file.
The file is gitignored, so nothing would ever have failed to warn about this.
It was caught by reading the file after deploying, not by any test.
Redirected with an autouse fixture rather than per-test patches, for the same
reason _isolate_filesystem is autouse: enumerating the tests that need it is
the version of this that breaks the next time someone adds one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.
It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.
The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.
LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.
Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.
resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.
Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.
Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
A server started as `python -c "from ..._web_server import serve; serve(
port=8000)"` ran for five hours after the code beneath it changed, reporting
an 8h gaming budget while the daemon enforced 6h and had already cut gaming
off. Two separate holes let that happen.
The staleness check only ran from cmd_serve, so a server started any other
way never met it. And is_our_server matched argv containing
"steam_backlog_enforcer.main", which a `python -c` process does not have --
so that server could not even be identified as ours: `run.sh serve` would
have refused the port and named it as a stranger rather than replacing it.
_serve_stale puts the check inside the server, where no launch path can skip
it. Every route, static included, returns 503 once any package .py is newer
than this process, because a fresh page fetching stale numbers is the same
lie with extra steps. The answer is memoised for five seconds so it does not
stat the package per request; that bounds the staleness window instead of
leaving it open forever.
Refusing is only half a fix -- a process that 503s forever is honest and
useless -- so a refused request also stands the server down. The new
steam-backlog-enforcer-web.service supplies the other half with
Restart=always: it dies on stale code and returns on current code with
nobody having to remember anything. A user unit, not a system one; it serves
read-only views and needs no privilege. install.sh installs and enables it
rather than printing instructions.
is_our_server now substring-matches the joined argv, so run.sh serve,
python -m ...main serve and a bare python -c are all recognised.
newest_py_since moves into _serve_stale and _serve_guard delegates to it.
Both needed the same scan, and two copies would have been free to disagree
about whether a server is current -- which is the entire failure being fixed.
Verified end to end: healthy 200, touch _budget_view.py, next request 503,
systemd replaces the process, back to 200 reporting 6.0h and blocked=True.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
8h is now earned rather than given. A gaming day with a counted workout
logged in screen-locker gets daily_gaming_seconds; a day without gets the
new unearned_gaming_seconds floor. Skipping a workout costs two hours the
same day, instead of nothing until the weekly lock fires.
The fact comes from screen-locker's GET /api/status, which reports only
workout_today -- the hour values live here. Neither repo can drift from the
other about what a workout day is: that flag is derived from
count_day_credits, the same rule the locker enforces with.
Every way of not getting an answer applies the floor, logged at warning with
the URL and the concrete error. Failing open would let the coupling be
defeated by stopping one user service, and a silent 8h would be
indistinguishable from an earned one.
resolve_budget_seconds is called from rules_for rather than at each call
site, because the daemon holds one Config for its whole life while
_budget_view reloads it per HTTP request. Resolved at only one of those and
the web UI would report a budget the daemon was not enforcing -- the exact
silent divergence this codebase is built to prevent.
A budget that rises mid-day now releases the block properly.
_release_after_raise clears blocked_at, which otherwise only roll_over reset
and which would leave the UI reporting "blocked" while gaming worked, and
clears warned_seconds, which is keyed on seconds *remaining* and would
otherwise let the extra two hours arrive with no warnings at all.
Known and accepted (2026-08-29): the budget can DROP mid-day if the resolved
answer changes under a day already in progress, re-pricing time already
spent and cutting off on the next tick with no warning. The module docstring
says so plainly rather than claiming a monotonicity the code does not
implement; the fix, if it ever bites, is a persisted granted_budget_seconds.
Tests get an autouse stub for the lookup. Without it every test that builds
rules makes a real loopback request, and the suite would pass in the morning
and fail in the evening depending on whether the machine's owner had trained.
test_budget_view builds its RULES at import time, before fixtures apply, so
it stubs the resolver explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
CI failed at 99.98%: _serve_guard.py:125-126, the `except OSError: continue`
in the fd scan, was never executed there. The test planted one unreadable
entry beside a real socket symlink and asserted the socket was still found —
which only exercises the skip if iterdir() happens to yield the broken entry
first. It does locally; on CI the socket came first, the function returned
early, and the skip never ran.
Now split in two: one case with only unreadable entries, which hits the skip
whatever the order, and one that keeps the original assertion that a broken
fd does not make the scan abandon the process.
The private guard helpers are also imported directly rather than reached
through the module, which drops 20-odd pylint protected-access warnings and
takes this file from 5.75 to passing on its own. The repo's pylint gate scores
an aggregate against --fail-under=8.0, so a file that is only ever committed
alongside others can hide below it; this one no longer needs the company.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
The serve rework was sitting untracked: a /proc-based port guard, the
start-or-defer decision, and a stale-bundle rebuild. It had no tests, and it
changed cmd_serve(_config, state) to cmd_serve(argv) without updating the one
test that called it, so the suite was red and coverage was 251 lines short.
Tests, all against fakes:
- _fake_proc builds a synthetic /proc. _NET_TCP is derived from _PROC at
import time, so patch_proc redirects both; patching _PROC alone would have
had these tests parse the host's real process table.
- _serve_guard is split across two test files to stay under the 250-line cap.
os is replaced wholesale in every termination test, so no signal can reach a
real process even if the guard's self-protection were removed.
- _web_build mocks subprocess entirely and redirects every path constant into
tmp_path. _no_subprocess now blocks _web_build.subprocess.run too: an
unguarded test would fire a real `npm run build`, rewriting web/dist and
perturbing frontend_is_stale() for every later test in the run.
Source changes, both required rather than incidental:
- _serve_guard's wildcard match was a `# noqa: S104` on a "0.0.0.0" literal,
which the no-noqa hook rejects outright. It is now the all-zero hex column
/proc actually prints, so the dotted-quad literal never appears.
- main/__init__.py's serve dispatch arm was itself uncovered; it now has a
test asserting the raw argv passes through unparsed.
Also drops the document.hidden guard from useBudgetPoll: it skipped the very
first load, so the budget panel sat on "Reading budget state…" forever in a
background tab, which is every tab under browser automation.
Verified: 1712 python tests, 100% branch coverage; 128 vitest, 100%. The
guard's /proc parser was checked read-only against a real listener on :8000
and resolved the same pid as `ss`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz