Manual picks: a way out, and a second slot

Two changes to the Steam Backlog Enforcer — a 4-day window to undo a manual pick, and support for up to three locked-in games at once. Commits eb1472a and 05e070c, 19 July 2026.

Background — what the enforcer does

The Steam Backlog Enforcer is a self-discipline tool. It picks one game from your Steam backlog, then makes it genuinely hard to play anything else: it uninstalls every other game, hides them from the Steam library, blocks the store, and runs a daemon that kills unauthorized game processes on sight. You finish the assigned game to 100% achievements, run done, and it hands you the next one.

Most of that machinery answers a single question — which game is allowed to exist right now? Before this change, the answer lived in one field of the persisted state:

@dataclass
class State:
    current_app_id: int | None = None
    current_game_name: str = ""
    finished_app_ids: list[int] = field(default_factory=list)
    ...

and every enforcement primitive took that one id as a parameter — uninstall_other_games(allowed_app_id), hide_other_games(owned, allowed_app_id), enforce_allowed_game(allowed_app_id), _guard_installed_games(allowed_app_id). Each one compared with == and removed everything else.

The manual pick, and its trap

Normally the enforcer chooses for you. pick-manual <app_id> lets you override that and name the game yourself — but because self-chosen commitments are the easy ones to wriggle out of, it was deliberately built as the strictest mode in the tool. Picking a game recorded three fields and slammed a door:

manual_pick_app_id: int | None
manual_pick_game_name: str
manual_pick_started_at: str   # ISO-8601 UTC

From then on, main() ran a check before dispatching any command:

def _enforce_manual_pick_lock(command: str, state: State) -> None:
    if not _is_manual_pick_locked(state):
        return
    if command in _MANUAL_LOCK_EXEMPT_COMMANDS:
        return
    _show_manual_pick_lock_message(state)
    sys.exit(1)

Only six commands survived that gate: done, check, status, enforce, setup, serve. The lock released on exactly two conditions — the picked game appearing in finished_app_ids, or 14 days elapsing.

Which is fine right up until you pick the wrong game. That is what prompted this change: a manual pick of Age of Empires II that turned out to be a mistake, with no in-app path out of it for two weeks. scan, pick, list, reset — all of them exited 1.

Intuition — one slot becomes a set

Two independent problems, and the second one is what makes the first one safe.

Problem 1: a commitment with no undo

A lock is only useful if it survives the moment you want to break it — that is the whole point. But there's a difference between "I don't feel like this game today" (the lock should absolutely win) and "I typed the wrong app id" or "this isn't the game I thought it was". The fix is a short window where the second reading is still plausible:

Lifetime of one manual pick

day 0
1
2
3
4
5
…
13
14 →
Days 0–4: graceabandon-pick <id> works. Advertised in the lock message.
Days 4–14: lockedCommand refuses and exits 1; it is dropped from the advertised command list.

Four days, and the window is deliberately awkward to use: you must pass the app id explicitly and it must match an active pick, so no one abandons a commitment by muscle memory. Abandoning also costs something — the game goes onto the existing 30-day skipped_until cooldown, so scan will not hand it back tomorrow.

Why reuse skipped_until? The state already had a skip_for_days(app_id, days) helper, used when you decline a freshly-picked game in cmd_done. An abandoned pick is the same idea with a longer fuse, so it reuses the mechanism rather than inventing a parallel one.

Problem 2: undo alone is an escape hatch

Here's the loop that a naive grace period opens: pick a game → abandon it → pick another → abandon that one. Each new pick stamps a fresh started_at, so the grace clock resets forever and the enforcer enforces nothing.

The chosen answer was not to forbid re-picking, but to make a second concurrent pick a first-class, capped feature. If you may legitimately have several games locked in, correcting a pick no longer requires a pick→abandon→pick cycle, and the cap is what bounds the whole thing. Config.max_manual_picks defaults to 3.

That turns the enforcer's central question from "which game?" into "which set of games?" — and that is the real substance of the diff:

An enforcement pass, before and after

Before — one allowed id

current_app_id = 1794680

KCD II1771300 · removed
Vampire Survivors1794680 · kept
↓
Anything elseuninstalled, hidden, killed
After — an allowed set

allowed_app_ids = {1771300, 1794680}

KCD II1771300 · kept
Vampire Survivors1794680 · kept
↓
Anything elseuninstalled, hidden, killed

One function now answers that question for the whole system:

def allowed_app_ids(state: State) -> set[int]:
    """Return every app id the enforcer must leave installed and visible."""
    return {app_id for app_id, _ in allowed_games(state)}

It is the union of the active manual picks and the current assignment. Every primitive that used to take one id now takes this set, and every call site passes allowed_app_ids(state). "Active" does real work here — a pick drops out of the set the moment its game is finished or its own 14-day deadline passes, so nothing has to be explicitly cleaned up.

Play with the rule

The widget below runs the actual new logic — _pick_is_active, the grace test, the cap, and the allowed set — reimplemented faithfully in JS. Advance the clock and watch picks fall out of the set on their own.

Enforcement sandbox

