fix: give the Windows scheduled task the same FAILED-push backstop

The task now runs scheduling\run-sync.ps1 instead of an inline cmd.exe
loop: every provider is attempted, a run that dies without the app's
own report pushes a high-priority FAILED with the exception class only,
and each run's output is appended to cache\logs\scheduled-sync.log
because Task Scheduler keeps none. Verified under PowerShell 7.6 on
Linux with a stand-in cmd.exe; not yet run under Windows PowerShell 5.1
or Task Scheduler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
This commit is contained in:
JesseMarkowitz
2026-10-07 06:05:20 -04:00
co-authored by Claude Opus 5.5
parent 23c6e1f512
commit 805689c649
4 changed files with 187 additions and 24 deletions
+16 -15
View File
@@ -35,6 +35,7 @@ $ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo 'ai-chat-exporter.cmd'
$wrapper = Join-Path $PSScriptRoot 'run-sync.ps1'
if ($Unregister) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
@@ -42,22 +43,22 @@ if ($Unregister) {
return
}
if (-not (Test-Path $launcher)) {
throw "Launcher not found at $launcher"
foreach ($f in $launcher, $wrapper) {
if (-not (Test-Path $f)) { throw "Not found: $f" }
}
# A single action looping over the providers, rather than one action each.
# Task Scheduler runs multiple actions in order but reports only the last one's
# result, so a failure in an earlier provider would be invisible. The loop keeps
# going after a failure and propagates a non-zero exit code.
# One action running run-sync.ps1, rather than one action per provider: Task
# Scheduler runs multiple actions in order but reports only the last one's
# result, so a failure in an earlier provider would be invisible. The script
# keeps going after a failure, exits non-zero if any provider failed, and pushes
# a FAILED notification for any run that died without sending its own.
#
# /v:on and !RC! are required, not stylistic: cmd expands every %VAR% on a
# command line *before* running any of it, so "exit /b %RC%" would report the
# value RC had before the loop ever ran - i.e. always success. Delayed expansion
# reads it at the point of use.
$loop = ($Provider | ForEach-Object { "`"$launcher`" sync --provider $_ --joplin-optional || set RC=1" }) -join ' & '
$taskArgs = "/v:on /c set RC=0 & $loop & exit /b !RC!"
$actions = New-ScheduledTaskAction -Execute 'cmd.exe' `
# Providers go as separate arguments: under -File, "chatgpt,claude" would arrive
# as one string. -WindowStyle Hidden because the output now goes to
# cache\logs\scheduled-sync.log, so the console window would only ever be blank.
$taskArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden " +
"-File `"$wrapper`" $($Provider -join ' ')"
$actions = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument $taskArgs `
-WorkingDirectory $repo
@@ -82,12 +83,12 @@ Register-ScheduledTask -TaskName $TaskName `
Write-Host "Registered '$TaskName' - daily at $Time for: $($Provider -join ', ')"
Write-Host ''
Write-Host 'Command the task will run:'
Write-Host " cmd.exe $taskArgs"
Write-Host " powershell.exe $taskArgs"
Write-Host " (working directory: $repo)"
Write-Host ''
Write-Host 'Next steps:'
Write-Host " * Run it once now: Start-ScheduledTask -TaskName $TaskName"
Write-Host " * Check the result: Get-ScheduledTaskInfo -TaskName $TaskName"
Write-Host " * Read the log: Get-Content '$repo\cache\logs\exporter.log' -Tail 50"
Write-Host " * Read the run output: Get-Content '$repo\cache\logs\scheduled-sync.log' -Tail 50"
Write-Host ' * The terms-of-service notice must have been acknowledged'
Write-Host ' interactively once on this machine, or the task exits 1.'
+150
View File
@@ -0,0 +1,150 @@
<#
.SYNOPSIS
Run `ai-chat-exporter sync` once per provider - the action of the scheduled
task that Register-AiChatSyncTask.ps1 creates.
.DESCRIPTION
The Windows counterpart to run-sync.sh, and there for the same reason.
Every provider is attempted even after one fails, and the exit code is
non-zero if any failed - Task Scheduler reports only the last action's
result, so one action per provider would hide an earlier failure.
The app pushes its own ntfy result, but only from the end of a run it
finished. A crash, a non-zero exit before the sync starts (the terms-of-
service gate, a cache error) or a launcher that can't build its venv sends
nothing, and because each provider pushes separately, the providers that did
succeed still send "OK" - so a broken one looks like a quiet day. This
script pushes a FAILED notification for any run that exited non-zero without
the app having reported it. (Its "Sync completed with failures" banner
prints right after its push.)
The push carries the provider, the exit code and, for a crash, the
exception's class name - never its message. Same counts-only rule as
src/notify.py: on a public ntfy topic anyone who guesses the name can read
it, and exception text can carry conversation titles.
Task Scheduler keeps no output, so each run's output is appended to
cache\logs\scheduled-sync.log (rolled over to .1 past 1 MB). That is where
the traceback is.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File .\scheduling\run-sync.ps1 chatgpt claude
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Provider
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo 'ai-chat-exporter.cmd'
$envFile = Join-Path $repo '.env'
$logDir = Join-Path $repo 'cache\logs'
$runLog = Join-Path $logDir 'scheduled-sync.log'
# -File passes "chatgpt,claude" as one string; accept it as well as a list.
$Provider = @($Provider | ForEach-Object { $_ -split ',' } | Where-Object { $_ })
if ($Provider.Count -eq 0) { $Provider = @('all') }
foreach ($p in $Provider) {
# Each name is spliced into a cmd.exe command line below.
if ($p -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid provider name: '$p'" }
}
# Under Task Scheduler stdout is a file, not a console, so Python would encode
# it in the ANSI code page and die on the first character outside it.
$env:PYTHONIOENCODING = 'utf-8'
# NTFY_* as the app resolves them: the environment wins, then .env.
function Get-EnvValue([string]$Name) {
$value = [Environment]::GetEnvironmentVariable($Name)
if (-not $value -and (Test-Path -LiteralPath $envFile)) {
foreach ($line in Get-Content -LiteralPath $envFile) {
if ($line -match "^\s*$Name\s*=\s*(.*)$") { $value = $Matches[1] }
}
if ($value) { $value = ($value -replace '\s+#.*$', '').Trim().Trim('"', "'") }
}
return "$value"
}
function Send-FailurePush([string]$Body) {
$topic = Get-EnvValue 'NTFY_TOPIC'
$policy = (Get-EnvValue 'NTFY_NOTIFY').ToLower()
if (-not $topic -or $policy -eq 'off') { return }
$server = Get-EnvValue 'NTFY_SERVER'
if (-not $server) { $server = 'https://ntfy.sh' }
$token = Get-EnvValue 'NTFY_TOKEN'
# The same short name src/notify.py puts in its titles.
$machine = [System.Net.Dns]::GetHostName().Split('.')[0]
$headers = @{
Title = "AI archive FAILED - $machine"
Tags = 'rotating_light'
Priority = 'high'
}
if ($token) { $headers.Authorization = "Bearer $token" }
try {
# Windows PowerShell 5.1 on an older .NET may not offer TLS 1.2 by default.
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 15 `
-Uri "$($server.TrimEnd('/'))/$topic" -Headers $headers `
-ContentType 'text/plain; charset=utf-8' `
-Body ([Text.Encoding]::UTF8.GetBytes($Body)) | Out-Null
} catch {
Write-Warning "run-sync: could not send the failure notification ($($_.Exception.Message))"
}
}
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
if ((Test-Path -LiteralPath $runLog) -and (Get-Item -LiteralPath $runLog).Length -gt 1MB) {
Move-Item -LiteralPath $runLog -Destination "$runLog.1" -Force
}
$rc = 0
foreach ($p in $Provider) {
$out = [IO.Path]::GetTempFileName()
try {
# cmd.exe does the redirect, so stdout and stderr stay interleaved and
# PowerShell's native-command stderr handling never sees either. /s strips
# exactly the outer pair of quotes, leaving the quoted paths inside intact.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' }
$psi.Arguments = "/d /s /c `"`"$launcher`" sync --provider $p --joplin-optional > `"$out`" 2>&1`""
$psi.WorkingDirectory = $repo
$psi.UseShellExecute = $false
$proc = [Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$status = $proc.ExitCode
$text = [IO.File]::ReadAllText($out, [Text.Encoding]::UTF8)
Write-Host $text
Add-Content -LiteralPath $runLog -Encoding UTF8 -Value (
"===== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') sync --provider $p (exit $status) =====`r`n$text")
if ($status -ne 0) {
$rc = 1
if (-not $text.Contains('Sync completed with failures')) {
$crash = [regex]::Matches($text, '(?m)^[A-Za-z_][A-Za-z0-9_.]*(Error|Exception)\b') |
Select-Object -Last 1
if ($crash) {
$reason = "crashed ($($crash.Value))"
} else {
$reason = "exited $status before reporting a result"
}
Send-FailurePush "${p}: $reason`ncache\logs\scheduled-sync.log"
}
}
} catch {
# This script failing is a run that reports nothing, too.
$rc = 1
Write-Warning "run-sync: $p - $($_.Exception.Message)"
Send-FailurePush "${p}: run-sync.ps1 failed ($($_.Exception.GetType().Name))"
} finally {
Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue
}
}
exit $rc