railcall scheduler silently ignores every flag — --once starts the forever-loop instead of a single tick
railcall scheduler accepts --once, --dry-run, --interval, and --port
on the command line, but drops all of them. The most concrete consequence:railcall scheduler --once — the documented cron/launchd single-tick — instead
starts the continuous never-exiting loop.
Reproduction (station-v0.78)
railcall scheduler --once
Expected (per scheduler_driver.py L19, --once # single tick (cron/launchd)):
one tick, then exit.
Actual: the process enters the continuous loop and runs until Ctrl+C. Same for
the other flags — --dry-run, --interval N, --port N all have no effect.
(Traced from source below, not run live.)
Observed (why)
The CLI dispatches every command with a plain list — _dispatch() callsfn(sys.argv[2:]):
fn = COMMANDS.get(arg1)
...
return fn(sys.argv[2:])
(railcall_cli.py, _dispatch, ~L7383–7387)
But cmd_scheduler reads its options as attributes of args, as if args
were an argparse Namespace:
if getattr(args, "once", False):
argv.append("--once")
if getattr(args, "dry_run", False):
argv.append("--dry-run")
if getattr(args, "interval", None):
argv += ["--interval", str(args.interval)]
if getattr(args, "port", None):
argv += ["--port", str(args.port)]
return mod.main(argv)
(railcall_cli.py, cmd_scheduler, ~L1000–1007)
args is a list. A list has no .once / .dry_run / .interval / .port
attributes, so every getattr returns its default, argv stays [], and the
call is always mod.main([]).
The flags themselves are real and fully implemented in the driver —scheduler_driver.py documents them (L19–20) and argparse-parses them
(L131–141). With argv == [], argparse applies its defaults (--once false,--dry-run false), and the driver branches straight past the single-tick path
into the infinite loop:
a = ap.parse_args(argv)
if a.once:
res = tick_once(port=a.port, ws=a.ws, dry_run=a.dry_run)
print(_describe(res))
return 0 if res.get("ok") else 1
print(f"scheduler driver: asking 127.0.0.1:{a.port} every {a.interval}s "
f"({'dry-run' if a.dry_run else 'live'}) — Ctrl+C to stop")
while True:
res = tick_once(port=a.port, ws=a.ws, dry_run=a.dry_run)
line = _describe(res)
...
try:
time.sleep(max(5, a.interval))
except KeyboardInterrupt:
print("\nstopped")
return 0
(scheduler_driver.py, main, L141–161)
So with the flags dropped: a.once is false → the if a.once: single-tick
return is skipped → execution falls into while True: (live, becausea.dry_run is false too).
Consequences
--oncebreaks cron/launchd persistence. The intended pattern is a
scheduler entry that fires railcall scheduler --once on a timer (cron,
launchd, Task Scheduler) so each invocation ticks once and exits. Because
--once is dropped, every invocation instead starts a process that never
exits — so a per-minute cron job spawns a new never-ending process every
minute. This is the deterministic, load-bearing one.
--dry-runis ignored, so it runs live. A user who runs
railcall scheduler --dry-run to preview what would fire (the driver
documents it as "report, execute nothing") actually runs it live. More
subtle than #1, but a footgun when you're testing a schedule.
--intervaland--portare ignored; the defaults are always used.
Scoped check: getattr(args, …) / attribute access on args appears in
only this one function across the whole CLI — every other command treatsargs as the list it is. This looks like a single spot left behind by an
argparse → list-dispatch refactor.
Suggested fix
Parse the flags out of the list the way the rest of the CLI does, e.g.:
args = list(args or [])
argv = []
if "--once" in args: argv.append("--once")
if "--dry-run" in args: argv.append("--dry-run")
for opt in ("--interval", "--port"):
if opt in args:
i = args.index(opt)
if i + 1 < len(args):
argv += [opt, args[i + 1]]
return mod.main(argv)
or simply forward the raw list — return mod.main(list(args or [])) — and let
the driver's own argparse handle it, since it already parses exactly these flags.
---
Environment: station-v0.78. Verified by reading the dispatch path andcmd_scheduler in railcall_cli.py against scheduler_driver.py; behavior
traced from source, not run live.