feat: add manual pause functionality to prevent automation during user play sessions
This commit is contained in:
parent
4ddd0d7fba
commit
bf87a097c3
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ graphify-out/cache/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
scratchpad/
|
scratchpad/
|
||||||
|
/PAUSED
|
||||||
|
|||||||
19
CLAUDE.md
19
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
|
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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
50
ba_daily.py
50
ba_daily.py
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user