feat: add manual pause functionality to prevent automation during user play sessions

This commit is contained in:
Nik Afiq 2026-07-29 17:21:31 +09:00
parent 4ddd0d7fba
commit bf87a097c3
5 changed files with 93 additions and 7 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ graphify-out/cache/
__pycache__/ __pycache__/
*.pyc *.pyc
scratchpad/ scratchpad/
/PAUSED

View File

@ -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 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 <preset>` (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/<preset>.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 <preset>` (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/<preset>.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. 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 ## Runtime dependencies on nik-gpu
These are host-level dependencies. Confirm they exist before assuming a bug is in the project code. These are host-level dependencies. Confirm they exist before assuming a bug is in the project code.

View File

@ -19,6 +19,23 @@ ASSET_DIR = os.path.join(PROJECT_ROOT, "assets")
SCRATCHPAD_DIR = os.path.join(PROJECT_ROOT, "scratchpad") SCRATCHPAD_DIR = os.path.join(PROJECT_ROOT, "scratchpad")
os.makedirs(SCRATCHPAD_DIR, exist_ok=True) 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 <task>` 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 # Refined from (1726, 60): that coordinate sat on the edge of the icon's
# hitbox and intermittently missed during live testing. # hitbox and intermittently missed during live testing.
MAILBOX_ICON = (1732, 50) MAILBOX_ICON = (1732, 50)

View File

@ -11,14 +11,19 @@
# a scheduled fire time can never silently run late. # a scheduled fire time can never silently run late.
# #
# Logs to ~/ba_logs/<preset>.log -- one file per preset, so "daily" and # Logs to ~/ba_logs/<preset>.log -- one file per preset, so "daily" and
# "q4h" never interleave. Every line is one of three explicitly-tagged # "q4h" never interleave. Every line is one of four explicitly-tagged
# outcomes (OK / FAILED / SKIPPED) specifically so a real failure can be # outcomes (OK / FAILED / SKIPPED -- previous run still in progress /
# found with a single command, e.g.: # SKIPPED -- paused) specifically so a real failure can be found with a
# single command, e.g.:
# grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log # grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log
# A bare exit code alone can't do this: flock and a genuinely crashed task # 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 # 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 # (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. # 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 set -uo pipefail
# Resolves its own directory (same pattern ba_dailies.sh itself already # Resolves its own directory (same pattern ba_dailies.sh itself already
@ -47,6 +52,8 @@ mkdir -p "$LOG_DIR"
status=$? status=$?
if [ "$status" -eq 0 ]; then if [ "$status" -eq 0 ]; then
echo "=== $(date -Iseconds) finished $PRESET OK (exit 0) ===" echo "=== $(date -Iseconds) finished $PRESET OK (exit 0) ==="
elif [ "$status" -eq 75 ]; then
echo "=== $(date -Iseconds) SKIPPED $PRESET -- paused ==="
else else
echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ===" echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ==="
fi fi

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Python CLI entry point: ba_dailies.sh -> ba_daily.py -> ba_auto/tasks/*.py""" """Python CLI entry point: ba_dailies.sh -> ba_daily.py -> ba_auto/tasks/*.py"""
import os
import sys import sys
from ba_auto import config, driver, navigation from ba_auto import config, driver, navigation
@ -194,17 +195,59 @@ def _run_sequence(names):
print("All done.") 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): def main(argv):
args = argv[1:] args = argv[1:]
if args and args[0] == "--list-commands": if args and args[0] == "--list-commands":
# Machine-readable command list for shell completion (see # Machine-readable command list for shell completion (see
# completions/ba_dailies.bash) -- kept as a thin read of TASKS/ # completions/ba_dailies.bash) -- kept as a thin read of TASKS/
# PRESETS themselves so the completion list can never drift from # PRESETS/META_COMMANDS themselves so the completion list can never
# what's actually dispatchable. # drift from what's actually dispatchable.
print(" ".join(list(TASKS.keys()) + list(PRESETS.keys()))) print(" ".join(list(TASKS.keys()) + list(PRESETS.keys()) + list(META_COMMANDS.keys())))
return 0 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: if not args:
_run_sequence(DEFAULT_ORDER) _run_sequence(DEFAULT_ORDER)
return 0 return 0
@ -219,6 +262,7 @@ def main(argv):
print(f"Unknown phase: {command}", file=sys.stderr) print(f"Unknown phase: {command}", file=sys.stderr)
print(f"Valid phases: {' '.join(TASKS.keys())}", 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 presets: {' '.join(PRESETS.keys())}", file=sys.stderr)
print(f"Valid meta-commands: {' '.join(META_COMMANDS.keys())}", file=sys.stderr)
return 1 return 1
_run_task(command) _run_task(command)