Library below is what's installed. Advance days, finish a game, or abandon a pick, and watch the allowed set and lock recompute.

The code, by concern

1. State: one slot becomes a list, without losing a live lock

The three manual_pick_* fields became a list of entries. The subtlety is that a lock was active on the author's own machine while this was being written — dropping the old fields would have silently released it. So they are still read, migrated once, and cleared:

def _migrate_legacy_manual_pick(self) -> None:
    """Fold a pre-multi-pick single manual pick into ``manual_picks``."""
    if self.manual_pick_app_id is None or self.manual_picks:
        return
    self.manual_picks = [{
        "app_id": self.manual_pick_app_id,
        "game_name": self.manual_pick_game_name,
        "started_at": self.manual_pick_started_at,
    }]
    self.manual_pick_app_id = None
    ...

Called from State.load(), so it happens in memory on read and persists on the next save. Entries are plain dicts rather than a dataclass because save() serialises self.__dict__ straight to JSON.

2. Predicates: "is it locked" becomes "is anything still active"

_pick_is_active holds the per-pick rule. Note the defensive branch — a corrupt timestamp keeps the pick active, because for an enforcement tool the safe answer to "I can't tell when this expires" is "it hasn't":

started_at = pick.get("started_at") or ""
if started_at:
    try:
        started = datetime.fromisoformat(started_at)
    except ValueError:
        return True
    if datetime.now(timezone.utc) >= started + timedelta(days=MANUAL_LOCK_DAYS):
        return False
return True

Everything else is a thin layer on top: active_manual_picks filters the list, is_manual_pick_locked is bool(active_manual_picks(state)), and the grace helpers take an app_id so each pick has its own window.

3. Plumbing the set through the enforcement primitives

Four functions changed shape from allowed_app_id: int | None to allowed_app_ids: set[int], swapping equality for membership. The interesting one is the library hider, because its comparison lives inside JavaScript injected into Steam over the DevTools Protocol:

- allowed_js = str(allowed_app_id) if allowed_app_id is not None else "null"
- extra_ids = sorted(aid for aid in owned_app_ids if aid != allowed_app_id)
+ allowed_json = json.dumps(sorted(allowed_app_ids))
+ extra_ids = sorted(aid for aid in owned_app_ids if aid not in allowed_app_ids)

- const allowed = {allowed_js};
+ const allowed = new Set({allowed_json});
      ...
-     .filter(id => id !== allowed)
+     .filter(id => !allowed.has(id))
      ...
- if (allowed !== null) await collectionStore.SetAppsAsHidden([allowed], false);
+ if (allowed.size > 0) await collectionStore.SetAppsAsHidden([...allowed], false);

4. The CLI: the lock now permits the commands that manage it

Two commands had to become reachable while locked, which sounds like weakening the lock but isn't:

_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset({
    "done", "check", "status", "enforce", "setup", "serve",
    "abandon-pick",
    # Allowed so a second game can be locked in alongside the first; the
    # cap inside cmd_pick_manual is what stops this being a way out.
    "pick-manual",
})

abandon-pick is obvious — it is the only way out, so the pre-dispatch gate would otherwise make it dead on arrival. pick-manual is the subtle one: the gate no longer limits it, so the limit moved into the command itself, where _report_pick_slots exits 1 once manual_pick_slots_left hits zero. The lock check answers "may I run this command", the cap answers "may I add another game" — two different questions that used to share one mechanism.

The post-pick cascade moved from a single id to the whole set, which is what stops a second pick from tearing down the first:

allowed = allowed_app_ids(state)
if config.uninstall_other_games:
    count = uninstall_other_games(allowed)
for app_id, name in allowed_games(state):
    if not is_game_installed(app_id):
        install_game(app_id, name, config.steam_id, use_steam_protocol=True)
hidden, skipped = try_hide_other_games(owned_ids, allowed)

5. A crash found along the way

Picking a replacement game surfaced an unrelated bug: SteamUpdateInProgressError (raised when a game update defers a Steam restart) was only caught in the enforce loop. The five interactive call sites called hide_other_games bare, so an undrivable Steam aborted the command with a traceback — after the pick had already been saved. A small wrapper turns that class of failure into a return value:

def try_hide_other_games(owned_app_ids, allowed_app_ids) -> tuple[int, str | None]:
    try:
        return hide_other_games(owned_app_ids, allowed_app_ids), None
    except SteamUnavailableError as exc:
        return 0, str(exc)

All six sites now report Library hiding: skipped (<reason>) and carry on.

The deployment trap. The enforce daemon runs from the working tree (WorkingDirectory=/home/kuhy/steam-backlog-enforcer) but only reloads on restart. During development, the still-running pre-upgrade daemon knew only current_app_id — which the second pick had just moved — so within one 3-second loop it uninstalled the other pick as "unauthorized" and deleted an in-progress download. The code was right; the running process was stale. sudo systemctl restart steam-backlog-enforcer is now part of deploying any change to this logic.

Quiz

Five questions on the reasoning behind the change. Click an option to check it.

Score: 0 / 0