Replace the single hardcoded signed-out check with a guards list on each firm: any guard can trip mid-run via a URL substring (checked on every step) or a selector probe (checked when a step fails). Each guard defines recovery steps or a human-wait fallback, a per-run trigger cap, and a resume policy — 'continue' retries the tripped step, 'restart' rewinds the run to step 1. Runner bumped to 0.20.0; the server still ships the legacy signedOut/authSteps shape for stale runners, and a new runner synthesizes a guard from a legacy payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
573 lines
24 KiB
Python
573 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Automation runner — the process that makes dashboard buttons do something.
|
|
|
|
Leave this running. It polls the dashboard for queued runs, and when one appears
|
|
it works through that automation's steps, driving the real mouse and keyboard,
|
|
reporting each step back so the page can show progress.
|
|
|
|
python runner.py # against http://localhost:3000
|
|
python runner.py --api http://vps:3000
|
|
|
|
Steps come from the server, so adding a new button means editing
|
|
lib/automations.ts — nothing here needs to change.
|
|
|
|
Stop with Ctrl-C. A run in progress can be halted from the dashboard's Stop
|
|
button; the runner notices between steps.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import random
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import actions
|
|
import captcha
|
|
import focus
|
|
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
|
|
|
|
POLL_SECONDS = 1.0
|
|
HEARTBEAT_SECONDS = 2.0
|
|
|
|
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
|
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
|
# a stale process fail on a step type it has never heard of.
|
|
# 0.20.0: page guards — run-level `guards` replace per-step signedOut/authSteps.
|
|
VERSION = "0.20.0"
|
|
|
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
|
_busy = threading.Event()
|
|
|
|
|
|
def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
|
"""Check in on our own thread.
|
|
|
|
Deliberately not folded into the main poll loop: a single step can block for
|
|
twenty seconds waiting on the extension, and a runner that goes quiet that
|
|
long would show as offline in the middle of the run it is executing.
|
|
"""
|
|
payload = {"host": socket.gethostname(), "pid": os.getpid(),
|
|
"dryRun": opts.dry_run, "version": VERSION}
|
|
while not stop.is_set():
|
|
try:
|
|
dash._request("/api/autobuyer/runner", "POST", {**payload, "busy": _busy.is_set()})
|
|
except DashboardError:
|
|
pass # the main loop reports connectivity; don't double up
|
|
stop.wait(HEARTBEAT_SECONDS)
|
|
|
|
|
|
def run_one_step(dash: Dashboard, step: dict, opts, rng, report,
|
|
run_id: int | None = None, guards: list | None = None) -> None:
|
|
"""Dispatch a single step. Raises StepError / DashboardError on failure,
|
|
GuardTripped when a URL guard's condition shows up on the page.
|
|
|
|
`guards` is empty for recovery steps: they run while a guard's condition
|
|
holds, so checking it again would trip on the very page they exist to fix.
|
|
"""
|
|
if step["action"] == "wait":
|
|
time.sleep(float(step.get("seconds", 1)))
|
|
report(f"waited {step.get('seconds', 1)}s")
|
|
|
|
elif step["action"] == "navigate":
|
|
# No mouse involved: the extension points the tab at the page and waits
|
|
# for it to load. Locating <body> confirms it is really there.
|
|
target = step.get("url", "")
|
|
landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
|
|
opts.timeout, step.get("openUrl", "") or "",
|
|
navigate_url=target)
|
|
# Navigating to a protected page is where an expired session shows up:
|
|
# the site answers with a redirect to its login form.
|
|
actions.check_guards(landed, guards)
|
|
report(f"tab is on {landed.get('url', target)}")
|
|
|
|
elif step["action"] == "waitFor":
|
|
# A gate, not an action: block until the element exists — or, with
|
|
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
|
|
# change the page — a person solving a challenge, a modal closing itself,
|
|
# a slow server — happens outside this run.
|
|
#
|
|
# `hidden` widens an absent gate from "removed from the DOM" to "no
|
|
# longer visible": a modal that closes by going display:none never stops
|
|
# matching its selector, so the plain gate can only ever time out.
|
|
selector = step["selector"]
|
|
absent = bool(step.get("absent"))
|
|
by_sight = absent and bool(step.get("hidden"))
|
|
goal = "disappear from view" if by_sight else "disappear" if absent else "appear"
|
|
timeout_s = float(step.get("timeoutSeconds", 120))
|
|
deadline = time.time() + timeout_s
|
|
announced = False
|
|
|
|
while True:
|
|
# Long gates must still honour the dashboard's Stop button.
|
|
if run_id is not None and run_status(dash, run_id) == "cancelled":
|
|
raise actions.StepError("cancelled while waiting")
|
|
|
|
# A frame-scoped gate resolves the selector inside the named <iframe>;
|
|
# `hidden` adds the visibility probe. Either can be set independently.
|
|
loc_opts: dict = {}
|
|
if by_sight:
|
|
loc_opts["op"] = "visible"
|
|
if step.get("frame"):
|
|
loc_opts["frame"] = step["frame"]
|
|
|
|
try:
|
|
seen = dash.locate(selector, int(step.get("index", 0)),
|
|
step.get("urlPattern", "") or opts.url,
|
|
opts.timeout, step.get("openUrl", "") or "",
|
|
options=loc_opts or None)
|
|
# A guard page appearing mid-gate would otherwise burn the whole
|
|
# gate timeout before anyone noticed. GuardTripped is not caught
|
|
# below, so it escapes the wait as it should.
|
|
actions.check_guards(seen, guards)
|
|
if by_sight:
|
|
# An extension too old for the probe quietly does an ordinary
|
|
# locate instead, whose result has no `visible` key — reading
|
|
# that as "gone" would wave the gate through on a visible
|
|
# element, so insist the answer is the one we asked for.
|
|
if seen.get("op") != "visible":
|
|
raise actions.StepError(
|
|
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
|
|
"reload it in chrome://extensions"
|
|
)
|
|
if not seen.get("visible"):
|
|
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
|
|
break
|
|
elif not absent:
|
|
report(f"{selector} appeared")
|
|
break
|
|
# Still there. Keep waiting for it to go.
|
|
except NotFoundError:
|
|
if absent:
|
|
report(f"{selector} is gone")
|
|
break
|
|
# Not there yet; keep waiting for it to arrive.
|
|
except DashboardError:
|
|
# A dead extension or an unreachable dashboard must not be read
|
|
# as "the element is gone" — that would satisfy an absent gate
|
|
# for entirely the wrong reason.
|
|
pass
|
|
|
|
if time.time() >= deadline:
|
|
raise actions.StepError(
|
|
f"{selector} did not {goal} within {timeout_s:.0f}s"
|
|
)
|
|
if not announced:
|
|
report(f"waiting for {selector} to {goal} (up to {timeout_s:.0f}s)")
|
|
announced = True
|
|
time.sleep(2.0)
|
|
|
|
elif step["action"] == "solveCaptcha":
|
|
# No mouse: the extension reads the widget, the dashboard buys a token
|
|
# from 2captcha, and the extension hands it to the page's own callback.
|
|
captcha.solve(
|
|
dash, step, opts, report,
|
|
cancelled=(lambda: run_status(dash, run_id) == "cancelled") if run_id is not None else None,
|
|
)
|
|
|
|
elif step["action"] == "scrollToLoad":
|
|
# Lists that load progressively need walking to the bottom before the
|
|
# steps that act on their items can see everything.
|
|
result = dash.locate(
|
|
step["selector"], 0,
|
|
step.get("urlPattern", "") or opts.url,
|
|
max(opts.timeout, 120.0), # scrolling a long list outlasts a normal step
|
|
step.get("openUrl", "") or "",
|
|
options={
|
|
"op": "scrollToLoad",
|
|
"containerSelector": step.get("containerSelector", ""),
|
|
"maxScrolls": int(step.get("maxScrolls", 25)),
|
|
"settleMs": int(step.get("settleMs", 800)),
|
|
},
|
|
)
|
|
found_n = result.get("after", 0)
|
|
report(f"{found_n} match(es) after {result.get('scrolls', 0)} scroll(s) "
|
|
f"of {result.get('container', '?')} (was {result.get('before', 0)})")
|
|
if not result.get("exhausted"):
|
|
# Stopping on the scroll cap is not a failure, but it does mean the
|
|
# list may still have more below — worth saying so rather than
|
|
# letting a later step quietly work on a partial list.
|
|
report("hit the scroll limit — there may be more not loaded")
|
|
|
|
elif step["action"] not in ("click", "type"):
|
|
# Almost always a stale runner: the server defines the step vocabulary,
|
|
# so a step type this process has never heard of means automations.ts has
|
|
# moved on and this file has not been restarted.
|
|
raise actions.StepError(
|
|
f"unknown step action {step['action']!r} — this runner is "
|
|
f"v{VERSION}; restart it to pick up newer step types"
|
|
)
|
|
|
|
else:
|
|
try:
|
|
actions.perform(
|
|
dash,
|
|
step["action"],
|
|
step["selector"],
|
|
index=int(step.get("index", 0)),
|
|
url=step.get("urlPattern", "") or opts.url,
|
|
open_url=step.get("openUrl", "") or "",
|
|
guards=guards,
|
|
frame=step.get("frame", "") or "",
|
|
text=step.get("text"),
|
|
clear=bool(step.get("clear")),
|
|
scale=opts.scale,
|
|
timeout=opts.timeout,
|
|
rng=rng,
|
|
activate=not opts.no_activate,
|
|
dry_run=opts.dry_run,
|
|
report=report,
|
|
)
|
|
except NotFoundError:
|
|
# Only absence is skippable. A covered element, an off-screen target
|
|
# or an unreachable extension still fails the run.
|
|
if not step.get("skipIfNotFound"):
|
|
raise
|
|
report(f"{step['selector']} not present — skipped")
|
|
|
|
|
|
def run_status(dash: Dashboard, run_id: int) -> str | None:
|
|
"""Current status, so a wait can notice the dashboard's Stop button."""
|
|
try:
|
|
return dash._request(f"/api/autobuyer/runs?id={run_id}")["run"]["status"]
|
|
except (DashboardError, KeyError):
|
|
return None
|
|
|
|
|
|
def guard_present(dash: Dashboard, guard: dict, step: dict, opts,
|
|
probe_timeout: float = 6.0) -> bool | None:
|
|
"""Is the guard's condition on the page right now? None = could not tell.
|
|
|
|
A URL guard reads the tab's URL off a cheap body locate; a selector guard
|
|
needs a real DOM probe, whose timeout is kept short — a guard element that
|
|
exists shows up immediately, so most of the timeout is only ever spent
|
|
confirming absence. A guard defining both trips when either is present.
|
|
"""
|
|
pattern = step.get("urlPattern", "") or opts.url
|
|
open_url = step.get("openUrl", "") or ""
|
|
|
|
if guard.get("urlContains"):
|
|
try:
|
|
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
|
|
except DashboardError:
|
|
return None # tab busy mid-redirect; the caller decides
|
|
if guard["urlContains"] in (landed.get("url") or ""):
|
|
return True
|
|
if not guard.get("selector"):
|
|
return False
|
|
|
|
if guard.get("selector"):
|
|
try:
|
|
dash.locate(guard["selector"], 0, pattern, probe_timeout, open_url)
|
|
return True
|
|
except NotFoundError:
|
|
return False
|
|
except DashboardError:
|
|
return None
|
|
return False
|
|
|
|
|
|
def guard_cleared(dash: Dashboard, guard: dict, step: dict, opts,
|
|
settle_seconds: float = 20.0) -> bool:
|
|
"""Did recovery actually clear the condition?
|
|
|
|
Recovery steps can 'succeed' — every click landing — while the page stays in
|
|
the guarded state, so this confirms rather than assumes before resuming.
|
|
|
|
It polls rather than checking once: submitting a login form starts a network
|
|
round trip and then a redirect, so the tab is still sitting on the guard
|
|
page for a second or two afterwards. Checking immediately reports a failure
|
|
for a recovery that is merely still in flight.
|
|
"""
|
|
deadline = time.time() + settle_seconds
|
|
while True:
|
|
if guard_present(dash, guard, step, opts) is False:
|
|
return True
|
|
if time.time() >= deadline:
|
|
return False
|
|
time.sleep(1.0)
|
|
|
|
|
|
def wait_for_clear(dash: Dashboard, guard: dict, step: dict, run_id: int, opts,
|
|
deadline: float) -> bool:
|
|
"""Block until a human deals with the guard's condition.
|
|
|
|
The runner does not act here — for the login guard, entering credentials is
|
|
the human's job, and a keystroke aimed at the wrong field is not something
|
|
to risk automating. All this does is watch the page and pick the run back
|
|
up once the condition is gone.
|
|
"""
|
|
while time.time() < deadline:
|
|
if run_status(dash, run_id) == "cancelled":
|
|
return False
|
|
time.sleep(3.0)
|
|
if guard_present(dash, guard, step, opts) is False:
|
|
return True
|
|
return False
|
|
|
|
|
|
def find_tripped_guard(dash: Dashboard, guards: list, step: dict, opts) -> dict | None:
|
|
"""After a step failure: is a guard's condition what actually broke it?
|
|
|
|
URL guards are caught in flight by check_guards; selector guards cost a
|
|
locate round trip each, so they are only probed once a step has already
|
|
failed — a session-expired modal explains a NotFound far better than a
|
|
wrong selector does.
|
|
"""
|
|
for guard in guards:
|
|
if not guard.get("selector"):
|
|
continue # URL-only guards were already checked in flight
|
|
if guard_present(dash, guard, step, opts):
|
|
return guard
|
|
return None
|
|
|
|
|
|
def legacy_guards(run: dict) -> list:
|
|
"""Guards synthesized from the pre-guard payload shape, so this runner still
|
|
works against a dashboard that predates run-level guards."""
|
|
for step in run.get("steps") or []:
|
|
if step.get("signedOut"):
|
|
return [{
|
|
"id": "signed-out",
|
|
"label": "signed out",
|
|
"urlContains": step["signedOut"],
|
|
"resume": "continue",
|
|
"waitSeconds": float(step.get("signedOutWait", 0) or 0),
|
|
"recoverySteps": run.get("authSteps") or [],
|
|
}]
|
|
return []
|
|
|
|
|
|
def handle_guard(dash: Dashboard, run_id: int, step_index: int, label: str,
|
|
guard: dict, step: dict, opts, rng, report,
|
|
trigger_counts: dict) -> str:
|
|
"""A guard tripped mid-run: recover, then say where the run picks up.
|
|
|
|
Returns "retry" (redo the step that tripped), "restart" (back to step 1),
|
|
"cancelled", or "fail" — the terminal outcomes have already been reported
|
|
and the run finished by the time they are returned.
|
|
"""
|
|
gid = guard.get("id") or "guard"
|
|
name = guard.get("label") or gid
|
|
|
|
def fail(msg: str) -> str:
|
|
post_progress(dash, run_id, step_index, label, False, msg)
|
|
finish(dash, run_id, msg)
|
|
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
|
|
return "fail"
|
|
|
|
# A guard whose recovery "works" without clearing the cause would trip
|
|
# again on the next step, forever. Cap it per run, not per step: a restart
|
|
# revisits every step, so a per-step cap would never fill up.
|
|
trigger_counts[gid] = trigger_counts.get(gid, 0) + 1
|
|
limit = int(guard.get("maxTriggers") or 3)
|
|
if trigger_counts[gid] > limit:
|
|
return fail(f"{name} guard tripped {trigger_counts[gid]} times in one run — giving up")
|
|
|
|
recovery = guard.get("recoverySteps") or []
|
|
if recovery:
|
|
post_progress(dash, run_id, step_index,
|
|
f"{name} guard tripped — running {len(recovery)} recovery step(s)",
|
|
False, None)
|
|
try:
|
|
for rstep in recovery:
|
|
rlabel = rstep.get("label") or f"recovery: {rstep['action']}"
|
|
print(f" [{name}] {rlabel}")
|
|
# No guards passed: recovery runs on the guard page itself.
|
|
run_one_step(dash, rstep, opts, rng, report, run_id)
|
|
except (actions.StepError, DashboardError) as exc:
|
|
return fail(f"{name} guard recovery failed: {exc}")
|
|
|
|
if not guard_cleared(dash, guard, step, opts):
|
|
return fail(f"{name} guard recovery ran but the condition is still on the page")
|
|
report(f"{name} guard cleared")
|
|
|
|
else:
|
|
wait_seconds = float(guard.get("waitSeconds") or 0)
|
|
if wait_seconds <= 0:
|
|
return fail(f"{name} guard tripped and defines no recovery — stopping")
|
|
|
|
mins = wait_seconds / 60
|
|
note = f"{name} guard tripped — waiting up to {mins:.0f} min for you to deal with it"
|
|
print(f" {note}", file=sys.stderr)
|
|
post_progress(dash, run_id, step_index, note, False, None)
|
|
|
|
if not wait_for_clear(dash, guard, step, run_id, opts, time.time() + wait_seconds):
|
|
if run_status(dash, run_id) == "cancelled":
|
|
print(f"■ run #{run_id} cancelled while waiting on the {name} guard")
|
|
return "cancelled"
|
|
return fail(f"{name} guard still tripped after {mins:.0f} min")
|
|
report(f"{name} guard cleared")
|
|
|
|
return "restart" if (guard.get("resume") or "continue") == "restart" else "retry"
|
|
|
|
|
|
def run_steps(dash: Dashboard, run: dict, opts) -> None:
|
|
"""Work through one automation. Reports every step; stops on the first
|
|
failure, because a half-completed purchase flow should not barrel on.
|
|
|
|
Page guards can interrupt any step. Once one is handled, its `resume`
|
|
policy decides where the run picks up: "continue" retries the step that
|
|
tripped, "restart" goes back to the first step — hence a while loop over
|
|
the step index rather than a plain for."""
|
|
run_id = run["id"]
|
|
steps = run["steps"]
|
|
guards = run.get("guards")
|
|
if guards is None:
|
|
guards = legacy_guards(run)
|
|
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
|
|
|
|
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
|
|
|
|
trigger_counts: dict[str, int] = {}
|
|
i = 0
|
|
while i < len(steps):
|
|
step = steps[i]
|
|
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
|
|
print(f" [{i + 1}/{len(steps)}] {label}")
|
|
|
|
detail_parts: list[str] = []
|
|
|
|
def report(message: str) -> None:
|
|
detail_parts.append(message)
|
|
print(f" {message}")
|
|
|
|
outcome = None
|
|
try:
|
|
run_one_step(dash, step, opts, rng, report, run_id, guards=guards)
|
|
|
|
except actions.GuardTripped as exc:
|
|
print(f" {exc}", file=sys.stderr)
|
|
outcome = handle_guard(dash, run_id, i, label, exc.guard, step,
|
|
opts, rng, report, trigger_counts)
|
|
|
|
except (actions.StepError, DashboardError) as exc:
|
|
if run_status(dash, run_id) == "cancelled":
|
|
print(f"■ run #{run_id} cancelled from the dashboard")
|
|
return
|
|
|
|
# Before writing the failure off, see whether a selector guard's
|
|
# condition is sitting on the page — an expired-session modal
|
|
# explains a missing button better than a wrong selector does.
|
|
tripped = find_tripped_guard(dash, guards, step, opts) if guards else None
|
|
if tripped is not None:
|
|
name = tripped.get("label") or tripped.get("id")
|
|
print(f" step failed with the {name} guard's condition on the page",
|
|
file=sys.stderr)
|
|
outcome = handle_guard(dash, run_id, i, label, tripped, step,
|
|
opts, rng, report, trigger_counts)
|
|
else:
|
|
print(f" FAILED: {exc}", file=sys.stderr)
|
|
post_progress(dash, run_id, i, label, False, str(exc))
|
|
finish(dash, run_id, str(exc))
|
|
print(f"✗ run #{run_id} stopped at step {i + 1}")
|
|
return
|
|
|
|
if outcome == "retry":
|
|
continue # same step, another go
|
|
if outcome == "restart":
|
|
print(f" ↺ restarting run #{run_id} from step 1")
|
|
i = 0
|
|
continue
|
|
if outcome in ("fail", "cancelled"):
|
|
return # handle_guard already reported and finished
|
|
|
|
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
|
|
|
|
# The dashboard's Stop button shows up here.
|
|
if status == "cancelled":
|
|
print(f"■ run #{run_id} cancelled from the dashboard")
|
|
return
|
|
|
|
i += 1
|
|
|
|
finish(dash, run_id, None)
|
|
print(f"✓ run #{run_id} complete")
|
|
|
|
|
|
def post_progress(dash, run_id, step_index, step, ok, detail):
|
|
try:
|
|
res = dash._request("/api/autobuyer/runs/progress", "POST", {
|
|
"id": run_id, "stepIndex": step_index, "step": step, "ok": ok, "detail": detail,
|
|
})
|
|
return res.get("status")
|
|
except DashboardError as exc:
|
|
print(f" (could not report progress: {exc})", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def finish(dash, run_id, error):
|
|
try:
|
|
dash._request("/api/autobuyer/runs/progress", "POST",
|
|
{"id": run_id, "finish": True, "error": error})
|
|
except DashboardError as exc:
|
|
print(f" (could not report completion: {exc})", file=sys.stderr)
|
|
|
|
|
|
def main() -> int:
|
|
force_utf8_output()
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--api", default="http://localhost:3000", help="dashboard URL")
|
|
parser.add_argument("--url", default="", help="fallback Chrome match pattern for steps that omit one")
|
|
parser.add_argument("--timeout", type=float, default=45.0, help="seconds to wait for the extension per step (a cold page load takes time)")
|
|
parser.add_argument("--scale", type=float, default=None, help="CSS-to-desktop pixel ratio")
|
|
parser.add_argument("--seed", type=int, default=None, help="seed the motion RNG (debugging)")
|
|
parser.add_argument("--dry-run", action="store_true", help="move the cursor through the steps but never press or type")
|
|
parser.add_argument("--no-activate", action="store_true", help="do not raise the browser before each step")
|
|
opts = parser.parse_args()
|
|
|
|
try:
|
|
import pyautogui # noqa: F401
|
|
except ImportError:
|
|
print("error: pyautogui is not installed — run: pip install -r requirements.txt", file=sys.stderr)
|
|
return 1
|
|
|
|
# Before anything asks the OS how big the screen is. No-op off Windows.
|
|
dpi = focus.enable_dpi_awareness()
|
|
if dpi:
|
|
print(f" {dpi}")
|
|
|
|
dash = Dashboard(opts.api)
|
|
print(f"Runner watching {opts.api}" + (" [DRY RUN — nothing will be pressed]" if opts.dry_run else ""))
|
|
print("Waiting for a run. Press a button on the AutoBuyer page. Ctrl-C to stop.")
|
|
|
|
stop = threading.Event()
|
|
beat = threading.Thread(target=heartbeat_loop, args=(dash, opts, stop), daemon=True)
|
|
beat.start()
|
|
|
|
idle_warned = False
|
|
while True:
|
|
try:
|
|
claim = dash._request("/api/autobuyer/runs/claim", "POST", {})
|
|
except DashboardError as exc:
|
|
if not idle_warned:
|
|
print(f" ({exc})", file=sys.stderr)
|
|
idle_warned = True
|
|
time.sleep(POLL_SECONDS * 3)
|
|
continue
|
|
|
|
idle_warned = False
|
|
run = claim.get("run")
|
|
if not run:
|
|
time.sleep(POLL_SECONDS)
|
|
continue
|
|
|
|
_busy.set()
|
|
try:
|
|
run_steps(dash, run, opts)
|
|
except Exception as exc: # keep the daemon alive
|
|
print(f"✗ run failed unexpectedly: {exc}", file=sys.stderr)
|
|
finish(dash, run["id"], f"runner error: {exc}")
|
|
finally:
|
|
_busy.clear()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
print("\nstopped.")
|
|
sys.exit(0)
|