1 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5.5 805689c649 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
2026-10-07 06:05:20 -04:00
4 changed files with 187 additions and 24 deletions
+5 -1
View File
@@ -14,10 +14,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- **A scheduled run that crashed sent no notification, and the providers that survived said "OK".** `sync` pushes its ntfy result from the end of a run it finished, so the `RecursionError` above — and any crash, any exit before the sync starts (the ToS gate, a cache error), a launcher that cannot build its venv — sent nothing at all. Worse, the unit runs each provider as its own `sync`, so codex kept pushing a low-priority "AI archive OK" every morning for the eleven days claude-code was dead: a broken provider was indistinguishable from a quiet day. - **A scheduled run that crashed sent no notification, and the providers that survived said "OK".** `sync` pushes its ntfy result from the end of a run it finished, so the `RecursionError` above — and any crash, any exit before the sync starts (the ToS gate, a cache error), a launcher that cannot build its venv — sent nothing at all. Worse, the unit runs each provider as its own `sync`, so codex kept pushing a low-priority "AI archive OK" every morning for the eleven days claude-code was dead: a broken provider was indistinguishable from a quiet day.
The systemd unit's `ExecStart` is now `scheduling/run-sync.sh`, which carries the old per-provider loop and pushes a high-priority **FAILED** notification for any run that exits non-zero without the app's "Sync completed with failures" banner (printed right after the app's own push, so an app-reported failure is not reported twice). The push names the provider and, for a crash, the exception class only — `claude-code: crashed (RecursionError)` — never its message, holding to `src/notify.py`'s counts-only rule for a topic anyone can read. It honours `NTFY_NOTIFY=off` and reads `NTFY_*` the way the app does: environment first, then `.env`. Re-run `install-systemd-timer.sh` to pick it up. The Windows task has no equivalent yet. The systemd unit's `ExecStart` is now `scheduling/run-sync.sh`, which carries the old per-provider loop and pushes a high-priority **FAILED** notification for any run that exits non-zero without the app's "Sync completed with failures" banner (printed right after the app's own push, so an app-reported failure is not reported twice). The push names the provider and, for a crash, the exception class only — `claude-code: crashed (RecursionError)` — never its message, holding to `src/notify.py`'s counts-only rule for a topic anyone can read. It honours `NTFY_NOTIFY=off` and reads `NTFY_*` the way the app does: environment first, then `.env`. Re-run `install-systemd-timer.sh` to pick it up.
Verified against a fake launcher and a local capture server: a crash pushes with the class and without the message, an exit before the sync pushes, an app-reported failure and a success push nothing extra, `off` pushes nothing, and the run still exits 1 if any provider failed. Verified against a fake launcher and a local capture server: a crash pushes with the class and without the message, an exit before the sync pushes, an app-reported failure and a success push nothing extra, `off` pushes nothing, and the run still exits 1 if any provider failed.
The Windows task gets the same backstop. Its action is now `scheduling\run-sync.ps1` (via `powershell.exe -File`, window hidden) in place of the inline `cmd.exe` loop, with the same rules: every provider attempted, exit 1 if any failed, a FAILED push carrying the class only for a run that died unreported, `NTFY_NOTIFY=off` honoured, `NTFY_*` read from the environment first. Because Task Scheduler keeps no output, each run's output is appended to `cache\logs\scheduled-sync.log` (one rollover to `.1` past 1 MB), and the push points there. The wrapper sets `PYTHONIOENCODING=utf-8`: with stdout redirected to a file rather than a console, Python would otherwise encode in the ANSI code page and fail on the first `✓`. Re-run `Register-AiChatSyncTask.ps1` with your providers to pick it up.
Verified on Linux under PowerShell 7.6 with a stand-in `cmd.exe`, the same fake launcher and capture server: the same five outcomes as above, plus a bearer token, an environment `NTFY_TOPIC` overriding `.env`, an unreachable server (warning, exit still 1) and the `chatgpt,claude` single-argument form. Not yet run under Windows PowerShell 5.1 or Task Scheduler.
- **An expired Claude session key reported a raw JSON dump instead of how to fix it.** `_make_request` routed only **401** to the auth handler (`src/providers/base.py`), and claude.ai does not use 401 — an invalid or expired `sessionKey` comes back as `403 permission_error` with `details.error_code = account_session_invalid`. So the one message that names the cookie, its ~30-day lifetime and the DevTools path to refresh it could never fire for Claude. What the user got instead was the generic 4xx path: `HTTP 403 — error: {'type': 'permission_error', 'message': 'Invalid authorization'…}`, which reads like a permissions problem with the account and not like "your key expired, here is how to replace it." - **An expired Claude session key reported a raw JSON dump instead of how to fix it.** `_make_request` routed only **401** to the auth handler (`src/providers/base.py`), and claude.ai does not use 401 — an invalid or expired `sessionKey` comes back as `403 permission_error` with `details.error_code = account_session_invalid`. So the one message that names the cookie, its ~30-day lifetime and the DevTools path to refresh it could never fire for Claude. What the user got instead was the generic 4xx path: `HTTP 403 — error: {'type': 'permission_error', 'message': 'Invalid authorization'…}`, which reads like a permissions problem with the account and not like "your key expired, here is how to replace it."
Measured live 2026-09-20 against `GET /api/organizations`: a valid key returns 200, while an expired key, a deliberately malformed key and **no cookie at all** return byte-identical 403s carrying that code — i.e. the API treats a dead session as an absent one. This is the same mistake as the ChatGPT media 403s below: assuming 403 means "forbidden" when the service uses it for "unauthenticated." Measured live 2026-09-20 against `GET /api/organizations`: a valid key returns 200, while an expired key, a deliberately malformed key and **no cookie at all** return byte-identical 403s carrying that code — i.e. the API treats a dead session as an absent one. This is the same mistake as the ChatGPT media 403s below: assuming 403 means "forbidden" when the service uses it for "unauthenticated."
+16 -8
View File
@@ -407,7 +407,13 @@ Check on it with `systemctl --user list-timers aichat-sync.timer` and
``` ```
Per-user task, no admin rights needed. `-StartWhenAvailable` is the counterpart Per-user task, no admin rights needed. `-StartWhenAvailable` is the counterpart
of systemd's `Persistent=true`. of systemd's `Persistent=true`. The task runs `scheduling\run-sync.ps1` with its
window hidden; 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).
Check on it with `Get-ScheduledTaskInfo -TaskName AiChatExporterSync` (look at
`LastTaskResult`: 0 is success) and
`Get-Content cache\logs\scheduled-sync.log -Tail 50`.
### What to know before you rely on it ### What to know before you rely on it
@@ -429,15 +435,15 @@ cache on the next run that finds Joplin up.
providers in a single action rather than one action each, because systemd providers in a single action rather than one action each, because systemd
`oneshot` stops at the first failing `ExecStart` and Task Scheduler reports only `oneshot` stops at the first failing `ExecStart` and Task Scheduler reports only
the last action's result. Every provider is attempted; the run still exits the last action's result. Every provider is attempted; the run still exits
non-zero if any failed. On Linux the loop is `scheduling/run-sync.sh`, which the non-zero if any failed. The loop is `scheduling/run-sync.sh` on Linux (the
unit's `ExecStart` calls. unit's `ExecStart`) and `scheduling\run-sync.ps1` on Windows (the task's action).
### Getting notified ### Getting notified
A scheduled run is silent by default. Output goes to three pull-only places: the A scheduled run is silent by default. Output goes to three pull-only places: the
exporter's own log (`cache/logs/exporter.log`), the systemd journal on Linux exporter's own log (`cache/logs/exporter.log`), the systemd journal on Linux
(`journalctl --user -u aichat-sync.service`), and Task Scheduler's (`journalctl --user -u aichat-sync.service`), and on Windows Task Scheduler's
`LastTaskResult` on Windows. `LastTaskResult` plus `cache\logs\scheduled-sync.log`.
To have runs report back, set an [ntfy](https://ntfy.sh) topic in `.env`: To have runs report back, set an [ntfy](https://ntfy.sh) topic in `.env`:
@@ -461,12 +467,14 @@ disables it; `--notify` / `--no-notify` override per run.
`sync` can only push from the end of a run it finished. A crash, an exit before `sync` can only push from the end of a run it finished. A crash, an exit before
the sync starts (the ToS gate, a cache error) or a launcher that can't build its the sync starts (the ToS gate, a cache error) or a launcher that can't build its
venv sends nothing — and since each provider pushes separately, the ones that venv sends nothing — and since each provider pushes separately, the ones that
succeeded still say "OK", so a dead provider looks like a quiet day. On Linux, succeeded still say "OK", so a dead provider looks like a quiet day. The
`scheduling/run-sync.sh` closes that gap: any run that exits non-zero without the scheduler wrappers (`scheduling/run-sync.sh`, `scheduling\run-sync.ps1`) close
that gap: any run that exits non-zero without the
app having reported it gets a high-priority **FAILED** push naming the provider app having reported it gets a high-priority **FAILED** push naming the provider
and, for a crash, the exception's class (`claude-code: crashed (RecursionError)`) and, for a crash, the exception's class (`claude-code: crashed (RecursionError)`)
— the class only, never its message, which can carry a conversation title. The — the class only, never its message, which can carry a conversation title. The
traceback is in the journal. The Windows task has no such backstop yet. traceback is in the journal on Linux and in `cache\logs\scheduled-sync.log` on
Windows.
The message includes the **machine name**, which matters because both machines The message includes the **machine name**, which matters because both machines
archive into one topic. It contains counts only — never conversation titles. A archive into one topic. It contains counts only — never conversation titles. A
+16 -15
View File
@@ -35,6 +35,7 @@ $ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot $repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo 'ai-chat-exporter.cmd' $launcher = Join-Path $repo 'ai-chat-exporter.cmd'
$wrapper = Join-Path $PSScriptRoot 'run-sync.ps1'
if ($Unregister) { if ($Unregister) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
@@ -42,22 +43,22 @@ if ($Unregister) {
return return
} }
if (-not (Test-Path $launcher)) { foreach ($f in $launcher, $wrapper) {
throw "Launcher not found at $launcher" if (-not (Test-Path $f)) { throw "Not found: $f" }
} }
# A single action looping over the providers, rather than one action each. # One action running run-sync.ps1, rather than one action per provider: Task
# Task Scheduler runs multiple actions in order but reports only the last one's # 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 # result, so a failure in an earlier provider would be invisible. The script
# going after a failure and propagates a non-zero exit code. # 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 # Providers go as separate arguments: under -File, "chatgpt,claude" would arrive
# command line *before* running any of it, so "exit /b %RC%" would report the # as one string. -WindowStyle Hidden because the output now goes to
# value RC had before the loop ever ran - i.e. always success. Delayed expansion # cache\logs\scheduled-sync.log, so the console window would only ever be blank.
# reads it at the point of use. $taskArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden " +
$loop = ($Provider | ForEach-Object { "`"$launcher`" sync --provider $_ --joplin-optional || set RC=1" }) -join ' & ' "-File `"$wrapper`" $($Provider -join ' ')"
$taskArgs = "/v:on /c set RC=0 & $loop & exit /b !RC!" $actions = New-ScheduledTaskAction -Execute 'powershell.exe' `
$actions = New-ScheduledTaskAction -Execute 'cmd.exe' `
-Argument $taskArgs ` -Argument $taskArgs `
-WorkingDirectory $repo -WorkingDirectory $repo
@@ -82,12 +83,12 @@ Register-ScheduledTask -TaskName $TaskName `
Write-Host "Registered '$TaskName' - daily at $Time for: $($Provider -join ', ')" Write-Host "Registered '$TaskName' - daily at $Time for: $($Provider -join ', ')"
Write-Host '' Write-Host ''
Write-Host 'Command the task will run:' Write-Host 'Command the task will run:'
Write-Host " cmd.exe $taskArgs" Write-Host " powershell.exe $taskArgs"
Write-Host " (working directory: $repo)" Write-Host " (working directory: $repo)"
Write-Host '' Write-Host ''
Write-Host 'Next steps:' Write-Host 'Next steps:'
Write-Host " * Run it once now: Start-ScheduledTask -TaskName $TaskName" Write-Host " * Run it once now: Start-ScheduledTask -TaskName $TaskName"
Write-Host " * Check the result: Get-ScheduledTaskInfo -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 ' * The terms-of-service notice must have been acknowledged'
Write-Host ' interactively once on this machine, or the task exits 1.' 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