From bf87a097c31b2d9a34a66ba9655005c4c792885a Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 29 Jul 2026 17:21:31 +0900 Subject: [PATCH] feat: add manual pause functionality to prevent automation during user play sessions --- .gitignore | 1 + CLAUDE.md | 19 +++++++++++++++++- ba_auto/config.py | 17 ++++++++++++++++ ba_cron_run.sh | 13 +++++++++--- ba_daily.py | 50 ++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 93 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index a10db92..15afd8c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ graphify-out/cache/ __pycache__/ *.pyc scratchpad/ +/PAUSED diff --git a/CLAUDE.md b/CLAUDE.md index 8c06142..99e4e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,10 +236,27 @@ As of 2026-07-16, `ba_dailies.sh` presets run unattended on a schedule via cron 0 1,5,9,13,17,21 * * * /home/nik/repo/ba-auto-daily/ba_cron_run.sh q4h ``` -Times are nik-gpu's local system time (`Asia/Tokyo`/JST). `ba_cron_run.sh` (repo root, run directly from the checkout -- see "Deployment model" above) wraps `ba_dailies.sh ` (resolved as the sibling file next to its own `BASH_SOURCE`) in a shared, non-blocking `flock` (`~/ba_logs/ba_dailies.lock`) so overlapping fire times can never launch concurrent `xdotool`/`scrot` sessions against the same game window, and logs each run to its own `~/ba_logs/.log` (`daily.log`/`q4h.log`, never interleaved). It contains no game-automation logic -- pure locking/logging plumbing, same category as `ba_dailies.sh` itself. To check whether a scheduled run actually worked: `grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log` -- every run logs one of `OK`/`FAILED (exit N)`/`SKIPPED (previous run still in progress)`, deliberately distinct outcomes (a lock-skip and a genuinely crashed task both exit 1, so the wrapper checks lock acquisition as its own explicit step rather than folding it into the wrapped command's exit code). +Times are nik-gpu's local system time (`Asia/Tokyo`/JST). `ba_cron_run.sh` (repo root, run directly from the checkout -- see "Deployment model" above) wraps `ba_dailies.sh ` (resolved as the sibling file next to its own `BASH_SOURCE`) in a shared, non-blocking `flock` (`~/ba_logs/ba_dailies.lock`) so overlapping fire times can never launch concurrent `xdotool`/`scrot` sessions against the same game window, and logs each run to its own `~/ba_logs/.log` (`daily.log`/`q4h.log`, never interleaved). It contains no game-automation logic -- pure locking/logging plumbing, same category as `ba_dailies.sh` itself. To check whether a scheduled run actually worked: `grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log` -- every run logs one of `OK`/`FAILED (exit N)`/`SKIPPED (previous run still in progress)`/`SKIPPED (paused)` (see "Manual pause switch" below), deliberately distinct outcomes (a lock-skip and a genuinely crashed task both exit 1, so the wrapper checks lock acquisition as its own explicit step rather than folding it into the wrapped command's exit code). This means: resources (AP, tickets, currency) may already be spent by the time a session starts, independent of anything done in that session -- check `~/ba_logs/` before assuming a given game state is from manual testing. The `daily`/`q4h` preset definitions themselves live in `ba_daily.py`'s `PRESETS` dict (see `plan.md`'s Phase 18 follow-ups for the full writeup), not here -- keep this section in sync if the schedule or preset names change. +### Manual pause switch + +Added 2026-07-29, per explicit user request: a manual switch so a scheduled cron fire (or a forgotten manual invocation) can never fight the user for control of the game mid-play-session, e.g. right after a game update where the user wants to play manually before automation touches the account again. + +A single flag file, `config.PAUSE_FLAG_PATH` (`PAUSED` at the repo root), is checked once at the very top of `ba_daily.py`'s `main()`, before any task dispatch -- this covers the default flow, a named preset, and a single manually-typed task alike, not just cron fires, per explicit user direction that manual invocations should be blocked too, not only scheduled ones. + +- `./ba_dailies.sh pause` -- creates the flag file. +- `./ba_dailies.sh resume` -- removes it. +- `./ba_dailies.sh pause_status` -- prints `PAUSED` or `ACTIVE`. +- Or just `touch`/`rm` the file directly (e.g. over `ssh nik-gpu`) if that's faster than the CLI -- the check is pure file-existence, nothing else reads or interprets its contents. + +These three are meta-commands (`ba_daily.py`'s `META_COMMANDS` dict), not real automation tasks -- they never touch the driver/game at all, and are dispatched before the pause check itself so they stay reachable even while paused. + +While paused, `ba_daily.py` exits with a distinct `PAUSE_EXIT_CODE` (75) rather than the generic failure code, printing a message to stderr. `ba_cron_run.sh` checks specifically for that exit code and logs `SKIPPED $PRESET -- paused` instead of `FAILED (exit 75)`, matching the same lock-skip-vs-crash distinction that section's own log convention already relies on. + +`PAUSED` lives inside the repo checkout itself (not alongside the cron lock file in `~/ba_logs/`), per explicit user request for something quick to reach on the same host without needing a separate path -- but it's gitignored (`/PAUSED` in `.gitignore`). It is host-specific mutable runtime state, the same category as the cron lock file, not something that should ever be committed or pushed between machines -- the rsync command in "Deployment model" has no `--delete`, so pushing repo updates from `nik-macbookair` can never delete or overwrite a `PAUSED` flag that only exists on `nik-gpu`. + ## Runtime dependencies on nik-gpu These are host-level dependencies. Confirm they exist before assuming a bug is in the project code. diff --git a/ba_auto/config.py b/ba_auto/config.py index e3669ff..0f01a20 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -19,6 +19,23 @@ ASSET_DIR = os.path.join(PROJECT_ROOT, "assets") SCRATCHPAD_DIR = os.path.join(PROJECT_ROOT, "scratchpad") os.makedirs(SCRATCHPAD_DIR, exist_ok=True) +# Manual "I'm playing right now" pause switch. ba_daily.py's main() checks +# this once, before dispatching anything (default flow, a preset, or a +# single manually-typed task) -- both a scheduled cron fire and a manual +# `./ba_dailies.sh ` are equally blocked while this file exists, so a +# forgotten cron run can never fight the user for control of the game mid- +# session. Presence = paused, absence = active; toggle via +# `./ba_dailies.sh pause` / `./ba_dailies.sh resume`, or just touch/rm this +# path directly (e.g. over ssh) if that's faster than the CLI. Lives inside +# the repo checkout itself, per explicit user request, rather than +# alongside the cron lock file in ~/ba_logs/ -- but it's gitignored (see +# .gitignore's `/PAUSED`): this is host-specific mutable runtime state, the +# same category as that lock file, not something that should ever be +# committed or pushed between machines by the rsync in CLAUDE.md's +# "Deployment model" (that command has no --delete, so it won't touch a +# copy that exists only on nik-gpu either way). +PAUSE_FLAG_PATH = os.path.join(PROJECT_ROOT, "PAUSED") + # Refined from (1726, 60): that coordinate sat on the edge of the icon's # hitbox and intermittently missed during live testing. MAILBOX_ICON = (1732, 50) diff --git a/ba_cron_run.sh b/ba_cron_run.sh index 01cb986..3ff9ae9 100755 --- a/ba_cron_run.sh +++ b/ba_cron_run.sh @@ -11,14 +11,19 @@ # a scheduled fire time can never silently run late. # # Logs to ~/ba_logs/.log -- one file per preset, so "daily" and -# "q4h" never interleave. Every line is one of three explicitly-tagged -# outcomes (OK / FAILED / SKIPPED) specifically so a real failure can be -# found with a single command, e.g.: +# "q4h" never interleave. Every line is one of four explicitly-tagged +# outcomes (OK / FAILED / SKIPPED -- previous run still in progress / +# SKIPPED -- paused) specifically so a real failure can be found with a +# single command, e.g.: # grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log # A bare exit code alone can't do this: flock and a genuinely crashed task # both exit 1, so lock acquisition is checked as its own explicit step # (via an fd-based flock, not the "flock -n LOCKFILE COMMAND" form used # originally) rather than folded into the wrapped command's own exit code. +# Likewise, ba_daily.py's manual pause switch (config.PAUSE_FLAG_PATH, +# toggled via `./ba_dailies.sh pause`/`resume`) exits with the distinct +# PAUSE_EXIT_CODE (75) below rather than the generic failure code, so a +# deliberate pause never gets logged as a crash. set -uo pipefail # Resolves its own directory (same pattern ba_dailies.sh itself already @@ -47,6 +52,8 @@ mkdir -p "$LOG_DIR" status=$? if [ "$status" -eq 0 ]; then echo "=== $(date -Iseconds) finished $PRESET OK (exit 0) ===" + elif [ "$status" -eq 75 ]; then + echo "=== $(date -Iseconds) SKIPPED $PRESET -- paused ===" else echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ===" fi diff --git a/ba_daily.py b/ba_daily.py index b101630..632e0f8 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Python CLI entry point: ba_dailies.sh -> ba_daily.py -> ba_auto/tasks/*.py""" +import os import sys from ba_auto import config, driver, navigation @@ -194,17 +195,59 @@ def _run_sequence(names): print("All done.") +# Distinct from the generic error exit code (1) so ba_cron_run.sh can log a +# paused run as "SKIPPED", matching the existing lock-skip convention +# instead of a misleading "FAILED (exit N)" -- see that file's own handling +# of this exact value. +PAUSE_EXIT_CODE = 75 + + +def _pause(): + open(config.PAUSE_FLAG_PATH, "w").close() + print(f"Paused -- automated and manual runs will be skipped until 'resume'. Flag: {config.PAUSE_FLAG_PATH}") + + +def _resume(): + if os.path.exists(config.PAUSE_FLAG_PATH): + os.remove(config.PAUSE_FLAG_PATH) + print("Resumed.") + else: + print("Not paused.") + + +def _pause_status(): + print("PAUSED" if os.path.exists(config.PAUSE_FLAG_PATH) else "ACTIVE") + + +# Meta-commands: control the pause flag itself rather than the game, so +# they must stay reachable even while paused -- handled in main() before +# the pause check, not added to TASKS (no driver/game interaction at all). +META_COMMANDS = { + "pause": _pause, + "resume": _resume, + "pause_status": _pause_status, +} + + def main(argv): args = argv[1:] if args and args[0] == "--list-commands": # Machine-readable command list for shell completion (see # completions/ba_dailies.bash) -- kept as a thin read of TASKS/ - # PRESETS themselves so the completion list can never drift from - # what's actually dispatchable. - print(" ".join(list(TASKS.keys()) + list(PRESETS.keys()))) + # PRESETS/META_COMMANDS themselves so the completion list can never + # drift from what's actually dispatchable. + print(" ".join(list(TASKS.keys()) + list(PRESETS.keys()) + list(META_COMMANDS.keys()))) return 0 + if args and args[0] in META_COMMANDS: + META_COMMANDS[args[0]]() + return 0 + + if os.path.exists(config.PAUSE_FLAG_PATH): + print(f"Paused (flag present at {config.PAUSE_FLAG_PATH}) -- skipping this run. Run './ba_dailies.sh resume' to re-enable.", file=sys.stderr) + return PAUSE_EXIT_CODE + if not args: _run_sequence(DEFAULT_ORDER) return 0 @@ -219,6 +262,7 @@ def main(argv): print(f"Unknown phase: {command}", file=sys.stderr) print(f"Valid phases: {' '.join(TASKS.keys())}", file=sys.stderr) print(f"Valid presets: {' '.join(PRESETS.keys())}", file=sys.stderr) + print(f"Valid meta-commands: {' '.join(META_COMMANDS.keys())}", file=sys.stderr) return 1 _run_task(command)