A good share of my automation runs on a Windows desktop, not a server. Some jobs need things only that machine has: a signed-in browser, local credentials, a checkout of the repo. The rest run on a small Linux server under systemd. Windows Task Scheduler starts the desktop ones, some as often as every ten minutes.
Two problems kept coming back. Terminal windows popped up over whatever I was doing. And when a job stopped running, nothing told me. This is how each one broke and what fixed it.
Window one: the task itself
The first case was simple. A sync job ran every ten minutes, and its scheduled task pointed at cmd.exe running a .bat file. Every ten minutes a console window opened, ran for a few seconds and closed, usually while I was typing.
The usual advice is powershell.exe -WindowStyle Hidden. In my experience it still flashes. PowerShell is a console program, so Windows gives it a console window before PowerShell has a chance to read its own arguments and hide it.
What worked was launching through wscript.exe, which is a GUI program and gets no console, and having a tiny VBScript start the real command with window style 0:
' run-hidden.vbs: wscript.exe run-hidden.vbs "C:\path\to\job.bat"
Set args = WScript.Arguments
If args.Count = 0 Then WScript.Quit 2
cmd = ""
For i = 0 To args.Count - 1
a = args(i)
If InStr(a, " ") > 0 Or a = "" Then a = """" & a & """"
cmd = cmd & a & " "
Next
cmd = Trim(cmd)
ext = LCase(Right(args(0), 4))
If ext = ".bat" Or ext = ".cmd" Then cmd = "cmd.exe /c """ & cmd & """"
WScript.Quit CreateObject("WScript.Shell").Run(cmd, 0, True)
The last argument, True, matters more than it looks. It makes the script wait for the job to finish. That keeps the task in the Running state for the whole job, which lets the "do not start a new instance" setting skip an overlapping run, and it passes the job's exit code back as the task's last result. Without the wait, every run reports success in a fraction of a second, whatever the job did.
The scheduled action looks like this:
$a = New-ScheduledTaskAction -Execute 'wscript.exe' `
-Argument '"C:\path\to\run-hidden.vbs" "C:\path\to\job.bat"'
For a Python entry point with no child processes, pythonw.exe does the same job directly. I moved every task over and the windows stopped. For a while.
Window two: the grandchildren
A few weeks later they were back, from jobs that were already launching through the hidden wrapper.
These jobs start other processes. One monitor, when it raises an alert, starts a background worker that launches a headless coding-agent run to investigate. Two others start headless agent runs of their own. A notification job running under pythonw calls git, ssh and powershell. The parent was hidden. Its children were not.
The worker launch looked like this:
flags = (subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS) if os.name == "nt" else 0
popen([sys.executable, "-m", "worker", "run", run_id], creationflags=flags, ...)
Both flags sound like "no window". Together they gave me windows.
Why does that combination open windows?
Microsoft's process creation flags page answers it in one line. CREATE_NO_WINDOW "is ignored if the application is not a console application, or if it is used with either CREATE_NEW_CONSOLE or DETACHED_PROCESS."
So the effective flag was DETACHED_PROCESS alone, and a detached process has no console at all. That's fine for the Python worker, which prints nothing anyone sees. The trouble starts when it launches a console program. Per the console creation docs, a console process normally inherits its parent's console. A parent with no console has nothing to hand down, so Windows creates a new one, with a visible window. The agent CLI is a .cmd shim, which means cmd.exe, which means a console, which means a window, once per run.
pythonw has the same shape. It's a GUI-subsystem build of Python with no console, so every git or ssh it starts without flags gets a fresh console window.
The fix is to drop DETACHED_PROCESS and keep CREATE_NO_WINDOW alone:
# A hidden console, not DETACHED_PROCESS: a detached child has none,
# so the agent CLI it starts opened a terminal.
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
CREATE_NO_WINDOW gives the child a console that exists but has no window. Its own children inherit that hidden console instead of making new ones. The same flag goes on every subprocess.run and Popen in any job that runs without a console, including one-off calls to git. The rule I ended up writing down: any code that starts a process from a scheduled job passes CREATE_NO_WINDOW, and never DETACHED_PROCESS.
When a window does appear, I check two things in order. First, the non-Microsoft scheduled tasks, for any action that points straight at a console program:
Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*'
Then the code those tasks run, searching for DETACHED_PROCESS and for subprocess calls with no creationflags at all.
The opposite problem: a job that stops without a sound
Hidden jobs have a downside. When one stops, you don't see that either. A desktop goes to sleep, a task gets disabled while testing, a timer on the server disappears after a reinstall. None of those produce an error, because nothing ran.
The first layer is a heartbeat wrapper around the job command, using healthchecks.io's ping API. It pings /start, runs the job, then pings the exit code. The service raises an alert when a ping is late or reports a non-zero exit.
def run(argv, send=_send, key=None, runner=subprocess.call) -> int:
slug, cmd = argv[0], argv[2:]
key = key if key is not None else ping_key()
if key:
ping(key, slug, "start", send)
code = runner(cmd)
if key:
ping(key, slug, str(code if 0 <= code <= 255 else 1), send)
return code
Three choices in there are deliberate. A failed ping (5-second timeout) is logged and swallowed, so a monitoring hiccup can never fail the job. With no key configured, the wrapper runs the job unchanged, so a job can be wired in before the monitoring account exists. And it only ever sends start and exit-code pings, never the job's output, which can contain personal data.
Is it late, or is the PC just off?
The second layer is one table of every scheduled job and always-on service on both machines, refreshed hourly. On the desktop it reads Task Scheduler through its COM interface from PowerShell. On the server it runs systemctl show over SSH. Each job gets a status: ok, running, failed, stopped (enabled but no next run), late, down (a service that should always run), missing (listed in config, gone from the machine) or off.
Task Scheduler reports results as codes, and three non-zero ones are not failures:
RUNNING_CODE = 0x41301 # the task is running now
NOT_RUN_CODES = (0x41303,) # has not run yet
OK_CODES = (0, 0x41306) # done; or stopped by the owner
It also reports "never" as a date in 1899 or 1999, so any date before 2001 is treated as no date.
"Late" was the hard one. A job is late when its last run is older than twice its normal gap plus a grace period. On the server the grace is 30 minutes. On the desktop it's 14 hours, because that machine is only on from about midday to one in the morning. Without that, every daily job would be flagged late every morning, and an alert that fires every morning is one I'd learn to ignore.
Two smaller details made it trustworthy. A job only counts as missing when the machine it lives on was actually reachable. If SSH to the server fails, that's one error row, not every server job marked missing at once. And a job that's on the machine but not in the config still shows up under its raw name, so a new job can't hide by being forgotten.
Alert once, and again only when something changes
Problems go to my phone, and the rule is once per problem. A small state file maps each job to the problem status already sent:
def new_problems(rows, alerted):
fresh, keep = [], {}
for r in rows:
if r["Status"] in PROBLEMS:
keep[r["Key"]] = r["Status"]
if alerted.get(r["Key"]) != r["Status"]:
fresh.append(r)
return fresh, keep
A job that's still failing an hour later stays quiet. A job that moves from late to failed alerts again, because that's new information. A job that recovers drops out of the map, so if it breaks again next week, that's a fresh alert. If the phone message itself fails to send, those jobs are left out of the saved state and the next hourly run tries again.
The collector follows the window rule too: its PowerShell call passes CREATE_NO_WINDOW.
What I'd do from day one
- Launch every scheduled task through a GUI host (
wscript.exewith a hidden-window script, orpythonw.exe). Never point a task straight atcmd.exe, a.batorpowershell.exe. - Pass
CREATE_NO_WINDOWon every child process a background job starts, and never combine it withDETACHED_PROCESS. Windows ignores it when you do. - Make the wrapper wait for the job, so the task's state and exit code mean something.
- Watch for silence as well as errors, with a grace period that matches when the machine is actually on.
- Alert on change, not on state.
One of the jobs that table watches is the publishing pipeline in this write-up of an automated SEO system. It runs unattended, so it's exactly the kind of job that could stop for a week with nobody noticing, without the table.
This article was drafted with AI assistance, working from the project's own scripts, commit history and Microsoft's documentation.
Top comments (1)
창을 숨기는 문제와 작업이 실제로 끝났는지 아는 문제를 함께 다룬 점이 좋네요. 특히 래퍼가 기다리지 않으면 중복 실행 방지와 종료 코드 둘 다 의미를 잃는다는 설명이 명확합니다. PC가 꺼진 상태와 작업 누락을 구분하고 상태가 바뀔 때만 알림을 보내는 방식도 알림 피로를 줄이는 데 중요해 보여요.